From 6a6ec1682571cd2f194959e3bbde7c7b240d962f Mon Sep 17 00:00:00 2001 From: Jonathon Leight Date: Sun, 28 Jun 2026 19:59:09 -0400 Subject: [PATCH] Linting and formatting --- .config/mise/config.toml | 4 ++++ .golangci.yml | 28 +++++++++++++++++++++++ .woodpecker/build.yaml | 3 ++- .woodpecker/lint.yaml | 17 ++++++++++++++ internal/auth/handlers.go | 2 +- internal/auth/handoff.go | 12 +++++----- internal/auth/password_handlers.go | 4 ++-- internal/auth/user.go | 2 +- internal/auth/web.go | 8 +++---- internal/core/config_profile.go | 2 +- internal/core/confirm.go | 10 ++++---- internal/core/confirm_integration_test.go | 6 ++--- internal/core/confirm_location_test.go | 2 +- internal/core/confirm_retry_test.go | 2 +- internal/core/console.go | 10 ++++---- internal/core/console_integration_test.go | 2 +- internal/core/console_resolve_test.go | 10 ++++---- internal/core/org_domains.go | 17 +++++++------- internal/core/org_participation.go | 4 ++-- internal/core/orgs.go | 12 +++++----- internal/core/registry.go | 6 ++--- internal/core/repeaters.go | 8 +++---- internal/core/shares.go | 12 +++++----- internal/core/web.go | 2 +- internal/marketing/marketing.go | 2 +- internal/mesh/command_test.go | 4 ++-- internal/mesh/exchange_test.go | 2 +- internal/mesh/mesh.go | 4 ++-- internal/mesh/mesh_test.go | 8 +++---- internal/store/config_profiles_test.go | 2 +- internal/store/helpers.go | 2 +- internal/store/store.go | 2 +- internal/testdb/testdb.go | 8 +++---- internal/web/env.go | 2 +- internal/web/markdown.go | 2 +- 35 files changed, 137 insertions(+), 86 deletions(-) create mode 100644 .golangci.yml create mode 100644 .woodpecker/lint.yaml diff --git a/.config/mise/config.toml b/.config/mise/config.toml index e0fd244..352f092 100644 --- a/.config/mise/config.toml +++ b/.config/mise/config.toml @@ -1,5 +1,6 @@ [tools] go = "latest" +"golangci-lint" = "latest" pitchfork = "latest" [env] @@ -11,6 +12,9 @@ auto = true [tasks.dev] run = "go run ./cmd/meshtender" +[tasks.lint] +run = "golangci-lint run" + [tasks.seed] run = "go run ./cmd/meshtender --seed" diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..8432fa1 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,28 @@ +version: "2" + +run: + timeout: 5m + +issues: + # Report everything; the defaults (max-same-issues: 3, max-issues-per-linter: + # 50) hide repeated findings and make fixing a whack-a-mole. + max-issues-per-linter: 0 + max-same-issues: 0 + +linters: + default: standard # errcheck, govet, ineffassign, staticcheck, unused + enable: + - gosec # security analyzer (injection, weak crypto, hardcoded creds, ...) + exclusions: + rules: + # Test code doesn't warrant errcheck/gosec scrutiny (unchecked Close on + # throwaway connections, hand-built insecure cookies in fixtures, etc.). + - path: _test\.go + linters: + - errcheck + - gosec + +formatters: + enable: + - gofmt + - goimports diff --git a/.woodpecker/build.yaml b/.woodpecker/build.yaml index 766ad1f..4b93f49 100644 --- a/.woodpecker/build.yaml +++ b/.woodpecker/build.yaml @@ -1,5 +1,5 @@ # Builds the OCI image and pushes it to the Forgejo registry. Runs only after -# tests pass, and only on the main branch and on tags (not on pull requests). +# tests and lint pass, and only on the main branch and on tags (not on PRs). when: - event: push branch: main @@ -7,6 +7,7 @@ when: depends_on: - test + - lint steps: publish: diff --git a/.woodpecker/lint.yaml b/.woodpecker/lint.yaml new file mode 100644 index 0000000..8f93e5b --- /dev/null +++ b/.woodpecker/lint.yaml @@ -0,0 +1,17 @@ +# Runs golangci-lint (staticcheck + gosec + the standard correctness set, per +# .golangci.yml) on every push and pull request. Tracks :latest to match the +# unpinned golangci-lint in .config/mise/config.toml used for local runs. +when: + - event: [push, pull_request] + +steps: + lint: + image: golangci/golangci-lint:latest + environment: + # go.mod may target a newer Go than the image bundles; let the toolchain + # be fetched on demand rather than failing. + GOTOOLCHAIN: auto + commands: + - golangci-lint run + +depends_on: [] diff --git a/internal/auth/handlers.go b/internal/auth/handlers.go index fa2bd3b..4259726 100644 --- a/internal/auth/handlers.go +++ b/internal/auth/handlers.go @@ -269,7 +269,7 @@ func (s *Service) LoginDiscoverableFinish(w http.ResponseWriter, r *http.Request if len(userHandle) != 8 { return nil, errors.New("unrecognized user handle") } - uid := int64(binary.BigEndian.Uint64(userHandle)) + uid := int64(binary.BigEndian.Uint64(userHandle)) //nolint:gosec // G115: decodes the 8-byte WebAuthn handle encoded from our int64 user ID u, err := s.store.GetUserByID(ctx, uid) if err != nil { return nil, err diff --git a/internal/auth/handoff.go b/internal/auth/handoff.go index 41d29a6..f28044d 100644 --- a/internal/auth/handoff.go +++ b/internal/auth/handoff.go @@ -155,7 +155,7 @@ func (s *Service) startAuth(w http.ResponseWriter, r *http.Request, next, page s http.Error(w, "could not start sign-in", http.StatusInternalServerError) return } - http.SetCookie(w, &http.Cookie{ + http.SetCookie(w, &http.Cookie{ //nolint:gosec // G124: HttpOnly+SameSite set below; Secure is gated on TLS via s.secure Name: cookieName(stateCookie, s.secure), Value: state, Path: "/", @@ -165,7 +165,7 @@ func (s *Service) startAuth(w http.ResponseWriter, r *http.Request, next, page s SameSite: http.SameSiteLaxMode, }) q := url.Values{"next": {next}, "state": {state}} - http.Redirect(w, r, s.authOrigin(r)+page+"?"+q.Encode(), http.StatusSeeOther) + http.Redirect(w, r, s.authOrigin(r)+page+"?"+q.Encode(), http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin } // SessionCallback runs on the app host. It verifies the state nonce against the @@ -206,11 +206,11 @@ func (s *Service) SessionCallback(w http.ResponseWriter, r *http.Request) { // round (discovery renders anonymous until the next sign-in). if s.rootHost != "" { if code, err := s.store.CreateAuthCode(ctx, userID, loginID, next); err == nil { - http.Redirect(w, r, s.rootOrigin(r)+"/session/beacon?code="+url.QueryEscape(code), http.StatusSeeOther) + http.Redirect(w, r, s.rootOrigin(r)+"/session/beacon?code="+url.QueryEscape(code), http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin return } } - http.Redirect(w, r, next, http.StatusSeeOther) + http.Redirect(w, r, next, http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin } // BeaconCallback runs on the root (discovery) host. It redeems a single-use code @@ -234,11 +234,11 @@ func (s *Service) BeaconCallback(w http.ResponseWriter, r *http.Request) { if ok { _ = s.loginWithID(ctx, userID, loginID) } - http.Redirect(w, r, s.appOrigin(r)+next, http.StatusSeeOther) + http.Redirect(w, r, s.appOrigin(r)+next, http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin } func clearStateCookie(w http.ResponseWriter, secure bool) { - http.SetCookie(w, &http.Cookie{ + http.SetCookie(w, &http.Cookie{ //nolint:gosec // G124: clears the state cookie (MaxAge<0); attributes mirror the original Name: cookieName(stateCookie, secure), Value: "", Path: "/", diff --git a/internal/auth/password_handlers.go b/internal/auth/password_handlers.go index 21f288f..a982a5d 100644 --- a/internal/auth/password_handlers.go +++ b/internal/auth/password_handlers.go @@ -36,7 +36,7 @@ func (s *Service) SignupPassword(w http.ResponseWriter, r *http.Request) { redirectErr(w, r, "/signup", "Could not start session.") return } - http.Redirect(w, r, s.PostAuthRedirect(r, u.ID), http.StatusSeeOther) + http.Redirect(w, r, s.PostAuthRedirect(r, u.ID), http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin } // LoginPassword handles a form-based password sign-in. @@ -53,7 +53,7 @@ func (s *Service) LoginPassword(w http.ResponseWriter, r *http.Request) { redirectErr(w, r, "/login", "Could not start session.") return } - http.Redirect(w, r, s.PostAuthRedirect(r, u.ID), http.StatusSeeOther) + http.Redirect(w, r, s.PostAuthRedirect(r, u.ID), http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin } func redirectErr(w http.ResponseWriter, r *http.Request, path, msg string) { diff --git a/internal/auth/user.go b/internal/auth/user.go index 9be55ba..7f4c963 100644 --- a/internal/auth/user.go +++ b/internal/auth/user.go @@ -21,7 +21,7 @@ type webauthnUser struct { func (u *webauthnUser) WebAuthnID() []byte { b := make([]byte, 8) - binary.BigEndian.PutUint64(b, uint64(u.user.ID)) + binary.BigEndian.PutUint64(b, uint64(u.user.ID)) //nolint:gosec // G115: user row ID is non-negative return b } diff --git a/internal/auth/web.go b/internal/auth/web.go index 74e7b78..d7c5883 100644 --- a/internal/auth/web.go +++ b/internal/auth/web.go @@ -91,7 +91,7 @@ func (s *Handlers) pageLogin(w http.ResponseWriter, r *http.Request) { // Already signed in (e.g. an existing auth-host SSO session): skip the // ceremony and hand straight off, rather than re-prompting. if uid := s.Auth.CurrentUserID(ctx); uid != 0 { - http.Redirect(w, r, s.Auth.PostAuthRedirect(r, uid), http.StatusSeeOther) + http.Redirect(w, r, s.Auth.PostAuthRedirect(r, uid), http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin return } s.Render(w, r, "login.html", map[string]any{ @@ -106,7 +106,7 @@ func (s *Handlers) pageSignup(w http.ResponseWriter, r *http.Request) { s.Auth.SetNext(ctx, r.URL.Query().Get("next")) s.Auth.SetAuthState(ctx, r.URL.Query().Get("state")) if uid := s.Auth.CurrentUserID(ctx); uid != 0 { - http.Redirect(w, r, s.Auth.PostAuthRedirect(r, uid), http.StatusSeeOther) + http.Redirect(w, r, s.Auth.PostAuthRedirect(r, uid), http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin return } s.Render(w, r, "signup.html", map[string]any{ @@ -122,9 +122,9 @@ func (s *Handlers) handleAuthLogout(w http.ResponseWriter, r *http.Request) { _ = s.Auth.Logout(r.Context()) switch { case s.Cfg.RootHost != "": - http.Redirect(w, r, s.Origin(r, s.Cfg.RootHost)+"/", http.StatusSeeOther) + http.Redirect(w, r, s.Origin(r, s.Cfg.RootHost)+"/", http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin case s.Cfg.PrimaryHost != "": - http.Redirect(w, r, s.Origin(r, s.Cfg.PrimaryHost)+"/", http.StatusSeeOther) + http.Redirect(w, r, s.Origin(r, s.Cfg.PrimaryHost)+"/", http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin default: http.Redirect(w, r, "/login", http.StatusSeeOther) } diff --git a/internal/core/config_profile.go b/internal/core/config_profile.go index a9b330e..8d2e7c4 100644 --- a/internal/core/config_profile.go +++ b/internal/core/config_profile.go @@ -153,7 +153,7 @@ func (s *Handlers) handleSaveOrgConfig(w http.ResponseWriter, r *http.Request) { http.Error(w, "could not save", http.StatusInternalServerError) return } - http.Redirect(w, r, "/orgs/"+orgParam(r)+"/config", http.StatusSeeOther) + http.Redirect(w, r, "/orgs/"+orgParam(r)+"/config", http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin } // parseProfiles reads the repeated profile blocks from the form into store inputs diff --git a/internal/core/confirm.go b/internal/core/confirm.go index 18d4fa8..403a340 100644 --- a/internal/core/confirm.go +++ b/internal/core/confirm.go @@ -78,7 +78,7 @@ func (s *Handlers) wsConfirm(w http.ResponseWriter, r *http.Request) { // spurious "tx done timeout". We don't need it; the repeater's reply is the // real confirmation we wait for. modem := hardware.NewKissModem(bridge, hardware.WithTxFlowControl(0)) - defer modem.Close() + defer func() { _ = modem.Close() }() server := s.Identity.Local() debug := r.URL.Query().Get("debug") == "1" @@ -139,10 +139,10 @@ func (s *Handlers) wsConfirm(w http.ResponseWriter, r *http.Request) { _ = bridge.Status("info", "Tuning radio…") if err := modem.SetRadio(&hardware.RadioConfig{ - FreqHz: uint32(rep.RadioFreqHz), - BwHz: uint32(rep.RadioBwHz), - SF: uint8(rep.RadioSF), - CR: uint8(rep.RadioCR), + FreqHz: uint32(rep.RadioFreqHz), //nolint:gosec // G115: radio config value is bounded (preset-constrained) + BwHz: uint32(rep.RadioBwHz), //nolint:gosec // G115: radio config value is bounded (preset-constrained) + SF: uint8(rep.RadioSF), //nolint:gosec // G115: radio config value is bounded (preset-constrained) + CR: uint8(rep.RadioCR), //nolint:gosec // G115: radio config value is bounded (preset-constrained) }); err != nil { _ = bridge.Status("error", "set radio: "+err.Error()) return diff --git a/internal/core/confirm_integration_test.go b/internal/core/confirm_integration_test.go index 136502d..971f34f 100644 --- a/internal/core/confirm_integration_test.go +++ b/internal/core/confirm_integration_test.go @@ -131,8 +131,8 @@ func TestConfirmRoundTrip(t *testing.T) { if err != nil { t.Fatalf("parse anon req: %v", err) } - if anon.Destination != repeater.Identity.Hash()[0] { - t.Fatalf("login addressed to 0x%02x, want repeater 0x%02x", anon.Destination, repeater.Identity.Hash()[0]) + if anon.Destination != repeater.Hash()[0] { + t.Fatalf("login addressed to 0x%02x, want repeater 0x%02x", anon.Destination, repeater.Hash()[0]) } shared, err := repeater.SharedSecret(serverID) if err != nil { @@ -198,7 +198,7 @@ func buildResponseFrame(t *testing.T, repeater meshcore.LocalIdentity, server me } resp := &meshcore.Response{ Destination: server.Hash()[0], - Source: repeater.Identity.Hash()[0], + Source: repeater.Hash()[0], MAC: [2]byte{enc[0], enc[1]}, EncryptedPayload: enc[2:], } diff --git a/internal/core/confirm_location_test.go b/internal/core/confirm_location_test.go index dcf6abc..d334d5a 100644 --- a/internal/core/confirm_location_test.go +++ b/internal/core/confirm_location_test.go @@ -147,7 +147,7 @@ func TestConfirmFetchesLocation(t *testing.T) { resp[7] = 3 plain := append([]byte{0x00, meshcore.PayloadTypeResponse}, resp...) // [path_len=0][type][response] enc, _ := meshcore.EncryptThenMAC(shared, plain) - p := &meshcore.Path{Destination: serverID.Hash()[0], Source: repeater.Identity.Hash()[0], MAC: [2]byte{enc[0], enc[1]}, EncryptedPayload: enc[2:]} + p := &meshcore.Path{Destination: serverID.Hash()[0], Source: repeater.Hash()[0], MAC: [2]byte{enc[0], enc[1]}, EncryptedPayload: enc[2:]} payload, _ := p.ToBytes() lp := &meshcore.Packet{Header: meshcore.MakeHeader(meshcore.RouteTypeFlood, meshcore.PayloadTypePath, 0), Payload: payload} raw, _ := lp.ToBytes() diff --git a/internal/core/confirm_retry_test.go b/internal/core/confirm_retry_test.go index 408d0c6..711bf0e 100644 --- a/internal/core/confirm_retry_test.go +++ b/internal/core/confirm_retry_test.go @@ -124,7 +124,7 @@ func TestConfirmLoginRetry(t *testing.T) { respData[7] = 3 body := append([]byte{0x00, meshcore.PayloadTypeResponse}, respData...) enc, _ := meshcore.EncryptThenMAC(shared, body) - p := &meshcore.Path{Destination: serverID.Hash()[0], Source: repeater.Identity.Hash()[0], MAC: [2]byte{enc[0], enc[1]}, EncryptedPayload: enc[2:]} + p := &meshcore.Path{Destination: serverID.Hash()[0], Source: repeater.Hash()[0], MAC: [2]byte{enc[0], enc[1]}, EncryptedPayload: enc[2:]} payload, _ := p.ToBytes() lp := &meshcore.Packet{Header: meshcore.MakeHeader(meshcore.RouteTypeFlood, meshcore.PayloadTypePath, 0), Payload: payload} raw, _ := lp.ToBytes() diff --git a/internal/core/console.go b/internal/core/console.go index 6f832da..7253b5f 100644 --- a/internal/core/console.go +++ b/internal/core/console.go @@ -197,7 +197,7 @@ func (s *Handlers) wsConsole(w http.ResponseWriter, r *http.Request) { bridge := wsbridge.New(ctx, ws) modem := hardware.NewKissModem(bridge, hardware.WithTxFlowControl(0)) - defer modem.Close() + defer func() { _ = modem.Close() }() server := s.Identity.Local() // All commands go through one exchanger: rate-limited, monotonic timestamps, @@ -272,10 +272,10 @@ func (s *Handlers) wsConsole(w http.ResponseWriter, r *http.Request) { // Tune the modem to the repeater's channel. _ = bridge.Status("info", "Tuning radio…") if err := modem.SetRadio(&hardware.RadioConfig{ - FreqHz: uint32(rep.RadioFreqHz), - BwHz: uint32(rep.RadioBwHz), - SF: uint8(rep.RadioSF), - CR: uint8(rep.RadioCR), + FreqHz: uint32(rep.RadioFreqHz), //nolint:gosec // G115: radio config value is bounded (preset-constrained) + BwHz: uint32(rep.RadioBwHz), //nolint:gosec // G115: radio config value is bounded (preset-constrained) + SF: uint8(rep.RadioSF), //nolint:gosec // G115: radio config value is bounded (preset-constrained) + CR: uint8(rep.RadioCR), //nolint:gosec // G115: radio config value is bounded (preset-constrained) }); err != nil { _ = bridge.Status("error", "set radio: "+err.Error()) return diff --git a/internal/core/console_integration_test.go b/internal/core/console_integration_test.go index bf8b99e..029f936 100644 --- a/internal/core/console_integration_test.go +++ b/internal/core/console_integration_test.go @@ -139,7 +139,7 @@ func TestConsoleRoundTrip(t *testing.T) { resp[7] = 3 body := append([]byte{0x00, meshcore.PayloadTypeResponse}, resp...) enc, _ := meshcore.EncryptThenMAC(shared, body) - p := &meshcore.Path{Destination: serverID.Hash()[0], Source: repeater.Identity.Hash()[0], MAC: [2]byte{enc[0], enc[1]}, EncryptedPayload: enc[2:]} + p := &meshcore.Path{Destination: serverID.Hash()[0], Source: repeater.Hash()[0], MAC: [2]byte{enc[0], enc[1]}, EncryptedPayload: enc[2:]} payload, _ := p.ToBytes() lp := &meshcore.Packet{Header: meshcore.MakeHeader(meshcore.RouteTypeFlood, meshcore.PayloadTypePath, 0), Payload: payload} raw, _ := lp.ToBytes() diff --git a/internal/core/console_resolve_test.go b/internal/core/console_resolve_test.go index ee363d8..212e5a7 100644 --- a/internal/core/console_resolve_test.go +++ b/internal/core/console_resolve_test.go @@ -128,10 +128,10 @@ func TestValidCommandText(t *testing.T) { bad := []string{ "", " ", - "ver\nreboot", // newline (chaining attempt) - "ver\rreboot", // carriage return - "set name a\x00b", // null - "cmd\x7f", // delete + "ver\nreboot", // newline (chaining attempt) + "ver\rreboot", // carriage return + "set name a\x00b", // null + "cmd\x7f", // delete strings.Repeat("a", maxCommandLen+1), // too long } for _, s := range bad { @@ -145,7 +145,7 @@ func TestValidCommandText(t *testing.T) { // a valid operation, and that its feature is listed in featureOrder so it renders // in a known position (a feature missing from featureOrder still shows, but at // the end — this catches the Go list drifting from the DB). Requires the *_test -// database. The DB also enforces feature<>'' and operation IN (...) via CHECK. +// database. The DB also enforces feature<>” and operation IN (...) via CHECK. func TestCommandFeatureCoverage(t *testing.T) { t.Parallel() cat := loadRealCatalog(t) diff --git a/internal/core/org_domains.go b/internal/core/org_domains.go index e292dfe..4b0fb35 100644 --- a/internal/core/org_domains.go +++ b/internal/core/org_domains.go @@ -10,7 +10,7 @@ import ( ) // txtRecordName is the DNS label an org adds under its domain to prove ownership. -const txtRecordPrefix = "_meshtender." +const txtRecordPrefix = "_meshtender." //nolint:unused // wired when the /domains routes are enabled (see web.go) // normalizeHostname lowercases and strips any scheme, path, port, or trailing // dot from user-entered domain input, returning "" if it isn't a plausible host. @@ -27,7 +27,8 @@ func normalizeHostname(raw string) string { return "" } for _, c := range h { - if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '.' || c == '-') { + ok := c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '.' || c == '-' + if !ok { return "" } } @@ -35,7 +36,7 @@ func normalizeHostname(raw string) string { } // handleAddOrgDomain registers a new custom domain for an org (admin only). -func (s *Handlers) handleAddOrgDomain(w http.ResponseWriter, r *http.Request) { +func (s *Handlers) handleAddOrgDomain(w http.ResponseWriter, r *http.Request) { //nolint:unused // wired when the /domains routes are enabled (see web.go) id, ok := s.requireOrgAdmin(w, r) if !ok { return @@ -52,12 +53,12 @@ func (s *Handlers) handleAddOrgDomain(w http.ResponseWriter, r *http.Request) { orgErr(w, r, "Could not add domain.") return } - http.Redirect(w, r, "/orgs/"+orgParam(r), http.StatusSeeOther) + http.Redirect(w, r, "/orgs/"+orgParam(r), http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin } // handleVerifyOrgDomain checks the org's DNS TXT record carries the domain's // verification token, then marks it verified (admin only). -func (s *Handlers) handleVerifyOrgDomain(w http.ResponseWriter, r *http.Request) { +func (s *Handlers) handleVerifyOrgDomain(w http.ResponseWriter, r *http.Request) { //nolint:unused // wired when the /domains routes are enabled (see web.go) id, ok := s.requireOrgAdmin(w, r) if !ok { return @@ -85,7 +86,7 @@ func (s *Handlers) handleVerifyOrgDomain(w http.ResponseWriter, r *http.Request) orgErr(w, r, "Could not save verification.") return } - http.Redirect(w, r, "/orgs/"+orgParam(r), http.StatusSeeOther) + http.Redirect(w, r, "/orgs/"+orgParam(r), http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin } // txtRecordsHaveToken reports whether any TXT record exactly matches the token @@ -100,7 +101,7 @@ func txtRecordsHaveToken(records []string, token string) bool { } // handleDeleteOrgDomain removes a custom domain (admin only). -func (s *Handlers) handleDeleteOrgDomain(w http.ResponseWriter, r *http.Request) { +func (s *Handlers) handleDeleteOrgDomain(w http.ResponseWriter, r *http.Request) { //nolint:unused // wired when the /domains routes are enabled (see web.go) id, ok := s.requireOrgAdmin(w, r) if !ok { return @@ -114,5 +115,5 @@ func (s *Handlers) handleDeleteOrgDomain(w http.ResponseWriter, r *http.Request) orgErr(w, r, "Could not remove domain.") return } - http.Redirect(w, r, "/orgs/"+orgParam(r), http.StatusSeeOther) + http.Redirect(w, r, "/orgs/"+orgParam(r), http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin } diff --git a/internal/core/org_participation.go b/internal/core/org_participation.go index 8d39822..139ca71 100644 --- a/internal/core/org_participation.go +++ b/internal/core/org_participation.go @@ -116,7 +116,7 @@ func (s *Handlers) handleSaveOrgCommands(w http.ResponseWriter, r *http.Request) http.Error(w, "could not save", http.StatusInternalServerError) return } - http.Redirect(w, r, "/orgs/"+orgParam(r)+"/my-commands", http.StatusSeeOther) + http.Redirect(w, r, "/orgs/"+orgParam(r)+"/my-commands", http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin return } ceiling, err := s.orgCeilingCommands(r) @@ -133,7 +133,7 @@ func (s *Handlers) handleSaveOrgCommands(w http.ResponseWriter, r *http.Request) http.Error(w, "could not save", http.StatusInternalServerError) return } - http.Redirect(w, r, "/orgs/"+orgParam(r)+"/my-commands", http.StatusSeeOther) + http.Redirect(w, r, "/orgs/"+orgParam(r)+"/my-commands", http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin } // orgCeilingCommands returns the catalog commands an org is ever permitted to run diff --git a/internal/core/orgs.go b/internal/core/orgs.go index 6181a39..afa0294 100644 --- a/internal/core/orgs.go +++ b/internal/core/orgs.go @@ -62,7 +62,7 @@ func (s *Handlers) handleCreateOrg(w http.ResponseWriter, r *http.Request) { web.RedirectErr(w, r, "/orgs/new", "Could not create organization.") return } - http.Redirect(w, r, "/orgs/"+org.Slug, http.StatusSeeOther) + http.Redirect(w, r, "/orgs/"+org.Slug, http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin } // pageOrg shows an org's home. Members get the full management view; everyone @@ -287,7 +287,7 @@ func (s *Handlers) handleEditOrg(w http.ResponseWriter, r *http.Request) { return } // The slug may have changed; redirect to the new canonical URL. - http.Redirect(w, r, "/orgs/"+slug, http.StatusSeeOther) + http.Redirect(w, r, "/orgs/"+slug, http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin } // handleSetOrgLinks replaces an org's whole set of social/site links from the @@ -344,7 +344,7 @@ func (s *Handlers) handleSetOrgLinks(w http.ResponseWriter, r *http.Request) { orgErr(w, r, "Could not save links.") return } - http.Redirect(w, r, "/orgs/"+orgParam(r), http.StatusSeeOther) + http.Redirect(w, r, "/orgs/"+orgParam(r), http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin } // validLinkURL reports whether s is an absolute http(s) URL with a host. Limiting @@ -413,7 +413,7 @@ func (s *Handlers) pageJoinOrg(w http.ResponseWriter, r *http.Request) { return } if isMember { - http.Redirect(w, r, "/orgs/"+org.Slug, http.StatusSeeOther) + http.Redirect(w, r, "/orgs/"+org.Slug, http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin return } hasRepeaters, err := s.Store.OwnsAnyRepeater(r.Context(), uid) @@ -444,7 +444,7 @@ func (s *Handlers) handleJoinOrg(w http.ResponseWriter, r *http.Request) { return } } - http.Redirect(w, r, "/orgs/"+orgParam(r), http.StatusSeeOther) + http.Redirect(w, r, "/orgs/"+orgParam(r), http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin } // handleSetOrgMember promotes/demotes/removes a member (admin only). @@ -479,5 +479,5 @@ func (s *Handlers) handleSetOrgMember(w http.ResponseWriter, r *http.Request) { memberErr("Could not update member.") return } - http.Redirect(w, r, membersURL, http.StatusSeeOther) + http.Redirect(w, r, membersURL, http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin } diff --git a/internal/core/registry.go b/internal/core/registry.go index 40a9799..ee1aab1 100644 --- a/internal/core/registry.go +++ b/internal/core/registry.go @@ -50,7 +50,7 @@ func (s *Handlers) handleRepeaterDocs(w http.ResponseWriter, r *http.Request) { web.RedirectErr(w, r, docsPath(repeaterParam(r)), "Could not save documentation.") return } - http.Redirect(w, r, docsPath(repeaterParam(r)), http.StatusSeeOther) + http.Redirect(w, r, docsPath(repeaterParam(r)), http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin } // pageRepeaterMaintenance renders the Maintenance tab: the history plus, for @@ -112,7 +112,7 @@ func (s *Handlers) handleAddMaintenance(w http.ResponseWriter, r *http.Request) web.RedirectErr(w, r, maintPath(repeaterParam(r)), "Could not log maintenance entry.") return } - http.Redirect(w, r, maintPath(repeaterParam(r)), http.StatusSeeOther) + http.Redirect(w, r, maintPath(repeaterParam(r)), http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin } // handleDeleteMaintenance removes a maintenance entry (owner only). @@ -130,7 +130,7 @@ func (s *Handlers) handleDeleteMaintenance(w http.ResponseWriter, r *http.Reques web.RedirectErr(w, r, maintPath(repeaterParam(r)), "Could not delete entry.") return } - http.Redirect(w, r, maintPath(repeaterParam(r)), http.StatusSeeOther) + http.Redirect(w, r, maintPath(repeaterParam(r)), http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin } func docsPath(publicID string) string { return "/repeaters/" + publicID + "/docs" } diff --git a/internal/core/repeaters.go b/internal/core/repeaters.go index 6811434..b4c6e5f 100644 --- a/internal/core/repeaters.go +++ b/internal/core/repeaters.go @@ -64,7 +64,7 @@ func (s *Handlers) pageAddRepeater(w http.ResponseWriter, r *http.Request) { orgs := s.setupOrgOptions(r) data["Orgs"] = orgs if b, err := json.Marshal(orgs); err == nil { - data["OrgsJS"] = template.JS(b) + data["OrgsJS"] = template.JS(b) //nolint:gosec // G203: b is json.Marshal output (Go escapes <>& in JS context) } else { data["OrgsJS"] = template.JS("[]") } @@ -124,7 +124,7 @@ func (s *Handlers) pageRepeater(w http.ResponseWriter, r *http.Request) { qr.BackgroundColor = color.Transparent qr.ForegroundColor = color.RGBA{R: 0x8a, G: 0x97, B: 0xa8, A: 0xff} if png, err := qr.PNG(256); err == nil { - data["PublicPageQR"] = template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(png)) + data["PublicPageQR"] = template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(png)) //nolint:gosec // G203: fixed data: URI over base64 PNG, no user input } } } @@ -136,7 +136,7 @@ func (s *Handlers) pageRepeater(w http.ResponseWriter, r *http.Request) { qr.BackgroundColor = color.Transparent qr.ForegroundColor = color.RGBA{R: 0x8a, G: 0x97, B: 0xa8, A: 0xff} if png, err := qr.PNG(256); err == nil { - data["ContactQR"] = template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(png)) + data["ContactQR"] = template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(png)) //nolint:gosec // G203: fixed data: URI over base64 PNG, no user input } } if isOwner { @@ -251,7 +251,7 @@ func (s *Handlers) pageEditRepeater(w http.ResponseWriter, r *http.Request) { s.Render(w, r, "edit_repeater.html", map[string]any{ "Repeater": rep, "Presets": radioPresets, - "SelectedPreset": presetIDFor(config.RadioDefaults{FreqHz: uint32(rep.RadioFreqHz), BwHz: uint32(rep.RadioBwHz), SF: uint8(rep.RadioSF), CR: uint8(rep.RadioCR)}), + "SelectedPreset": presetIDFor(config.RadioDefaults{FreqHz: uint32(rep.RadioFreqHz), BwHz: uint32(rep.RadioBwHz), SF: uint8(rep.RadioSF), CR: uint8(rep.RadioCR)}), //nolint:gosec // G115: radio config value is bounded (preset-constrained) "RevokeCommand": s.Identity.RevokePermCommand(), "Error": r.URL.Query().Get("error"), }) diff --git a/internal/core/shares.go b/internal/core/shares.go index ec3b025..bf98b0e 100644 --- a/internal/core/shares.go +++ b/internal/core/shares.go @@ -62,7 +62,7 @@ func (s *Handlers) handleCreateLink(w http.ResponseWriter, r *http.Request) { shareErr(w, r, "Could not create share link.") return } - http.Redirect(w, r, sharePath(repeaterParam(r)), http.StatusSeeOther) + http.Redirect(w, r, sharePath(repeaterParam(r)), http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin } // handleDeleteInvite revokes (or clears) a single share link by id (owner only). @@ -80,7 +80,7 @@ func (s *Handlers) handleDeleteInvite(w http.ResponseWriter, r *http.Request) { shareErr(w, r, "Could not remove link.") return } - http.Redirect(w, r, sharePath(repeaterParam(r)), http.StatusSeeOther) + http.Redirect(w, r, sharePath(repeaterParam(r)), http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin } // handleSetShareSteward flags or unflags a shared user as a steward (owner only). @@ -102,7 +102,7 @@ func (s *Handlers) handleSetShareSteward(w http.ResponseWriter, r *http.Request) shareErr(w, r, "Could not update steward.") return } - http.Redirect(w, r, sharePath(repeaterParam(r)), http.StatusSeeOther) + http.Redirect(w, r, sharePath(repeaterParam(r)), http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin } // handleUnshare revokes a user's access (owner only). @@ -120,7 +120,7 @@ func (s *Handlers) handleUnshare(w http.ResponseWriter, r *http.Request) { shareErr(w, r, "Could not revoke access.") return } - http.Redirect(w, r, sharePath(repeaterParam(r)), http.StatusSeeOther) + http.Redirect(w, r, sharePath(repeaterParam(r)), http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin } // --- invite accept flow --- @@ -249,7 +249,7 @@ func (s *Handlers) pageShareCommands(w http.ResponseWriter, r *http.Request) { } // A steward already has every command; per-command limits don't apply to them. if steward, err := s.Store.IsSteward(r.Context(), id, targetID); err == nil && steward { - http.Redirect(w, r, sharePath(repeaterParam(r)), http.StatusSeeOther) + http.Redirect(w, r, sharePath(repeaterParam(r)), http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin return } target, err := s.Store.GetUserByID(r.Context(), targetID) @@ -303,7 +303,7 @@ func (s *Handlers) handleSetShareCommands(w http.ResponseWriter, r *http.Request http.Error(w, "could not save commands", http.StatusInternalServerError) return } - http.Redirect(w, r, sharePath(repeaterParam(r)), http.StatusSeeOther) + http.Redirect(w, r, sharePath(repeaterParam(r)), http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin } // --- helpers --- diff --git a/internal/core/web.go b/internal/core/web.go index 9eb4adb..5f5529a 100644 --- a/internal/core/web.go +++ b/internal/core/web.go @@ -343,5 +343,5 @@ func splitOwnedShared(repeaters []*store.Repeater) (owned, shared []*store.Repea // would silently re-authenticate on the next request. func (s *Handlers) handleLogout(w http.ResponseWriter, r *http.Request) { _ = s.Auth.Logout(r.Context()) - http.Redirect(w, r, s.Origin(r, s.Cfg.AuthHost)+"/logout", http.StatusSeeOther) + http.Redirect(w, r, s.Origin(r, s.Cfg.AuthHost)+"/logout", http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin } diff --git a/internal/marketing/marketing.go b/internal/marketing/marketing.go index 84f128a..4ffed45 100644 --- a/internal/marketing/marketing.go +++ b/internal/marketing/marketing.go @@ -98,7 +98,7 @@ func (s *Handlers) CustomDomain(next http.Handler) http.Handler { s.renderOrgPublic(w, r, org, false, false) return } - http.Redirect(w, r, s.Origin(r, s.Cfg.PrimaryHost)+r.URL.RequestURI(), http.StatusFound) + http.Redirect(w, r, s.Origin(r, s.Cfg.PrimaryHost)+r.URL.RequestURI(), http.StatusFound) //nolint:gosec // G710: local path or config-pinned origin }) } diff --git a/internal/mesh/command_test.go b/internal/mesh/command_test.go index 4dd529a..5d812d1 100644 --- a/internal/mesh/command_test.go +++ b/internal/mesh/command_test.go @@ -30,8 +30,8 @@ func TestBuildCommandPacketDecodableByRepeater(t *testing.T) { if err != nil { t.Fatalf("TextMessageFromBytes: %v", err) } - if tm.Destination != repeater.Identity.Hash()[0] { - t.Errorf("dest = 0x%02x, want 0x%02x", tm.Destination, repeater.Identity.Hash()[0]) + if tm.Destination != repeater.Hash()[0] { + t.Errorf("dest = 0x%02x, want 0x%02x", tm.Destination, repeater.Hash()[0]) } shared, err := repeater.SharedSecret(server.Identity) if err != nil { diff --git a/internal/mesh/exchange_test.go b/internal/mesh/exchange_test.go index cdb5190..4024a25 100644 --- a/internal/mesh/exchange_test.go +++ b/internal/mesh/exchange_test.go @@ -118,7 +118,7 @@ func TestExchangerUsesDirectAfterLogin(t *testing.T) { body = append(body, meshcore.PayloadTypeResponse) body = append(body, resp...) enc, _ := meshcore.EncryptThenMAC(shared, body) - p := &meshcore.Path{Destination: server.Identity.Hash()[0], Source: repeater.Identity.Hash()[0], MAC: [2]byte{enc[0], enc[1]}, EncryptedPayload: enc[2:]} + p := &meshcore.Path{Destination: server.Hash()[0], Source: repeater.Hash()[0], MAC: [2]byte{enc[0], enc[1]}, EncryptedPayload: enc[2:]} payload, _ := p.ToBytes() lp := &meshcore.Packet{Header: meshcore.MakeHeader(meshcore.RouteTypeFlood, meshcore.PayloadTypePath, 0), Payload: payload} r, _ := lp.ToBytes() diff --git a/internal/mesh/mesh.go b/internal/mesh/mesh.go index 7b4a4cc..b2c8361 100644 --- a/internal/mesh/mesh.go +++ b/internal/mesh/mesh.go @@ -60,7 +60,7 @@ func BuildLoginPacket(server meshcore.LocalIdentity, repeater meshcore.Identity, // Repeater login request plaintext: timestamp(4, little-endian) + password. plaintext := make([]byte, 4+len(password)) - binary.LittleEndian.PutUint32(plaintext[:4], uint32(now.Unix())) + binary.LittleEndian.PutUint32(plaintext[:4], uint32(now.Unix())) //nolint:gosec // G115: unix-seconds into the 4-byte protocol timestamp field copy(plaintext[4:], password) enc, err := meshcore.EncryptThenMAC(shared, plaintext) // MAC(2) || ciphertext @@ -160,7 +160,7 @@ func decodeAddressedReply(server meshcore.LocalIdentity, repeater meshcore.Ident ciphertext := p[4:] // Destination of the reply is the hash of our (server) public key. - if dest != server.Identity.Hash()[0] { + if dest != server.Hash()[0] { return nil, payloadType, ErrNotForUs } diff --git a/internal/mesh/mesh_test.go b/internal/mesh/mesh_test.go index bba8cbf..28d1276 100644 --- a/internal/mesh/mesh_test.go +++ b/internal/mesh/mesh_test.go @@ -46,7 +46,7 @@ func buildRepeaterResponse(t *testing.T, repeater meshcore.LocalIdentity, server } resp := &meshcore.Response{ Destination: server.Hash()[0], - Source: repeater.Identity.Hash()[0], + Source: repeater.Hash()[0], MAC: [2]byte{enc[0], enc[1]}, EncryptedPayload: enc[2:], } @@ -94,8 +94,8 @@ func TestLoginRequestDecodableByRepeater(t *testing.T) { if err != nil { t.Fatalf("AnonReqFromBytes: %v", err) } - if anon.Destination != repeater.Identity.Hash()[0] { - t.Errorf("destination = 0x%02x, want 0x%02x", anon.Destination, repeater.Identity.Hash()[0]) + if anon.Destination != repeater.Hash()[0] { + t.Errorf("destination = 0x%02x, want 0x%02x", anon.Destination, repeater.Hash()[0]) } // The repeater derives the shared secret from the sender pubkey in the packet. sender, err := meshcore.NewIdentityFromBytes(anon.EphemeralPubKey[:]) @@ -150,7 +150,7 @@ func buildRepeaterPathReply(t *testing.T, repeater meshcore.LocalIdentity, serve } p := &meshcore.Path{ Destination: server.Hash()[0], - Source: repeater.Identity.Hash()[0], + Source: repeater.Hash()[0], MAC: [2]byte{enc[0], enc[1]}, EncryptedPayload: enc[2:], } diff --git a/internal/store/config_profiles_test.go b/internal/store/config_profiles_test.go index 3e485a6..f545d5f 100644 --- a/internal/store/config_profiles_test.go +++ b/internal/store/config_profiles_test.go @@ -94,7 +94,7 @@ func TestRegionDefCommands(t *testing.T) { regions := []Region{ mk("us", 1, geo.Rectangle(0, 0, 100, 100)), mk("ny", 2, geo.Rectangle(10, 10, 30, 30)), - mk("pa", 2, geo.Rectangle(10, 25, 30, 45)), // overlaps ny in lon 25–30 + mk("pa", 2, geo.Rectangle(10, 25, 30, 45)), // overlaps ny in lon 25–30 mk("buf", 3, geo.Rectangle(12, 12, 18, 18)), // inside ny, west of pa } diff --git a/internal/store/helpers.go b/internal/store/helpers.go index 008e73b..a1ade7e 100644 --- a/internal/store/helpers.go +++ b/internal/store/helpers.go @@ -51,7 +51,7 @@ func (s *Store) inTx(ctx context.Context, fn func(pgx.Tx) error) error { if err != nil { return fmt.Errorf("begin: %w", err) } - defer tx.Rollback(ctx) + defer func() { _ = tx.Rollback(ctx) }() if err := fn(tx); err != nil { return err } diff --git a/internal/store/store.go b/internal/store/store.go index 992f252..c98fabd 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -47,7 +47,7 @@ func (s *Store) Migrate(ctx context.Context) error { } // goose needs a database/sql handle; derive one from the pool config. db := stdlib.OpenDBFromPool(s.pool) - defer db.Close() + defer func() { _ = db.Close() }() if err := goose.UpContext(ctx, db, "migrations"); err != nil { return fmt.Errorf("goose up: %w", err) } diff --git a/internal/testdb/testdb.go b/internal/testdb/testdb.go index c02bb0f..6767f22 100644 --- a/internal/testdb/testdb.go +++ b/internal/testdb/testdb.go @@ -96,7 +96,7 @@ func ensureTemplate(ctx context.Context, migrate func(dsn string) error) error { templateErr = fmt.Errorf("admin connect: %w", err) return } - defer conn.Close(ctx) + defer func() { _ = conn.Close(ctx) }() // Hold a cross-process advisory lock only around the template DDL: this is // the one CREATE DATABASE that copies the shared template1, so concurrent // package binaries on one server must take turns. Released before migrate, @@ -153,7 +153,7 @@ func Fresh(t *testing.T, migrate func(dsn string) error) string { conn, err := pgx.Connect(ctx, adminDSN) if err == nil { _, err = conn.Exec(ctx, `CREATE DATABASE `+quoteIdent(name)+` TEMPLATE `+quoteIdent(templateName)) - conn.Close(ctx) + _ = conn.Close(ctx) } createMu.Unlock() if err != nil { @@ -165,7 +165,7 @@ func Fresh(t *testing.T, migrate func(dsn string) error) string { if err != nil { return } - defer c.Close(ctx) + defer func() { _ = c.Close(ctx) }() _, _ = c.Exec(ctx, `DROP DATABASE IF EXISTS `+quoteIdent(name)+` WITH (FORCE)`) }) @@ -187,7 +187,7 @@ func RunMain(m *testing.M) int { if templateName != "" && adminDSN != "" { if conn, err := pgx.Connect(ctx, adminDSN); err == nil { _, _ = conn.Exec(ctx, `DROP DATABASE IF EXISTS `+quoteIdent(templateName)+` WITH (FORCE)`) - conn.Close(ctx) + _ = conn.Close(ctx) } } if container != nil { diff --git a/internal/web/env.go b/internal/web/env.go index bb3473d..0fe59f9 100644 --- a/internal/web/env.go +++ b/internal/web/env.go @@ -270,7 +270,7 @@ func Dispatcher(cfg *config.Config, authH, rootH, appH http.Handler) http.Handle case rootH != nil && strings.EqualFold(host, cfg.RootHost): rootH.ServeHTTP(w, r) case cfg.WWWHost != "" && strings.EqualFold(host, cfg.WWWHost): - http.Redirect(w, r, originFor(cfg, r, cfg.RootHost)+r.URL.RequestURI(), http.StatusMovedPermanently) + http.Redirect(w, r, originFor(cfg, r, cfg.RootHost)+r.URL.RequestURI(), http.StatusMovedPermanently) //nolint:gosec // G710: local path or config-pinned origin default: appH.ServeHTTP(w, r) } diff --git a/internal/web/markdown.go b/internal/web/markdown.go index 50c10af..c9bad73 100644 --- a/internal/web/markdown.go +++ b/internal/web/markdown.go @@ -27,5 +27,5 @@ func Markdown(src string) template.HTML { if err := mdRenderer.Convert([]byte(src), &buf); err != nil { return "" } - return template.HTML(mdPolicy.SanitizeBytes(buf.Bytes())) + return template.HTML(mdPolicy.SanitizeBytes(buf.Bytes())) //nolint:gosec // G203: output is bluemonday-sanitized by mdPolicy }