Skip to content

TCP proxy IP allowlist, plus security, reliability and accessibility fixes - #27

Open
turin-dev wants to merge 25 commits into
mainfrom
claude/brave-babbage-oez2ow
Open

turin-dev wants to merge 25 commits into
mainfrom
claude/brave-babbage-oez2ow

Conversation

@turin-dev

@turin-dev turin-dev commented Nov 5, 2026 •

Copy link
Copy Markdown
Owner

What and why

This PR adds a client IP allowlist for TCP proxies, caps login sessions at an absolute lifetime, and adds stricter CI checks. It also carries an audit pass over the backend, the dashboard, the installers and the docs. Each commit stands on its own.

New: TCP proxy client allowlist

TCP proxy ports are published Docker ports. Docker's publish rules bypass ufw, so a database behind a TCP proxy was reachable from the whole internet. The docs listed AVEN_TCP_PORT_ALLOW_CIDRS as designed but not implemented.

  • Per-proxy list: each TCP proxy can carry allow_cidrs, a list of IPv4 addresses or CIDRs (at most 32). The server normalizes the list.
  • Server default: AVEN_TCP_PORT_ALLOW_CIDRS applies to proxies that have no list of their own. A list containing 0.0.0.0/0 opts a proxy out of the default.
  • Enforcement: the socat forwarder checks each connection's SOCAT_PEERADDR against the list before it connects to the service.
    • The script gets its data from environment variables, never by string interpolation, and the agent validates the list again.
    • Without a list, the forwarder command and its signature label are byte-for-byte what they were before, so existing proxies are not recreated on upgrade.
  • Remote nodes: an agent built before this field refuses allow_cidrs. The API then answers 409, and the proxy is never reopened without the list.
  • Copies keep the list: duplicating an environment and moving a proxy between HA root and edge carry the list.
  • API and dashboard: POST/PATCH/GET …/tcp-proxy handle the list. The TCP proxy row shows who may connect and has an editor that validates as you type. Changes emit an event and an audit entry.
  • Verified with real containers (alpine/socat:1.8.0.3 in Docker):
    • Listed /32 and /24 clients connect, and a 270 KB round trip arrives intact.
    • Clients outside the list are closed and logged.
    • This run caught a socat 1.8 quoting bug (double quotes are stripped inside an address unless it is single-quoted), fixed in the commit.
  • Limitation: IPv4 only. The forwarder cannot see IPv6 client addresses, so IPv6 entries are refused with 400.

New: absolute session lifetime

  • Sessions slid by 30 days on every use, so a session that kept being used, including a stolen cookie, never expired.
  • Sessions now also end 90 days after login. The stored expiry and the cookie are both capped, and sessions created before this change are cut off at the cap.
  • AVEN_SESSION_MAX_AGE takes 2160h, 30d or off. An invalid value fails at startup.

Security (Windows host)

  • The plain-HTTP dashboard was bound to 0.0.0.0:7070, and the installer opened it to the local subnet. It now listens on 127.0.0.1:7070, and the installer removes the old firewall rule on upgrade.
  • The proxy route on 0.0.0.0:7071 served requests without the gateway secret. It now refuses them.
  • The wake secret header was forwarded to user apps. It is now stripped once it has been checked.

Bugs

  • imageupdate: a shadowed err left resp nil and panicked aven-server. A regression test is included.
  • webauthn: a shadowed err meant BeginLogin failures were never checked.
  • deploy: data race in RestorePITR, found by -race.
  • TestOAuthClientAuthAndPKCE failed about 1 run in 4096.
  • Four tests computed a result and never checked it. They now assert it.

Resilience

  • A panic in any one of the 18 background loops killed aven-server. Now only that loop restarts, with a backoff from 30s doubling up to 5 minutes, and its stack is logged.
  • A panic in one aven-agent operation killed the agent. It now comes back as that request's error.
  • The wake handlers used ReverseProxy.Director, deprecated in Go 1.26. They now share a Rewrite-based proxy.WakeReverseProxy. A test sends the same request through the old and the new proxy and checks that the app receives identical headers.

