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>" 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/account/subscription.go b/account/subscription.go index 2a6f54f1..081fb1e2 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. @@ -180,6 +186,9 @@ func (a *Client) SubscriptionPaymentRedirectURL(ctx context.Context, data Paymen "email": data.Email, "billingType": string(data.BillingType), } + if data.IdempotencyKey != "" { + params["idempotencyKey"] = data.IdempotencyKey + } return a.paymentRedirect(ctx, "/subscription-payment-redirect", params) } @@ -194,6 +203,9 @@ func (a *Client) PaymentRedirect(ctx context.Context, data PaymentRedirectData) "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 cedd3ee3..8b1006d6 100644 --- a/account/subscription_test.go +++ b/account/subscription_test.go @@ -9,30 +9,67 @@ 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: "", + 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 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"} + + 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..bb103f73 100644 --- a/account/user_test.go +++ b/account/user_test.go @@ -22,9 +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 + 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) { @@ -180,11 +185,21 @@ 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"}) + 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) { - writeJSONResponse(w, map[string]string{"Redirect": "https://example.com/redirect"}) + 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"} + } + writeJSONResponse(w, resp) }) mux.HandleFunc("/stripe-subscription", func(w http.ResponseWriter, r *http.Request) { diff --git a/backend/radiance.go b/backend/radiance.go index 68f362aa..e46acc74 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" @@ -67,6 +68,7 @@ type LocalBackend struct { srvManager *servers.Manager vpnClient *vpn.VPNClient splitTunnelMgr *vpn.SplitTunnel + sessionHistory *vpn.SessionHistory peerClient peerController peerToggleMu sync.Mutex peerWG sync.WaitGroup @@ -199,6 +201,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 } @@ -227,9 +231,17 @@ func (r *LocalBackend) Start() { } r.startVPNStatusListeners() r.startAutoSelectedListener() + r.startSessionAutoSelectListener() 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) != "" { @@ -318,7 +330,9 @@ func (r *LocalBackend) Close() { // Wait for an in-flight peer auto-resume so we don't tear down ctx // while it's mid-Start (which would leave a registered route + open // box behind). Then stop with a fresh ctx so Deregister has a live - // HTTP deadline even though r.ctx is about to cancel. + // HTTP deadline even though r.ctx is about to cancel. Peer-share + // teardown runs BEFORE DisconnectVPN because the peer's + // /v1/peer/deregister request needs a working outbound path. if r.peerClient != nil { r.peerWG.Wait() if r.peerClient.IsActive() { @@ -329,6 +343,9 @@ func (r *LocalBackend) Close() { cancel() } } + 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 { @@ -352,15 +369,32 @@ 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 // ////////////////// // 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. The additionalAttachments parameter allows -// the caller to include any extra files they want to attach to the issue report. -func (r *LocalBackend) ReportIssue(issueType issue.IssueType, description, email string, additionalAttachments []string) error { +// and locale, and log files to include in the report. +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 @@ -372,11 +406,11 @@ func (r *LocalBackend) ReportIssue(issueType issue.IssueType, description, email country = cfg.Country } - attachments := baseIssueAttachments() + attachmentPaths := baseIssueAttachments() if r.splitTunnelMgr.IsEnabled() { - attachments = append(attachments, filepath.Join(settings.GetString(settings.DataPathKey), internal.SplitTunnelFileName)) + attachmentPaths = append(attachmentPaths, filepath.Join(settings.GetString(settings.DataPathKey), internal.SplitTunnelFileName)) } - attachments = append(attachments, additionalAttachments...) + attachmentPaths = append(attachmentPaths, additionalAttachments...) report := issue.IssueReport{ Type: issueType, @@ -387,7 +421,8 @@ func (r *LocalBackend) ReportIssue(issueType issue.IssueType, description, email UserID: settings.GetString(settings.UserIDKey), SubscriptionLevel: settings.GetString(settings.UserLevelKey), Locale: settings.GetString(settings.LocaleKey), - AdditionalAttachments: attachments, + Attachments: attachments, + AdditionalAttachments: attachmentPaths, } err = r.issueReporter.Report(ctx, report) if err != nil { @@ -842,8 +877,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 @@ -852,6 +886,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 } @@ -889,6 +928,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() @@ -942,6 +985,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..67c74244 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"` } @@ -46,25 +53,109 @@ type ReportIssueCmd struct { } func runReportIssue(ctx context.Context, c *ipc.Client, cmd *ReportIssueCmd) error { - return c.ReportIssue(ctx, issue.IssueType(cmd.Type), cmd.Description, cmd.Email, nil) + return c.ReportIssue(ctx, issue.IssueType(cmd.Type), cmd.Description, cmd.Email, nil, 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/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/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 f8673a58..6a8ee457 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" @@ -55,11 +56,32 @@ 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) + // 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] 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") @@ -94,6 +116,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 @@ -105,6 +128,121 @@ 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. Candidates in priority order: +// +// 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) +// +// 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) { + 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"}, + } + 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 + } + } + } + // 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). + 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..e38b1a22 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,270 @@ func TestInitSettings(t *testing.T) { require.Error(t, loadSettings(path), "expected error for invalid config file") }) } + +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") + 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("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 + // 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") + }) +} 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/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) +} diff --git a/go.mod b/go.mod index 4ff14ecb..a8086083 100644 --- a/go.mod +++ b/go.mod @@ -26,12 +26,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/kindling v0.0.0-20260507163327-92a44d03bdc5 + 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 @@ -113,9 +114,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-20260501210609-ce5f75aa2054 // 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 @@ -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 94048681..c7232c90 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= @@ -246,10 +246,10 @@ 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/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/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-0.20260511221021-3201d6aa113f h1:g69qLYaYQ6rDAJb2Ipj1UxXieeOXn22kv4JvItcZ8wE= +github.com/getlantern/lantern-box v0.0.78-0.20260511221021-3201d6aa113f/go.mod h1:Ywnt1UzFdYVj5/cvSAoQ5KYpQqt00rQyKAdJuFDM3T4= 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= @@ -260,8 +260,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= diff --git a/ipc/client.go b/ipc/client.go index 75afa548..0f3de307 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 { @@ -627,10 +646,18 @@ 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. -func (c *Client) ReportIssue(ctx context.Context, issueType issue.IssueType, description, email string, additionalAttachments []string) error { +// 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{IssueType: issueType, Description: description, Email: email, AdditionalAttachments: additionalAttachments}) + IssueReportRequest{ + IssueType: issueType, + Description: description, + Email: email, + AdditionalAttachments: additionalAttachments, + Attachments: attachments, + }) return err } 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/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 a9fdf5c1..2a06ecd2 100644 --- a/ipc/server.go +++ b/ipc/server.go @@ -11,6 +11,7 @@ import ( "log/slog" "net" "net/http" + "strconv" "sync/atomic" "time" @@ -36,8 +37,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" @@ -62,8 +65,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" @@ -199,7 +203,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) @@ -228,6 +234,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)) @@ -385,6 +392,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) @@ -485,6 +516,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 // /////////////////////// @@ -1154,7 +1234,7 @@ func (s *localapi) issueReportHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } - if err := s.backend(r.Context()).ReportIssue(req.IssueType, req.Description, req.Email, req.AdditionalAttachments); err != nil { + if err := s.backend(r.Context()).ReportIssue(req.IssueType, req.Description, req.Email, req.AdditionalAttachments, req.Attachments); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } diff --git a/ipc/types.go b/ipc/types.go index b79f9c30..7845a7de 100644 --- a/ipc/types.go +++ b/ipc/types.go @@ -102,10 +102,11 @@ type VerifySubscriptionRequest struct { } type IssueReportRequest struct { - IssueType issue.IssueType `json:"issueType"` - Description string `json:"description"` - Email string `json:"email"` - AdditionalAttachments []string `json:"additionalAttachments"` + IssueType issue.IssueType `json:"issueType"` + Description string `json:"description"` + Email string `json:"email"` + AdditionalAttachments []string `json:"additionalAttachments"` + Attachments []*issue.Attachment `json:"attachments"` } // Shared response types used by both client and server. diff --git a/issue/issue.go b/issue/issue.go index 12e67c8d..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" ) @@ -41,6 +41,12 @@ func NewIssueReporter(httpClient *http.Client) *IssueReporter { type IssueType int +type Attachment struct { + Name string + Type string + Data []byte +} + const ( CannotCompletePurchase IssueType = iota CannotSignIn @@ -80,6 +86,10 @@ type IssueReport struct { Locale string // device alphanumeric name Model string + // 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. AdditionalAttachments []string @@ -116,17 +126,25 @@ func (ir *IssueReporter) Report(ctx context.Context, report IssueReport) error { OsVersion: osVersion, } + firstClassAttachments := make([]*Attachment, 0, len(report.Attachments)) + for _, attachment := range report.Attachments { + if attachment == nil { + continue + } + firstClassAttachments = append(firstClassAttachments, attachment) + } + logDir := settings.GetString(settings.LogPathKey) archive, err := buildIssueArchive(logDir, report.AdditionalAttachments, maxCompressedSize) if err != nil { slog.Error("failed to build issue archive", "error", err) } if len(archive) > 0 { - r.Attachments = []*ReportIssueRequest_Attachment{{ + r.Attachments = append(r.Attachments, &ReportIssueRequest_Attachment{ Type: "application/zip", Name: "logs.zip", Content: archive, - }} + }) } // send message to lantern-cloud @@ -136,12 +154,30 @@ func (ir *IssueReporter) Report(ctx context.Context, report IssueReport) error { return fmt.Errorf("error marshaling proto: %w", err) } + contentType := "application/x-protobuf" + body := bytes.NewReader(out) + if len(firstClassAttachments) > 0 { + if err := validateFirstClassAttachments(firstClassAttachments); 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 := newIssueRequest( ctx, http.MethodPost, issueURL, - bytes.NewReader(out), + body, + contentType, ) if err != nil { slog.Error("unable to create issue report request", "error", err) @@ -169,13 +205,16 @@ func (ir *IssueReporter) Report(ctx context.Context, report IssueReport) error { } // 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 := common.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(common.ContentTypeHeader, contentType) req.Header.Set(common.SupportedDataCapsHeader, "monthly,weekly,daily") if tz, err := timezone.IANANameForTime(time.Now()); err == nil { req.Header.Set(common.TimeZoneHeader, tz) diff --git a/issue/issue_test.go b/issue/issue_test.go index 58609fa3..2e812ce7 100644 --- a/issue/issue_test.go +++ b/issue/issue_test.go @@ -5,11 +5,12 @@ import ( "bytes" "context" "io" + "mime" + "mime/multipart" "net/http" - "net/http/httptest" - "net/url" "os" "path/filepath" + "strings" "testing" "github.com/getlantern/osversion" @@ -51,17 +52,7 @@ func TestSendReport(t *testing.T) { Language: settings.GetString(settings.LocaleKey), } - srv := newTestServer(t, want) - defer srv.Close() - - reporter := NewIssueReporter(&http.Client{ - Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { - parsedURL, err := url.Parse(srv.URL) - require.NoError(t, err, "failed to parse test server URL") - req.URL = parsedURL - return http.DefaultTransport.RoundTrip(req) - }), - }) + reporter := NewIssueReporter(newProtobufTestClient(t, want, assertLogsZipContainsHello)) report := IssueReport{ Type: CannotAccessBlockedSites, Description: "Description placeholder-test only", @@ -80,6 +71,79 @@ func TestSendReport(t *testing.T) { require.NoError(t, err) } +func TestSendReportWithFirstClassAttachment(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: settings.GetString(settings.UserIDKey), + Device: "Samsung Galaxy S10", + Model: "SM-G973F", + OsVersion: osVer, + Language: settings.GetString(settings.LocaleKey), + } + + reporter := NewIssueReporter(newMultipartTestClient(t, want, multipartFile{ + fieldName: attachmentPartName, + filename: "screenshot.png", + contentType: "image/png", + content: []byte("png-bytes"), + })) + report := IssueReport{ + Type: CannotAccessBlockedSites, + Description: "Description placeholder-test only", + Email: "radiancetest@getlantern.org", + CountryCode: "US", + SubscriptionLevel: "free", + DeviceID: settings.GetString(settings.DeviceIDKey), + UserID: settings.GetString(settings.UserIDKey), + Locale: settings.GetString(settings.LocaleKey), + Device: "Samsung Galaxy S10", + Model: "SM-G973F", + Attachments: []*Attachment{ + { + Name: "screenshot.png", + Type: "image/png", + Data: []byte("png-bytes"), + }, + }, + } + + err = reporter.Report(context.Background(), report) + require.NoError(t, err) +} + +func TestSendReportRejectsInvalidFirstClassAttachment(t *testing.T) { + settings.InitSettings(t.TempDir()) + defer settings.Reset() + + reporter := NewIssueReporter(&http.Client{}) + err := reporter.Report(context.Background(), IssueReport{ + Type: CannotAccessBlockedSites, + Description: "validation path", + Email: "radiancetest@getlantern.org", + Attachments: []*Attachment{ + { + Name: "report.pdf", + Type: "application/pdf", + Data: []byte("pdf"), + }, + }, + }) + require.ErrorContains(t, err, "unsupported screenshot attachment type") +} + // roundTripperFunc allows using a function as http.RoundTripper type roundTripperFunc func(*http.Request) (*http.Response, error) @@ -87,53 +151,139 @@ 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 multipartFile struct { + fieldName string + filename string + contentType string + content []byte } -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") - - // Verify logs.zip attachment contains the additional file - var foundHello bool - for _, att := range got.Attachments { - if att.Name == "logs.zip" { - zr, err := zip.NewReader(bytes.NewReader(att.Content), int64(len(att.Content))) - require.NoError(t, err, "should open logs.zip") - for _, f := range zr.File { - if f.Name == "attachments/Hello.txt" { - rc, err := f.Open() - require.NoError(t, err) - data, err := io.ReadAll(rc) - require.NoError(t, err) - rc.Close() - assert.Equal(t, "Hello World", string(data)) - foundHello = true - } - } - } +func newMultipartTestClient(t *testing.T, want *ReportIssueRequest, wantFile multipartFile) *http.Client { + t.Helper() + + return &http.Client{ + Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + require.True(t, strings.HasPrefix(req.Header.Get("Content-Type"), "multipart/form-data")) + + got, files := decodeMultipartRequest(t, req) + require.True(t, proto.Equal(want, got), "received report should match expected report") + require.Len(t, files, 1) + assert.Equal(t, wantFile, files[0]) + + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader("ok")), + Header: make(http.Header), + Request: req, + }, nil + }), + } +} + +func decodeMultipartRequest(t *testing.T, r *http.Request) (*ReportIssueRequest, []multipartFile) { + t.Helper() + defer r.Body.Close() + + mediaType, params, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + require.NoError(t, err) + require.Equal(t, "multipart/form-data", mediaType) + + reader := multipart.NewReader(r.Body, params["boundary"]) + var request *ReportIssueRequest + var files []multipartFile + + for { + part, err := reader.NextPart() + if err == io.EOF { + break } - assert.True(t, foundHello, "logs.zip should contain attachments/Hello.txt") + require.NoError(t, err) - // Clear attachments for field-level comparison - got.Attachments = nil + content, err := io.ReadAll(part) + require.NoError(t, err) - // 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) + switch part.FormName() { + case requestPartName: + var got ReportIssueRequest + err = proto.Unmarshal(content, &got) + require.NoError(t, err) + request = &got + case attachmentPartName: + files = append(files, multipartFile{ + 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, request) + return request, files +} + +func newProtobufTestClient(t *testing.T, want *ReportIssueRequest, validate func(*testing.T, *ReportIssueRequest)) *http.Client { + t.Helper() + + return &http.Client{ + Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + require.Equal(t, "application/x-protobuf", req.Header.Get("Content-Type")) + + got := decodeProtoRequest(t, req.Body) + if validate != nil { + validate(t, got) + } + + got.Attachments = nil + require.True(t, proto.Equal(want, got), "received report should match expected report") + + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader("ok")), + Header: make(http.Header), + Request: req, + }, nil + }), + } +} + +func decodeProtoRequest(t *testing.T, body io.ReadCloser) *ReportIssueRequest { + t.Helper() + defer body.Close() + + payload, err := io.ReadAll(body) + require.NoError(t, err) + + var got ReportIssueRequest + err = proto.Unmarshal(payload, &got) + require.NoError(t, err) + return &got +} + +func assertLogsZipContainsHello(t *testing.T, got *ReportIssueRequest) { + t.Helper() + + var found bool + for _, att := range got.Attachments { + if att.Name != "logs.zip" { + continue + } + zr, err := zip.NewReader(bytes.NewReader(att.Content), int64(len(att.Content))) + require.NoError(t, err) + for _, f := range zr.File { + if f.Name != "attachments/Hello.txt" { + continue + } + rc, err := f.Open() + require.NoError(t, err) + data, err := io.ReadAll(rc) + require.NoError(t, err) + require.NoError(t, rc.Close()) + assert.Equal(t, "Hello World", string(data)) + found = true + } + } + assert.True(t, found, "logs.zip should contain attachments/Hello.txt") } diff --git a/issue/transport.go b/issue/transport.go new file mode 100644 index 00000000..48d89609 --- /dev/null +++ b/issue/transport.go @@ -0,0 +1,228 @@ +package issue + +import ( + "bytes" + "fmt" + "mime" + "mime/multipart" + "net/http" + "net/textproto" + "path/filepath" + "strings" +) + +const ( + // 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" + 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", +} + +// 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 == "" { + return "" + } + + mediaType, _, err := mime.ParseMediaType(contentType) + if err == nil { + contentType = mediaType + } + + if alias, ok := attachmentTypeAliases[contentType]; ok { + return alias + } + 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 || len(attachment.Data) == 0 { + return octetStreamContentType + } + + if contentType := normalizeAttachmentType(attachment.Type); contentType != "" { + return contentType + } + + name := strings.TrimSpace(attachment.Name) + if contentType := normalizeAttachmentType( + mime.TypeByExtension(strings.ToLower(filepath.Ext(name))), + ); contentType != "" { + return contentType + } + + 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) error { + count := 0 + 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 + } + + 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 > MaxFirstClassAttachmentBytes { + return fmt.Errorf( + "total screenshot attachment size exceeds %d bytes", + MaxFirstClassAttachmentBytes, + ) + } + } + + 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, +) (*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 + } + + filename, err := normalizeAttachmentName(attachment.Name) + if err != nil { + return nil, "", err + } + + partHeader := make(textproto.MIMEHeader) + partHeader.Set( + "Content-Disposition", + multipartContentDisposition(attachmentPartName, filename), + ) + 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 +} + +// 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"`, + escapeMultipartToken(fieldName), + escapeMultipartToken(filename), + ) +} + +// 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 == "" { + 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 +} + +// escapeMultipartToken quotes characters that are special in Content-Disposition +// parameter values. +func escapeMultipartToken(value string) string { + replacer := strings.NewReplacer("\\", "\\\\", `"`, "\\\"") + return replacer.Replace(value) +} diff --git a/peer/api.go b/peer/api.go index 05752402..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" ) @@ -47,32 +48,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 @@ -87,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.go b/peer/peer.go index 8530e5ee..60954854 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -7,11 +7,16 @@ import ( "fmt" "log/slog" "math/rand/v2" + "os" "sync" + "sync/atomic" "time" "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" "github.com/getlantern/radiance/portforward" ) @@ -23,6 +28,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. @@ -45,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"` @@ -87,6 +132,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 @@ -101,6 +159,33 @@ func NewClient(cfg Config) (*Client, error) { } if cfg.NewForwarder == nil { cfg.NewForwarder = func(ctx context.Context) (portForwarder, error) { + // 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 { + 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 @@ -127,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() @@ -136,6 +221,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 @@ -156,6 +246,13 @@ 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. 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() } @@ -168,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) @@ -180,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, @@ -200,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) @@ -219,6 +332,48 @@ 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 + // 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 + // 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. 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) { + 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 { heartbeat = time.Duration(regResp.HeartbeatIntervalSeconds) * time.Second @@ -236,6 +391,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, @@ -280,8 +436,21 @@ 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 + // 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() <-done @@ -304,7 +473,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 } @@ -320,6 +493,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. @@ -409,8 +598,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) } diff --git a/peer/peer_test.go b/peer/peer_test.go index 870dbfe8..6bc70797 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" ) @@ -139,12 +140,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 +159,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 +169,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 +181,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 +199,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 { @@ -509,37 +523,222 @@ 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") + } } } 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) + } +} diff --git a/portforward/manual.go b/portforward/manual.go new file mode 100644 index 00000000..8d9dea9f --- /dev/null +++ b/portforward/manual.go @@ -0,0 +1,107 @@ +package portforward + +import ( + "context" + "fmt" + "strconv" +) + +// 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 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 +// 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..18daef5f --- /dev/null +++ b/portforward/manual_test.go @@ -0,0 +1,122 @@ +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 +} + +// 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() + + 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) + } + }) +} 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 + } +} 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/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/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, }, } 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