Skip to content
Merged
Show file tree
Hide file tree
Changes from 164 commits
Commits
Show all changes
165 commits
Select commit Hold shift + click to select a range
8c01eeb
docs(setup): show the setup link with the token in the hash
thegdsks Oct 27, 2026
7d782a6
feat(web): say a first deploy can take up to two minutes
thegdsks Oct 27, 2026
32c8a07
feat(cli): up stops when the remote branch lacks your commit
thegdsks Oct 27, 2026
3574b29
feat(cli): up waits for a healthy first deploy
thegdsks Oct 27, 2026
f93e672
chore: merge release/v0.8.8 into release/v0.8.9
thegdsks Oct 27, 2026
16126b1
feat(catalog): add RSSHub template
thegdsks Oct 27, 2026
1df51cf
feat(web): one next action on the empty dashboard
thegdsks Oct 27, 2026
3bc187c
docs(adr): plan UDP ports, then gated NET_ADMIN and sysctls
thegdsks Oct 27, 2026
b7a24fb
feat(templates): add ArchiveBox web archiver template
thegdsks Oct 27, 2026
0291441
feat(templates): add Crawl4AI crawler API template
thegdsks Oct 27, 2026
739a26a
feat(dashboard): give the available update its own strip
thegdsks Oct 27, 2026
0a8e17c
feat(templates): add Backrest restic backup web UI
thegdsks Oct 27, 2026
9e5d3f4
feat(shell): link the sidebar version to update settings
thegdsks Oct 27, 2026
da57500
feat(templates): add Open Notebook research template
thegdsks Oct 27, 2026
77c1fb4
feat(catalog): add Headscale and Authelia templates
thegdsks Oct 27, 2026
71835d6
feat(web): open the first app by its direct address when HTTPS is off
thegdsks Oct 27, 2026
19b4b92
feat(compose): accept UDP ports and store them per service
thegdsks Oct 27, 2026
6ebe08a
feat(reconcile): publish UDP ports directly on the host
thegdsks Oct 27, 2026
adcbc1e
feat(api): show UDP ports and refuse conflicting compose deploys
thegdsks Oct 27, 2026
e9914b7
fix(catalog): Open Notebook API_URL from domain, licence notes, Authe…
thegdsks Oct 27, 2026
db53b46
docs(catalog): mark seven new templates verified
thegdsks Oct 27, 2026
e93a5ad
docs(compose): document UDP published ports and what is not yet suppo…
thegdsks Oct 27, 2026
f519c7e
docs(compose): fix UDP ports page link and DNS example
thegdsks Oct 27, 2026
a630c99
feat(cli): decode published ports on app and compose deploy responses
thegdsks Oct 27, 2026
f96bbc5
fix(web): show one open action for a live first app
thegdsks Oct 27, 2026
6e46267
feat(web): type published ports on apps and compose services
thegdsks Oct 27, 2026
4be8a97
fix(web): one compact HTTPS prompt in the first-app flow
thegdsks Oct 27, 2026
cce31d8
feat(cli): show published ports as 53/udp in apps and compose output
thegdsks Oct 27, 2026
a0e426f
feat(api): app urls report a plain-http link when the ingress routes one
thegdsks Oct 27, 2026
5a8c21b
feat(web): show UDP and TCP published ports on the app network panel
thegdsks Oct 27, 2026
786c18d
feat(cli): explain published port refusals with a next step
thegdsks Oct 27, 2026
5a09739
feat(firewall): list published UDP ports in the host firewall view
thegdsks Oct 27, 2026
d05350c
fix(web): compact first-run path cards on narrow screens
thegdsks Oct 27, 2026
70d8b2e
feat(cli): apps create --file sends published_ports
thegdsks Oct 27, 2026
1be4c72
feat(web): explain published port deploy errors with a fix hint
thegdsks Oct 27, 2026
54dcf05
docs(udp): examples verified by running them
thegdsks Oct 27, 2026
2fbb728
fix(web): live title and real scheme on the first-app screen
thegdsks Oct 27, 2026
259aafb
feat(compose): refuse a UDP port another host process already binds
thegdsks Oct 27, 2026
8c9cb4c
test(exposure): pin UDP handling of DOCKER-USER rules apart from TCP
thegdsks Oct 27, 2026
29eb24c
feat(compose): apply shm_size to /dev/shm with drift recreate
thegdsks Oct 27, 2026
68b3dc1
fix(catalog): drop ignored compose keys from three templates
thegdsks Oct 27, 2026
58e2eca
docs(compose): document shm_size and platform hardening keys
thegdsks Oct 27, 2026
14b49fa
feat(cli): explain a host port already in use
thegdsks Oct 27, 2026
3690f0c
feat(web): edit UDP and TCP published ports in the app port editor
thegdsks Oct 27, 2026
4a65c01
feat(web): show UDP rows in the host firewall card and port-in-use hint
thegdsks Oct 27, 2026
6cc23a6
test(web): sidebar logo badge keeps unhealthy states visible
thegdsks Oct 27, 2026
1339423
fix(apps): keep and admit published ports on app create and update
thegdsks Oct 27, 2026
acf2f0c
fix(web): first app live state states its status once
thegdsks Oct 27, 2026
9bb8dd9
fix(deploy-urls): retained deploy clones no longer bind published ports
thegdsks Oct 27, 2026
ee87213
fix(web): one compact HTTPS prompt on first run
thegdsks Oct 27, 2026
40468c4
fix(compose): cap shm_size by default and count it against memory quota
thegdsks Oct 27, 2026
8a4b5b0
test(web): HTTPS card test follows the shorter sign-in note
thegdsks Oct 27, 2026
efeb9a2
fix(catalog): keep the Authelia admin password off the command line
thegdsks Oct 27, 2026
794b28a
fix(apps): refuse published ports the control plane or a host port holds
thegdsks Oct 27, 2026
3f0451a
fix(web): 44px tap targets for first-run secondary links on phones
thegdsks Oct 27, 2026
02c514b
test(web): link mock keeps its class so the tap target is checked
thegdsks Oct 27, 2026
0dd838c
fix(apps): accept a UDP-only app with no HTTP port on create and edit
thegdsks Oct 27, 2026
1bd0acd
fix(web): only link https release notes from the update strip
thegdsks Oct 27, 2026
fcd570e
fix(web): firewall rows keep bind and state together, loopback reads …
thegdsks Oct 27, 2026
7b62acd
fix(web): published port chips carry the container mapping inside
thegdsks Oct 27, 2026
f4184fb
fix(web): hide the published ports hint while its error shows
thegdsks Oct 27, 2026
cb08820
fix(apps): let a portless app edit or clear its published ports
thegdsks Oct 27, 2026
cd7782c
test(e2e): UDP published ports through the platform
thegdsks Oct 27, 2026
e04797a
fix(quota): count /dev/shm of apps without a memory limit in usage
thegdsks Oct 27, 2026
bafdb50
fix(api): reject negative resources and oversize shm on app writes
thegdsks Oct 27, 2026
3e5b804
fix(cli): harden up push check git calls and allowlist it
thegdsks Oct 27, 2026
8abbcc0
chore(iac): regenerate published resources schema
thegdsks Oct 27, 2026
716a251
perf(api): take the host port lock only for deploys that claim one
thegdsks Oct 27, 2026
0c4be3f
fix(web): map authelia and rsshub marks, list five known-missing logos
thegdsks Oct 27, 2026
b88325a
fix(deploy): keep canaries, previews and revives off published host p…
thegdsks Oct 27, 2026
711c5bf
feat(catalog): add Radicale template
thegdsks Oct 27, 2026
5350b08
feat(catalog): add RustDesk server template
thegdsks Oct 27, 2026
9d2145b
fix(catalog): RustDesk template refuses to start without a relay host
thegdsks Oct 27, 2026
22226c1
fix(ports): close published port holes and dial the tcp binding
thegdsks Oct 27, 2026
88caa56
fix(compose): keep the 2 GiB shm cap when its env value is invalid
thegdsks Oct 27, 2026
8e72c92
fix(domains): refuse a domain on an app with no HTTP port
thegdsks Oct 27, 2026
487e8db
fix(catalog): Authelia start fails closed without a valid admin hash
thegdsks Oct 27, 2026
9914522
fix(deploy): app.yaml deploys refuse host ports the API would refuse
thegdsks Oct 27, 2026
8cad70d
fix(ports): proxy and probes dial the service port, not the first bin…
thegdsks Oct 27, 2026
4870709
fix(deploy): app.yaml host_port pins get the API's holder checks
thegdsks Oct 27, 2026
5406ac6
fix(publish-lock): publishing a held build rechecks its host ports
thegdsks Oct 27, 2026
ca37316
fix(ports): reserve the mesh's configured WireGuard port
thegdsks Oct 27, 2026
9224628
fix(cli): up's push check ignores repo git proxy and credential hooks
thegdsks Oct 27, 2026
3560d4c
fix(catalog): Authelia keeps users.yml with argon2id or added users
thegdsks Oct 27, 2026
bd4f0f0
test(api): health discovery fakes report real container ports
thegdsks Oct 27, 2026
c4954e5
fix(web): map RustDesk mark and list Radicale as known-missing
thegdsks Oct 27, 2026
6a77b77
feat(catalog): expose an optional template description in the API
thegdsks Oct 27, 2026
a7ae492
feat(catalog): add setup notes to RustDesk, Mosquitto and two more te…
thegdsks Oct 28, 2026
5a80fd9
feat(cli): show template setup notes in templates get
thegdsks Oct 28, 2026
4644fe1
fix(reconcile): exited port-less service no longer reports Ready
thegdsks Oct 28, 2026
d43509b
feat(web): show template setup notes in the detail page and deploy di…
thegdsks Oct 28, 2026
6cfeb36
docs: describe the template description field for authors
thegdsks Oct 28, 2026
b454a54
fix(api): claim a deploy approval before applying it
thegdsks Oct 28, 2026
7c18492
test(api): Go batch template descriptions reach the catalog API
thegdsks Oct 28, 2026
0a6495e
fix(api): stop a stack reject from failing an apply in flight
thegdsks Oct 28, 2026
48c56ad
feat(compose): record an explicit restart no or on-failure policy
thegdsks Oct 28, 2026
b1a9356
fix(reconcile): stop rerunning a one-shot service that exited 0
thegdsks Oct 28, 2026
249cafd
feat(web): show the approving state on deploy approvals
thegdsks Oct 28, 2026
b853c41
fix(reconcile): honor depends_on service_completed_successfully
thegdsks Oct 28, 2026
e38401a
fix(deploywatch): ignore a one-shot service that exited 0
thegdsks Oct 28, 2026
e399a51
docs(compose): document run-to-completion restart and completed depen…
thegdsks Oct 28, 2026
6d22f18
fix(deploy): report a completed one-shot service as rolled out
thegdsks Oct 28, 2026
0fef1b9
fix(apps): keep a one-shot restart policy across health edits
thegdsks Oct 28, 2026
47ae3c7
fix(reconcile): let a long one-shot finish instead of killing it
thegdsks Oct 28, 2026
1ea59f5
fix(reconcile): stop counting a never-started one-shot as completed
thegdsks Oct 28, 2026
ed55ad6
fix(reconcile): judge a completed dependency by its current release only
thegdsks Oct 28, 2026
f32f6ae
fix(reconcile): keep a worker crash restart from failing its deploy
thegdsks Oct 28, 2026
6d303ed
fix(api): return a same-tag approval to pending when its apply never ran
thegdsks Oct 28, 2026
556e436
fix(compose): warn on and ignore a malformed on-failure:N restart value
thegdsks Oct 28, 2026
f350a65
chore(ci): turn SonarCloud off behind SONAR_ENABLED
thegdsks Oct 28, 2026
cc46fbd
fix(spec): keep run-to-completion on app.yaml and compose build deploys
thegdsks Oct 28, 2026
031746c
fix(agent): stop counting a never-started remote one-shot as completed
thegdsks Oct 28, 2026
14a2e22
chore(ci): coverage no longer runs on or blocks pull requests
thegdsks Oct 28, 2026
523e8c1
docs(console): add CLI console page and list both beta features in st…
thegdsks Oct 28, 2026
e27df98
chore(ci): skip heavy test lanes on draft pull requests
thegdsks Oct 28, 2026
2abdcab
fix(deploy): report a one-shot past its completion budget as failed
thegdsks Oct 28, 2026
4e5c137
chore(ci): move the CI required verdict into a script
thegdsks Oct 28, 2026
fafcb9a
chore(ci): table-test the CI required verdict
thegdsks Oct 28, 2026
8ad6963
feat(templates): show address, logins and notes after a template deploy
thegdsks Oct 28, 2026
b759fd8
feat(ui): confirm app Restart and Stop before running
thegdsks Oct 28, 2026
1c6b267
feat(api): verify GitLab and CircleCI OIDC identity tokens
thegdsks Oct 28, 2026
395713b
chore(ci): run CI when the required-check verdict script changes
thegdsks Oct 28, 2026
01ae110
feat(iac): stacks declare published ports and run-to-completion restart
thegdsks Oct 28, 2026
57a72a9
test(stacks): published ports pass admission, drift and rollback
thegdsks Oct 28, 2026
c5acde9
docs(stacks): published ports and restart in app documents
thegdsks Oct 28, 2026
7eb7531
fix(stacks): accept a UDP-only app with no HTTP port
thegdsks Oct 28, 2026
eddeee2
fix(console): run only allowlisted API-only CLI commands
thegdsks Oct 28, 2026
6c4ffb2
feat(web): first app always shows an openable address and failure reason
thegdsks Oct 28, 2026
1b8afb4
feat(ci): trust GitLab and CircleCI pipelines to deploy with OIDC
thegdsks Oct 28, 2026
f130bfa
feat(ui): follow Restart and Stop until the app is really back
thegdsks Oct 28, 2026
856c015
docs(ci): deploy from GitLab CI and CircleCI with OIDC, no stored token
thegdsks Oct 28, 2026
2478ce0
fix(console): mask secrets and strip escapes in console output
thegdsks Oct 28, 2026
6619607
fix(console): start the CLI in its own session with no extra fds
thegdsks Oct 28, 2026
836d37e
docs(console): document the allowlist, drift tests and per-mode limits
thegdsks Oct 28, 2026
542fe0d
refactor(ui): palette restart and stop reuse the shared confirm
thegdsks Oct 28, 2026
0ae4d3c
fix(ci): GitLab trusts require protected refs and environments
thegdsks Oct 28, 2026
e3ee537
fix(ci): adding a CI trust needs deploy, custom issuers need root
thegdsks Oct 28, 2026
7877ba7
fix(ui): restart no longer reports a false failure while recovering
thegdsks Oct 28, 2026
b6b0148
fix(ci): trusts on the same path no longer collide across providers
thegdsks Oct 28, 2026
1de7eeb
fix(ci): CI token exchanges are audited and old tokens refused
thegdsks Oct 28, 2026
57ccb0c
docs(ci): GitLab safe defaults, audit entries and downgrading
thegdsks Oct 28, 2026
387c9f9
fix(web): wrap long notes and tokens in the template result dialog
thegdsks Oct 28, 2026
4d41269
fix(web): keep the assistant button clear of page actions on phones
thegdsks Oct 28, 2026
8fefeeb
fix(web): 44px tap targets on phones for first-app and confirm buttons
thegdsks Oct 28, 2026
ea78aba
fix(web): focus Cancel first and make Stop confirm solid destructive
thegdsks Oct 28, 2026
d1557e6
fix(web): one HTTPS prompt on the first-app card and no no-op shortcut
thegdsks Oct 28, 2026
50e0acd
test(compose): cover restart notices after run-to-completion landed
thegdsks Oct 28, 2026
3d1ff51
test(api): allowlist the CI token exchange in the body-ref guard
thegdsks Oct 28, 2026
dfb8233
test(agent): assert the exact capability set including exit_times
thegdsks Oct 28, 2026
57164b5
fix(catalog): Authelia says why it stopped when hashing fails
thegdsks Oct 28, 2026
173683f
fix(deploy): report a deploy stuck in a crash loop as failed
thegdsks Oct 28, 2026
a90e267
test(e2e): keep exit state visible through the pull retry runtime
thegdsks Oct 28, 2026
83faa25
test(e2e): run-to-completion services through the platform
thegdsks Oct 28, 2026
9903725
fix(web): show a crash-looping first deploy as failed
thegdsks Oct 28, 2026
cf90975
feat(api): report deploy outcome and reason on every deploy response
thegdsks Oct 28, 2026
b121ecb
fix(api): keep omitted fields on PUT /apps instead of clearing them
thegdsks Oct 28, 2026
847136b
feat(templates): keep access details on the app page and in the wizard
thegdsks Oct 28, 2026
cf6ec5e
feat(ci): trust GitLab and CircleCI pipelines to plan and apply stacks
thegdsks Oct 28, 2026
3135618
feat(cli): add provider-neutral apps ci token and trust commands
thegdsks Oct 28, 2026
81d5352
feat(web): choose GitHub, GitLab or CircleCI when trusting a CI pipeline
thegdsks Oct 28, 2026
e7a27a1
fix(web): trust the server deploy outcome for crash-looping apps
thegdsks Oct 28, 2026
c2c45db
feat(apps): show template setup notes in Access details for any templ…
thegdsks Oct 28, 2026
f263e5d
test(api): align app update tests with keep-omitted semantics
thegdsks Oct 28, 2026
089822b
chore: merge main into release/v0.8.9
thegdsks Oct 28, 2026
715ed1a
fix(mcp): app tool output no longer fails on a null env
thegdsks Oct 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
55 changes: 31 additions & 24 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ on:
pull_request:
branches:
- main
types: [opened, synchronize, reopened, ready_for_review]
merge_group:

# PR runs cancel superseded pushes; main and merge queue runs never cancel.
Expand Down Expand Up @@ -50,6 +51,8 @@ jobs:
installer: ${{ steps.detect.outputs.installer }}
workflows: ${{ steps.detect.outputs.workflows }}
planner: ${{ steps.detect.outputs.planner }}
# Draft PRs skip the heavy jobs; CI_ON_DRAFTS=true opts back in.
draft: ${{ github.event.pull_request.draft == true && vars.CI_ON_DRAFTS != 'true' }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand Down Expand Up @@ -202,6 +205,7 @@ jobs:
run: |
scripts/test-ci-go-plan.sh
scripts/test-ci-changes.sh
scripts/test-ci-required-verdict.sh

- name: go build
if: needs.plan.outputs.scope != 'none'
Expand Down Expand Up @@ -254,6 +258,7 @@ jobs:
# scoped by the same impact detection as a PR. See docs/ci.md.
if: >-
needs.plan.result == 'success'
&& needs.changes.outputs.draft != 'true'
&& needs.plan.outputs.has_tests == 'true'
runs-on: ${{ vars.CI_RUNNER || 'ubuntu-latest' }}
# 5 min above the docker/rest lanes' own -timeout=27m (scripts/ci-go-plan.sh):
Expand Down Expand Up @@ -334,7 +339,7 @@ jobs:
name: Test (internal/api)
needs: [changes, plan, test-go]
if: >-
!cancelled() && (needs.changes.result != 'success' || (needs.changes.outputs.go == 'true'
!cancelled() && needs.changes.outputs.draft != 'true' && (needs.changes.result != 'success' || (needs.changes.outputs.go == 'true'
&& (needs.plan.result != 'success' || contains(needs.plan.outputs.api_checks, 'Test ('))))
runs-on: ${{ vars.CI_RUNNER || 'ubuntu-latest' }}
permissions:
Expand Down Expand Up @@ -363,7 +368,7 @@ jobs:
name: Test (everything else)
needs: [changes, plan, test-go]
if: >-
!cancelled() && (needs.changes.result != 'success' || (needs.changes.outputs.go == 'true'
!cancelled() && needs.changes.outputs.draft != 'true' && (needs.changes.result != 'success' || (needs.changes.outputs.go == 'true'
&& (needs.plan.result != 'success' || contains(needs.plan.outputs.rest_checks, 'Test ('))))
runs-on: ${{ vars.CI_RUNNER || 'ubuntu-latest' }}
permissions:
Expand Down Expand Up @@ -396,6 +401,7 @@ jobs:
# Not a required check, but still a real test run.
if: >-
needs.plan.result == 'success'
&& needs.changes.outputs.draft != 'true'
&& needs.plan.outputs.quarantine == 'true'
runs-on: ${{ vars.CI_RUNNER || 'ubuntu-latest' }}
continue-on-error: true
Expand Down Expand Up @@ -425,11 +431,14 @@ jobs:
RERUNS: '0'
run: scripts/ci-go-test.sh quarantined ./...

# Informational: never runs on a PR, never a need of ci-required, and a
# low number on main reports but does not fail the run.
coverage-gate:
name: Coverage gate
needs: [changes, plan, test-go]
continue-on-error: true
if: >-
!cancelled() && (needs.changes.result != 'success' || (needs.changes.outputs.go == 'true'
!cancelled() && github.event_name == 'push' && (needs.changes.result != 'success' || (needs.changes.outputs.go == 'true'
&& (needs.plan.result != 'success' || needs.plan.outputs.has_tests == 'true')))
runs-on: ${{ vars.CI_RUNNER || 'ubuntu-latest' }}
timeout-minutes: 5
Expand Down Expand Up @@ -582,7 +591,7 @@ jobs:
kit:
name: Kit (vet, lint, test)
needs: changes
if: ${{ !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.kit == 'true') }}
if: ${{ !cancelled() && needs.changes.outputs.draft != 'true' && (needs.changes.result != 'success' || needs.changes.outputs.kit == 'true') }}
runs-on: ${{ vars.CI_RUNNER || 'ubuntu-latest' }}
timeout-minutes: 10
env:
Expand Down Expand Up @@ -627,19 +636,23 @@ jobs:
- name: go test
run: go -C kit test -short -timeout=5m -coverprofile="$GITHUB_WORKSPACE/kit-coverage.out" ./...

# Coverage never blocks a PR: kit gates report on push only.
- name: Coverage gate (70% for kit/)
if: github.event_name == 'push'
continue-on-error: true
run: scripts/check-coverage.sh kit-coverage.out kit/ 70

- name: Changed-line coverage gate (50% for kit/)
if: env.BASE != '' && env.BASE != '0000000000000000000000000000000000000000'
if: github.event_name == 'push' && env.BASE != '' && env.BASE != '0000000000000000000000000000000000000000'
continue-on-error: true
run: scripts/check-changed-file-coverage.sh kit-coverage.out "$BASE" kit/ 50

# integrations/terraform-provider/ is its own module: vet and unit tests only. The
# acceptance tests need a live control plane and run by hand (its README).
tfprovider:
name: Terraform provider (vet, test)
needs: changes
if: ${{ !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.tfprovider == 'true') }}
if: ${{ !cancelled() && needs.changes.outputs.draft != 'true' && (needs.changes.result != 'success' || needs.changes.outputs.tfprovider == 'true') }}
runs-on: ${{ vars.CI_RUNNER || 'ubuntu-latest' }}
timeout-minutes: 10
steps:
Expand Down Expand Up @@ -782,6 +795,7 @@ jobs:
needs: changes
if: >-
${{ !cancelled() && needs.changes.result == 'success'
&& needs.changes.outputs.draft != 'true'
&& needs.changes.outputs.web == 'full'
&& (needs.changes.outputs.vitest == 'full' || needs.changes.outputs.vitest == 'changed') }}
runs-on: ${{ vars.CI_RUNNER || 'ubuntu-latest' }}
Expand Down Expand Up @@ -825,7 +839,7 @@ jobs:
install-sh:
name: install.sh (${{ matrix.image }})
needs: changes
if: needs.changes.outputs.installer == 'true'
if: needs.changes.outputs.installer == 'true' && needs.changes.outputs.draft != 'true'
runs-on: ${{ vars.CI_RUNNER || 'ubuntu-latest' }}
timeout-minutes: 25
strategy:
Expand Down Expand Up @@ -866,7 +880,7 @@ jobs:
install-cli-macos:
name: install-cli.sh (macOS)
needs: changes
if: needs.changes.outputs.installer == 'true'
if: needs.changes.outputs.installer == 'true' && needs.changes.outputs.draft != 'true'
runs-on: macos-latest
timeout-minutes: 15
steps:
Expand All @@ -891,7 +905,7 @@ jobs:
install-cli-windows:
name: install-cli.ps1 (Windows)
needs: changes
if: needs.changes.outputs.installer == 'true'
if: needs.changes.outputs.installer == 'true' && needs.changes.outputs.draft != 'true'
runs-on: windows-latest
timeout-minutes: 15
steps:
Expand Down Expand Up @@ -938,25 +952,18 @@ jobs:
# failed or was cancelled. A skipped job had no work for this change.
ci-required:
name: CI required
needs: [changes, plan, build-vet, lint, kit, tfprovider, test-go, test-api, test-rest, coverage-gate, web-check, web-test, install-sh, install-cli-macos, install-cli-windows, release-pacing]
needs: [changes, plan, build-vet, lint, kit, tfprovider, test-go, test-api, test-rest, web-check, web-test, install-sh, install-cli-macos, install-cli-windows, release-pacing]
if: always()
runs-on: ${{ vars.CI_RUNNER || 'ubuntu-latest' }}
timeout-minutes: 3
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: scripts/ci-required-verdict.sh
sparse-checkout-cone-mode: false

- name: Evaluate required jobs
env:
NEEDS: ${{ toJSON(needs) }}
run: |
jq -r 'to_entries[] | "\(.key): \(.value.result)"' <<<"$NEEDS"
fail=0
if [ "$(jq -r '.changes.result' <<<"$NEEDS")" != success ]; then
echo "::error::changes: change detection did not succeed"
fail=1
fi
bad="$(jq -r 'to_entries[] | select(.value.result == "failure" or .value.result == "cancelled") | .key' <<<"$NEEDS")"
if [ -n "$bad" ]; then
echo "::error::failed or cancelled: $(echo "$bad" | paste -sd' ' -)"
fail=1
fi
[ "$fail" -eq 0 ] || exit 1
echo "all required jobs for this change passed"
run: scripts/ci-required-verdict.sh
2 changes: 2 additions & 0 deletions .github/workflows/sonar.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ name: sonar

# Advisory SonarCloud analysis. Never on a PR, never in `CI required`.
# The scan step is continue-on-error so a failed analysis never paints a commit red.
# Off unless the repository variable SONAR_ENABLED is 'true'.
# Every step skips with a notice when SONAR_TOKEN is not set.
on:
push:
Expand All @@ -25,6 +26,7 @@ permissions:
jobs:
scan:
name: SonarCloud scan
if: ${{ vars.SONAR_ENABLED == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 45
env:
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,12 @@ The last lines of output include the dashboard address and a one-time setup link
Levelrail vX.Y.Z installed and running.

Open the dashboard:
http://203.0.113.10:8080/login?setup=<token>
http://203.0.113.10:8080/login#setup=<token>
```

### 2. Create the admin account

Open the setup link and choose a password. The first sign-in opens one screen that turns on a free certificate for `<dashed-ip>.sslip.io` with one click (no DNS needed) and deploys your first app.
Open the setup link and choose a password. The token sits in the `#` fragment, so it never reaches the server or its logs; lost it? `sudo APP_DATA_DIR=/var/lib/levelrail-data levelrail setup-token` prints it again. The first sign-in opens one screen that turns on a free certificate for `<dashed-ip>.sslip.io` with one click (no DNS needed) and deploys your first app.

### 3. Deploy an app

Expand Down
178 changes: 178 additions & 0 deletions adr/049-udp-ports-capabilities-sysctls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
# ADR 049: UDP ports, a capability allowlist and namespaced sysctls

Status: Proposed

Date: 2026-10-27

## Context

VPN and remote access apps (wg-easy, RustDesk server, Headscale STUN,
DNS on UDP 53, game servers) cannot run today. Verified on
`release/v0.8.9`:

1. **UDP is rejected, not dropped.** Short form `51820:51820/udp` fails in
`parsePort` (`internal/compose/yaml.go:277`, `Atoi` on `51820/udp`), long
form `protocol: udp` fails at `yaml.go:247`. `compose.Port`
(`internal/compose/compose.go:163`) has no protocol, and the package doc
says "no UDP" (`compose.go:11`). `store.DesiredService` carries one
`Port` plus optional `HostPort` (`internal/store/service.go:216`), no
protocol, and the reconciler publishes exactly that one TCP binding
(`internal/reconcile/application/controller.go:2731`).
2. **Lower layers already speak UDP.** `docker.PortBinding.Protocol`
(`internal/docker/runtime.go:36`) feeds `nat.NewPort(proto, ...)`
(`internal/docker/client.go:790`). Host exposure restrictions accept
udp (`internal/exposure/manager.go:122`), node firewall rules carry
`Protocol` (`internal/reconcile/firewall/controller.go:135`). App streams
(Caddy layer4, `internal/ingress/layer4.go`) store a protocol column but
the API refuses anything but tcp (`internal/api/app_streams.go:65`,
`internal/store/app_stream.go:14`). The edge firewall
(`internal/api/firewall_edge_view.go`, `internal/edgefw`) inspects HTTP
requests only and has no UDP view.
3. **`cap_add` is rejected.** `validateHostAccess` rejects any `cap_add`
(`internal/compose/security.go:69`), citing the socket proxy rule
`cap_add_disallowed`. That proxy already has a narrow grant path:
`dockerguard.GrantableCaps = NET_ADMIN, NET_RAW`
(`internal/dockerguard/policy.go:14`) allowed only for a create declared
with them (`validate.go:83`), used today by the platform's own egress
sidecar (`internal/reconcile/application/egress.go:214`), which shares
the app container's network namespace. The default profile drops ALL
and adds back `MinimalCapabilities` (`internal/docker/hardening.go:48`),
with no-new-privileges and a PID limit. `APP_CONTAINER_HARDENING_CAP_ADD`
adds caps to every container on the host.
4. **`sysctls` is ignored with a notice**, not rejected
(`internal/compose/unsupported.go:51`). `docker.ContainerSpec` has no
sysctl field and dockerguard has no sysctl rule.

No cloud firewall ADR exists on origin; this ADR does not depend on one.

## Options

**(a) UDP publishing end to end.** Add `PublishedPorts []{ContainerPort,
HostPort, Protocol}` to the spec and `DesiredService` (new table or JSON
column), parse `/udp` and `protocol: udp` in compose, emit one
`docker.PortBinding` per entry with the same `bindaddr.Resolve` treatment
the TCP port gets (default `private`). UDP goes straight to a host port,
not through Caddy: the HTTP proxy cannot carry it, and a layer4 UDP proxy
on the control plane would hide client source addresses that WireGuard
roaming and RustDesk hole punching depend on, and would only serve the
control plane host. Health checks stay on the TCP port or a container
healthcheck; a UDP port gets no reachability probe, only "bound" status
from the observed `Ports`. Dashboard, CLI and API show `port/udp`
everywhere a host port shows today, and a public UDP bind gets the same
exposure warning and firewall allow rule flow as a public TCP host port.

**(b) Capability allowlist.** A named set, starting with `NET_ADMIN` and
`NET_RAW`, read from env (`APP_GRANTABLE_CAPS`, default those two, values
outside the set dockerguard grants are refused at startup). Granted per
template or per app, never `privileged`, never `SYS_ADMIN`, `SYS_MODULE`
or `SYS_PTRACE`. A grant requires an owner or admin confirmation at deploy
time with a visible warning, is written to audit with the actor, is shown
on the app page and in `app show`, and is denied by default for imported
compose files, docker run imports and PR previews (previews inherit no
grant). Rejected alternative: the host wide
`APP_CONTAINER_HARDENING_CAP_ADD`, which grants every container.

**(c) Sysctls.** Only namespaced net sysctls, allowlisted from env
(`APP_ALLOWED_SYSCTLS`, default `net.ipv4.ip_forward`,
`net.ipv4.conf.all.src_valid_mark`). Anything else stays a blocking error
rather than today's silent notice. Needs `ContainerSpec.Sysctls` and a
dockerguard rule that rejects sysctls outside the list.

**(d) Host networking** stays out of scope. `network_mode: host`
remains rejected (`security.go:53`).

**(e) Template coverage.**

| Template | Needs | New risk |
| --- | --- | --- |
| RustDesk server | (a) only: 21116/udp plus TCP 21115 to 21119 | Public relay, abuse traffic |
| Headscale with STUN | (a) only: 3478/udp | STUN reflection, small |
| DNS servers | (a), UDP 53 | Open resolver amplification if public |
| wg-easy | (a), (b) NET_ADMIN, (c) both sysctls, host `wireguard` kernel module | Routes traffic into app network and node LAN |

## Recommendation

Ship (a) first, alone. It adds no privilege, unblocks RustDesk, Headscale
STUN and DNS, and reuses existing bind address, firewall and exposure
paths. Ship (b) and (c) together later, gated on the threat model below,
since wg-easy is the only target that needs them.

Ports: TCP host port ranges stay out of scope; RustDesk needs five
single TCP ports, which `PublishedPorts` covers.

## Security analysis

- **UDP:** no session, so spoofed sources and amplification matter.
Default bind stays `private`; a `public` UDP bind needs the same
confirmation a public TCP host port gets. Docker published ports bypass
the host INPUT chain, so restrictions must land in DOCKER-USER, which
`internal/exposure` already manages; UDP there is untested.
- **NET_ADMIN** is the real container escape surface: it exposes netfilter
and nf_tables, the source of several kernel privilege escalations. It is
scoped to the container's own network namespace, but a kernel bug is
not. Required before shipping: a minimum kernel version check from env,
default seccomp profile kept, no-new-privileges kept, and documentation
that NET_ADMIN trusts the image author with the kernel.
- **Egress conflict:** the egress sidecar enforces policy with iptables in
the app's network namespace. An app with NET_ADMIN can flush those rules,
so an egress policy and a NET_ADMIN grant must be mutually exclusive, or
egress must be reported as unenforced.
- **NET_RAW** allows spoofed packets and ARP tricks against other
containers on the same bridge network.
- **Who may grant:** owner or admin role only; developers may request. A
template marks the grant as required, it never applies it silently.

## Compatibility and migration

Existing apps keep their single `Port`. `PublishedPorts` is additive, empty
by default, and its hash joins the applied config hash so a change
recreates the container. Compose files that failed on `/udp` now parse.
`sysctls` moves from notice to error only for keys off the allowlist,
which is a visible change for imported files that set them today; those
get a clear message, not a silent drop.

## Implementation slices

1. **S1, M:** `PublishedPorts` in spec, store migration, compose parse of
udp, reconciler bindings, applied hash. Files: `internal/compose`,
`internal/spec`, `internal/store`, `internal/reconcile/application`.
2. **S2, S:** API, CLI, dashboard display, exposure warning and firewall
allow rule for UDP host ports. Files: `internal/api`, `cmd/levelrail`,
`web/src`.
3. **S3, S:** RustDesk and Headscale STUN templates. Files:
`internal/catalog`.
4. **S4, M:** threat model note, kernel version gate, `ContainerSpec.Sysctls`,
dockerguard sysctl rule. Files: `internal/docker`, `internal/dockerguard`.
5. **S5, M:** grant model, approval flow, audit, app page badge, preview and
import denial, egress exclusivity. Files: `internal/api`, `internal/store`,
`internal/compose`, `internal/preview`, `web/src`.
6. **S6, S:** wg-easy template.

## Testing plan

Unit tests per slice: compose udp parsing, binding protocol, applied hash,
grant denial for previews and imports, dockerguard refusing caps and
sysctls outside the env lists. One real server run per release line: a
UDP echo from outside the node, DOCKER-USER restriction blocking it, and
a wg-easy client handshake.

## What cannot be verified locally

UDP through Docker Desktop on macOS, DOCKER-USER behavior for UDP on
nftables engines (Podman with netavark), the wireguard kernel module, and
real kernel exposure of NET_ADMIN.

## Consequences

The VPN and remote access category opens in two steps, with the privileged
step behind an explicit, audited human decision. The platform takes on a
second publishing path (direct UDP host ports) beside Caddy.

## Open questions

- Should `net.ipv6.conf.all.forwarding` join the default sysctl list?
- Does dockerguard currently pass an unknown `HostConfig.Sysctls` through?
Not verified; S4 must check.
- Minimum kernel version for NET_ADMIN, and who maintains it.
- Should UDP host ports work on remote nodes in S1, given node firewall
rules are local only today (`internal/reconcile/firewall/controller.go:128`)?
Loading
Loading