Dashboard accessibility

  • axe now reports no violations on any route in the dark theme, and none on mobile; the only remaining violations are faint (gray-400) text in the light theme.
  • Fixes: log table ARIA, node meters, canvas badges, light-theme kbd and timestamps, and the dark-theme gray-400 faint-text token (3.4:1 → 4.5:1).

CI, cleanup and docs

  • CI: added gofmt, staticcheck and a separate go-race job (-timeout 30m; internal/api takes about 10 minutes under -race). Wired up the web unit tests that never ran (74).
  • Cleanup: staticcheck is clean on every GOOS, with staticcheck.conf dropping ST1005 and S1016. Removed dead code.
  • Docs: API reference, aven.env.example and the READMEs are updated, and the SDK link is fixed.

How

  • Allowlist: internal/node/tcpforward.go, internal/deploy/tcpproxy.go, internal/agent/extra.go and TcpProxyRow.tsx.
  • Sessions: sessionExpiry and sessionMaxAge in internal/api/auth.go.
  • Resilience: cmd/aven-server/loops.go, Handler.handleRecovering and internal/proxy/wakeproxy.go.
  • Reviewers should check one assumption: binding 7070 to loopback relies on Docker Desktop forwarding host.docker.internal to the host's loopback. I could not test this on a Windows host.

Checklist

  • go test ./..., go test -race ./... (all 45 packages), staticcheck, GOOS=windows build and vet, cd web && npm run typecheck && npm run i18n:check && npm test && npm run build
  • Tests for behavior changes; docs updated
  • Breaking API, database migration, deployment or configuration changes are called out:
    • No migration.
    • Login sessions now end 90 days after login (AVEN_SESSION_MAX_AGE=off restores the old behavior).
    • Remote agents need upgrading before a proxy on them can use a list.
    • On Windows, existing installs keep AVEN_LISTEN=0.0.0.0:7070 until it is edited.
  • No real credentials, private hostnames or user data

🤖 Generated with Claude Code

https://claude.ai/code/session_01CJyoeEBvvbpiXsP7oR67cb

claude added 18 commits November 5, 2026 04:10
…oxy-route requests

The native Windows defaults bound the plain-HTTP dashboard to 0.0.0.0:7070 and
the installer opened it to the local subnet, so session cookies and the admin
login travelled in clear text across the LAN. The proxy-to-server route on
0.0.0.0:7071 also served any request without the gateway secret as an
ordinary client, which made it a second plain-HTTP copy of the dashboard.

- default listen and the installer now use 127.0.0.1:7070; Docker Desktop
  still forwards host.docker.internal to the host's loopback
- the installer removes the old 7070 firewall rule on upgrade
- the 7071 listener refuses requests without the gateway secret header
- the wake secret check is shared as requireHeaderSecret, with a test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CJyoeEBvvbpiXsP7oR67cb
The 'wrong secret' case replaced the last two characters of a random client
secret with "AA", which is the real secret whenever it already ends in AA.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CJyoeEBvvbpiXsP7oR67cb
62 tests under web/lib were never run by CI. Add an npm test script that runs
them and call it in the web job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CJyoeEBvvbpiXsP7oR67cb
…y retry fails

In the bearer branch, 'access, err :=' declared a new err, so the error from
the retried request was assigned to that inner variable. The check after the
switch saw the outer nil err and read resp.StatusCode on a nil response,
which panics the image-update loop and with it aven-server.

Adds a regression test that drops the connection on the retry; it panicked
before this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CJyoeEBvvbpiXsP7oR67cb
'wu, err :=' inside the password branch shadowed err, so a BeginLogin failure
was never seen: the handler stored a challenge with null session data and
returned options: null. Also drop a dead assignment to acct.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CJyoeEBvvbpiXsP7oR67cb
The wake handler proxies the visitor's request to the app after waking it,
and on Windows that request still carried X-Aven-Wake-Secret from Caddy.
Strip the header once it has been checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CJyoeEBvvbpiXsP7oR67cb
Ran axe-core (WCAG 2 A/AA) over the dashboard in mock mode and fixed:

