From 8ce6dee1164b3478fc3437eb433cbddfef461137 Mon Sep 17 00:00:00 2001 From: atavism Date: Wed, 22 Apr 2026 03:17:43 -0700 Subject: [PATCH 01/35] initial commit --- backend/headers.go | 12 +- go.mod | 4 + issue/issue.go | 53 +++++++- issue/issue_test.go | 321 ++++++++++++++++++++++++++++++++++++-------- issue/transport.go | 189 ++++++++++++++++++++++++++ 5 files changed, 518 insertions(+), 61 deletions(-) create mode 100644 issue/transport.go diff --git a/backend/headers.go b/backend/headers.go index d1e5a99c..7076b933 100644 --- a/backend/headers.go +++ b/backend/headers.go @@ -78,13 +78,21 @@ func NewRequestWithHeaders(ctx context.Context, method, url string, body io.Read } // NewIssueRequest creates a new HTTP request with the required headers for issue reporting. -func NewIssueRequest(ctx context.Context, method, url string, body io.Reader) (*http.Request, error) { +func NewIssueRequest( + ctx context.Context, + method, url string, + body io.Reader, + contentType string, +) (*http.Request, error) { req, err := NewRequestWithHeaders(ctx, method, url, body) if err != nil { return nil, err } - req.Header.Set("content-type", "application/x-protobuf") + if contentType == "" { + contentType = "application/x-protobuf" + } + req.Header.Set("content-type", contentType) // data caps req.Header.Set(SupportedDataCapsHeader, "monthly,weekly,daily") diff --git a/go.mod b/go.mod index 1972a644..6bb9a894 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,10 @@ replace github.com/tetratelabs/wazero => github.com/getlantern/wazero v1.11.0-wa replace github.com/refraction-networking/water => github.com/getlantern/water v0.7.1-alpha.0.20260309190745-bd547c14b4aa +replace cloud.google.com/go => cloud.google.com/go v0.121.3 + +replace google.golang.org/genproto => google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79 + // replace github.com/getlantern/common => ../common // replace github.com/sagernet/sing => ../sing diff --git a/issue/issue.go b/issue/issue.go index 711f61f5..0de9c4ff 100644 --- a/issue/issue.go +++ b/issue/issue.go @@ -48,8 +48,10 @@ func randStr(n int) string { // Attachment is a file attachment type Attachment struct { - Name string - Data []byte + Name string + Type string + Data []byte + FirstClass bool } type IssueReport struct { @@ -114,13 +116,25 @@ func (ir *IssueReporter) Report(ctx context.Context, report IssueReport, userEma OsVersion: osVersion, Language: settings.GetString(settings.LocaleKey), } + firstClassAttachments := make([]*Attachment, 0, len(report.Attachments)) + protoAttachmentBytes := 0 for _, attachment := range report.Attachments { + if attachment == nil || attachment.Name == "" || len(attachment.Data) == 0 { + continue + } + + if attachment.FirstClass { + firstClassAttachments = append(firstClassAttachments, attachment) + continue + } + r.Attachments = append(r.Attachments, &ReportIssueRequest_Attachment{ - Type: "application/zip", + Type: attachmentContentType(attachment), Name: attachment.Name, Content: attachment.Data, }) + protoAttachmentBytes += len(attachment.Data) } // Zip logs @@ -135,24 +149,51 @@ func (ir *IssueReporter) Report(ctx context.Context, report IssueReport, userEma Name: "logs.zip", Content: buf.Bytes(), }) + protoAttachmentBytes += len(buf.Bytes()) slog.Debug("log files zipped for issue report", "size", len(buf.Bytes())) } else { - slog.Error("unable to zip log files", "error", err, "logDir", logDir, "maxSize", maxUncompressedLogSize) + slog.Error( + "unable to zip log files", + "error", + zipErr, + "logDir", + logDir, + "maxSize", + maxUncompressedLogSize, + ) } - // send message to lantern-cloud out, err := proto.Marshal(r) if err != nil { slog.Error("unable to marshal issue report", "error", err) return fmt.Errorf("error marshaling proto: %w", err) } + contentType := "application/x-protobuf" + body := bytes.NewReader(out) + if len(firstClassAttachments) > 0 { + if err := validateFirstClassAttachments(firstClassAttachments, protoAttachmentBytes); err != nil { + slog.Error("invalid issue attachments", "error", err) + return err + } + + multipartBody, multipartContentType, err := buildMultipartIssueBody(out, firstClassAttachments) + if err != nil { + slog.Error("unable to build multipart issue report", "error", err) + return fmt.Errorf("build multipart issue report: %w", err) + } + + body = bytes.NewReader(multipartBody.Bytes()) + contentType = multipartContentType + } + issueURL := common.GetBaseURL() + "/issue" req, err := backend.NewIssueRequest( ctx, http.MethodPost, issueURL, - bytes.NewReader(out), + body, + contentType, ) if err != nil { slog.Error("unable to create issue report request", "error", err) diff --git a/issue/issue_test.go b/issue/issue_test.go index 7e6b4634..35876736 100644 --- a/issue/issue_test.go +++ b/issue/issue_test.go @@ -3,10 +3,11 @@ package issue import ( "context" "io" + "mime" + "mime/multipart" "net/http" - "net/http/httptest" - "net/url" "strconv" + "strings" "testing" "github.com/getlantern/osversion" @@ -19,14 +20,13 @@ import ( "github.com/getlantern/radiance/kindling" ) -func TestSendReport(t *testing.T) { +func TestSendReport_ProtobufPath(t *testing.T) { settings.InitSettings(t.TempDir()) defer settings.Reset() - // Get OS version for expected report + osVer, err := osversion.GetHumanReadable() require.NoError(t, err) - // Build expected report want := &ReportIssueRequest{ Type: ReportIssueRequest_NO_ACCESS, CountryCode: "US", @@ -43,24 +43,25 @@ func TestSendReport(t *testing.T) { Language: settings.GetString(settings.LocaleKey), Attachments: []*ReportIssueRequest_Attachment{ { - Type: "application/zip", + Type: "text/plain", Name: "Hello.txt", Content: []byte("Hello World"), }, }, } - srv := newTestServer(t, want) - defer srv.Close() - reporter := &IssueReporter{} - kindling.SetKindling(&mockKindling{newTestClient(t, srv.URL)}) + kindling.SetKindling(&mockKindling{newValidatingClient(t, testExpectations{ + wantContentTypePrefix: "application/x-protobuf", + wantProto: want, + })}) report := IssueReport{ Type: "Cannot access blocked sites", Description: "Description placeholder-test only", Attachments: []*Attachment{ { Name: "Hello.txt", + Type: "text/plain", Data: []byte("Hello World"), }, }, @@ -72,76 +73,290 @@ func TestSendReport(t *testing.T) { require.NoError(t, err) } -func newTestClient(t *testing.T, testURL string) *http.Client { +func TestSendReport_MultipartPath(t *testing.T) { + settings.InitSettings(t.TempDir()) + defer settings.Reset() + + osVer, err := osversion.GetHumanReadable() + require.NoError(t, err) + + want := &ReportIssueRequest{ + Type: ReportIssueRequest_NO_ACCESS, + CountryCode: "US", + AppVersion: common.Version, + SubscriptionLevel: "free", + Platform: common.Platform, + Description: "Description placeholder-test only", + UserEmail: "radiancetest@getlantern.org", + DeviceId: settings.GetString(settings.DeviceIDKey), + UserId: strconv.FormatInt(settings.GetInt64(settings.UserIDKey), 10), + Device: "Samsung Galaxy S10", + Model: "SM-G973F", + OsVersion: osVer, + Language: settings.GetString(settings.LocaleKey), + Attachments: []*ReportIssueRequest_Attachment{ + { + Type: "text/plain", + Name: "context.txt", + Content: []byte("Support context"), + }, + }, + } + + reporter := &IssueReporter{} + kindling.SetKindling(&mockKindling{newValidatingClient(t, testExpectations{ + wantContentTypePrefix: "multipart/form-data", + wantProto: want, + wantMultipartFiles: []multipartFileExpectation{ + { + FieldName: attachmentPartName, + Filename: "screenshot.png", + ContentType: "image/png", + Content: []byte("png-bytes"), + }, + }, + })}) + report := IssueReport{ + Type: "Cannot access blocked sites", + Description: "Description placeholder-test only", + Attachments: []*Attachment{ + { + Name: "context.txt", + Type: "text/plain", + Data: []byte("Support context"), + }, + { + Name: "screenshot.png", + Type: "image/png", + Data: []byte("png-bytes"), + FirstClass: true, + }, + }, + Device: "Samsung Galaxy S10", + Model: "SM-G973F", + } + + err = reporter.Report(context.Background(), report, "radiancetest@getlantern.org", "US") + require.NoError(t, err) +} + +func TestSendReportMultipartValidation(t *testing.T) { + settings.InitSettings(t.TempDir()) + defer settings.Reset() + + reporter := &IssueReporter{} + + tests := []struct { + name string + attachments []*Attachment + wantErr string + }{ + { + name: "rejects too many screenshots", + attachments: []*Attachment{ + {Name: "1.png", Type: "image/png", Data: []byte("1"), FirstClass: true}, + {Name: "2.png", Type: "image/png", Data: []byte("2"), FirstClass: true}, + {Name: "3.png", Type: "image/png", Data: []byte("3"), FirstClass: true}, + {Name: "4.png", Type: "image/png", Data: []byte("4"), FirstClass: true}, + }, + wantErr: "too many screenshot attachments", + }, + { + name: "rejects unsupported screenshot types", + attachments: []*Attachment{ + {Name: "report.pdf", Type: "application/pdf", Data: []byte("pdf"), FirstClass: true}, + }, + wantErr: "unsupported screenshot attachment type", + }, + { + name: "rejects oversized total attachment payload", + attachments: []*Attachment{ + { + Name: "oversized.png", + Type: "image/png", + Data: bytesOfLen(maxIssueAttachmentBytes + 1), + FirstClass: true, + }, + }, + wantErr: "total issue attachment size exceeds", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := reporter.Report(context.Background(), IssueReport{ + Type: "Cannot access blocked sites", + Description: "validation path", + Attachments: tt.attachments, + Device: "Test device", + Model: "Model 1", + }, "radiancetest@getlantern.org", "US") + require.ErrorContains(t, err, tt.wantErr) + }) + } +} + +func bytesOfLen(n int) []byte { + return make([]byte, n) +} + +func newValidatingClient(t *testing.T, want testExpectations) *http.Client { return &http.Client{ Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { - parsedURL, err := url.Parse(testURL) - if err != nil { - t.Fatalf("failed to parse testURL: %v", err) + require.True( + t, + strings.HasPrefix(req.Header.Get("Content-Type"), want.wantContentTypePrefix), + "unexpected content type: %s", + req.Header.Get("Content-Type"), + ) + + switch { + case strings.HasPrefix(req.Header.Get("Content-Type"), "application/x-protobuf"): + got := decodeProtoRequest(t, req.Body) + assertProtoMatches(t, want.wantProto, got) + case strings.HasPrefix(req.Header.Get("Content-Type"), "multipart/form-data"): + gotProto, gotFiles := decodeMultipartRequest(t, req) + assertProtoMatches(t, want.wantProto, gotProto) + require.Len(t, gotFiles, len(want.wantMultipartFiles)) + for i, wantFile := range want.wantMultipartFiles { + gotFile := gotFiles[i] + assert.Equal(t, wantFile.FieldName, gotFile.FieldName) + assert.Equal(t, wantFile.Filename, gotFile.Filename) + assert.Equal(t, wantFile.ContentType, gotFile.ContentType) + assert.Equal(t, wantFile.Content, gotFile.Content) + } + default: + t.Fatalf("unexpected content type: %s", req.Header.Get("Content-Type")) } - req.URL = parsedURL - return http.DefaultTransport.RoundTrip(req) + + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader("ok")), + Header: make(http.Header), + Request: req, + }, nil }), } } -// roundTripperFunc allows using a function as http.RoundTripper type roundTripperFunc func(*http.Request) (*http.Response, error) func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } -// testServer wraps httptest.Server and holds the expected report for comparison -type testServer struct { - *httptest.Server - want *ReportIssueRequest +type testExpectations struct { + wantContentTypePrefix string + wantProto *ReportIssueRequest + wantMultipartFiles []multipartFileExpectation } -func newTestServer(t *testing.T, want *ReportIssueRequest) *testServer { - ts := &testServer{want: want} - ts.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Read and unmarshal the request body - body, err := io.ReadAll(r.Body) - require.NoError(t, err, "should read request body") - - var got ReportIssueRequest - err = proto.Unmarshal(body, &got) - require.NoError(t, err, "should unmarshal protobuf request") - - // Filter got.Attachments to only include the ones we're testing - // (exclude logs.zip and other dynamic attachments) - filteredAttachments := make([]*ReportIssueRequest_Attachment, 0) - for _, gotAtt := range got.Attachments { - for _, wantAtt := range ts.want.Attachments { - if gotAtt.Name == wantAtt.Name { - filteredAttachments = append(filteredAttachments, gotAtt) - break - } - } +type multipartFileExpectation struct { + FieldName string + Filename string + ContentType string + Content []byte +} + +func decodeProtoRequest(t *testing.T, body io.ReadCloser) *ReportIssueRequest { + t.Helper() + defer body.Close() + + payload, err := io.ReadAll(body) + require.NoError(t, err, "should read request body") + + var got ReportIssueRequest + err = proto.Unmarshal(payload, &got) + require.NoError(t, err, "should unmarshal protobuf request") + return &got +} + +func decodeMultipartRequest( + t *testing.T, + r *http.Request, +) (*ReportIssueRequest, []multipartFileExpectation) { + t.Helper() + + mediaType, params, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + require.NoError(t, err, "should parse multipart content type") + require.Equal(t, "multipart/form-data", mediaType) + + reader := multipart.NewReader(r.Body, params["boundary"]) + var gotProto *ReportIssueRequest + gotFiles := make([]multipartFileExpectation, 0) + + for { + part, err := reader.NextPart() + if err == io.EOF { + break } - got.Attachments = filteredAttachments + require.NoError(t, err, "should read multipart body") - // Compare received report with expected report using proto.Equal - if assert.True(t, proto.Equal(ts.want, &got), "received report should match expected report") { - w.WriteHeader(http.StatusOK) - } else { - w.WriteHeader(http.StatusBadRequest) + content, err := io.ReadAll(part) + require.NoError(t, err, "should read multipart part") + + switch part.FormName() { + case requestPartName: + var request ReportIssueRequest + err = proto.Unmarshal(content, &request) + require.NoError(t, err, "should unmarshal request part") + gotProto = &request + case attachmentPartName: + gotFiles = append(gotFiles, multipartFileExpectation{ + FieldName: part.FormName(), + Filename: part.FileName(), + ContentType: part.Header.Get("Content-Type"), + Content: content, + }) + default: + t.Fatalf("unexpected multipart form field: %s", part.FormName()) } - })) - return ts + } + + require.NotNil(t, gotProto, "multipart payload should include request part") + return gotProto, gotFiles +} + +func assertProtoMatches( + t *testing.T, + want *ReportIssueRequest, + got *ReportIssueRequest, +) { + t.Helper() + got.Attachments = filterExpectedAttachments(got.Attachments, want.Attachments) + if !assert.True( + t, + proto.Equal(want, got), + "received report should match expected report", + ) { + t.Fatalf("protobuf payload mismatch") + } +} + +func filterExpectedAttachments( + got []*ReportIssueRequest_Attachment, + want []*ReportIssueRequest_Attachment, +) []*ReportIssueRequest_Attachment { + filtered := make([]*ReportIssueRequest_Attachment, 0, len(want)) + for _, gotAttachment := range got { + for _, wantAttachment := range want { + if gotAttachment.Name == wantAttachment.Name { + filtered = append(filtered, gotAttachment) + break + } + } + } + return filtered } type mockKindling struct { c *http.Client } -// NewHTTPClient returns a new HTTP client that is configured to use kindling. func (m *mockKindling) NewHTTPClient() *http.Client { return m.c } -// ReplaceTransport replaces an existing transport RoundTripper generator with the provided one. func (m *mockKindling) ReplaceTransport(name string, rt func(ctx context.Context, addr string) (http.RoundTripper, error)) error { - panic("not implemented") // TODO: Implement + panic("not implemented") } diff --git a/issue/transport.go b/issue/transport.go new file mode 100644 index 00000000..972eac35 --- /dev/null +++ b/issue/transport.go @@ -0,0 +1,189 @@ +package issue + +import ( + "bytes" + "fmt" + "mime" + "mime/multipart" + "net/http" + "net/textproto" + "path/filepath" + "strings" +) + +const ( + maxFirstClassAttachmentCount = 3 + maxIssueAttachmentBytes = 15 * 1024 * 1024 + + requestPartName = "request" + requestPartFilename = "request.pb" + attachmentPartName = "attachments[]" + octetStreamContentType = "application/octet-stream" +) + +var allowedFirstClassAttachmentTypes = map[string]struct{}{ + "image/gif": {}, + "image/jpeg": {}, + "image/png": {}, +} + +var attachmentTypeAliases = map[string]string{ + "image/jpg": "image/jpeg", +} + +func normalizeAttachmentType(contentType string) string { + contentType = strings.TrimSpace(strings.ToLower(contentType)) + if contentType == "" { + return "" + } + + mediaType, _, err := mime.ParseMediaType(contentType) + if err == nil { + contentType = mediaType + } + + if alias, ok := attachmentTypeAliases[contentType]; ok { + return alias + } + return contentType +} + +func attachmentContentType(attachment *Attachment) string { + if attachment == nil { + return octetStreamContentType + } + + if contentType := normalizeAttachmentType(attachment.Type); contentType != "" { + return contentType + } + + if contentType := normalizeAttachmentType( + mime.TypeByExtension(strings.ToLower(filepath.Ext(attachment.Name))), + ); contentType != "" { + return contentType + } + + if len(attachment.Data) == 0 { + return octetStreamContentType + } + + return normalizeAttachmentType(http.DetectContentType(attachment.Data)) +} + +func validateFirstClassAttachments(attachments []*Attachment, existingBytes int) error { + count := 0 + totalBytes := existingBytes + + for _, attachment := range attachments { + if attachment == nil { + continue + } + + name := strings.TrimSpace(attachment.Name) + if name == "" { + return fmt.Errorf("attachment name is required") + } + if len(attachment.Data) == 0 { + return fmt.Errorf("attachment %q is empty", name) + } + + count++ + if count > maxFirstClassAttachmentCount { + return fmt.Errorf( + "too many screenshot attachments: max %d", + maxFirstClassAttachmentCount, + ) + } + + contentType := attachmentContentType(attachment) + if _, ok := allowedFirstClassAttachmentTypes[contentType]; !ok { + return fmt.Errorf( + "unsupported screenshot attachment type %q for %q", + contentType, + name, + ) + } + + totalBytes += len(attachment.Data) + if totalBytes > maxIssueAttachmentBytes { + return fmt.Errorf( + "total issue attachment size exceeds %d bytes", + maxIssueAttachmentBytes, + ) + } + } + + return nil +} + +func buildMultipartIssueBody( + requestPayload []byte, + attachments []*Attachment, +) (*bytes.Buffer, string, error) { + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + requestHeader := make(textproto.MIMEHeader) + requestHeader.Set( + "Content-Disposition", + multipartContentDisposition(requestPartName, requestPartFilename), + ) + requestHeader.Set("Content-Type", "application/x-protobuf") + + requestPart, err := writer.CreatePart(requestHeader) + if err != nil { + return nil, "", fmt.Errorf("create issue request part: %w", err) + } + if _, err := requestPart.Write(requestPayload); err != nil { + return nil, "", fmt.Errorf("write issue request part: %w", err) + } + + for _, attachment := range attachments { + if attachment == nil { + continue + } + + partHeader := make(textproto.MIMEHeader) + partHeader.Set( + "Content-Disposition", + multipartContentDisposition(attachmentPartName, attachment.Name), + ) + partHeader.Set("Content-Type", attachmentContentType(attachment)) + + part, err := writer.CreatePart(partHeader) + if err != nil { + return nil, "", fmt.Errorf( + "create attachment part for %q: %w", + attachment.Name, + err, + ) + } + if _, err := part.Write(attachment.Data); err != nil { + return nil, "", fmt.Errorf( + "write attachment part for %q: %w", + attachment.Name, + err, + ) + } + } + + contentType := writer.FormDataContentType() + if err := writer.Close(); err != nil { + return nil, "", fmt.Errorf("close multipart writer: %w", err) + } + + return body, contentType, nil +} + +func multipartContentDisposition(fieldName, filename string) string { + return fmt.Sprintf( + `form-data; name="%s"; filename="%s"`, + escapeMultipartToken(fieldName), + escapeMultipartToken(filename), + ) +} + +func escapeMultipartToken(value string) string { + replacer := strings.NewReplacer("\\", "\\\\", `"`, "\\\"") + return replacer.Replace(value) +} From 89bbaa1e4d15d81969d01d59dc3bb77ccb3825d8 Mon Sep 17 00:00:00 2001 From: atavism Date: Wed, 22 Apr 2026 05:00:30 -0700 Subject: [PATCH 02/35] code review updates --- issue/transport.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/issue/transport.go b/issue/transport.go index 972eac35..2e0e382f 100644 --- a/issue/transport.go +++ b/issue/transport.go @@ -31,6 +31,8 @@ var attachmentTypeAliases = map[string]string{ "image/jpg": "image/jpeg", } +// normalizeAttachmentType trims parameters and folds a few common aliases so +// validation and multipart writing can reason about one canonical content type. func normalizeAttachmentType(contentType string) string { contentType = strings.TrimSpace(strings.ToLower(contentType)) if contentType == "" { @@ -48,6 +50,8 @@ func normalizeAttachmentType(contentType string) string { return contentType } +// attachmentContentType prefers an explicitly supplied type, then falls back to +// the filename, and finally sniffs the payload when we have to. func attachmentContentType(attachment *Attachment) string { if attachment == nil { return octetStreamContentType @@ -70,6 +74,8 @@ func attachmentContentType(attachment *Attachment) string { return normalizeAttachmentType(http.DetectContentType(attachment.Data)) } +// validateFirstClassAttachments applies the screenshot limits before we switch +// the issue request from the protobuf-only path to multipart/form-data. func validateFirstClassAttachments(attachments []*Attachment, existingBytes int) error { count := 0 totalBytes := existingBytes @@ -116,6 +122,8 @@ func validateFirstClassAttachments(attachments []*Attachment, existingBytes int) return nil } +// buildMultipartIssueBody keeps the protobuf request as one part and sends each +// screenshot as its own attachment so the ticketing side can surface them directly. func buildMultipartIssueBody( requestPayload []byte, attachments []*Attachment, @@ -175,6 +183,7 @@ func buildMultipartIssueBody( return body, contentType, nil } +// Keep disposition quoting in one place since filenames can come from users. func multipartContentDisposition(fieldName, filename string) string { return fmt.Sprintf( `form-data; name="%s"; filename="%s"`, From 2e1f0d46ea483fb18c110093d05f539c0d394bd5 Mon Sep 17 00:00:00 2001 From: atavism Date: Wed, 22 Apr 2026 05:06:27 -0700 Subject: [PATCH 03/35] code review updates --- go.mod | 4 ---- 1 file changed, 4 deletions(-) diff --git a/go.mod b/go.mod index 6bb9a894..1972a644 100644 --- a/go.mod +++ b/go.mod @@ -12,10 +12,6 @@ replace github.com/tetratelabs/wazero => github.com/getlantern/wazero v1.11.0-wa replace github.com/refraction-networking/water => github.com/getlantern/water v0.7.1-alpha.0.20260309190745-bd547c14b4aa -replace cloud.google.com/go => cloud.google.com/go v0.121.3 - -replace google.golang.org/genproto => google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79 - // replace github.com/getlantern/common => ../common // replace github.com/sagernet/sing => ../sing From d35977b7762f04dc1f343170a1d7e8581e6da0ff Mon Sep 17 00:00:00 2001 From: atavism Date: Wed, 22 Apr 2026 05:16:31 -0700 Subject: [PATCH 04/35] code review updates --- backend/headers.go | 2 +- issue/issue_test.go | 63 +++++++++++++++++++++++++++++++++++++++++++++ issue/transport.go | 31 ++++++++++++++++++---- 3 files changed, 90 insertions(+), 6 deletions(-) diff --git a/backend/headers.go b/backend/headers.go index 7076b933..473a4058 100644 --- a/backend/headers.go +++ b/backend/headers.go @@ -92,7 +92,7 @@ func NewIssueRequest( if contentType == "" { contentType = "application/x-protobuf" } - req.Header.Set("content-type", contentType) + req.Header.Set(ContentTypeHeader, contentType) // data caps req.Header.Set(SupportedDataCapsHeader, "monthly,weekly,daily") diff --git a/issue/issue_test.go b/issue/issue_test.go index 35876736..a7068200 100644 --- a/issue/issue_test.go +++ b/issue/issue_test.go @@ -140,6 +140,61 @@ func TestSendReport_MultipartPath(t *testing.T) { require.NoError(t, err) } +func TestSendReport_MultipartPathNormalizesScreenshotFilename(t *testing.T) { + settings.InitSettings(t.TempDir()) + defer settings.Reset() + + osVer, err := osversion.GetHumanReadable() + require.NoError(t, err) + + want := &ReportIssueRequest{ + Type: ReportIssueRequest_NO_ACCESS, + CountryCode: "US", + AppVersion: common.Version, + SubscriptionLevel: "free", + Platform: common.Platform, + Description: "Description placeholder-test only", + UserEmail: "radiancetest@getlantern.org", + DeviceId: settings.GetString(settings.DeviceIDKey), + UserId: strconv.FormatInt(settings.GetInt64(settings.UserIDKey), 10), + Device: "Samsung Galaxy S10", + Model: "SM-G973F", + OsVersion: osVer, + Language: settings.GetString(settings.LocaleKey), + } + + reporter := &IssueReporter{} + kindling.SetKindling(&mockKindling{newValidatingClient(t, testExpectations{ + wantContentTypePrefix: "multipart/form-data", + wantProto: want, + wantMultipartFiles: []multipartFileExpectation{ + { + FieldName: attachmentPartName, + Filename: "screenshot.png", + ContentType: "image/png", + Content: []byte("png-bytes"), + }, + }, + })}) + report := IssueReport{ + Type: "Cannot access blocked sites", + Description: "Description placeholder-test only", + Attachments: []*Attachment{ + { + Name: " screenshot.png ", + Type: "image/png", + Data: []byte("png-bytes"), + FirstClass: true, + }, + }, + Device: "Samsung Galaxy S10", + Model: "SM-G973F", + } + + err = reporter.Report(context.Background(), report, "radiancetest@getlantern.org", "US") + require.NoError(t, err) +} + func TestSendReportMultipartValidation(t *testing.T) { settings.InitSettings(t.TempDir()) defer settings.Reset() @@ -180,6 +235,13 @@ func TestSendReportMultipartValidation(t *testing.T) { }, wantErr: "total issue attachment size exceeds", }, + { + name: "rejects screenshot names with control characters", + attachments: []*Attachment{ + {Name: "bad\r\nname.png", Type: "image/png", Data: []byte("1"), FirstClass: true}, + }, + wantErr: "contains invalid control characters", + }, } for _, tt := range tests { @@ -276,6 +338,7 @@ func decodeMultipartRequest( r *http.Request, ) (*ReportIssueRequest, []multipartFileExpectation) { t.Helper() + defer r.Body.Close() mediaType, params, err := mime.ParseMediaType(r.Header.Get("Content-Type")) require.NoError(t, err, "should parse multipart content type") diff --git a/issue/transport.go b/issue/transport.go index 2e0e382f..c1c6560e 100644 --- a/issue/transport.go +++ b/issue/transport.go @@ -61,8 +61,9 @@ func attachmentContentType(attachment *Attachment) string { return contentType } + name := strings.TrimSpace(attachment.Name) if contentType := normalizeAttachmentType( - mime.TypeByExtension(strings.ToLower(filepath.Ext(attachment.Name))), + mime.TypeByExtension(strings.ToLower(filepath.Ext(name))), ); contentType != "" { return contentType } @@ -85,9 +86,9 @@ func validateFirstClassAttachments(attachments []*Attachment, existingBytes int) continue } - name := strings.TrimSpace(attachment.Name) - if name == "" { - return fmt.Errorf("attachment name is required") + name, err := normalizeAttachmentName(attachment.Name) + if err != nil { + return err } if len(attachment.Data) == 0 { return fmt.Errorf("attachment %q is empty", name) @@ -151,10 +152,15 @@ func buildMultipartIssueBody( continue } + filename, err := normalizeAttachmentName(attachment.Name) + if err != nil { + return nil, "", err + } + partHeader := make(textproto.MIMEHeader) partHeader.Set( "Content-Disposition", - multipartContentDisposition(attachmentPartName, attachment.Name), + multipartContentDisposition(attachmentPartName, filename), ) partHeader.Set("Content-Type", attachmentContentType(attachment)) @@ -192,6 +198,21 @@ func multipartContentDisposition(fieldName, filename string) string { ) } +func normalizeAttachmentName(name string) (string, error) { + name = strings.TrimSpace(name) + if name == "" { + return "", fmt.Errorf("attachment name is required") + } + + for _, r := range name { + if r < 0x20 || r == 0x7f { + return "", fmt.Errorf("attachment %q contains invalid control characters", name) + } + } + + return name, nil +} + func escapeMultipartToken(value string) string { replacer := strings.NewReplacer("\\", "\\\\", `"`, "\\\"") return replacer.Replace(value) From d8d535dc79269cd07dd1839e56978385113e2a2f Mon Sep 17 00:00:00 2001 From: garmr-ulfr <104022054+garmr-ulfr@users.noreply.github.com> Date: Mon, 4 May 2026 17:04:20 -0700 Subject: [PATCH 05/35] fix: disconnect VPN on backend close (#461) --- backend/radiance.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/radiance.go b/backend/radiance.go index 468323a3..7fdc9db4 100644 --- a/backend/radiance.go +++ b/backend/radiance.go @@ -294,6 +294,9 @@ func (r *LocalBackend) Start() { func (r *LocalBackend) Close() { r.closeOnce.Do(func() { slog.Debug("Closing Radiance") + if err := r.DisconnectVPN(); err != nil { + slog.Error("Failed to disconnect VPN on shutdown", "error", err) + } r.cancel() // cancels context, unsubscribes all event listeners and stops child goroutines close(r.stopChan) for _, shutdown := range r.shutdownFuncs { From 9fac6db61fe7445dfb4f25cc322257abe4409a2f Mon Sep 17 00:00:00 2001 From: Myles Horton Date: Wed, 6 May 2026 10:29:21 -0600 Subject: [PATCH 06/35] bump lantern-box to v0.0.78 for QUIC err_class instrumentation (#459) Picks up getlantern/lantern-box#253 which bumped broflake to land getlantern/unbounded#360 (clientcore: classify QUIC connection errors). After this ships in a Lantern build, lantern.log will carry err_class structured-log fields on every "QUIC connection ended" record, distinguishing idle_timeout / handshake_timeout / application_close_remote / application_close_local / transport_error / stateless_reset / context_canceled / etc. The transitive broflake bump also picked up getlantern/unbounded#359 (integration test for connection migration), which is test-only. `go mod tidy` clean; pre-existing `cmd/lantern/lantern.go:78` ipc.NewClient build error reproduces on main and is unrelated. Co-authored-by: Adam Fisk Co-authored-by: Claude Opus 4.7 (1M context) --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 251984c3..a0392d04 100644 --- a/go.mod +++ b/go.mod @@ -31,7 +31,7 @@ require ( github.com/getlantern/domainfront v0.0.0-20260419161617-0bff0b2169f4 github.com/getlantern/keepcurrent v0.0.0-20260422161259-54a4d9a93694 github.com/getlantern/kindling v0.0.0-20260428171407-6143132aaf40 - github.com/getlantern/lantern-box v0.0.77 + github.com/getlantern/lantern-box v0.0.78 github.com/getlantern/pluriconfig v0.0.0-20251126214241-8cc8bc561535 github.com/getlantern/publicip v0.0.0-20260328175246-2c460fe80c6b github.com/getlantern/semconv v0.0.0-20260327040646-21845dda05cb @@ -112,7 +112,7 @@ require ( github.com/gaissmai/bart v0.11.1 // indirect github.com/gaukas/wazerofs v0.1.0 // indirect github.com/getlantern/algeneva v0.0.0-20250307163401-1824e7b54f52 // indirect - github.com/getlantern/broflake v0.0.0-20260501210609-ce5f75aa2054 // indirect + github.com/getlantern/broflake v0.0.0-20260504215251-ed3cf75062d1 // indirect github.com/getlantern/lantern-water v0.0.0-20260317143726-e0ee64a11d90 // indirect github.com/getlantern/samizdat v0.0.3-0.20260327203406-ef7323341974 // indirect github.com/go-chi/chi/v5 v5.2.2 // indirect diff --git a/go.sum b/go.sum index 94048681..1efc2333 100644 --- a/go.sum +++ b/go.sum @@ -226,8 +226,8 @@ github.com/getlantern/algeneva v0.0.0-20250307163401-1824e7b54f52 h1:w2/RqYPw7Pb github.com/getlantern/algeneva v0.0.0-20250307163401-1824e7b54f52/go.mod h1:PrNR8tMXO26YNs8K9653XCUH7u2Kv4OdfFC3Ke1GsX0= github.com/getlantern/amp v0.0.0-20260305201851-782bc8045e58 h1:3wxMKw90adxiEzsJmAmMHqBJQr/P/9Goqy/U2a1l/sg= github.com/getlantern/amp v0.0.0-20260305201851-782bc8045e58/go.mod h1:p6WdG48YAz5SCUpiMSGLy616A6YghKToc63y3NP7avI= -github.com/getlantern/broflake v0.0.0-20260501210609-ce5f75aa2054 h1:nrRMiRRjzR43yihrVxdnmmt66ZqjRhHE73TyHW1ySgg= -github.com/getlantern/broflake v0.0.0-20260501210609-ce5f75aa2054/go.mod h1:bZGGfTwne9NIsy3Kc1avcXNWn/yA8ghUwlXdS2z+AlA= +github.com/getlantern/broflake v0.0.0-20260504215251-ed3cf75062d1 h1:3WYvObOo8gpKwjcLrV6O/vRp+ubKdjpvJwZrRkbbDWw= +github.com/getlantern/broflake v0.0.0-20260504215251-ed3cf75062d1/go.mod h1:bZGGfTwne9NIsy3Kc1avcXNWn/yA8ghUwlXdS2z+AlA= github.com/getlantern/common v1.2.1-0.20260326210434-cb69537aaf46 h1:Ab2esudqgFz2K1WYQKtX+58kaiVMX0UohjW2XmdEgf4= github.com/getlantern/common v1.2.1-0.20260326210434-cb69537aaf46/go.mod h1:eSSuV4bMPgQJnczBw+KWWqWNo1itzmVxC++qUBPRTt0= github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 h1:oEZYEpZo28Wdx+5FZo4aU7JFXu0WG/4wJWese5reQSA= @@ -248,8 +248,8 @@ github.com/getlantern/keepcurrent v0.0.0-20260422161259-54a4d9a93694 h1:iLWm6S/4 github.com/getlantern/keepcurrent v0.0.0-20260422161259-54a4d9a93694/go.mod h1:ag5g9aWUw2FJcX5RVRpJ9EBQBy5yJuy2WXDouIn/m4w= github.com/getlantern/kindling v0.0.0-20260428171407-6143132aaf40 h1:P5pkaBGxWOGBn7bKzjzdln/ro+ShG1RUbOuy+7pSzXE= github.com/getlantern/kindling v0.0.0-20260428171407-6143132aaf40/go.mod h1:TGTxpoNVwc8Be4qkBNtf5oj2psJaEIZEq47GOPS7zkA= -github.com/getlantern/lantern-box v0.0.77 h1:2b2TyrPXYHzIx1aPUvpE//AxoW0TMl/EF/bQHaZyfqw= -github.com/getlantern/lantern-box v0.0.77/go.mod h1:YV6+5bOdvw9rmc0cJoOTP7UaFt/6XWVOierv7KcfAkY= +github.com/getlantern/lantern-box v0.0.78 h1:6P68+v7zukSXs3KFEfqY6iKBtqV3bLCzKouigN4kaw4= +github.com/getlantern/lantern-box v0.0.78/go.mod h1:wJhPQKdnwD6qW/ghAfzsrj/IfHZbvFSAfr52+Tu6dbw= github.com/getlantern/lantern-water v0.0.0-20260317143726-e0ee64a11d90 h1:P9JX1yAu2uq3b5YiT0sLtHkTrkZuttV8gPZh81nUuag= github.com/getlantern/lantern-water v0.0.0-20260317143726-e0ee64a11d90/go.mod h1:3JpJgwi4KEI6rS9loOAvcBp+F2jP65d0tTg2GQcTPBU= github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534 h1:3BwvWj0JZzFEvNNiMhCu4bf60nqcIuQpTYb00Ezm1ag= From b8f04e3a710ebfe0d75f8293dfd785cd81f0644e Mon Sep 17 00:00:00 2001 From: Myles Horton Date: Wed, 6 May 2026 10:59:09 -0600 Subject: [PATCH 07/35] fix: preserve caller-supplied data dir; restore Pro on upgrade (#463) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: preserve caller-supplied data dir; migrate v9.1.x stragglers `setupDirectories` (added in #370) unconditionally appended `/data` and `/logs` suffixes to the caller-supplied paths, even when the caller had already passed a fully-qualified directory. On Android, where `MainActivity.kt` calls `Mobile.initLogging(initConfigDir(), …)` and `initConfigDir()` returns `/.lantern`, this rewrote the radiance data dir from `.lantern` (used by v9.0.x) to `.lantern/data` (used by v9.1.x). The settings file path therefore moved silently between minor versions: v9.0.x → /.lantern/settings.json v9.1.x → /.lantern/data/settings.json On every existing install, the v9.1.x client failed to find the prior file, fell through to defaults (`UserLevelKey="free"`, no `UserIDKey`, no `DeviceIDKey`, no `JwtTokenKey`), generated a fresh device id, and called `UserRecoverByDevice` cold against the auth server. The user's local Pro state was effectively wiped on upgrade, surfacing as "Pro shown as expired" — which is exactly what the v9.1.5 China-Android tickets (#174455 / #174496 / #174515) reported. Auto-diagnosis on those tickets initially blamed cancelled Stripe checkouts; the actual cause was this path regression and the prior payment channel (Shepherd / AliPay / WeChat — not Stripe — for the vast majority of CN users). Two fixes: 1. setupDirectories now honors the caller's path as-is (the pre-#370 behavior). When the caller passes an empty string we still fall back to internal.DefaultDataPath() / DefaultLogPath(); the `maybeAddSuffix` helper is removed. 2. Settings init has a one-shot v9.1.x migration: on the first launch of the fixed client, if `/settings.json` is missing but `/data/settings.json` exists, the latter is copied up. This means v9.1.x users who'd already lost their v9.0.x state recover their freshly-minted v9.1.x identifiers on upgrade rather than getting wiped again. Quick stat check, no-op for the vast majority of installs that never had the bad nested file. Three sub-tests cover the recover, no-op, and canonical-already-present paths. Affects Android (confirmed via auto-diagnosis) and any other platform that passes a fully-qualified path through to `common.Init` — iOS via `lantern-core/ffi/ffi.go:114` and desktop via `cmd/lanternd/lanternd.go:328` use the same code. Refs Freshdesk #174455, #174515, #174496. Co-Authored-By: Claude Opus 4.7 (1M context) * ask-migration: prefer the file whose user_level == 'pro' Prior version of the migration just preferred the canonical path when it existed. That was correct for the typical upgrade path (v9.0.x canonical=pro vs v9.1.x nested=expired) but wrong if the inverse ever happened — e.g., a Shepherd payment landed during the v9.1.x window and the nested file legitimately holds the pro state. Now the migration reads both files, compares their user_level, and prefers whichever actually says 'pro'. Falls back to canonical when both or neither have pro. Three additional sub-tests cover: - canonical-pro vs nested-expired (the broken-upgrade case) - canonical-expired vs nested-pro (rare inverse) - both-pro and neither-pro (canonical wins by tiebreaker) Co-Authored-By: Claude Opus 4.7 (1M context) * ask-migration: also recover v9.0.x local.json (Derek's failing case) Derek's macOS test on the fix branch went 9.1.5 → fix (Pro restored ✓) but 9.0.25 → fix (Pro lost ✗). Investigation of #174542 logs shows 'Created new user' fired in (*fetcher).ensureUser at fetcher.go:135 because UserIDKey was 0 — the canonical settings.json didn't exist on his disk. Reason: v9.0.x's radiance (commit 2d396075314e) called the settings file `local.json`. The LocalBackend refactor in #370 renamed it to `settings.json`. So 9.0.25 wrote `/local.json`; the fix was looking only at `/settings.json` and the v9.1.x nested `/data/settings.json`. v9.0.x's good state at local.json was orphaned. The keys (UserIDKey, TokenKey, JwtTokenKey, UserLevelKey, etc.) are identical between v9.0.x and current — only the filename changed — so the file format is forward-compatible. The migration just needs to add `/local.json` as a third candidate path. Generalized the migration to consider all three candidates and pick whichever has user_level == "pro" first; if none has pro, prefer canonical → legacy → nested in that order so user identifiers survive even when Pro state is non-pro (losing identifiers creates server-side device-registration orphans, which matters more than losing the Pro string). Renamed migrateV91xSettingsIfNeeded → migrateLegacySettingsIfNeeded since it's no longer about a single version. Eight sub-tests cover: v9.0.x-only (Derek's case), v9.1.x-only, v9.0.x-pro-vs-v9.1.x-expired (chained upgrade), canonical-pro vs nested-expired, nested-pro-only, all-pro tiebreak, no-pro identifier-survival, and the empty-disk no-op. Co-Authored-By: Claude Opus 4.7 (1M context) * review: distinguish ENOENT, atomic write the migrated file Address Copilot's two review comments on radiance#463: 1. Migration was treating any os.Stat error as "file not present," which masked permission/I/O errors on the canonical path. Now distinguishes errors.Is(err, fs.ErrNotExist) (expected, proceed) from other read errors (logged; if the canonical path itself is unreadable for non-ENOENT reasons, skip migration entirely so we don't try to write over a file the OS won't let us read). 2. writeMigrated used os.WriteFile, which is non-atomic and could leave a partially-written settings.json if the process crashed mid-write. Switched to atomicfile.WriteFile, the same mechanism the normal save path uses (writes to a temp file then renames). New sub-test exercises the unreadable-canonical case (skipped on windows where chmod 000 doesn't reproduce the same semantics). Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- common/init.go | 19 ++-- common/settings/settings.go | 122 ++++++++++++++++++++++++ common/settings/settings_test.go | 159 +++++++++++++++++++++++++++++++ 3 files changed, 290 insertions(+), 10 deletions(-) diff --git a/common/init.go b/common/init.go index b2c2a900..44cdbcd0 100644 --- a/common/init.go +++ b/common/init.go @@ -146,9 +146,15 @@ func setupDirectories(data, logs string) (dataDir, logDir string, err error) { } else if logs == "" { logs = internal.DefaultLogPath() } - // ensure the data and logs directories end with the correct suffix - data = maybeAddSuffix(data, "data") - logs = maybeAddSuffix(logs, "logs") + // Honor the caller's path as-is. A previous version of this function + // unconditionally appended /data and /logs suffixes here even when the + // caller passed a fully-resolved path (e.g. Android passes + // /.lantern). That broke upgrade continuity: v9.0.x had + // written settings.json under /, while v9.1.x reads from + // /data/, so every existing install lost its persisted + // user_id, device_id, jwt token, and user_level on upgrade — surfacing + // as "Pro is suddenly expired after the update." See ticket #174515 + // and the "Pro lost on upgrade" memory note. data, _ = filepath.Abs(data) logs, _ = filepath.Abs(logs) for _, path := range []string{data, logs} { @@ -158,10 +164,3 @@ func setupDirectories(data, logs string) (dataDir, logDir string, err error) { } return data, logs, nil } - -func maybeAddSuffix(path, suffix string) string { - if !strings.EqualFold(filepath.Base(path), suffix) { - path = filepath.Join(path, suffix) - } - return path -} diff --git a/common/settings/settings.go b/common/settings/settings.go index 7e9d29f0..6e9cb950 100644 --- a/common/settings/settings.go +++ b/common/settings/settings.go @@ -2,6 +2,7 @@ package settings import ( + jsonpkg "encoding/json" "errors" "fmt" "io/fs" @@ -59,6 +60,12 @@ const ( PreferredLocationKey _key = "preferred_location" // [common.PreferredLocation] settingsFileName = "settings.json" + // legacySettingsFileName is what v9.0.x called the same file (it was + // renamed in radiance PR #370). On upgrade from v9.0.x, the user's + // persisted user_id / token / user_level live at /local.json; + // migrateLegacySettingsIfNeeded reads it from there so Pro state + // survives the rename. + legacySettingsFileName = "local.json" ) var ErrNotExist = errors.New("key does not exist") @@ -93,6 +100,7 @@ func InitSettings(fileDir string) error { return fmt.Errorf("failed to create data directory: %v", err) } k.filePath = filepath.Join(fileDir, settingsFileName) + migrateLegacySettingsIfNeeded(fileDir, k.filePath) switch err := loadSettings(k.filePath); { case errors.Is(err, fs.ErrNotExist): slog.Warn("settings file not found", "path", k.filePath) // file may not have been created yet @@ -104,6 +112,120 @@ func InitSettings(fileDir string) error { return nil } +// migrateLegacySettingsIfNeeded recovers persisted user state written +// by older client versions. Three candidate paths are considered: +// +// - /settings.json — the current canonical path +// - /local.json — what v9.0.x wrote (renamed in #370) +// - /data/settings.json — what v9.1.x wrote, due to a bug in +// #370's setupDirectories that +// appended an unconditional "/data" +// suffix to the caller's data dir +// +// On the first launch of a fixed client, any of the three may exist +// depending on the user's upgrade path. Pick whichever has +// user_level == "pro" so anyone who legitimately paid keeps their Pro +// state regardless of which file holds the good copy. If none have pro, +// prefer canonical → v9.0.x → v9.1.x in that order so the user's +// identifiers (user_id, token, device_id) survive — losing Pro is +// recoverable, losing the device registration creates server-side +// orphans. +// +// Runs unconditionally — quick stat-and-read of three small files; +// no-op for the vast majority of installs that don't have a nested or +// legacy file. +func migrateLegacySettingsIfNeeded(fileDir, canonicalPath string) { + type candidate struct { + path string + contents []byte + exists bool + label string + } + candidates := []candidate{ + {path: canonicalPath, label: "canonical settings.json"}, + {path: filepath.Join(fileDir, legacySettingsFileName), label: "v9.0.x local.json"}, + {path: filepath.Join(fileDir, "data", settingsFileName), label: "v9.1.x data/settings.json"}, + } + for i := range candidates { + b, err := os.ReadFile(candidates[i].path) + switch { + case err == nil: + candidates[i].contents = b + candidates[i].exists = true + case errors.Is(err, fs.ErrNotExist): + // Expected — file just isn't there. Treat as not-present. + default: + // Permission / I/O error — log it but don't bail outright. If + // it's the canonical path that's unreadable for non-ENOENT + // reasons, skip migration entirely so we don't try to write + // over a file the OS is telling us we can't see; for legacy + // or nested paths, treat the same as not-present. + slog.Warn("legacy settings migration: read failed", + "path", candidates[i].path, "error", err) + if candidates[i].path == canonicalPath { + return + } + } + } + + // Pick: highest-priority file with user_level=="pro"; if none has pro, + // highest-priority file that exists at all (with non-empty contents). + pickIdx := -1 + for i, c := range candidates { + if c.exists && userLevelInJSON(c.contents) == "pro" { + pickIdx = i + break + } + } + if pickIdx == -1 { + for i, c := range candidates { + if c.exists { + pickIdx = i + break + } + } + } + if pickIdx == -1 { + // Nothing on disk yet — fresh install, normal path. No-op. + return + } + if candidates[pickIdx].path == canonicalPath { + // Canonical already wins — no migration needed. + return + } + writeMigrated(canonicalPath, candidates[pickIdx].contents, candidates[pickIdx].label) +} + +// writeMigrated overwrites the canonical settings file with the recovered +// contents and logs the outcome. Uses atomicfile.WriteFile (the same +// mechanism the normal save path uses) so a crash mid-write can't leave +// a half-written settings.json on disk. Errors are logged-and-swallowed: +// if the write fails the caller falls through to the fresh-install path, +// which is a worse UX but not a corruption risk. +func writeMigrated(canonicalPath string, contents []byte, source string) { + if err := atomicfile.WriteFile(canonicalPath, contents, fileperm.File); err != nil { + slog.Warn("legacy settings migration: write failed", + "dst", canonicalPath, "source", source, "error", err) + return + } + slog.Info("legacy settings migration: recovered persisted state", + "dst", canonicalPath, "source", source, "bytes", len(contents)) +} + +// userLevelInJSON returns the value of the "user_level" key from a JSON +// settings blob, or "" if the key is missing / the blob is malformed. +// Lightweight extraction so the migration doesn't need to load the full +// koanf state machine before we've decided which file to read. +func userLevelInJSON(contents []byte) string { + var s struct { + UserLevel string `json:"user_level"` + } + if err := jsonpkg.Unmarshal(contents, &s); err != nil { + return "" + } + return s.UserLevel +} + func loadSettings(path string) error { contents, err := atomicfile.ReadFile(path) if err != nil { diff --git a/common/settings/settings_test.go b/common/settings/settings_test.go index 585205c2..01e4f8ce 100644 --- a/common/settings/settings_test.go +++ b/common/settings/settings_test.go @@ -3,6 +3,7 @@ package settings import ( "os" "path/filepath" + "runtime" "testing" "github.com/stretchr/testify/assert" @@ -30,3 +31,161 @@ func TestInitSettings(t *testing.T) { require.Error(t, loadSettings(path), "expected error for invalid config file") }) } + +func TestMigrateLegacySettingsIfNeeded(t *testing.T) { + writeNested := func(t *testing.T, dir string, contents []byte) { + t.Helper() + nd := filepath.Join(dir, "data") + require.NoError(t, os.MkdirAll(nd, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(nd, settingsFileName), contents, 0o644)) + } + writeLegacy := func(t *testing.T, dir string, contents []byte) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(dir, legacySettingsFileName), contents, 0o644)) + } + + t.Run("v9.0.x local.json recovered when canonical is missing (Derek's failing case)", func(t *testing.T) { + // User upgraded from v9.0.x straight to the fixed build. v9.0.x wrote + // to /local.json; canonical settings.json doesn't exist; + // no v9.1.x nested file. The fix must read local.json so Pro survives. + tempDir := t.TempDir() + want := []byte(`{"user_id": 3580849, "user_level": "pro", "token": "abc"}`) + writeLegacy(t, tempDir, want) + + canonical := filepath.Join(tempDir, settingsFileName) + migrateLegacySettingsIfNeeded(tempDir, canonical) + + got, err := os.ReadFile(canonical) + require.NoError(t, err) + assert.Equal(t, want, got, "v9.0.x local.json should be migrated to canonical") + }) + + t.Run("v9.1.x nested file recovered when canonical is missing", func(t *testing.T) { + tempDir := t.TempDir() + want := []byte(`{"user_id": 135809562, "user_level": "pro", "device_id": "abc"}`) + writeNested(t, tempDir, want) + + canonical := filepath.Join(tempDir, settingsFileName) + migrateLegacySettingsIfNeeded(tempDir, canonical) + + got, err := os.ReadFile(canonical) + require.NoError(t, err) + assert.Equal(t, want, got, "v9.1.x nested file should be migrated to canonical") + }) + + t.Run("v9.0.x local.json wins over v9.1.x expired nested", func(t *testing.T) { + // Upgrade chain v9.0.x → v9.1.x → fix: legacy has pro, nested has + // expired (because v9.1.x lost the user_id). Migration must pick + // legacy so Pro survives. + tempDir := t.TempDir() + canonical := filepath.Join(tempDir, settingsFileName) + legacyPro := []byte(`{"user_id": 1, "user_level": "pro"}`) + writeLegacy(t, tempDir, legacyPro) + writeNested(t, tempDir, []byte(`{"user_id": 999, "user_level": "expired"}`)) + + migrateLegacySettingsIfNeeded(tempDir, canonical) + + got, err := os.ReadFile(canonical) + require.NoError(t, err) + assert.Equal(t, legacyPro, got, "legacy local.json with pro should beat nested expired") + }) + + t.Run("canonical-pro wins over nested-expired", func(t *testing.T) { + tempDir := t.TempDir() + canonical := filepath.Join(tempDir, settingsFileName) + canonicalPro := []byte(`{"user_id": 1, "user_level": "pro"}`) + require.NoError(t, os.WriteFile(canonical, canonicalPro, 0o644)) + writeNested(t, tempDir, []byte(`{"user_id": 999, "user_level": "expired"}`)) + + migrateLegacySettingsIfNeeded(tempDir, canonical) + + got, err := os.ReadFile(canonical) + require.NoError(t, err) + assert.Equal(t, canonicalPro, got, "canonical-pro should survive") + }) + + t.Run("nested-pro wins over canonical-expired and legacy-expired", func(t *testing.T) { + // e.g., user paid via Shepherd while on v9.1.x, so the nested file + // legitimately holds pro state. + tempDir := t.TempDir() + canonical := filepath.Join(tempDir, settingsFileName) + require.NoError(t, os.WriteFile(canonical, []byte(`{"user_id": 1, "user_level": "expired"}`), 0o644)) + writeLegacy(t, tempDir, []byte(`{"user_id": 1, "user_level": "expired"}`)) + nestedPro := []byte(`{"user_id": 2, "user_level": "pro"}`) + writeNested(t, tempDir, nestedPro) + + migrateLegacySettingsIfNeeded(tempDir, canonical) + + got, err := os.ReadFile(canonical) + require.NoError(t, err) + assert.Equal(t, nestedPro, got, "nested-pro should beat both canonical and legacy when only it has pro") + }) + + t.Run("all-pro: canonical wins (most recent deliberate state)", func(t *testing.T) { + tempDir := t.TempDir() + canonical := filepath.Join(tempDir, settingsFileName) + canonicalContents := []byte(`{"user_id": 1, "user_level": "pro"}`) + require.NoError(t, os.WriteFile(canonical, canonicalContents, 0o644)) + writeLegacy(t, tempDir, []byte(`{"user_id": 2, "user_level": "pro"}`)) + writeNested(t, tempDir, []byte(`{"user_id": 3, "user_level": "pro"}`)) + + migrateLegacySettingsIfNeeded(tempDir, canonical) + + got, err := os.ReadFile(canonical) + require.NoError(t, err) + assert.Equal(t, canonicalContents, got, "canonical preferred when all have pro") + }) + + t.Run("none have pro: legacy wins over nested when canonical missing", func(t *testing.T) { + // User identifiers must survive even when Pro state is non-pro, + // to keep the device registration intact server-side. + tempDir := t.TempDir() + canonical := filepath.Join(tempDir, settingsFileName) + legacyContents := []byte(`{"user_id": 1, "user_level": "free", "token": "abc"}`) + writeLegacy(t, tempDir, legacyContents) + writeNested(t, tempDir, []byte(`{"user_id": 2, "user_level": "free", "token": "xyz"}`)) + + migrateLegacySettingsIfNeeded(tempDir, canonical) + + got, err := os.ReadFile(canonical) + require.NoError(t, err) + assert.Equal(t, legacyContents, got, "legacy preferred over nested when canonical missing and neither has pro") + }) + + t.Run("nothing on disk is a no-op", func(t *testing.T) { + tempDir := t.TempDir() + canonical := filepath.Join(tempDir, settingsFileName) + + migrateLegacySettingsIfNeeded(tempDir, canonical) + + _, err := os.Stat(canonical) + assert.True(t, os.IsNotExist(err), "no migration when no source files exist") + }) + + t.Run("unreadable canonical (non-ENOENT) skips migration", func(t *testing.T) { + // Permission error on the canonical path: don't fall through and + // overwrite a file we couldn't read. unix only — windows handles + // permissions differently and chmod wouldn't reproduce this. + if runtime.GOOS == "windows" { + t.Skip("permission semantics differ on windows") + } + tempDir := t.TempDir() + canonical := filepath.Join(tempDir, settingsFileName) + require.NoError(t, os.WriteFile(canonical, []byte(`{"user_level": "expired"}`), 0o644)) + // Make the file unreadable. + require.NoError(t, os.Chmod(canonical, 0o000)) + t.Cleanup(func() { _ = os.Chmod(canonical, 0o644) }) + // Stage a legacy-pro candidate that would otherwise win. + writeLegacy(t, tempDir, []byte(`{"user_id": 1, "user_level": "pro"}`)) + + migrateLegacySettingsIfNeeded(tempDir, canonical) + + // Restore readability and confirm the canonical contents are + // unchanged (still the expired body, not the legacy-pro body). + require.NoError(t, os.Chmod(canonical, 0o644)) + got, err := os.ReadFile(canonical) + require.NoError(t, err) + assert.Equal(t, `{"user_level": "expired"}`, string(got), + "canonical with non-ENOENT read error should be left alone") + }) +} From 4445a2040ede16f1ac2ce6fe85220c174058d2ef Mon Sep 17 00:00:00 2001 From: Myles Horton Date: Wed, 6 May 2026 12:07:21 -0600 Subject: [PATCH 08/35] settings: also migrate from pre-9.x flashlight/lantern-client YAML (#464) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * settings: also migrate from pre-9.x flashlight/lantern-client YAML The fix in #463 covered the v9.0.x → v9.1.x → fixed upgrade path (rename of local.json → settings.json plus the bogus /data suffix). But Lantern users on the older flashlight + lantern-client stack (pre-9.0.x) wrote their state to a different file entirely: macOS ~/.lantern/settings.yaml Windows %APPDATA%\Lantern\settings.yaml Linux ~/.config/lantern/settings.yaml iOS /userconfig.yaml Anyone upgrading directly from v8.x (or anything with that schema) would skip both fixed paths and start fresh — same Pro-lost symptom Derek reproduced for the v9.0.x case. Adds a fourth candidate to migrateLegacySettingsIfNeeded that reads the platform-specific YAML, translates field names and types into the canonical settings.json schema, and feeds the translated JSON into the same priority-pick logic the existing JSON candidates use. Order: canonical > v9.0.x local.json > pre-9.x YAML > v9.1.x nested. The pre-9.x YAML beats the v9.1.x nested file because the nested file is known-bugged (fresh device id, possibly wrong user_id) while the YAML is real legitimate state. Field translation (desktop schema): userID → user_id deviceID → device_id userPro bool → user_level "pro" / "free" (only "free" if user_id is set; an anonymous yaml leaves it unset so the next /account/login decides) userToken → token emailAddress → email Field translation (iOS schema): UserID/DeviceID/Token only — iOS didn't persist user_level locally. Android is intentionally not handled here — its pre-9.x state lived in an encrypted SQLite (/data/data/.../files/masterDBv2/db) whose password lives in EncryptedSharedPreferences. Reading that requires Kotlin-side code in the lantern repo, not a pure-Go migration in radiance. Will be a separate change. Adds 6 new sub-tests covering both desktop and iOS layouts (pro, free-but-identified, anonymous, malformed, unknown layout, and the end-to-end iOS migration on darwin/ios). Co-Authored-By: Claude Opus 4.7 (1M context) * fix macOS path: ~/Library/Application Support/Lantern (not ~/.lantern) The pre-9.x desktop client used getlantern/appdir.General('Lantern') which on macOS routes to os.UserConfigDir() — i.e. ~/Library/Application Support/Lantern, NOT ~/.lantern. (Verified against lantern-client/desktop/app/settings.go:132 and appdir/appdir_darwin.go.) Linux is os.UserConfigDir() + lowercase 'lantern' (linux build tag in appdir.General does the lowercasing). Windows is os.UserConfigDir() + 'Lantern' which resolves to %APPDATA%\Lantern. Switched to os.UserConfigDir() across all three desktop platforms to match exactly. Also factored the path resolver into a package- level var legacyYAMLPathFn so tests can redirect the lookup at a tempDir without picking up the host machine's actual Lantern install (which is what was breaking the 'nothing on disk is a no-op' test on my macOS). Three new sub-tests cover the YAML migration path end-to-end: - pre-9.x desktop YAML recovered (Pro state translates correctly) - v9.0.x local.json beats pre-9.x YAML when both present - pre-9.x YAML beats v9.1.x bugged nested file Co-Authored-By: Claude Opus 4.7 (1M context) * trim verbose comments Drop multi-paragraph philosophical asides and per-platform tables that duplicate what the code shows. Keeping only the non-obvious WHYs: - linux's lowercase appdir quirk - iOS sandbox path provenance - "free" fallback when userID is set - empty user_level on iOS ~90 fewer lines, no behavior change. --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Adam Fisk --- common/settings/legacy_yaml.go | 119 ++++++++++++++++++++++++++++ common/settings/legacy_yaml_test.go | 89 +++++++++++++++++++++ common/settings/settings.go | 53 +++++++------ common/settings/settings_test.go | 109 +++++++++++++++++++++++++ 4 files changed, 344 insertions(+), 26 deletions(-) create mode 100644 common/settings/legacy_yaml.go create mode 100644 common/settings/legacy_yaml_test.go diff --git a/common/settings/legacy_yaml.go b/common/settings/legacy_yaml.go new file mode 100644 index 00000000..9e38b4a2 --- /dev/null +++ b/common/settings/legacy_yaml.go @@ -0,0 +1,119 @@ +package settings + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "log/slog" + "os" + "path/filepath" + "runtime" + + "github.com/goccy/go-yaml" +) + +// legacyYAMLPathFn is overridable so tests can redirect lookup to a temp +// dir without touching the host's app-config layout. +var legacyYAMLPathFn = legacyYAMLPath + +// legacyYAMLCandidate returns the pre-9.x flashlight/lantern-client +// settings file (if any), translated into canonical JSON. Android is +// excluded — it persisted state in an encrypted SQLite that needs a +// Kotlin-side migration. +func legacyYAMLCandidate(fileDir string) candidateSource { + path, layout := legacyYAMLPathFn(fileDir) + if path == "" { + return candidateSource{} + } + raw, err := os.ReadFile(path) + if err != nil { + if !errors.Is(err, fs.ErrNotExist) { + slog.Warn("pre-9.x yaml read failed", "path", path, "error", err) + } + return candidateSource{} + } + translated, err := translateLegacyYAML(raw, layout) + if err != nil { + slog.Warn("pre-9.x yaml translate failed", "path", path, "error", err) + return candidateSource{} + } + return candidateSource{ + path: path, + contents: translated, + exists: true, + label: fmt.Sprintf("pre-9.x %s yaml", layout), + } +} + +func legacyYAMLPath(fileDir string) (path, layout string) { + switch runtime.GOOS { + case "darwin", "windows": + if cfg, err := os.UserConfigDir(); err == nil { + return filepath.Join(cfg, "Lantern", "settings.yaml"), "desktop" + } + case "linux": + // Pre-9.x appdir lowercased the app name on linux only. + if cfg, err := os.UserConfigDir(); err == nil { + return filepath.Join(cfg, "lantern", "settings.yaml"), "desktop" + } + case "ios": + // iOS lantern-client wrote userconfig.yaml inside the app sandbox, + // the same sandbox radiance's dataDir lives in. + return filepath.Join(fileDir, "userconfig.yaml"), "ios" + } + return "", "" +} + +func translateLegacyYAML(raw []byte, layout string) ([]byte, error) { + type canonical struct { + UserID int64 `json:"user_id,omitempty"` + DeviceID string `json:"device_id,omitempty"` + UserLevel string `json:"user_level,omitempty"` + Token string `json:"token,omitempty"` + Email string `json:"email,omitempty"` + } + + var out canonical + switch layout { + case "desktop": + var d struct { + UserID int64 `yaml:"userID"` + DeviceID string `yaml:"deviceID"` + UserPro bool `yaml:"userPro"` + UserToken string `yaml:"userToken"` + EmailAddress string `yaml:"emailAddress"` + } + if err := yaml.Unmarshal(raw, &d); err != nil { + return nil, fmt.Errorf("desktop yaml: %w", err) + } + out.UserID = d.UserID + out.DeviceID = d.DeviceID + out.Token = d.UserToken + out.Email = d.EmailAddress + switch { + case d.UserPro: + out.UserLevel = "pro" + case d.UserID != 0: + // Identified-but-not-pro → "free" so downstream sees a real value. + out.UserLevel = "free" + } + case "ios": + var i struct { + UserID int64 `yaml:"UserID"` + DeviceID string `yaml:"DeviceID"` + Token string `yaml:"Token"` + } + if err := yaml.Unmarshal(raw, &i); err != nil { + return nil, fmt.Errorf("ios yaml: %w", err) + } + out.UserID = i.UserID + out.DeviceID = i.DeviceID + out.Token = i.Token + // user_level left empty: iOS didn't persist it here, so the next + // /account/login is authoritative. + default: + return nil, fmt.Errorf("unknown layout: %s", layout) + } + return json.Marshal(out) +} diff --git a/common/settings/legacy_yaml_test.go b/common/settings/legacy_yaml_test.go new file mode 100644 index 00000000..03a591a2 --- /dev/null +++ b/common/settings/legacy_yaml_test.go @@ -0,0 +1,89 @@ +package settings + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTranslateLegacyYAML_Desktop(t *testing.T) { + t.Run("pro user with all fields", func(t *testing.T) { + // Shape mirrors what the pre-9.x desktop client (flashlight + + // lantern-client) wrote into ~/.lantern/settings.yaml / + // %APPDATA%\Lantern\settings.yaml / ~/.config/lantern/settings.yaml. + yaml := []byte(`userID: 3580849 +deviceID: 84e9c7b2-2a54-44f3-9ec6-276086017e49 +userPro: true +userToken: abc123token +emailAddress: derek@example.com +userFirstVisit: true +otherStuff: ignored +`) + + out, err := translateLegacyYAML(yaml, "desktop") + require.NoError(t, err) + + var got map[string]any + require.NoError(t, json.Unmarshal(out, &got)) + assert.Equal(t, float64(3580849), got["user_id"]) + assert.Equal(t, "84e9c7b2-2a54-44f3-9ec6-276086017e49", got["device_id"]) + assert.Equal(t, "pro", got["user_level"]) + assert.Equal(t, "abc123token", got["token"]) + assert.Equal(t, "derek@example.com", got["email"]) + }) + + t.Run("free user with id is marked free", func(t *testing.T) { + yaml := []byte(`userID: 100 +deviceID: dev-abc +userPro: false +userToken: tok +`) + out, err := translateLegacyYAML(yaml, "desktop") + require.NoError(t, err) + assert.Equal(t, "free", userLevelInJSON(out), + "a known but non-pro user should translate to user_level=free") + }) + + t.Run("anonymous (no user id) leaves user_level empty", func(t *testing.T) { + yaml := []byte(`userPro: false +userToken: "" +`) + out, err := translateLegacyYAML(yaml, "desktop") + require.NoError(t, err) + assert.Equal(t, "", userLevelInJSON(out), + "no user_id means we shouldn't claim 'free'; let next login decide") + }) + + t.Run("malformed yaml errors", func(t *testing.T) { + _, err := translateLegacyYAML([]byte("\tnot: a [valid yaml: doc\n"), "desktop") + assert.Error(t, err) + }) +} + +func TestTranslateLegacyYAML_iOS(t *testing.T) { + yaml := []byte(`AppName: lantern +DeviceID: ios-device-9000 +UserID: 7777 +Token: ios-token +Language: en +Country: US +`) + out, err := translateLegacyYAML(yaml, "ios") + require.NoError(t, err) + + var got map[string]any + require.NoError(t, json.Unmarshal(out, &got)) + assert.Equal(t, float64(7777), got["user_id"]) + assert.Equal(t, "ios-device-9000", got["device_id"]) + assert.Equal(t, "ios-token", got["token"]) + // iOS yaml didn't carry user_level — should be omitted, not "free". + _, hasLevel := got["user_level"] + assert.False(t, hasLevel, "iOS yaml should not produce a user_level field") +} + +func TestTranslateLegacyYAML_UnknownLayout(t *testing.T) { + _, err := translateLegacyYAML([]byte(`userID: 1`), "android") + assert.Error(t, err) +} diff --git a/common/settings/settings.go b/common/settings/settings.go index 6e9cb950..c6a8ffc8 100644 --- a/common/settings/settings.go +++ b/common/settings/settings.go @@ -112,36 +112,32 @@ func InitSettings(fileDir string) error { return nil } +// candidateSource is one possible location of persisted user state. +// contents is always canonical JSON — direct for v9.x, translated for +// pre-9.x YAML. +type candidateSource struct { + path string + contents []byte + exists bool + label string +} + // migrateLegacySettingsIfNeeded recovers persisted user state written -// by older client versions. Three candidate paths are considered: -// -// - /settings.json — the current canonical path -// - /local.json — what v9.0.x wrote (renamed in #370) -// - /data/settings.json — what v9.1.x wrote, due to a bug in -// #370's setupDirectories that -// appended an unconditional "/data" -// suffix to the caller's data dir +// by older client versions. Candidates in priority order: // -// On the first launch of a fixed client, any of the three may exist -// depending on the user's upgrade path. Pick whichever has -// user_level == "pro" so anyone who legitimately paid keeps their Pro -// state regardless of which file holds the good copy. If none have pro, -// prefer canonical → v9.0.x → v9.1.x in that order so the user's -// identifiers (user_id, token, device_id) survive — losing Pro is -// recoverable, losing the device registration creates server-side -// orphans. +// 1. /settings.json — canonical +// 2. /local.json — v9.0.x (renamed in #370) +// 3. pre-9.x platform-specific YAML (legacy_yaml.go); spliced in below +// 4. /data/settings.json — v9.1.x (bugged: #370's +// setupDirectories appended an +// unconditional "/data" suffix) // -// Runs unconditionally — quick stat-and-read of three small files; -// no-op for the vast majority of installs that don't have a nested or -// legacy file. +// Pick the highest-priority candidate with user_level=="pro"; if none +// is pro, the highest-priority candidate that exists. Losing Pro is +// recoverable; losing the device registration creates server-side +// orphans, so identifier continuity wins ties. func migrateLegacySettingsIfNeeded(fileDir, canonicalPath string) { - type candidate struct { - path string - contents []byte - exists bool - label string - } - candidates := []candidate{ + candidates := []candidateSource{ {path: canonicalPath, label: "canonical settings.json"}, {path: filepath.Join(fileDir, legacySettingsFileName), label: "v9.0.x local.json"}, {path: filepath.Join(fileDir, "data", settingsFileName), label: "v9.1.x data/settings.json"}, @@ -167,6 +163,11 @@ func migrateLegacySettingsIfNeeded(fileDir, canonicalPath string) { } } } + // Splice the pre-9.x YAML candidate before the v9.1.x nested file so + // priority is canonical > local.json > pre-9.x > nested. + if yc := legacyYAMLCandidate(fileDir); yc.exists { + candidates = append(candidates[:2], append([]candidateSource{yc}, candidates[2:]...)...) + } // Pick: highest-priority file with user_level=="pro"; if none has pro, // highest-priority file that exists at all (with non-empty contents). diff --git a/common/settings/settings_test.go b/common/settings/settings_test.go index 01e4f8ce..e38b1a22 100644 --- a/common/settings/settings_test.go +++ b/common/settings/settings_test.go @@ -33,6 +33,15 @@ func TestInitSettings(t *testing.T) { } func TestMigrateLegacySettingsIfNeeded(t *testing.T) { + // Redirect the OS-specific pre-9.x YAML lookup to nowhere by + // default so individual tests don't pick up the host machine's + // actual ~/Library/Application Support/Lantern/settings.yaml or + // equivalent. Sub-tests that exercise the YAML path opt in by + // pointing the function at their tempDir. + prevYAMLPath := legacyYAMLPathFn + legacyYAMLPathFn = func(string) (string, string) { return "", "" } + t.Cleanup(func() { legacyYAMLPathFn = prevYAMLPath }) + writeNested := func(t *testing.T, dir string, contents []byte) { t.Helper() nd := filepath.Join(dir, "data") @@ -162,6 +171,106 @@ func TestMigrateLegacySettingsIfNeeded(t *testing.T) { assert.True(t, os.IsNotExist(err), "no migration when no source files exist") }) + t.Run("pre-9.x desktop YAML recovered when no JSON candidates exist", func(t *testing.T) { + // Redirect the YAML lookup at a tempDir-local file so the test + // is portable across OSes. + tempDir := t.TempDir() + yamlPath := filepath.Join(tempDir, "fake-pre9x-settings.yaml") + require.NoError(t, os.WriteFile(yamlPath, []byte(`userID: 3580849 +deviceID: legacy-device-id +userPro: true +userToken: legacy-token +emailAddress: derek@example.com +`), 0o644)) + legacyYAMLPathFn = func(string) (string, string) { return yamlPath, "desktop" } + t.Cleanup(func() { legacyYAMLPathFn = func(string) (string, string) { return "", "" } }) + + canonical := filepath.Join(tempDir, settingsFileName) + migrateLegacySettingsIfNeeded(tempDir, canonical) + + got, err := os.ReadFile(canonical) + require.NoError(t, err) + gotStr := string(got) + assert.Contains(t, gotStr, `"user_id":3580849`) + assert.Contains(t, gotStr, `"device_id":"legacy-device-id"`) + assert.Contains(t, gotStr, `"user_level":"pro"`) + assert.Contains(t, gotStr, `"token":"legacy-token"`) + assert.Contains(t, gotStr, `"email":"derek@example.com"`) + }) + + t.Run("v9.0.x local.json beats pre-9.x YAML", func(t *testing.T) { + // Both exist with pro state. local.json is the higher-priority + // (more recent) source, so it should win. + tempDir := t.TempDir() + yamlPath := filepath.Join(tempDir, "fake-pre9x-settings.yaml") + require.NoError(t, os.WriteFile(yamlPath, []byte(`userID: 1 +userPro: true +userToken: legacy-token +`), 0o644)) + legacyYAMLPathFn = func(string) (string, string) { return yamlPath, "desktop" } + t.Cleanup(func() { legacyYAMLPathFn = func(string) (string, string) { return "", "" } }) + + writeLegacy(t, tempDir, []byte(`{"user_id": 2, "user_level": "pro", "token": "v9.0-token"}`)) + + canonical := filepath.Join(tempDir, settingsFileName) + migrateLegacySettingsIfNeeded(tempDir, canonical) + + got, err := os.ReadFile(canonical) + require.NoError(t, err) + assert.Contains(t, string(got), `"user_id": 2`, + "v9.0.x local.json should win over pre-9.x YAML when both have pro") + assert.Contains(t, string(got), `"v9.0-token"`) + }) + + t.Run("pre-9.x YAML beats v9.1.x bugged nested file", func(t *testing.T) { + // Pre-9.x has pro; v9.1.x nested has expired (the bugged case). + // Pre-9.x must win. + tempDir := t.TempDir() + yamlPath := filepath.Join(tempDir, "fake-pre9x-settings.yaml") + require.NoError(t, os.WriteFile(yamlPath, []byte(`userID: 1 +userPro: true +userToken: legacy-token +`), 0o644)) + legacyYAMLPathFn = func(string) (string, string) { return yamlPath, "desktop" } + t.Cleanup(func() { legacyYAMLPathFn = func(string) (string, string) { return "", "" } }) + + writeNested(t, tempDir, []byte(`{"user_id": 999, "user_level": "expired"}`)) + + canonical := filepath.Join(tempDir, settingsFileName) + migrateLegacySettingsIfNeeded(tempDir, canonical) + + got, err := os.ReadFile(canonical) + require.NoError(t, err) + assert.Contains(t, string(got), `"user_level":"pro"`, + "pre-9.x YAML with pro should win over v9.1.x nested expired") + assert.Contains(t, string(got), `"legacy-token"`) + }) + + t.Run("iOS userconfig.yaml recovered when canonical is missing", func(t *testing.T) { + // On iOS the legacy YAML is sandbox-relative — it lives next to + // where settings.json now lives, so legacyYAMLCandidate reads + // from fileDir directly and we can exercise it from a test + // without monkeypatching $HOME or $APPDATA. (Desktop legacy + // paths are covered via translateLegacyYAML's unit tests, which + // don't depend on the OS-specific path resolution.) + if runtime.GOOS != "ios" { + t.Skip("iOS-only path: legacy YAML elsewhere is OS-specific") + } + tempDir := t.TempDir() + yamlPath := filepath.Join(tempDir, "userconfig.yaml") + require.NoError(t, os.WriteFile(yamlPath, []byte(`UserID: 7777 +DeviceID: ios-device +Token: tok +`), 0o644)) + canonical := filepath.Join(tempDir, settingsFileName) + migrateLegacySettingsIfNeeded(tempDir, canonical) + + got, err := os.ReadFile(canonical) + require.NoError(t, err) + assert.Contains(t, string(got), `"user_id":7777`) + assert.Contains(t, string(got), `"device_id":"ios-device"`) + }) + t.Run("unreadable canonical (non-ENOENT) skips migration", func(t *testing.T) { // Permission error on the canonical path: don't fall through and // overwrite a file we couldn't read. unix only — windows handles From 6309530dece6d9abce1d44830a1f3f622a15c484 Mon Sep 17 00:00:00 2001 From: atavism Date: Wed, 6 May 2026 12:26:24 -0700 Subject: [PATCH 09/35] account: forward payment redirect idempotency keys --- account/subscription.go | 40 +++++++++++++++++++++--------------- account/subscription_test.go | 39 +++++++++++++++++++++++++---------- account/user_test.go | 19 ++++++++++++----- 3 files changed, 66 insertions(+), 32 deletions(-) diff --git a/account/subscription.go b/account/subscription.go index 2a6f54f1..136d9e93 100644 --- a/account/subscription.go +++ b/account/subscription.go @@ -7,6 +7,7 @@ import ( "log/slog" "net/url" "strconv" + "strings" "time" "go.opentelemetry.io/otel" @@ -33,11 +34,12 @@ const ( // PaymentRedirectData contains the data required to generate a payment redirect URL. type PaymentRedirectData struct { - Plan string `json:"plan" validate:"required"` - Provider string `json:"provider" validate:"required"` - Email string `json:"email"` - DeviceName string `json:"deviceName" validate:"required" errorId:"device-name"` - BillingType SubscriptionType `json:"billingType"` + Plan string `json:"plan" validate:"required"` + Provider string `json:"provider" validate:"required"` + Email string `json:"email"` + DeviceName string `json:"deviceName" validate:"required" errorId:"device-name"` + BillingType SubscriptionType `json:"billingType"` + IdempotencyKey string `json:"idempotencyKey"` } type SubscriptionPlans struct { @@ -150,7 +152,7 @@ func (a *Client) StripeBillingPortalURL(ctx context.Context, baseURL, userID, pr } type redirect struct { - Redirect string + Redirect string `json:"redirect"` } func (a *Client) paymentRedirect(ctx context.Context, path string, params map[string]string) (string, error) { @@ -166,7 +168,11 @@ func (a *Client) paymentRedirect(ctx context.Context, path string, params map[st if err := json.Unmarshal(resp, &r); err != nil { return "", traces.RecordError(ctx, fmt.Errorf("unmarshaling payment redirect response: %w", err)) } - return r.Redirect, nil + redirectURL := strings.TrimSpace(r.Redirect) + if redirectURL == "" { + return "", traces.RecordError(ctx, fmt.Errorf("payment redirect response missing redirect URL")) + } + return redirectURL, nil } // SubscriptionPaymentRedirectURL generates a redirect URL for subscription payment. @@ -174,11 +180,12 @@ func (a *Client) SubscriptionPaymentRedirectURL(ctx context.Context, data Paymen ctx, span := otel.Tracer(tracerName).Start(ctx, "subscription_payment_redirect_url") defer span.End() params := map[string]string{ - "provider": data.Provider, - "plan": data.Plan, - "deviceName": data.DeviceName, - "email": data.Email, - "billingType": string(data.BillingType), + "provider": data.Provider, + "plan": data.Plan, + "deviceName": data.DeviceName, + "email": data.Email, + "billingType": string(data.BillingType), + "idempotencyKey": data.IdempotencyKey, } return a.paymentRedirect(ctx, "/subscription-payment-redirect", params) } @@ -189,10 +196,11 @@ func (a *Client) PaymentRedirect(ctx context.Context, data PaymentRedirectData) ctx, span := otel.Tracer(tracerName).Start(ctx, "payment_redirect") defer span.End() params := map[string]string{ - "provider": data.Provider, - "plan": data.Plan, - "deviceName": data.DeviceName, - "email": data.Email, + "provider": data.Provider, + "plan": data.Plan, + "deviceName": data.DeviceName, + "email": data.Email, + "idempotencyKey": data.IdempotencyKey, } return a.paymentRedirect(ctx, "/payment-redirect", params) } diff --git a/account/subscription_test.go b/account/subscription_test.go index cedd3ee3..651fc834 100644 --- a/account/subscription_test.go +++ b/account/subscription_test.go @@ -9,30 +9,47 @@ import ( ) func TestSubscriptionPaymentRedirect(t *testing.T) { - ac, _ := newTestClient(t) + ac, ts := newTestClient(t) data := PaymentRedirectData{ - Provider: "stripe", - Plan: "pro", - DeviceName: "test-device", - Email: "", - BillingType: SubscriptionTypeOneTime, + Provider: "stripe", + Plan: "pro", + DeviceName: "test-device", + Email: "", + BillingType: SubscriptionTypeOneTime, + IdempotencyKey: "subscription-redirect-key", } url, err := ac.SubscriptionPaymentRedirectURL(context.Background(), data) require.NoError(t, err) assert.NotEmpty(t, url) + assert.Equal(t, data.IdempotencyKey, ts.subscriptionPaymentRedirectIdempotencyKey) } func TestPaymentRedirect(t *testing.T) { - ac, _ := newTestClient(t) + ac, ts := newTestClient(t) data := PaymentRedirectData{ - Provider: "stripe", - Plan: "pro", - DeviceName: "test-device", - Email: "", + Provider: "stripe", + Plan: "pro", + DeviceName: "test-device", + Email: "", + IdempotencyKey: "payment-redirect-key", } url, err := ac.PaymentRedirect(context.Background(), data) require.NoError(t, err) assert.NotEmpty(t, url) + assert.Equal(t, data.IdempotencyKey, ts.paymentRedirectIdempotencyKey) +} + +func TestPaymentRedirectRequiresRedirectURL(t *testing.T) { + ac, ts := newTestClient(t) + ts.paymentRedirectResponse = map[string]string{"status": "error", "error": "try again later"} + + url, err := ac.PaymentRedirect(context.Background(), PaymentRedirectData{ + Provider: "stripe", + Plan: "pro", + DeviceName: "test-device", + }) + require.Error(t, err) + assert.Empty(t, url) } func TestNewUser(t *testing.T) { diff --git a/account/user_test.go b/account/user_test.go index 66d0a295..94d60272 100644 --- a/account/user_test.go +++ b/account/user_test.go @@ -22,9 +22,12 @@ import ( // testServer holds server-side SRP state for the mock auth server. type testServer struct { - salt map[string][]byte - verifier []byte - cache map[string]string + salt map[string][]byte + verifier []byte + cache map[string]string + paymentRedirectIdempotencyKey string + subscriptionPaymentRedirectIdempotencyKey string + paymentRedirectResponse any } func writeProtoResponse(w http.ResponseWriter, msg proto.Message) { @@ -180,11 +183,17 @@ func newTestServer(t *testing.T) (*httptest.Server, *testServer) { }) mux.HandleFunc("/subscription-payment-redirect", func(w http.ResponseWriter, r *http.Request) { - writeJSONResponse(w, map[string]string{"Redirect": "https://example.com/redirect"}) + state.subscriptionPaymentRedirectIdempotencyKey = r.URL.Query().Get("idempotencyKey") + writeJSONResponse(w, map[string]string{"redirect": "https://example.com/redirect"}) }) mux.HandleFunc("/payment-redirect", func(w http.ResponseWriter, r *http.Request) { - writeJSONResponse(w, map[string]string{"Redirect": "https://example.com/redirect"}) + state.paymentRedirectIdempotencyKey = r.URL.Query().Get("idempotencyKey") + resp := state.paymentRedirectResponse + if resp == nil { + resp = map[string]string{"redirect": "https://example.com/redirect"} + } + writeJSONResponse(w, resp) }) mux.HandleFunc("/stripe-subscription", func(w http.ResponseWriter, r *http.Request) { From 602b754345643ef68c496747de8b973f016ff5d0 Mon Sep 17 00:00:00 2001 From: Myles Horton Date: Thu, 7 May 2026 10:40:52 -0600 Subject: [PATCH 10/35] deps: bump kindling for method-aware retry across transports (#468) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * deps: bump kindling for method-aware retry across transports raceTransport in kindling was retrying every request on 4xx/5xx and on post-RoundTrip transport errors, which meant non-idempotent endpoints (notably POST /peer/verify) were being replayed across transports — each replay observable on the server, with the first succeeding, deprecating its row, and subsequent replays 404'ing. Bump pulls in getlantern/kindling#32 which makes the retry behavior method-aware: - Non-idempotent (POST/PUT/DELETE/PATCH/OPTIONS): exactly one request fires once any transport connects; whatever happens is returned. - Idempotent (GET/HEAD): retain retry-on-5xx and retry-on-transport- error across transports for the legitimate "fronting CDN is being blocked" case. 4xx still short-circuits — the request itself is the problem and replay won't help. - Connection-establishment failures fall back regardless of method. Behavioral effect on radiance: /config-new (GET) — unchanged retry behavior. /peer/register, /peer/verify, /peer/heartbeat, /peer/deregister (POST) — single-shot, removing the multi-pod-deprecation race seen during peer-share testing on 2026-05-07. Co-Authored-By: Claude Opus 4.7 (1M context) * config: opt /config-new POST into kindling's idempotent retry Address Copilot review on PR #468: /config-new is a POST (config/fetcher.go:150), not a GET, so kindling's new method-aware retry made it single-shot — losing the cross-transport fallback that historically kept config fetches resilient when one fronting CDN returns 5xx. /config-new is semantically a read-only fetch despite the POST shape (body carries the client's last-known etag/version and metadata), so it's safe to retry. Set kindling.IdempotentHeader on the request to opt it back into raceTransport's GET/HEAD-style retry behavior. Pulls in the merge SHA of getlantern/kindling#33 which adds IdempotentHeader. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Adam Fisk Co-authored-by: Claude Opus 4.7 (1M context) --- config/fetcher.go | 9 +++++++++ go.mod | 2 +- go.sum | 4 ++-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/config/fetcher.go b/config/fetcher.go index b60ad761..a5af7b85 100644 --- a/config/fetcher.go +++ b/config/fetcher.go @@ -20,6 +20,7 @@ import ( "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" + "github.com/getlantern/kindling" "github.com/getlantern/lantern-box/protocol" "github.com/getlantern/radiance/account" @@ -153,6 +154,14 @@ func (f *fetcher) send(ctx context.Context, body io.Reader) ([]byte, error) { } req.Header.Set("Content-Type", "application/json") req.Header.Set("Cache-Control", "no-cache") + // /config-new is POST-shaped (request carries last-known etag/version + // + client metadata in the body) but is semantically a read-only + // fetch — no server-side state mutates. Tag it idempotent so kindling's + // raceTransport falls back to the next transport on transport-level + // errors and 5xx, the same way it does for GET/HEAD. Without this, a + // single fronting CDN returning 5xx (e.g., during a localized block) + // would fail the whole fetch instead of being routed around. + req.Header.Set(kindling.IdempotentHeader, "1") if val := env.GetString(env.Country); val != "" { slog.Info("Setting x-lantern-client-country header", "country", val) diff --git a/go.mod b/go.mod index a0392d04..ff527c6c 100644 --- a/go.mod +++ b/go.mod @@ -30,7 +30,7 @@ require ( github.com/getlantern/dnstt v0.0.0-20260112160750-05100563bd0d github.com/getlantern/domainfront v0.0.0-20260419161617-0bff0b2169f4 github.com/getlantern/keepcurrent v0.0.0-20260422161259-54a4d9a93694 - github.com/getlantern/kindling v0.0.0-20260428171407-6143132aaf40 + github.com/getlantern/kindling v0.0.0-20260507163327-92a44d03bdc5 github.com/getlantern/lantern-box v0.0.78 github.com/getlantern/pluriconfig v0.0.0-20251126214241-8cc8bc561535 github.com/getlantern/publicip v0.0.0-20260328175246-2c460fe80c6b diff --git a/go.sum b/go.sum index 1efc2333..27cfef3e 100644 --- a/go.sum +++ b/go.sum @@ -246,8 +246,8 @@ github.com/getlantern/hidden v0.0.0-20220104173330-f221c5a24770 h1:cSrD9ryDfTV2y github.com/getlantern/hidden v0.0.0-20220104173330-f221c5a24770/go.mod h1:GOQsoDnEHl6ZmNIL+5uVo+JWRFWozMEp18Izcb++H+A= github.com/getlantern/keepcurrent v0.0.0-20260422161259-54a4d9a93694 h1:iLWm6S/47Hfk7FjW6yaD+1h6kO7C/iauV0DkVia/bXU= github.com/getlantern/keepcurrent v0.0.0-20260422161259-54a4d9a93694/go.mod h1:ag5g9aWUw2FJcX5RVRpJ9EBQBy5yJuy2WXDouIn/m4w= -github.com/getlantern/kindling v0.0.0-20260428171407-6143132aaf40 h1:P5pkaBGxWOGBn7bKzjzdln/ro+ShG1RUbOuy+7pSzXE= -github.com/getlantern/kindling v0.0.0-20260428171407-6143132aaf40/go.mod h1:TGTxpoNVwc8Be4qkBNtf5oj2psJaEIZEq47GOPS7zkA= +github.com/getlantern/kindling v0.0.0-20260507163327-92a44d03bdc5 h1:ukTEQ2S16zMK2BJxIM0qKz+WiiyiPwvmLCWlK1EOvVU= +github.com/getlantern/kindling v0.0.0-20260507163327-92a44d03bdc5/go.mod h1:TGTxpoNVwc8Be4qkBNtf5oj2psJaEIZEq47GOPS7zkA= github.com/getlantern/lantern-box v0.0.78 h1:6P68+v7zukSXs3KFEfqY6iKBtqV3bLCzKouigN4kaw4= github.com/getlantern/lantern-box v0.0.78/go.mod h1:wJhPQKdnwD6qW/ghAfzsrj/IfHZbvFSAfr52+Tu6dbw= github.com/getlantern/lantern-water v0.0.0-20260317143726-e0ee64a11d90 h1:P9JX1yAu2uq3b5YiT0sLtHkTrkZuttV8gPZh81nUuag= From 7d593165a3aa82c6fb576a40a4f112d73bb740f8 Mon Sep 17 00:00:00 2001 From: garmr-ulfr <104022054+garmr-ulfr@users.noreply.github.com> Date: Thu, 7 May 2026 10:05:00 -0700 Subject: [PATCH 11/35] bump lantern-box - urltest reselect on failure (#469) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index ff527c6c..7d83a2db 100644 --- a/go.mod +++ b/go.mod @@ -31,7 +31,7 @@ require ( github.com/getlantern/domainfront v0.0.0-20260419161617-0bff0b2169f4 github.com/getlantern/keepcurrent v0.0.0-20260422161259-54a4d9a93694 github.com/getlantern/kindling v0.0.0-20260507163327-92a44d03bdc5 - github.com/getlantern/lantern-box v0.0.78 + github.com/getlantern/lantern-box v0.0.79 github.com/getlantern/pluriconfig v0.0.0-20251126214241-8cc8bc561535 github.com/getlantern/publicip v0.0.0-20260328175246-2c460fe80c6b github.com/getlantern/semconv v0.0.0-20260327040646-21845dda05cb diff --git a/go.sum b/go.sum index 27cfef3e..c2ac388c 100644 --- a/go.sum +++ b/go.sum @@ -248,8 +248,8 @@ github.com/getlantern/keepcurrent v0.0.0-20260422161259-54a4d9a93694 h1:iLWm6S/4 github.com/getlantern/keepcurrent v0.0.0-20260422161259-54a4d9a93694/go.mod h1:ag5g9aWUw2FJcX5RVRpJ9EBQBy5yJuy2WXDouIn/m4w= github.com/getlantern/kindling v0.0.0-20260507163327-92a44d03bdc5 h1:ukTEQ2S16zMK2BJxIM0qKz+WiiyiPwvmLCWlK1EOvVU= github.com/getlantern/kindling v0.0.0-20260507163327-92a44d03bdc5/go.mod h1:TGTxpoNVwc8Be4qkBNtf5oj2psJaEIZEq47GOPS7zkA= -github.com/getlantern/lantern-box v0.0.78 h1:6P68+v7zukSXs3KFEfqY6iKBtqV3bLCzKouigN4kaw4= -github.com/getlantern/lantern-box v0.0.78/go.mod h1:wJhPQKdnwD6qW/ghAfzsrj/IfHZbvFSAfr52+Tu6dbw= +github.com/getlantern/lantern-box v0.0.79 h1:35NFpHxy5pU7xWX5VDFwoOpvOJ7Z7JG++GW9jaTqEAg= +github.com/getlantern/lantern-box v0.0.79/go.mod h1:wJhPQKdnwD6qW/ghAfzsrj/IfHZbvFSAfr52+Tu6dbw= github.com/getlantern/lantern-water v0.0.0-20260317143726-e0ee64a11d90 h1:P9JX1yAu2uq3b5YiT0sLtHkTrkZuttV8gPZh81nUuag= github.com/getlantern/lantern-water v0.0.0-20260317143726-e0ee64a11d90/go.mod h1:3JpJgwi4KEI6rS9loOAvcBp+F2jP65d0tTg2GQcTPBU= github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534 h1:3BwvWj0JZzFEvNNiMhCu4bf60nqcIuQpTYb00Ezm1ag= From dad58a121727964799a8bd6dec4b9d033c330a0e Mon Sep 17 00:00:00 2001 From: garmr-ulfr <104022054+garmr-ulfr@users.noreply.github.com> Date: Thu, 7 May 2026 10:34:56 -0700 Subject: [PATCH 12/35] feat(cli): add monitor TUI, throughput, and session history (#462) * feat(cli): add monitor TUI, throughput, and session history Adds a `lantern monitor` subcommand showing live VPN status, throughput, recent sessions, and errors, with q/Ctrl-C to quit. Supporting changes: - vpn: throughput tracker (global + per-outbound) and session history recorded across server changes; expose Bytes() and Throughput() on VPNClient - ipc: /vpn/throughput and /vpn/sessions endpoints; map net errors to ErrIPCNotRunning so clients can drive reconnect - cli: --json on status, ip, and servers list; promote servers/private flags to subcommands with positional args; `throughput` subcommand; --level and --grep filters plus reconnect on `logs` - backend: wire SessionHistory into LocalBackend; disconnect VPN on shutdown - AGENTS.md: expand code comment guidance and reinstate Go doc rules * test(vpn): cover session history and throughput tracker; preserve ipc dial error Wrap ErrIPCNotRunning with the underlying connection error so callers can errors.Is the sentinel while still seeing the network failure in the chain. --- AGENTS.md | 92 +++- backend/radiance.go | 48 +- cmd/lantern/format.go | 30 ++ cmd/lantern/ip.go | 11 +- cmd/lantern/lantern.go | 125 +++++- cmd/lantern/monitor.go | 781 +++++++++++++++++++++++++++++++++ cmd/lantern/servers.go | 130 ++++-- cmd/lantern/tty.go | 44 ++ cmd/lantern/vpn.go | 131 +++++- cmd/lantern/watch.go | 142 ++++++ ipc/client.go | 19 + ipc/client_nonmobile.go | 5 +- ipc/server.go | 29 ++ vpn/clash.go | 32 +- vpn/session_history.go | 327 ++++++++++++++ vpn/session_history_test.go | 278 ++++++++++++ vpn/throughput_tracker.go | 143 ++++++ vpn/throughput_tracker_test.go | 131 ++++++ vpn/types.go | 52 ++- vpn/vpn.go | 36 ++ 20 files changed, 2475 insertions(+), 111 deletions(-) create mode 100644 cmd/lantern/format.go create mode 100644 cmd/lantern/monitor.go create mode 100644 cmd/lantern/tty.go create mode 100644 cmd/lantern/watch.go create mode 100644 vpn/session_history.go create mode 100644 vpn/session_history_test.go create mode 100644 vpn/throughput_tracker.go create mode 100644 vpn/throughput_tracker_test.go diff --git a/AGENTS.md b/AGENTS.md index cd599f1a..1f582f0a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,19 +2,37 @@ ## Code Comments -**Default: no comment.** Only add one if a specific *why* is load-bearing — invariant, concurrency guarantee, error condition, zero-value behavior, non-obvious caller contract, or a constraint that would surprise the reader. Aesthetic "this section is well-documented" comments are noise. +**Language doc conventions take precedence.** When writing a doc comment that the language's tooling formats or renders (Go's `// Foo ...`, Python docstrings, JSDoc, rustdoc, etc.), follow that convention even if it conflicts with the "lead with the *why*" guidance — for Go that means start with the identifier's name. The *why* still belongs in the comment, just in the body after the conventional opening. + +- **Default: no comment.** Only comment when necessary to explain a non-obvious contract, invariant, rationale, or surprising behavior. +- Comments must answer *why* something is done a particular way, not *what* is being done (which should be clear from the code and naming). +- Before adding a comment, ask: + - Is this information not obvious from the code or naming? + - Does it document a constraint, invariant, concurrency guarantee, or error condition that would surprise a reader? + - Is it essential for future maintainers to understand the reasoning or risk behind this code? +- **Do not add comments that:** + - Restate the identifier name (in-line only). + - Narrate the next line of code. + - Reference tickets, coworkers, or code locations (these belong in commit messages). + - Describe the mechanism instead of the contract. + - Are aesthetic or redundant ("well-documented"). +- Prefer documenting contracts at the declaration site. Use inline comments only for truly non-obvious lines. +- Remove or update obsolete comments promptly. +- **TODOs:** Must state both what needs to be done and why it isn’t done now. Remove or resolve unclear TODOs. + +**Examples:** -Before writing any comment, run this checklist on the proposed text. If any answer is yes, delete or rewrite: - -1. Does it restate the identifier name or signature? (`// Foo does foo`, `// updateX manages X across Y`) -2. Does it narrate what the visible next line does? (`// Cancel any existing listener` immediately above `cancel()`) -3. Does it open with a generic lifecycle/management preamble before getting to the point? (`// manages the lifecycle of...`, `// handles the X for Y`) -4. Does it reference tickets, coworkers, sibling files, commit SHAs, or other code locations? Those belong in the commit message / PR description — they rot in source. -5. Does it describe the mechanism instead of the contract? (`authenticates via peer credentials over a Unix socket` vs. `authenticates each connection`) +```go +// BAD: Restates what the code does +// Cancel any in-flight requests. +cancelRequests() -Lead with the *why*, not a summary of the function. If the only thing you can write is a summary, the comment isn't needed. +// GOOD: Explains why this is necessary +// Must cancel in-flight requests to avoid leaking goroutines on shutdown. +cancelRequests() +``` -Examples: +--- ```go // BAD — restates name, generic preamble, narrates the code @@ -50,18 +68,60 @@ c.offlineTestCancel() // access is released before disk I/O so a slow write can't starve readers. ``` +```go +// BAD — doc block enumerates every branch; only one branch has hidden why, +// the rest restate cases the code already shows +// mapStatusEvent maps a radiance VPN status event to the wire value sent +// to Dart. Three cases deviate from a direct pass-through: +// - vpn.Restarting collapses into vpn.Connecting so the UI shows a +// transitional state during a tunnel restart. +// - A non-empty evt.Error always maps to vpn.ErrorStatus. +// - An unrecognized status falls back to Disconnected. +func mapStatusEvent(evt vpn.StatusUpdateEvent) (vpn.VPNStatus, string) { ... } + +// GOOD — no doc block; inline comment on the only branch with hidden context +func mapStatusEvent(evt vpn.StatusUpdateEvent) (vpn.VPNStatus, string) { + if evt.Error != "" { + return vpn.ErrorStatus, evt.Error + } + switch evt.Status { + case vpn.Connected, vpn.Connecting, vpn.Disconnecting, vpn.Disconnected, vpn.ErrorStatus: + return evt.Status, "" + case vpn.Restarting: + // Map to Connecting; Dart's parser falls back to Disconnected otherwise. + return vpn.Connecting, "" + default: + return vpn.Disconnected, "" + } +} +``` + Before writing an inline comment, consider whether a doc comment on the enclosing function or type would make it unnecessary. Prefer documenting contracts at the declaration over explaining implementation details inline. +Conversely, before writing a multi-bullet doc block that enumerates branches or cases, check each bullet against the line that implements it. If only one bullet carries hidden *why* and the rest restate visible branches, drop the doc block and put a single inline comment on the surprising branch. Doc blocks belong on contracts that surprise as a whole, not on functions where one corner of the implementation is non-obvious. The bar is higher for unexported helpers: the Go doc convention targets exported API, and unexported functions should default to no comment unless the contract genuinely surprises. + TODO comments must state *what* needs to happen and *why* it isn't done now. `TODO: ???` is not actionable — either resolve it or remove it. +## Go Doc Comments + +- Use Go doc comments (`// Foo ...`) for exported identifiers and any unexported ones with non-obvious contracts. +- Start with the identifier’s name and a concise summary: `// Foo does X.` The first sentence is shown by `go doc` and pkg.go.dev. +- Follow with additional context or rationale as needed, especially if the *why* is not obvious. +- Place the comment immediately above the declaration, with no blank line. +- For package comments, place one above the `package` clause (typically in `doc.go`), starting with `// Package foo ...`. +- Formatting: + - Use blank lines for paragraphs. + - Indent code blocks. + - Use lists and headings as supported by Go doc formatting. + - Avoid HTML and manual line wrapping; let gofmt handle formatting. +- Use `// Deprecated: ...` on its own paragraph for deprecated identifiers. +- Prefer `ExampleFoo` functions in `_test.go` for usage examples; these are rendered and tested by Go tooling. +- Review doc comments regularly to keep them accurate and relevant. + +**Reference:** [Go doc comment guidelines](https://go.dev/doc/comment) + ## Comment Verification After any edit that adds or modifies a comment, you MUST spawn a code-reviewer subagent with the diff before declaring the task done. The subagent applies the Code Comments checklist above and reports violations. Fix the violations and re-spawn until the subagent reports none. You MUST NOT skip this by self-reviewing the diff. The point of the subagent is to review without the generation bias of the Claude that wrote the comment — a self-review by the writer is a known failure mode and does not satisfy this step. - -## Go Doc Comments - -- When a doc comment is warranted on an exported identifier, start it with the identifier's name and use complete sentences: `// Foo does X.` The first sentence is the summary shown by `go doc` and pkg.go.dev. -- Package comments: one per package, above the `package` clause (conventionally in `doc.go` for larger packages), starting with `// Package foo ...`. -- Formatting (gofmt-aware since Go 1.19): blank lines separate paragraphs; indented lines render as code blocks; lines starting with `-`, `*`, or `1.` render as lists; `[Name]` links to other symbols; `# Heading` renders as a heading. Avoid HTML and manual wrapping. diff --git a/backend/radiance.go b/backend/radiance.go index 7fdc9db4..4a8ee317 100644 --- a/backend/radiance.go +++ b/backend/radiance.go @@ -59,6 +59,7 @@ type LocalBackend struct { srvManager *servers.Manager vpnClient *vpn.VPNClient splitTunnelMgr *vpn.SplitTunnel + sessionHistory *vpn.SessionHistory shutdownFuncs []func() error closeOnce sync.Once @@ -180,6 +181,8 @@ func NewLocalBackend(ctx context.Context, opts Options) (*LocalBackend, error) { deviceID: platformDeviceID, dataCapCh: make(chan *account.DataCapInfo, 1), } + r.sessionHistory = vpn.NewSessionHistory(slog.Default().With("service", "session_history"), r.sessionInfo()) + r.shutdownFuncs = append(r.shutdownFuncs, func() error { r.sessionHistory.Close(); return nil }) return r, nil } @@ -208,6 +211,7 @@ func (r *LocalBackend) Start() { } r.startVPNStatusListeners() r.startAutoSelectedListener() + r.startSessionAutoSelectListener() // set country code in settings when new config is received so it can be included in issue reports events.SubscribeOnce(func(evt config.NewConfigEvent) { @@ -320,6 +324,24 @@ func (r *LocalBackend) startVPNStatusListeners() { }) } +func (r *LocalBackend) sessionInfo() vpn.SessionInfo { + return vpn.SessionInfo{ + Status: r.vpnClient.Status, + SelectedServer: func() (tag, city, country string) { + server, _, err := r.SelectedServer() + if err != nil || server == nil { + return "", "", "" + } + return server.Tag, server.Location.City, server.Location.Country + }, + Bytes: r.vpnClient.Bytes, + } +} + +func (r *LocalBackend) Sessions(limit int) []vpn.Session { + return r.sessionHistory.Sessions(limit) +} + ////////////////// // Issue Report // ////////////////// @@ -745,8 +767,7 @@ func (r *LocalBackend) RestartVPN() error { return r.vpnClient.Restart(bOptions) } -// SelectServer selects the server identified by tag. The empty string is -// treated as [vpn.AutoSelectTag]. +// SelectServer selects the server identified by tag. The empty string is treated as [vpn.AutoSelectTag]. func (r *LocalBackend) SelectServer(tag string) error { if tag == "" { tag = vpn.AutoSelectTag @@ -755,6 +776,11 @@ func (r *LocalBackend) SelectServer(tag string) error { return fmt.Errorf("failed to select server: %w", err) } r.persistSelection(tag) + if r.vpnClient.Status() == vpn.Connected { + if sel, _, err := r.SelectedServer(); err == nil && sel != nil { + r.sessionHistory.HandleServerChange(sel.Tag, sel.Location.City, sel.Location.Country) + } + } return nil } @@ -792,6 +818,10 @@ func (r *LocalBackend) VPNConnections() ([]vpn.Connection, error) { return r.vpnClient.Connections() } +func (r *LocalBackend) VPNThroughput() (vpn.ThroughputSnapshot, error) { + return r.vpnClient.Throughput() +} + // ActiveVPNConnections returns a list of currently active connections, ordered from newest to oldest. func (r *LocalBackend) ActiveVPNConnections() ([]vpn.Connection, error) { connections, err := r.vpnClient.Connections() @@ -845,6 +875,20 @@ func (r *LocalBackend) CurrentAutoSelectedServer() (string, error) { return r.vpnClient.CurrentAutoSelectedServer() } +func (r *LocalBackend) startSessionAutoSelectListener() { + events.SubscribeContext(r.ctx, func(evt vpn.AutoSelectedEvent) { + if evt.Selected == "" || r.vpnClient.Status() != vpn.Connected { + return + } + var city, country string + if server, found := r.srvManager.GetServerByTag(evt.Selected); found { + city = server.Location.City + country = server.Location.Country + } + r.sessionHistory.HandleServerChange(evt.Selected, city, country) + }) +} + func (r *LocalBackend) startAutoSelectedListener() { var ( mu sync.Mutex diff --git a/cmd/lantern/format.go b/cmd/lantern/format.go new file mode 100644 index 00000000..3c94f042 --- /dev/null +++ b/cmd/lantern/format.go @@ -0,0 +1,30 @@ +package main + +import ( + "fmt" + "slices" + "strings" +) + +func formatBytes(b int64) string { + const ( + kib = 1024 + mib = kib * 1024 + gib = mib * 1024 + ) + switch { + case b >= gib: + return fmt.Sprintf("%6.2f GiB", float64(b)/gib) + case b >= mib: + return fmt.Sprintf("%6.2f MiB", float64(b)/mib) + case b >= kib: + return fmt.Sprintf("%6.2f KiB", float64(b)/kib) + default: + return fmt.Sprintf("%6d B ", b) + } +} + +func joinNonEmpty(sep string, parts ...string) string { + out := slices.DeleteFunc(parts, func(p string) bool { return p == "" }) + return strings.Join(out, sep) +} diff --git a/cmd/lantern/ip.go b/cmd/lantern/ip.go index 5e40b38d..f3a3217b 100644 --- a/cmd/lantern/ip.go +++ b/cmd/lantern/ip.go @@ -33,15 +33,22 @@ func init() { } } -type IPCmd struct{} +type IPCmd struct { + JSON bool `arg:"--json" help:"output JSON"` +} -func runIP(ctx context.Context) error { +func runIP(ctx context.Context, cmd *IPCmd) error { tctx, tcancel := context.WithTimeout(ctx, 10*time.Second) defer tcancel() ip, err := getPublicIP(tctx) if err != nil { return err } + if cmd.JSON { + return printJSON(struct { + IP string `json:"ip"` + }{IP: ip}) + } fmt.Println(ip) return nil } diff --git a/cmd/lantern/lantern.go b/cmd/lantern/lantern.go index 25ac2c3a..c6fa85f0 100644 --- a/cmd/lantern/lantern.go +++ b/cmd/lantern/lantern.go @@ -2,10 +2,15 @@ package main import ( "encoding/json" + "errors" "fmt" + "log/slog" "os" "os/signal" + "regexp" + "strings" "syscall" + "time" "context" @@ -22,15 +27,17 @@ type args struct { Disconnect *DisconnectCmd `arg:"subcommand:disconnect" help:"disconnect VPN"` Status *StatusCmd `arg:"subcommand:status" help:"show VPN status"` Servers *ServersCmd `arg:"subcommand:servers" help:"manage servers"` - Features *FeaturesCmd `arg:"subcommand:features" help:"list available features and their status"` Set *SetCmd `arg:"subcommand:set" help:"update one or more settings"` Get *GetCmd `arg:"subcommand:get" help:"show one or all settings"` - UpdateConfig *UpdateConfigCmd `arg:"subcommand:update-config" help:"force an immediate config fetch"` SplitTunnel *SplitTunnelCmd `arg:"subcommand:split-tunnel" help:"split-tunnel filter management"` + Features *FeaturesCmd `arg:"subcommand:features" help:"list available features and their status"` Account *AccountCmd `arg:"subcommand:account" help:"login, signup, user data, devices, recovery"` Subscription *SubscriptionCmd `arg:"subcommand:subscription" help:"plans, payments, and billing"` ReportIssue *ReportIssueCmd `arg:"subcommand:report-issue" help:"report an issue"` - Logs *LogsCmd `arg:"subcommand:logs" help:"tail daemon logs"` + Throughput *ThroughputCmd `arg:"subcommand:throughput" help:"show throughput, globally and per outbound"` + Monitor *MonitorCmd `arg:"subcommand:monitor" help:"watch status, throughput, settings, recent history and errors; press q or Ctrl-C to quit"` + Logs *LogsCmd `arg:"subcommand:logs" help:"tail daemon logs; press q or Ctrl-C to quit"` + UpdateConfig *UpdateConfigCmd `arg:"subcommand:update-config" help:"force an immediate config fetch"` IP *IPCmd `arg:"subcommand:ip" help:"show public IP address"` Version *VersionCmd `arg:"subcommand:version" help:"print version"` } @@ -49,22 +56,106 @@ func runReportIssue(ctx context.Context, c *ipc.Client, cmd *ReportIssueCmd) err return c.ReportIssue(ctx, issue.IssueType(cmd.Type), cmd.Description, cmd.Email, nil) } -type LogsCmd struct{} +type LogsCmd struct { + Level string `arg:"--level" help:"only show entries at this level or higher (trace|debug|info|warn|error|fatal|panic)"` + Grep string `arg:"--grep" help:"regex; only show entries that match"` + ReconnectTimeout time.Duration `arg:"--reconnect-timeout" default:"60s" help:"retry the daemon for this long after it goes away (0 disables retry)"` +} -func tailLogs(ctx context.Context, c *ipc.Client) error { - err := c.TailLogs(ctx, func(entry rlog.LogEntry) { - fmt.Println(entry) - }) - if ctx.Err() != nil { - fmt.Fprintln(os.Stderr, "\nStopped tailing logs.") - return nil +// tailLogs streams log entries from the daemon, with optional filtering and reconnect logic. +func tailLogs(ctx context.Context, c *ipc.Client, cmd *LogsCmd) error { + ctx, cleanup := quitOnKey(ctx) + defer cleanup() + + var levelMin slog.Level + levelSet := false + if cmd.Level != "" { + lvl, err := rlog.ParseLogLevel(cmd.Level) + if err != nil { + return err + } + levelMin = lvl + levelSet = true + } + var grepRE *regexp.Regexp + if cmd.Grep != "" { + re, err := regexp.Compile(cmd.Grep) + if err != nil { + return fmt.Errorf("invalid --grep regex: %w", err) + } + grepRE = re + } + + st := newReconnect(cmd.ReconnectTimeout) + handler := func(entry rlog.LogEntry) { + st.onSuccess() + if levelSet && !logEntryMeetsLevel(entry, levelMin) { + return + } + if grepRE != nil && !grepRE.MatchString(entry) { + return + } + fmt.Printf("%s\r\n", entry) + } + + for { + err := c.TailLogs(ctx, handler) + if ctx.Err() != nil { + st.abandon() + fmt.Fprint(os.Stderr, "\r\nStopped tailing logs.\r\n") + return nil + } + if err == nil { + // We connected even if no entries arrived, so the reconnect window has to reset + // before we map nil → ErrIPCNotRunning to drive the next retry. + st.onSuccess() + err = ipc.ErrIPCNotRunning + } + if !errors.Is(err, ipc.ErrIPCNotRunning) { + st.abandon() + return err + } + wait := st.onError() + if wait <= 0 { + st.abandon() + return fmt.Errorf("daemon unreachable: %w", err) + } + if err := st.waitForRetry(ctx, wait); err != nil { + st.abandon() + fmt.Fprint(os.Stderr, "\r\nStopped tailing logs.\r\n") + return nil + } + select { + case <-ctx.Done(): + return nil + default: + } + } +} + +// logEntryMeetsLevel checks whether the log entry has a level at least as high as the specified +// minimum. Lines without a parseable level=... attr are passed through, not filtered out. Callers +// should not assume `false` means "below min". +func logEntryMeetsLevel(entry string, min slog.Level) bool { + _, rest, fnd := strings.Cut(entry, "level=") + if !fnd { + return true } - return err + end := strings.IndexAny(rest, " \t") + if end < 0 { + end = len(rest) + } + lvlStr := rest[:end] + lvl, err := rlog.ParseLogLevel(lvlStr) + return err != nil || lvl >= min } type VersionCmd struct{} func main() { + // Watch-mode TUI frames are corrupted by stray library slog output on stderr. + slog.SetDefault(slog.New(slog.DiscardHandler)) + var a args p := arg.MustParse(&a) if p.Subcommand() == nil { @@ -92,7 +183,9 @@ func run(ctx context.Context, c *ipc.Client, a *args) error { case a.Disconnect != nil: return c.DisconnectVPN(ctx) case a.Status != nil: - return vpnStatus(ctx, c) + return vpnStatus(ctx, c, a.Status) + case a.Throughput != nil: + return vpnThroughput(ctx, c, a.Throughput) case a.Servers != nil: return runServers(ctx, c, a.Servers) case a.Features != nil: @@ -111,10 +204,12 @@ func run(ctx context.Context, c *ipc.Client, a *args) error { return runSubscription(ctx, c, a.Subscription) case a.ReportIssue != nil: return runReportIssue(ctx, c, a.ReportIssue) + case a.Monitor != nil: + return runMonitor(ctx, c, a.Monitor) case a.Logs != nil: - return tailLogs(ctx, c) + return tailLogs(ctx, c, a.Logs) case a.IP != nil: - return runIP(ctx) + return runIP(ctx, a.IP) case a.Version != nil: fmt.Println(common.Version) return nil diff --git a/cmd/lantern/monitor.go b/cmd/lantern/monitor.go new file mode 100644 index 00000000..3b38502a --- /dev/null +++ b/cmd/lantern/monitor.go @@ -0,0 +1,781 @@ +package main + +import ( + "context" + "fmt" + "io" + "os" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + "unicode/utf8" + + "golang.org/x/term" + + "github.com/getlantern/radiance/account" + "github.com/getlantern/radiance/common" + "github.com/getlantern/radiance/common/settings" + "github.com/getlantern/radiance/ipc" + rlog "github.com/getlantern/radiance/log" + "github.com/getlantern/radiance/servers" + "github.com/getlantern/radiance/vpn" +) + +const ( + ansiCursorHome = "\033[H" + ansiClearToEOL = "\033[K" + ansiClearBelow = "\033[J" + ansiHideCursor = "\033[?25l" + ansiShowCursor = "\033[?25h" + ansiAltScreen = "\033[?1049h" + ansiMainScreen = "\033[?1049l" + eol = ansiClearToEOL + "\r\n" +) + +type MonitorCmd struct { + Interval time.Duration `arg:"-i,--interval" default:"1s" help:"refresh interval"` + Pool int `arg:"--pool" default:"5" help:"number of fastest servers to list; 0 to omit pool summary"` + History int `arg:"--history" default:"3" help:"number of recent sessions to include; 0 to omit"` + Logs int `arg:"--logs" default:"5" help:"number of recent warn/error log entries to display (totals always shown); 0 hides entries"` + JSON bool `arg:"--json" help:"emit one JSON snapshot per refresh"` + ReconnectTimeout time.Duration `arg:"--reconnect-timeout" default:"60s" help:"retry the daemon for this long after it goes away (0 disables retry)"` +} + +type monitorSnapshot struct { + Version string `json:"version"` + DeviceID string `json:"device_id,omitempty"` + UserID string `json:"user_id,omitempty"` + Pro bool `json:"pro"` + Status statusSnapshot `json:"status"` + Throughput vpn.ThroughputSnapshot `json:"throughput"` + DataCap *account.DataCapInfo `json:"data_cap,omitempty"` + DataCapStreaming bool `json:"data_cap_streaming"` + DataCapAgeMs int64 `json:"data_cap_age_ms,omitempty"` + Settings map[string]any `json:"settings"` + History []vpn.Session `json:"history,omitempty"` + ServerPool *poolSummary `json:"server_pool,omitempty"` + RecentLogs []logEvent `json:"recent_logs"` + LogCounts logCounts `json:"log_counts"` +} + +type logCounts struct { + Warn int `json:"warn"` + Error int `json:"error"` +} + +type poolSummary struct { + Total int `json:"total"` + Tested int `json:"tested"` + Fastest []serverLatency `json:"fastest,omitempty"` +} + +type serverLatency struct { + Tag string `json:"tag"` + Type string `json:"type,omitempty"` + Location string `json:"location,omitempty"` + DelayMs uint16 `json:"delay_ms"` + TestedAt time.Time `json:"tested_at"` +} + +type logEvent struct { + Level string `json:"level"` + Pkg string `json:"pkg,omitempty"` + Src string `json:"src,omitempty"` + Msg string `json:"msg"` + First time.Time `json:"first"` + Last time.Time `json:"last"` + Count int `json:"count"` +} + +func runMonitor(ctx context.Context, c *ipc.Client, cmd *MonitorCmd) error { + interval := cmd.Interval + if interval <= 0 { + interval = time.Second + } + + ctx, cleanup := quitOnKey(ctx) + defer cleanup() + + tty := !cmd.JSON && stdoutIsTTY() + if tty { + // Use the alternate screen buffer so we don't mess with the user's scrollback, and hide the + // cursor since it would be distracting when refreshing the screen. + fmt.Print(ansiAltScreen + ansiHideCursor) + defer fmt.Print(ansiShowCursor + ansiMainScreen) + } + + state := newMonitorState(cmd.Logs) + go state.streamDataCap(ctx, c) + go state.tailLogs(ctx, c) + + st := newReconnect(cmd.ReconnectTimeout) + refresh := func() error { + var snap monitorSnapshot + err := callWithReconnect(ctx, st, func() error { + return fetchMonitor(ctx, c, cmd, &snap) + }) + if err != nil { + return err + } + state.fillSnapshot(&snap, cmd.Logs) + if cmd.JSON { + return printJSON(snap) + } + width, height := 0, 0 + if tty { + if w, h, err := term.GetSize(int(os.Stdout.Fd())); err == nil { + width, height = w, h + } + } + var b strings.Builder + b.WriteString(ansiCursorHome) + renderMonitor(&b, &snap, width) + b.WriteString(ansiClearBelow) + out := b.String() + if height > 0 { + out = clipToHeight(out, height, width) + } + _, _ = io.WriteString(os.Stdout, out) + return nil + } + + if err := refresh(); err != nil { + if ctx.Err() != nil { + return nil + } + return err + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + } + if err := refresh(); err != nil { + if ctx.Err() != nil { + return nil + } + return err + } + } +} + +func fetchMonitor(ctx context.Context, c *ipc.Client, cmd *MonitorCmd, snap *monitorSnapshot) error { + snap.Version = common.Version + + s, err := fetchStatus(ctx, c) + if err != nil { + return err + } + snap.Status = s + + tp, err := c.VPNThroughput(ctx) + if err != nil { + return err + } + snap.Throughput = tp + + cfg, err := c.Settings(ctx) + if err != nil { + return err + } + snap.Settings = make(map[string]any, len(settingNames)) + for _, name := range settingNames { + if v, ok := settingValue(name, cfg); ok { + snap.Settings[name] = v + } + } + if uid := cfg[settings.UserIDKey]; uid != nil { + if v, ok := uid.(float64); ok { + snap.UserID = strconv.FormatInt(int64(v), 10) + } else { + snap.UserID = fmt.Sprintf("%v", uid) + } + } + if did := cfg[settings.DeviceIDKey]; did != nil { + snap.DeviceID = fmt.Sprintf("%v", did) + } + snap.Pro = strings.EqualFold(fmt.Sprintf("%v", cfg[settings.UserLevelKey]), "pro") + + if cmd.History > 0 { + h, err := c.VPNSessions(ctx, cmd.History) + if err != nil { + return err + } + snap.History = h + } + if cmd.Pool > 0 { + srvs, err := c.Servers(ctx) + if err != nil { + return err + } + snap.ServerPool = summarizePool(srvs, cmd.Pool) + } + return nil +} + +func summarizePool(srvs []*servers.Server, top int) *poolSummary { + out := &poolSummary{Total: len(srvs)} + tested := make([]serverLatency, 0, len(srvs)) + for _, s := range srvs { + if s == nil || s.URLTestResult == nil { + continue + } + tested = append(tested, serverLatency{ + Tag: s.Tag, + Type: s.Type, + Location: joinNonEmpty(", ", s.Location.City, s.Location.Country), + DelayMs: s.URLTestResult.Delay, + TestedAt: s.URLTestResult.Time, + }) + } + out.Tested = len(tested) + sort.Slice(tested, func(i, j int) bool { return tested[i].DelayMs < tested[j].DelayMs }) + if top > len(tested) { + top = len(tested) + } + out.Fastest = tested[:top] + return out +} + +func renderMonitor(w io.Writer, snap *monitorSnapshot, width int) { + tier := "free" + if snap.Pro { + tier = "pro" + } + user := "—" + if snap.UserID != "" { + user = snap.UserID + } + fmt.Fprintf(w, "Lantern v%s — user %s (%s)%s", snap.Version, user, tier, eol) + if snap.DeviceID != "" { + fmt.Fprintf(w, "Device: %s%s", snap.DeviceID, eol) + } + io.WriteString(w, eol) + + status := string(snap.Status.Status) + if status != "" { + status = strings.ToUpper(status[:1]) + status[1:] + } + fmt.Fprintf(w, "Status: %s%s", status, eol) + if snap.Status.Server != "" { + line := " Server: " + formatTag(snap.Status.Server) + if snap.Status.Location != "" { + line += " (" + snap.Status.Location + ")" + } + if snap.Status.LatencyMs > 0 { + line += fmt.Sprintf(" — %dms", snap.Status.LatencyMs) + } + fmt.Fprintf(w, "%s%s", line, eol) + } + if snap.Status.IP != "" { + fmt.Fprintf(w, " IP: %s%s", snap.Status.IP, eol) + } + if cur := currentSession(snap); cur != nil { + fmt.Fprintf(w, " Session: ↓ %s ↑ %s (%s)%s", + formatBytes(cur.BytesDown), formatBytes(cur.BytesUp), + cur.Duration().Truncate(time.Second), eol) + } + io.WriteString(w, eol) + + renderDataCap(w, snap) + + fmt.Fprintf(w, "Throughput:%s", eol) + fmt.Fprintf(w, " Global ↓ %s ↑ %s (%d active)%s", + formatBitsPerSec(snap.Throughput.Global.Down), + formatBitsPerSec(snap.Throughput.Global.Up), + snap.Throughput.ActiveConnections, eol) + tags := outboundTags(snap.Throughput) + for _, tag := range tags { + sp := snap.Throughput.PerOutbound[tag] + name := formatTag(tag) + if name == "" { + name = "(unrouted)" + } + fmt.Fprintf(w, " %-30s ↓ %s ↑ %s (%d active)%s", + name, formatBitsPerSec(sp.Down), formatBitsPerSec(sp.Up), + snap.Throughput.ActivePerOutbound[tag], eol) + } + io.WriteString(w, eol) + + renderSettings(w, snap.Settings, width) + + renderServerPool(w, snap.ServerPool) + + if len(snap.History) > 0 { + fmt.Fprintf(w, "Recent sessions:%s", eol) + for _, s := range snap.History { + fmt.Fprintf(w, " %s%s", formatSessionLine(s), eol) + if s.Error != "" { + fmt.Fprintf(w, " error: %s%s", s.Error, eol) + } + } + io.WriteString(w, eol) + } + + renderRecentLogs(w, snap.RecentLogs, snap.LogCounts) + + fmt.Fprintf(w, "(press q to quit)%s", eol) +} + +func renderDataCap(w io.Writer, snap *monitorSnapshot) { + dc := snap.DataCap + if dc != nil && dc.Enabled && dc.Usage != nil { + used, _ := strconv.ParseInt(dc.Usage.BytesUsed, 10, 64) + allotted, _ := strconv.ParseInt(dc.Usage.BytesAllotted, 10, 64) + line := fmt.Sprintf("Data cap: %s / %s used", formatBytes(used), formatBytes(allotted)) + if snap.DataCapStreaming { + age := time.Duration(snap.DataCapAgeMs) * time.Millisecond + if age > 30*time.Second { + line += fmt.Sprintf(" (last update %s ago)", age.Truncate(time.Second)) + } + } + fmt.Fprintf(w, "%s%s", line, eol) + if t, err := time.Parse(time.RFC3339, dc.Usage.AllotmentEndTime); err == nil { + fmt.Fprintf(w, " resets %s%s", t.Local().Format("2006-01-02 15:04"), eol) + } + } else { + fmt.Fprintf(w, "Data cap: no samples yet%s", eol) + } + io.WriteString(w, eol) +} + +func renderSettings(w io.Writer, s map[string]any, width int) { + if len(s) == 0 { + return + } + keys := make([]string, 0, len(s)) + for k := range s { + keys = append(keys, k) + } + sort.Strings(keys) + items := make([]string, len(keys)) + maxLen := 0 + for i, k := range keys { + items[i] = fmt.Sprintf("%s: %v", k, s[k]) + if l := len(items[i]); l > maxLen { + maxLen = l + } + } + const indent, gap = " ", " " + cellWidth := maxLen + len(gap) + cols := 1 + if avail := width - len(indent); avail > cellWidth { + cols = avail / cellWidth + } + + fmt.Fprintf(w, "Settings:%s", eol) + for i, item := range items { + if i%cols == 0 { + io.WriteString(w, indent) + } + endOfRow := (i+1)%cols == 0 || i == len(items)-1 + if endOfRow { + io.WriteString(w, item) + io.WriteString(w, eol) + } else { + fmt.Fprintf(w, "%-*s", cellWidth, item) + } + } + io.WriteString(w, eol) +} + +func renderServerPool(w io.Writer, p *poolSummary) { + if p == nil || p.Total == 0 { + return + } + fmt.Fprintf(w, "Server pool: %d total, %d with recent test%s", p.Total, p.Tested, eol) + now := time.Now() + for _, s := range p.Fastest { + name := formatTag(s.Tag) + if s.Location != "" { + name = fmt.Sprintf("%s [%s]", name, s.Location) + } + age := "—" + if !s.TestedAt.IsZero() { + age = now.Sub(s.TestedAt).Truncate(time.Second).String() + " ago" + } + fmt.Fprintf(w, " %5dms %s (tested %s)%s", s.DelayMs, name, age, eol) + } + io.WriteString(w, eol) +} + +func renderRecentLogs(w io.Writer, logs []logEvent, counts logCounts) { + fmt.Fprintf(w, "Recent warn/error logs: %d warn, %d error%s", counts.Warn, counts.Error, eol) + if len(logs) == 0 { + fmt.Fprintf(w, " (none)%s", eol) + io.WriteString(w, eol) + return + } + for _, e := range logs { + when := e.Last.Local().Format("15:04:05") + count := "" + if e.Count > 1 { + count = fmt.Sprintf(" (×%d)", e.Count) + } + src := e.Pkg + if e.Src != "" { + if src != "" { + src += " " + e.Src + } else { + src = e.Src + } + } + if src != "" { + src = " [" + src + "]" + } + fmt.Fprintf(w, " %s %-5s%s %s%s%s", when, e.Level, src, e.Msg, count, eol) + } + io.WriteString(w, eol) +} + +func formatSessionLine(s vpn.Session) string { + when := s.ConnectedAt.Local().Format("15:04:05") + dur := s.Duration().Truncate(time.Second) + status := "ended" + if s.DisconnectedAt.IsZero() { + status = "active" + } + srv := formatTag(s.Server.Tag) + if srv == "" { + srv = "(auto)" + } + if loc := joinNonEmpty(", ", s.Server.City, s.Server.Country); loc != "" { + srv = fmt.Sprintf("%s [%s]", srv, loc) + } + return fmt.Sprintf("%s %-9s %-6s ↓ %s ↑ %s %s", + when, dur, status, formatBytes(s.BytesDown), formatBytes(s.BytesUp), srv) +} + +func currentSession(snap *monitorSnapshot) *vpn.Session { + if snap.Status.Status != vpn.Connected || len(snap.History) == 0 { + return nil + } + first := snap.History[0] + if !first.DisconnectedAt.IsZero() { + return nil + } + return &first +} + +// clipToHeight trims the rendered frame to at most h visual rows so the cursor +// stays within the viewport. The alt screen has no scrollback, so any line that +// would push the cursor past the bottom permanently drops the topmost row. +// +// Lines wider than width wrap and consume multiple visual rows, so naive newline +// counting under-counts when wrapping is on (the case here, which we keep so log +// messages stay readable). +func clipToHeight(s string, h, width int) string { + if h <= 0 { + return s + } + suffix := "" + if strings.HasSuffix(s, ansiClearBelow) { + s = s[:len(s)-len(ansiClearBelow)] + suffix = ansiClearBelow + } + dropFromLine := func(lineStart int) string { + if lineStart == 0 { + return suffix + } + // Drop the \n preceding this line so the cursor lands at the end of the + // previous line rather than at the start of an empty next row. + return s[:lineStart-1] + suffix + } + visual := 0 + lineStart := 0 + for i := 0; i < len(s); i++ { + if s[i] != '\n' { + continue + } + rows := visualRows(s[lineStart:i], width) + // Including the trailing \n moves the cursor down one extra row, so the + // budget for "line + \n" is h-1 rows total (cursor lands at row h). + if visual+rows > h-1 { + // If the line content fits without its trailing \n (cursor stops at + // end of last wrap row), keep it as the final visible line. + if visual+rows <= h { + return s[:i] + suffix + } + return dropFromLine(lineStart) + } + visual += rows + lineStart = i + 1 + } + if lineStart < len(s) { + rows := visualRows(s[lineStart:], width) + if visual+rows > h { + return dropFromLine(lineStart) + } + } + return s + suffix +} + +// visualRows returns the number of terminal rows a line occupies after wrapping +// at width. width <= 0 disables wrap accounting (one row per line). +func visualRows(line string, width int) int { + if width <= 0 { + return 1 + } + n := visualWidth(line) + if n == 0 { + return 1 + } + return (n + width - 1) / width +} + +// visualWidth returns the rendered column count of line. Each rune counts as +// one column — close enough for the ASCII + light-Unicode (↓ ↑ — ×) content +// this dashboard renders; full wcwidth would be overkill. +func visualWidth(line string) int { + n := 0 + i := 0 + for i < len(line) { + c := line[i] + switch { + case c == 0x1b && i+1 < len(line) && line[i+1] == '[': + i += 2 + for i < len(line) { + t := line[i] + i++ + if t >= 0x40 && t <= 0x7e { + break + } + } + case c == '\r': + i++ + case c < 0x80: + n++ + i++ + default: + _, size := utf8.DecodeRuneInString(line[i:]) + if size <= 0 { + size = 1 + } + n++ + i += size + } + } + return n +} + +var tagUUID = regexp.MustCompile(`([0-9a-f]{8})-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-([0-9a-f]{12})`) + +func formatTag(tag string) string { + if i := strings.Index(tag, "-out-"); i > 0 { + proto := tag[:i] + if rest := tag[i+len("-out-"):]; strings.HasPrefix(rest, proto+"-") { + tag = rest + } + } + return tagUUID.ReplaceAllString(tag, "$1-...-$2") +} + +func outboundTags(s vpn.ThroughputSnapshot) []string { + set := make(map[string]struct{}, len(s.PerOutbound)+len(s.ActivePerOutbound)) + for tag := range s.PerOutbound { + set[tag] = struct{}{} + } + for tag := range s.ActivePerOutbound { + set[tag] = struct{}{} + } + tags := make([]string, 0, len(set)) + for tag := range set { + tags = append(tags, tag) + } + sort.Strings(tags) + return tags +} + +type monitorState struct { + mu sync.Mutex + dataCap atomic.Pointer[account.DataCapInfo] + dataCapAt atomic.Int64 // unix nanoseconds of last update; 0 if never + logCapacity int + logs []logEvent + warnTotal atomic.Int64 + errorTotal atomic.Int64 +} + +func newMonitorState(logCapacity int) *monitorState { + return &monitorState{logCapacity: logCapacity} +} + +func (s *monitorState) setDataCap(info account.DataCapInfo) { + cp := info + s.dataCap.Store(&cp) + s.dataCapAt.Store(time.Now().UnixNano()) +} + +func (s *monitorState) fillSnapshot(snap *monitorSnapshot, logLimit int) { + snap.DataCap = s.dataCap.Load() + if at := s.dataCapAt.Load(); at != 0 { + snap.DataCapStreaming = true + snap.DataCapAgeMs = time.Since(time.Unix(0, at)).Milliseconds() + } + snap.LogCounts = logCounts{ + Warn: int(s.warnTotal.Load()), + Error: int(s.errorTotal.Load()), + } + if logLimit <= 0 { + return + } + s.mu.Lock() + defer s.mu.Unlock() + if len(s.logs) == 0 { + return + } + out := make([]logEvent, len(s.logs)) + copy(out, s.logs) + sort.Slice(out, func(i, j int) bool { return out[i].Last.After(out[j].Last) }) + if logLimit < len(out) { + out = out[:logLimit] + } + snap.RecentLogs = out +} + +func (s *monitorState) streamDataCap(ctx context.Context, c *ipc.Client) { + for ctx.Err() == nil { + _ = c.DataCapStream(ctx, s.setDataCap) + if ctx.Err() != nil { + return + } + select { + case <-ctx.Done(): + return + case <-time.After(time.Second): + } + } +} + +func (s *monitorState) tailLogs(ctx context.Context, c *ipc.Client) { + for ctx.Err() == nil { + _ = c.TailLogs(ctx, func(entry rlog.LogEntry) { + if evt, ok := parseLogEvent(entry); ok { + s.recordLog(evt) + } + }) + if ctx.Err() != nil { + return + } + select { + case <-ctx.Done(): + return + case <-time.After(time.Second): + } + } +} + +func (s *monitorState) recordLog(evt logEvent) { + switch evt.Level { + case "WARN": + s.warnTotal.Add(1) + case "ERROR": + s.errorTotal.Add(1) + } + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.logs { + e := &s.logs[i] + if e.Level == evt.Level && e.Pkg == evt.Pkg && e.Msg == evt.Msg { + e.Last = evt.Last + e.Count++ + return + } + } + if s.logCapacity > 0 && len(s.logs) >= s.logCapacity*4 { + // Cap distinct entries at 4× display so a flood of unique messages + // can't grow the slice unbounded. + oldestIdx := 0 + for i := range s.logs { + if s.logs[i].Last.Before(s.logs[oldestIdx].Last) { + oldestIdx = i + } + } + s.logs = append(s.logs[:oldestIdx], s.logs[oldestIdx+1:]...) + } + s.logs = append(s.logs, evt) +} + +var ( + logKeyTimeQuoted = regexp.MustCompile(`time="([^"]+)"`) + logKeyTimeBare = regexp.MustCompile(`(?:^|\s)time=(\S+)`) + logKeyLevel = regexp.MustCompile(`level=(\w+)`) + logKeyPkg = regexp.MustCompile(`pkg=(\S+)`) + logKeySrcFile = regexp.MustCompile(`source\.file=(\S+)`) + logKeyMsgQuoted = regexp.MustCompile(`msg="((?:[^"\\]|\\.)*)"`) + logKeyMsgBare = regexp.MustCompile(`msg=(\S+)`) +) + +func parseLogEvent(line string) (logEvent, bool) { + m := logKeyLevel.FindStringSubmatch(line) + if m == nil { + return logEvent{}, false + } + level := strings.ToUpper(m[1]) + if level != "WARN" && level != "WARNING" && level != "ERROR" { + return logEvent{}, false + } + if level == "WARNING" { + level = "WARN" + } + evt := logEvent{Level: level, Count: 1} + if m = logKeyMsgQuoted.FindStringSubmatch(line); m != nil { + evt.Msg = unescapeQuoted(m[1]) + } else if m = logKeyMsgBare.FindStringSubmatch(line); m != nil { + evt.Msg = m[1] + } + if m = logKeyPkg.FindStringSubmatch(line); m != nil { + evt.Pkg = m[1] + } + if m = logKeySrcFile.FindStringSubmatch(line); m != nil { + evt.Src = m[1] + } + ts := time.Now() + if m = logKeyTimeQuoted.FindStringSubmatch(line); m != nil { + if t, ok := parseLogTime(m[1]); ok { + ts = t + } + } else if m = logKeyTimeBare.FindStringSubmatch(line); m != nil { + if t, ok := parseLogTime(m[1]); ok { + ts = t + } + } + evt.First = ts + evt.Last = ts + return evt, true +} + +func parseLogTime(s string) (time.Time, bool) { + for _, layout := range []string{ + "2006-01-02 15:04:05.000 MST", + "2006-01-02T15:04:05.000Z07:00", + time.RFC3339Nano, + time.RFC3339, + } { + if t, err := time.Parse(layout, s); err == nil { + return t, true + } + } + return time.Time{}, false +} + +func unescapeQuoted(s string) string { + var b strings.Builder + b.Grow(len(s)) + for i := 0; i < len(s); i++ { + if s[i] == '\\' && i+1 < len(s) { + b.WriteByte(s[i+1]) + i++ + continue + } + b.WriteByte(s[i]) + } + return b.String() +} diff --git a/cmd/lantern/servers.go b/cmd/lantern/servers.go index 920110db..f592e0a7 100644 --- a/cmd/lantern/servers.go +++ b/cmd/lantern/servers.go @@ -13,69 +13,126 @@ import ( ) type ServersCmd struct { - Show string `arg:"-s,--show" help:"display server by tag"` - AddJSON string `arg:"--add-json" help:"add servers from JSON config"` - AddURL string `arg:"--add-url" help:"add servers from comma-separated URLs"` - SkipCertVerify bool `arg:"--skip-cert-verify" help:"skip cert verification (with --add-url)"` - Remove string `arg:"--remove" help:"comma-separated list of servers to remove"` - List bool `arg:"-l,--list" help:"list servers"` - Latency bool `arg:"--latency" help:"include URL test latency results (with --list)"` + List *ServersListCmd `arg:"subcommand:list" help:"list servers"` + Show *ServersShowCmd `arg:"subcommand:show" help:"display server by tag"` + AddJSON *ServersAddJSONCmd `arg:"subcommand:add-json" help:"add servers from JSON config"` + AddURL *ServersAddURLCmd `arg:"subcommand:add-url" help:"add servers from URLs"` + Remove *ServersRemoveCmd `arg:"subcommand:remove" help:"remove servers by tag"` + PrivateServer *PrivateServerCmd `arg:"subcommand:private" help:"private server operations"` +} + +type ServersListCmd struct { + Latency bool `arg:"--latency" help:"include URL test latency results"` + JSON bool `arg:"--json" help:"output JSON"` +} + +type ServersShowCmd struct { + Tag string `arg:"positional,required" help:"server tag"` +} + +type ServersAddJSONCmd struct { + Config string `arg:"positional,required" help:"JSON config"` +} + +type ServersAddURLCmd struct { + URLs []string `arg:"positional,required" help:"server URLs"` + SkipCertVerify bool `arg:"--skip-cert-verify" help:"skip cert verification"` +} - PrivateServer *PrivateServerCmd `arg:"subcommand:private" help:"private server operations"` +type ServersRemoveCmd struct { + Tags []string `arg:"positional,required" help:"server tags to remove"` +} + +// ServerListEntry represents a server in the list output. +type ServerListEntry struct { + Tag string `json:"tag"` + Type string `json:"type"` + Location C.ServerLocation `json:"location,omitempty"` + URLTestResult *servers.URLTestResult `json:"urlTestResult,omitempty"` } type PrivateServerCmd struct { - Add string `arg:"-a,--add" help:"add private server with given tag"` - Invite string `arg:"-i,--invite" help:"invite to private server"` - RevokeInvite string `arg:"-r,--revoke-invite" help:"revoke invite"` - IP string `arg:"--ip" help:"server IP"` - Port int `arg:"--port" help:"server port"` - Token string `arg:"--token" help:"access token"` + Add *PrivateServerAddCmd `arg:"subcommand:add" help:"add a private server"` + Invite *PrivateServerInviteCmd `arg:"subcommand:invite" help:"create an invite for a private server"` + RevokeInvite *PrivateServerRevokeInviteCmd `arg:"subcommand:revoke-invite" help:"revoke a private server invite"` +} + +// PrivateServerConn holds connection parameters for a private server. +type PrivateServerConn struct { + IP string `arg:"--ip,required" help:"server IP"` + Port int `arg:"--port,required" help:"server port"` + Token string `arg:"--token,required" help:"access token"` +} + +type PrivateServerAddCmd struct { + Tag string `arg:"positional,required" help:"tag to assign to the server"` + PrivateServerConn +} + +type PrivateServerInviteCmd struct { + Name string `arg:"positional,required" help:"invitee name"` + PrivateServerConn +} + +type PrivateServerRevokeInviteCmd struct { + Name string `arg:"positional,required" help:"invitee name to revoke"` + PrivateServerConn } func runServers(ctx context.Context, c *ipc.Client, cmd *ServersCmd) error { switch { - case cmd.Show != "": - return serversGet(ctx, c, cmd.Show) - case cmd.AddJSON != "": - return printAddedServers(c.AddServersByJSON(ctx, cmd.AddJSON)) - case cmd.AddURL != "": - urls := strings.Split(cmd.AddURL, ",") - return printAddedServers(c.AddServersByURL(ctx, urls, cmd.SkipCertVerify)) - case cmd.Remove != "": - return serversRemove(ctx, c, cmd.Remove) - case cmd.List: - return serversList(ctx, c, cmd.Latency) + case cmd.Show != nil: + return serversGet(ctx, c, cmd.Show.Tag) + case cmd.AddJSON != nil: + return printAddedServers(c.AddServersByJSON(ctx, cmd.AddJSON.Config)) + case cmd.AddURL != nil: + return printAddedServers(c.AddServersByURL(ctx, cmd.AddURL.URLs, cmd.AddURL.SkipCertVerify)) + case cmd.Remove != nil: + return c.RemoveServers(ctx, cmd.Remove.Tags) case cmd.PrivateServer != nil: return runPrivateServer(ctx, c, cmd.PrivateServer) + case cmd.List != nil: + return serversList(ctx, c, cmd.List.Latency, cmd.List.JSON) default: - return fmt.Errorf("must specify one of --get, --add-json, --add-url, --remove, or --list") + return serversList(ctx, c, false, false) } } func runPrivateServer(ctx context.Context, c *ipc.Client, cmd *PrivateServerCmd) error { switch { - case cmd.Add != "": - return c.AddPrivateServer(ctx, cmd.Add, cmd.IP, cmd.Port, cmd.Token) - case cmd.Invite != "": - code, err := c.InviteToPrivateServer(ctx, cmd.IP, cmd.Port, cmd.Token, cmd.Invite) + case cmd.Add != nil: + return c.AddPrivateServer(ctx, cmd.Add.Tag, cmd.Add.IP, cmd.Add.Port, cmd.Add.Token) + case cmd.Invite != nil: + code, err := c.InviteToPrivateServer(ctx, cmd.Invite.IP, cmd.Invite.Port, cmd.Invite.Token, cmd.Invite.Name) if err != nil { return err } fmt.Println(code) return nil - case cmd.RevokeInvite != "": - return c.RevokePrivateServerInvite(ctx, cmd.IP, cmd.Port, cmd.Token, cmd.RevokeInvite) + case cmd.RevokeInvite != nil: + return c.RevokePrivateServerInvite(ctx, cmd.RevokeInvite.IP, cmd.RevokeInvite.Port, cmd.RevokeInvite.Token, cmd.RevokeInvite.Name) default: - return fmt.Errorf("must specify one of --add, --invite, or --revoke-invite") + return fmt.Errorf("must specify one of: add, invite, revoke-invite") } } -func serversList(ctx context.Context, c *ipc.Client, showLatency bool) error { +func serversList(ctx context.Context, c *ipc.Client, showLatency, asJSON bool) error { srvs, err := c.Servers(ctx) if err != nil { return err } + if asJSON { + out := make([]ServerListEntry, 0, len(srvs)) + for _, s := range srvs { + out = append(out, ServerListEntry{ + Tag: s.Tag, + Type: s.Type, + Location: s.Location, + URLTestResult: s.URLTestResult, + }) + } + return printJSON(out) + } if len(srvs) == 0 { fmt.Println("No servers available") return nil @@ -148,8 +205,3 @@ func printAddedServers(tags []string, err error) error { fmt.Printf("Added %d server(s): %s\n", len(tags), strings.Join(tags, ", ")) return nil } - -func serversRemove(ctx context.Context, c *ipc.Client, tags string) error { - tagList := strings.Split(tags, ",") - return c.RemoveServers(ctx, tagList) -} diff --git a/cmd/lantern/tty.go b/cmd/lantern/tty.go new file mode 100644 index 00000000..c5a0a9e7 --- /dev/null +++ b/cmd/lantern/tty.go @@ -0,0 +1,44 @@ +package main + +import ( + "context" + "os" + + "golang.org/x/term" +) + +// quitOnKey cancels ctx when q, Q, or Ctrl-C is read from stdin. +// The returned cleanup MUST be deferred: on a TTY it restores terminal +// state from raw mode. Raw mode also disables \n -> \r\n translation, so +// callers must emit \r\n explicitly to avoid stairstepping. +func quitOnKey(ctx context.Context) (context.Context, func()) { + ctx, cancel := context.WithCancel(ctx) + fd := int(os.Stdin.Fd()) + if !term.IsTerminal(fd) { + return ctx, cancel + } + oldState, err := term.MakeRaw(fd) + if err != nil { + return ctx, cancel + } + go watchKeys(os.Stdin, cancel) + return ctx, func() { + _ = term.Restore(fd, oldState) + cancel() + } +} + +func watchKeys(r *os.File, cancel context.CancelFunc) { + buf := make([]byte, 1) + for { + n, err := r.Read(buf) + if err != nil || n == 0 { + return + } + switch buf[0] { + case 'q', 'Q', 0x03: + cancel() + return + } + } +} diff --git a/cmd/lantern/vpn.go b/cmd/lantern/vpn.go index cd83f14e..4e6c0549 100644 --- a/cmd/lantern/vpn.go +++ b/cmd/lantern/vpn.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "sort" "strings" "time" @@ -17,7 +18,13 @@ type ConnectCmd struct { type DisconnectCmd struct{} -type StatusCmd struct{} +type StatusCmd struct { + JSON bool `arg:"--json" help:"output JSON"` +} + +type ThroughputCmd struct { + JSON bool `arg:"--json" help:"output JSON"` +} func vpnConnect(ctx context.Context, c *ipc.Client, tag string, wait bool) error { tctx, tcancel := context.WithTimeout(ctx, 5*time.Second) @@ -79,25 +86,127 @@ func waitForIPChange(ctx context.Context, current string, interval time.Duration } } -func vpnStatus(ctx context.Context, c *ipc.Client) error { - status, err := c.VPNStatus(ctx) +func vpnThroughput(ctx context.Context, c *ipc.Client, cmd *ThroughputCmd) error { + s, err := c.VPNThroughput(ctx) + if err != nil { + return err + } + if cmd.JSON { + return printJSON(s) + } + printThroughput(s) + return nil +} + +func printThroughput(s vpn.ThroughputSnapshot) { + fmt.Printf("Global ↓ %s ↑ %s (%d active)\r\n", + formatBitsPerSec(s.Global.Down), formatBitsPerSec(s.Global.Up), s.ActiveConnections) + + tagSet := make(map[string]struct{}, len(s.PerOutbound)+len(s.ActivePerOutbound)) + for tag := range s.PerOutbound { + tagSet[tag] = struct{}{} + } + for tag := range s.ActivePerOutbound { + tagSet[tag] = struct{}{} + } + if len(tagSet) == 0 { + return + } + tags := make([]string, 0, len(tagSet)) + for tag := range tagSet { + tags = append(tags, tag) + } + sort.Strings(tags) + fmt.Print("\r\n") + for _, tag := range tags { + sp := s.PerOutbound[tag] + name := tag + if name == "" { + name = "(unrouted)" + } + fmt.Printf(" %-32s ↓ %s ↑ %s (%d active)\r\n", + name, formatBitsPerSec(sp.Down), formatBitsPerSec(sp.Up), s.ActivePerOutbound[tag]) + } +} + +func formatBitsPerSec(bps int64) string { + const ( + kbit = 1_000 + mbit = 1_000_000 + gbit = 1_000_000_000 + ) + switch { + case bps >= gbit: + return fmt.Sprintf("%6.2f Gbps", float64(bps)/gbit) + case bps >= mbit: + return fmt.Sprintf("%6.2f Mbps", float64(bps)/mbit) + case bps >= kbit: + return fmt.Sprintf("%6.2f Kbps", float64(bps)/kbit) + default: + return fmt.Sprintf("%6d bps ", bps) + } +} + +func vpnStatus(ctx context.Context, c *ipc.Client, cmd *StatusCmd) error { + snap, err := fetchStatus(ctx, c) if err != nil { return err } - line := string(status) - line = strings.ToUpper(line[:1]) + line[1:] // capitalize first letter + return renderStatus(snap, cmd.JSON) +} + +type statusSnapshot struct { + Status vpn.VPNStatus `json:"status"` + Server string `json:"server,omitempty"` + Location string `json:"location,omitempty"` + LatencyMs uint16 `json:"latency_ms,omitempty"` + IP string `json:"ip,omitempty"` +} + +func fetchStatus(ctx context.Context, c *ipc.Client) (statusSnapshot, error) { + status, err := c.VPNStatus(ctx) + if err != nil { + return statusSnapshot{}, err + } + snap := statusSnapshot{Status: status} if status == vpn.Connected { - if sel, exists, err := c.SelectedServer(ctx); err == nil && exists { - line += "\nServer: " + sel.Tag - } else { - fmt.Printf("error getting selected server: err=%v, sel=%v, exists=%v\n", err, sel, exists) + if sel, exists, err := c.SelectedServer(ctx); err == nil && exists && sel != nil { + snap.Server = sel.Tag + snap.Location = joinNonEmpty(", ", sel.Location.City, sel.Location.Country) + if sel.URLTestResult != nil { + snap.LatencyMs = sel.URLTestResult.Delay + } } } tctx, tcancel := context.WithTimeout(ctx, 5*time.Second) if ip, err := getPublicIP(tctx); err == nil { - line += "\nIP: " + ip + snap.IP = ip } tcancel() - fmt.Println(line) + return snap, nil +} + +func renderStatus(snap statusSnapshot, asJSON bool) error { + if asJSON { + return printJSON(snap) + } + s := string(snap.Status) + if s != "" { + s = strings.ToUpper(s[:1]) + s[1:] + } + fmt.Println(s) + if snap.Server != "" { + line := "Server: " + snap.Server + if snap.Location != "" { + line += " (" + snap.Location + ")" + } + if snap.LatencyMs > 0 { + line += fmt.Sprintf(" — %dms", snap.LatencyMs) + } + fmt.Println(line) + } + if snap.IP != "" { + fmt.Println("IP: " + snap.IP) + } return nil } diff --git a/cmd/lantern/watch.go b/cmd/lantern/watch.go new file mode 100644 index 00000000..91c1230b --- /dev/null +++ b/cmd/lantern/watch.go @@ -0,0 +1,142 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "time" + + "golang.org/x/term" + + "github.com/getlantern/radiance/ipc" +) + +const ( + defaultReconnectTimeout = 60 * time.Second + reconnectInitialBackoff = 500 * time.Millisecond + reconnectMaxBackoff = 5 * time.Second + + reconnectPrefix = "Daemon disconnected; retrying" + spinnerFrames = `|/-\` + spinnerInterval = 150 * time.Millisecond +) + +type reconnectState struct { + timeout time.Duration + deadline time.Time + backoff time.Duration + notified bool + spinIdx int +} + +func newReconnect(timeout time.Duration) *reconnectState { + return &reconnectState{timeout: timeout} +} + +func (r *reconnectState) onError() time.Duration { + if r.timeout <= 0 { + return 0 + } + now := time.Now() + if r.deadline.IsZero() { + r.deadline = now.Add(r.timeout) + r.backoff = reconnectInitialBackoff + } + if now.After(r.deadline) { + return 0 + } + wait := r.backoff + r.backoff *= 2 + r.backoff = min(r.backoff, reconnectMaxBackoff) + return wait +} + +func (r *reconnectState) onSuccess() { + if r.notified { + clearReconnectLine() + fmt.Fprint(os.Stderr, "Daemon reconnected.\r\n") + r.notified = false + } + r.deadline = time.Time{} + r.backoff = 0 +} + +func (r *reconnectState) waitForRetry(ctx context.Context, wait time.Duration) error { + if wait <= 0 { + return nil + } + if !stderrIsTTY() { + if !r.notified { + fmt.Fprintln(os.Stderr, reconnectPrefix+"...") + r.notified = true + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(wait): + return nil + } + } + deadline := time.Now().Add(wait) + for { + c := spinnerFrames[r.spinIdx%len(spinnerFrames)] + r.spinIdx++ + fmt.Fprintf(os.Stderr, "\r%s %c ", reconnectPrefix, c) + r.notified = true + remaining := time.Until(deadline) + if remaining <= 0 { + return nil + } + sleep := min(remaining, spinnerInterval) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(sleep): + } + } +} + +func (r *reconnectState) abandon() { + if r.notified { + clearReconnectLine() + r.notified = false + } +} + +func stderrIsTTY() bool { + return term.IsTerminal(int(os.Stderr.Fd())) +} + +func stdoutIsTTY() bool { + return term.IsTerminal(int(os.Stdout.Fd())) +} + +func clearReconnectLine() { + if stderrIsTTY() { + fmt.Fprint(os.Stderr, "\r\033[K") + } +} + +func callWithReconnect(ctx context.Context, st *reconnectState, fn func() error) error { + for { + err := fn() + if err == nil { + st.onSuccess() + return nil + } + if !errors.Is(err, ipc.ErrIPCNotRunning) { + st.abandon() + return err + } + wait := st.onError() + if wait <= 0 { + st.abandon() + return fmt.Errorf("daemon unreachable: %w", err) + } + if err := st.waitForRetry(ctx, wait); err != nil { + st.abandon() + return err + } + } +} diff --git a/ipc/client.go b/ipc/client.go index 75afa548..b110407e 100644 --- a/ipc/client.go +++ b/ipc/client.go @@ -129,6 +129,25 @@ func (c *Client) ActiveVPNConnections(ctx context.Context) ([]vpn.Connection, er return conns, err } +// VPNSessions returns recorded VPN sessions in descending order. A limit value of 0 returns all +// sessions. +func (c *Client) VPNSessions(ctx context.Context, limit int) ([]vpn.Session, error) { + endpoint := vpnSessionsEndpoint + if limit > 0 { + endpoint = fmt.Sprintf("%s?limit=%d", endpoint, limit) + } + var sessions []vpn.Session + err := c.doJSON(ctx, http.MethodGet, endpoint, nil, &sessions) + return sessions, err +} + +// VPNThroughput returns the most recent global and per-outbound throughput sample. +func (c *Client) VPNThroughput(ctx context.Context) (vpn.ThroughputSnapshot, error) { + var s vpn.ThroughputSnapshot + err := c.doJSON(ctx, http.MethodGet, vpnThroughputEndpoint, nil, &s) + return s, err +} + // RunOfflineURLTests runs URL performance tests when offline (VPN disconnected) and caches the // results. This enables autoconnect to select the best server for the initial connection. func (c *Client) RunOfflineURLTests(ctx context.Context) error { diff --git a/ipc/client_nonmobile.go b/ipc/client_nonmobile.go index 733047bc..69b493ed 100644 --- a/ipc/client_nonmobile.go +++ b/ipc/client_nonmobile.go @@ -49,6 +49,9 @@ func (c *Client) do(ctx context.Context, method, endpoint string, body any) ([]b resp, err := c.http.Do(req) if err != nil { + if isConnectionError(err) { + return nil, fmt.Errorf("ipc request %s %s: %w: %w", method, endpoint, ErrIPCNotRunning, err) + } return nil, fmt.Errorf("ipc request %s %s: %w", method, endpoint, err) } defer resp.Body.Close() @@ -86,7 +89,7 @@ func (c *Client) sseStream(ctx context.Context, endpoint string, handler func([] resp, err := c.http.Do(req) if err != nil { if isConnectionError(err) { - return ErrIPCNotRunning + return fmt.Errorf("SSE connect %s: %w: %w", endpoint, ErrIPCNotRunning, err) } return fmt.Errorf("SSE connect %s: %w", endpoint, err) } diff --git a/ipc/server.go b/ipc/server.go index d80f9b52..5d81960f 100644 --- a/ipc/server.go +++ b/ipc/server.go @@ -11,6 +11,7 @@ import ( "log/slog" "net" "net/http" + "strconv" "sync/atomic" "time" @@ -35,8 +36,10 @@ const ( vpnDisconnectEndpoint = "/vpn/disconnect" vpnRestartEndpoint = "/vpn/restart" vpnConnectionsEndpoint = "/vpn/connections" + vpnThroughputEndpoint = "/vpn/throughput" vpnOfflineTestsEndpoint = "/vpn/offline-tests" vpnStatusEventsEndpoint = "/vpn/status/events" + vpnSessionsEndpoint = "/vpn/sessions" // Server selection endpoints serverSelectedEndpoint = "/server/selected" @@ -194,7 +197,9 @@ func newLocalAPI(b *backend.LocalBackend, withAuth bool) *localapi { mux.HandleFunc("POST "+vpnDisconnectEndpoint, traced(s.vpnDisconnectHandler)) mux.HandleFunc("POST "+vpnRestartEndpoint, traced(s.vpnRestartHandler)) mux.HandleFunc("GET "+vpnConnectionsEndpoint, traced(s.vpnConnectionsHandler)) + mux.HandleFunc("GET "+vpnThroughputEndpoint, traced(s.vpnThroughputHandler)) mux.HandleFunc("POST "+vpnOfflineTestsEndpoint, traced(s.vpnOfflineTestsHandler)) + mux.HandleFunc("GET "+vpnSessionsEndpoint, traced(s.vpnSessionsHandler)) // SSE routes skip the tracer middleware since it buffers the entire response body. mux.HandleFunc("GET "+vpnStatusEventsEndpoint, s.vpnStatusEventsHandler) @@ -375,6 +380,30 @@ func (s *localapi) vpnConnectionsHandler(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusOK, conns) } +func (s *localapi) vpnThroughputHandler(w http.ResponseWriter, r *http.Request) { + tp, err := s.backend(r.Context()).VPNThroughput() + if err != nil { + // Disconnected has no traffic; a zero snapshot is the correct value, not an error. + if errors.Is(err, vpn.ErrTunnelNotConnected) { + writeJSON(w, http.StatusOK, vpn.ThroughputSnapshot{}) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, tp) +} + +func (s *localapi) vpnSessionsHandler(w http.ResponseWriter, r *http.Request) { + limit := 0 + if v := r.URL.Query().Get("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + limit = n + } + } + writeJSON(w, http.StatusOK, s.backend(r.Context()).Sessions(limit)) +} + func (s *localapi) vpnOfflineTestsHandler(w http.ResponseWriter, r *http.Request) { if err := s.backend(r.Context()).RunOfflineURLTests(); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) diff --git a/vpn/clash.go b/vpn/clash.go index ff10b9b0..cad2b54d 100644 --- a/vpn/clash.go +++ b/vpn/clash.go @@ -9,6 +9,7 @@ import ( "slices" "strings" "sync" + "time" N "github.com/sagernet/sing/common/network" "github.com/sagernet/sing/service" @@ -27,12 +28,17 @@ var _ adapter.ClashServer = (*clashServer)(nil) // owned resources beyond what's wired in via the sing-box service context. type clashServer struct { ctx context.Context + cancel context.CancelFunc + startOnce sync.Once + dnsRouter adapter.DNSRouter outbound adapter.OutboundManager endpoint adapter.EndpointManager - urlTestHistory adapter.URLTestHistoryStorage - trafficManager *trafficontrol.Manager + urlTestHistory adapter.URLTestHistoryStorage + trafficManager *trafficontrol.Manager + throughputTracker *throughputTracker + trackerDone chan struct{} mode string modeList []string @@ -52,12 +58,17 @@ func newClashServer(ctx context.Context, _ log.ObservableFactory, options option return nil, fmt.Errorf("initial mode %q is not in mode list", initial) } + runCtx, cancel := context.WithCancel(ctx) + trafficManager := trafficontrol.NewManager() return &clashServer{ + ctx: runCtx, + cancel: cancel, dnsRouter: service.FromContext[adapter.DNSRouter](ctx), outbound: service.FromContext[adapter.OutboundManager](ctx), endpoint: service.FromContext[adapter.EndpointManager](ctx), urlTestHistory: service.FromContext[adapter.URLTestHistoryStorage](ctx), - trafficManager: trafficontrol.NewManager(), + trafficManager: trafficManager, + throughputTracker: newThroughputTracker(trafficManager, time.Second), modeList: modeList, mode: initial, }, nil @@ -94,10 +105,21 @@ func (s *clashServer) ModeList() []string { } func (s *clashServer) Start(stage adapter.StartStage) error { + s.startOnce.Do(func() { + s.trackerDone = make(chan struct{}) + go func() { + defer close(s.trackerDone) + s.throughputTracker.Run(s.ctx) + }() + }) return nil } func (s *clashServer) Close() error { + s.cancel() + if s.trackerDone != nil { + <-s.trackerDone + } return nil } @@ -113,6 +135,10 @@ func (s *clashServer) TrafficManager() *trafficontrol.Manager { return s.trafficManager } +func (s *clashServer) ThroughputTracker() *throughputTracker { + return s.throughputTracker +} + func (s *clashServer) RoutedConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, matchedRule adapter.Rule, matchOutbound adapter.Outbound) net.Conn { return trafficontrol.NewTCPTracker(conn, s.trafficManager, metadata, s.outbound, matchedRule, matchOutbound) } diff --git a/vpn/session_history.go b/vpn/session_history.go new file mode 100644 index 00000000..1f835a4f --- /dev/null +++ b/vpn/session_history.go @@ -0,0 +1,327 @@ +package vpn + +import ( + "context" + "log/slog" + "sync" + "time" + + "github.com/getlantern/radiance/events" +) + +const ( + maxSessions = 10 + sessionPollEvery = time.Second + sessionRetention = 15 * time.Minute + prunePeriod = time.Minute +) + +// Session covers a single server selection while connected. A new Session begins on connect and +// on every server switch; the prior Session is finalized at that boundary. History lives only in +// the daemon process — sessions are lost when the process exits. +type Session struct { + ConnectedAt time.Time `json:"connected_at"` + DisconnectedAt time.Time `json:"disconnected_at,omitempty"` + Server SessionServer `json:"server"` + BytesUp int64 `json:"bytes_up"` + BytesDown int64 `json:"bytes_down"` + Error string `json:"error,omitempty"` +} + +type SessionServer struct { + Tag string `json:"tag,omitempty"` + City string `json:"city,omitempty"` + Country string `json:"country,omitempty"` +} + +// Duration returns the session length. +func (s Session) Duration() time.Duration { + end := s.DisconnectedAt + if end.IsZero() { + end = time.Now() + } + return end.Sub(s.ConnectedAt) +} + +// SessionInfo supplies live session metadata to a SessionHistory. Bytes is invoked from a +// background poll goroutine, so the function must be safe for concurrent use. +type SessionInfo struct { + Status func() VPNStatus + SelectedServer func() (tag, city, country string) + Bytes func() (up, down int64, ok bool) +} + +// SessionHistory keeps an in-memory ring of recent VPN sessions, retaining the most recent +// maxSessions entries. A session covers a single server selection while connected; a server +// switch finalizes the current session and starts a new one. +type SessionHistory struct { + logger *slog.Logger + info SessionInfo + sub *events.Subscription[StatusUpdateEvent] + closeOnce sync.Once + + // A tunnel restart resets the underlying traffic-manager counters mid-session; baseline + // absorbs the prior tally so cumulative bytes stay monotonic across restarts. + bytesMu sync.Mutex + startUp int64 + startDown int64 + baselineUp int64 + baselineDown int64 + livePolledUp int64 + livePolledDown int64 + + mu sync.Mutex + current *Session + stored []Session + pollCancel context.CancelFunc + pollDone chan struct{} + pruneCancel context.CancelFunc + pruneDone chan struct{} +} + +// NewSessionHistory creates a SessionHistory subscribed to VPN status events. Call Close to +// unsubscribe and finalize any in-progress session. +func NewSessionHistory(logger *slog.Logger, info SessionInfo) *SessionHistory { + if logger == nil { + logger = slog.Default() + } + h := &SessionHistory{ + logger: logger, + info: info, + } + h.sub = events.Subscribe(h.handleStatus) + h.startPruner() + return h +} + +// Close unsubscribes and finalizes any in-progress session. Safe to call multiple times. +func (h *SessionHistory) Close() { + h.closeOnce.Do(func() { + h.sub.Unsubscribe() + h.stopPruner() + h.mu.Lock() + defer h.mu.Unlock() + if h.current != nil { + h.finalizeLocked("") + } + }) +} + +func (h *SessionHistory) handleStatus(evt StatusUpdateEvent) { + h.mu.Lock() + defer h.mu.Unlock() + // Status events are dispatched in unordered goroutines, so reacting to intermediate statuses + // risks a stale handler tearing down a session a concurrent Connected handler just started. + // Gate on the live VPNClient status rather than the event payload. + live := h.info.Status() + switch evt.Status { + case Connected: + if live != Connected { + return + } + // A Connected event arriving while a session is already active means the tunnel + // re-attached after a restart; the existing session continues. + if h.current != nil { + return + } + tag, city, country := h.info.SelectedServer() + h.startSessionLocked(tag, city, country) + case Disconnected, ErrorStatus: + if live == Connected || live == Restarting { + return + } + h.finalizeLocked(evt.Error) + } +} + +// HandleServerChange finalizes the current per-server session and starts a new one for the new +// server. No-op when no session is active or when tag matches the current server. +func (h *SessionHistory) HandleServerChange(tag, city, country string) { + h.mu.Lock() + defer h.mu.Unlock() + if h.current == nil { + return + } + if h.current.Server.Tag == tag { + return + } + h.finalizeLocked("") + h.startSessionLocked(tag, city, country) +} + +func (h *SessionHistory) startSessionLocked(tag, city, country string) { + h.current = &Session{ + ConnectedAt: time.Now(), + Server: SessionServer{ + Tag: tag, + City: city, + Country: country, + }, + } + h.snapshotStartBytesLocked() + if h.pollCancel == nil { + h.startPollLocked() + } +} + +func (h *SessionHistory) startPollLocked() { + ctx, cancel := context.WithCancel(context.Background()) + h.pollCancel = cancel + h.pollDone = make(chan struct{}) + go h.poll(ctx, h.pollDone) +} + +func (h *SessionHistory) stopPollLocked() { + if h.pollCancel == nil { + return + } + h.pollCancel() + <-h.pollDone + h.pollCancel = nil + h.pollDone = nil +} + +func (h *SessionHistory) finalizeLocked(errMsg string) { + if h.current == nil { + return + } + h.stopPollLocked() + h.sampleBytesLocked() + now := time.Now() + h.current.DisconnectedAt = now + if errMsg != "" { + h.current.Error = errMsg + } + s := *h.current + h.current = nil + h.stored = append([]Session{s}, h.stored...) + if len(h.stored) > maxSessions { + h.stored = h.stored[:maxSessions] + } + h.pruneLocked(now) +} + +func (h *SessionHistory) pruneLocked(now time.Time) { + cutoff := now.Add(-sessionRetention) + for i, s := range h.stored { + if s.DisconnectedAt.Before(cutoff) { + h.stored = h.stored[:i] + return + } + } +} + +func (h *SessionHistory) startPruner() { + ctx, cancel := context.WithCancel(context.Background()) + h.pruneCancel = cancel + h.pruneDone = make(chan struct{}) + go h.prune(ctx, h.pruneDone) +} + +func (h *SessionHistory) stopPruner() { + if h.pruneCancel == nil { + return + } + h.pruneCancel() + <-h.pruneDone + h.pruneCancel = nil + h.pruneDone = nil +} + +func (h *SessionHistory) prune(ctx context.Context, done chan struct{}) { + defer close(done) + ticker := time.NewTicker(prunePeriod) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case now := <-ticker.C: + h.mu.Lock() + h.pruneLocked(now) + h.mu.Unlock() + } + } +} + +func (h *SessionHistory) sampleBytesLocked() { + if h.current == nil { + return + } + if up, down, ok := h.info.Bytes(); ok { + h.observeBytes(up, down) + } + h.current.BytesUp, h.current.BytesDown = h.sessionBytes() +} + +func (h *SessionHistory) poll(ctx context.Context, done chan struct{}) { + defer close(done) + ticker := time.NewTicker(sessionPollEvery) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if up, down, ok := h.info.Bytes(); ok { + h.observeBytes(up, down) + } + } + } +} + +func (h *SessionHistory) observeBytes(up, down int64) { + h.bytesMu.Lock() + defer h.bytesMu.Unlock() + // Decrease means the tunnel restarted and reset its counters; fold the prior tally forward. + if up < h.livePolledUp { + h.baselineUp += h.livePolledUp + } + if down < h.livePolledDown { + h.baselineDown += h.livePolledDown + } + h.livePolledUp = up + h.livePolledDown = down +} + +func (h *SessionHistory) sessionBytes() (int64, int64) { + h.bytesMu.Lock() + defer h.bytesMu.Unlock() + up := h.baselineUp + h.livePolledUp - h.startUp + down := h.baselineDown + h.livePolledDown - h.startDown + if up < 0 { + up = 0 + } + if down < 0 { + down = 0 + } + return up, down +} + +func (h *SessionHistory) snapshotStartBytesLocked() { + if up, down, ok := h.info.Bytes(); ok { + h.observeBytes(up, down) + } + h.bytesMu.Lock() + defer h.bytesMu.Unlock() + h.startUp = h.baselineUp + h.livePolledUp + h.startDown = h.baselineDown + h.livePolledDown +} + +// Sessions returns recorded sessions in descending order (most recent first), including the +// current session if active. A limit value of 0 returns all sessions up to maxSessions. +func (h *SessionHistory) Sessions(limit int) []Session { + h.mu.Lock() + h.pruneLocked(time.Now()) + h.sampleBytesLocked() + out := make([]Session, 0, len(h.stored)+1) + if h.current != nil { + out = append(out, *h.current) + } + out = append(out, h.stored...) + h.mu.Unlock() + if limit > 0 && limit < len(out) { + out = out[:limit] + } + return out +} diff --git a/vpn/session_history_test.go b/vpn/session_history_test.go new file mode 100644 index 00000000..10bd2bcd --- /dev/null +++ b/vpn/session_history_test.go @@ -0,0 +1,278 @@ +package vpn + +import ( + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeInfo struct { + mu sync.Mutex + status VPNStatus + tag string + city string + country string + up, down int64 + bytesOK bool +} + +func (f *fakeInfo) info() SessionInfo { + return SessionInfo{ + Status: func() VPNStatus { + f.mu.Lock() + defer f.mu.Unlock() + return f.status + }, + SelectedServer: func() (string, string, string) { + f.mu.Lock() + defer f.mu.Unlock() + return f.tag, f.city, f.country + }, + Bytes: func() (int64, int64, bool) { + f.mu.Lock() + defer f.mu.Unlock() + return f.up, f.down, f.bytesOK + }, + } +} + +func (f *fakeInfo) set(status VPNStatus, tag, city, country string) { + f.mu.Lock() + defer f.mu.Unlock() + f.status = status + f.tag, f.city, f.country = tag, city, country +} + +func (f *fakeInfo) setBytes(up, down int64) { + f.mu.Lock() + defer f.mu.Unlock() + f.up, f.down, f.bytesOK = up, down, true +} + +// newTestHistory skips the global event subscription and pruner goroutine +// so tests can drive state directly. +func newTestHistory(t *testing.T, status VPNStatus, tag string, up, down int64) (*SessionHistory, *fakeInfo) { + t.Helper() + info := &fakeInfo{} + info.set(status, tag, "", "") + info.setBytes(up, down) + return &SessionHistory{info: info.info()}, info +} + +func TestSessionHistory_StatusEvents(t *testing.T) { + tests := []struct { + name string + run func(h *SessionHistory, info *fakeInfo) + wantCurrent bool + wantStored int + extra func(t *testing.T, h *SessionHistory) + }{ + { + name: "connect then disconnect records session", + run: func(h *SessionHistory, info *fakeInfo) { + h.handleStatus(StatusUpdateEvent{Status: Connected}) + info.set(Disconnected, "", "", "") + info.setBytes(500, 1000) + h.handleStatus(StatusUpdateEvent{Status: Disconnected}) + }, + wantStored: 1, + extra: func(t *testing.T, h *SessionHistory) { + assert.Equal(t, int64(500), h.stored[0].BytesUp) + assert.Equal(t, int64(1000), h.stored[0].BytesDown) + assert.False(t, h.stored[0].DisconnectedAt.IsZero()) + }, + }, + { + name: "repeat Connected leaves current session intact", + run: func(h *SessionHistory, info *fakeInfo) { + h.handleStatus(StatusUpdateEvent{Status: Connected}) + h.handleStatus(StatusUpdateEvent{Status: Connected}) + }, + wantCurrent: true, + }, + { + name: "Disconnected ignored while live=Connected", + run: func(h *SessionHistory, info *fakeInfo) { + h.handleStatus(StatusUpdateEvent{Status: Connected}) + info.set(Connected, "vpn-a", "", "") + h.handleStatus(StatusUpdateEvent{Status: Disconnected}) + }, + wantCurrent: true, + }, + { + name: "Disconnected ignored while live=Restarting", + run: func(h *SessionHistory, info *fakeInfo) { + h.handleStatus(StatusUpdateEvent{Status: Connected}) + info.set(Restarting, "vpn-a", "", "") + h.handleStatus(StatusUpdateEvent{Status: Disconnected}) + }, + wantCurrent: true, + }, + { + name: "stale Connected (live != Connected) ignored", + run: func(h *SessionHistory, info *fakeInfo) { + h.handleStatus(StatusUpdateEvent{Status: Connected}) + info.set(Connecting, "vpn-a", "", "") + h.handleStatus(StatusUpdateEvent{Status: Connected}) + }, + wantCurrent: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h, info := newTestHistory(t, Connected, "vpn-a", 0, 0) + tt.run(h, info) + if tt.wantCurrent { + assert.NotNil(t, h.current) + } else { + assert.Nil(t, h.current) + } + require.Len(t, h.stored, tt.wantStored) + if tt.extra != nil { + tt.extra(t, h) + } + }) + } +} + +func TestSessionHistory_ServerSwitch(t *testing.T) { + tests := []struct { + name string + startStatus VPNStatus + startBytes [2]int64 + setup func(h *SessionHistory, info *fakeInfo) + switchTag string + wantCurrent string + wantStored []string + wantBytesUp int64 + wantBytesDow int64 + }{ + { + name: "same tag is a no-op", + startStatus: Connected, + setup: func(h *SessionHistory, info *fakeInfo) { + h.handleStatus(StatusUpdateEvent{Status: Connected}) + }, + switchTag: "vpn-a", + wantCurrent: "vpn-a", + }, + { + name: "new tag finalizes prior session with carried bytes", + startStatus: Connected, + startBytes: [2]int64{100, 200}, + setup: func(h *SessionHistory, info *fakeInfo) { + h.handleStatus(StatusUpdateEvent{Status: Connected}) + info.setBytes(300, 600) + }, + switchTag: "vpn-b", + wantCurrent: "vpn-b", + wantStored: []string{"vpn-a"}, + wantBytesUp: 200, + wantBytesDow: 400, + }, + { + name: "no current session is a no-op", + startStatus: Disconnected, + setup: func(h *SessionHistory, info *fakeInfo) {}, + switchTag: "vpn-a", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h, info := newTestHistory(t, tt.startStatus, "vpn-a", tt.startBytes[0], tt.startBytes[1]) + tt.setup(h, info) + h.HandleServerChange(tt.switchTag, "", "") + + if tt.wantCurrent == "" { + assert.Nil(t, h.current) + } else { + require.NotNil(t, h.current) + assert.Equal(t, tt.wantCurrent, h.current.Server.Tag) + } + require.Len(t, h.stored, len(tt.wantStored)) + for i, tag := range tt.wantStored { + assert.Equal(t, tag, h.stored[i].Server.Tag) + } + if len(tt.wantStored) > 0 { + assert.Equal(t, tt.wantBytesUp, h.stored[0].BytesUp) + assert.Equal(t, tt.wantBytesDow, h.stored[0].BytesDown) + } + }) + } +} + +func TestSessionHistory_ByteAccounting(t *testing.T) { + h, _ := newTestHistory(t, Connected, "vpn-a", 100, 200) + h.handleStatus(StatusUpdateEvent{Status: Connected}) + + tests := []struct { + name string + observeUp, obDn int64 + wantUp, wantDown int64 + }{ + {"initial", 100, 200, 0, 0}, + {"steady accumulation", 150, 260, 50, 60}, + {"counter reset preserves prior tally", 10, 20, 60, 80}, + {"continued growth after reset", 40, 70, 90, 130}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h.observeBytes(tt.observeUp, tt.obDn) + up, down := h.sessionBytes() + assert.Equal(t, tt.wantUp, up) + assert.Equal(t, tt.wantDown, down) + }) + } +} + +func TestSessionHistory_Storage(t *testing.T) { + t.Run("prune drops entries older than retention", func(t *testing.T) { + h, _ := newTestHistory(t, Disconnected, "", 0, 0) + now := time.Now() + h.stored = []Session{ + {DisconnectedAt: now.Add(-30 * time.Second)}, + {DisconnectedAt: now.Add(-9 * time.Minute)}, + {DisconnectedAt: now.Add(-20 * time.Minute)}, + {DisconnectedAt: now.Add(-50 * time.Minute)}, + } + h.pruneLocked(now) + require.Len(t, h.stored, 2) + for _, s := range h.stored { + assert.WithinDuration(t, now, s.DisconnectedAt, sessionRetention) + } + }) + + t.Run("Sessions returns current first then stored, honoring limit", func(t *testing.T) { + h, _ := newTestHistory(t, Connected, "vpn-current", 0, 0) + now := time.Now() + h.stored = []Session{ + {DisconnectedAt: now.Add(-30 * time.Second), Server: SessionServer{Tag: "older"}}, + {DisconnectedAt: now.Add(-90 * time.Second), Server: SessionServer{Tag: "oldest"}}, + } + h.handleStatus(StatusUpdateEvent{Status: Connected}) + + tags := func(ss []Session) []string { + out := make([]string, len(ss)) + for i, s := range ss { + out[i] = s.Server.Tag + } + return out + } + assert.Equal(t, []string{"vpn-current", "older", "oldest"}, tags(h.Sessions(0))) + assert.Equal(t, []string{"vpn-current", "older"}, tags(h.Sessions(2))) + }) + + t.Run("stored slice caps at maxSessions", func(t *testing.T) { + h, info := newTestHistory(t, Connected, "tag", 0, 0) + for i := 0; i < maxSessions+3; i++ { + info.set(Connected, "tag", "", "") + h.handleStatus(StatusUpdateEvent{Status: Connected}) + info.set(Disconnected, "", "", "") + h.handleStatus(StatusUpdateEvent{Status: Disconnected}) + } + assert.LessOrEqual(t, len(h.stored), maxSessions) + }) +} diff --git a/vpn/throughput_tracker.go b/vpn/throughput_tracker.go new file mode 100644 index 00000000..aaf92844 --- /dev/null +++ b/vpn/throughput_tracker.go @@ -0,0 +1,143 @@ +package vpn + +import ( + "context" + "sync" + "time" + + "github.com/gofrs/uuid/v5" + "github.com/sagernet/sing-box/experimental/clashapi/trafficontrol" +) + +// Throughput reports network throughput in bits per second. +type Throughput struct { + Up int64 `json:"up"` + Down int64 `json:"down"` +} + +const defaultThroughputSampleInterval = time.Second + +type byteTotals struct { + up int64 + down int64 +} + +// throughputTracker reports network throughput, globally and per outbound tag. +// Throughput is sampled at a fixed interval; readers see the most recent +// completed sample. +type throughputTracker struct { + manager *trafficontrol.Manager + interval time.Duration + + mu sync.RWMutex + perOutbound map[string]Throughput + globalThroughput Throughput + + seen map[uuid.UUID]byteTotals + lastGlobal byteTotals + lastTickAt time.Time +} + +func newThroughputTracker(manager *trafficontrol.Manager, interval time.Duration) *throughputTracker { + if interval <= 0 { + interval = defaultThroughputSampleInterval + } + return &throughputTracker{ + manager: manager, + interval: interval, + perOutbound: make(map[string]Throughput), + seen: make(map[uuid.UUID]byteTotals), + } +} + +// Run samples the underlying counters until ctx is canceled. It blocks. +func (s *throughputTracker) Run(ctx context.Context) { + s.lastTickAt = time.Now() + gUp, gDown := s.manager.Total() + s.lastGlobal = byteTotals{up: gUp, down: gDown} + ticker := time.NewTicker(s.interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case now := <-ticker.C: + s.sample(now) + } + } +} + +func (s *throughputTracker) Global() Throughput { + s.mu.RLock() + defer s.mu.RUnlock() + return s.globalThroughput +} + +// Outbound returns the most recent throughput sample for tag, or a zero +// Throughput if no traffic has been observed for that tag. +func (s *throughputTracker) Outbound(tag string) Throughput { + s.mu.RLock() + defer s.mu.RUnlock() + return s.perOutbound[tag] +} + +// PerOutbound returns a snapshot copy of the most recent per-outbound samples. +func (s *throughputTracker) PerOutbound() map[string]Throughput { + s.mu.RLock() + defer s.mu.RUnlock() + out := make(map[string]Throughput, len(s.perOutbound)) + for k, v := range s.perOutbound { + out[k] = v + } + return out +} + +func (s *throughputTracker) sample(now time.Time) { + elapsed := now.Sub(s.lastTickAt).Seconds() + // Skip on clock jumps or coalesced ticks: leaving lastTickAt and the byte baselines + // untouched means the next sample's elapsed and deltas span the same window. + if elapsed <= 0 { + return + } + s.lastTickAt = now + + deltas := make(map[string]byteTotals) + nextSeen := make(map[uuid.UUID]byteTotals, len(s.seen)) + visit := func(m trafficontrol.TrackerMetadata) { + up := m.Upload.Load() + down := m.Download.Load() + prev := s.seen[m.ID] + d := deltas[m.Outbound] + d.up += up - prev.up + d.down += down - prev.down + deltas[m.Outbound] = d + nextSeen[m.ID] = byteTotals{up: up, down: down} + } + for _, m := range s.manager.Connections() { + visit(m) + } + for _, m := range s.manager.ClosedConnections() { + visit(m) + } + s.seen = nextSeen + + perOutbound := make(map[string]Throughput, len(deltas)) + for tag, d := range deltas { + perOutbound[tag] = Throughput{ + Up: int64(float64(d.up*8) / elapsed), + Down: int64(float64(d.down*8) / elapsed), + } + } + + gUp, gDown := s.manager.Total() + globalThroughput := Throughput{ + Up: int64(float64((gUp-s.lastGlobal.up)*8) / elapsed), + Down: int64(float64((gDown-s.lastGlobal.down)*8) / elapsed), + } + s.lastGlobal = byteTotals{up: gUp, down: gDown} + + s.mu.Lock() + s.perOutbound = perOutbound + s.globalThroughput = globalThroughput + s.mu.Unlock() +} diff --git a/vpn/throughput_tracker_test.go b/vpn/throughput_tracker_test.go new file mode 100644 index 00000000..b657f96c --- /dev/null +++ b/vpn/throughput_tracker_test.go @@ -0,0 +1,131 @@ +package vpn + +import ( + "sync/atomic" + "testing" + "time" + + "github.com/gofrs/uuid/v5" + "github.com/sagernet/sing-box/experimental/clashapi/trafficontrol" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeTracker struct { + md trafficontrol.TrackerMetadata +} + +func (f *fakeTracker) Metadata() trafficontrol.TrackerMetadata { return f.md } +func (f *fakeTracker) Close() error { return nil } + +func newFakeTracker(outbound string) *fakeTracker { + id, err := uuid.NewV4() + if err != nil { + panic(err) + } + return &fakeTracker{ + md: trafficontrol.TrackerMetadata{ + ID: id, + CreatedAt: time.Now(), + Upload: new(atomic.Int64), + Download: new(atomic.Int64), + Outbound: outbound, + }, + } +} + +// addBytes keeps the fake tracker and manager totals in sync; updating only one side +// produces phantom throughput in the next sample. +func addBytes(mgr *trafficontrol.Manager, t *fakeTracker, up, down int64) { + t.md.Upload.Add(up) + t.md.Download.Add(down) + mgr.PushUploaded(up) + mgr.PushDownloaded(down) +} + +func TestThroughputTracker_Sample(t *testing.T) { + tests := []struct { + name string + run func(mgr *trafficontrol.Manager, tr *throughputTracker, t0 time.Time) + wantPer map[string]Throughput + wantGlobal Throughput + }{ + { + name: "computes per-outbound and global bps from byte deltas", + run: func(mgr *trafficontrol.Manager, tr *throughputTracker, t0 time.Time) { + a, b := newFakeTracker("vpn-a"), newFakeTracker("vpn-b") + mgr.Join(a) + mgr.Join(b) + addBytes(mgr, a, 125, 250) + addBytes(mgr, b, 500, 1000) + tr.sample(t0.Add(time.Second)) + }, + wantPer: map[string]Throughput{ + "vpn-a": {Up: 125 * 8, Down: 250 * 8}, + "vpn-b": {Up: 500 * 8, Down: 1000 * 8}, + }, + wantGlobal: Throughput{Up: 625 * 8, Down: 1250 * 8}, + }, + { + name: "includes bytes from connections closed during the window", + run: func(mgr *trafficontrol.Manager, tr *throughputTracker, t0 time.Time) { + live, closing := newFakeTracker("vpn-a"), newFakeTracker("vpn-a") + mgr.Join(live) + mgr.Join(closing) + addBytes(mgr, live, 100, 0) + addBytes(mgr, closing, 400, 0) + mgr.Leave(closing) + tr.sample(t0.Add(time.Second)) + }, + wantPer: map[string]Throughput{"vpn-a": {Up: 500 * 8}}, + wantGlobal: Throughput{Up: 500 * 8}, + }, + { + name: "non-positive elapsed leaves baselines untouched for the next tick", + run: func(mgr *trafficontrol.Manager, tr *throughputTracker, t0 time.Time) { + a := newFakeTracker("vpn-a") + mgr.Join(a) + addBytes(mgr, a, 100, 200) + tr.sample(t0) + + addBytes(mgr, a, 50, 50) + tr.sample(t0.Add(time.Second)) + }, + wantPer: map[string]Throughput{"vpn-a": {Up: 150 * 8, Down: 250 * 8}}, + wantGlobal: Throughput{Up: 150 * 8, Down: 250 * 8}, + }, + } + for i, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mgr := trafficontrol.NewManager() + tr := newThroughputTracker(mgr, time.Second) + t0 := time.Unix(int64(1000+i), 0) + tr.lastTickAt = t0 + tt.run(mgr, tr, t0) + assert.Equal(t, tt.wantPer, tr.PerOutbound()) + assert.Equal(t, tt.wantGlobal, tr.Global()) + }) + } +} + +func TestThroughputTracker_PerOutboundIsIsolatedCopy(t *testing.T) { + mgr := trafficontrol.NewManager() + tr := newThroughputTracker(mgr, time.Second) + a := newFakeTracker("vpn-a") + mgr.Join(a) + addBytes(mgr, a, 10, 10) + + t0 := time.Unix(4000, 0) + tr.lastTickAt = t0 + tr.sample(t0.Add(time.Second)) + + snap := tr.PerOutbound() + require.Equal(t, Throughput{Up: 80, Down: 80}, snap["vpn-a"]) + snap["vpn-a"] = Throughput{Up: 999} + assert.Equal(t, Throughput{Up: 80, Down: 80}, tr.PerOutbound()["vpn-a"]) +} + +func TestThroughputTracker_OutboundUnknownTag(t *testing.T) { + tr := newThroughputTracker(trafficontrol.NewManager(), time.Second) + assert.Equal(t, Throughput{}, tr.Outbound("missing")) +} diff --git a/vpn/types.go b/vpn/types.go index fccbdd6a..18e37391 100644 --- a/vpn/types.go +++ b/vpn/types.go @@ -24,34 +24,42 @@ type Selector interface { } type OutboundGroup struct { - Tag string - Type string - Selected string - Outbounds []Outbounds + Tag string `json:"tag"` + Type string `json:"type"` + Selected string `json:"selected"` + Outbounds []Outbounds `json:"outbounds"` } type Outbounds struct { - Tag string - Type string + Tag string `json:"tag"` + Type string `json:"type"` +} + +// ThroughputSnapshot is the most recent throughput sample for the tunnel. +type ThroughputSnapshot struct { + Global Throughput `json:"global"` + PerOutbound map[string]Throughput `json:"per_outbound"` + ActiveConnections int `json:"active_connections"` + ActivePerOutbound map[string]int `json:"active_per_outbound"` } type Connection struct { - ID string - Inbound string - IPVersion int - Network string - Source string - Destination string - Domain string - Protocol string - FromOutbound string - CreatedAt int64 - ClosedAt int64 - Uplink int64 - Downlink int64 - Rule string - Outbound string - ChainList []string + ID string `json:"id"` + Inbound string `json:"inbound"` + IPVersion int `json:"ip_version"` + Network string `json:"network"` + Source string `json:"source"` + Destination string `json:"destination"` + Domain string `json:"domain,omitempty"` + Protocol string `json:"protocol,omitempty"` + FromOutbound string `json:"from_outbound,omitempty"` + CreatedAt int64 `json:"created_at"` + ClosedAt int64 `json:"closed_at,omitempty"` + Uplink int64 `json:"uplink"` + Downlink int64 `json:"downlink"` + Rule string `json:"rule,omitempty"` + Outbound string `json:"outbound"` + ChainList []string `json:"chain,omitempty"` } // NewConnection creates a Connection from tracker metadata. diff --git a/vpn/vpn.go b/vpn/vpn.go index eb74cb62..f2620bc4 100644 --- a/vpn/vpn.go +++ b/vpn/vpn.go @@ -29,6 +29,7 @@ import ( "go.opentelemetry.io/otel/trace" box "github.com/getlantern/lantern-box" + "github.com/getlantern/radiance/events" "github.com/getlantern/radiance/log" "github.com/getlantern/radiance/servers" @@ -359,6 +360,41 @@ func (c *VPNClient) Connections() ([]Connection, error) { return connections, nil } +// Bytes returns the cumulative up/down byte counters for the active tunnel. ok is false if the +// tunnel is not connected; counters reset when a tunnel restarts. +func (c *VPNClient) Bytes() (up, down int64, ok bool) { + c.mu.RLock() + defer c.mu.RUnlock() + if c.tunnel == nil { + return 0, 0, false + } + up, down = c.tunnel.clashServer.TrafficManager().Total() + return up, down, true +} + +// Throughput returns the most recent global and per-outbound throughput sample. +// Returns ErrTunnelNotConnected if the tunnel is not connected. +func (c *VPNClient) Throughput() (ThroughputSnapshot, error) { + c.mu.RLock() + defer c.mu.RUnlock() + if c.tunnel == nil { + return ThroughputSnapshot{}, ErrTunnelNotConnected + } + tt := c.tunnel.clashServer.ThroughputTracker() + tm := c.tunnel.clashServer.TrafficManager() + active := tm.Connections() + perOut := make(map[string]int, len(active)) + for _, m := range active { + perOut[m.Outbound]++ + } + return ThroughputSnapshot{ + Global: tt.Global(), + PerOutbound: tt.PerOutbound(), + ActiveConnections: len(active), + ActivePerOutbound: perOut, + }, nil +} + // AutoSelectedEvent is emitted when the auto-selected server changes. type AutoSelectedEvent struct { events.Event From 5a0a9f5fb6e30f38380cb8bfc989e54f99ba5030 Mon Sep 17 00:00:00 2001 From: atavism Date: Thu, 7 May 2026 10:35:31 -0700 Subject: [PATCH 13/35] code review updates --- backend/radiance.go | 4 ++-- ipc/client.go | 4 +++- issue/issue.go | 29 +++++++++-------------------- issue/issue_test.go | 14 ++++++-------- issue/transport.go | 39 ++++++++++++++++++++++----------------- 5 files changed, 42 insertions(+), 48 deletions(-) diff --git a/backend/radiance.go b/backend/radiance.go index 097dc88a..27ca7209 100644 --- a/backend/radiance.go +++ b/backend/radiance.go @@ -327,7 +327,7 @@ func (r *LocalBackend) startVPNStatusListeners() { // ReportIssue allows the user to report an issue with the application. It collects relevant // information about the user's environment such as country, device ID, user ID, subscription level, // and locale, and log files to include in the report. -func (r *LocalBackend) ReportIssue(issueType issue.IssueType, description, email string, additionalAttachments []string, firstClassAttachments []*issue.Attachment) error { +func (r *LocalBackend) ReportIssue(issueType issue.IssueType, description, email string, additionalAttachments []string, attachments []*issue.Attachment) error { ctx, span := otel.Tracer(tracerName).Start(context.Background(), "report_issue") defer span.End() // get country from the config returned by the backend @@ -354,7 +354,7 @@ func (r *LocalBackend) ReportIssue(issueType issue.IssueType, description, email UserID: settings.GetString(settings.UserIDKey), SubscriptionLevel: settings.GetString(settings.UserLevelKey), Locale: settings.GetString(settings.LocaleKey), - Attachments: firstClassAttachments, + Attachments: attachments, AdditionalAttachments: attachmentPaths, } err = r.issueReporter.Report(ctx, report) diff --git a/ipc/client.go b/ipc/client.go index 9c1832b2..0d8563f1 100644 --- a/ipc/client.go +++ b/ipc/client.go @@ -627,7 +627,9 @@ func (c *Client) VerifySubscription(ctx context.Context, service account.Subscri // ReportIssue submits an issue report. additionalAttachments is a list of file paths for additional // files to include. Logs, diagnostics, and the config response are included automatically and do -// not need to be specified. +// not need to be specified. attachments contains screenshot files sent as first-class multipart +// attachments; callers may include up to [issue.MaxFirstClassAttachmentCount] files with a +// combined size of [issue.MaxFirstClassAttachmentBytes] bytes. func (c *Client) ReportIssue(ctx context.Context, issueType issue.IssueType, description, email string, additionalAttachments []string, attachments []*issue.Attachment) error { _, err := c.do(ctx, http.MethodPost, issueEndpoint, IssueReportRequest{ diff --git a/issue/issue.go b/issue/issue.go index 629d458e..34a97649 100644 --- a/issue/issue.go +++ b/issue/issue.go @@ -42,10 +42,9 @@ func NewIssueReporter(httpClient *http.Client) *IssueReporter { type IssueType int type Attachment struct { - Name string - Type string - Data []byte - FirstClass bool + Name string + Type string + Data []byte } const ( @@ -87,8 +86,9 @@ type IssueReport struct { Locale string // device alphanumeric name Model string - // Attachments contains in-memory attachments supplied by the caller. FirstClass - // attachments are sent as separate multipart files. + // Attachments contains in-memory screenshot attachments supplied by the caller. + // They are sent as separate multipart files, with at most + // [MaxFirstClassAttachmentCount] files and [MaxFirstClassAttachmentBytes] bytes. Attachments []*Attachment // AdditionalAttachments is a list of additional files to be attached. The log file will be // automatically included. @@ -127,21 +127,11 @@ func (ir *IssueReporter) Report(ctx context.Context, report IssueReport) error { } firstClassAttachments := make([]*Attachment, 0, len(report.Attachments)) - protoAttachmentBytes := 0 for _, attachment := range report.Attachments { - if attachment == nil || attachment.Name == "" || len(attachment.Data) == 0 { + if attachment == nil { continue } - if attachment.FirstClass { - firstClassAttachments = append(firstClassAttachments, attachment) - continue - } - r.Attachments = append(r.Attachments, &ReportIssueRequest_Attachment{ - Type: attachmentContentType(attachment), - Name: attachment.Name, - Content: attachment.Data, - }) - protoAttachmentBytes += len(attachment.Data) + firstClassAttachments = append(firstClassAttachments, attachment) } logDir := settings.GetString(settings.LogPathKey) @@ -155,7 +145,6 @@ func (ir *IssueReporter) Report(ctx context.Context, report IssueReport) error { Name: "logs.zip", Content: archive, }) - protoAttachmentBytes += len(archive) } // send message to lantern-cloud @@ -168,7 +157,7 @@ func (ir *IssueReporter) Report(ctx context.Context, report IssueReport) error { contentType := "application/x-protobuf" body := bytes.NewReader(out) if len(firstClassAttachments) > 0 { - if err := validateFirstClassAttachments(firstClassAttachments, protoAttachmentBytes); err != nil { + if err := validateFirstClassAttachments(firstClassAttachments); err != nil { slog.Error("invalid issue attachments", "error", err) return err } diff --git a/issue/issue_test.go b/issue/issue_test.go index 40d0a8b9..2e812ce7 100644 --- a/issue/issue_test.go +++ b/issue/issue_test.go @@ -113,10 +113,9 @@ func TestSendReportWithFirstClassAttachment(t *testing.T) { Model: "SM-G973F", Attachments: []*Attachment{ { - Name: "screenshot.png", - Type: "image/png", - Data: []byte("png-bytes"), - FirstClass: true, + Name: "screenshot.png", + Type: "image/png", + Data: []byte("png-bytes"), }, }, } @@ -136,10 +135,9 @@ func TestSendReportRejectsInvalidFirstClassAttachment(t *testing.T) { Email: "radiancetest@getlantern.org", Attachments: []*Attachment{ { - Name: "report.pdf", - Type: "application/pdf", - Data: []byte("pdf"), - FirstClass: true, + Name: "report.pdf", + Type: "application/pdf", + Data: []byte("pdf"), }, }, }) diff --git a/issue/transport.go b/issue/transport.go index c1c6560e..d3dcd27b 100644 --- a/issue/transport.go +++ b/issue/transport.go @@ -12,8 +12,12 @@ import ( ) const ( - maxFirstClassAttachmentCount = 3 - maxIssueAttachmentBytes = 15 * 1024 * 1024 + // MaxFirstClassAttachmentCount is the maximum number of screenshot + // attachments that can be sent as first-class multipart files. + MaxFirstClassAttachmentCount = 3 + // MaxFirstClassAttachmentBytes is the maximum combined size of screenshot + // attachments that can be sent as first-class multipart files. + MaxFirstClassAttachmentBytes = 15 * 1024 * 1024 requestPartName = "request" requestPartFilename = "request.pb" @@ -53,7 +57,7 @@ func normalizeAttachmentType(contentType string) string { // attachmentContentType prefers an explicitly supplied type, then falls back to // the filename, and finally sniffs the payload when we have to. func attachmentContentType(attachment *Attachment) string { - if attachment == nil { + if attachment == nil || len(attachment.Data) == 0 { return octetStreamContentType } @@ -68,37 +72,38 @@ func attachmentContentType(attachment *Attachment) string { return contentType } - if len(attachment.Data) == 0 { - return octetStreamContentType - } - return normalizeAttachmentType(http.DetectContentType(attachment.Data)) } // validateFirstClassAttachments applies the screenshot limits before we switch // the issue request from the protobuf-only path to multipart/form-data. -func validateFirstClassAttachments(attachments []*Attachment, existingBytes int) error { +func validateFirstClassAttachments(attachments []*Attachment) error { count := 0 - totalBytes := existingBytes + totalBytes := 0 for _, attachment := range attachments { if attachment == nil { continue } + if len(attachment.Data) == 0 { + name := strings.TrimSpace(attachment.Name) + if name == "" { + return fmt.Errorf("attachment is empty") + } + return fmt.Errorf("attachment %q is empty", name) + } + name, err := normalizeAttachmentName(attachment.Name) if err != nil { return err } - if len(attachment.Data) == 0 { - return fmt.Errorf("attachment %q is empty", name) - } count++ - if count > maxFirstClassAttachmentCount { + if count > MaxFirstClassAttachmentCount { return fmt.Errorf( "too many screenshot attachments: max %d", - maxFirstClassAttachmentCount, + MaxFirstClassAttachmentCount, ) } @@ -112,10 +117,10 @@ func validateFirstClassAttachments(attachments []*Attachment, existingBytes int) } totalBytes += len(attachment.Data) - if totalBytes > maxIssueAttachmentBytes { + if totalBytes > MaxFirstClassAttachmentBytes { return fmt.Errorf( - "total issue attachment size exceeds %d bytes", - maxIssueAttachmentBytes, + "total screenshot attachment size exceeds %d bytes", + MaxFirstClassAttachmentBytes, ) } } From 23e81a3267253658cfc7e7879274f839bf636462 Mon Sep 17 00:00:00 2001 From: atavism Date: Thu, 7 May 2026 10:38:25 -0700 Subject: [PATCH 14/35] code review updates --- issue/transport.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/issue/transport.go b/issue/transport.go index d3dcd27b..48d89609 100644 --- a/issue/transport.go +++ b/issue/transport.go @@ -203,6 +203,8 @@ func multipartContentDisposition(fieldName, filename string) string { ) } +// normalizeAttachmentName trims the filename and rejects characters that would +// make the multipart header invalid. func normalizeAttachmentName(name string) (string, error) { name = strings.TrimSpace(name) if name == "" { @@ -218,6 +220,8 @@ func normalizeAttachmentName(name string) (string, error) { return name, nil } +// escapeMultipartToken quotes characters that are special in Content-Disposition +// parameter values. func escapeMultipartToken(value string) string { replacer := strings.NewReplacer("\\", "\\\\", `"`, "\\\"") return replacer.Replace(value) From d4fc0cb240b8ec83878ebc605a25eea903c1fece Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Thu, 7 May 2026 12:37:02 -0600 Subject: [PATCH 15/35] peer: emit ConnectionEvent on samizdat accept/close Plumb lantern-box's peerconn listener registry through to the radiance event bus so consumers (Flutter globe view, future abuse aggregation) can subscribe to a per-connection accept/close stream. Listener is registered after libbox.Start so the box's accept loop is already serving when notifications start flowing; cleared on Stop and in the Start rollback path so post-teardown callbacks land on a no-op rather than emitting events to a torn-down consumer. Source field carries the remote "ip:port" string verbatim from M.Socksaddr.String(); consumers extract the IP for geo-lookup or rate-limit attribution. Pinned to local lantern-box via a replace directive while the peerconn package is in flight; remove once lantern-box tags a release. Co-Authored-By: Claude Opus 4.7 (1M context) --- go.mod | 6 +++++- go.sum | 6 ++---- peer/peer.go | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 4ff14ecb..4bf94e5f 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,10 @@ module github.com/getlantern/radiance go 1.26.2 +// Local while peerconn listener registry is in flight; remove once +// lantern-box tags a release that includes tracker/peerconn. +replace github.com/getlantern/lantern-box => ../lantern-box + replace github.com/sagernet/sing => github.com/getlantern/sing v0.7.18-lantern replace github.com/sagernet/sing-box => github.com/getlantern/sing-box-minimal v1.12.22-lantern @@ -113,7 +117,7 @@ require ( github.com/gaissmai/bart v0.11.1 // indirect github.com/gaukas/wazerofs v0.1.0 // indirect github.com/getlantern/algeneva v0.0.0-20250307163401-1824e7b54f52 // indirect - github.com/getlantern/broflake v0.0.0-20260501210609-ce5f75aa2054 // indirect + github.com/getlantern/broflake v0.0.0-20260504215251-ed3cf75062d1 // indirect github.com/getlantern/lantern-water v0.0.0-20260317143726-e0ee64a11d90 // indirect github.com/getlantern/samizdat v0.0.3-0.20260327203406-ef7323341974 // indirect github.com/go-chi/chi/v5 v5.2.2 // indirect diff --git a/go.sum b/go.sum index 94048681..53a3fee8 100644 --- a/go.sum +++ b/go.sum @@ -226,8 +226,8 @@ github.com/getlantern/algeneva v0.0.0-20250307163401-1824e7b54f52 h1:w2/RqYPw7Pb github.com/getlantern/algeneva v0.0.0-20250307163401-1824e7b54f52/go.mod h1:PrNR8tMXO26YNs8K9653XCUH7u2Kv4OdfFC3Ke1GsX0= github.com/getlantern/amp v0.0.0-20260305201851-782bc8045e58 h1:3wxMKw90adxiEzsJmAmMHqBJQr/P/9Goqy/U2a1l/sg= github.com/getlantern/amp v0.0.0-20260305201851-782bc8045e58/go.mod h1:p6WdG48YAz5SCUpiMSGLy616A6YghKToc63y3NP7avI= -github.com/getlantern/broflake v0.0.0-20260501210609-ce5f75aa2054 h1:nrRMiRRjzR43yihrVxdnmmt66ZqjRhHE73TyHW1ySgg= -github.com/getlantern/broflake v0.0.0-20260501210609-ce5f75aa2054/go.mod h1:bZGGfTwne9NIsy3Kc1avcXNWn/yA8ghUwlXdS2z+AlA= +github.com/getlantern/broflake v0.0.0-20260504215251-ed3cf75062d1 h1:3WYvObOo8gpKwjcLrV6O/vRp+ubKdjpvJwZrRkbbDWw= +github.com/getlantern/broflake v0.0.0-20260504215251-ed3cf75062d1/go.mod h1:bZGGfTwne9NIsy3Kc1avcXNWn/yA8ghUwlXdS2z+AlA= github.com/getlantern/common v1.2.1-0.20260326210434-cb69537aaf46 h1:Ab2esudqgFz2K1WYQKtX+58kaiVMX0UohjW2XmdEgf4= github.com/getlantern/common v1.2.1-0.20260326210434-cb69537aaf46/go.mod h1:eSSuV4bMPgQJnczBw+KWWqWNo1itzmVxC++qUBPRTt0= github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 h1:oEZYEpZo28Wdx+5FZo4aU7JFXu0WG/4wJWese5reQSA= @@ -248,8 +248,6 @@ github.com/getlantern/keepcurrent v0.0.0-20260422161259-54a4d9a93694 h1:iLWm6S/4 github.com/getlantern/keepcurrent v0.0.0-20260422161259-54a4d9a93694/go.mod h1:ag5g9aWUw2FJcX5RVRpJ9EBQBy5yJuy2WXDouIn/m4w= github.com/getlantern/kindling v0.0.0-20260428171407-6143132aaf40 h1:P5pkaBGxWOGBn7bKzjzdln/ro+ShG1RUbOuy+7pSzXE= github.com/getlantern/kindling v0.0.0-20260428171407-6143132aaf40/go.mod h1:TGTxpoNVwc8Be4qkBNtf5oj2psJaEIZEq47GOPS7zkA= -github.com/getlantern/lantern-box v0.0.77 h1:2b2TyrPXYHzIx1aPUvpE//AxoW0TMl/EF/bQHaZyfqw= -github.com/getlantern/lantern-box v0.0.77/go.mod h1:YV6+5bOdvw9rmc0cJoOTP7UaFt/6XWVOierv7KcfAkY= github.com/getlantern/lantern-water v0.0.0-20260317143726-e0ee64a11d90 h1:P9JX1yAu2uq3b5YiT0sLtHkTrkZuttV8gPZh81nUuag= github.com/getlantern/lantern-water v0.0.0-20260317143726-e0ee64a11d90/go.mod h1:3JpJgwi4KEI6rS9loOAvcBp+F2jP65d0tTg2GQcTPBU= github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534 h1:3BwvWj0JZzFEvNNiMhCu4bf60nqcIuQpTYb00Ezm1ag= diff --git a/peer/peer.go b/peer/peer.go index 8530e5ee..268ca1dd 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -12,6 +12,7 @@ import ( "github.com/sagernet/sing-box/experimental/libbox" + "github.com/getlantern/lantern-box/tracker/peerconn" "github.com/getlantern/radiance/events" "github.com/getlantern/radiance/portforward" ) @@ -23,6 +24,19 @@ type StatusEvent struct { Status Status `json:"status"` } +// ConnectionEvent fires every time a remote client opens or closes a +// samizdat session against the local peer's inbound. Source carries the +// remote "ip:port" string; consumers (the globe view, abuse aggregation) +// extract the IP for geo-lookup or rate-limit attribution. +// +// State +1 on accept, -1 on close +// Source remote peer "ip:port" +type ConnectionEvent struct { + events.Event + State int `json:"state"` + Source string `json:"source"` +} + // Lower bound avoids well-known/registered ports; upper bound stays below the // typical OS ephemeral range so the OS isn't likely to assign the same port // to another local process. @@ -156,6 +170,11 @@ func (c *Client) Start(ctx context.Context) error { // registered route + router rule. cleanupCtx, cancel := context.WithTimeout(context.Background(), peerCleanupTimeout) defer cancel() + // Always clear the connection listener on rollback. The listener is + // only Set on the success path, so this is a no-op if Start failed + // before reaching it — but cheap insurance against a future re-order + // that registers earlier. + peerconn.SetListener(nil) if box != nil { _ = box.Close() } @@ -219,6 +238,16 @@ func (c *Client) Start(ctx context.Context) error { return fmt.Errorf("start sing-box: %w", err) } + // Forward inbound accept/close events from lantern-box's samizdat + // inbound to the radiance event bus, so consumers (the Flutter globe, + // future abuse aggregation) get a per-connection stream. Listener is + // process-wide single-active; cleared on Stop. This must run AFTER + // box.Start() so the box's accept loop is live when the listener is + // registered. + peerconn.SetListener(func(state int, source string) { + events.Emit(ConnectionEvent{State: state, Source: source}) + }) + heartbeat := c.cfg.HeartbeatInterval if heartbeat == 0 { heartbeat = time.Duration(regResp.HeartbeatIntervalSeconds) * time.Second @@ -283,6 +312,11 @@ func (c *Client) Stop(ctx context.Context) error { c.status = Status{} c.mu.Unlock() + // Clear the connection listener BEFORE box.Close so any in-flight + // accept-loop callbacks land on a no-op rather than emit ConnectionEvents + // after the consumer side has already torn down its subscription. + peerconn.SetListener(nil) + cancel() <-done From 48e0f6f197cf0d0b3017b35161c7f52bc8ebb549 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Thu, 7 May 2026 12:41:52 -0600 Subject: [PATCH 16/35] peer: serve live connection snapshot on 127.0.0.1:17099/peer/connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a localhost HTTP endpoint exposing the active samizdat connection set as JSON, fed by the lantern-box peerconn listener registered when peer.Client.Start succeeds. Replaces the planned full Go→FFI→Dart event channel for the prototype with poll-driven Dart consumption — much smaller surface, same data shape, swap with a streaming FFI events path later without changing the Dart side. Loopback-only: net.Listen 127.0.0.1 enforces it at the kernel level, plus a defense-in-depth host check on each request in case someone later misconfigures RADIANCE_PEER_STATS_ADDR to a non-loopback bind. The endpoint reveals connected client IPs which we don't want surfaced beyond the local machine. Co-Authored-By: Claude Opus 4.7 (1M context) --- peer/connstats.go | 199 ++++++++++++++++++++++++++++++++++++++++++++++ peer/peer.go | 30 +++++-- 2 files changed, 221 insertions(+), 8 deletions(-) create mode 100644 peer/connstats.go diff --git a/peer/connstats.go b/peer/connstats.go new file mode 100644 index 00000000..7aa3520b --- /dev/null +++ b/peer/connstats.go @@ -0,0 +1,199 @@ +package peer + +import ( + "context" + "encoding/json" + "net" + "net/http" + "os" + "strings" + "sync" + "time" + + "github.com/getlantern/lantern-box/tracker/peerconn" +) + +// connStatsServer is the localhost HTTP endpoint Flutter polls to render +// the live globe. It maintains an in-memory set of active source IPs by +// subscribing to peerconn lifecycle notifications, and serves the current +// snapshot as JSON on GET /peer/connections. +// +// This is a deliberately simple bridge for the prototype: it skips the +// proper Go→FFI→Dart event channel (which Adam's lantern#8492 had a +// pattern for but is on a stale branch with merge conflicts) in favour of +// a poll loop. Replace with a streaming FFI events path once the broader +// peer-share / unbounded plumbing lands; the data shape is intentionally +// the same so Dart consumers don't need to change. +// +// Listen address: +// - RADIANCE_PEER_STATS_ADDR env var if set (e.g. "127.0.0.1:17099") +// - default 127.0.0.1:17099 +// +// 127.0.0.1 only — never bound to public interfaces. The endpoint reveals +// active proxy clients' IP addresses, which we don't want surfaced to +// anyone outside the local user's machine. +const defaultConnStatsAddr = "127.0.0.1:17099" + +type connEntry struct { + Source string `json:"source"` + Since time.Time `json:"since"` + Inbound int `json:"-"` // for refcount on duplicate accepts (re-uses) + id int // monotonic id for stable equality across snapshots +} + +type connSnapshot struct { + Sources []string `json:"sources"` + ActiveCount int `json:"active_count"` + GeneratedAt time.Time `json:"generated_at"` + ListenerHits int64 `json:"listener_hits"` +} + +type connStats struct { + mu sync.Mutex + active map[string]*connEntry + hits int64 + server *http.Server + listener net.Listener +} + +func newConnStats() *connStats { + return &connStats{active: make(map[string]*connEntry)} +} + +// note records a +1 or -1 transition. Source is "ip:port". +func (s *connStats) note(state int, source string) { + s.mu.Lock() + defer s.mu.Unlock() + s.hits++ + if state == +1 { + if e, ok := s.active[source]; ok { + e.Inbound++ + return + } + s.active[source] = &connEntry{ + Source: source, + Since: time.Now(), + Inbound: 1, + } + } else if state == -1 { + if e, ok := s.active[source]; ok { + e.Inbound-- + if e.Inbound <= 0 { + delete(s.active, source) + } + } + } +} + +func (s *connStats) snapshot() connSnapshot { + s.mu.Lock() + defer s.mu.Unlock() + out := connSnapshot{ + Sources: make([]string, 0, len(s.active)), + ActiveCount: len(s.active), + GeneratedAt: time.Now(), + ListenerHits: s.hits, + } + for src := range s.active { + out.Sources = append(out.Sources, src) + } + return out +} + +// start spins up the HTTP server. Returns an error if the listen address +// is already in use; falls back to a kernel-assigned port (":0" suffix) +// only if the configured address conflicts and the env var was unset, so +// users who explicitly pinned a port get a clean failure. +func (s *connStats) start(parent context.Context) error { + addr := os.Getenv("RADIANCE_PEER_STATS_ADDR") + envSet := addr != "" + if !envSet { + addr = defaultConnStatsAddr + } + + ln, err := net.Listen("tcp", addr) + if err != nil { + if envSet { + return err + } + // Default already taken — try a random localhost port so a second + // app instance still surfaces some endpoint rather than failing. + ln, err = net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return err + } + } + s.listener = ln + + mux := http.NewServeMux() + mux.HandleFunc("/peer/connections", func(w http.ResponseWriter, r *http.Request) { + // Strict localhost gate. net.Listen on 127.0.0.1 already prevents + // remote connections, but a misconfigured listener (e.g. someone + // changing addr to ":17099" later) would happily accept LAN + // requests; this is a defense-in-depth check. + host, _, splitErr := net.SplitHostPort(r.RemoteAddr) + if splitErr != nil || !isLoopback(host) { + http.Error(w, "loopback only", http.StatusForbidden) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(s.snapshot()) + }) + + s.server = &http.Server{ + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + } + go func() { + _ = s.server.Serve(ln) + }() + + // Tear down when the parent context is cancelled. + go func() { + <-parent.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = s.server.Shutdown(shutdownCtx) + }() + return nil +} + +func (s *connStats) addr() string { + if s.listener == nil { + return "" + } + return s.listener.Addr().String() +} + +func isLoopback(host string) bool { + host = strings.TrimSuffix(strings.TrimPrefix(host, "["), "]") + if host == "localhost" { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +// startConnStats wires the lantern-box peerconn listener through to a new +// connStats instance and starts its HTTP server. Returns the stats object +// (so peer.Client can read snapshots for its own internal stats) and an +// error if the HTTP listener can't be bound. +// +// On success the connection-event listener registered via peerconn is the +// stats notifier; callers SHOULD NOT register a competing listener while +// stats is running. Stop is by cancelling the supplied ctx. +func startConnStats(ctx context.Context) (*connStats, error) { + s := newConnStats() + if err := s.start(ctx); err != nil { + return nil, err + } + peerconn.SetListener(func(state int, source string) { + s.note(state, source) + }) + go func() { + <-ctx.Done() + peerconn.SetListener(nil) + }() + return s, nil +} + diff --git a/peer/peer.go b/peer/peer.go index 268ca1dd..4b94724a 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -239,14 +239,28 @@ func (c *Client) Start(ctx context.Context) error { } // Forward inbound accept/close events from lantern-box's samizdat - // inbound to the radiance event bus, so consumers (the Flutter globe, - // future abuse aggregation) get a per-connection stream. Listener is - // process-wide single-active; cleared on Stop. This must run AFTER - // box.Start() so the box's accept loop is live when the listener is - // registered. - peerconn.SetListener(func(state int, source string) { - events.Emit(ConnectionEvent{State: state, Source: source}) - }) + // inbound to the radiance event bus AND a localhost HTTP stats + // endpoint that Flutter polls to render the live globe. Listener is + // process-wide single-active; cleared automatically when runCtx + // cancels (in Stop / rollback). Must run AFTER box.Start so the + // box's accept loop is serving when notifications start flowing. + stats, statsErr := startConnStats(runCtx) + if statsErr != nil { + // Don't fail Start over a stats-endpoint error — a bound port + // shouldn't kill the user's peer-share session. Log and continue. + slog.Warn("peer connection stats endpoint failed to start", "err", statsErr) + } else { + // startConnStats sets a peerconn listener that feeds the snapshot + // HTTP server. Layer ConnectionEvent emission alongside, since + // Go-side consumers (e.g. metrics) may want the stream too. + peerconn.SetListener(func(state int, source string) { + stats.note(state, source) + events.Emit(ConnectionEvent{State: state, Source: source}) + }) + slog.Info("peer connection stats endpoint listening", + "addr", stats.addr(), + ) + } heartbeat := c.cfg.HeartbeatInterval if heartbeat == 0 { From a1c10cfe36fbd80e8b295e19a74c285388590f9c Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Thu, 7 May 2026 13:14:06 -0600 Subject: [PATCH 17/35] peer: drop localhost HTTP stats endpoint, keep ConnectionEvent emit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HTTP endpoint at 127.0.0.1:17099/peer/connections was added to bridge peer connection lifecycle to Flutter without writing FFI plumbing, but two problems with that approach: 1. Detectability — a fixed loopback port is a Lantern-specific fingerprint any local process (incl. malware) can probe. Sandboxed adversary on the user's machine could detect Lantern is running. 2. Local server adds attack surface for free. Reverting to ConnectionEvent emission only; Flutter consumption rides on the existing FlutterEventEmitter / Dart api_dl bridge in lantern-core (separate commit) which has no port footprint. Co-Authored-By: Claude Opus 4.7 (1M context) --- peer/connstats.go | 199 ---------------------------------------------- peer/peer.go | 32 +++----- 2 files changed, 10 insertions(+), 221 deletions(-) delete mode 100644 peer/connstats.go diff --git a/peer/connstats.go b/peer/connstats.go deleted file mode 100644 index 7aa3520b..00000000 --- a/peer/connstats.go +++ /dev/null @@ -1,199 +0,0 @@ -package peer - -import ( - "context" - "encoding/json" - "net" - "net/http" - "os" - "strings" - "sync" - "time" - - "github.com/getlantern/lantern-box/tracker/peerconn" -) - -// connStatsServer is the localhost HTTP endpoint Flutter polls to render -// the live globe. It maintains an in-memory set of active source IPs by -// subscribing to peerconn lifecycle notifications, and serves the current -// snapshot as JSON on GET /peer/connections. -// -// This is a deliberately simple bridge for the prototype: it skips the -// proper Go→FFI→Dart event channel (which Adam's lantern#8492 had a -// pattern for but is on a stale branch with merge conflicts) in favour of -// a poll loop. Replace with a streaming FFI events path once the broader -// peer-share / unbounded plumbing lands; the data shape is intentionally -// the same so Dart consumers don't need to change. -// -// Listen address: -// - RADIANCE_PEER_STATS_ADDR env var if set (e.g. "127.0.0.1:17099") -// - default 127.0.0.1:17099 -// -// 127.0.0.1 only — never bound to public interfaces. The endpoint reveals -// active proxy clients' IP addresses, which we don't want surfaced to -// anyone outside the local user's machine. -const defaultConnStatsAddr = "127.0.0.1:17099" - -type connEntry struct { - Source string `json:"source"` - Since time.Time `json:"since"` - Inbound int `json:"-"` // for refcount on duplicate accepts (re-uses) - id int // monotonic id for stable equality across snapshots -} - -type connSnapshot struct { - Sources []string `json:"sources"` - ActiveCount int `json:"active_count"` - GeneratedAt time.Time `json:"generated_at"` - ListenerHits int64 `json:"listener_hits"` -} - -type connStats struct { - mu sync.Mutex - active map[string]*connEntry - hits int64 - server *http.Server - listener net.Listener -} - -func newConnStats() *connStats { - return &connStats{active: make(map[string]*connEntry)} -} - -// note records a +1 or -1 transition. Source is "ip:port". -func (s *connStats) note(state int, source string) { - s.mu.Lock() - defer s.mu.Unlock() - s.hits++ - if state == +1 { - if e, ok := s.active[source]; ok { - e.Inbound++ - return - } - s.active[source] = &connEntry{ - Source: source, - Since: time.Now(), - Inbound: 1, - } - } else if state == -1 { - if e, ok := s.active[source]; ok { - e.Inbound-- - if e.Inbound <= 0 { - delete(s.active, source) - } - } - } -} - -func (s *connStats) snapshot() connSnapshot { - s.mu.Lock() - defer s.mu.Unlock() - out := connSnapshot{ - Sources: make([]string, 0, len(s.active)), - ActiveCount: len(s.active), - GeneratedAt: time.Now(), - ListenerHits: s.hits, - } - for src := range s.active { - out.Sources = append(out.Sources, src) - } - return out -} - -// start spins up the HTTP server. Returns an error if the listen address -// is already in use; falls back to a kernel-assigned port (":0" suffix) -// only if the configured address conflicts and the env var was unset, so -// users who explicitly pinned a port get a clean failure. -func (s *connStats) start(parent context.Context) error { - addr := os.Getenv("RADIANCE_PEER_STATS_ADDR") - envSet := addr != "" - if !envSet { - addr = defaultConnStatsAddr - } - - ln, err := net.Listen("tcp", addr) - if err != nil { - if envSet { - return err - } - // Default already taken — try a random localhost port so a second - // app instance still surfaces some endpoint rather than failing. - ln, err = net.Listen("tcp", "127.0.0.1:0") - if err != nil { - return err - } - } - s.listener = ln - - mux := http.NewServeMux() - mux.HandleFunc("/peer/connections", func(w http.ResponseWriter, r *http.Request) { - // Strict localhost gate. net.Listen on 127.0.0.1 already prevents - // remote connections, but a misconfigured listener (e.g. someone - // changing addr to ":17099" later) would happily accept LAN - // requests; this is a defense-in-depth check. - host, _, splitErr := net.SplitHostPort(r.RemoteAddr) - if splitErr != nil || !isLoopback(host) { - http.Error(w, "loopback only", http.StatusForbidden) - return - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(s.snapshot()) - }) - - s.server = &http.Server{ - Handler: mux, - ReadHeaderTimeout: 5 * time.Second, - } - go func() { - _ = s.server.Serve(ln) - }() - - // Tear down when the parent context is cancelled. - go func() { - <-parent.Done() - shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - _ = s.server.Shutdown(shutdownCtx) - }() - return nil -} - -func (s *connStats) addr() string { - if s.listener == nil { - return "" - } - return s.listener.Addr().String() -} - -func isLoopback(host string) bool { - host = strings.TrimSuffix(strings.TrimPrefix(host, "["), "]") - if host == "localhost" { - return true - } - ip := net.ParseIP(host) - return ip != nil && ip.IsLoopback() -} - -// startConnStats wires the lantern-box peerconn listener through to a new -// connStats instance and starts its HTTP server. Returns the stats object -// (so peer.Client can read snapshots for its own internal stats) and an -// error if the HTTP listener can't be bound. -// -// On success the connection-event listener registered via peerconn is the -// stats notifier; callers SHOULD NOT register a competing listener while -// stats is running. Stop is by cancelling the supplied ctx. -func startConnStats(ctx context.Context) (*connStats, error) { - s := newConnStats() - if err := s.start(ctx); err != nil { - return nil, err - } - peerconn.SetListener(func(state int, source string) { - s.note(state, source) - }) - go func() { - <-ctx.Done() - peerconn.SetListener(nil) - }() - return s, nil -} - diff --git a/peer/peer.go b/peer/peer.go index 4b94724a..9f829b03 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -239,28 +239,16 @@ func (c *Client) Start(ctx context.Context) error { } // Forward inbound accept/close events from lantern-box's samizdat - // inbound to the radiance event bus AND a localhost HTTP stats - // endpoint that Flutter polls to render the live globe. Listener is - // process-wide single-active; cleared automatically when runCtx - // cancels (in Stop / rollback). Must run AFTER box.Start so the - // box's accept loop is serving when notifications start flowing. - stats, statsErr := startConnStats(runCtx) - if statsErr != nil { - // Don't fail Start over a stats-endpoint error — a bound port - // shouldn't kill the user's peer-share session. Log and continue. - slog.Warn("peer connection stats endpoint failed to start", "err", statsErr) - } else { - // startConnStats sets a peerconn listener that feeds the snapshot - // HTTP server. Layer ConnectionEvent emission alongside, since - // Go-side consumers (e.g. metrics) may want the stream too. - peerconn.SetListener(func(state int, source string) { - stats.note(state, source) - events.Emit(ConnectionEvent{State: state, Source: source}) - }) - slog.Info("peer connection stats endpoint listening", - "addr", stats.addr(), - ) - } + // inbound to the radiance event bus. Consumers (lantern-core's + // FlutterEventEmitter, future abuse aggregation) subscribe via + // events.Subscribe[ConnectionEvent]. Listener is process-wide + // single-active; cleared on Stop and in the rollback defer so + // post-teardown accept-loop callbacks land on a no-op rather than + // emit events to a torn-down consumer. Must run AFTER box.Start so + // the accept loop is serving when notifications start flowing. + peerconn.SetListener(func(state int, source string) { + events.Emit(ConnectionEvent{State: state, Source: source}) + }) heartbeat := c.cfg.HeartbeatInterval if heartbeat == 0 { From 361a645c379db792533feb0c085e63822935493d Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Thu, 7 May 2026 14:12:35 -0600 Subject: [PATCH 18/35] peer: support manual port-forward override via RADIANCE_PEER_EXTERNAL_PORT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Many users can't or don't use UPnP — routers ship with UPnP off for security, ISP gateways without IGD, networks behind double-NAT — but can manually configure a port forward on their router. Today peer.Client fails to start in those environments because NewForwarder only knows how to talk UPnP IGDv2/v1. This adds a ManualForwarder that satisfies the peer.portForwarder interface without router interaction: - MapPort returns the configured port unchanged (1:1 NAT — every consumer router exposes port forwarding as a single port number, and splitting external/internal isn't a real-world use case) - UnmapPort and StartRenewal are no-ops (user owns the router rule) - ExternalIP probes a public-IP discovery service since no UPnP gateway is available to ask peer.Client.Start now reads RADIANCE_PEER_EXTERNAL_PORT at the NewForwarder factory; if set, ManualForwarder is used instead of UPnP discovery. Surfacing this as a proper user-facing setting (so non-engineers can configure it without env vars) is a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) --- peer/peer.go | 19 ++++++ portforward/manual.go | 119 +++++++++++++++++++++++++++++++++++++ portforward/manual_test.go | 105 ++++++++++++++++++++++++++++++++ 3 files changed, 243 insertions(+) create mode 100644 portforward/manual.go create mode 100644 portforward/manual_test.go diff --git a/peer/peer.go b/peer/peer.go index 9f829b03..f19e3b5f 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -7,6 +7,7 @@ import ( "fmt" "log/slog" "math/rand/v2" + "os" "sync" "time" @@ -115,6 +116,24 @@ func NewClient(cfg Config) (*Client, error) { } if cfg.NewForwarder == nil { cfg.NewForwarder = func(ctx context.Context) (portForwarder, error) { + // Manual port-forward override (RADIANCE_PEER_EXTERNAL_PORT). + // Use case: networks where UPnP is disabled or unavailable + // (router has UPnP off for security, ISP-provided gateways + // without IGD, networks behind double-NAT) but the user has + // manually configured a port forward on their router. We + // trust the user's config — no UPnP roundtrip — and report + // the configured port as both the external and internal + // port (the 1:1 case every consumer router exposes). The + // peer's samizdat inbound binds on this port and + // lantern-cloud is told to advertise the same port to + // connecting clients. + if extStr := os.Getenv("RADIANCE_PEER_EXTERNAL_PORT"); extStr != "" { + port, err := portforward.ParseManualPort(extStr) + if err != nil { + return nil, fmt.Errorf("RADIANCE_PEER_EXTERNAL_PORT: %w", err) + } + return portforward.NewManualForwarder(port) + } // Explicitly return a nil interface on error — `return // portforward.NewForwarder(ctx)` collapses the (*Forwarder, error) // pair into a typed-nil interface on failure, which then panics diff --git a/portforward/manual.go b/portforward/manual.go new file mode 100644 index 00000000..75ff1d07 --- /dev/null +++ b/portforward/manual.go @@ -0,0 +1,119 @@ +package portforward + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/getlantern/publicip" +) + +// ManualForwarder is a no-op port forwarder for users who can't (or won't) +// use UPnP and have manually configured a port forward on their router. +// Assumes a 1:1 router mapping (WAN:port → LAN:port), which covers every +// real-world manual setup we've seen: routers expose port forwarding as +// a single port number, mapping the same port externally and internally. +// MapPort synthesises a Mapping with that single port, UnmapPort and +// StartRenewal are no-ops, and ExternalIP probes a public-IP discovery +// service since there's no UPnP gateway to ask. +// +// Constructed via NewManualForwarder when peer.Client detects the +// RADIANCE_PEER_EXTERNAL_PORT env var (or a future "manual port" +// setting). Use cases: routers with UPnP disabled (most common), users +// who deliberately turned UPnP off for security, ISP-provided gateways +// that ship without IGD, networks where the user has port-forwarded by +// hand because UPnP didn't work. +type ManualForwarder struct { + port uint16 + mapping *Mapping +} + +// NewManualForwarder returns a ManualForwarder for the given TCP port, +// which it reports as both the external (WAN-side) and internal +// (LAN-side) port. Splitting them isn't supported — every manual +// router-config UI we've seen treats port forwarding as a single port +// number, and the 1:1 case is the only one that comes up in practice. +func NewManualForwarder(port uint16) (*ManualForwarder, error) { + if port == 0 { + return nil, fmt.Errorf("manual forwarder requires non-zero port") + } + return &ManualForwarder{port: port}, nil +} + +// MapPort returns the manually-configured port as a synthetic Mapping. +// Ignores the caller-supplied internalPort — the manual forwarder +// already has its port fixed at construction. description is unused; +// real router configuration was done by the user out-of-band. +func (f *ManualForwarder) MapPort(_ context.Context, _ uint16, _ string) (*Mapping, error) { + if f.mapping != nil { + return nil, fmt.Errorf("manual forwarder already has an active mapping") + } + internalIP, err := localIP() + if err != nil { + return nil, fmt.Errorf("determine local ip: %w", err) + } + f.mapping = &Mapping{ + ExternalPort: f.port, + InternalPort: f.port, + InternalIP: internalIP, + Protocol: "TCP", + LeaseDuration: 0, // user-managed; no router-side TTL we own + Method: "manual", + } + return f.mapping, nil +} + +func (f *ManualForwarder) UnmapPort(_ context.Context) error { + if f == nil { + return nil + } + // Nothing to undo — the user owns the router-side mapping. Just + // drop our local handle so a subsequent MapPort doesn't error. + f.mapping = nil + return nil +} + +// StartRenewal is a no-op — manual forwards persist on the router until +// the user removes them, with no UPnP lease to renew. +func (f *ManualForwarder) StartRenewal(_ context.Context) {} + +// ExternalIP probes a public-IP discovery service (the radiance default +// methods, hitting api.iantem.io etc.) since there's no UPnP gateway to +// ask. Cached for the duration of the forwarder's life — the user's +// public IP shouldn't change mid-session, and a stale cache yields a +// clean re-register on a heartbeat 404 if it does. +func (f *ManualForwarder) ExternalIP(ctx context.Context) (string, error) { + probeCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + result, err := publicip.Detect(probeCtx, &publicip.Config{ + Timeout: 5 * time.Second, + MinConsensus: 1, + }) + if err != nil { + return "", fmt.Errorf("detect public ip: %w", err) + } + if result.IP == nil { + return "", fmt.Errorf("public-ip detection returned no result") + } + return result.IP.String(), nil +} + +// ParseManualPort is a small helper for callers that want to read a +// port from a string (env var, settings value). Returns 0, nil when s +// is empty so callers can use "no port configured" as the empty case +// without distinguishing it from a parse failure caller-side. +func ParseManualPort(s string) (uint16, error) { + if s == "" { + return 0, nil + } + n, err := strconv.Atoi(s) + if err != nil { + return 0, fmt.Errorf("invalid port %q: %w", s, err) + } + if n < 1 || n > 65535 { + return 0, fmt.Errorf("port %d out of range (1-65535)", n) + } + return uint16(n), nil +} + diff --git a/portforward/manual_test.go b/portforward/manual_test.go new file mode 100644 index 00000000..58484dae --- /dev/null +++ b/portforward/manual_test.go @@ -0,0 +1,105 @@ +package portforward + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewManualForwarder_RejectsZeroPort(t *testing.T) { + t.Parallel() + _, err := NewManualForwarder(0) + assert.Error(t, err) +} + +func TestNewManualForwarder_StoresPort(t *testing.T) { + t.Parallel() + f, err := NewManualForwarder(5698) + require.NoError(t, err) + assert.Equal(t, uint16(5698), f.port) +} + +func TestManualForwarder_MapPort_ReturnsConfiguredPort(t *testing.T) { + t.Parallel() + f, err := NewManualForwarder(5698) + require.NoError(t, err) + + // Caller-passed internalPort is intentionally ignored — the manual + // forwarder already has its port fixed at construction. + m, err := f.MapPort(context.Background(), 12345, "ignored description") + require.NoError(t, err) + assert.Equal(t, uint16(5698), m.ExternalPort, + "manual forwarder must report the configured port, not the "+ + "random one peer.Client.pickInternalPort happened to pass in") + assert.Equal(t, uint16(5698), m.InternalPort, + "1:1 router mapping — internal == external") + assert.Equal(t, "TCP", m.Protocol) + assert.Equal(t, "manual", m.Method) + assert.NotEmpty(t, m.InternalIP, "internal IP should resolve via localIP()") +} + +func TestManualForwarder_MapPort_RejectsDoubleMap(t *testing.T) { + t.Parallel() + f, _ := NewManualForwarder(5698) + _, err := f.MapPort(context.Background(), 0, "") + require.NoError(t, err) + _, err = f.MapPort(context.Background(), 0, "") + assert.Error(t, err, "second MapPort on a forwarder with an active mapping should fail") +} + +func TestManualForwarder_UnmapPort_AllowsRemap(t *testing.T) { + t.Parallel() + f, _ := NewManualForwarder(5698) + _, err := f.MapPort(context.Background(), 0, "") + require.NoError(t, err) + require.NoError(t, f.UnmapPort(context.Background())) + _, err = f.MapPort(context.Background(), 0, "") + assert.NoError(t, err) +} + +func TestManualForwarder_UnmapPort_NilSafe(t *testing.T) { + t.Parallel() + var f *ManualForwarder + assert.NoError(t, f.UnmapPort(context.Background()), + "nil receiver UnmapPort should be a no-op (matches *Forwarder behavior)") +} + +func TestManualForwarder_StartRenewal_NoOp(t *testing.T) { + t.Parallel() + f, _ := NewManualForwarder(5698) + f.StartRenewal(context.Background()) // must not panic +} + +func TestParseManualPort(t *testing.T) { + t.Parallel() + + t.Run("empty returns zero, no error", func(t *testing.T) { + t.Parallel() + p, err := ParseManualPort("") + require.NoError(t, err) + assert.Equal(t, uint16(0), p) + }) + + t.Run("valid port", func(t *testing.T) { + t.Parallel() + p, err := ParseManualPort("5698") + require.NoError(t, err) + assert.Equal(t, uint16(5698), p) + }) + + t.Run("non-numeric rejected", func(t *testing.T) { + t.Parallel() + _, err := ParseManualPort("not-a-port") + assert.Error(t, err) + }) + + t.Run("out of range rejected", func(t *testing.T) { + t.Parallel() + for _, s := range []string{"0", "-1", "65536", "999999"} { + _, err := ParseManualPort(s) + assert.Error(t, err, "expected error for %q", s) + } + }) +} From 869dc9dccc0d4de76fcfbfcf781d4dd533e0e8b7 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Thu, 7 May 2026 14:28:47 -0600 Subject: [PATCH 19/35] peer: read PeerManualPortKey setting alongside RADIANCE_PEER_EXTERNAL_PORT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds settings.PeerManualPortKey so the user-facing Advanced UI can persist the manual port forward without an env var. Resolution order in peer.Client.Start's NewForwarder: 1. settings.PeerManualPortKey (Advanced UI in lantern Flutter) 2. RADIANCE_PEER_EXTERNAL_PORT env var (developer / power-user) 3. UPnP discovery (default) The setting is wired through lantern-core's PatchSettings(PeerShareEnabledKey...) path on a separate branch — the new `setPeerManualPort` FFI export over there calls PatchSettings({PeerManualPortKey: }) which lands in radiance's settings store and gets picked up on the next peer.Client.Start. Co-Authored-By: Claude Opus 4.7 (1M context) --- common/settings/settings.go | 7 +++++++ peer/peer.go | 32 +++++++++++++++++++++----------- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/common/settings/settings.go b/common/settings/settings.go index f8673a58..6e2357bf 100644 --- a/common/settings/settings.go +++ b/common/settings/settings.go @@ -55,6 +55,13 @@ const ( AdBlockKey _key = "ad_block" // bool AutoConnectKey _key = "auto_connect" // bool PeerShareEnabledKey _key = "peer_share_enabled" // bool + // PeerManualPortKey is the TCP port number the user has manually + // forwarded on their router (single-port 1:1 NAT). When non-zero, + // peer.Client.Start uses portforward.ManualForwarder with this port + // instead of probing UPnP. Surfaced as an Advanced setting in the + // Share My Connection UI for users on networks where UPnP is + // disabled or unavailable. + PeerManualPortKey _key = "peer_manual_port" // int (0 = use UPnP) SelectedServerKey _key = "selected_server" // [servers.Server] Server.Options is not stored PreferredLocationKey _key = "preferred_location" // [common.PreferredLocation] diff --git a/peer/peer.go b/peer/peer.go index f19e3b5f..d69dac80 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -14,6 +14,7 @@ import ( "github.com/sagernet/sing-box/experimental/libbox" "github.com/getlantern/lantern-box/tracker/peerconn" + "github.com/getlantern/radiance/common/settings" "github.com/getlantern/radiance/events" "github.com/getlantern/radiance/portforward" ) @@ -116,17 +117,26 @@ func NewClient(cfg Config) (*Client, error) { } if cfg.NewForwarder == nil { cfg.NewForwarder = func(ctx context.Context) (portForwarder, error) { - // Manual port-forward override (RADIANCE_PEER_EXTERNAL_PORT). - // Use case: networks where UPnP is disabled or unavailable - // (router has UPnP off for security, ISP-provided gateways - // without IGD, networks behind double-NAT) but the user has - // manually configured a port forward on their router. We - // trust the user's config — no UPnP roundtrip — and report - // the configured port as both the external and internal - // port (the 1:1 case every consumer router exposes). The - // peer's samizdat inbound binds on this port and - // lantern-cloud is told to advertise the same port to - // connecting clients. + // Manual port-forward override. Use case: networks where + // UPnP is disabled or unavailable (router has UPnP off for + // security, ISP-provided gateways without IGD, networks + // behind double-NAT) but the user has manually configured + // a port forward on their router. We trust the user's + // config — no UPnP roundtrip — and report the configured + // port as both the external and internal port (the 1:1 + // case every consumer router exposes). The peer's samizdat + // inbound binds on this port and lantern-cloud is told to + // advertise the same port to connecting clients. + // + // Resolution order: + // 1. settings.PeerManualPortKey (Advanced UI in the + // Share My Connection screen) + // 2. RADIANCE_PEER_EXTERNAL_PORT env var (developer / + // power-user override) + // 3. fall through to UPnP discovery + if port := uint16(settings.GetInt(settings.PeerManualPortKey)); port != 0 { + return portforward.NewManualForwarder(port) + } if extStr := os.Getenv("RADIANCE_PEER_EXTERNAL_PORT"); extStr != "" { port, err := portforward.ParseManualPort(extStr) if err != nil { From afc173eb00f9a1fa4048baf35d4c0233b8ccb979 Mon Sep 17 00:00:00 2001 From: Myles Horton Date: Thu, 7 May 2026 16:24:21 -0600 Subject: [PATCH 20/35] ci: open a PR for fronted refresh instead of pushing direct to main (#467) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: open a PR for fronted refresh instead of pushing direct to main The scheduled refresh of kindling/fronted/fronted.yaml.gz was direct- pushing to main, which the repo's pull_request rule (ruleset "copilot-review") now rejects with GH013. Switch to peter-evans/ create-pull-request so the daily refresh produces a PR on a stable branch (chore/refresh-fronted-config) — successive runs supersede the prior unmerged refresh rather than piling up. Add pull-requests: write to the workflow's permissions block. Failing run for reference: https://github.com/getlantern/radiance/actions/runs/25478096992 Co-Authored-By: Claude Opus 4.7 (1M context) * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Adam Fisk Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/refresh-fronted-config.yml | 33 ++++++++++---------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/.github/workflows/refresh-fronted-config.yml b/.github/workflows/refresh-fronted-config.yml index 9ee7d9c9..ac1d1ed1 100644 --- a/.github/workflows/refresh-fronted-config.yml +++ b/.github/workflows/refresh-fronted-config.yml @@ -13,6 +13,7 @@ on: permissions: contents: write + pull-requests: write # Serialize concurrent runs (cron + manual dispatch can race). cancel-in-progress # is false so a manual dispatch during a cron run still completes rather than @@ -46,20 +47,20 @@ jobs: echo "changed=true" >> "$GITHUB_OUTPUT" fi - - name: Commit and push + # Open a PR rather than pushing directly: main is protected by a + # "Changes must be made through a pull request" repo rule. The PR + # branch is reused day-to-day so a stale unmerged refresh gets + # superseded by the next one rather than piling up as separate PRs. + - name: Open / update refresh PR if: steps.diff.outputs.changed == 'true' - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add kindling/fronted/fronted.yaml.gz - git commit -m "fronted: refresh embedded fronted.yaml.gz" - # Rebase-and-retry to survive concurrent commits to the target branch. - for attempt in 1 2 3; do - if git push; then - exit 0 - fi - echo "Push attempt $attempt failed, rebasing on latest origin and retrying..." - git pull --rebase origin "$(git rev-parse --abbrev-ref HEAD)" - done - echo "Push failed after 3 attempts" >&2 - exit 1 + uses: peter-evans/create-pull-request@8a45c9a0f9071f4b1e4a0f3b660a1e9e3d9f0d7f # v7 + with: + commit-message: "fronted: refresh embedded fronted.yaml.gz" + title: "fronted: refresh embedded fronted.yaml.gz" + body: | + Automated daily refresh of `kindling/fronted/fronted.yaml.gz` + from `getlantern/fronted@main`. Safe to merge once CI passes. + branch: chore/refresh-fronted-config + delete-branch: true + author: "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>" + committer: "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>" From 71c8a8b5b1c2a98489c4efa1a13635f2a9a6828b Mon Sep 17 00:00:00 2001 From: garmr-ulfr <104022054+garmr-ulfr@users.noreply.github.com> Date: Thu, 7 May 2026 16:22:51 -0700 Subject: [PATCH 21/35] vpn: tune base box options and route RU locale to Yandex DNS (#470) * vpn: tune base box options and route RU locale to Yandex DNS - drop explicit MTU=1500 to let sing-box pick the platform default - enable EndpointIndependentNat for QUIC migration and UDP hole-punching - enable StoreFakeIP and StoreRDRC so fake-IP and reject-cache survive restarts - drop RU from AliDNS locales; resolve via Yandex (77.88.8.8) instead * use Yandex DNS for RURU - normalized ru_RU * fix test --- vpn/boxoptions.go | 18 ++++++++++-------- vpn/dnsoptions.go | 34 +++++++++++++++++++--------------- vpn/dnsoptions_test.go | 22 +++++++++++----------- 3 files changed, 40 insertions(+), 34 deletions(-) diff --git a/vpn/boxoptions.go b/vpn/boxoptions.go index 12137eec..cda33b58 100644 --- a/vpn/boxoptions.go +++ b/vpn/boxoptions.go @@ -115,9 +115,9 @@ func baseOpts(basePath string) O.Options { Address: []netip.Prefix{ netip.MustParsePrefix("10.10.1.1/30"), }, - AutoRoute: true, - StrictRoute: true, - MTU: 1500, + AutoRoute: true, + StrictRoute: true, + EndpointIndependentNat: true, // needed for QUIC migration and hole-punching }, }, { @@ -164,9 +164,11 @@ func baseOpts(basePath string) O.Options { ExternalController: "", // intentionally left empty }, CacheFile: &O.CacheFileOptions{ - Enabled: true, - Path: cacheFile, - CacheID: cacheID, + Enabled: true, + Path: cacheFile, + CacheID: cacheID, + StoreFakeIP: true, + StoreRDRC: true, }, }, } @@ -182,8 +184,8 @@ func baseRoutingRules() []O.Rule { // 4. Route private IPs to direct outbound // 5. Split tunnel rule (user-configurable) // 6. Rules from config file (added in buildOptions) - // 7-9. Group rules for auto, lantern, and user (added in buildOptions) - // 10. Catch-all blocking rule (added in buildOptions). This ensures that any traffic not covered + // 7,8. Group rules for auto and manual selector modes (added in buildOptions). + // 9. Catch-all blocking rule (added in buildOptions). This ensures that any traffic not covered // by previous rules does not automatically bypass the VPN. // // * DO NOT change the order of these rules unless you know what you're doing. Changing these diff --git a/vpn/dnsoptions.go b/vpn/dnsoptions.go index 02a975cd..88d986a4 100644 --- a/vpn/dnsoptions.go +++ b/vpn/dnsoptions.go @@ -5,18 +5,18 @@ import ( "net/netip" "strings" - "github.com/getlantern/radiance/common/settings" "github.com/miekg/dns" "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing/common/json/badoption" + + "github.com/getlantern/radiance/common/settings" ) // buildDNSServers returns a list of three DNSServerOptions, a local DNS server -// used for local requests; a remote DNS server (like quad9) -// for remote websites without sharing user private IP; and fake IP dns server, which -// effectively resolves DNS locally while allowing us to route traffic based on -// domains. +// used for local requests; a remote DNS server (like quad9) for remote websites +// without sharing user private IP; and fake IP dns server, which effectively resolves +// DNS locally while allowing us to route traffic based on domains. func buildDNSServers() []option.DNSServerOptions { local := option.DNSServerOptions{ Tag: "dns_local", @@ -71,6 +71,12 @@ func buildDNSServers() []option.DNSServerOptions { } } +const ( + aliDNS = "223.5.5.5" + yandexDNS = "77.88.8.8" + quad9DNS = "9.9.9.9" +) + // Locales where AliDNS is used as local DNS server. Note that AliDNS is // primarily attractive because it is accessible but is understood to return // results that are DNS poisoned for many sites. This is fine because our @@ -79,30 +85,28 @@ func buildDNSServers() []option.DNSServerOptions { var aliDNSLocales = map[string]struct{}{ "FAIR": {}, "ZHCN": {}, - "RURU": {}, "CN": {}, "IR": {}, - "RU": {}, } func localDNSIP() string { - // First, normalize the locale to upper case and remove any hyphens or underscores. locale := settings.GetString(settings.LocaleKey) normalizedLocale := normalizeLocale(locale) if _, ok := aliDNSLocales[normalizedLocale]; ok { slog.Info("Using AliDNS for locale", "locale", locale) - // AliDNS - return "223.5.5.5" + return aliDNS + } + if normalizedLocale == "RU" || normalizedLocale == "RURU" { + slog.Info("Using Yandex DNS for locale", "locale", locale) + return yandexDNS } - // Quad9, which is more privacy preserving by doing things such as - // not sending EDNS Client-Subnet data + // default to Quad9 slog.Info("Using Quad9 for locale", "locale", locale) - return "9.9.9.9" + return quad9DNS } // normalizeLocale normalizes the locale string by converting it to upper case -// and removing any hyphens or underscores. Locales can come it from all platforms in various -// formats, so this helps standardize them for comparison. +// and removing any hyphens or underscores. func normalizeLocale(locale string) string { return strings.ReplaceAll(strings.ReplaceAll(strings.ToUpper(locale), "-", ""), "_", "") } diff --git a/vpn/dnsoptions_test.go b/vpn/dnsoptions_test.go index 9f5866b8..b0d60c2b 100644 --- a/vpn/dnsoptions_test.go +++ b/vpn/dnsoptions_test.go @@ -77,57 +77,57 @@ func TestLocalDNSIP(t *testing.T) { { name: "FAIR locale returns AliDNS", locale: "FAIR", - expected: "223.5.5.5", + expected: aliDNS, }, { name: "fair lowercase returns AliDNS", locale: "fair", - expected: "223.5.5.5", + expected: aliDNS, }, { name: "ZHCN locale returns AliDNS", locale: "ZHCN", - expected: "223.5.5.5", + expected: aliDNS, }, { name: "zh-cn with hyphen returns AliDNS", locale: "zh-cn", - expected: "223.5.5.5", + expected: aliDNS, }, { name: "zh_cn with underscore returns AliDNS", locale: "zh_cn", - expected: "223.5.5.5", + expected: aliDNS, }, { name: "RURU locale returns AliDNS", locale: "RURU", - expected: "223.5.5.5", + expected: yandexDNS, }, { name: "ru-ru with hyphen returns AliDNS", locale: "ru-ru", - expected: "223.5.5.5", + expected: yandexDNS, }, { name: "en-US returns Quad9", locale: "en-US", - expected: "9.9.9.9", + expected: quad9DNS, }, { name: "enus returns Quad9", locale: "enus", - expected: "9.9.9.9", + expected: quad9DNS, }, { name: "empty locale returns Quad9", locale: "", - expected: "9.9.9.9", + expected: quad9DNS, }, { name: "unknown locale returns Quad9", locale: "fr-FR", - expected: "9.9.9.9", + expected: quad9DNS, }, } From fa40e472cf87b09bc53ae56f2f9e8f5ab2203745 Mon Sep 17 00:00:00 2001 From: atavism Date: Thu, 7 May 2026 16:48:23 -0700 Subject: [PATCH 22/35] code review updates --- account/subscription.go | 26 +++++++++++++++----------- account/subscription_test.go | 20 ++++++++++++++++++++ account/user_test.go | 22 ++++++++++++++-------- 3 files changed, 49 insertions(+), 19 deletions(-) diff --git a/account/subscription.go b/account/subscription.go index 136d9e93..081fb1e2 100644 --- a/account/subscription.go +++ b/account/subscription.go @@ -180,12 +180,14 @@ func (a *Client) SubscriptionPaymentRedirectURL(ctx context.Context, data Paymen ctx, span := otel.Tracer(tracerName).Start(ctx, "subscription_payment_redirect_url") defer span.End() params := map[string]string{ - "provider": data.Provider, - "plan": data.Plan, - "deviceName": data.DeviceName, - "email": data.Email, - "billingType": string(data.BillingType), - "idempotencyKey": data.IdempotencyKey, + "provider": data.Provider, + "plan": data.Plan, + "deviceName": data.DeviceName, + "email": data.Email, + "billingType": string(data.BillingType), + } + if data.IdempotencyKey != "" { + params["idempotencyKey"] = data.IdempotencyKey } return a.paymentRedirect(ctx, "/subscription-payment-redirect", params) } @@ -196,11 +198,13 @@ func (a *Client) PaymentRedirect(ctx context.Context, data PaymentRedirectData) ctx, span := otel.Tracer(tracerName).Start(ctx, "payment_redirect") defer span.End() params := map[string]string{ - "provider": data.Provider, - "plan": data.Plan, - "deviceName": data.DeviceName, - "email": data.Email, - "idempotencyKey": data.IdempotencyKey, + "provider": data.Provider, + "plan": data.Plan, + "deviceName": data.DeviceName, + "email": data.Email, + } + if data.IdempotencyKey != "" { + params["idempotencyKey"] = data.IdempotencyKey } return a.paymentRedirect(ctx, "/payment-redirect", params) } diff --git a/account/subscription_test.go b/account/subscription_test.go index 651fc834..8b1006d6 100644 --- a/account/subscription_test.go +++ b/account/subscription_test.go @@ -39,6 +39,26 @@ func TestPaymentRedirect(t *testing.T) { assert.Equal(t, data.IdempotencyKey, ts.paymentRedirectIdempotencyKey) } +func TestPaymentRedirectOmitsEmptyIdempotencyKey(t *testing.T) { + ac, ts := newTestClient(t) + data := PaymentRedirectData{ + Provider: "stripe", + Plan: "pro", + DeviceName: "test-device", + Email: "", + } + + url, err := ac.PaymentRedirect(context.Background(), data) + require.NoError(t, err) + assert.NotEmpty(t, url) + assert.False(t, ts.paymentRedirectHasIdempotencyKey) + + url, err = ac.SubscriptionPaymentRedirectURL(context.Background(), data) + require.NoError(t, err) + assert.NotEmpty(t, url) + assert.False(t, ts.subscriptionPaymentRedirectHasIdempotencyKey) +} + func TestPaymentRedirectRequiresRedirectURL(t *testing.T) { ac, ts := newTestClient(t) ts.paymentRedirectResponse = map[string]string{"status": "error", "error": "try again later"} diff --git a/account/user_test.go b/account/user_test.go index 94d60272..bb103f73 100644 --- a/account/user_test.go +++ b/account/user_test.go @@ -22,12 +22,14 @@ import ( // testServer holds server-side SRP state for the mock auth server. type testServer struct { - salt map[string][]byte - verifier []byte - cache map[string]string - paymentRedirectIdempotencyKey string - subscriptionPaymentRedirectIdempotencyKey string - paymentRedirectResponse any + salt map[string][]byte + verifier []byte + cache map[string]string + paymentRedirectIdempotencyKey string + paymentRedirectHasIdempotencyKey bool + subscriptionPaymentRedirectIdempotencyKey string + subscriptionPaymentRedirectHasIdempotencyKey bool + paymentRedirectResponse any } func writeProtoResponse(w http.ResponseWriter, msg proto.Message) { @@ -183,12 +185,16 @@ func newTestServer(t *testing.T) (*httptest.Server, *testServer) { }) mux.HandleFunc("/subscription-payment-redirect", func(w http.ResponseWriter, r *http.Request) { - state.subscriptionPaymentRedirectIdempotencyKey = r.URL.Query().Get("idempotencyKey") + values := r.URL.Query() + state.subscriptionPaymentRedirectIdempotencyKey = values.Get("idempotencyKey") + _, state.subscriptionPaymentRedirectHasIdempotencyKey = values["idempotencyKey"] writeJSONResponse(w, map[string]string{"redirect": "https://example.com/redirect"}) }) mux.HandleFunc("/payment-redirect", func(w http.ResponseWriter, r *http.Request) { - state.paymentRedirectIdempotencyKey = r.URL.Query().Get("idempotencyKey") + values := r.URL.Query() + state.paymentRedirectIdempotencyKey = values.Get("idempotencyKey") + _, state.paymentRedirectHasIdempotencyKey = values["idempotencyKey"] resp := state.paymentRedirectResponse if resp == nil { resp = map[string]string{"redirect": "https://example.com/redirect"} From e715af91218bb6c41660a27a69e74a08b3b01d5d Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Thu, 7 May 2026 18:48:45 -0600 Subject: [PATCH 23/35] unbounded: integrate broflake widget-proxy lifecycle manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the radiance side of the Unbounded ("Basic mode" in the SmC UI) WebRTC donor-mode integration. Self-contained package under radiance/unbounded; drops cleanly onto the current branch without needing the larger vpn/ refactor that adam/unbounded-widget-proxy ships with. unbounded.SetEnabled(bool) toggles the local opt-in (settings.UnboundedKey). InitSubscription wires the manager to config.NewConfigEvent — the broflake widget actually runs only when: 1. settings.UnboundedKey is true (local opt-in) 2. server cfg.Features[UNBOUNDED] is on 3. server provides cfg.Unbounded discovery + egress URLs Each consumer connection change emits unbounded.ConnectionEvent on the radiance event bus, mirroring the shape of peer.ConnectionEvent so lantern-core subscribers can feed both into one Flutter event stream. Wired into LocalBackend.Start so the manager is live for the process lifetime; sync.Once-guarded against double-subscribe. Mostly a port of the unbounded.go file from adam/unbounded-widget-proxy (getlantern/radiance#336), with the package moved out of vpn/ since that branch's vpn/ is undergoing a separate refactor and we want the unbounded code to land independently. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/radiance.go | 8 ++ common/settings/settings.go | 8 ++ unbounded/unbounded.go | 245 ++++++++++++++++++++++++++++++++++++ 3 files changed, 261 insertions(+) create mode 100644 unbounded/unbounded.go diff --git a/backend/radiance.go b/backend/radiance.go index 68f362aa..df3f8a82 100644 --- a/backend/radiance.go +++ b/backend/radiance.go @@ -39,6 +39,7 @@ import ( "github.com/getlantern/radiance/servers" "github.com/getlantern/radiance/telemetry" "github.com/getlantern/radiance/traces" + "github.com/getlantern/radiance/unbounded" "github.com/getlantern/radiance/vpn" "github.com/sagernet/sing-box/adapter" @@ -230,6 +231,13 @@ func (r *LocalBackend) Start() { r.resumePeerShareIfEnabled() + // Wire the broflake / Unbounded widget proxy lifecycle to config + // updates. This single subscription handles all three start/stop + // triggers (local toggle, server feature flag, server-supplied + // config); InitSubscription is sync.Once-guarded so a future Start + // retry after Close won't double-subscribe. + unbounded.InitSubscription() + // set country code in settings when new config is received so it can be included in issue reports events.SubscribeOnce(func(evt config.NewConfigEvent) { if env.GetString(env.Country) != "" { diff --git a/common/settings/settings.go b/common/settings/settings.go index 6e2357bf..d731ea67 100644 --- a/common/settings/settings.go +++ b/common/settings/settings.go @@ -62,6 +62,14 @@ const ( // Share My Connection UI for users on networks where UPnP is // disabled or unavailable. PeerManualPortKey _key = "peer_manual_port" // int (0 = use UPnP) + // UnboundedKey is the local opt-in for the broflake / Unbounded + // widget proxy. When true AND the server-side Features[unbounded] + // flag is on AND the server provides UnboundedConfig (discovery + // + egress URLs), vpn.InitUnboundedSubscription starts the widget + // proxy. Surfaced as a "Basic mode" option in the Share My + // Connection UI for networks where UPnP isn't workable but the + // user still wants to contribute via the WebRTC-based donor path. + UnboundedKey _key = "unbounded" // bool SelectedServerKey _key = "selected_server" // [servers.Server] Server.Options is not stored PreferredLocationKey _key = "preferred_location" // [common.PreferredLocation] diff --git a/unbounded/unbounded.go b/unbounded/unbounded.go new file mode 100644 index 00000000..aee88841 --- /dev/null +++ b/unbounded/unbounded.go @@ -0,0 +1,245 @@ +// Package unbounded manages the broflake / Unbounded widget-proxy lifecycle. +// +// Unbounded is the WebRTC-based donor mode for Lantern's Share My Connection +// feature: the local user contributes bandwidth to censored users via short- +// lived WebRTC sessions brokered through a discovery server, without exposing +// a long-lived inbound port the way the samizdat-over-UPnP "Share My +// Connection" mode does. It's the lower-bandwidth, lower-risk, universally- +// applicable alternative to SmC — works on networks where UPnP is disabled +// or unavailable, and the peer's residential IP isn't tied to a single +// long-lived inbound listener. +// +// Three conditions must all hold for the widget proxy to actually run: +// +// 1. settings.UnboundedKey is true (local opt-in via the UI toggle) +// 2. server-side cfg.Features[UNBOUNDED] is enabled (server says go) +// 3. server-side cfg.Unbounded provides discovery + egress URLs +// +// The manager subscribes to config.NewConfigEvent and recomputes the +// running state on every config update; it also re-evaluates when +// SetEnabled flips the local toggle. Each consumer connection change +// (accept / disconnect) emits a ConnectionEvent on the radiance event +// bus so the same Flutter globe used for SmC can render arcs without +// caring which protocol produced them. +package unbounded + +import ( + "context" + "log/slog" + "net" + "sync" + + C "github.com/getlantern/common" + + "github.com/getlantern/broflake/clientcore" + + "github.com/getlantern/radiance/common/settings" + "github.com/getlantern/radiance/config" + "github.com/getlantern/radiance/events" +) + +// ConnectionEvent fires every time a consumer (i.e. a censored client +// being routed through this widget proxy) connects or disconnects via +// the broflake mesh. State: +1 on accept, -1 on close. WorkerIdx is +// broflake's internal worker slot identifier — used by the Flutter +// globe to pair connect/disconnect events for the same arc. Addr is +// the remote consumer's IP if broflake exposes it, otherwise empty. +// +// Shape mirrors radiance/peer.ConnectionEvent so consumers (lantern- +// core's listenPeerConnectionEvents in particular) can subscribe with +// a single discriminator and feed both the SmC and Unbounded streams +// into the same globe view. +type ConnectionEvent struct { + events.Event + State int `json:"state"` + WorkerIdx int `json:"workerIdx"` + Addr string `json:"addr"` +} + +var manager = &unboundedManager{} + +type unboundedManager struct { + mu sync.Mutex + cancel context.CancelFunc + lastCfg *C.UnboundedConfig // most recent server-supplied config +} + +// Enabled reports whether the local opt-in is set. Doesn't say whether +// the proxy is currently running (server flag and config can override). +func Enabled() bool { + return settings.GetBool(settings.UnboundedKey) +} + +// SetEnabled flips the local opt-in. When enabling, the proxy starts +// immediately if a server config is already cached; otherwise it +// starts on the next config event. When disabling, the proxy stops. +// Idempotent — calling with the current value is a no-op. +func SetEnabled(enable bool) error { + if Enabled() == enable { + return nil + } + if err := settings.Set(settings.UnboundedKey, enable); err != nil { + return err + } + slog.Info("Unbounded widget proxy local opt-in changed", "enabled", enable) + if enable { + manager.mu.Lock() + cfg := manager.lastCfg + manager.mu.Unlock() + if cfg != nil { + manager.start(cfg) + } else { + slog.Info("Unbounded: enabled locally, will start when server config arrives") + } + } else { + manager.stop() + } + return nil +} + +// InitSubscription wires the manager into radiance's config event bus. +// Called once at LocalBackend startup; the subscription lives for the +// process lifetime, so repeated calls would leak goroutines — hence +// the package-level guard. +func InitSubscription() { + initOnce.Do(func() { + events.Subscribe(func(evt config.NewConfigEvent) { + if evt.New == nil { + return + } + // config.Config is a type alias for C.ConfigResponse on + // the current radiance branch — no nested .ConfigResponse + // field, just dereference and use directly. + cfg := *evt.New + manager.mu.Lock() + manager.lastCfg = cfg.Unbounded + running := manager.cancel != nil + manager.mu.Unlock() + + shouldRun := shouldRunUnbounded(cfg) + switch { + case shouldRun && !running: + manager.start(cfg.Unbounded) + case !shouldRun && running: + manager.stop() + } + }) + }) +} + +var initOnce sync.Once + +// Stop tears down a running widget proxy. Idempotent. Used as a +// LocalBackend shutdown hook so the broflake goroutines don't outlive +// the radiance process during a graceful exit. +func Stop(_ context.Context) error { + manager.stop() + return nil +} + +func shouldRunUnbounded(cfg C.ConfigResponse) bool { + if !settings.GetBool(settings.UnboundedKey) { + return false + } + if !cfg.Features[C.UNBOUNDED] { + return false + } + if cfg.Unbounded == nil { + return false + } + return true +} + +func (m *unboundedManager) start(ucfg *C.UnboundedConfig) { + m.mu.Lock() + defer m.mu.Unlock() + if m.cancel != nil { + return // already running + } + + ctx, cancel := context.WithCancel(context.Background()) + m.cancel = cancel + + go func() { + slog.Info("Unbounded: starting broflake widget proxy") + + bfOpt := clientcore.NewDefaultBroflakeOptions() + bfOpt.ClientType = "widget" + if ucfg != nil { + if ucfg.CTableSize > 0 { + bfOpt.CTableSize = ucfg.CTableSize + } + if ucfg.PTableSize > 0 { + bfOpt.PTableSize = ucfg.PTableSize + } + } + + // Wire the broflake connection callback into the radiance event + // bus so the Flutter globe (and any future abuse aggregation) + // sees consumer connect/disconnect. + bfOpt.OnConnectionChangeFunc = func(state int, workerIdx int, addr net.IP) { + addrStr := "" + if addr != nil { + addrStr = addr.String() + } + slog.Debug("Unbounded: consumer connection change", + "state", state, "workerIdx", workerIdx, "addr", addrStr) + events.Emit(ConnectionEvent{ + State: state, + WorkerIdx: workerIdx, + Addr: addrStr, + }) + } + + rtcOpt := clientcore.NewDefaultWebRTCOptions() + if ucfg != nil { + if ucfg.DiscoverySrv != "" { + rtcOpt.DiscoverySrv = ucfg.DiscoverySrv + } + if ucfg.DiscoveryEndpoint != "" { + rtcOpt.Endpoint = ucfg.DiscoveryEndpoint + } + } + + egOpt := clientcore.NewDefaultEgressOptions() + if ucfg != nil { + if ucfg.EgressAddr != "" { + egOpt.Addr = ucfg.EgressAddr + } + if ucfg.EgressEndpoint != "" { + egOpt.Endpoint = ucfg.EgressEndpoint + } + } + + // BroflakeConn is for clients routing traffic *through* the + // mesh. A widget proxy only donates bandwidth, so the conn + // is unused — discard it. + _, ui, err := clientcore.NewBroflake(bfOpt, rtcOpt, egOpt) + if err != nil { + slog.Error("Unbounded: failed to create broflake widget", "error", err) + cancel() + m.mu.Lock() + m.cancel = nil + m.mu.Unlock() + return + } + + slog.Info("Unbounded: broflake widget proxy started") + <-ctx.Done() + slog.Info("Unbounded: stopping broflake widget proxy") + ui.Stop() + m.mu.Lock() + m.cancel = nil + m.mu.Unlock() + slog.Info("Unbounded: broflake widget proxy stopped") + }() +} + +func (m *unboundedManager) stop() { + m.mu.Lock() + defer m.mu.Unlock() + if m.cancel != nil { + m.cancel() + m.cancel = nil + } +} From a292331f2e45206183ab1fcc6202deef72427122 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Fri, 8 May 2026 10:40:50 -0600 Subject: [PATCH 24/35] portforward: ManualForwarder.ExternalIP returns "" again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original manualPortForwarder (introduced in b81b6b0) returned "", nil from ExternalIP, leaning on lantern-cloud peer_handler's "external_ip empty → use observed" fall-through to fill the IP from the /v1/peer/register call's RemoteAddr. The refactor into portforward.ManualForwarder regressed this path by actively probing publicip.Detect, which fails on the only deployment where it matters: machines with Lantern's own VPN tunnel up. When the tunnel is up, outbound traffic routes through it. The publicip discovery endpoints (api.iantem.io etc) either time out or return the tunnel exit's IP rather than the user's WAN IP — neither is what we want. Worse, the failure surfaces as a hard error so peer.Client.Start aborts before /v1/peer/register is even called, breaking peer-share for every user with a manual port + an active tunnel. Empty is also more correct: peer_handler uses the IP that will actually receive inbound traffic on the manually-forwarded port (the register-call's RemoteAddr), which is by definition the right answer. Add a regression test pinning the empty-string contract. Co-Authored-By: Claude Opus 4.7 (1M context) --- portforward/manual.go | 32 ++++++++++---------------------- portforward/manual_test.go | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 22 deletions(-) diff --git a/portforward/manual.go b/portforward/manual.go index 75ff1d07..8d9dea9f 100644 --- a/portforward/manual.go +++ b/portforward/manual.go @@ -4,9 +4,6 @@ import ( "context" "fmt" "strconv" - "time" - - "github.com/getlantern/publicip" ) // ManualForwarder is a no-op port forwarder for users who can't (or won't) @@ -78,25 +75,16 @@ func (f *ManualForwarder) UnmapPort(_ context.Context) error { // the user removes them, with no UPnP lease to renew. func (f *ManualForwarder) StartRenewal(_ context.Context) {} -// ExternalIP probes a public-IP discovery service (the radiance default -// methods, hitting api.iantem.io etc.) since there's no UPnP gateway to -// ask. Cached for the duration of the forwarder's life — the user's -// public IP shouldn't change mid-session, and a stale cache yields a -// clean re-register on a heartbeat 404 if it does. -func (f *ManualForwarder) ExternalIP(ctx context.Context) (string, error) { - probeCtx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - result, err := publicip.Detect(probeCtx, &publicip.Config{ - Timeout: 5 * time.Second, - MinConsensus: 1, - }) - if err != nil { - return "", fmt.Errorf("detect public ip: %w", err) - } - if result.IP == nil { - return "", fmt.Errorf("public-ip detection returned no result") - } - return result.IP.String(), nil +// ExternalIP returns "" so the server fills in the observed IP from the +// register call's RemoteAddr (peer_handler's "external_ip empty → use +// observed" path). Probing publicip.Detect from here regresses on +// machines where Lantern's own tunnel is up — outbound goes through the +// tunnel and the discovery endpoints either time out or report the +// tunnel exit's IP rather than the user's WAN IP. Letting the server +// use the observed RemoteAddr is also more correct: it's the IP that +// will actually receive inbound traffic on the manually-forwarded port. +func (f *ManualForwarder) ExternalIP(_ context.Context) (string, error) { + return "", nil } // ParseManualPort is a small helper for callers that want to read a diff --git a/portforward/manual_test.go b/portforward/manual_test.go index 58484dae..18daef5f 100644 --- a/portforward/manual_test.go +++ b/portforward/manual_test.go @@ -72,6 +72,23 @@ func TestManualForwarder_StartRenewal_NoOp(t *testing.T) { f.StartRenewal(context.Background()) // must not panic } +// TestManualForwarder_ExternalIP_ReturnsEmpty pins the contract that +// ExternalIP returns "" with no error so the lantern-cloud peer_handler +// fills the IP from the register call's RemoteAddr. A previous revision +// regressed this to call publicip.Detect, which fails on machines where +// Lantern's own VPN tunnel is up — outbound traffic gets routed through +// the tunnel and the discovery endpoints return the tunnel exit's IP +// (or time out entirely), breaking peer registration silently. +func TestManualForwarder_ExternalIP_ReturnsEmpty(t *testing.T) { + t.Parallel() + f, err := NewManualForwarder(5698) + require.NoError(t, err) + ip, err := f.ExternalIP(context.Background()) + require.NoError(t, err) + assert.Empty(t, ip, + "ManualForwarder.ExternalIP must return \"\" so server uses observed RemoteAddr") +} + func TestParseManualPort(t *testing.T) { t.Parallel() From 16443934ea98ea4eb4c4fec75aa0b04808c84859 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Wed, 6 May 2026 14:01:07 -0600 Subject: [PATCH 25/35] peer: call /peer/verify after starting sing-box; fix doubled /v1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes that pair with the lantern-cloud /peer/verify split: 1. peer/api.go: drop the leading /v1 from peer endpoint paths. baseURL already ends with /api/v1 (from common.GetBaseURL), so /v1/peer/register was hitting /api/v1/v1/peer/register on prod and 404'ing. Every other radiance API caller appends without /v1 (config/fetcher.go, issue/issue.go); peer/api.go was the odd one out. Updated NewAPI's docstring to spell out the convention. 2. peer/peer.go: after box.Start succeeds, call API.Verify(routeID). The server's verifier dials back through the peer's external port using the just-built creds, so the inbound has to be listening before verify runs. Splitting verify out of register resolves the chicken-and-egg where register-time verify could never see a peer that didn't yet have its cert. Verify failure here is fatal — the server has already deprecated the row, so the deferred cleanup tears the rest of the session down. 3. peer/api.go: new API.Verify(ctx, routeID) wrapping POST /peer/verify. Tests: stubServer's mux handles the new /peer/verify route plus verifyCount / verifyDeviceID / verifyStatus knobs. Existing tests exercise the new step transparently because they use the default verifyStatus=200. Co-Authored-By: Claude Opus 4.7 (1M context) --- peer/api.go | 25 ++++++++++++++++++++----- peer/peer.go | 15 ++++++++++++++- peer/peer_test.go | 19 ++++++++++++++++--- 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/peer/api.go b/peer/api.go index 05752402..06fae20e 100644 --- a/peer/api.go +++ b/peer/api.go @@ -47,32 +47,47 @@ type API struct { deviceID string } -// NewAPI constructs the client. baseURL must not have a trailing slash and -// must not include "/v1" — that's appended per-endpoint. +// NewAPI constructs the client. baseURL must already include the API +// version prefix (matches common.GetBaseURL() which returns ".../api/v1"); +// per-endpoint paths are appended without re-adding /v1, mirroring every +// other radiance caller of common.GetBaseURL (config/fetcher.go, +// issue/issue.go, etc.). func NewAPI(httpClient *http.Client, baseURL, deviceID string) *API { return &API{httpClient: httpClient, baseURL: baseURL, deviceID: deviceID} } func (a *API) Register(ctx context.Context, req RegisterRequest) (*RegisterResponse, error) { var resp RegisterResponse - if err := a.do(ctx, http.MethodPost, "/v1/peer/register", req, &resp); err != nil { + if err := a.do(ctx, http.MethodPost, "/peer/register", req, &resp); err != nil { return nil, fmt.Errorf("register: %w", err) } return &resp, nil } +// Verify asks lantern-cloud to dial the peer's external endpoint through a +// freshly-built samizdat client. Called after Start has finished bringing +// up sing-box locally so the server's verifier hits a live listener with +// the matching creds. Server-side failure deprecates the row + returns +// 422; the caller treats that as a fatal Start error and tears down. +func (a *API) Verify(ctx context.Context, routeID string) error { + if err := a.do(ctx, http.MethodPost, "/peer/verify", LifecycleRequest{RouteID: routeID}, nil); err != nil { + return fmt.Errorf("verify: %w", err) + } + return nil +} + // Heartbeat extends the peer route's TTL. The server owner-gates via // X-Lantern-Device-Id, so a leaked route_id can't be used by another device // to keep the registration alive. func (a *API) Heartbeat(ctx context.Context, routeID string) error { - if err := a.do(ctx, http.MethodPost, "/v1/peer/heartbeat", LifecycleRequest{RouteID: routeID}, nil); err != nil { + if err := a.do(ctx, http.MethodPost, "/peer/heartbeat", LifecycleRequest{RouteID: routeID}, nil); err != nil { return fmt.Errorf("heartbeat: %w", err) } return nil } func (a *API) Deregister(ctx context.Context, routeID string) error { - if err := a.do(ctx, http.MethodPost, "/v1/peer/deregister", LifecycleRequest{RouteID: routeID}, nil); err != nil { + if err := a.do(ctx, http.MethodPost, "/peer/deregister", LifecycleRequest{RouteID: routeID}, nil); err != nil { return fmt.Errorf("deregister: %w", err) } return nil diff --git a/peer/peer.go b/peer/peer.go index d69dac80..f01c73ec 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -267,6 +267,17 @@ func (c *Client) Start(ctx context.Context) error { return fmt.Errorf("start sing-box: %w", err) } + // Now that sing-box is listening with the just-built creds, ask the + // server to dial back through them. Splitting verify out of Register + // into this explicit follow-up avoids the chicken-and-egg where the + // server tried to verify before the peer could possibly be listening + // (the cert/key only arrive in the Register response). Failure here + // is fatal — the server has already deprecated the row, so the + // deferred cleanup tears the rest of the session down. + if err := c.cfg.API.Verify(ctx, regResp.RouteID); err != nil { + return fmt.Errorf("verify with lantern-cloud: %w", err) + } + // Forward inbound accept/close events from lantern-box's samizdat // inbound to the radiance event bus. Consumers (lantern-core's // FlutterEventEmitter, future abuse aggregation) subscribe via @@ -274,7 +285,9 @@ func (c *Client) Start(ctx context.Context) error { // single-active; cleared on Stop and in the rollback defer so // post-teardown accept-loop callbacks land on a no-op rather than // emit events to a torn-down consumer. Must run AFTER box.Start so - // the accept loop is serving when notifications start flowing. + // the accept loop is serving when notifications start flowing. We + // set it after Verify so the verifier's transient probe connection + // doesn't surface as a real peer-connection event in the UI. peerconn.SetListener(func(state int, source string) { events.Emit(ConnectionEvent{State: state, Source: source}) }) diff --git a/peer/peer_test.go b/peer/peer_test.go index 870dbfe8..e9b5ad13 100644 --- a/peer/peer_test.go +++ b/peer/peer_test.go @@ -139,12 +139,15 @@ type stubServer struct { server *httptest.Server registerStatus int registerResp RegisterResponse + verifyStatus int heartbeatStatus int deregisterStatus int registerCount atomic.Int64 + verifyCount atomic.Int64 heartbeatCount atomic.Int64 deregisterCount atomic.Int64 registerDeviceID atomic.Value // string + verifyDeviceID atomic.Value // string heartbeatDeviceID atomic.Value // string deregisterDeviceID atomic.Value // string lastRegisterReq atomic.Value // RegisterRequest @@ -155,6 +158,7 @@ func newStubServer(t *testing.T) *stubServer { s := &stubServer{ t: t, registerStatus: http.StatusOK, + verifyStatus: http.StatusOK, heartbeatStatus: http.StatusOK, deregisterStatus: http.StatusOK, registerResp: RegisterResponse{ @@ -164,7 +168,7 @@ func newStubServer(t *testing.T) *stubServer { }, } mux := http.NewServeMux() - mux.HandleFunc("/v1/peer/register", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("/peer/register", func(w http.ResponseWriter, r *http.Request) { s.registerCount.Add(1) s.registerDeviceID.Store(r.Header.Get("X-Lantern-Device-Id")) var req RegisterRequest @@ -176,7 +180,16 @@ func newStubServer(t *testing.T) *stubServer { } _ = json.NewEncoder(w).Encode(s.registerResp) }) - mux.HandleFunc("/v1/peer/heartbeat", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("/peer/verify", func(w http.ResponseWriter, r *http.Request) { + s.verifyCount.Add(1) + s.verifyDeviceID.Store(r.Header.Get("X-Lantern-Device-Id")) + if s.verifyStatus != http.StatusOK { + http.Error(w, "verify failed", s.verifyStatus) + return + } + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("/peer/heartbeat", func(w http.ResponseWriter, r *http.Request) { s.heartbeatCount.Add(1) s.heartbeatDeviceID.Store(r.Header.Get("X-Lantern-Device-Id")) if s.heartbeatStatus != http.StatusOK { @@ -185,7 +198,7 @@ func newStubServer(t *testing.T) *stubServer { } w.WriteHeader(http.StatusOK) }) - mux.HandleFunc("/v1/peer/deregister", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("/peer/deregister", func(w http.ResponseWriter, r *http.Request) { s.deregisterCount.Add(1) s.deregisterDeviceID.Store(r.Header.Get("X-Lantern-Device-Id")) if s.deregisterStatus != http.StatusOK { From 8ce106b3afcc2d16562d3b8e215e83f5b10eeb99 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Thu, 7 May 2026 10:47:10 -0600 Subject: [PATCH 26/35] peer: forward common headers (notably X-Lantern-Config-Client-IP) on every peer endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit peer/api.go was building requests with bare http.NewRequestWithContext, skipping the X-Lantern-Config-Client-IP / X-Lantern-User-Id / version header set that /config-new sends via common.NewRequestWithHeaders. That mattered for /peer/register specifically: the server's util.ClientIPWithAddr (lantern-cloud cmd/api/util/header.go:155-184) prefers X-Lantern-Config-Client-IP over X-Forwarded-For and RemoteAddr when resolving clientIP. With the header missing, the server fell back to whatever its X-Forwarded-For chain produced — potentially a different IP than the radiance-detected publicIP, leading the verifier to dial back to an address the peer's listener wasn't bound to. Switching to common.NewRequestWithHeaders makes peer endpoints consistent with /config-new's header set: - X-Lantern-Config-Client-IP (the key one for verify-dial targeting) - X-Lantern-App-Version, X-Lantern-Version, X-Lantern-Platform, X-Lantern-App, X-Lantern-User-Id, X-Lantern-Time-Zone, X-Lantern-Rand DeviceIDHeader is set by NewRequestWithHeaders from settings; we explicitly re-set it to a.deviceID afterward for parity with the prior behavior in case the two ever diverge. Adds TestAPI_ForwardsCommonHeaders which hits all four peer endpoints against a stub server and asserts each carries the expected headers (uses common.SetPublicIP / Cleanup to avoid leaking into other tests). Co-Authored-By: Claude Opus 4.7 (1M context) --- peer/api.go | 14 +++++- peer/peer_test.go | 124 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 2 deletions(-) diff --git a/peer/api.go b/peer/api.go index 06fae20e..60a7f12a 100644 --- a/peer/api.go +++ b/peer/api.go @@ -10,6 +10,7 @@ import ( "io" "net/http" + "github.com/getlantern/radiance/common" "github.com/getlantern/radiance/common/settings" ) @@ -102,14 +103,23 @@ func (a *API) do(ctx context.Context, method, path string, body, out any) error } reqBody = bytes.NewReader(buf) } - r, err := http.NewRequestWithContext(ctx, method, a.baseURL+path, reqBody) + // Use common.NewRequestWithHeaders so peer endpoints carry the same + // header set as /config-new — most importantly X-Lantern-Config-Client-IP, + // which the server's util.ClientIPWithAddr prefers over X-Forwarded-For + // and RemoteAddr. Without it, register/verify can resolve a different + // IP than radiance has detected as the client's public IP, and the + // server's verifier dials an address the peer's listener isn't bound to. + r, err := common.NewRequestWithHeaders(ctx, method, a.baseURL+path, reqBody) if err != nil { return fmt.Errorf("build request: %w", err) } if body != nil { r.Header.Set("Content-Type", "application/json") } - r.Header.Set("X-Lantern-Device-Id", a.deviceID) + // NewRequestWithHeaders sets DeviceIDHeader from settings; override with + // the API's bound deviceID for parity with the prior behavior in case + // the two ever diverge. + r.Header.Set(common.DeviceIDHeader, a.deviceID) // Forward the same feature-override header that config/fetcher.go uses // for /config-new requests, so QA can flip on `peer_proxy` ahead of the // public-flag rollout via FeatureOverridesKey (RADIANCE_FEATURE_OVERRIDES). diff --git a/peer/peer_test.go b/peer/peer_test.go index e9b5ad13..805eb8ce 100644 --- a/peer/peer_test.go +++ b/peer/peer_test.go @@ -14,6 +14,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/getlantern/radiance/common" "github.com/getlantern/radiance/events" "github.com/getlantern/radiance/portforward" ) @@ -556,3 +557,126 @@ func TestClient_StatusEventEmittedOnStartAndStop(t *testing.T) { var _ portForwarder = (*fakeForwarder)(nil) var _ boxService = (*fakeBoxService)(nil) + +// TestDefaultBuildBoxService_DecodesSamizdatInbound is the regression net +// for the "missing inbound fields registry in context" failure that bit +// us live: defaultBuildBoxService used to call libbox.NewServiceWithContext +// with a fresh ctx that didn't have the lantern-box protocol registries +// (samizdat, reflex, …) plumbed in, so the JSON decoder couldn't resolve +// inbounds[0].type="samizdat" → libbox.NewServiceWithContext returned an +// error → applyPeerShare rolled the toggle back. The integration tests +// stub BuildBoxService entirely, so neither the libbox setup nor the +// samizdat decoder were exercised in CI. +// +// Calling defaultBuildBoxService directly with a minimal samizdat-inbound +// options JSON walks the actual decode path. If the registry is missing +// in the ctx that defaultBuildBoxService produces, libbox returns the +// "missing inbound fields registry" error and this test fails before any +// of the runtime cycle (rebuild, redeploy, toggle UI, dial-back) — what +// used to take a 5-minute round-trip is now a 0.1s test failure. +func TestDefaultBuildBoxService_DecodesSamizdatInbound(t *testing.T) { + // Minimal but complete samizdat inbound — every field that + // option.SamizdatInboundOptions's json tags require to round-trip. + // Values are placeholders; we don't run the box, just decode. + const opts = `{ + "inbounds": [{ + "type": "samizdat", + "tag": "samizdat-in", + "listen": "127.0.0.1", + "listen_port": 5698, + "private_key": "0000000000000000000000000000000000000000000000000000000000000000", + "short_ids": ["0000000000000000"], + "cert_pem": "-----BEGIN CERTIFICATE-----\nMIIBhTCCASugAwIBAgIQCHOFXAcuEzPfyHK6LdwxwzAKBggqhkjOPQQDAjATMREw\nDwYDVQQKEwhJbnRlcm5ldDAeFw0yNjA1MDYwMDAwMDBaFw0yNzA1MDYwMDAwMDBa\nMBMxETAPBgNVBAoTCEludGVybmV0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE\nb6xQ7UDl11wL/8mZwLxrNqx6JJ+FczIw9V0a9Q3CYUYFGu5DzVyDUwmfVTZiQ+wR\nkQXjrkAwsOWK99JsM3R2bqNIMEYwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoG\nCCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwEQYDVR0RBAowCIIGdGVzdC5xMAoGCCqG\nSM49BAMCA0kAMEYCIQCqhyaQaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaIh\nAOaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa=\n-----END CERTIFICATE-----\n", + "key_pem": "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIBaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaoAoGCCqGSM49\nAwEHoUQDQgAEb6xQ7UDl11wL/8mZwLxrNqx6JJ+FczIw9V0a9Q3CYUYFGu5DzVyD\nUwmfVTZiQ+wRkQXjrkAwsOWK99JsM3R2bg==\n-----END EC PRIVATE KEY-----\n", + "masquerade_domain": "example.com" + }] + }` + + bs, err := defaultBuildBoxService(context.Background(), opts) + require.NoError(t, err, "defaultBuildBoxService must decode a samizdat inbound — "+ + "the lantern-box protocol registries have to be in ctx") + require.NotNil(t, bs) + // We never call Start; just verifying the decode path. Close drops + // any background structures libbox might have stood up. + _ = bs.Close() +} + +// All four peer endpoints must carry the same standard header set as +// /config-new (X-Lantern-Config-Client-IP in particular). The server's +// util.ClientIPWithAddr prefers that header over X-Forwarded-For and +// RemoteAddr; without it, register/verify resolve a different IP than +// radiance has detected, and the server's verifier dials an address the +// peer's listener isn't bound to. +func TestAPI_ForwardsCommonHeaders(t *testing.T) { + const fakePublicIP = "198.51.100.7" + common.SetPublicIP(fakePublicIP) + t.Cleanup(func() { common.SetPublicIP("") }) + + type capture struct { + clientIP string + deviceID string + platform string + appName string + userAgent string + } + captured := make(map[string]capture) + var mu sync.Mutex + record := func(path string, r *http.Request) { + mu.Lock() + defer mu.Unlock() + captured[path] = capture{ + clientIP: r.Header.Get(common.ClientIPHeader), + deviceID: r.Header.Get(common.DeviceIDHeader), + platform: r.Header.Get(common.PlatformHeader), + appName: r.Header.Get(common.AppNameHeader), + userAgent: r.Header.Get("User-Agent"), + } + } + + mux := http.NewServeMux() + mux.HandleFunc("/peer/register", func(w http.ResponseWriter, r *http.Request) { + record("/peer/register", r) + _ = json.NewEncoder(w).Encode(RegisterResponse{ + RouteID: "00000000-0000-0000-0000-000000000123", + ServerConfig: `{}`, + HeartbeatIntervalSeconds: 60, + }) + }) + mux.HandleFunc("/peer/verify", func(w http.ResponseWriter, r *http.Request) { + record("/peer/verify", r) + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("/peer/heartbeat", func(w http.ResponseWriter, r *http.Request) { + record("/peer/heartbeat", r) + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("/peer/deregister", func(w http.ResponseWriter, r *http.Request) { + record("/peer/deregister", r) + w.WriteHeader(http.StatusOK) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + api := NewAPI(srv.Client(), srv.URL, "test-device-id") + ctx := context.Background() + + _, err := api.Register(ctx, RegisterRequest{ExternalIP: "203.0.113.42", ExternalPort: 5698, InternalPort: 35698}) + require.NoError(t, err) + require.NoError(t, api.Verify(ctx, "00000000-0000-0000-0000-000000000123")) + require.NoError(t, api.Heartbeat(ctx, "00000000-0000-0000-0000-000000000123")) + require.NoError(t, api.Deregister(ctx, "00000000-0000-0000-0000-000000000123")) + + for _, path := range []string{"/peer/register", "/peer/verify", "/peer/heartbeat", "/peer/deregister"} { + mu.Lock() + c, ok := captured[path] + mu.Unlock() + require.True(t, ok, "no request captured for %s", path) + assert.Equal(t, fakePublicIP, c.clientIP, + "%s must forward radiance's detected public IP via %s "+ + "so server-side ClientIPWithAddr resolves the same IP it does for /config-new", + path, common.ClientIPHeader) + assert.Equal(t, "test-device-id", c.deviceID, "%s must carry %s", path, common.DeviceIDHeader) + assert.NotEmpty(t, c.platform, "%s must carry %s", path, common.PlatformHeader) + assert.NotEmpty(t, c.appName, "%s must carry %s", path, common.AppNameHeader) + } +} From b25b01b1cee85e21b8c6df583bb69c74880ecc8e Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Wed, 6 May 2026 14:42:04 -0600 Subject: [PATCH 27/35] peer: register lantern-box protocols in box ctx + regression test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit defaultBuildBoxService used to call libbox.NewServiceWithContext with the caller's bare ctx, which has no lantern-box protocol registries plumbed in. The samizdat inbound type ServerConfig sends back from /peer/register isn't a built-in sing-box protocol, so libbox's JSON decoder couldn't resolve inbounds[0].type="samizdat" and returned "missing inbound fields registry in context". The integration tests stub BuildBoxService entirely, so this layer was never exercised in CI — only surfaced live during the eero end-to-end test. Two pieces: 1. Use box.BaseContext() (from getlantern/lantern-box) when calling libbox.NewServiceWithContext. That ctx has the InboundOptionsRegistry populated with samizdat / reflex / etc. so the decode succeeds. Coexists with the user's VPN tunnel (vpn/tunnel.go) — libbox.Setup is process-global, the ctx registries are per-box. 2. TestDefaultBuildBoxService_DecodesSamizdatInbound walks the actual decode path with a minimal samizdat-inbound JSON. Verified to fail with the exact production error message under the pre-fix code, pass under the fix. Cuts the diagnostic loop from a 5-minute rebuild+redeploy+toggle cycle to a 0.5s test failure. Co-Authored-By: Claude Opus 4.7 (1M context) --- peer/peer.go | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/peer/peer.go b/peer/peer.go index f01c73ec..a059e48b 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -13,6 +13,7 @@ import ( "github.com/sagernet/sing-box/experimental/libbox" + box "github.com/getlantern/lantern-box" "github.com/getlantern/lantern-box/tracker/peerconn" "github.com/getlantern/radiance/common/settings" "github.com/getlantern/radiance/events" @@ -487,8 +488,22 @@ func pickInternalPort() uint16 { // platform-VPN integration the way the main VPN tunnel does. The samizdat // inbound is just an HTTPS server bound to a TCP port; sing-box's default // network stack handles it. -func defaultBuildBoxService(ctx context.Context, options string) (boxService, error) { - bs, err := libbox.NewServiceWithContext(ctx, options, nil) +// +// box.BaseContext registers the lantern-box protocol fields registries +// (samizdat, reflex, etc.) into the ctx so libbox can decode the +// inbounds[0].type="samizdat" stanza coming back from /peer/register. +// Without it the user's ctx is missing InboundOptionsRegistry and +// libbox returns "missing inbound fields registry in context" — the +// failure mode is silent in CI because the integration tests stub +// BuildBoxService entirely; only TestDefaultBuildBoxService_DecodesSamizdatInbound +// exercises the real decode path. +// +// Runs in the same process as the user's VPN tunnel (vpn/tunnel.go), +// which calls libbox.Setup once at process start; the registries set +// here are scoped to this peer's box instance so the two coexist +// without stomping on each other. +func defaultBuildBoxService(_ context.Context, options string) (boxService, error) { + bs, err := libbox.NewServiceWithContext(box.BaseContext(), options, nil) if err != nil { return nil, fmt.Errorf("libbox.NewServiceWithContext: %w", err) } From e9491c6882289e3cfc659f7e6b3af81b70a31173 Mon Sep 17 00:00:00 2001 From: garmr-ulfr <104022054+garmr-ulfr@users.noreply.github.com> Date: Mon, 11 May 2026 09:35:29 -0700 Subject: [PATCH 28/35] set max compressed size for issue report to 19.5 (#475) * set max compressed size for issue report to 19 * change to 19.5MB --- issue/issue.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/issue/issue.go b/issue/issue.go index 34a97649..1df9c94c 100644 --- a/issue/issue.go +++ b/issue/issue.go @@ -24,7 +24,7 @@ import ( ) const ( - maxCompressedSize = 20 * 1024 * 1024 // 20 MB + maxCompressedSize = int64(19.5 * 1024 * 1024) // 19.5 MB - 20 MB is the max size so allow some buffer for overhead tracerName = "github.com/getlantern/radiance/issue" ) From f6774c61f1793296dfe5a4673157c3480c287bee Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Mon, 11 May 2026 12:44:44 -0600 Subject: [PATCH 29/35] peer: silence connection-event cascade during box.Close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user toggles SmC off while real client traffic is flowing, box.Close fires per-connection disconnect callbacks for every in-flight inbound. peerconn.Notify reads its registered listener under an RLock and releases the lock before invoking — SetListener(nil) alone races against goroutines that have already snapshotted the listener (one per live connection). Each surviving callback hits events.Emit, which spawns yet another goroutine per subscriber. The Flutter-side subscriber posts main-thread tasks per event, and a hundred-task flood against an engine that's simultaneously handling the SmC-off state change reproduced as a Flutter mutex abort on the main thread. Add a sync/atomic flag the listener wrapper checks inline. Flip it before box.Close in both Stop and the Start-rollback defer; re-arm it at the top of Start so a Stop→Start cycle doesn't leave the wrapper muted. SetListener(nil) still runs for cleanliness, but the flag is what actually halts the cascade. Co-Authored-By: Claude Opus 4.7 (1M context) --- peer/peer.go | 38 ++++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/peer/peer.go b/peer/peer.go index a059e48b..bda8e7b1 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -9,6 +9,7 @@ import ( "math/rand/v2" "os" "sync" + "sync/atomic" "time" "github.com/sagernet/sing-box/experimental/libbox" @@ -104,6 +105,19 @@ type Client struct { forwarder portForwarder box boxService routeID string + + // listenerDraining short-circuits the peerconn listener wrapper while + // box.Close is firing per-connection disconnect callbacks. peerconn.Notify + // reads its registered listener under an RLock and then releases the lock + // before invoking it, so SetListener(nil) alone races against in-flight + // Notify calls — under load (real client traffic), Close fires N disconnect + // callbacks from N goroutines that have already snapshotted the listener, + // each then events.Emit spawns one more goroutine per subscriber. The + // Flutter-side subscriber posts main-thread tasks per event, and a + // hundred-task flood against a Flutter engine that's simultaneously + // processing the SmC-off state change is the Flutter mutex crash we hit. + // Setting this flag before box.Close drops the cascade inline. + listenerDraining atomic.Bool } // peerCleanupTimeout caps how long Start's rollback path waits for @@ -180,6 +194,11 @@ func (c *Client) Start(ctx context.Context) error { c.starting = true c.mu.Unlock() + // Re-arm the listener wrapper. Stop / rollback flips this to true to + // silence the disconnect cascade during box.Close; if we don't reset + // here, a Stop→Start cycle would leave the wrapper permanently muted. + c.listenerDraining.Store(false) + var ( success bool fwd portForwarder @@ -203,7 +222,9 @@ func (c *Client) Start(ctx context.Context) error { // Always clear the connection listener on rollback. The listener is // only Set on the success path, so this is a no-op if Start failed // before reaching it — but cheap insurance against a future re-order - // that registers earlier. + // that registers earlier. Drain-flag first so any in-flight Notify + // callbacks short-circuit even if SetListener races (see Stop). + c.listenerDraining.Store(true) peerconn.SetListener(nil) if box != nil { _ = box.Close() @@ -290,6 +311,9 @@ func (c *Client) Start(ctx context.Context) error { // set it after Verify so the verifier's transient probe connection // doesn't surface as a real peer-connection event in the UI. peerconn.SetListener(func(state int, source string) { + if c.listenerDraining.Load() { + return + } events.Emit(ConnectionEvent{State: state, Source: source}) }) @@ -357,9 +381,15 @@ func (c *Client) Stop(ctx context.Context) error { c.status = Status{} c.mu.Unlock() - // Clear the connection listener BEFORE box.Close so any in-flight - // accept-loop callbacks land on a no-op rather than emit ConnectionEvents - // after the consumer side has already torn down its subscription. + // Suppress the connection listener BEFORE box.Close. peerconn.Notify + // reads its registered listener under an RLock and releases it before + // invoking — SetListener(nil) alone races against in-flight Notify + // goroutines that have already snapshotted the listener (one per live + // inbound connection at Close time). Flipping listenerDraining first + // short-circuits the wrapper inline so even the racing invocations + // become no-ops. SetListener(nil) is still called for cleanliness and + // to release the listener closure's reference to this Client. + c.listenerDraining.Store(true) peerconn.SetListener(nil) cancel() From 39b6b454965b256c24dc4204b9240f895d124187 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Mon, 11 May 2026 13:32:06 -0600 Subject: [PATCH 30/35] peer: emit phase-granular StatusEvents through Start/Stop lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UI today sees a single active/inactive flip — toggling SmC on looks "hung" through the multi-second sequence of port-forwarding, registering, starting the local box, and verifying. This adds a Phase field to Status and emits one StatusEvent per stage: Start: mapping_port → detecting_ip → registering → starting_proxy → verifying → serving Stop: stopping → idle on err: error (Status.Error populated with the wrapped fmt.Errorf message, e.g. "map port 33445: upnp gateway refused mapping") Phase is a stable string so Flutter / web consumers can switch on it without depending on Go enum ordering. Active stays as a derived bool (true only on PhaseServing) for subscribers that just want the binary. Co-Authored-By: Claude Opus 4.7 (1M context) --- peer/peer.go | 73 ++++++++++++++++++++++++++++++++++++-- peer/peer_test.go | 90 +++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 146 insertions(+), 17 deletions(-) diff --git a/peer/peer.go b/peer/peer.go index bda8e7b1..d20cba10 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -63,7 +63,34 @@ type boxService interface { type boxFactory func(ctx context.Context, options string) (boxService, error) +// Phase is the peer.Client lifecycle stage surfaced to the UI. Granular +// enough that "Share My Connection" can render a real progress sequence +// (mapping port → registering → verifying → serving) instead of a single +// active/inactive boolean. Values are stable strings so Flutter / web +// consumers can switch on them without depending on Go enum ordering. +type Phase string + +const ( + PhaseIdle Phase = "idle" + PhaseMappingPort Phase = "mapping_port" + PhaseDetectingIP Phase = "detecting_ip" + PhaseRegistering Phase = "registering" + PhaseStartingBox Phase = "starting_proxy" + PhaseVerifying Phase = "verifying" + PhaseServing Phase = "serving" + PhaseStopping Phase = "stopping" + PhaseError Phase = "error" +) + type Status struct { + Phase Phase `json:"phase"` + // Error is the human-readable failure reason when Phase == PhaseError. + // Empty for every other phase; consumers should render this only when + // the UI is in the error state. + Error string `json:"error,omitempty"` + // Active is true only when Phase == PhaseServing. Kept distinct from + // Phase so subscribers that just want a boolean "is sharing?" don't + // have to switch on the phase enum. Active bool `json:"active"` SharingSince time.Time `json:"sharing_since,omitempty"` ExternalIP string `json:"external_ip,omitempty"` @@ -185,7 +212,7 @@ func NewClient(cfg Config) (*Client, error) { // Start opens the peer-proxy session. On success a background heartbeat // goroutine is running; on error any partial setup is torn down before // returning. -func (c *Client) Start(ctx context.Context) error { +func (c *Client) Start(ctx context.Context) (retErr error) { c.mu.Lock() if c.active || c.starting { c.mu.Unlock() @@ -238,8 +265,20 @@ func (c *Client) Start(ctx context.Context) error { if fwd != nil { _ = fwd.UnmapPort(cleanupCtx) } + // Surface the failure to the UI. Emitted AFTER cleanup so the UI + // sees the error phase as the terminal state of this Start attempt, + // not as a transient between phases. retErr carries whichever + // fmt.Errorf the failing branch returned, which is the most + // human-readable diagnostic we have ("map port %d: ...", + // "register with lantern-cloud: ...", etc.). + var errMsg string + if retErr != nil { + errMsg = retErr.Error() + } + c.emitPhase(PhaseError, errMsg) }() + c.emitPhase(PhaseMappingPort, "") fwd, err := c.cfg.NewForwarder(ctx) if err != nil { return fmt.Errorf("discover gateway: %w", err) @@ -250,10 +289,13 @@ func (c *Client) Start(ctx context.Context) error { return fmt.Errorf("map port %d: %w", internalPort, err) } + c.emitPhase(PhaseDetectingIP, "") externalIP, err := fwd.ExternalIP(ctx) if err != nil { return fmt.Errorf("get external ip: %w", err) } + + c.emitPhase(PhaseRegistering, "") regResp, err = c.cfg.API.Register(ctx, RegisterRequest{ ExternalIP: externalIP, ExternalPort: mapping.ExternalPort, @@ -270,6 +312,7 @@ func (c *Client) Start(ctx context.Context) error { // auto_detect_interface tells sing-box to bind outbound dials to the // underlying physical interface rather than whatever the OS routing // table picks (which would be the VPN TUN if the VPN is up). + c.emitPhase(PhaseStartingBox, "") options, err := ensurePeerOutboundsBypassVPN(regResp.ServerConfig) if err != nil { return fmt.Errorf("patch sing-box options: %w", err) @@ -289,6 +332,7 @@ func (c *Client) Start(ctx context.Context) error { return fmt.Errorf("start sing-box: %w", err) } + c.emitPhase(PhaseVerifying, "") // Now that sing-box is listening with the just-built creds, ask the // server to dial back through them. Splitting verify out of Register // into this explicit follow-up avoids the chicken-and-egg where the @@ -334,6 +378,7 @@ func (c *Client) Start(ctx context.Context) error { c.cancelRun = cancelRun c.runDone = runDone c.status = Status{ + Phase: PhaseServing, Active: true, SharingSince: time.Now(), ExternalIP: externalIP, @@ -378,8 +423,10 @@ func (c *Client) Stop(ctx context.Context) error { c.forwarder = nil c.box = nil c.routeID = "" - c.status = Status{} + c.status = Status{Phase: PhaseStopping} + stoppingSnapshot := c.status c.mu.Unlock() + events.Emit(StatusEvent{Status: stoppingSnapshot}) // Suppress the connection listener BEFORE box.Close. peerconn.Notify // reads its registered listener under an RLock and releases it before @@ -413,7 +460,11 @@ func (c *Client) Stop(ctx context.Context) error { slog.Warn("peer client unmap port failed", "err", err) } slog.Info("peer client stopped", "route_id", routeID) - events.Emit(StatusEvent{Status: Status{}}) + c.mu.Lock() + c.status = Status{Phase: PhaseIdle} + idleSnapshot := c.status + c.mu.Unlock() + events.Emit(StatusEvent{Status: idleSnapshot}) return firstErr } @@ -429,6 +480,22 @@ func (c *Client) CurrentStatus() Status { return c.status } +// emitPhase updates c.status.Phase under the lock and emits a snapshot. +// Used at each lifecycle boundary in Start / Stop so the UI sees progress +// instead of a binary active/inactive flip. Active is recomputed here: +// only PhaseServing implies active=true; every other phase clears it so +// subscribers using just the Active flag don't see e.g. "active=true with +// Phase=verifying" mid-Start. +func (c *Client) emitPhase(p Phase, errMsg string) { + c.mu.Lock() + c.status.Phase = p + c.status.Error = errMsg + c.status.Active = (p == PhaseServing) + snapshot := c.status + c.mu.Unlock() + events.Emit(StatusEvent{Status: snapshot}) +} + // heartbeatLoop closes done on exit so Stop can wait for the loop before // tearing down resources. The channel is passed in rather than read off the // Client because Stop nils c.runDone before waiting on its local copy. diff --git a/peer/peer_test.go b/peer/peer_test.go index 805eb8ce..6bc70797 100644 --- a/peer/peer_test.go +++ b/peer/peer_test.go @@ -523,35 +523,97 @@ func TestAPIError_StringFormat(t *testing.T) { assert.Contains(t, e.Error(), "could not connect") } -// Subscribers (the IPC SSE handler in production) need both edges so the UI -// can render fresh state without polling. +// TestClient_StatusEventEmittedOnStartAndStop pins the full lifecycle +// phase sequence: Start fires one StatusEvent per stage so the UI can +// render granular progress (mapping port → registering → verifying → +// serving) instead of a single active/inactive flip. Stop fires +// stopping → idle on the way back down. +// +// Subscribers (the IPC SSE handler in production) need every edge so the +// UI can render fresh state without polling. func TestClient_StatusEventEmittedOnStartAndStop(t *testing.T) { fwd := &fakeForwarder{} box := &fakeBoxService{} srv := newStubServer(t) c := newTestClient(t, fwd, box, srv) - got := make(chan StatusEvent, 4) + // Buffer must exceed total emit count (6 on Start: mapping → detecting + // → registering → starting_proxy → verifying → serving; 2 on Stop: + // stopping → idle) or the subscriber's send blocks and emits drop. + got := make(chan StatusEvent, 16) sub := events.Subscribe(func(evt StatusEvent) { got <- evt }) defer sub.Unsubscribe() require.NoError(t, c.Start(context.Background())) - select { - case evt := <-got: - assert.True(t, evt.Status.Active) - assert.NotEmpty(t, evt.Status.RouteID) - case <-time.After(time.Second): - t.Fatal("no Start status event within 1s") + + wantStartPhases := []Phase{ + PhaseMappingPort, + PhaseDetectingIP, + PhaseRegistering, + PhaseStartingBox, + PhaseVerifying, + PhaseServing, + } + for _, want := range wantStartPhases { + select { + case evt := <-got: + assert.Equal(t, want, evt.Status.Phase, "wrong phase in Start sequence") + if want == PhaseServing { + assert.True(t, evt.Status.Active, "active must be true on serving") + assert.NotEmpty(t, evt.Status.RouteID, "route_id must be set on serving") + } else { + assert.False(t, evt.Status.Active, "active must be false on intermediate phase %q", want) + } + case <-time.After(time.Second): + t.Fatalf("no Start status event for phase %q within 1s", want) + } } require.NoError(t, c.Stop(context.Background())) - select { - case evt := <-got: - assert.False(t, evt.Status.Active) - case <-time.After(time.Second): - t.Fatal("no Stop status event within 1s") + for _, want := range []Phase{PhaseStopping, PhaseIdle} { + select { + case evt := <-got: + assert.Equal(t, want, evt.Status.Phase, "wrong phase in Stop sequence") + assert.False(t, evt.Status.Active, "active must be false during stop") + case <-time.After(time.Second): + t.Fatalf("no Stop status event for phase %q within 1s", want) + } + } +} + +// TestClient_StatusEventOnStartError surfaces a Start failure to the UI +// via PhaseError with the wrapped error message. Without this, a user +// who clicks SmC-on and hits e.g. a UPnP failure sees the toggle silently +// flip back without any diagnostic. +func TestClient_StatusEventOnStartError(t *testing.T) { + fwd := &fakeForwarder{mapErr: errors.New("upnp gateway refused mapping")} + box := &fakeBoxService{} + srv := newStubServer(t) + c := newTestClient(t, fwd, box, srv) + + got := make(chan StatusEvent, 16) + sub := events.Subscribe(func(evt StatusEvent) { got <- evt }) + defer sub.Unsubscribe() + + err := c.Start(context.Background()) + require.Error(t, err) + + var sawError bool + deadline := time.After(time.Second) + for !sawError { + select { + case evt := <-got: + if evt.Status.Phase == PhaseError { + sawError = true + assert.False(t, evt.Status.Active) + assert.Contains(t, evt.Status.Error, "upnp gateway refused mapping", + "error message must surface so the UI can render a real diagnostic") + } + case <-deadline: + t.Fatal("no PhaseError status event within 1s") + } } } From 51be53e10adda631a8b722a018b4cca870afb255 Mon Sep 17 00:00:00 2001 From: garmr-ulfr <104022054+garmr-ulfr@users.noreply.github.com> Date: Mon, 11 May 2026 13:09:00 -0700 Subject: [PATCH 31/35] bump lantern-box to v0.0.82 (#476) --- go.mod | 4 ++-- go.sum | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 7d83a2db..6f7fa7dc 100644 --- a/go.mod +++ b/go.mod @@ -31,7 +31,7 @@ require ( github.com/getlantern/domainfront v0.0.0-20260419161617-0bff0b2169f4 github.com/getlantern/keepcurrent v0.0.0-20260422161259-54a4d9a93694 github.com/getlantern/kindling v0.0.0-20260507163327-92a44d03bdc5 - github.com/getlantern/lantern-box v0.0.79 + github.com/getlantern/lantern-box v0.0.82 github.com/getlantern/pluriconfig v0.0.0-20251126214241-8cc8bc561535 github.com/getlantern/publicip v0.0.0-20260328175246-2c460fe80c6b github.com/getlantern/semconv v0.0.0-20260327040646-21845dda05cb @@ -247,7 +247,7 @@ require ( github.com/go-ole/go-ole v1.3.0 // indirect github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect - github.com/gofrs/uuid/v5 v5.3.2 // indirect + github.com/gofrs/uuid/v5 v5.3.2 github.com/google/btree v1.1.3 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/hashicorp/yamux v0.1.2 // indirect diff --git a/go.sum b/go.sum index c2ac388c..417f06b7 100644 --- a/go.sum +++ b/go.sum @@ -248,8 +248,8 @@ github.com/getlantern/keepcurrent v0.0.0-20260422161259-54a4d9a93694 h1:iLWm6S/4 github.com/getlantern/keepcurrent v0.0.0-20260422161259-54a4d9a93694/go.mod h1:ag5g9aWUw2FJcX5RVRpJ9EBQBy5yJuy2WXDouIn/m4w= github.com/getlantern/kindling v0.0.0-20260507163327-92a44d03bdc5 h1:ukTEQ2S16zMK2BJxIM0qKz+WiiyiPwvmLCWlK1EOvVU= github.com/getlantern/kindling v0.0.0-20260507163327-92a44d03bdc5/go.mod h1:TGTxpoNVwc8Be4qkBNtf5oj2psJaEIZEq47GOPS7zkA= -github.com/getlantern/lantern-box v0.0.79 h1:35NFpHxy5pU7xWX5VDFwoOpvOJ7Z7JG++GW9jaTqEAg= -github.com/getlantern/lantern-box v0.0.79/go.mod h1:wJhPQKdnwD6qW/ghAfzsrj/IfHZbvFSAfr52+Tu6dbw= +github.com/getlantern/lantern-box v0.0.82 h1:hCXqpCxLOQNxYtQZQDYVh3aj3t8NqSBqJjCn2mIBtK0= +github.com/getlantern/lantern-box v0.0.82/go.mod h1:wJhPQKdnwD6qW/ghAfzsrj/IfHZbvFSAfr52+Tu6dbw= github.com/getlantern/lantern-water v0.0.0-20260317143726-e0ee64a11d90 h1:P9JX1yAu2uq3b5YiT0sLtHkTrkZuttV8gPZh81nUuag= github.com/getlantern/lantern-water v0.0.0-20260317143726-e0ee64a11d90/go.mod h1:3JpJgwi4KEI6rS9loOAvcBp+F2jP65d0tTg2GQcTPBU= github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534 h1:3BwvWj0JZzFEvNNiMhCu4bf60nqcIuQpTYb00Ezm1ag= From bf26ce2e04119ef9c86f2a90eb7c40527cf544f4 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Mon, 11 May 2026 14:30:02 -0600 Subject: [PATCH 32/35] peer: instrument peerconn listener registration + per-event forwarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "no globe arcs despite 200+ samizdat connections" pattern is unobservable from current logs: peerconn.SetListener and events.Emit don't log, so when the chain breaks between samizdat-in's Notify and the Flutter bridge, there's no trace. This adds three breadcrumbs to make the failure mode diagnosable on the next rebuild: - "peer listener: registered with peerconn" — one line per Start that confirms the listener actually got installed - "peer listener: forwarding connection event" — one line per accept AND per close; pairs with the lantern-core subscriber breadcrumb so we can see if events bus delivers what the listener emits - "peer listener: dropping post-Stop Notify" — DEBUG-level for the race window the listenerDraining flag silences; makes that bucket countable instead of silently discarding events Co-Authored-By: Claude Opus 4.7 (1M context) --- peer/peer.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/peer/peer.go b/peer/peer.go index d20cba10..60954854 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -356,10 +356,23 @@ func (c *Client) Start(ctx context.Context) (retErr error) { // doesn't surface as a real peer-connection event in the UI. peerconn.SetListener(func(state int, source string) { if c.listenerDraining.Load() { + // Diagnostic: if Notify reaches this point but we drop because + // the drain flag is set, that's the post-Stop racing-Notify case + // the flag was added to silence. Logging makes its frequency + // observable instead of "events silently vanish." + slog.Debug("peer listener: dropping post-Stop Notify", + "state", state, "source", source) return } + // One-line breadcrumb per accept/close so we can correlate samizdat-in + // activity with peer-connection FlutterEvents on the consumer side + // — without this, "no globe arcs despite samizdat traffic" is + // indistinguishable from "events fire but the bridge swallows them." + slog.Info("peer listener: forwarding connection event", + "state", state, "source", source) events.Emit(ConnectionEvent{State: state, Source: source}) }) + slog.Info("peer listener: registered with peerconn", "route_id", regResp.RouteID) heartbeat := c.cfg.HeartbeatInterval if heartbeat == 0 { From 810ef9b82e62ae36bc3cef2b8dbb71654fd0d53c Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Mon, 11 May 2026 15:21:27 -0600 Subject: [PATCH 33/35] events: log Emit subscriber count to debug "events vanish" path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The radiance peer listener fires (42 ConnectionEvents observed) but lantern-core's subscriber breadcrumb never fires, suggesting either Subscribe never ran or Emit is looking at a different subscriptions map. Logs the type key + subscriber count at every Emit so we can distinguish "no subscribers registered" (init bug) from "subscribers registered but callback panics" (rare, but possible). Uses stdlib log to avoid pulling slog into the events package (and a possible import cycle with slog-forwarding handlers that subscribe to events). Temporary diagnostic — should be downgraded to Debug or removed once the chain works end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) --- events/events.go | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/events/events.go b/events/events.go index fba0d7a6..a5e37cd4 100644 --- a/events/events.go +++ b/events/events.go @@ -28,6 +28,7 @@ package events import ( "context" + stdlog "log" "reflect" "sync" "sync/atomic" @@ -120,9 +121,27 @@ func (e *Subscription[T]) Unsubscribe() { func Emit[T Event](evt T) { subscriptionsMu.RLock() defer subscriptionsMu.RUnlock() - if subs, ok := subscriptions[reflect.TypeFor[T]()]; ok { - for _, cb := range subs { - go cb(evt) - } + key := reflect.TypeFor[T]() + subs, ok := subscriptions[key] + // Diagnostic: surfaces the subscriber count at emit time so a missing + // FlutterEvent on the consumer side is distinguishable from "no + // subscribers registered for this type" vs "subscribers registered + // but callback panics silently." Spam-friendly when traffic spikes, + // but we're investigating a zero-callback path so the noise is + // short-lived; remove (or downgrade to Debug) once the chain works. + emitDebugLogger(key, len(subs)) + if !ok { + return + } + for _, cb := range subs { + go cb(evt) } } + +// emitDebugLogger is a package-level var so tests can suppress the +// per-emit log, and so prod can swap in slog. Default uses Go's stdlib +// log so events package doesn't need to import slog (and avoid a cycle +// with anything that imports events for its own log forwarding). +var emitDebugLogger = func(key reflect.Type, subCount int) { + stdlog.Printf("events.Emit type=%s subscribers=%d", key, subCount) +} From 29a4b7e3bec82e3ce701734bd9ca80e6db015474 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Mon, 11 May 2026 15:47:52 -0600 Subject: [PATCH 34/35] ipc: stream peer-status + peer-connection events over IPC SSE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The events package's globals are process-scoped — events.Emit in lanternd (where radiance/peer runs) doesn't reach events.Subscribe in Liblantern. Diagnostic at events.go showed subscribers=0 for every peer.ConnectionEvent emit despite Subscribe being called. Adds the cross-process bridge: - New /peer/connection/events SSE endpoint (mirrors /peer/status/events). peerConnectionEventsHandler buffers 64 events to absorb slow consumers without backpressuring events.Emit; drops on overflow rather than growing unbounded. - Client.PeerStatusEvents(ctx, handler) and Client.PeerConnectionEvents( ctx, handler) in both mobile and nonmobile client variants. Mobile keeps the events.SubscribeContext path so in-process delivery still works for builds that bundle radiance with the consumer; otherwise falls through to SSE. The peer-status SSE endpoint and handler were already there; this PR just adds the matching client method so lantern-core can actually consume it. Co-Authored-By: Claude Opus 4.7 (1M context) --- ipc/client_events_mobile.go | 35 ++++++++++++++++++++++ ipc/client_events_nonmobile.go | 34 +++++++++++++++++++++ ipc/server.go | 55 ++++++++++++++++++++++++++++++++-- 3 files changed, 122 insertions(+), 2 deletions(-) diff --git a/ipc/client_events_mobile.go b/ipc/client_events_mobile.go index d03fca66..a3a8dfd2 100644 --- a/ipc/client_events_mobile.go +++ b/ipc/client_events_mobile.go @@ -9,6 +9,7 @@ import ( "github.com/getlantern/radiance/account" "github.com/getlantern/radiance/config" "github.com/getlantern/radiance/events" + "github.com/getlantern/radiance/peer" "github.com/getlantern/radiance/vpn" ) @@ -60,3 +61,37 @@ func (c *Client) DataCapStream(ctx context.Context, handler func(account.DataCap } return c.dataCapStream(ctx, handler) } + +// PeerStatusEvents — see client_events_nonmobile.go for the full +// docstring. Mobile builds may share a process with radiance (localOnly) +// in which case events.SubscribeContext delivers directly; otherwise the +// SSE retry loop matches the desktop path. +func (c *Client) PeerStatusEvents(ctx context.Context, handler func(peer.StatusEvent)) error { + events.SubscribeContext(ctx, handler) + if c.localOnly { + <-ctx.Done() + return ctx.Err() + } + return c.sseRetryLoop(ctx, peerStatusEventsEndpoint, func(data []byte) { + var evt peer.StatusEvent + if err := json.Unmarshal(data, &evt); err == nil { + handler(evt) + } + }) +} + +// PeerConnectionEvents — see client_events_nonmobile.go for the full +// docstring. Same mobile dual-path as PeerStatusEvents. +func (c *Client) PeerConnectionEvents(ctx context.Context, handler func(peer.ConnectionEvent)) error { + events.SubscribeContext(ctx, handler) + if c.localOnly { + <-ctx.Done() + return ctx.Err() + } + return c.sseRetryLoop(ctx, peerConnectionEventsEndpoint, func(data []byte) { + var evt peer.ConnectionEvent + if err := json.Unmarshal(data, &evt); err == nil { + handler(evt) + } + }) +} diff --git a/ipc/client_events_nonmobile.go b/ipc/client_events_nonmobile.go index 16d3184e..e0330fe1 100644 --- a/ipc/client_events_nonmobile.go +++ b/ipc/client_events_nonmobile.go @@ -7,6 +7,7 @@ import ( "encoding/json" "github.com/getlantern/radiance/account" + "github.com/getlantern/radiance/peer" "github.com/getlantern/radiance/vpn" ) @@ -40,3 +41,36 @@ func (c *Client) VPNStatusEvents(ctx context.Context, handler func(vpn.StatusUpd func (c *Client) DataCapStream(ctx context.Context, handler func(account.DataCapInfo)) error { return c.dataCapStream(ctx, handler) } + +// PeerStatusEvents streams peer-share lifecycle phase changes (mapping_port +// → registering → verifying → serving on Start, stopping → idle on Stop, +// error on failure). Each frame is a peer.StatusEvent JSON whose .Status +// is the live snapshot at the moment the event fired — consumers SHOULD +// re-render on every frame rather than diffing, since events.Emit's +// per-callback goroutine can land Start phases out of order. Blocks until +// ctx is cancelled. +func (c *Client) PeerStatusEvents(ctx context.Context, handler func(peer.StatusEvent)) error { + return c.sseRetryLoop(ctx, peerStatusEventsEndpoint, func(data []byte) { + var evt peer.StatusEvent + if err := json.Unmarshal(data, &evt); err == nil { + handler(evt) + } + }) +} + +// PeerConnectionEvents streams accept/close events for the local +// samizdat-in inbound. State is +1 on accept and -1 on close; Source +// is the remote "ip:port" string for geo-lookup / abuse attribution. +// Blocks until ctx is cancelled. +// +// Why this exists alongside events.Subscribe[peer.ConnectionEvent]: +// the events package's globals are process-scoped, so a subscriber in +// Liblantern can't see emits in lanternd. The SSE path bridges them. +func (c *Client) PeerConnectionEvents(ctx context.Context, handler func(peer.ConnectionEvent)) error { + return c.sseRetryLoop(ctx, peerConnectionEventsEndpoint, func(data []byte) { + var evt peer.ConnectionEvent + if err := json.Unmarshal(data, &evt); err == nil { + handler(evt) + } + }) +} diff --git a/ipc/server.go b/ipc/server.go index a9fdf5c1..61d98979 100644 --- a/ipc/server.go +++ b/ipc/server.go @@ -62,8 +62,9 @@ const ( settingsEndpoint = "/settings" // Peer-share ("Share My Connection") endpoints - peerStatusEndpoint = "/peer/status" - peerStatusEventsEndpoint = "/peer/status/events" + peerStatusEndpoint = "/peer/status" + peerStatusEventsEndpoint = "/peer/status/events" + peerConnectionEventsEndpoint = "/peer/connection/events" // Split tunnel endpoint splitTunnelEndpoint = "/split-tunnel" @@ -228,6 +229,7 @@ func newLocalAPI(b *backend.LocalBackend, withAuth bool) *localapi { mux.HandleFunc("GET "+peerStatusEndpoint, traced(s.peerStatusHandler)) // SSE skips the tracer middleware since it buffers the entire response body. mux.HandleFunc("GET "+peerStatusEventsEndpoint, s.peerStatusEventsHandler) + mux.HandleFunc("GET "+peerConnectionEventsEndpoint, s.peerConnectionEventsHandler) // Split tunnel mux.HandleFunc(splitTunnelEndpoint, traced(s.splitTunnelHandler)) @@ -485,6 +487,55 @@ func (s *localapi) peerStatusEventsHandler(w http.ResponseWriter, r *http.Reques } } +// peerConnectionEventsHandler streams peer.ConnectionEvent over SSE for +// each accept/close on the local samizdat-in. Unlike peerStatusEventsHandler +// (which always sends the live snapshot), each emit's captured value is +// what the consumer needs here — the Source IP and +1/-1 state ARE the +// payload, not a periodic poll. Out-of-order +1/-1 from events.Emit's +// per-callback goroutine is fine: the consumer (lantern-core's globe-arc +// renderer) keys arcs by source, so it handles re-orderings naturally. +// +// The events package lives in this process (lanternd); cross-process +// consumers in Liblantern can only receive these via this SSE stream, +// since events.Subscribe in the Liblantern process sees a different +// (empty) subscriptions map. +func (s *localapi) peerConnectionEventsHandler(w http.ResponseWriter, r *http.Request) { + flusher := sseWriter(w) + if flusher == nil { + return + } + // Buffered channel so a slow SSE consumer doesn't apply backpressure + // to events.Emit (which spawns a goroutine per subscriber but blocks + // nothing). 64 holds ~one second of accept/close pairs under heavy + // load; beyond that we drop to avoid unbounded memory growth. + queue := make(chan peer.ConnectionEvent, 64) + sub := events.Subscribe(func(evt peer.ConnectionEvent) { + select { + case queue <- evt: + default: + // queue full — drop. SSE consumer is too slow; better to + // lose this event than to back up the events.Emit goroutine. + } + }) + defer sub.Unsubscribe() + + for { + select { + case evt := <-queue: + data, err := json.Marshal(evt) + if err != nil { + continue + } + if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil { + return + } + flusher.Flush() + case <-r.Context().Done(): + return + } + } +} + /////////////////////// // Server selection // /////////////////////// From 0b72cd457c21d8a9fcba2f8f4b4dc1c1ad4552f7 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Mon, 11 May 2026 16:12:47 -0600 Subject: [PATCH 35/35] bump lantern-box: real peer addr in ConnectionEvent.Source lantern-box bumps samizdat to plumb the underlying TLS conn's RemoteAddr through serverStreamConn. With this, peer.ConnectionEvent emitted from the peerconn listener carries a real peer ip:port instead of the "client:0" placeholder, so the Dart Share My Connection UI can key globe arcs per actual peer (and arcs persist through real connection lifetimes instead of flickering). Co-Authored-By: Claude Opus 4.7 (1M context) --- go.mod | 6 +++--- go.sum | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 4bf94e5f..99a88414 100644 --- a/go.mod +++ b/go.mod @@ -30,12 +30,13 @@ require ( github.com/alexflint/go-arg v1.6.1 github.com/alitto/pond v1.9.2 github.com/getlantern/amp v0.0.0-20260305201851-782bc8045e58 + github.com/getlantern/broflake v0.0.0-20260504215251-ed3cf75062d1 github.com/getlantern/common v1.2.1-0.20260326210434-cb69537aaf46 github.com/getlantern/dnstt v0.0.0-20260112160750-05100563bd0d github.com/getlantern/domainfront v0.0.0-20260419161617-0bff0b2169f4 github.com/getlantern/keepcurrent v0.0.0-20260422161259-54a4d9a93694 github.com/getlantern/kindling v0.0.0-20260428171407-6143132aaf40 - github.com/getlantern/lantern-box v0.0.77 + github.com/getlantern/lantern-box v0.0.78-0.20260511221021-3201d6aa113f github.com/getlantern/pluriconfig v0.0.0-20251126214241-8cc8bc561535 github.com/getlantern/publicip v0.0.0-20260328175246-2c460fe80c6b github.com/getlantern/semconv v0.0.0-20260327040646-21845dda05cb @@ -117,9 +118,8 @@ require ( github.com/gaissmai/bart v0.11.1 // indirect github.com/gaukas/wazerofs v0.1.0 // indirect github.com/getlantern/algeneva v0.0.0-20250307163401-1824e7b54f52 // indirect - github.com/getlantern/broflake v0.0.0-20260504215251-ed3cf75062d1 // indirect github.com/getlantern/lantern-water v0.0.0-20260317143726-e0ee64a11d90 // indirect - github.com/getlantern/samizdat v0.0.3-0.20260327203406-ef7323341974 // indirect + github.com/getlantern/samizdat v0.0.3-0.20260511220916-bbb8ba1a28db // indirect github.com/go-chi/chi/v5 v5.2.2 // indirect github.com/go-json-experiment/json v0.0.0-20250103232110-6a9a0fde9288 // indirect github.com/go-llsqlite/adapter v0.0.0-20230927005056-7f5ce7f0c916 // indirect diff --git a/go.sum b/go.sum index 53a3fee8..951465ce 100644 --- a/go.sum +++ b/go.sum @@ -258,8 +258,8 @@ github.com/getlantern/pluriconfig v0.0.0-20251126214241-8cc8bc561535 h1:rtDmW8YL github.com/getlantern/pluriconfig v0.0.0-20251126214241-8cc8bc561535/go.mod h1:WKJEdjMOD4IuTRYwjQHjT4bmqDl5J82RShMLxPAvi0Q= github.com/getlantern/publicip v0.0.0-20260328175246-2c460fe80c6b h1:gMYJzEhLrmIqQ+JnjiYNm+UyUDalK3WUmVyecFwmV5g= github.com/getlantern/publicip v0.0.0-20260328175246-2c460fe80c6b/go.mod h1:NpfXdK4ldEKkjQ4P1R+DBF4ua5VFOlxmgHROTnYrApg= -github.com/getlantern/samizdat v0.0.3-0.20260327203406-ef7323341974 h1:k+/qNo5YNO+8M8LVUp6G5Evm1OQdEs3Z4ye8top4AhI= -github.com/getlantern/samizdat v0.0.3-0.20260327203406-ef7323341974/go.mod h1:uEeykQSW2/6rTjfPlj3MTTo59poSHXfAHTGgzYDkbr0= +github.com/getlantern/samizdat v0.0.3-0.20260511220916-bbb8ba1a28db h1:2gV2u8cnjgmXRZHdVk7/amuo+PzboBqZxuWwwMIALsY= +github.com/getlantern/samizdat v0.0.3-0.20260511220916-bbb8ba1a28db/go.mod h1:uEeykQSW2/6rTjfPlj3MTTo59poSHXfAHTGgzYDkbr0= github.com/getlantern/semconv v0.0.0-20260327040646-21845dda05cb h1:c5YM7b3a4r2J8Eh89KkI6M/iTFe6Bi+b8AJlfkKdFq4= github.com/getlantern/semconv v0.0.0-20260327040646-21845dda05cb/go.mod h1:GkPT5P9JoOTIRXRmFWxYgu1hhXgTFFTNc2hoG7WQc3g= github.com/getlantern/sing v0.7.18-lantern h1:QKGgIUA3LwmKYP/7JlQTRkxj9jnP4cX2Q/B+nd8XEjo=