From 87dee3e5a5fe11c1ecc395d47bd820413f49ad84 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Fri, 25 Sep 2026 18:31:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20=E5=8D=95=E6=AC=A1=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E4=B8=8A=E9=99=90=E6=94=B9=E4=B8=BA=20context=5Fmax?= =?UTF-8?q?=5Fbytes=20=E5=8F=AF=E9=85=8D=EF=BC=8CCloudflare=20=E8=BE=B9?= =?UTF-8?q?=E7=BC=98=E5=90=8E=E9=BB=98=E8=AE=A4=2095Mi=EF=BC=8C=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20GET=20/me/submissions/limits=20=E4=BE=9B=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E9=A2=84=E6=A3=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/felis/api.go | 29 +++++++++++++++++++++++++ cmd/felis/context_max_test.go | 37 ++++++++++++++++++++++++++++++++ deploy/bootstrap.sh | 2 +- docs/openapi.yaml | 31 +++++++++++++++++++++++++- docs/troubleshooting.md | 4 ++++ internal/api/api.go | 1 + internal/api/submissions.go | 17 +++++++++++++++ internal/api/submissions_test.go | 24 +++++++++++++++++++++ internal/config/config.go | 12 +++++++++++ internal/submit/submit.go | 3 +++ 10 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 cmd/felis/context_max_test.go diff --git a/cmd/felis/api.go b/cmd/felis/api.go index eb9d1f8..171aebf 100644 --- a/cmd/felis/api.go +++ b/cmd/felis/api.go @@ -235,6 +235,11 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int { submissions.MaxStoredBytesTotal = n } } + if n, err := contextMaxBytes(cfg); err != nil { + fmt.Fprintf(stderr, "felis api: [registry] context_max_bytes %q is not a positive size such as 95Mi; keeping the default\n", cfg.Registry.ContextMaxBytes) + } else { + submissions.MaxContextBytes = n + } // Restore subsystem (spec §7): the weak-SA restore Job mounts the target // world PVC + the backup PVC and runs `felis restore`. It needs deployment- @@ -876,3 +881,27 @@ func startServerCache(ctx context.Context, cfg *rest.Config, scheme *runtime.Sch }() return c, inf.HasSynced, nil } + +// cloudflareContextMaxBytes is the per-upload cap behind the Cloudflare edge, +// which refuses request bodies over 100 MB (the Free and Pro plan limit) with +// its own 413 page before they reach the API. 95Mi leaves headroom under it, so +// an oversized context meets the API's own JSON refusal instead. +const cloudflareContextMaxBytes = "95Mi" + +// contextMaxBytes resolves [registry] context_max_bytes, defaulting to +// cloudflareContextMaxBytes behind the Cloudflare edge. 0 keeps the submit +// package's own default (1 GiB). +func contextMaxBytes(cfg *config.Config) (int64, error) { + v := cfg.Registry.ContextMaxBytes + if v == "" && cfg.Auth.BehindCloudflare() { + v = cloudflareContextMaxBytes + } + if v == "" { + return 0, nil + } + n, err := parseByteSize(v) + if err != nil || n <= 0 { + return 0, fmt.Errorf("not a positive size: %q", v) + } + return n, nil +} diff --git a/cmd/felis/context_max_test.go b/cmd/felis/context_max_test.go new file mode 100644 index 0000000..3600231 --- /dev/null +++ b/cmd/felis/context_max_test.go @@ -0,0 +1,37 @@ +package main + +import ( + "testing" + + "felis.lolicon.best/internal/config" +) + +func TestContextMaxBytesFollowsTheEdge(t *testing.T) { + cases := []struct { + name string + reg config.RegistryConfig + auth config.AuthConfig + want int64 + wantErr bool + }{ + {name: "direct install keeps the package default", want: 0}, + {name: "access audience means the Cloudflare edge", auth: config.AuthConfig{AccessJWTAud: "aud-1"}, want: 99614720}, + {name: "CF-Connecting-IP header means the Cloudflare edge", auth: config.AuthConfig{ClientIPHeader: "cf-connecting-ip"}, want: 99614720}, + {name: "an operator proxy keeps the package default", auth: config.AuthConfig{ClientIPHeader: "X-Forwarded-For"}, want: 0}, + {name: "explicit value wins over the edge default", reg: config.RegistryConfig{ContextMaxBytes: "50Mi"}, auth: config.AuthConfig{AccessJWTAud: "aud-1"}, want: 52428800}, + {name: "explicit value on a direct install", reg: config.RegistryConfig{ContextMaxBytes: "2Gi"}, want: 2147483648}, + {name: "garbage is refused", reg: config.RegistryConfig{ContextMaxBytes: "lots"}, wantErr: true}, + {name: "zero is refused", reg: config.RegistryConfig{ContextMaxBytes: "0"}, wantErr: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := contextMaxBytes(&config.Config{Registry: tc.reg, Auth: tc.auth}) + if (err != nil) != tc.wantErr { + t.Fatalf("err = %v, wantErr %v", err, tc.wantErr) + } + if got != tc.want { + t.Fatalf("contextMaxBytes = %d, want %d", got, tc.want) + } + }) + } +} diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index 557e0eb..ed0c444 100644 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -3002,7 +3002,7 @@ persisted_registry_block() { out="$(awk ' /^[[:space:]]*\[/ { sect = $0; next } sect ~ /^[[:space:]]*\[registry\][[:space:]]*$/ && - /^[[:space:]]*(kaniko_image|trivy_image|trivy_db_repository|trivy_java_db_repository|build_cpu_limit|build_mem_limit|build_disk_limit|build_user_namespaces|build_runtime_class|max_concurrent_builds|user_uploads_context|user_uploads_max_bytes)[[:space:]]*=/ { print } + /^[[:space:]]*(kaniko_image|trivy_image|trivy_db_repository|trivy_java_db_repository|build_cpu_limit|build_mem_limit|build_disk_limit|build_user_namespaces|build_runtime_class|max_concurrent_builds|user_uploads_context|user_uploads_max_bytes|context_max_bytes)[[:space:]]*=/ { print } sect ~ /^[[:space:]]*\[registry\.s3\][[:space:]]*$/ && /^[[:space:]]*[A-Za-z_]+[[:space:]]*=/ { if (!s3hdr) { printf "[registry.s3]\n"; s3hdr = 1 } print diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 9e5b485..1cfc254 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -5278,6 +5278,31 @@ paths: '503': $ref: '#/components/responses/ServiceUnavailable' + /api/v1/me/submissions/limits: + get: + tags: [submissions] + operationId: submissionLimits + x-felis-face: [external] + x-felis-tier: app + security: [{ sessionCookie: [] }] + summary: The per-upload build-context cap + description: >- + The effective [registry] context_max_bytes: 1 GiB by default, 95 MiB + behind the Cloudflare edge (its proxy refuses bodies over 100 MB before + they reach the API). The panel checks a file against it before upload. + responses: + '200': + description: The cap. + content: + application/json: + schema: + type: object + additionalProperties: false + required: [max_context_bytes] + properties: + max_context_bytes: {type: integer, format: int64} + '503': + $ref: '#/components/responses/ServiceUnavailable' /api/v1/me/submissions/{id}/context: post: tags: [submissions] @@ -5290,7 +5315,11 @@ paths: principal; a submission the caller does not own is reported as 404, so this endpoint cannot upload to or probe another user's submission. Only a pending_review submission accepts a context (409 otherwise); a wrong-format - or oversize body is rejected with 400, and an upload that would push the + or oversize body is rejected with 400 (the per-upload cap is [registry] + context_max_bytes: 1 GiB by default and 95 MiB behind the Cloudflare + edge, whose proxy refuses bodies over 100 MB with its own HTML 413 + before they reach the API; GET /api/v1/me/submissions/limits reports + the effective cap so a client can check a file before sending it), and an upload that would push the caller past their per-user stored-context budget is refused with 403 before the excess is persisted. Returns 503 when the deployment's context store has no implemented upload transport. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 8ca96b3..0647761 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -552,6 +552,7 @@ build_user_namespaces = "auto" # §8f: auto | on | off build_runtime_class = "" # §8f: e.g. "gvisor" max_concurrent_builds = 2 # §8f: 1-6; later builds queue user_uploads_max_bytes = "4Gi" # every user's uploaded contexts together; 507 uploads_full past it +context_max_bytes = "95Mi" # one uploaded context; empty = 1Gi, or 95Mi behind Cloudflare (edge caps bodies at 100 MB) ``` Put them in **both** `/etc/felis/felis.host.toml` (host-side CLI) and @@ -693,6 +694,9 @@ control namespace (or `--registry-namespace`): (`FELIS_UPLOADS_STORAGE`, 5Gi) and the world-archive PVC (`FELIS_BACKUP_STORAGE`, 10Gi) work the same way; re-running the installer keeps an existing claim's size and warns when the variable asks for another. + One uploaded context is capped by `context_max_bytes` (1Gi, or 95Mi behind + the Cloudflare edge, whose proxy answers its own 413 page for bodies over + 100 MB; the panel checks the file against it before uploading). Uploaded build contexts are bounded by `user_uploads_max_bytes` (4Gi for all users together, §8e), 2 GiB per user, and 10% free space on the volume; past any of them an upload answers `507 uploads_full` or `403 submission_quota_exceeded`. A diff --git a/internal/api/api.go b/internal/api/api.go index 32838ae..b92f21d 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -610,6 +610,7 @@ func (a *API) externalAPIRoutes() []apiRoute { // session is the correct gate (the admin verdict lives below, behind adminOnly). {Method: "POST", Pattern: "/api/v1/me/submissions", h: a.handleCreateSubmission}, {Method: "GET", Pattern: "/api/v1/me/submissions", h: a.handleMySubmissions}, + {Method: "GET", Pattern: "/api/v1/me/submissions/limits", h: a.handleSubmissionLimits}, // The blob upload for a submission the caller owns: the request body is the // raw gzip build context, streamed to the derived, id-namespaced location. // App-tier and owner-scoped (the id must belong to the principal), exactly diff --git a/internal/api/submissions.go b/internal/api/submissions.go index 72912de..321c491 100644 --- a/internal/api/submissions.go +++ b/internal/api/submissions.go @@ -202,6 +202,23 @@ func (a *API) handleUploadSubmissionContext(w http.ResponseWriter, r *http.Reque // query to another user's uploads. Each row is enriched with its linked build's // outcome — this list is the only player-visible outlet for a build result, so // a failed build is not invisible to the person who submitted it. +// SubmissionLimits is what one upload may carry, read before sending it. +type SubmissionLimits struct { + MaxContextBytes int64 `json:"max_context_bytes"` +} + +// handleSubmissionLimits reports the effective per-upload context cap +// ([registry] context_max_bytes), so the panel can refuse an oversized file +// before streaming it into the edge's own body limit. +func (a *API) handleSubmissionLimits(w http.ResponseWriter, r *http.Request) { + l, ok := a.Submissions.(interface{ ContextLimit() int64 }) + if !ok { + writeError(w, r, errSubmissionsUnavailable) + return + } + writeJSON(w, http.StatusOK, SubmissionLimits{MaxContextBytes: l.ContextLimit()}) +} + func (a *API) handleMySubmissions(w http.ResponseWriter, r *http.Request) { if a.Submissions == nil { writeError(w, r, errSubmissionsUnavailable) diff --git a/internal/api/submissions_test.go b/internal/api/submissions_test.go index 257a18f..4ef03ab 100644 --- a/internal/api/submissions_test.go +++ b/internal/api/submissions_test.go @@ -934,3 +934,27 @@ func TestUploadSubmissionContextRateLimited(t *testing.T) { t.Fatalf("retry at the same instant after failure: code = %d, want 200 (%s)", w.Code, w.Body.String()) } } + +type limitedSubmissions struct { + *fakeSubmissions + limit int64 +} + +func (l limitedSubmissions) ContextLimit() int64 { return l.limit } + +// The panel reads the per-upload cap before sending a file; a lane that cannot +// report one answers like any unwired submissions endpoint. +func TestSubmissionLimitsReportsTheContextCap(t *testing.T) { + api := appSubAPI(&fakeSubmissions{}) + api.Submissions = limitedSubmissions{&fakeSubmissions{}, 99614720} + w := do(api.ExternalHandler(), "GET", "/api/v1/me/submissions/limits", "", nil) + if w.Code != 200 || strings.TrimSpace(w.Body.String()) != `{"max_context_bytes":99614720}` { + t.Fatalf("limits = %d %s", w.Code, w.Body.String()) + } + + api.Submissions = nil + w = do(api.ExternalHandler(), "GET", "/api/v1/me/submissions/limits", "", nil) + if w.Code != 503 { + t.Fatalf("unwired limits = %d %s", w.Code, w.Body.String()) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index e1e53d8..91e4513 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -166,6 +166,13 @@ func (a AuthConfig) EffectiveClientIPHeader() string { return "" } +// BehindCloudflare reports whether requests reach the API through the +// Cloudflare edge: an Access audience (set only by the edge setup) or +// CF-Connecting-IP as the client address header. +func (a AuthConfig) BehindCloudflare() bool { + return strings.EqualFold(a.EffectiveClientIPHeader(), "CF-Connecting-IP") +} + // K8sConfig is the [k8s] table. type K8sConfig struct { Namespace string `toml:"namespace"` @@ -232,6 +239,11 @@ type RegistryConfig struct { // own; this bounds the sum, which on k3s local-path is the only bound, since // the uploads PVC's size is not enforced there. Empty keeps 4Gi. UserUploadsMaxBytes string `toml:"user_uploads_max_bytes"` + // ContextMaxBytes caps one uploaded build context, as a quantity ("95Mi"). + // Empty keeps 1Gi, except behind the Cloudflare edge (see BehindCloudflare), + // whose proxy refuses request bodies over 100 MB before they reach the API; + // there it keeps 95Mi, so the API's own 413 is what the uploader sees. + ContextMaxBytes string `toml:"context_max_bytes"` // S3 configures the object-store backend for user_uploads_context when it is an // s3:// base (the alternative to a local uploads path). It mirrors // ArchiveS3Config: Endpoint + Region locate the store and the *Ref fields NAME diff --git a/internal/submit/submit.go b/internal/submit/submit.go index e1798d2..0be15e5 100644 --- a/internal/submit/submit.go +++ b/internal/submit/submit.go @@ -384,6 +384,9 @@ type Manager struct { IDGen func() string } +// ContextLimit is the effective cap on one uploaded context. +func (m *Manager) ContextLimit() int64 { return m.maxContextBytes() } + func (m *Manager) maxContextBytes() int64 { if m.MaxContextBytes > 0 { return m.MaxContextBytes