- logs: row/cell roles had no table ancestor (critical). The header and the
  scroller are now wrapped in role=table, and the scroller is the rowgroup.
- nodes: CPU/memory bars used role=meter without aria-valuenow when there is
  no reading (critical). With no reading the bar is now aria-hidden.
- canvas: staged-change badges (new/edited/removed) drew the page background
  colour on the badge, 2.15:1. They now use the foreground colour.
- nodes 'this server' badge, the selected dashboard tab, and the log header
  timezone/line count used colours below 4.5:1; moved one step up the scale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CJyoeEBvvbpiXsP7oR67cb
The link pointed at sdk/docs/api-v1.md, which does not exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CJyoeEBvvbpiXsP7oR67cb
AVEN_DEBUG, AVEN_TCP_PORT_MIN/MAX, AVEN_PROXY_TRUSTED_PROXIES, AVEN_SELF_ROUTE
and AVEN_DOCKER_BIN are read by aven-server but were not listed in the
example env file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CJyoeEBvvbpiXsP7oR67cb
RestorePITR copied the restore status for its response after starting the
goroutine that updates that status under pitrJobs, so the copy raced with the
first step update (seen by go test -race in TestPITRAPI). Copy it before the
goroutine starts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CJyoeEBvvbpiXsP7oR67cb
shortVal, remoteDriver.tcpRemove, edge.Rule.label, proxy.originHeaders and
pathRule.routes are unused on every GOOS. originHeaders' work is done inline
in hostCfg.handlers, and remote TCP proxies are removed through the generic
driver Remove.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CJyoeEBvvbpiXsP7oR67cb
An axe pass over the light theme found:

- the shortcut hints (kbd) in the header on every page: light gray-500 was
  4.3:1 on gray-100. Darken it slightly to #6e6c79 (4.6:1 on gray-100,
  5.1:1 on white); it stays lighter than gray-600.
- timestamps on the observability page, project cards, notification
  deliveries and the inbox used the decorative gray-400 (2.6:1 in light).
  They carry information, so use gray-500 like other secondary text.

Mobile (iPhone 13) showed no violations and no horizontal overflow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CJyoeEBvvbpiXsP7oR67cb
…r down

No goroutine recovered from panics, so a bug in any of the 18 background
loops (the image-update panic fixed earlier in this branch was one) killed
the whole process: API, dashboard, proxy sync and deploys. When the panic
repeats on every tick, systemd restarts into the same crash.

goLoop runs each loop, logs a panic with its stack, and restarts that loop
after 30s, doubling to at most 5 minutes. A loop that returns normally ends
as before, and shutdown still waits for every loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CJyoeEBvvbpiXsP7oR67cb
A panic while handling one server request stopped aven-agent, and with it
every other request, the wake handler and the proxy on that node. Recover
per operation, log the stack, and answer the request with an error, the way
net/http isolates handlers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CJyoeEBvvbpiXsP7oR67cb
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CJyoeEBvvbpiXsP7oR67cb
TCP proxy ports are published Docker ports, which bypass ufw, so a database
behind one was reachable from the whole internet. The docs listed
AVEN_TCP_PORT_ALLOW_CIDRS as designed but not implemented.

- Each TCP proxy can carry allow_cidrs (IPv4 addresses/CIDRs, normalized,
  at most 32). AVEN_TCP_PORT_ALLOW_CIDRS is the default for proxies without
  their own list, and ["0.0.0.0/0"] opts a proxy out of the default.
- Enforcement is in the socat forwarder: with a list, socat runs a small
  check per connection (SOCAT_PEERADDR against the list) and only then
  connects to the service. Data reaches the check through the environment,
  not interpolation, and the agent re-validates the list. Without a list the
  forwarder command and its signature label are byte-for-byte unchanged, so
  existing proxies are not recreated on upgrade.
