diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bcebdcb..7589b19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,12 @@ jobs: - uses: actions/setup-go@v7 with: go-version-file: go.mod + - name: gofmt + run: test -z "$(gofmt -l cmd internal | tee /dev/stderr)" - run: go vet ./... + # staticcheck caught two shadowed-err bugs (a nil-response panic, an unchecked WebAuthn error); keep it clean. + - name: staticcheck + run: go run honnef.co/go/tools/cmd/staticcheck@2026.1 ./... - run: go test ./... - run: make server agents cli - name: Cross-build Windows CLI @@ -25,6 +30,18 @@ jobs: GOOS=windows GOARCH=arm64 CGO_ENABLED=0 go build -o /dev/null ./cmd/aven GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o /dev/null ./cmd/aven-server + # The race detector found a real data race (PITR restore status) that the plain run missed. + # It is about ten times slower, so it runs as its own job next to the others. + go-race: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + # internal/api alone takes about ten minutes under -race, past go test's default timeout. + - run: go test -race -timeout 30m ./... + web: runs-on: ubuntu-latest defaults: @@ -40,6 +57,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/README.ko.md b/README.ko.md index 05befbb..4c3631c 100644 --- a/README.ko.md +++ b/README.ko.md @@ -179,7 +179,7 @@ Aven은 Keel이라는 이전 프로젝트를 잇습니다. 이 공개 저장소 **네트워크** - 관리형 Caddy 프록시로 생성 도메인과 커스텀 도메인에 TLS 자동 발급. 경로 규칙, 리디렉션, basic auth, 와일드카드 도메인. -- IPv6 우선: 도메인별 공개 IPv6 주소(선택), 아웃바운드 IPv6 제어, 데이터베이스 같은 TCP 서비스를 위한 TCP 프록시. +- IPv6 우선: 도메인별 공개 IPv6 주소(선택), 아웃바운드 IPv6 제어, 데이터베이스 같은 TCP 서비스를 위한 TCP 프록시(프록시별 접속 허용 IP 지정 가능). - 서비스 사이 사설 DNS 이름. 암호화된 mesh(Tailscale 또는 WireGuard 주소)로 서버를 넘어서도 이어집니다. - 엣지 캐싱, WAF 규칙, Cloudflare Tunnel 진입. diff --git a/README.md b/README.md index 720a5d5..b5d61a9 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,7 @@ AI coding agents were used substantially in development. Changes went through hu **Networking** - Generated and custom domains with automatic TLS through a managed Caddy proxy, path rules, redirects, basic auth and wildcard domains. -- IPv6 first: optional per-domain public IPv6 addresses, outbound IPv6 control and TCP proxies for databases and other raw TCP services. +- IPv6 first: optional per-domain public IPv6 addresses, outbound IPv6 control and TCP proxies for databases and other raw TCP services, with an optional client IP allowlist per proxy. - Private DNS names between services, also across servers over an encrypted mesh (Tailscale or WireGuard addresses). - Edge caching, WAF rules and Cloudflare Tunnel ingress. diff --git a/cmd/aven-server/loops.go b/cmd/aven-server/loops.go new file mode 100644 index 0000000..9388181 --- /dev/null +++ b/cmd/aven-server/loops.go @@ -0,0 +1,53 @@ +package main + +import ( + "context" + "log/slog" + "runtime/debug" + "sync" + "time" +) + +// Restart delays for a background loop that panicked: the first restart waits loopRestartMin, each +// further one doubles up to loopRestartMax, so a loop that panics on every tick cannot spin. +var ( + loopRestartMin = 30 * time.Second + loopRestartMax = 5 * time.Minute +) + +// goLoop runs fn as a tracked background task. A panic in fn is logged with its stack and fn is started +// again after a delay, so one faulty loop (an image-update check, a metrics collector) cannot take down +// the API, the proxy sync and every other loop with it. fn returning normally ends the task. +func goLoop(bg *sync.WaitGroup, ctx context.Context, log *slog.Logger, name string, fn func()) { + bg.Go(func() { runRecovering(ctx, log, name, fn) }) +} + +func runRecovering(ctx context.Context, log *slog.Logger, name string, fn func()) { + delay := loopRestartMin + for { + if !runOnce(log, name, fn) || ctx.Err() != nil { + return + } + log.Error("background task restarts after a panic", "task", name, "in", delay) + t := time.NewTimer(delay) + select { + case <-ctx.Done(): + t.Stop() + return + case <-t.C: + } + delay = min(delay*2, loopRestartMax) + } +} + +// runOnce calls fn and reports whether it panicked. +func runOnce(log *slog.Logger, name string, fn func()) (panicked bool) { + defer func() { + if v := recover(); v != nil { + panicked = true + log.Error("background task panicked", "task", name, "panic", v, "stack", string(debug.Stack())) + } + }() + fn() + return false +} diff --git a/cmd/aven-server/loops_test.go b/cmd/aven-server/loops_test.go new file mode 100644 index 0000000..49a27f6 --- /dev/null +++ b/cmd/aven-server/loops_test.go @@ -0,0 +1,54 @@ +package main + +import ( + "context" + "io" + "log/slog" + "sync" + "sync/atomic" + "testing" + "time" +) + +func withRestartDelays(t *testing.T, lo, hi time.Duration) { + t.Helper() + oldLo, oldHi := loopRestartMin, loopRestartMax + loopRestartMin, loopRestartMax = lo, hi + t.Cleanup(func() { loopRestartMin, loopRestartMax = oldLo, oldHi }) +} + +func TestGoLoopRestartsAfterPanic(t *testing.T) { + withRestartDelays(t, time.Millisecond, 4*time.Millisecond) + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + var runs atomic.Int32 + var bg sync.WaitGroup + goLoop(&bg, context.Background(), log, "test", func() { + if runs.Add(1) < 3 { + panic("boom") + } + }) + bg.Wait() + if got := runs.Load(); got != 3 { + t.Fatalf("runs = %d, want 3 (two panics, then a normal return)", got) + } +} + +func TestGoLoopStopsWaitingOnCancel(t *testing.T) { + withRestartDelays(t, time.Hour, time.Hour) + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + ctx, cancel := context.WithCancel(context.Background()) + var runs atomic.Int32 + var bg sync.WaitGroup + goLoop(&bg, ctx, log, "test", func() { + runs.Add(1) + panic("boom") + }) + time.Sleep(20 * time.Millisecond) + cancel() + if !waitTimeout(&bg, 5*time.Second) { + t.Fatal("the task kept waiting for its restart after cancellation") + } + if got := runs.Load(); got != 1 { + t.Fatalf("runs = %d, want 1", got) + } +} diff --git a/cmd/aven-server/main.go b/cmd/aven-server/main.go index a33c94c..4596294 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)") @@ -142,9 +144,47 @@ func checkEnv(listen string) error { if _, err := parsePrefixes(compat.Env("TRUSTED_PROXIES")); err != nil { return fmt.Errorf("%s: %w", compat.Describe("TRUSTED_PROXIES"), err) } + if _, err := sessionMaxAge(); err != nil { + return fmt.Errorf("%s: %w", compat.Describe("SESSION_MAX_AGE"), err) + } + if _, err := tcpAllowDefault(); err != nil { + return fmt.Errorf("%s: %w", compat.Describe("TCP_PORT_ALLOW_CIDRS"), err) + } return nil } +// sessionMaxAge is AVEN_SESSION_MAX_AGE: the absolute lifetime of a login session, as a Go duration +// ("2160h") or a number of days ("90d"); "off" removes the cap. Unset uses api.DefaultSessionMaxAge. +func sessionMaxAge() (time.Duration, error) { + v := strings.TrimSpace(compat.Env("SESSION_MAX_AGE")) + switch { + case v == "": + return 0, nil + case strings.EqualFold(v, "off"): + return -1, nil + } + if days, ok := strings.CutSuffix(v, "d"); ok { + n, err := strconv.Atoi(days) + if err != nil || n < 1 { + return 0, fmt.Errorf("%q is not a number of days (e.g. 90d)", v) + } + return time.Duration(n) * 24 * time.Hour, nil + } + d, err := time.ParseDuration(v) + if err != nil || d < time.Hour { + return 0, fmt.Errorf("%q: use a duration of at least 1h (e.g. 2160h), a number of days (90d) or off", v) + } + return d, nil +} + +// tcpAllowDefault is AVEN_TCP_PORT_ALLOW_CIDRS: the client allowlist of TCP proxies without their own +// (IPv4 addresses or CIDRs separated by commas or spaces). +func tcpAllowDefault() ([]string, error) { + return node.NormalizeTCPAllowCIDRs(strings.FieldsFunc(compat.Env("TCP_PORT_ALLOW_CIDRS"), func(r rune) bool { + return r == ',' || r == ' ' || r == '\t' || r == '\n' + })) +} + func run(log *slog.Logger, listen, dataDir string) (err error) { sigCtx, stopSignals := serverSignalContext() defer stopSignals() @@ -303,6 +343,7 @@ func runWithContext(sigCtx context.Context, stopSignals context.CancelFunc, log RailpackFrontend: env("RAILPACK_FRONTEND", "ghcr.io/railwayapp/railpack-frontend:v0.42.0"), TCPPortMin: envInt("TCP_PORT_MIN", 20000), TCPPortMax: envInt("TCP_PORT_MAX", 29999), + TCPAllowDefault: tcpAllowDefaultOrNil(), PrivateAddress: strings.TrimSpace(compat.Env("PRIVATE_ADDRESS")), Mesh: env("MESH", "auto"), MeshPort: envInt("MESH_PORT", 7443), @@ -381,16 +422,16 @@ func runWithContext(sigCtx context.Context, stopSignals context.CancelFunc, log } else if mgr.LocalMesh != nil { log.Info("mesh gateway enabled", "listen", mgr.LocalMesh.Listen) } - bg.Go(func() { mgr.ReconcileLoop(ctx, 30*time.Second) }) - bg.Go(func() { mgr.EgressLoop(ctx, 30*time.Second) }) // 아웃바운드 IPv6 차단 규칙(outbound_ipv6=false)을 컨테이너 주소에 맞춘다 - bg.Go(func() { mgr.DomainIPv6Loop(ctx, time.Minute) }) // 도메인별 공개 IPv6(노드 풀이 있을 때만): 시작할 때, 도메인 변경 때, 60초마다 + goLoop(&bg, ctx, log, "reconcile", func() { mgr.ReconcileLoop(ctx, 30*time.Second) }) + goLoop(&bg, ctx, log, "egress", func() { mgr.EgressLoop(ctx, 30*time.Second) }) // 아웃바운드 IPv6 차단 규칙(outbound_ipv6=false)을 컨테이너 주소에 맞춘다 + goLoop(&bg, ctx, log, "domain-ipv6", func() { mgr.DomainIPv6Loop(ctx, time.Minute) }) // 도메인별 공개 IPv6(노드 풀이 있을 때만): 시작할 때, 도메인 변경 때, 60초마다 // DNS 조회/연결(flow) 로그: 환경 bridge의 패킷 헤더를 관찰한다(CAP_NET_RAW 필요, AVEN_NETWORK_LOGS=off로 끈다). if runtime.GOOS != "windows" && !strings.EqualFold(strings.TrimSpace(env("NETWORK_LOGS", "on")), "off") { mgr.StartLocalNetworkLogs(ctx) } - bg.Go(func() { mgr.WatchCrashes(ctx) }) - bg.Go(func() { mgr.CronLoop(ctx) }) - bg.Go(func() { mgr.ImageUpdateLoop(ctx) }) + goLoop(&bg, ctx, log, "watch-crashes", func() { mgr.WatchCrashes(ctx) }) + goLoop(&bg, ctx, log, "cron", func() { mgr.CronLoop(ctx) }) + goLoop(&bg, ctx, log, "image-updates", func() { mgr.ImageUpdateLoop(ctx) }) // wake handler: keel-proxy와 같이 쓰는 볼륨의 unix socket에서만 listen한다(외부에 열지 않음). if wl, err := listenWake(ctx, docker, caddy); err != nil { log.Warn("wake handler disabled (sleeping services cannot wake)", "err", err) @@ -401,15 +442,16 @@ func runWithContext(sigCtx context.Context, stopSignals context.CancelFunc, log } wakeHandler := mgr.WakeHandler() if wakeSecret != "" { - wakeHandler = requireWakeSecret(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) go ws.Serve(wl) log.Info("wake handler listening", "addr", wl.Addr().String()) } - bg.Go(func() { mgr.SleepLoop(ctx) }) // 로컬(wake handler가 있을 때)과 원격 노드의 sleeping 판정 - bg.Go(func() { caddy.FollowAccessLogs(ctx, mgr.HTTPLogs.Add) }) + goLoop(&bg, ctx, log, "sleep", func() { mgr.SleepLoop(ctx) }) // 로컬(wake handler가 있을 때)과 원격 노드의 sleeping 판정 + goLoop(&bg, ctx, log, "access-logs", func() { caddy.FollowAccessLogs(ctx, mgr.HTTPLogs.Add) }) collector := &metrics.Collector{Store: st, Docker: docker, Log: log, DataDir: dataDir, Events: bus, RemoteNodes: func(ctx context.Context) []metrics.NodeSource { var out []metrics.NodeSource for _, id := range mgr.OnlineRemoteNodeIDs(ctx) { @@ -427,14 +469,14 @@ func runWithContext(sigCtx context.Context, stopSignals context.CancelFunc, log } return out }} - bg.Go(func() { collector.Run(ctx) }) + goLoop(&bg, ctx, log, "metrics", func() { collector.Run(ctx) }) // 지표 알림 규칙(30초마다)과 시간별 사용량 집계(매시, 놓친 시간은 보관 중인 지표로 채운다). sources := &alerts.Sources{Store: st, HTTP: mgr.HTTPLogs.Window, Restarts: mgr.InstanceRestarts, VolumeSize: collector.VolumeSize} - bg.Go(func() { (&alerts.Evaluator{Store: st, Events: bus, Log: log, Value: sources.Value}).Run(ctx) }) + goLoop(&bg, ctx, log, "alerts", func() { (&alerts.Evaluator{Store: st, Events: bus, Log: log, Value: sources.Value}).Run(ctx) }) // 수평 자동 확장(30초마다, 최근 2분 지표 평균으로 HPA 방식 계산). - bg.Go(func() { mgr.AutoscaleLoop(ctx) }) - bg.Go(func() { (&usage.Roller{Store: st, Events: bus, Log: log, Instances: mgr}).Run(ctx) }) - bg.Go(func() { + goLoop(&bg, ctx, log, "autoscale", func() { mgr.AutoscaleLoop(ctx) }) + goLoop(&bg, ctx, log, "usage", func() { (&usage.Roller{Store: st, Events: bus, Log: log, Instances: mgr}).Run(ctx) }) + goLoop(&bg, ctx, log, "prune", func() { t := time.NewTicker(time.Hour) defer t.Stop() for { @@ -456,7 +498,8 @@ func runWithContext(sigCtx context.Context, stopSignals context.CancelFunc, log Store: st, Agents: hub, Deploy: mgr, Events: bus, Metrics: collector, AdminPassword: password, APIToken: compat.Env("API_TOKEN"), AllowFileRepo: compat.Env("ALLOW_FILE_REPO") != "", SessionKey: key, SecureCookie: compat.Env("INSECURE_COOKIE") == "", - Web: webui.FS(), Version: version, NodeName: hostname, Log: log, + SessionMaxAge: mustSessionMaxAge(), + Web: webui.FS(), Version: version, NodeName: hostname, Log: log, GitHubSecret: compat.Env("GITHUB_WEBHOOK_SECRET"), GitHubAPIBase: env("GITHUB_API_URL", "https://api.github.com"), GitHubClientID: compat.Env("GITHUB_CLIENT_ID"), GitHubClientSecret: compat.Env("GITHUB_CLIENT_SECRET"), PublicURL: compat.Env("PUBLIC_URL"), AgentBinDir: compat.Env("AGENT_BIN_DIR"), @@ -464,10 +507,10 @@ func runWithContext(sigCtx context.Context, stopSignals context.CancelFunc, log Notifier: notifier, Pricing: pricing, Traces: traceReceiver, } - bg.Go(func() { tracing.RetentionLoop(ctx, st, traceRetention(log), log) }) + goLoop(&bg, ctx, log, "trace-retention", func() { tracing.RetentionLoop(ctx, st, traceRetention(log), log) }) trusted, _ := parsePrefixes(compat.Env("TRUSTED_PROXIES")) // checked by checkEnv srv.TrustedProxies = trusted - bg.Go(func() { srv.RunHA(ctx) }) // 데이터베이스 HA 클러스터 감시와 자동 failover(10초마다) + goLoop(&bg, ctx, log, "ha", func() { srv.RunHA(ctx) }) // 데이터베이스 HA 클러스터 감시와 자동 failover(10초마다) // 공개 도메인 상태 확인(DNS 분류, Cloudflare/터널 감지, 소유 확인, 인증서): 60초마다, 검증된 도메인은 최대 10분까지 간격을 늘린다. domainChecker := &netcheck.Checker{Store: st, Targets: srv.NetCheckTargets, CheckCerts: true, Log: log} // 확인 전 신규 도메인이 검증되면 라우트를 다시 보내 ACME 보류(on-demand)를 풀고 관리 정책으로 옮긴다(원격 노드 포함). @@ -480,7 +523,7 @@ func runWithContext(sigCtx context.Context, stopSignals context.CancelFunc, log }() } srv.SetNetCheck(domainChecker) - bg.Go(func() { domainChecker.Run(ctx) }) + goLoop(&bg, ctx, log, "domain-check", func() { domainChecker.Run(ctx) }) // 옛 Bearer 비밀키 agent는 기본 허용(첫 인증서 연결 때 자동 업그레이드). srv.CA = agentCA srv.DisableLegacyAgents = compat.Env("AGENT_LEGACY_BEARER") == "off" @@ -499,9 +542,9 @@ func runWithContext(sigCtx context.Context, stopSignals context.CancelFunc, log } } handler := srv.Handler() - bg.Go(func() { srv.SandboxLoop(ctx) }) + goLoop(&bg, ctx, log, "sandboxes", func() { srv.SandboxLoop(ctx) }) if runtime.GOOS != "windows" { - bg.Go(func() { srv.SelfUpdateLoop(ctx) }) // aven-server(self 서비스) 저장소의 새 커밋 확인(30분마다) + goLoop(&bg, ctx, log, "self-update", func() { srv.SelfUpdateLoop(ctx) }) // aven-server(self 서비스) 저장소의 새 커밋 확인(30분마다) } hs := &http.Server{Handler: handler, ReadHeaderTimeout: 10 * time.Second, BaseContext: baseCtx} // OTLP 수신기(OTLP/HTTP + h2c OTLP/gRPC). 컨테이너가 host-gateway로 닿아야 하므로 기본은 모든 주소다. @@ -563,7 +606,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,13 +851,37 @@ 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 } 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) + }) +} + +// tcpAllowDefaultOrNil is tcpAllowDefault after checkEnv has validated it. +func tcpAllowDefaultOrNil() []string { + l, _ := tcpAllowDefault() + return l +} + +// mustSessionMaxAge is sessionMaxAge after checkEnv has validated it. +func mustSessionMaxAge() time.Duration { + d, _ := sessionMaxAge() + return d +} diff --git a/cmd/aven-server/main_test.go b/cmd/aven-server/main_test.go index 3b78f26..330b6e6 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" @@ -28,9 +29,14 @@ func TestCheckEnv(t *testing.T) { {"bad proxies", map[string]string{"KEEL_ADMIN_PASSWORD": "long-enough", "KEEL_TRUSTED_PROXIES": "10.0.0.0/8,nope"}, ":7070", "KEEL_TRUSTED_PROXIES"}, {"new name", map[string]string{"AVEN_ADMIN_PASSWORD": "long-enough"}, "[::1]:7070", ""}, {"new name short", map[string]string{"AVEN_ADMIN_PASSWORD": "short"}, "[::1]:7070", "AVEN_ADMIN_PASSWORD"}, + {"tcp allowlist", map[string]string{"AVEN_ADMIN_PASSWORD": "long-enough", "AVEN_TCP_PORT_ALLOW_CIDRS": "10.0.0.0/8, 203.0.113.7"}, "[::1]:7070", ""}, + {"session max age days", map[string]string{"AVEN_ADMIN_PASSWORD": "long-enough", "AVEN_SESSION_MAX_AGE": "30d"}, "[::1]:7070", ""}, + {"session max age off", map[string]string{"AVEN_ADMIN_PASSWORD": "long-enough", "AVEN_SESSION_MAX_AGE": "off"}, "[::1]:7070", ""}, + {"bad session max age", map[string]string{"AVEN_ADMIN_PASSWORD": "long-enough", "AVEN_SESSION_MAX_AGE": "5m"}, "[::1]:7070", "SESSION_MAX_AGE"}, + {"bad tcp allowlist", map[string]string{"AVEN_ADMIN_PASSWORD": "long-enough", "AVEN_TCP_PORT_ALLOW_CIDRS": "10.0.0.0/8,nope"}, "[::1]:7070", "TCP_PORT_ALLOW_CIDRS"}, } { t.Run(tc.name, func(t *testing.T) { - for _, k := range []string{"AVEN_ADMIN_PASSWORD", "AVEN_TLS_CERT", "AVEN_TLS_KEY", "AVEN_TRUSTED_PROXIES", "KEEL_ADMIN_PASSWORD", "KEEL_TLS_CERT", "KEEL_TLS_KEY", "KEEL_TRUSTED_PROXIES"} { + for _, k := range []string{"AVEN_ADMIN_PASSWORD", "AVEN_TLS_CERT", "AVEN_TLS_KEY", "AVEN_TRUSTED_PROXIES", "KEEL_ADMIN_PASSWORD", "KEEL_TLS_CERT", "KEEL_TLS_KEY", "KEEL_TRUSTED_PROXIES", "AVEN_TCP_PORT_ALLOW_CIDRS", "KEEL_TCP_PORT_ALLOW_CIDRS", "AVEN_SESSION_MAX_AGE", "KEEL_SESSION_MAX_AGE"} { t.Setenv(k, tc.env[k]) } err := checkEnv(tc.listen) @@ -125,3 +131,43 @@ 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) + } + }) + } +} + +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) + } +} diff --git a/deploy/aven.env.example b/deploy/aven.env.example index 3b65e4b..d4e6f8e 100644 --- a/deploy/aven.env.example +++ b/deploy/aven.env.example @@ -29,6 +29,9 @@ AVEN_PUBLIC_IPV6= # TCP 프록시 접속 호스트(AVEN_TCP_PROXY_DOMAIN과 AVEN_TCP_PROXY_PORT 변수, 화면의 host:port). 비우면 노드 tcp_hostname → 기본 도메인/주소 순서다. # TCP 프록시 포트는 Docker publish라 ufw를 거치지 않는다. 막으려면 DOCKER-USER에 규칙을 넣는다. # AVEN_TCP_PROXY_DOMAIN=aven.example.com +# 자체 허용 목록이 없는 TCP 프록시에 적용할 기본 허용 IP(IPv4 주소/CIDR, 쉼표나 공백으로 구분). 비우면 모든 IP가 접속한다. +# 서비스마다 네트워킹 설정에서 따로 정할 수 있고, 서비스에 0.0.0.0/0을 넣으면 이 기본값을 따르지 않는다. +# AVEN_TCP_PORT_ALLOW_CIDRS=203.0.113.7,10.0.0.0/8 # 노드 간 사설망(mesh)의 이 노드 주소. 노드 간 통신을 쓸 때 실제 사설 IP로 설정한다. AVEN_PRIVATE_ADDRESS= AVEN_MESH=auto @@ -49,3 +52,20 @@ 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 +# Absolute lifetime of a dashboard login session, however often it is used (default 90d; sessions also end after +# 30 idle days). A Go duration (2160h), a number of days (30d), or off for no cap. +# AVEN_SESSION_MAX_AGE=90d +# 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 diff --git a/docs/api-v1.md b/docs/api-v1.md index 4419293..4c38843 100644 --- a/docs/api-v1.md +++ b/docs/api-v1.md @@ -1775,11 +1775,15 @@ Settings > Networking provides domain status and ownership checks, per-node ingr - New domains on a `cloudflare_tunnel` node are created with `tls: "off", trust_forwarded_proto: true` explicitly (the tunnel terminates TLS; otherwise it loops on 308). Existing domains are not changed. ### TCP proxy -- `GET …/tcp-proxy` → `{enabled, host, public_port, container_port, available, reason?}`. `host` is, for the local node, `AVEN_TCP_PROXY_DOMAIN` → node `tcp_hostname` → the previous default; for a remote node, its `tcp_hostname` → its public address (IPv6 first). +- `GET …/tcp-proxy` → `{enabled, host, public_port, container_port, available, reason?, allow_cidrs, effective_allow_cidrs, default_allow_cidrs}`. `host` is, for the local node, `AVEN_TCP_PROXY_DOMAIN` → node `tcp_hostname` → the previous default; for a remote node, its `tcp_hostname` → its public address (IPv6 first). - `POST …/tcp-proxy` `{container_port}` opens a proxy (or changes the port of an existing one). On a node without a public address (Cloudflare tunnel node, or no address) opening a new proxy returns **`409` "이 노드는 공개 IP가 없어 TCP 프록시를 열 수 없습니다"**; `available` is `false` with the same `reason`. An existing proxy can still be retargeted. -- `PATCH …/tcp-proxy` `{container_port}` changes only the container port; the public port and host stay the same. `404` if no proxy is enabled. +- `PATCH …/tcp-proxy` `{container_port?, allow_cidrs?}` changes the container port and/or the client allowlist; the public port and host stay the same. At least one field is required (`400`). `404` if no proxy is enabled. `POST` also accepts `allow_cidrs`; a `POST` without it keeps the existing list. - Public ports come from `AVEN_TCP_PORT_MIN..MAX` (20000-29999) and are unique across all nodes. For a remote node the server asks the agent whether the candidate is free (agent op `port.free`, at most 64 candidates); agents that do not know the op get the first unused candidate as before. -- **Firewall caveat**: the proxy is a published Docker port, and Docker's publish rules bypass ufw. A proxy port is reachable from the whole internet (IPv4 and IPv6) regardless of ufw. Restrict it with `DOCKER-USER` rules if needed; the optional `AVEN_TCP_PORT_ALLOW_CIDRS` from the design is not implemented. +- **Firewall caveat**: the proxy is a published Docker port, and Docker's publish rules bypass ufw. Without an allowlist a proxy port is reachable from the whole internet regardless of ufw. +- **Client allowlist**: `allow_cidrs` is a list of IPv4 addresses or CIDRs (at most 32). The server normalizes it (a bare address becomes `/32`, host bits are cleared, duplicates are dropped, entries are sorted). `[]` means "use the server default" `AVEN_TCP_PORT_ALLOW_CIDRS`, and any list containing `0.0.0.0/0` becomes `["0.0.0.0/0"]`, which admits everyone even when a default is set. `effective_allow_cidrs` is what the proxy enforces (`[]` = everyone). IPv6 entries are refused with `400`: the forwarder listens on IPv4 and cannot see IPv6 client addresses, so while a list is in force only IPv4 clients in it can connect. + - Enforcement: the socat forwarder checks each accepted connection (`SOCAT_PEERADDR`) against the list and closes connections from other addresses, logging `aven: refused : not in the TCP proxy allowlist` in the forwarder container. Without a list the forwarder is unchanged from earlier versions. + - Remote nodes receive the list in `tcpproxy.ensure` (`allow_cidrs`), and the agent validates it again. An agent built before this field refuses it; the API then answers `409` saying the agent must be upgraded, and the proxy is not reopened without the list. The setting stays saved and applies once the agent is upgraded. + - Changes emit `tcp_proxy.allowlist_updated` and an audit entry. Duplicating an environment and moving a proxy between HA root and edge carry the list. ### Private DNS names - Inside an environment network a service always resolves as `` and `..keel.internal`; containers deployed by this version also answer to `..aven.internal`. `private_domain` and the injected `AVEN_PRIVATE_DOMAIN` / `KEEL_PRIVATE_DOMAIN` stay on `.keel.internal`, because containers that have not been redeployed since the rename, forwarders on older agents and stored HA replica-set members only know that name. `PATCH /services/{sid}/instances/{eid}` `{private_dns_name: ""}` adds ``, `..keel.internal` and `..aven.internal`. It is a staged change (`pending_changes` contains `private_dns_name`) that applies on the next deploy; `""` or the service name resets it. `private_domain` and the injected `AVEN_PRIVATE_DOMAIN` use the custom name. Cross-node mesh imports carry the extra aliases (older agents get them without aliases). 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 diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 7c25d17..19f6efa 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -15,10 +15,12 @@ import ( "errors" "fmt" "io" + "log/slog" "os" "path/filepath" "regexp" "runtime" + "runtime/debug" "slices" "strconv" "strings" @@ -307,6 +309,18 @@ func (h *Handler) workspace(id string) (string, error) { } // Handle은 요청 하나를 처리한다. stream은 중간 데이터를 서버로 보낸다. +// handleRecovering is Handle, but a panic in one operation becomes that operation's error instead of +// stopping the agent and with it every other request, the wake handler and the proxy on this node. +func (h *Handler) handleRecovering(ctx context.Context, op string, payload []byte, stream func(any)) (res any, err error) { + defer func() { + if v := recover(); v != nil { + slog.Error("agent operation panicked", "op", op, "panic", v, "stack", string(debug.Stack())) + res, err = nil, fmt.Errorf("agent operation %s failed with an internal error (see the agent log)", op) + } + }() + return h.Handle(ctx, op, payload, stream) +} + func (h *Handler) Handle(ctx context.Context, op string, payload []byte, stream func(any)) (any, error) { if len(payload) == 0 { payload = []byte("{}") @@ -1095,7 +1109,7 @@ func (h *Handler) Serve(ctx context.Context, read func(context.Context) ([]byte, c() } } - result, err := h.Handle(rctx, req.Operation, req.Payload, stream) + result, err := h.handleRecovering(rctx, req.Operation, req.Payload, stream) answer := agentprotocol.Message{ID: req.ID, Operation: req.Operation} if err != nil { answer.Error = err.Error() diff --git a/internal/agent/extra.go b/internal/agent/extra.go index c4aeebe..b277940 100644 --- a/internal/agent/extra.go +++ b/internal/agent/extra.go @@ -8,11 +8,11 @@ import ( "io" "net" "net/http" - "net/http/httputil" "net/url" "os" "path/filepath" "regexp" + "slices" "strconv" "strings" "sync" @@ -67,6 +67,13 @@ func ValidateTCPProxy(r agentprotocol.TCPProxyRequest) error { if r.ContainerPort < 1 || r.ContainerPort > 65535 { return errors.New("container port must be 1..65535") } + allow, err := node.NormalizeTCPAllowCIDRs(r.AllowCIDRs) + if err != nil { + return fmt.Errorf("allow_cidrs: %w", err) + } + if !slices.Equal(allow, r.AllowCIDRs) && len(r.AllowCIDRs) > 0 { + return errors.New("allow_cidrs must be normalized IPv4 CIDRs") + } return nil } @@ -93,7 +100,8 @@ func (h *Handler) tcpEnsure(ctx context.Context, r agentprotocol.TCPProxyRequest return err } next, prev := tcpName(r.InstanceID) - sig := fmt.Sprintf("%d:%d:%s", r.PublicPort, r.ContainerPort, r.Target) + fwd := node.TCPForward{Port: r.ContainerPort, Target: r.Target, Allow: r.AllowCIDRs} + sig := fwd.Signature(r.PublicPort) states, err := h.Docker.InspectMany(ctx, []string{next, prev}) if err != nil { return err @@ -123,8 +131,8 @@ func (h *Handler) tcpEnsure(ctx context.Context, r agentprotocol.TCPProxyRequest Labels: map[string]string{node.LabelManaged: "true", node.LabelRole: node.RoleTCPProxy, node.LabelInstance: r.InstanceID, node.LabelEnv: r.EnvID, node.LabelProject: r.ProjectID, "aven.tcp": sig, "keel.tcp": sig}, Publish: []string{h.tcpPublish(r.PublicPort, r.ContainerPort)}, - Cmd: []string{fmt.Sprintf("TCP-LISTEN:%d,fork,reuseaddr", r.ContainerPort), - fmt.Sprintf("TCP:%s:%d", r.Target, r.ContainerPort)}, + Env: fwd.Env(), + Cmd: fwd.Cmd(), }) } @@ -588,13 +596,7 @@ func (h *Handler) WakeHandler() http.Handler { http.Error(w, "no running replica", http.StatusBadGateway) return } - rp := httputil.NewSingleHostReverseProxy(&url.URL{Scheme: "http", Host: net.JoinHostPort(ips[0], strconv.Itoa(res.Port))}) - orig := rp.Director - rp.Director = func(req *http.Request) { - orig(req) - req.Host = r.Host - } - rp.ServeHTTP(w, r) + proxy.WakeReverseProxy(&url.URL{Scheme: "http", Host: net.JoinHostPort(ips[0], strconv.Itoa(res.Port))}).ServeHTTP(w, r) }) } diff --git a/internal/agent/extra_test.go b/internal/agent/extra_test.go index 7c6752c..4716112 100644 --- a/internal/agent/extra_test.go +++ b/internal/agent/extra_test.go @@ -671,3 +671,32 @@ func TestWakeCoalescesConcurrentRequestsPerHost(t *testing.T) { t.Fatalf("%d wake flights left", left) } } + +func TestTCPProxyEnsureWithAllowlist(t *testing.T) { + f := &extraDocker{} + h := &Handler{Docker: f, Proxy: &fakeProxy{}} + r := validTCP() + r.AllowCIDRs = []string{"10.0.0.0/8", "203.0.113.7/32"} + if _, err := handle(t, h, agentprotocol.OpTCPEnsure, r); err != nil { + t.Fatal(err) + } + s := f.ran[0] + if s.Env["AVEN_TCP_ALLOW"] != "10.0.0.0/8 203.0.113.7/32" || s.Env["AVEN_TCP_TARGET"] != "web.production.keel.internal:5432" || + len(s.Cmd) != 2 || !strings.HasPrefix(s.Cmd[1], "SYSTEM:") { + t.Fatalf("spec: env %v cmd %v", s.Env, s.Cmd) + } + if !strings.Contains(s.Labels["aven.tcp"], ":allow=10.0.0.0/8,203.0.113.7/32") { + t.Fatalf("signature label %q does not carry the list", s.Labels["aven.tcp"]) + } + // The agent re-validates: only normalized IPv4 CIDRs, never shell text or IPv6. + for _, bad := range [][]string{{"10.0.0.1/8"}, {"2001:db8::/32"}, {"10.0.0.0/8; reboot"}, {"$(id)"}} { + r := validTCP() + r.AllowCIDRs = bad + if _, err := handle(t, h, agentprotocol.OpTCPEnsure, r); err == nil { + t.Errorf("%v accepted", bad) + } + } + if len(f.ran) != 1 { + t.Fatal("container started for a rejected allowlist") + } +} diff --git a/internal/agent/recover_test.go b/internal/agent/recover_test.go new file mode 100644 index 0000000..1637776 --- /dev/null +++ b/internal/agent/recover_test.go @@ -0,0 +1,21 @@ +package agent + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/turin-dev/aven/internal/agentprotocol" +) + +// A panic inside one operation (here: a Handler without a Docker client) is returned as that +// operation's error instead of crashing the agent. +func TestHandleRecoveringTurnsPanicIntoError(t *testing.T) { + h := &Handler{} + payload, _ := json.Marshal(agentprotocol.TCPRemoveRequest{InstanceID: "ins_abcdefgh"}) + res, err := h.handleRecovering(context.Background(), agentprotocol.OpTCPRemove, payload, func(any) {}) + if err == nil || !strings.Contains(err.Error(), "internal error") || res != nil { + t.Fatalf("got %v, %v; want an internal-error result", res, err) + } +} diff --git a/internal/agentprotocol/protocol.go b/internal/agentprotocol/protocol.go index a0aa967..da1e314 100644 --- a/internal/agentprotocol/protocol.go +++ b/internal/agentprotocol/protocol.go @@ -365,6 +365,9 @@ type TCPProxyRequest struct { Target string `json:"target"` // 환경 네트워크 안의 서비스 alias(..keel.internal) PublicPort int `json:"public_port"` ContainerPort int `json:"container_port"` + // AllowCIDRs is the effective client allowlist (normalized IPv4 CIDRs); empty admits everyone. + // omitempty: agents older than this field refuse it, so a proxy without a list still works with them. + AllowCIDRs []string `json:"allow_cidrs,omitempty"` } type TCPRemoveRequest struct { diff --git a/internal/alerts/alerts_test.go b/internal/alerts/alerts_test.go index 00310c7..65bbfb6 100644 --- a/internal/alerts/alerts_test.go +++ b/internal/alerts/alerts_test.go @@ -144,7 +144,9 @@ func TestSourcesValues(t *testing.T) { if err := st.CreateVolume(ctx, vol); err != nil { t.Fatal(err) } - vol, _ = st.GetVolume(ctx, vol.ID) + if got, err := st.GetVolume(ctx, vol.ID); err != nil || got.SizeLimitMB == nil || *got.SizeLimitMB != limit { + t.Fatalf("volume: %+v %v", got, err) + } ts := func(d time.Duration) string { return now.Add(d).UTC().Format(time.RFC3339Nano) } logs := []proxy.AccessLog{{T: ts(-10 * time.Minute), Status: 500, DurationMS: 9999}} for i := 1; i <= 20; i++ { diff --git a/internal/api/api.go b/internal/api/api.go index e1d4a34..c6b7583 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -38,6 +38,9 @@ type Server struct { APIToken string // 비어 있으면 Bearer 인증을 쓰지 않음 AllowFileRepo bool // 개발용: file:// 저장소 허용 SessionKey []byte + // SessionMaxAge is the absolute lifetime of a login session (AVEN_SESSION_MAX_AGE). 0 uses + // DefaultSessionMaxAge; a negative value removes the cap (sessions then only expire when idle). + SessionMaxAge time.Duration SecureCookie bool Web fs.FS Version string diff --git a/internal/api/auth.go b/internal/api/auth.go index 33224cc..6993a78 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -53,7 +53,10 @@ func hasAPITokenPrefix(tok string) bool { const ( sessionTTL = 30 * 24 * time.Hour sessionTouch = time.Minute - tokenTouch = time.Minute + // DefaultSessionMaxAge caps how long a session lives after login however often it is used + // (sessionTTL alone slides forever, so a stolen cookie that keeps being used never expires). + DefaultSessionMaxAge = 90 * 24 * time.Hour + tokenTouch = time.Minute ) // ctEqual은 길이가 달라도 시간이 새지 않게 SHA-256 다이제스트끼리 상수 시간으로 비교한다. @@ -177,35 +180,60 @@ func (s *Server) bearerPrincipal(ctx context.Context, tok string) *principal { return &principal{User: u, Via: viaToken, Token: t} } -// sessionPrincipal은 세션 쿠키를 검사한다. refresh가 true면 쿠키 만료도 늦춰서 다시 보내야 한다. -func (s *Server) sessionPrincipal(ctx context.Context, value string) (p *principal, refresh bool) { +// sessionMaxAge is the absolute session lifetime; 0 or less means no cap. +func (s *Server) sessionMaxAge() time.Duration { + if s.SessionMaxAge == 0 { + return DefaultSessionMaxAge + } + return max(s.SessionMaxAge, 0) +} + +// sessionExpiry is the sliding expiry (now + sessionTTL), capped at created + the absolute lifetime. +func (s *Server) sessionExpiry(created, now time.Time) time.Time { + exp := now.Add(sessionTTL) + if m := s.sessionMaxAge(); m > 0 { + if limit := created.Add(m); limit.Before(exp) { + exp = limit + } + } + return exp +} + +// sessionPrincipal은 세션 쿠키를 검사한다. refreshUntil이 0이 아니면 쿠키 만료도 그 시각으로 늦춰서 다시 보내야 한다. +func (s *Server) sessionPrincipal(ctx context.Context, value string) (p *principal, refreshUntil time.Time) { if s.Store == nil || value == "" || len(value) > 200 { - return nil, false + return nil, time.Time{} } se, err := s.Store.GetSessionByHash(ctx, hashSecret(value)) if err != nil { - return nil, false + return nil, time.Time{} + } + now := time.Now() + if m := s.sessionMaxAge(); m > 0 && now.Sub(se.CreatedAt) > m { + // Sessions created before the cap existed can carry a later expires_at; end them here. + s.Store.DeleteSessionByHash(context.WithoutCancel(ctx), hashSecret(value)) + return nil, time.Time{} } u, err := s.Store.GetUser(ctx, se.UserID) if err != nil || u.Disabled { - return nil, false + return nil, time.Time{} } if u.Builtin { // 기본 계정 세션은 만들 때의 KEEL_ADMIN_PASSWORD로만 유효하다(환경변수로 비밀번호를 바꾸면 모두 끊긴다). if s.AdminPassword == "" || !hmac.Equal([]byte(se.AuthTag), []byte(s.adminTag())) { - return nil, false + return nil, time.Time{} } u.Role = store.RoleOwner } - if now := time.Now(); now.Sub(se.LastSeen) > sessionTouch { - s.Store.TouchSession(context.WithoutCancel(ctx), se.ID, now, now.Add(sessionTTL)) - refresh = true + if now.Sub(se.LastSeen) > sessionTouch { + refreshUntil = s.sessionExpiry(se.CreatedAt, now) + s.Store.TouchSession(context.WithoutCancel(ctx), se.ID, now, refreshUntil) } p = &principal{User: u, Via: viaSession, SessionID: se.ID, SAML: se.SAMLAuthenticated} if vp := s.viewAsPrincipal(ctx, p); vp != nil { - return vp, refresh + return vp, refreshUntil } - return p, refresh + return p, refreshUntil } func (s *Server) requireAuth(h http.Handler) http.Handler { @@ -224,7 +252,7 @@ func (s *Server) requireAuth(h http.Handler) http.Handler { s.lim().agentIP.fail(key) } if c, ok := sessionCookieFrom(r); ok { - if p, refresh := s.sessionPrincipal(r.Context(), c.Value); p != nil { + if p, refreshUntil := s.sessionPrincipal(r.Context(), c.Value); p != nil { if p.ViewAsBy != nil { if !viewAsAllowed(r) { writeJSON(w, 403, map[string]any{"error": "사용자로 보기 중에는 읽기만 할 수 있습니다. 보기를 끝낸 뒤 다시 시도하세요", "view_as": true}) @@ -238,9 +266,8 @@ func (s *Server) requireAuth(h http.Handler) http.Handler { writeErr(w, 403, "cross-origin request rejected") return } - if refresh { - exp := time.Now().Add(sessionTTL) - http.SetCookie(w, s.sessionCookie(r, c.Value, exp, 0)) + if !refreshUntil.IsZero() { + http.SetCookie(w, s.sessionCookie(r, c.Value, refreshUntil, 0)) } h.ServeHTTP(w, withPrincipal(r, p)) return @@ -289,7 +316,8 @@ func (s *Server) startSessionWithMethod(ctx context.Context, w http.ResponseWrit return errMaintenanceLogin } tok := randomToken(32) - exp := time.Now().Add(sessionTTL) + now := time.Now() + exp := s.sessionExpiry(now, now) se := &store.Session{UserID: u.ID, ExpiresAt: exp, IP: limitKey(s.clientIP(r)), UserAgent: truncate(r.UserAgent(), 200), SAMLAuthenticated: samlAuthenticated} if u.Builtin { se.AuthTag = s.adminTag() 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/api/graphql_test.go b/internal/api/graphql_test.go index debe48b..ce60a63 100644 --- a/internal/api/graphql_test.go +++ b/internal/api/graphql_test.go @@ -270,6 +270,9 @@ func TestGraphQLMutations(t *testing.T) { t.Fatalf("project volumes / environments: %v", data) } data = mustGQL(t, f.h, f.owner, `mutation($vid: String!) { volumeInstanceUpdate(volumeId: $vid, input: {serviceId: ""}) }`, map[string]any{"vid": volID}) + if data["volumeInstanceUpdate"] != true { + t.Fatalf("volumeInstanceUpdate: %v", data) + } if v, err := f.st.GetVolume(ctx, volID); err != nil || v.InstanceID != nil { t.Fatalf("volume not detached: %+v %v", v, err) } @@ -528,6 +531,9 @@ func TestGraphQLRESTMapping(t *testing.T) { t.Fatalf("deploymentRestart calls: %+v", calls) } data = run(`mutation { serviceInstanceRedeploy(serviceId: "svc_1", environmentId: "env_1") }`) + if data["serviceInstanceRedeploy"] != true { + t.Fatalf("serviceInstanceRedeploy: %v", data) + } if len(calls) != 2 || calls[0].path != "/api/services/svc_1/instances/env_1/deployments?limit=1" || calls[1].path != "/api/deployments/dep_9/redeploy" { t.Fatalf("serviceInstanceRedeploy calls: %+v", calls) } diff --git a/internal/api/ha.go b/internal/api/ha.go index 0c4e437..ccd77bb 100644 --- a/internal/api/ha.go +++ b/internal/api/ha.go @@ -789,7 +789,7 @@ func (s *Server) haCreate(ctx context.Context, c *instCtx, plan *haPlan, counts if err != nil { s.Log.Warn("ha: tcp proxy for edge", "err", err) } else { - edge.TCPProxy = &store.TCPProxy{ContainerPort: edge.Spec.EffectivePort(edge.Variables), PublicPort: port} + edge.TCPProxy = &store.TCPProxy{ContainerPort: edge.Spec.EffectivePort(edge.Variables), PublicPort: port, AllowCIDRs: rootIn.TCPProxy.AllowCIDRs} rootIn.TCPProxy = nil saved.TCPProxyMoved = true } @@ -1293,7 +1293,7 @@ func (s *Server) revertHA(w http.ResponseWriter, r *http.Request) { } if hc.insts[edgeID].TCPProxy != nil && rootIn.TCPProxy == nil { if port, err := s.Deploy.AllocateTCPPortFor(ctx, s.Deploy.HomeNode(ctx, rootIn)); err == nil { - rootIn.TCPProxy = &store.TCPProxy{ContainerPort: rootIn.Spec.EffectivePort(rootIn.Variables), PublicPort: port} + rootIn.TCPProxy = &store.TCPProxy{ContainerPort: rootIn.Spec.EffectivePort(rootIn.Variables), PublicPort: port, AllowCIDRs: hc.insts[edgeID].TCPProxy.AllowCIDRs} } else { s.Log.Warn("ha: tcp proxy for root", "err", err) } diff --git a/internal/api/ipv6_test.go b/internal/api/ipv6_test.go index 21fdfe1..cf76885 100644 --- a/internal/api/ipv6_test.go +++ b/internal/api/ipv6_test.go @@ -91,6 +91,9 @@ func TestPatchNodeIPv6Pool(t *testing.T) { } } code, out = call(store.LocalNodeID, `{"ipv6_pool":"2001:db8:6b:1943:e:1::/96"}`) + if code != 409 { + t.Fatalf("overlap: %d %v", code, out) + } if !strings.Contains(fmt.Sprint(out["error"]), "us") { t.Fatalf("overlap message: %v", out) } diff --git a/internal/api/networking.go b/internal/api/networking.go index 0b233f8..0f0fcad 100644 --- a/internal/api/networking.go +++ b/internal/api/networking.go @@ -66,6 +66,11 @@ type tcpProxyView struct { ContainerPort *int `json:"container_port"` Available bool `json:"available"` Reason string `json:"reason,omitempty"` + // AllowCIDRs is this proxy's own client allowlist ([] = use the default, ["0.0.0.0/0"] = everyone). + // EffectiveAllowCIDRs is what the proxy enforces ([] = everyone); DefaultAllowCIDRs is AVEN_TCP_PORT_ALLOW_CIDRS. + AllowCIDRs []string `json:"allow_cidrs"` + EffectiveAllowCIDRs []string `json:"effective_allow_cidrs"` + DefaultAllowCIDRs []string `json:"default_allow_cidrs"` } type privateView struct { @@ -451,10 +456,16 @@ func (s *Server) tcpProxyInfo(ctx context.Context, in *store.Instance) tcpProxyV if !v.Available { v.Reason = tcpNoPublicIPReason } + v.AllowCIDRs, v.EffectiveAllowCIDRs = []string{}, []string{} + v.DefaultAllowCIDRs = append([]string{}, s.Deploy.Cfg.TCPAllowDefault...) if t := in.TCPProxy; t != nil { v.Enabled = true pub, cp := t.PublicPort, t.ContainerPort v.PublicPort, v.ContainerPort = &pub, &cp + v.AllowCIDRs = append(v.AllowCIDRs, t.AllowCIDRs...) + if eff := s.Deploy.TCPAllowFor(t); !(len(eff) == 1 && eff[0] == node.TCPAllowAll) { + v.EffectiveAllowCIDRs = append(v.EffectiveAllowCIDRs, eff...) + } } return v } diff --git a/internal/api/networking_p3p6_test.go b/internal/api/networking_p3p6_test.go index cfe64f1..c2e0e7a 100644 --- a/internal/api/networking_p3p6_test.go +++ b/internal/api/networking_p3p6_test.go @@ -2,6 +2,7 @@ package api import ( "context" + "fmt" "io" "net/http" "net/http/httptest" @@ -235,3 +236,56 @@ func TestGraphQLTCPProxyMapping(t *testing.T) { t.Fatalf("serviceDomainUpdate rename: %s %+v", e, calls) } } + +func TestTCPProxyAllowlist(t *testing.T) { + f := newGHFixture(t) + fixtureProxy(t, f) + t.Setenv("KEEL_INGRESS_MODE", "") + f.mgr.Cfg.TCPPortMin, f.mgr.Cfg.TCPPortMax, f.mgr.Cfg.ProxyBind = 29900, 29999, "127.0.0.1" + f.mgr.Cfg.TCPAllowDefault = []string{"192.0.2.0/24"} + pv := map[string]string{"sid": f.db.ID, "eid": f.prod.ID} + + if code, out := f.call(f.srv.addTCPProxy, "POST", "/x", `{"container_port":5432,"allow_cidrs":["2001:db8::/32"]}`, pv); code != 400 { + t.Fatalf("ipv6 entry: %d %v", code, out) + } + // Without a list of its own the proxy enforces the server default. + if code, out := f.call(f.srv.addTCPProxy, "POST", "/x", `{"container_port":5432}`, pv); code != 200 { + t.Fatalf("enable: %d %v", code, out) + } + _, out := f.call(f.srv.getTCPProxy, "GET", "/x", "", pv) + if fmt.Sprint(out["allow_cidrs"]) != "[]" || fmt.Sprint(out["effective_allow_cidrs"]) != "[192.0.2.0/24]" || fmt.Sprint(out["default_allow_cidrs"]) != "[192.0.2.0/24]" { + t.Fatalf("default view: %v", out) + } + // PATCH with only allow_cidrs keeps the port; entries are normalized. + if code, out := f.call(f.srv.patchTCPProxy, "PATCH", "/x", `{"allow_cidrs":["10.1.2.3/8"," 203.0.113.7 "]}`, pv); code != 200 { + t.Fatalf("patch list: %d %v", code, out) + } + in, _ := f.st.GetInstanceFor(f.ctx, f.db.ID, f.prod.ID) + if in.TCPProxy.ContainerPort != 5432 || fmt.Sprint(in.TCPProxy.AllowCIDRs) != "[10.0.0.0/8 203.0.113.7/32]" { + t.Fatalf("stored: %+v", in.TCPProxy) + } + _, out = f.call(f.srv.getTCPProxy, "GET", "/x", "", pv) + if fmt.Sprint(out["effective_allow_cidrs"]) != "[10.0.0.0/8 203.0.113.7/32]" { + t.Fatalf("own list view: %v", out) + } + // 0.0.0.0/0 opts out of the default: nothing is enforced. + if code, _ := f.call(f.srv.patchTCPProxy, "PATCH", "/x", `{"allow_cidrs":["0.0.0.0/0"]}`, pv); code != 200 { + t.Fatal("allow all") + } + _, out = f.call(f.srv.getTCPProxy, "GET", "/x", "", pv) + if fmt.Sprint(out["allow_cidrs"]) != "[0.0.0.0/0]" || fmt.Sprint(out["effective_allow_cidrs"]) != "[]" { + t.Fatalf("allow-all view: %v", out) + } + // [] goes back to the default; a port-only POST does not touch the list. + f.call(f.srv.patchTCPProxy, "PATCH", "/x", `{"allow_cidrs":["10.0.0.0/8"]}`, pv) + f.call(f.srv.addTCPProxy, "POST", "/x", `{"container_port":6000}`, pv) + in, _ = f.st.GetInstanceFor(f.ctx, f.db.ID, f.prod.ID) + if in.TCPProxy.ContainerPort != 6000 || fmt.Sprint(in.TCPProxy.AllowCIDRs) != "[10.0.0.0/8]" { + t.Fatalf("port-only POST changed the list: %+v", in.TCPProxy) + } + f.call(f.srv.patchTCPProxy, "PATCH", "/x", `{"allow_cidrs":[]}`, pv) + in, _ = f.st.GetInstanceFor(f.ctx, f.db.ID, f.prod.ID) + if len(in.TCPProxy.AllowCIDRs) != 0 { + t.Fatalf("reset: %+v", in.TCPProxy) + } +} diff --git a/internal/api/oauth.go b/internal/api/oauth.go index c6f68ff..6c53b98 100644 --- a/internal/api/oauth.go +++ b/internal/api/oauth.go @@ -400,15 +400,15 @@ func (s *Server) oauthSession(w http.ResponseWriter, r *http.Request) *principal if !ok { return nil } - p, refresh := s.sessionPrincipal(r.Context(), c.Value) + p, refreshUntil := s.sessionPrincipal(r.Context(), c.Value) if p == nil { return nil } if p.ViewAsBy == nil && !s.samlSessionOK(r.Context(), p) { return nil } - if refresh { - http.SetCookie(w, s.sessionCookie(r, c.Value, time.Now().Add(sessionTTL), 0)) + if !refreshUntil.IsZero() { + http.SetCookie(w, s.sessionCookie(r, c.Value, refreshUntil, 0)) } return p } 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, ""), diff --git a/internal/api/projects.go b/internal/api/projects.go index 40ded61..a165fa8 100644 --- a/internal/api/projects.go +++ b/internal/api/projects.go @@ -480,7 +480,7 @@ func (s *Server) populateEnvironment(ctx context.Context, p *store.Project, env } if orig.TCPProxy != nil && !policies.RestrictTCPProxy { if port, err := s.Deploy.AllocateTCPPort(ctx); err == nil { - in.TCPProxy = &store.TCPProxy{ContainerPort: orig.TCPProxy.ContainerPort, PublicPort: port} + in.TCPProxy = &store.TCPProxy{ContainerPort: orig.TCPProxy.ContainerPort, PublicPort: port, AllowCIDRs: orig.TCPProxy.AllowCIDRs} } else { in.TCPProxy = nil } diff --git a/internal/api/session_maxage_test.go b/internal/api/session_maxage_test.go new file mode 100644 index 0000000..6c10921 --- /dev/null +++ b/internal/api/session_maxage_test.go @@ -0,0 +1,51 @@ +package api + +import ( + "context" + "io" + "log/slog" + "testing" + "time" + + "github.com/turin-dev/aven/internal/store" +) + +func TestSessionExpiryIsCappedByMaxAge(t *testing.T) { + s := &Server{} + now := time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC) + // A fresh session slides by sessionTTL. + if got := s.sessionExpiry(now, now); !got.Equal(now.Add(sessionTTL)) { + t.Errorf("fresh: %v", got) + } + // 80 days in, the 90-day cap is nearer than now+30d. + created := now.Add(-80 * 24 * time.Hour) + if got := s.sessionExpiry(created, now); !got.Equal(created.Add(DefaultSessionMaxAge)) { + t.Errorf("capped: %v, want %v", got, created.Add(DefaultSessionMaxAge)) + } + // A negative max age removes the cap. + s.SessionMaxAge = -1 + if got := s.sessionExpiry(created, now); !got.Equal(now.Add(sessionTTL)) { + t.Errorf("uncapped: %v", got) + } +} + +func TestSessionPastMaxAgeIsRejectedAndDeleted(t *testing.T) { + ctx := context.Background() + st := testStore(t) + s := &Server{Store: st, Log: slog.New(slog.NewTextHandler(io.Discard, nil))} + u := testUser(t, st, "member@example.com", store.RoleMember) + tok := testSessionCookie(t, s, u) + + s.SessionMaxAge = -1 // no cap: the session works + if p, _ := s.sessionPrincipal(ctx, tok); p == nil { + t.Fatal("session rejected without a cap") + } + s.SessionMaxAge = time.Nanosecond // older than the cap + time.Sleep(time.Millisecond) + if p, _ := s.sessionPrincipal(ctx, tok); p != nil { + t.Fatal("session past its max age was accepted") + } + if _, err := st.GetSessionByHash(ctx, hashSecret(tok)); err == nil { + t.Fatal("expired session was not deleted") + } +} diff --git a/internal/api/tcpproxy.go b/internal/api/tcpproxy.go index df24e78..e2bdb90 100644 --- a/internal/api/tcpproxy.go +++ b/internal/api/tcpproxy.go @@ -1,9 +1,13 @@ package api import ( + "errors" "fmt" "net/http" + "slices" + "strings" + "github.com/turin-dev/aven/internal/node" "github.com/turin-dev/aven/internal/store" ) @@ -20,7 +24,8 @@ func (s *Server) addTCPProxy(w http.ResponseWriter, r *http.Request) { } } var body struct { - ContainerPort int `json:"container_port"` + ContainerPort int `json:"container_port"` + AllowCIDRs *[]string `json:"allow_cidrs"` } if !decode(w, r, &body) { return @@ -29,6 +34,10 @@ func (s *Server) addTCPProxy(w http.ResponseWriter, r *http.Request) { writeErr(w, 400, "container_port must be 1..65535") return } + allow, ok := parseAllowCIDRs(w, body.AllowCIDRs) + if !ok { + return + } if c.in.TCPProxy != nil { c.in.TCPProxy.ContainerPort = body.ContainerPort } else { @@ -44,12 +53,20 @@ func (s *Server) addTCPProxy(w http.ResponseWriter, r *http.Request) { } c.in.TCPProxy = &store.TCPProxy{ContainerPort: body.ContainerPort, PublicPort: port} } + if body.AllowCIDRs != nil { + c.in.TCPProxy.AllowCIDRs = allow + } if err := s.Store.UpdateInstanceConfig(ctx, c.in); err != nil { s.storeErr(w, err) return } - if err := s.Deploy.SyncTCP(ctx, c.in.ID); err != nil { - s.Log.Warn("tcp proxy", "err", err) + syncErr := s.Deploy.SyncTCP(ctx, c.in.ID) + if syncErr != nil { + s.Log.Warn("tcp proxy", "err", syncErr) + if errors.Is(syncErr, node.ErrTCPAllowUnsupported) { + writeErr(w, 409, syncErr.Error()) + return + } } s.Events.Emit(ctx, c.p.ID, &c.env.ID, &c.svc.ID, "tcp_proxy.enabled", fmt.Sprintf("%s (%s): TCP proxy %s:%d → %d 활성화", c.svc.Name, c.env.Name, s.Deploy.TCPProxyHostFor(ctx, c.in), c.in.TCPProxy.PublicPort, c.in.TCPProxy.ContainerPort)) @@ -82,7 +99,8 @@ func (s *Server) patchTCPProxy(w http.ResponseWriter, r *http.Request) { return } var body struct { - ContainerPort int `json:"container_port"` + ContainerPort *int `json:"container_port"` + AllowCIDRs *[]string `json:"allow_cidrs"` } if !decode(w, r, &body) { return @@ -91,22 +109,46 @@ func (s *Server) patchTCPProxy(w http.ResponseWriter, r *http.Request) { writeErr(w, 404, "TCP proxy is not enabled") return } - if body.ContainerPort < 1 || body.ContainerPort > 65535 { + if body.ContainerPort == nil && body.AllowCIDRs == nil { + writeErr(w, 400, "nothing to change: send container_port and/or allow_cidrs") + return + } + if body.ContainerPort != nil && (*body.ContainerPort < 1 || *body.ContainerPort > 65535) { writeErr(w, 400, "container_port must be 1..65535") return } - prev := c.in.TCPProxy.ContainerPort - c.in.TCPProxy.ContainerPort = body.ContainerPort + allow, ok := parseAllowCIDRs(w, body.AllowCIDRs) + if !ok { + return + } + prev, prevAllow := c.in.TCPProxy.ContainerPort, c.in.TCPProxy.AllowCIDRs + if body.ContainerPort != nil { + c.in.TCPProxy.ContainerPort = *body.ContainerPort + } + if body.AllowCIDRs != nil { + c.in.TCPProxy.AllowCIDRs = allow + } if err := s.Store.UpdateInstanceConfig(ctx, c.in); err != nil { s.storeErr(w, err) return } - if err := s.Deploy.SyncTCP(ctx, c.in.ID); err != nil { - s.Log.Warn("tcp proxy", "err", err) + syncErr := s.Deploy.SyncTCP(ctx, c.in.ID) + if syncErr != nil { + s.Log.Warn("tcp proxy", "err", syncErr) } - if prev != body.ContainerPort { + if prev != c.in.TCPProxy.ContainerPort { s.Events.Emit(ctx, c.p.ID, &c.env.ID, &c.svc.ID, "tcp_proxy.updated", - fmt.Sprintf("%s (%s): TCP proxy 내부 포트 %d → %d", c.svc.Name, c.env.Name, prev, body.ContainerPort)) + fmt.Sprintf("%s (%s): TCP proxy 내부 포트 %d → %d", c.svc.Name, c.env.Name, prev, c.in.TCPProxy.ContainerPort)) + } + if !slices.Equal(prevAllow, c.in.TCPProxy.AllowCIDRs) { + s.Events.Emit(ctx, c.p.ID, &c.env.ID, &c.svc.ID, "tcp_proxy.allowlist_updated", + fmt.Sprintf("%s (%s): TCP proxy 허용 IP %s", c.svc.Name, c.env.Name, describeAllow(c.in.TCPProxy.AllowCIDRs))) + s.audit(r, "tcp_proxy.allowlist_updated", c.in.ID, fmt.Sprintf("%s (%s) TCP proxy 허용 IP: %s", c.svc.Name, c.env.Name, describeAllow(c.in.TCPProxy.AllowCIDRs))) + } + if syncErr != nil && errors.Is(syncErr, node.ErrTCPAllowUnsupported) { + // The setting is saved but the node cannot enforce it; say so instead of reporting success. + writeErr(w, 409, syncErr.Error()) + return } writeJSON(w, 200, s.viewInstance(r, c.svc, c.env, c.in)) } @@ -119,3 +161,27 @@ func (s *Server) getTCPProxy(w http.ResponseWriter, r *http.Request) { } writeJSON(w, 200, s.tcpProxyInfo(r.Context(), c.in)) } + +// parseAllowCIDRs validates an optional allow_cidrs body field. nil means "not sent". +func parseAllowCIDRs(w http.ResponseWriter, in *[]string) ([]string, bool) { + if in == nil { + return nil, true + } + allow, err := node.NormalizeTCPAllowCIDRs(*in) + if err != nil { + writeErr(w, 400, "allow_cidrs: "+err.Error()) + return nil, false + } + return allow, true +} + +func describeAllow(allow []string) string { + switch { + case len(allow) == 0: + return "서버 기본값" + case len(allow) == 1 && allow[0] == node.TCPAllowAll: + return "모두 허용" + default: + return strings.Join(allow, ", ") + } +} 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, "로그인 시도가 너무 많습니다") { diff --git a/internal/compose/compose.go b/internal/compose/compose.go index 4a2cb27..e8058a0 100644 --- a/internal/compose/compose.go +++ b/internal/compose/compose.go @@ -1152,7 +1152,7 @@ func parseHealthTest(test []string) (kind string, port int, path string) { } var ( - hostRuleRe = regexp.MustCompile("Host\\(([^)]*)\\)") + hostRuleRe = regexp.MustCompile(`Host\(([^)]*)\)`) btRe = regexp.MustCompile("`([^`]+)`") ) diff --git a/internal/dbquery/edit_test.go b/internal/dbquery/edit_test.go index 0b69762..776c18b 100644 --- a/internal/dbquery/edit_test.go +++ b/internal/dbquery/edit_test.go @@ -175,6 +175,7 @@ func TestTaggedCommands(t *testing.T) { if !strings.Contains(e, ".limit(11).maxTimeMS(25000).toArray()") { t.Errorf("mongo %s", e) } + //lint:ignore SA4000 two calls must return different tags if len(NewTag()) != 24 || !tagRe.MatchString(NewTag()) || NewTag() == NewTag() { t.Error("NewTag") } diff --git a/internal/deploy/compose_run.go b/internal/deploy/compose_run.go index a29f541..2ba84b4 100644 --- a/internal/deploy/compose_run.go +++ b/internal/deploy/compose_run.go @@ -12,9 +12,7 @@ func applyComposeRun(s *node.RunSpec, sp *store.Spec) { if c == nil { return } - for _, a := range c.Aliases { - s.Aliases = append(s.Aliases, a) - } + s.Aliases = append(s.Aliases, c.Aliases...) for k, v := range c.Labels { if _, taken := s.Labels[k]; !taken { s.Labels[k] = v 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 } 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/deploy/manager.go b/internal/deploy/manager.go index 8596e78..4e20da2 100644 --- a/internal/deploy/manager.go +++ b/internal/deploy/manager.go @@ -73,6 +73,9 @@ type Config struct { WakeSecret string // 로컬 TCP wake handler의 요청 인증 비밀(Windows host) TCPPortMin int TCPPortMax int + // TCPAllowDefault is the client allowlist of TCP proxies that have none of their own + // (AVEN_TCP_PORT_ALLOW_CIDRS, normalized). Empty admits everyone. + TCPAllowDefault []string // PrivateAddress는 로컬 노드의 사설 주소(KEEL_PRIVATE_ADDRESS, Tailscale/WireGuard)다. mesh gateway가 여기서만 listen한다. PrivateAddress string Mesh string // KEEL_MESH: on | off | auto(기본: 사설 IP가 있으면 켠다) diff --git a/internal/deploy/remote_test.go b/internal/deploy/remote_test.go index 4cfae2a..7a911e2 100644 --- a/internal/deploy/remote_test.go +++ b/internal/deploy/remote_test.go @@ -137,7 +137,6 @@ func TestRemoteImportAbortsWhenFinishFails(t *testing.T) { // memDriver는 볼륨 복사 테스트용 Driver다. type memDriver struct { Driver - name string vols map[string][]byte ensured []string removed []string diff --git a/internal/deploy/sleep.go b/internal/deploy/sleep.go index a96db51..5b25583 100644 --- a/internal/deploy/sleep.go +++ b/internal/deploy/sleep.go @@ -6,7 +6,6 @@ import ( "fmt" "net" "net/http" - "net/http/httputil" "net/url" "strings" "time" @@ -320,13 +319,7 @@ func (m *Manager) WakeHandler() http.Handler { http.Error(w, err.Error(), http.StatusBadGateway) return } - rp := httputil.NewSingleHostReverseProxy(target) - orig := rp.Director - rp.Director = func(req *http.Request) { - orig(req) - req.Host = r.Host - } - rp.ServeHTTP(w, r) + proxy.WakeReverseProxy(target).ServeHTTP(w, r) }) } diff --git a/internal/deploy/tcpproxy.go b/internal/deploy/tcpproxy.go index 3322dc9..e1ae73c 100644 --- a/internal/deploy/tcpproxy.go +++ b/internal/deploy/tcpproxy.go @@ -73,13 +73,18 @@ func (m *Manager) syncTCPOn(ctx context.Context, t *Target, in *store.Instance, return nil } target := PrivateDomain(t.Service.Name, t.Env.Name) - sig := fmt.Sprintf("%d:%d:%s", in.TCPProxy.PublicPort, in.TCPProxy.ContainerPort, target) + fwd := node.TCPForward{Port: in.TCPProxy.ContainerPort, Target: target, Allow: m.TCPAllowFor(in.TCPProxy)} + sig := fwd.Signature(in.TCPProxy.PublicPort) if !isLocal(nodeID) { if err := m.ensureEnvNetwork(ctx, t.Env, nodeID); err != nil { return err } - return (&remoteDriver{hub: m.Agents, node: nodeID}).tcpEnsure(ctx, agentprotocol.TCPProxyRequest{InstanceID: in.ID, ProjectID: t.Project.ID, EnvID: t.Env.ID, - Network: EnvNetwork(t.Env.ID), Target: target, PublicPort: in.TCPProxy.PublicPort, ContainerPort: in.TCPProxy.ContainerPort}) + req := agentprotocol.TCPProxyRequest{InstanceID: in.ID, ProjectID: t.Project.ID, EnvID: t.Env.ID, + Network: EnvNetwork(t.Env.ID), Target: target, PublicPort: in.TCPProxy.PublicPort, ContainerPort: in.TCPProxy.ContainerPort} + if fwd.Restricted() { + req.AllowCIDRs = fwd.Allow + } + return m.tcpEnsureRemote(ctx, nodeID, req) } got := "" if st != nil { @@ -110,8 +115,8 @@ func (m *Manager) syncTCPOn(ctx context.Context, t *Target, in *store.Instance, Labels: map[string]string{node.LabelManaged: "true", node.LabelRole: node.RoleTCPProxy, node.LabelInstance: in.ID, node.LabelEnv: t.Env.ID, node.LabelProject: t.Project.ID, "aven.tcp": sig, "keel.tcp": sig}, Publish: []string{pub}, - Cmd: []string{fmt.Sprintf("TCP-LISTEN:%d,fork,reuseaddr", in.TCPProxy.ContainerPort), - fmt.Sprintf("TCP:%s:%d", target, in.TCPProxy.ContainerPort)}, + Env: fwd.Env(), + Cmd: fwd.Cmd(), }) } @@ -266,6 +271,24 @@ func (m *Manager) tcpEndpointOn(ctx context.Context, nodeID string) (string, boo return v4, true } +// tcpEnsureRemote asks a remote agent to run the forwarder. An agent that predates allowlists refuses +// the allow_cidrs field; that is reported as ErrTCPAllowUnsupported, never retried as an open proxy. +func (m *Manager) tcpEnsureRemote(ctx context.Context, nodeID string, req agentprotocol.TCPProxyRequest) error { + err := (&remoteDriver{hub: m.Agents, node: nodeID}).tcpEnsure(ctx, req) + if err != nil && strings.Contains(err.Error(), `unknown field "allow_cidrs"`) { + return fmt.Errorf("node %s: %w", nodeID, node.ErrTCPAllowUnsupported) + } + return err +} + +// TCPAllowFor is the client allowlist a TCP proxy enforces: its own list, else the server default. +func (m *Manager) TCPAllowFor(p *store.TCPProxy) []string { + if p == nil { + return nil + } + return node.EffectiveTCPAllow(p.AllowCIDRs, m.Cfg.TCPAllowDefault) +} + // SyncTCP는 API에서 TCP proxy 설정이 바뀌었을 때 부른다. func (m *Manager) SyncTCP(ctx context.Context, instanceID string) error { t, err := m.Load(ctx, instanceID) diff --git a/internal/deploy/tcpproxy_test.go b/internal/deploy/tcpproxy_test.go index 76524e9..a13255c 100644 --- a/internal/deploy/tcpproxy_test.go +++ b/internal/deploy/tcpproxy_test.go @@ -3,12 +3,14 @@ package deploy import ( "context" "encoding/json" + "errors" "net" "path/filepath" "testing" "time" "github.com/turin-dev/aven/internal/agentprotocol" + "github.com/turin-dev/aven/internal/node" "github.com/turin-dev/aven/internal/store" ) @@ -156,3 +158,54 @@ func TestTCPProxyEndpoint(t *testing.T) { t.Setenv("KEEL_INGRESS_MODE", store.IngressCloudflareTunnel) check("local tunnel (env)", on(store.LocalNodeID), "", false) } + +// tcpHub records tcpproxy.ensure requests; old refuses allow_cidrs like an agent built before it. +type tcpHub struct { + old bool + reqs []agentprotocol.TCPProxyRequest +} + +func (h *tcpHub) Online(string) bool { return true } +func (h *tcpHub) Reply(context.Context, string, agentprotocol.Message) error { return nil } +func (h *tcpHub) CallStream(_ context.Context, _, op string, req, _ any, _ func(json.RawMessage)) error { + r := req.(agentprotocol.TCPProxyRequest) + if h.old && len(r.AllowCIDRs) > 0 { + return &agentErr{`invalid payload: json: unknown field "allow_cidrs"`} + } + h.reqs = append(h.reqs, r) + return nil +} + +func TestTCPAllowRemote(t *testing.T) { + m, _, _ := tcpFixture(t) + req := agentprotocol.TCPProxyRequest{InstanceID: "ins_abcd", AllowCIDRs: []string{"10.0.0.0/8"}} + + m.Agents = &tcpHub{old: true} + err := m.tcpEnsureRemote(context.Background(), "nod_x", req) + if !errors.Is(err, node.ErrTCPAllowUnsupported) { + t.Fatalf("old agent with a list: %v, want ErrTCPAllowUnsupported", err) + } + if got := m.Agents.(*tcpHub).reqs; len(got) != 0 { + t.Fatalf("an open proxy was requested as a fallback: %+v", got) + } + // An old agent still serves proxies without a list. + if err := m.tcpEnsureRemote(context.Background(), "nod_x", agentprotocol.TCPProxyRequest{InstanceID: "ins_abcd"}); err != nil { + t.Fatalf("old agent without a list: %v", err) + } + + hub := &tcpHub{} + m.Agents = hub + if err := m.tcpEnsureRemote(context.Background(), "nod_x", req); err != nil || len(hub.reqs) != 1 || hub.reqs[0].AllowCIDRs[0] != "10.0.0.0/8" { + t.Fatalf("new agent: %v %+v", err, hub.reqs) + } +} + +func TestTCPAllowForUsesDefault(t *testing.T) { + m := &Manager{Cfg: Config{TCPAllowDefault: []string{"192.0.2.0/24"}}} + if got := m.TCPAllowFor(&store.TCPProxy{}); len(got) != 1 || got[0] != "192.0.2.0/24" { + t.Errorf("default: %v", got) + } + if got := m.TCPAllowFor(&store.TCPProxy{AllowCIDRs: []string{node.TCPAllowAll}}); len(got) != 1 || got[0] != node.TCPAllowAll { + t.Errorf("explicit allow-all: %v", got) + } +} diff --git a/internal/dokploy/translate.go b/internal/dokploy/translate.go index 99a2239..03fbce7 100644 --- a/internal/dokploy/translate.go +++ b/internal/dokploy/translate.go @@ -141,9 +141,8 @@ type target struct { } type domainEntry struct { - svc string - d Domain - stack bool + svc string + d Domain } type translator struct { @@ -233,12 +232,11 @@ func Translate(s *Snapshot, o Options) *Plan { } type appItem struct { - p *Project - e *Environment - a *Application - sp *ServicePlan - pp *ProjectPlan - name string + p *Project + e *Environment + a *Application + sp *ServicePlan + pp *ProjectPlan } var items []*appItem var dbItems []*ServicePlan 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/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": diff --git a/internal/node/docker.go b/internal/node/docker.go index 28d1696..34cabac 100644 --- a/internal/node/docker.go +++ b/internal/node/docker.go @@ -508,9 +508,7 @@ func (d *Docker) RailpackBuild(ctx context.Context, railpack, frontend, srcDir, } } } - for _, k := range sortedKeys(vars) { - names = append(names, k) - } + names = append(names, sortedKeys(vars)...) p["secrets"] = dedupe(names) nb, _ := json.MarshalIndent(p, "", " ") os.WriteFile(plan, nb, 0o640) diff --git a/internal/node/tcpforward.go b/internal/node/tcpforward.go new file mode 100644 index 0000000..90bc264 --- /dev/null +++ b/internal/node/tcpforward.go @@ -0,0 +1,152 @@ +package node + +import ( + "errors" + "fmt" + "net/netip" + "strconv" + "strings" +) + +// MaxTCPAllowCIDRs bounds a TCP proxy allowlist. Each connection walks the whole list in a shell. +const MaxTCPAllowCIDRs = 32 + +// tcpAllowCheck runs (through socat SYSTEM) for every accepted connection when a TCP proxy has an +// allowlist. socat puts the client address in SOCAT_PEERADDR; the forwarder listens on IPv4 only, so +// it is a dotted IPv4 address. The script contains no commas, which socat would read as option +// separators, and gets its data from the environment, never from interpolation. +const tcpAllowCheck = `toint() { local IFS=.; set -- $1; echo $(( ($1 << 24) + ($2 << 16) + ($3 << 8) + $4 )); } +p=$(toint "${SOCAT_PEERADDR#::ffff:}") || exit 1 +for c in $AVEN_TCP_ALLOW; do + b=${c#*/} + m=$(( b == 0 ? 0 : (0xFFFFFFFF << (32 - b)) & 0xFFFFFFFF )) + if [ $(( p & m )) -eq $(( $(toint "${c%/*}") & m )) ]; then exec socat - "TCP:$AVEN_TCP_TARGET"; fi +done +echo "aven: refused $SOCAT_PEERADDR: not in the TCP proxy allowlist" >&2 +exit 1` + +// TCPAllowAll is the allowlist entry that admits every client. A list containing it normalizes to +// exactly [TCPAllowAll], so a service can opt out of the server-wide default list. +const TCPAllowAll = "0.0.0.0/0" + +// NormalizeTCPAllowCIDRs validates an allowlist and returns it as masked IPv4 prefixes, sorted and +// without duplicates. A bare address is a /32. An empty result means "no list" (the default applies), +// and a list that contains 0.0.0.0/0 becomes [TCPAllowAll]. +// +// Only IPv4 is accepted: the forwarder sees IPv4 peers only (IPv6 clients that Docker proxies arrive +// from the bridge gateway), so an IPv6 entry could never match. +func NormalizeTCPAllowCIDRs(in []string) ([]string, error) { + seen := map[string]bool{} + var out []string + for _, raw := range in { + s := strings.TrimSpace(raw) + if s == "" { + continue + } + var p netip.Prefix + var err error + if strings.Contains(s, "/") { + p, err = netip.ParsePrefix(s) + } else { + var a netip.Addr + a, err = netip.ParseAddr(s) + if err == nil { + p = netip.PrefixFrom(a, a.BitLen()) + } + } + if err != nil { + return nil, fmt.Errorf("%q is not an IP address or CIDR range", s) + } + if a := p.Addr(); a.Is4In6() { + p = netip.PrefixFrom(a.Unmap(), max(p.Bits()-96, 0)) + } + if !p.Addr().Is4() { + return nil, fmt.Errorf("%q: only IPv4 ranges are supported (the TCP proxy cannot see IPv6 client addresses)", s) + } + p = p.Masked() + if p.Bits() == 0 { + return []string{TCPAllowAll}, nil + } + if k := p.String(); !seen[k] { + seen[k] = true + out = append(out, k) + } + } + if len(out) > MaxTCPAllowCIDRs { + return nil, fmt.Errorf("at most %d ranges are allowed", MaxTCPAllowCIDRs) + } + sortPrefixes(out) + return out, nil +} + +func sortPrefixes(ps []string) { + key := func(s string) (netip.Addr, int) { + p := netip.MustParsePrefix(s) + return p.Addr(), p.Bits() + } + for i := 1; i < len(ps); i++ { + for j := i; j > 0; j-- { + a, ab := key(ps[j-1]) + b, bb := key(ps[j]) + if c := a.Compare(b); c < 0 || c == 0 && ab <= bb { + break + } + ps[j-1], ps[j] = ps[j], ps[j-1] + } + } +} + +// TCPForward is the socat forwarder of a TCP proxy: it listens on Port and forwards to Target:Port. +type TCPForward struct { + Port int + Target string + Allow []string // normalized (NormalizeTCPAllowCIDRs); empty or [TCPAllowAll] forwards every client +} + +// Restricted reports whether the forwarder checks client addresses. +func (f TCPForward) Restricted() bool { + return len(f.Allow) > 0 && !(len(f.Allow) == 1 && f.Allow[0] == TCPAllowAll) +} + +// EffectiveTCPAllow is the list a proxy enforces: its own list when it has one, else the default. +func EffectiveTCPAllow(own, def []string) []string { + if len(own) > 0 { + return own + } + return def +} + +// Signature identifies the forwarder's configuration; a container whose label differs is replaced. +// Without an allowlist it is the same "public:container:target" value earlier versions wrote, so +// existing forwarders are not recreated on upgrade. +func (f TCPForward) Signature(publicPort int) string { + sig := fmt.Sprintf("%d:%d:%s", publicPort, f.Port, f.Target) + if f.Restricted() { + sig += ":allow=" + strings.Join(f.Allow, ",") + } + return sig +} + +// Cmd and Env are the container command (arguments to socat) and environment. +func (f TCPForward) Cmd() []string { + listen := fmt.Sprintf("TCP-LISTEN:%d,fork,reuseaddr", f.Port) + if !f.Restricted() { + return []string{listen, fmt.Sprintf("TCP:%s:%d", f.Target, f.Port)} + } + // socat 1.8 removes double quotes from an address unless the parameter is single-quoted. + return []string{listen, `SYSTEM:'eval "$AVEN_TCP_CHECK"'`} +} + +func (f TCPForward) Env() map[string]string { + if !f.Restricted() { + return nil + } + return map[string]string{ + "AVEN_TCP_CHECK": tcpAllowCheck, + "AVEN_TCP_ALLOW": strings.Join(f.Allow, " "), + "AVEN_TCP_TARGET": f.Target + ":" + strconv.Itoa(f.Port), + } +} + +// ErrTCPAllowUnsupported is returned when a node's agent is too old to enforce an allowlist. +var ErrTCPAllowUnsupported = errors.New("this node's aven-agent does not support TCP proxy allowlists; upgrade the agent") diff --git a/internal/node/tcpforward_test.go b/internal/node/tcpforward_test.go new file mode 100644 index 0000000..eba031f --- /dev/null +++ b/internal/node/tcpforward_test.go @@ -0,0 +1,91 @@ +package node + +import ( + "fmt" + "reflect" + "strings" + "testing" +) + +func TestNormalizeTCPAllowCIDRs(t *testing.T) { + for _, tc := range []struct { + in []string + want []string + err string + }{ + {in: nil, want: nil}, + {in: []string{" ", ""}, want: nil}, + {in: []string{"203.0.113.7"}, want: []string{"203.0.113.7/32"}}, + {in: []string{"10.1.2.3/8", "10.0.0.0/8", "192.168.1.0/24"}, want: []string{"10.0.0.0/8", "192.168.1.0/24"}}, + {in: []string{"192.168.1.0/24", "10.0.0.0/8"}, want: []string{"10.0.0.0/8", "192.168.1.0/24"}}, + {in: []string{"10.0.0.0/16", "10.0.0.0/8"}, want: []string{"10.0.0.0/8", "10.0.0.0/16"}}, + {in: []string{"::ffff:198.51.100.1"}, want: []string{"198.51.100.1/32"}}, + {in: []string{"10.0.0.0/8", "0.0.0.0/0"}, want: []string{TCPAllowAll}}, + {in: []string{"2001:db8::/32"}, err: "only IPv4"}, + {in: []string{"10.0.0.0/33"}, err: "not an IP"}, + {in: []string{"nope"}, err: "not an IP"}, + {in: []string{"1.2.3.4,5.6.7.8"}, err: "not an IP"}, + } { + got, err := NormalizeTCPAllowCIDRs(tc.in) + if tc.err != "" { + if err == nil || !strings.Contains(err.Error(), tc.err) { + t.Errorf("%v: err %v, want %q", tc.in, err, tc.err) + } + continue + } + if err != nil || !reflect.DeepEqual(got, tc.want) { + t.Errorf("%v: got %v, %v; want %v", tc.in, got, err, tc.want) + } + } + many := make([]string, MaxTCPAllowCIDRs+1) + for i := range many { + many[i] = fmt.Sprintf("10.0.%d.0/24", i) + } + if _, err := NormalizeTCPAllowCIDRs(many[:MaxTCPAllowCIDRs]); err != nil { + t.Errorf("a list of %d ranges was refused: %v", MaxTCPAllowCIDRs, err) + } + if _, err := NormalizeTCPAllowCIDRs(many); err == nil || !strings.Contains(err.Error(), "at most") { + t.Errorf("an over-long list: %v", err) + } +} + +func TestTCPForwardWithoutAllowlistIsUnchanged(t *testing.T) { + f := TCPForward{Port: 5432, Target: "db.production.keel.internal"} + if got, want := f.Signature(20001), "20001:5432:db.production.keel.internal"; got != want { + t.Errorf("signature %q, want the pre-allowlist value %q", got, want) + } + if got, want := f.Cmd(), []string{"TCP-LISTEN:5432,fork,reuseaddr", "TCP:db.production.keel.internal:5432"}; !reflect.DeepEqual(got, want) { + t.Errorf("cmd %v, want %v", got, want) + } + if f.Env() != nil { + t.Error("env set without an allowlist") + } + open := TCPForward{Port: 5432, Target: "db.production.keel.internal", Allow: []string{TCPAllowAll}} + if open.Signature(20001) != f.Signature(20001) || !reflect.DeepEqual(open.Cmd(), f.Cmd()) { + t.Error("an explicit 0.0.0.0/0 should be the plain forwarder") + } + if got := EffectiveTCPAllow(nil, []string{"10.0.0.0/8"}); !reflect.DeepEqual(got, []string{"10.0.0.0/8"}) { + t.Errorf("default not applied: %v", got) + } + if got := EffectiveTCPAllow([]string{TCPAllowAll}, []string{"10.0.0.0/8"}); !reflect.DeepEqual(got, []string{TCPAllowAll}) { + t.Errorf("own list should win over the default: %v", got) + } +} + +func TestTCPForwardWithAllowlist(t *testing.T) { + f := TCPForward{Port: 5432, Target: "db.production.keel.internal", Allow: []string{"10.0.0.0/8", "203.0.113.7/32"}} + if sig := f.Signature(20001); !strings.HasSuffix(sig, ":allow=10.0.0.0/8,203.0.113.7/32") { + t.Errorf("signature %q does not include the allowlist", sig) + } + cmd := f.Cmd() + if len(cmd) != 2 || strings.Contains(cmd[1], ",") { + t.Errorf("cmd %v: the SYSTEM address must not contain commas", cmd) + } + env := f.Env() + if env["AVEN_TCP_ALLOW"] != "10.0.0.0/8 203.0.113.7/32" || env["AVEN_TCP_TARGET"] != "db.production.keel.internal:5432" { + t.Errorf("env %v", env) + } + if strings.Contains(env["AVEN_TCP_CHECK"], ",") { + t.Error("the check script must not contain commas") + } +} 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 == "" { diff --git a/internal/proxy/wakeproxy.go b/internal/proxy/wakeproxy.go new file mode 100644 index 0000000..0fe8005 --- /dev/null +++ b/internal/proxy/wakeproxy.go @@ -0,0 +1,32 @@ +package proxy + +import ( + "net" + "net/http/httputil" + "net/url" + "strings" +) + +// WakeReverseProxy forwards a request that woke a sleeping app to that app at target, keeping the +// visitor's Host. It replaces NewSingleHostReverseProxy+Director (Director is deprecated since Go 1.26) +// and keeps the headers the app saw before: the X-Forwarded-For/-Host/-Proto that Caddy set are passed +// on, and the address of the hop that reached the wake handler is appended to X-Forwarded-For, as +// ReverseProxy did for a Director. SetXForwarded is not used: it would overwrite Caddy's +// X-Forwarded-Proto with the scheme of this internal hop. +func WakeReverseProxy(target *url.URL) *httputil.ReverseProxy { + return &httputil.ReverseProxy{Rewrite: func(pr *httputil.ProxyRequest) { + pr.SetURL(target) + pr.Out.Host = pr.In.Host + for _, k := range []string{"X-Forwarded-For", "X-Forwarded-Host", "X-Forwarded-Proto"} { + if v, ok := pr.In.Header[k]; ok { + pr.Out.Header[k] = v + } + } + if ip, _, err := net.SplitHostPort(pr.In.RemoteAddr); err == nil { + if prior := pr.Out.Header["X-Forwarded-For"]; len(prior) > 0 { + ip = strings.Join(prior, ", ") + ", " + ip + } + pr.Out.Header.Set("X-Forwarded-For", ip) + } + }} +} diff --git a/internal/proxy/wakeproxy_test.go b/internal/proxy/wakeproxy_test.go new file mode 100644 index 0000000..0cb01e8 --- /dev/null +++ b/internal/proxy/wakeproxy_test.go @@ -0,0 +1,56 @@ +package proxy + +import ( + "net/http" + "net/http/httptest" + "net/http/httputil" + "net/url" + "testing" +) + +// The Rewrite-based proxy must hand the app the same request the Director-based one did. +func TestWakeReverseProxyMatchesDirectorBehavior(t *testing.T) { + var got []*http.Request + app := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = append(got, r.Clone(r.Context())) + })) + defer app.Close() + target, _ := url.Parse(app.URL) + + // The previous implementation, kept here as the reference the new one must match. + legacy := httputil.NewSingleHostReverseProxy(target) + //lint:ignore SA1019 reference implementation for the comparison + orig := legacy.Director + //lint:ignore SA1019 reference implementation for the comparison + legacy.Director = func(req *http.Request) { orig(req); req.Host = "shop.example.com" } + + for _, h := range []http.Handler{legacy, WakeReverseProxy(target)} { + r := httptest.NewRequest(http.MethodPost, "http://shop.example.com/cart/add?id=7", nil) + r.RemoteAddr = "172.18.0.5:41000" + r.Header.Set("X-Forwarded-For", "203.0.113.9") + r.Header.Set("X-Forwarded-Proto", "https") + r.Header.Set("X-Forwarded-Host", "shop.example.com") + r.Header.Set("Connection", "X-Secret") + r.Header.Set("X-Secret", "hop-by-hop") + h.ServeHTTP(httptest.NewRecorder(), r) + } + if len(got) != 2 { + t.Fatalf("app saw %d requests", len(got)) + } + old, now := got[0], got[1] + for _, c := range []struct{ name, old, now string }{ + {"host", old.Host, now.Host}, + {"path", old.URL.RequestURI(), now.URL.RequestURI()}, + {"x-forwarded-for", old.Header.Get("X-Forwarded-For"), now.Header.Get("X-Forwarded-For")}, + {"x-forwarded-proto", old.Header.Get("X-Forwarded-Proto"), now.Header.Get("X-Forwarded-Proto")}, + {"x-forwarded-host", old.Header.Get("X-Forwarded-Host"), now.Header.Get("X-Forwarded-Host")}, + {"hop-by-hop", old.Header.Get("X-Secret"), now.Header.Get("X-Secret")}, + } { + if c.old != c.now { + t.Errorf("%s: Director gave %q, Rewrite gives %q", c.name, c.old, c.now) + } + } + if now.Header.Get("X-Forwarded-For") != "203.0.113.9, 172.18.0.5" || now.Host != "shop.example.com" || now.Header.Get("X-Forwarded-Proto") != "https" { + t.Errorf("unexpected request: host %q xff %q proto %q", now.Host, now.Header.Get("X-Forwarded-For"), now.Header.Get("X-Forwarded-Proto")) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 2519fc5..55706fa 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -599,6 +599,9 @@ func DomainAuthKey(instanceID, host string) string { return "domain_auth:" + ins type TCPProxy struct { ContainerPort int `json:"container_port"` PublicPort int `json:"public_port"` + // AllowCIDRs limits which client addresses may connect (normalized IPv4 CIDRs, node.NormalizeTCPAllowCIDRs). + // Empty uses the server default (AVEN_TCP_PORT_ALLOW_CIDRS); ["0.0.0.0/0"] admits everyone regardless. + AllowCIDRs []string `json:"allow_cidrs,omitempty"` } type Canvas struct { 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. diff --git a/staticcheck.conf b/staticcheck.conf new file mode 100644 index 0000000..f0f729b --- /dev/null +++ b/staticcheck.conf @@ -0,0 +1,5 @@ +# staticcheck (run in CI). Defaults, minus: +# ST1005: error strings here often start with a proper noun (Docker, Dockerfile, Aven API). +# S1016: converting between protocol structs that happen to share fields would tie them together; +# an explicit literal is clearer. +checks = ["inherit", "-ST1005", "-S1016"] 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..2f1ba92 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -308,7 +308,7 @@ --gray-100: hsl(246, 18%, 15%); --gray-200: hsl(246, 11%, 22%); --gray-300: hsl(246, 8%, 35%); - --gray-400: hsl(246, 7%, 45%); + --gray-400: hsl(246, 7%, 53%); /* 4.5:1 on the page background: faint text still meets AA; gray-500 (57%) stays brighter */ --gray-500: hsl(246, 6%, 57%); /* 4.8:1 on gray-100, AA for secondary text */ --gray-600: hsl(246, 6%, 65%); --gray-700: hsl(246, 6%, 78%); @@ -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/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/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/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 })}}
diff --git a/web/components/panel/networking/TcpProxyRow.tsx b/web/components/panel/networking/TcpProxyRow.tsx index 08b6ec1..4c67df2 100644 --- a/web/components/panel/networking/TcpProxyRow.tsx +++ b/web/components/panel/networking/TcpProxyRow.tsx @@ -1,15 +1,19 @@ "use client"; /* TCP proxy row in the public networking list (modeled on Railway's dashboard: "host:port" + "→ Port N", copy/edit/delete) with the - sliding edit panel that changes the container port behind the same public port. */ + sliding edit panel that changes the container port behind the same public port and the client IP allowlist. */ import { useState } from "react"; import { Plug, SquarePen, Trash2 } from "lucide-react"; import { api, type CanvasService, type DetectedPort, type Environment, type TcpProxyView } from "@/lib/api"; import { t } from "@/lib/i18n"; -import { ErrorBox } from "../../ui"; +import { ErrorBox, cx } from "../../ui"; import { PortPicker, initialPortChoice, type PortChoice } from "./PortPicker"; -import { CopyBtn, IconBtn, PanelFooter, PortMeta, Sliding, portOk, useNetRun } from "./parts"; +import { CopyBtn, IconBtn, INPUT, PanelFooter, PortMeta, Sliding, portOk, useNetRun } from "./parts"; +import { TCP_ALLOW_MAX, parseTcpAllow } from "./validate"; + +const ALL = "0.0.0.0/0"; +const sameList = (a: string[], b: string[]) => a.length === b.length && a.every((x, i) => x === b[i]); export const tcpAddress = (t: Pick) => `${t.host.includes(":") ? `[${t.host}]` : t.host || "—"}:${t.public_port ?? "—"}`; @@ -46,7 +50,7 @@ export function TcpProxyRow({ {" · "}{t("TCP 프록시")} {!proxy.available && proxy.reason ? · {proxy.reason} : null} -

{t("인터넷 어디서나 이 주소로 접속할 수 있습니다. HTTP가 아닌 TCP(데이터베이스 등)를 그대로 전달하므로 서비스 자체의 비밀번호와 TLS로 보호하세요.")}

+
@@ -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 ? ( +
+ +