From ee91c98bd4c979958fbcb304a7f48158d5295db7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 04:10:16 +0000 Subject: [PATCH 01/25] windows: keep the dashboard on loopback and refuse unauthenticated proxy-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 Claude-Session: https://claude.ai/code/session_01CJyoeEBvvbpiXsP7oR67cb --- cmd/aven-server/main.go | 19 ++++++++++++++----- cmd/aven-server/main_test.go | 28 ++++++++++++++++++++++++++++ docs/windows-native.ko.md | 2 +- docs/windows-native.md | 2 +- install-server.ps1 | 6 ++++-- 5 files changed, 48 insertions(+), 9 deletions(-) diff --git a/cmd/aven-server/main.go b/cmd/aven-server/main.go index a33c94c..166369c 100644 --- a/cmd/aven-server/main.go +++ b/cmd/aven-server/main.go @@ -74,9 +74,11 @@ func main() { os.Exit(1) } defer closeLog() + // The dashboard listener speaks plain HTTP, so it stays on loopback by default on every platform. + // On Windows, Docker Desktop forwards host.docker.internal to the host's loopback, so containers still reach it. defaultListen := "[::1]:7070" if runtime.GOOS == "windows" { - defaultListen = "0.0.0.0:7070" + defaultListen = "127.0.0.1:7070" } listen := flag.String("listen", env("LISTEN", defaultListen), "API/UI listen address") dataDir := flag.String("data", env("DATA", compat.DefaultDataDir()), "data directory (AVEN_DATA)") @@ -401,7 +403,7 @@ func runWithContext(sigCtx context.Context, stopSignals context.CancelFunc, log } wakeHandler := mgr.WakeHandler() if wakeSecret != "" { - wakeHandler = requireWakeSecret(wakeSecret, wakeHandler) + wakeHandler = requireHeaderSecret(wakeSecretHeader, wakeSecret, wakeHandler) } ws := &http.Server{Handler: wakeHandler, ReadHeaderTimeout: 10 * time.Second, BaseContext: baseCtx} servers = append(servers, ws) @@ -563,7 +565,10 @@ func runWithContext(sigCtx context.Context, stopSignals context.CancelFunc, log if err != nil { return fmt.Errorf("self route listener: %w", err) } - selfServer := &http.Server{Handler: api.ViaGateway(selfSecret, handler), ReadHeaderTimeout: 10 * time.Second, BaseContext: baseCtx} + // Unlike the Linux unix socket, this TCP port can be reached from the network, so a request + // without the proxy's secret header is refused instead of being served as an untrusted client. + selfHandler := requireHeaderSecret(api.GatewayHeader, selfSecret, api.ViaGateway(selfSecret, handler)) + selfServer := &http.Server{Handler: selfHandler, ReadHeaderTimeout: 10 * time.Second, BaseContext: baseCtx} servers = append(servers, selfServer) go selfServer.Serve(selfListener) log.Info("aven-server listening for the proxy (own public host)", "host", selfHost, "addr", proxy.NativeSelfListen) @@ -805,10 +810,14 @@ func listenWake(ctx context.Context, docker *node.Docker, caddy *proxy.Caddy) (n return l, nil } -func requireWakeSecret(secret string, next http.Handler) http.Handler { +// wakeSecretHeader carries the wake handler secret from Caddy when the wake handler listens on TCP (Windows). +const wakeSecretHeader = "X-Aven-Wake-Secret" + +// requireHeaderSecret refuses requests whose header does not carry secret. An empty secret refuses everything. +func requireHeaderSecret(header, secret string, next http.Handler) http.Handler { want := []byte(secret) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if subtle.ConstantTimeCompare([]byte(r.Header.Get("X-Aven-Wake-Secret")), want) != 1 { + if secret == "" || subtle.ConstantTimeCompare([]byte(r.Header.Get(header)), want) != 1 { http.Error(w, "forbidden", http.StatusForbidden) return } diff --git a/cmd/aven-server/main_test.go b/cmd/aven-server/main_test.go index 3b78f26..6ec12b7 100644 --- a/cmd/aven-server/main_test.go +++ b/cmd/aven-server/main_test.go @@ -7,6 +7,7 @@ import ( "log/slog" "net" "net/http" + "net/http/httptest" "strings" "sync" "testing" @@ -125,3 +126,30 @@ func TestWaitTimeout(t *testing.T) { t.Error("finished group") } } + +func TestRequireHeaderSecret(t *testing.T) { + ok := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) }) + cases := []struct { + name, secret, sent string + want int + }{ + {"matching secret", "s3cret", "s3cret", http.StatusNoContent}, + {"missing header", "s3cret", "", http.StatusForbidden}, + {"wrong secret", "s3cret", "s3cre", http.StatusForbidden}, + {"empty secret refuses all", "", "", http.StatusForbidden}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + h := requireHeaderSecret("X-Test-Secret", c.secret, ok) + r := httptest.NewRequest(http.MethodGet, "/", nil) + if c.sent != "" { + r.Header.Set("X-Test-Secret", c.sent) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != c.want { + t.Fatalf("status = %d, want %d", w.Code, c.want) + } + }) + } +} diff --git a/docs/windows-native.ko.md b/docs/windows-native.ko.md index 9da859f..e0b553b 100644 --- a/docs/windows-native.ko.md +++ b/docs/windows-native.ko.md @@ -26,7 +26,7 @@ Restart-Service AvenServer Get-Content "$env:ProgramData\Aven\logs\aven-server.log" -Wait ``` -방화벽은 TCP 80·443과 QUIC용 UDP 443을 허용하고 API, proxy 전용 서버 경로, trace 수신기, 앱 깨우기 포트(7070, 7071, 4318, 5381)는 로컬 서브넷으로 제한합니다. TCP proxy를 켜면 설정한 공개 포트 범위(기본 20000–29999)를 Windows와 네트워크 방화벽에서도 허용해야 합니다. 대시보드와 와일드카드 서비스 도메인의 DNS는 [셀프 호스팅 가이드](self-hosting.ko.md)를 참고하세요. +방화벽은 TCP 80·443과 QUIC용 UDP 443을 허용하고 proxy 전용 서버 경로, trace 수신기, 앱 깨우기 포트(7071, 4318, 5381)는 로컬 서브넷으로 제한합니다. 이 세 포트는 각각 비밀 헤더나 토큰이 없는 요청을 거부합니다. 평문 HTTP인 대시보드(7070)는 루프백(`127.0.0.1`)에서만 받습니다. 예전 설치의 `aven.env`에 `AVEN_LISTEN=0.0.0.0:7070`이 남아 있으면 `127.0.0.1:7070`으로 바꾸고 서비스를 다시 시작하세요. TCP proxy를 켜면 설정한 공개 포트 범위(기본 20000–29999)를 Windows와 네트워크 방화벽에서도 허용해야 합니다. 대시보드와 와일드카드 서비스 도메인의 DNS는 [셀프 호스팅 가이드](self-hosting.ko.md)를 참고하세요. ## Windows 호스트 제한 diff --git a/docs/windows-native.md b/docs/windows-native.md index af88d88..3d4d624 100644 --- a/docs/windows-native.md +++ b/docs/windows-native.md @@ -26,7 +26,7 @@ Restart-Service AvenServer Get-Content "$env:ProgramData\Aven\logs\aven-server.log" -Wait ``` -The installer allows inbound TCP 80 and 443 plus UDP 443 for QUIC, and limits the API, proxy-to-server route, trace receiver, and app-wake ports (7070, 7071, 4318, 5381) to local subnets. If you enable TCP proxying, also allow the configured public port range (20,000–29,999 by default) in Windows Firewall and your network firewall. Configure public DNS for the dashboard and wildcard service domain as described in the [self-hosting guide](self-hosting.md). +The installer allows inbound TCP 80 and 443 plus UDP 443 for QUIC, and limits the proxy-to-server route, trace receiver, and app-wake ports (7071, 4318, 5381) to local subnets; each of them refuses requests without its secret header or token. The plain-HTTP dashboard port (7070) listens on loopback (`127.0.0.1`) only. If an older install's `aven.env` still has `AVEN_LISTEN=0.0.0.0:7070`, change it to `127.0.0.1:7070` and restart the service. If you enable TCP proxying, also allow the configured public port range (20,000–29,999 by default) in Windows Firewall and your network firewall. Configure public DNS for the dashboard and wildcard service domain as described in the [self-hosting guide](self-hosting.md). ## Windows-specific limits diff --git a/install-server.ps1 b/install-server.ps1 index 8790cee..19b3ec1 100644 --- a/install-server.ps1 +++ b/install-server.ps1 @@ -216,7 +216,7 @@ try { "AVEN_RAILPACK_BIN=$(Join-Path $programRoot 'railpack.exe')", "AVEN_AGENT_BIN_DIR=$programRoot", 'DOCKER_HOST=npipe:////./pipe/docker_engine', - 'AVEN_LISTEN=0.0.0.0:7070', + 'AVEN_LISTEN=127.0.0.1:7070', 'AVEN_SELF_PROJECT=off', "AVEN_ADMIN_PASSWORD=$AdminPassword" ) @@ -247,11 +247,13 @@ try { @{ Name = 'Aven HTTP'; Protocol = 'TCP'; Port = '80'; Remote = 'Any' }, @{ Name = 'Aven HTTPS'; Protocol = 'TCP'; Port = '443'; Remote = 'Any' }, @{ Name = 'Aven HTTPS QUIC'; Protocol = 'UDP'; Port = '443'; Remote = 'Any' }, - @{ Name = 'Aven API from local containers'; Protocol = 'TCP'; Port = '7070'; Remote = 'LocalSubnet' }, @{ Name = 'Aven public route from local proxy'; Protocol = 'TCP'; Port = '7071'; Remote = 'LocalSubnet' }, @{ Name = 'Aven traces from local containers'; Protocol = 'TCP'; Port = '4318'; Remote = 'LocalSubnet' }, @{ Name = 'Aven wake from local proxy'; Protocol = 'TCP'; Port = '5381'; Remote = 'LocalSubnet' } ) + # Older installers opened the plain-HTTP dashboard port (7070) to the local subnet. The dashboard now + # listens on loopback only, so drop that rule when upgrading. + Get-NetFirewallRule -DisplayName 'Aven API from local containers' -ErrorAction SilentlyContinue | Remove-NetFirewallRule foreach ($rule in $firewallRules) { Get-NetFirewallRule -DisplayName $rule.Name -ErrorAction SilentlyContinue | Remove-NetFirewallRule New-NetFirewallRule -DisplayName $rule.Name -Direction Inbound -Action Allow -Protocol $rule.Protocol -LocalPort $rule.Port -RemoteAddress $rule.Remote -Profile Any | Out-Null From edabbff95e77b2dbca1351de3a7cce9092016832 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 04:10:16 +0000 Subject: [PATCH 02/25] api: fix a 1-in-4096 flake in TestOAuthClientAuthAndPKCE 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 Claude-Session: https://claude.ai/code/session_01CJyoeEBvvbpiXsP7oR67cb --- internal/api/oauth_test.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/api/oauth_test.go b/internal/api/oauth_test.go index 848ed44..52dcfa8 100644 --- a/internal/api/oauth_test.go +++ b/internal/api/oauth_test.go @@ -574,9 +574,14 @@ func TestOAuthClientAuthAndPKCE(t *testing.T) { return url.Values{"grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {oauthTestRedirect}, "code_verifier": {oauthTestVerifier}} } code := codeFor(pkce()) + // The secret is random, so it can already end in "AA"; pick a suffix that is sure to differ. + wrongSecret := f.secret[:len(f.secret)-2] + "AA" + if wrongSecret == f.secret { + wrongSecret = f.secret[:len(f.secret)-2] + "BA" + } // 잘못된 client 인증은 401 invalid_client이고 코드는 소모되지 않는다. for name, w := range map[string]*httptest.ResponseRecorder{ - "wrong secret": tokenReq(f.h, form(code), f.clientID, f.secret[:len(f.secret)-2]+"AA"), + "wrong secret": tokenReq(f.h, form(code), f.clientID, wrongSecret), "no secret": tokenReq(f.h, merge(form(code), url.Values{"client_id": {f.clientID}}), "", ""), "basic and post": tokenReq(f.h, merge(form(code), url.Values{"client_secret": {f.secret}}), f.clientID, f.secret), "other client": tokenReq(f.h, form(code), f.publicID, ""), From 8dbf7159aa518549c1a11c99a04adb160206459b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 04:10:16 +0000 Subject: [PATCH 03/25] web: run the lib unit tests in CI 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 Claude-Session: https://claude.ai/code/session_01CJyoeEBvvbpiXsP7oR67cb --- .github/workflows/ci.yml | 1 + web/lib/__tests__/README.md | 2 +- web/package.json | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bcebdcb..2a55b1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,7 @@ jobs: - run: npm ci - run: npm run typecheck - run: npm run i18n:check + - run: npm test - run: npm run build sdk: diff --git a/web/lib/__tests__/README.md b/web/lib/__tests__/README.md index dc0978d..be92411 100644 --- a/web/lib/__tests__/README.md +++ b/web/lib/__tests__/README.md @@ -1,5 +1,5 @@ Tests for the pure helpers in `web/lib` (dotenv, refs, cron). No extra dependencies: Node 22.6+ strips the types. ```sh -cd web && node --import ./lib/__tests__/register.mjs --test "lib/__tests__/*.test.mjs" +cd web && npm test ``` diff --git a/web/package.json b/web/package.json index f116e0d..b67cd20 100644 --- a/web/package.json +++ b/web/package.json @@ -8,7 +8,8 @@ "build": "next build", "start": "next start", "typecheck": "tsc --noEmit", - "i18n:check": "node scripts/i18n-check.mjs" + "i18n:check": "node scripts/i18n-check.mjs", + "test": "node --import ./lib/__tests__/register.mjs --test \"lib/__tests__/*.test.mjs\" lib/dataSql.test.mjs lib/sseParse.test.mjs" }, "dependencies": { "@fontsource-variable/inter": "^5.3.0", From e993cee2367ac5c0d71235246400c88e09899961 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 04:13:20 +0000 Subject: [PATCH 04/25] imageupdate: stop a nil-response panic when the authenticated registry 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 Claude-Session: https://claude.ai/code/session_01CJyoeEBvvbpiXsP7oR67cb --- internal/imageupdate/imageupdate_test.go | 26 ++++++++++++++++++++++++ internal/imageupdate/registry.go | 8 +++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/internal/imageupdate/imageupdate_test.go b/internal/imageupdate/imageupdate_test.go index 5471a18..ea36788 100644 --- a/internal/imageupdate/imageupdate_test.go +++ b/internal/imageupdate/imageupdate_test.go @@ -257,3 +257,29 @@ func TestWindows(t *testing.T) { t.Error("Enabled") } } + +// A transport error on the authenticated retry must come back as an error, not a nil response. +func TestRegistryBearerRetryTransportError(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/token" { + fmt.Fprint(w, `{"token":"registry-token"}`) + return + } + if r.Header.Get("Authorization") != "Bearer registry-token" { + w.Header().Set("WWW-Authenticate", `Bearer realm="`+srvURL(r)+`/token",service="test",scope="repository:acme/app:pull"`) + w.WriteHeader(http.StatusUnauthorized) + return + } + conn, _, err := w.(http.Hijacker).Hijack() + if err == nil { + conn.Close() // drop the connection without a response + } + })) + defer srv.Close() + c := NewClient(nil) + c.HTTP, c.base = srv.Client(), srv.URL + r, _ := Parse("acme/app:latest") + if _, err := c.ManifestDigest(context.Background(), r, r.Tag); err == nil { + t.Fatal("ManifestDigest succeeded after the registry dropped the connection") + } +} diff --git a/internal/imageupdate/registry.go b/internal/imageupdate/registry.go index 89959b5..7752765 100644 --- a/internal/imageupdate/registry.go +++ b/internal/imageupdate/registry.go @@ -227,9 +227,11 @@ func (c *Client) request(ctx context.Context, method, rawURL string, r Reference scheme, params := parseChallenge(challenge) switch strings.ToLower(scheme) { case "bearer": - access, err := c.bearerToken(ctx, params, r) - if err != nil { - return nil, err + // tokenErr, not err: a := here would shadow err, and a failed request below would leave resp nil + // for the status check after the switch. + access, tokenErr := c.bearerToken(ctx, params, r) + if tokenErr != nil { + return nil, tokenErr } resp, err = makeRequest(access, false) case "basic": From 3c34fb1afe8bcc23c2cbb2f2dcd783496d9d2e0c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 04:13:20 +0000 Subject: [PATCH 05/25] webauthn: check BeginLogin errors in the password+passkey login flow '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 Claude-Session: https://claude.ai/code/session_01CJyoeEBvvbpiXsP7oR67cb --- internal/api/webauthn.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/internal/api/webauthn.go b/internal/api/webauthn.go index 4920bf1..282b63f 100644 --- a/internal/api/webauthn.go +++ b/internal/api/webauthn.go @@ -384,9 +384,10 @@ func (s *Server) beginPasskeyLogin(w http.ResponseWriter, r *http.Request) { s.authFailed(w, r, acct, "로그인 시도가 너무 많습니다", "이메일 또는 비밀번호가 올바르지 않습니다") return } - wu, err := s.loadWebAuthnUser(r.Context(), u, rpID) - if err != nil { - s.storeErr(w, err) + // loadErr, not err: a := here would shadow err, and a BeginLogin failure below would go unchecked. + wu, loadErr := s.loadWebAuthnUser(r.Context(), u, rpID) + if loadErr != nil { + s.storeErr(w, loadErr) return } if len(wu.credentials) == 0 { @@ -394,7 +395,7 @@ func (s *Server) beginPasskeyLogin(w http.ResponseWriter, r *http.Request) { return } options, session, err = wa.BeginLogin(wu, webauthn.WithUserVerification(protocol.VerificationRequired)) - userID, purpose, acct = u.ID, "login_factor", "passkey:"+u.ID + userID, purpose = u.ID, "login_factor" } else { acct = discoverableAcct(s, r) if s.lockedOut(w, r, acct, "로그인 시도가 너무 많습니다") { From 011d61fdc03f78679d63b3fd592e8800ca2e4591 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 04:13:20 +0000 Subject: [PATCH 06/25] windows: do not forward the wake secret header to apps 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 Claude-Session: https://claude.ai/code/session_01CJyoeEBvvbpiXsP7oR67cb --- cmd/aven-server/main.go | 11 ++++++++++- cmd/aven-server/main_test.go | 13 +++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/cmd/aven-server/main.go b/cmd/aven-server/main.go index 166369c..911e1de 100644 --- a/cmd/aven-server/main.go +++ b/cmd/aven-server/main.go @@ -403,7 +403,8 @@ func runWithContext(sigCtx context.Context, stopSignals context.CancelFunc, log } wakeHandler := mgr.WakeHandler() if wakeSecret != "" { - wakeHandler = requireHeaderSecret(wakeSecretHeader, wakeSecret, wakeHandler) + // The wake handler proxies the request on to the app, so drop the secret once it is checked. + wakeHandler = requireHeaderSecret(wakeSecretHeader, wakeSecret, stripHeader(wakeSecretHeader, wakeHandler)) } ws := &http.Server{Handler: wakeHandler, ReadHeaderTimeout: 10 * time.Second, BaseContext: baseCtx} servers = append(servers, ws) @@ -824,3 +825,11 @@ func requireHeaderSecret(header, secret string, next http.Handler) http.Handler next.ServeHTTP(w, r) }) } + +// stripHeader removes header from the request before next sees it. +func stripHeader(header string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Header.Del(header) + next.ServeHTTP(w, r) + }) +} diff --git a/cmd/aven-server/main_test.go b/cmd/aven-server/main_test.go index 6ec12b7..86bb0f5 100644 --- a/cmd/aven-server/main_test.go +++ b/cmd/aven-server/main_test.go @@ -153,3 +153,16 @@ func TestRequireHeaderSecret(t *testing.T) { }) } } + +func TestWakeSecretIsNotForwarded(t *testing.T) { + var forwarded string + app := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { forwarded = r.Header.Get(wakeSecretHeader) }) + h := requireHeaderSecret(wakeSecretHeader, "s3cret", stripHeader(wakeSecretHeader, app)) + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Header.Set(wakeSecretHeader, "s3cret") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusOK || forwarded != "" { + t.Fatalf("status %d, app saw secret %q", w.Code, forwarded) + } +} From 28fff6e6b35553272e899bebd9cebc4344a8f3d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 04:18:39 +0000 Subject: [PATCH 07/25] web: fix accessibility violations found by an axe audit 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 Claude-Session: https://claude.ai/code/session_01CJyoeEBvvbpiXsP7oR67cb --- web/components/Dashboards.tsx | 2 +- web/components/LogTable.tsx | 10 ++++++---- web/components/ServiceCard.tsx | 2 +- web/components/nodes/Gauge.tsx | 17 +++++++++++------ web/components/nodes/NodesView.tsx | 4 ++-- 5 files changed, 21 insertions(+), 14 deletions(-) diff --git a/web/components/Dashboards.tsx b/web/components/Dashboards.tsx index a08e274..18f66eb 100644 --- a/web/components/Dashboards.tsx +++ b/web/components/Dashboards.tsx @@ -99,7 +99,7 @@ export function DashboardSelector({ onClick={() => onChange(id)} className={cx( "relative -ml-px flex h-[34px] shrink-0 cursor-pointer items-center whitespace-nowrap border px-3 text-sm first:ml-0 first:rounded-l-md last:rounded-r-md hover:z-10 hover:border-gray-300 hover:bg-secondaryBg focus:outline-none focus-visible:z-20 focus-visible:ring-2 focus-visible:ring-pink-500", - value === id ? "z-10 border-gray-300 bg-secondaryBg text-pink-500" : "border-gray-200 text-gray-600 hover:text-foreground", + value === id ? "z-10 border-gray-300 bg-secondaryBg text-pink-600" : "border-gray-200 text-gray-600 hover:text-foreground", )} > {label} diff --git a/web/components/LogTable.tsx b/web/components/LogTable.tsx index 87e1ac2..acef562 100644 --- a/web/components/LogTable.tsx +++ b/web/components/LogTable.tsx @@ -604,11 +604,13 @@ export function LogTable({ )} + {/* role="table" groups the header row and the log rows, so the row/cell roles have the parent ARIA requires. */} +
{time ? (
- {t("날짜")} ({tz}) + {t("날짜")} ({tz})
) : (
@@ -636,7 +638,7 @@ export function LogTable({ {t("다시 연결")} )} - + {filtered ? `${shown.length.toLocaleString()} / ` : ""} {t("{count}줄", { count: rows.length.toLocaleString() })} @@ -648,8 +650,7 @@ export function LogTable({
+
{bar ?
diff --git a/web/components/nodes/NodesView.tsx b/web/components/nodes/NodesView.tsx index be38318..0bca58e 100644 --- a/web/components/nodes/NodesView.tsx +++ b/web/components/nodes/NodesView.tsx @@ -793,7 +793,7 @@ function NodeRow({
{n.name} - {n.is_local && {t("이 서버")}} + {n.is_local && {t("이 서버")}} {st.label} @@ -803,7 +803,7 @@ function NodeRow({
{addr(n)} - {n.address.private && · {t("사설 {address}", { address: n.address.private })}} + {n.address.private && · {t("사설 {address}", { address: n.address.private })}}
From c59f310efbd280293b767dc53b85d503c9b32b79 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 04:18:57 +0000 Subject: [PATCH 08/25] sdk: fix the sandbox README link to the API docs The link pointed at sdk/docs/api-v1.md, which does not exist. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CJyoeEBvvbpiXsP7oR67cb --- sdk/sandbox/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/sandbox/README.md b/sdk/sandbox/README.md index 4938f1a..ffe5e3b 100644 --- a/sdk/sandbox/README.md +++ b/sdk/sandbox/README.md @@ -1,6 +1,6 @@ # Aven sandbox SDK (TypeScript) -Dependency-free `aven-sandbox` client for [Aven sandboxes](../docs/api-v1.md#sandboxes). Works in Node.js 22+ (uses the global `fetch`) and other runtimes with `fetch`, `atob`/`btoa` and `TextEncoder`. +Dependency-free `aven-sandbox` client for [Aven sandboxes](../../docs/api-v1.md#sandboxes). Works in Node.js 22+ (uses the global `fetch`) and other runtimes with `fetch`, `atob`/`btoa` and `TextEncoder`. Configuration comes from options or the environment: `AVEN_URL`, `AVEN_TOKEN` (an API token with member access to the project), `AVEN_ENVIRONMENT_ID`. The `KEEL_*` names still work. From 6f5999ae5e99c37e9eda23a6225109bb8ea8dfd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 04:19:25 +0000 Subject: [PATCH 09/25] deploy: document optional server settings in aven.env.example 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 Claude-Session: https://claude.ai/code/session_01CJyoeEBvvbpiXsP7oR67cb --- deploy/aven.env.example | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/deploy/aven.env.example b/deploy/aven.env.example index 3b65e4b..252783e 100644 --- a/deploy/aven.env.example +++ b/deploy/aven.env.example @@ -49,3 +49,17 @@ AVEN_RAILPACK_BIN=/usr/local/bin/railpack # AVEN_SELF_ENV_FILE=/etc/aven/aven.env # 선택 사항: 모든 작업 공간을 관리할 계정 이메일 목록. AVEN_SUPERADMINS= + +# Optional settings with built-in defaults. +# Verbose (debug) logging when set to any value. +# AVEN_DEBUG=1 +# Port range for public TCP proxies. Ports must be unique across all nodes. +# AVEN_TCP_PORT_MIN=20000 +# AVEN_TCP_PORT_MAX=29999 +# Proxies in front of aven-proxy whose X-Forwarded-For is trusted for the client IP (comma-separated IPs/CIDRs, +# e.g. the docker network of a cloudflared container). Used by access logs, edge WAF rules and upstream X-Forwarded-For. +# AVEN_PROXY_TRUSTED_PROXIES=172.18.0.0/16 +# off: do not route AVEN_PUBLIC_URL's host through aven-proxy (use your own reverse proxy in front of AVEN_LISTEN). +# AVEN_SELF_ROUTE=off +# Path of the docker CLI when it is not on PATH. +# AVEN_DOCKER_BIN=/usr/bin/docker From 1416051d2b3096a3e31acdbbc3376bb3c4ac51af Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 04:22:26 +0000 Subject: [PATCH 10/25] deploy: fix a data race when starting a PITR restore 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 Claude-Session: https://claude.ai/code/session_01CJyoeEBvvbpiXsP7oR67cb --- internal/deploy/dbpitr.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/deploy/dbpitr.go b/internal/deploy/dbpitr.go index b1fcc19..6f04659 100644 --- a/internal/deploy/dbpitr.go +++ b/internal/deploy/dbpitr.go @@ -1118,6 +1118,8 @@ func (m *Manager) RestorePITR(ctx context.Context, src, target *store.Instance, } rs := &PITRRestoreStatus{Status: "running", Step: "준비", TargetTime: at.UTC(), TargetServiceID: target.ServiceID, TargetInstance: target.ID, InPlace: o.InPlace, StartedAt: time.Now().UTC()} + // Copy the status before the goroutine starts: from then on it updates rs under pitrJobs. + c := *rs pitrJobs.Lock() pitrJobs.restores[src.ID] = rs pitrJobs.Unlock() @@ -1149,7 +1151,6 @@ func (m *Manager) RestorePITR(ctx context.Context, src, target *store.Instance, } } }() - c := *rs return &c, nil } From 1cbfe6aec9f74a957fd7bb793641a061e5d34ddf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 04:38:31 +0000 Subject: [PATCH 11/25] Remove dead code reported by staticcheck 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 Claude-Session: https://claude.ai/code/session_01CJyoeEBvvbpiXsP7oR67cb --- internal/api/compose.go | 7 ------- internal/deploy/driver.go | 4 ---- internal/edge/rules.go | 10 ---------- internal/proxy/caddy.go | 9 ++++----- internal/proxy/edge.go | 11 ----------- 5 files changed, 4 insertions(+), 37 deletions(-) diff --git a/internal/api/compose.go b/internal/api/compose.go index a71093a..26c6403 100644 --- a/internal/api/compose.go +++ b/internal/api/compose.go @@ -431,13 +431,6 @@ type serviceDiff struct { Changes []string `json:"changes"` } -func shortVal(v *string) string { - if v == nil { - return "-" - } - return *v -} - func (s *Server) diffPlan(ctx context.Context, env *store.Environment, l *composeLoaded) []serviceDiff { out := []serviceDiff{} for _, sp := range l.plan.Services { diff --git a/internal/deploy/driver.go b/internal/deploy/driver.go index 4ec9721..72b4bc6 100644 --- a/internal/deploy/driver.go +++ b/internal/deploy/driver.go @@ -411,10 +411,6 @@ func (r *remoteDriver) tcpEnsure(ctx context.Context, req agentprotocol.TCPProxy return r.call(ctx, agentprotocol.OpTCPEnsure, req, nil) } -func (r *remoteDriver) tcpRemove(ctx context.Context, instanceID string) error { - return r.call(ctx, agentprotocol.OpTCPRemove, agentprotocol.TCPRemoveRequest{InstanceID: instanceID}, nil) -} - func (r *remoteDriver) nodeStats(ctx context.Context) (agentprotocol.NodeStats, error) { var s agentprotocol.NodeStats err := r.call(ctx, agentprotocol.OpNodeStats, struct{}{}, &s) diff --git a/internal/edge/rules.go b/internal/edge/rules.go index 2160f1c..afc5640 100644 --- a/internal/edge/rules.go +++ b/internal/edge/rules.go @@ -29,16 +29,6 @@ type Rule struct { func (r Rule) IsEnabled() bool { return r.Enabled == nil || *r.Enabled } -func (r Rule) label() string { - if r.Description != "" { - return r.Description - } - if r.ID != "" { - return r.ID - } - return fmt.Sprintf("priority %d", r.Priority) -} - // Expr은 조건 하나(attr/op/value[/key]) 또는 and/or/not 묶음이다. type Expr struct { And []*Expr `json:"and,omitempty"` diff --git a/internal/proxy/caddy.go b/internal/proxy/caddy.go index 3112b82..9b7cadc 100644 --- a/internal/proxy/caddy.go +++ b/internal/proxy/caddy.go @@ -429,11 +429,10 @@ func (c *Caddy) reloadThroughDocker(ctx context.Context, body []byte) error { } type pathRule struct { - path string // 정규화된 규칙("" = catch-all) - strip bool - routes []string - ups []string - wake bool + path string // 정규화된 규칙("" = catch-all) + strip bool + ups []string + wake bool trace bool traceAttrs map[string]string diff --git a/internal/proxy/edge.go b/internal/proxy/edge.go index 45e9fdc..2df2887 100644 --- a/internal/proxy/edge.go +++ b/internal/proxy/edge.go @@ -70,17 +70,6 @@ func (h *hostCfg) edgeForward() map[string]any { } } -// originHeaders는 origin 서버가 컨테이너로 보낼 때 엔진이 넘긴 클라이언트 정보를 X-Forwarded-*로 되돌린다. -func originHeaders() map[string]any { - return map[string]any{"request": map[string]any{ - "set": map[string][]string{ - "X-Forwarded-For": {"{http.request.header." + edge.HeaderClientIP + "}"}, - "X-Forwarded-Proto": {"{http.request.header." + edge.HeaderClientProto + "}"}, - }, - "delete": []string{edge.HeaderClientIP, edge.HeaderClientProto}, - }} -} - func (h *hostCfg) socket(name string) string { dir := h.wakeDir if dir == "" { From 71732bac2ede6442a4be6ac340dc21714be8262c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 04:41:30 +0000 Subject: [PATCH 12/25] web: meet AA contrast in the light theme 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 Claude-Session: https://claude.ai/code/session_01CJyoeEBvvbpiXsP7oR67cb --- web/app/(main)/observability/page.tsx | 4 ++-- web/app/globals.css | 2 +- web/components/InboxMenu.tsx | 2 +- web/components/NotificationSettings.tsx | 2 +- web/components/projects/ProjectCard.tsx | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/web/app/(main)/observability/page.tsx b/web/app/(main)/observability/page.tsx index 40eb53d..46d600e 100644 --- a/web/app/(main)/observability/page.tsx +++ b/web/app/(main)/observability/page.tsx @@ -94,7 +94,7 @@ function RecentErrors({ envId, names, onOpen }: { envId: string; names: Map {l.msg} - + {fmtRelative(l.t)} @@ -241,7 +241,7 @@ function ObservabilityInner() { {eventIcon(e.kind)} {(e.service_id && names.get(e.service_id)) || e.service_name || (e.service_id ? t("삭제된 서비스") : t("프로젝트"))} {e.message} - + {fmtRelative(e.created_at)} diff --git a/web/app/globals.css b/web/app/globals.css index 34aabbf..b38001d 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -416,7 +416,7 @@ --gray-200: #dcdce0; --gray-300: #c4c4ca; --gray-400: #a1a0ab; - --gray-500: #72707d; /* was #878593 (3.6:1 on white); now 4.8:1, AA */ + --gray-500: #6e6c79; /* 5.1:1 on white, 4.6:1 on gray-100 (kbd, chips): AA for secondary text on both */ --gray-600: #6c6b7b; --gray-700: #545260; --gray-800: #33323e; diff --git a/web/components/InboxMenu.tsx b/web/components/InboxMenu.tsx index 68c3b76..e78a0f5 100644 --- a/web/components/InboxMenu.tsx +++ b/web/components/InboxMenu.tsx @@ -174,7 +174,7 @@ export function InboxMenu({ className }: { className?: string }) { {projectName(n.project_id)} · {n.kind} - diff --git a/web/components/NotificationSettings.tsx b/web/components/NotificationSettings.tsx index 3a4dffa..3118c62 100644 --- a/web/components/NotificationSettings.tsx +++ b/web/components/NotificationSettings.tsx @@ -243,7 +243,7 @@ function Deliveries({ pid, cid }: { pid: ID | null; cid: ID }) { {d.status === "ok" ? `${d.status_code ?? "ok"} · ${d.duration_ms}ms` : d.error} {d.attempts > 1 && t(" · {count}회 시도", { count: d.attempts })} - + {fmtRelative(d.created_at)} diff --git a/web/components/projects/ProjectCard.tsx b/web/components/projects/ProjectCard.tsx index dafba3f..40b7684 100644 --- a/web/components/projects/ProjectCard.tsx +++ b/web/components/projects/ProjectCard.tsx @@ -98,7 +98,7 @@ function StatusLine({ status }: { status: Status }) { )} {lastDeploy && ( -
@@ -81,19 +85,84 @@ function TcpEditForm({ onDone: () => void; }) { const [port, setPort] = useState(() => initialPortChoice(proxy.container_port, detected)); + const supported = proxy.allow_cidrs !== undefined; // older servers do not know allowlists + const own = proxy.allow_cidrs ?? []; + const [allowText, setAllowText] = useState(() => own.join("\n")); const { run, busy, error } = useNetRun(svc, env); - const changed = portOk(port.text) && Number(port.text) !== proxy.container_port; + const { list: allow, bad } = parseTcpAllow(allowText); + const tooMany = allow.length > TCP_ALLOW_MAX; + const allowOk = bad.length === 0 && !tooMany; + const portChanged = portOk(port.text) && Number(port.text) !== proxy.container_port; + const allowChanged = supported && allowOk && !sameList(allow, own); + const canSubmit = (portChanged || allowChanged) && allowOk; + const defaults = proxy.default_allow_cidrs ?? []; + const problem = bad.length + ? t("IPv4 주소나 CIDR이 아닙니다: {entries}", { entries: bad.slice(0, 3).join(", ") }) + : tooMany + ? t("허용 대역은 {max}개까지 넣을 수 있습니다", { max: TCP_ALLOW_MAX }) + : null; return (
{ e.preventDefault(); - if (changed && (await run("save", () => api.patchTcpProxy(svc.id, env.id, Number(port.text))))) onDone(); + if (!canSubmit) return; + const body = { ...(portChanged ? { container_port: Number(port.text) } : {}), ...(allowChanged ? { allow_cidrs: allow } : {}) }; + if (await run("save", () => api.patchTcpProxy(svc.id, env.id, body))) onDone(); }} > + {supported ? ( +
+ +