- Remote nodes get the list in tcpproxy.ensure. An agent older than the
  field refuses it; that becomes ErrTCPAllowUnsupported (409 from the API)
  and is never retried as an open proxy.
- POST/PATCH .../tcp-proxy accept allow_cidrs (PATCH may now send it
  without container_port); GET shows allow_cidrs, effective_allow_cidrs and
  default_allow_cidrs. Changes emit tcp_proxy.allowlist_updated and an
  audit entry. Environment duplication and HA edge moves carry the list so
  a copy never silently opens up.

Verified against alpine/socat:1.8.0.3 in Docker: listed /32 and /24 clients
connect (270 KB round trip intact), others are closed and logged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CJyoeEBvvbpiXsP7oR67cb
The TCP proxy row now says who may connect: the open-to-everyone warning
(with a pointer to Edit) or the ranges the proxy enforces, marked when they
come from the server default. The edit panel gains an 'Allowed client IPs'
box (one IPv4 address or CIDR per line) validated as you type, and saves
only the fields that changed. Older servers that do not report allow_cidrs
keep the previous form.

Also runs components/panel/networking/validate.test.mjs in npm test; it
was not run anywhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CJyoeEBvvbpiXsP7oR67cb
API reference (allow_cidrs, effective/default lists, normalization, IPv4
only, old-agent 409, events), AVEN_TCP_PORT_ALLOW_CIDRS in aven.env.example,
and a mention in both READMEs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CJyoeEBvvbpiXsP7oR67cb
@turin-dev turin-dev changed the title Security, reliability and accessibility fixes across server, dashboard and docs TCP proxy IP allowlist, plus security, reliability and accessibility fixes Nov 5, 2026
staticcheck SA4006 found tests that assigned a result and never looked at
it: the volume reread in alerts, the volumeInstanceUpdate and
serviceInstanceRedeploy GraphQL results, and the status code of the
overlapping IPv6 pool request. Assert them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CJyoeEBvvbpiXsP7oR67cb
Director is deprecated since Go 1.26: headers a Director sets can be removed
again by a client's Connection header. The server and agent wake handlers
now share proxy.WakeReverseProxy, built on Rewrite. It forwards Caddy's
X-Forwarded-For/-Host/-Proto and appends the hop address to X-Forwarded-For
exactly as before (SetXForwarded would overwrite X-Forwarded-Proto with the
internal hop's scheme). A test sends the same request through the old
Director proxy and the new one and compares what the app receives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CJyoeEBvvbpiXsP7oR67cb
Sessions slid by 30 days on every use, so a session that kept being used,
including a stolen cookie, never expired. Sessions now also end 90 days
after login. Every refresh caps both the stored expiry and the cookie at
created_at + max age, and a session that is past the cap, or was created
before this change with a later expiry, is rejected and deleted.

AVEN_SESSION_MAX_AGE sets the lifetime (2160h, 30d) or turns the cap off;
an invalid value fails at startup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CJyoeEBvvbpiXsP7oR67cb
gray-400 is used for faint text in about 130 places and was 3.4:1 on the
dark page background. At 53% lightness it is 4.5:1 or more on the page and
panel backgrounds and still dimmer than gray-500 (57%). An axe pass over
every route in the dark theme now reports no violations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CJyoeEBvvbpiXsP7oR67cb
Raw-string regexp, two append(...) simplifications, unused struct fields
removed, and an intentional NewTag() == NewTag() comparison annotated.
staticcheck.conf drops ST1005 (error strings here start with proper nouns
such as Docker and Aven API) and S1016.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CJyoeEBvvbpiXsP7oR67cb
- gofmt and staticcheck in the go job. The tree is clean for both, and
  staticcheck is what found the two shadowed-err bugs fixed on this branch.
- A separate go-race job (go test -race -timeout 30m). -race found the PITR
  restore data race that plain go test missed. internal/api takes about ten
  minutes under -race, past the default test timeout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CJyoeEBvvbpiXsP7oR67cb
The new gofmt CI step flagged the constant block changed for the session
lifetime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CJyoeEBvvbpiXsP7oR67cb
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants