No description
  • Go 73.1%
  • JavaScript 21.9%
  • CSS 3.9%
  • Dockerfile 0.6%
  • HTML 0.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Serhii Chebonenko 1912104e7a
All checks were successful
ci / test (push) Successful in 1m33s
ci / images (push) Successful in 59s
Point the enroll page's agent command at the published image
The docker run command on the Enrollment page used jetty-agent:latest,
which only exists on a host that built it. It now pulls
forgejo.kube.in.ua/jetty/jetty-agent:latest, like the compose files
and the README. It also sets JETTY_HOSTNAME to the host's name, so the
agent doesn't enroll under its container's random ID.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:03:14 +03:00
.forgejo/workflows Push images without provenance attestations 2026-10-20 21:58:48 +03:00
cmd Use jetty.example.com instead of a real deployment's domain 2026-10-20 22:01:46 +03:00
deploy Run published images from the compose files and add an agent compose file 2026-10-21 20:57:34 +03:00
internal Point the enroll page's agent command at the published image 2026-10-21 21:03:14 +03:00
.dockerignore Add Forgejo CI that tests and publishes container images 2026-10-20 21:20:43 +03:00
.gitignore Use jetty.example.com instead of a real deployment's domain 2026-10-20 22:01:46 +03:00
Dockerfile Add Forgejo CI that tests and publishes container images 2026-10-20 21:20:43 +03:00
go.mod Add passkey (WebAuthn) login 2026-10-16 13:48:29 +03:00
go.sum Add passkey (WebAuthn) login 2026-10-16 13:48:29 +03:00
README.md Run published images from the compose files and add an agent compose file 2026-10-21 20:57:34 +03:00

Jetty

Minimal Docker management web UI in Go. Two binaries:

  • jetty-server: web UI + JSON API + agent gateway. Holds all agent state.
  • jetty-agent: runs on every Docker host (as a container), dials out to the server and serves a small, fixed Docker API over that tunnel.

Features: list containers, images, networks, volumes and compose stacks; inspect containers; tail and follow logs; deploy, edit and remove docker compose stacks; check images for registry updates.

Security model

browser ──HTTPS (Let's Encrypt)──▶ jetty-server :443   (session cookie + CSRF token)
                                         │
agent ──mTLS, TLS 1.3, private CA────▶ :7443 gateway ── yamux streams ──▶ agent HTTP API

Enrollment (one time per host)

  1. An admin creates a single-use token in the UI. It expires in 15 min to 24 h. Its format is jt1.<sha256(server CA)>.<32-byte secret>, and the server stores only a SHA-256 of the secret.
  2. The agent generates an ECDSA P-256 key locally (the key never leaves the host) and POSTs a CSR plus the token to /api/enroll.
  3. The agent authenticates the server by the public web PKI (Let's Encrypt). If that fails, it accepts only a chain signed by the CA whose fingerprint is in the token. The CA bundle the server returns must match that fingerprint too.
  4. The server checks the token (unused, unexpired, issued by this CA), then signs a 90-day client cert. The server alone chooses the cert identity (CN = agent ID).

Runtime

  • The agent connects to :7443 with mutual TLS 1.3. Both sides trust only Jetty's private CA. The gateway also requires that the cert serial matches the one on record. Revoke deletes that record and drops the tunnel at once.
  • Certificates rotate automatically when fewer than 30 days remain: the server asks the agent for a CSR over the tunnel and installs the new cert, and the old serial stops being accepted. The server checks on connect and then hourly while the tunnel stays up, and retries a failed renewal with backoff (1 min up to 1 h).
  • The agent has no listening port. Its API is an allow-list (read-only endpoints plus compose deploy and remove). It is not a Docker socket passthrough, and the server strips browser cookies and headers before proxying. Responses coming back are treated as untrusted too: only their content type (forced to JSON or plain text) and length are kept, so an agent can't set cookies or serve a page or script on the UI's origin.
  • Web: bcrypt passwords, HttpOnly + Secure + SameSite=Strict session cookie (sessions end after 1 hour without activity and after 12 hours at most; the UI's automatic refreshes don't count as activity), CSRF header on every mutating request, rate limits on login and enrollment per client IP (IPv6 per /64) plus a cap of 20 failed passwords per account every 15 minutes (passkey sign-in still works while it applies), strict CSP, HSTS, X-Frame-Options: DENY.
  • Passkeys (WebAuthn): optional, added under Account. Adding one needs the current password. Login is usernameless, and both registration and login require user verification (biometric or PIN). Passkeys are bound to -domain and accepted only from -origin. Each challenge is single use, expires after 5 minutes and is tied to the browser by an HttpOnly cookie. A signature counter that goes backwards is rejected as a possibly cloned key. After a password sign-in, users without a passkey are reminded to add one. Each user can turn off password sign-in under Account → Require a passkey, which needs at least one passkey and the current password. A refused password login gets the same answer whether or not the password was right. The last passkey can't be removed while the setting is on. Setting JETTY_ADMIN_PASSWORD to a new value resets the admin password and turns password sign-in back on, which is the way back in if every passkey is lost. It never removes passkeys, and a value that matches the current password changes nothing.

Deploying a compose file is root-equivalent on that host (privileged containers, bind mounts). Treat the admin account accordingly.

Run the server

Needs a DNS name pointing at the host, with ports 80, 443 and 7443 open.

cd deploy
JETTY_DOMAIN=jetty.example.com JETTY_ACME_EMAIL=you@example.com docker compose -f server.compose.yaml up -d
docker run --rm -v jetty_jetty-data:/d alpine cat /d/initial-admin-password

Compose also reads these variables from deploy/.env, so you don't have to repeat them on every command. The compose files use the images CI publishes, forgejo.kube.in.ua/jetty/jetty-server:latest and forgejo.kube.in.ua/jetty/jetty-agent:latest. To update, run docker compose -f … pull, then up -d again.

Open https://jetty.example.com, sign in as admin, then change the password under Account.

Server flags and env vars: -domain/JETTY_DOMAIN, -tls acme|self-signed|off, -acme-email, -acme-directory (use the LE staging URL for testing), -gateway :7443, -gateway-public host:port, JETTY_ADMIN_PASSWORD (sets or resets admin), -data /var/lib/jetty, -origin/JETTY_ORIGIN (exact web UI origin for passkeys; defaults to https://<domain>, plus the -https port when it is not 443 and TLS is on. Set it when a proxy serves the UI on a non-standard port), -trusted-proxies/JETTY_TRUSTED_PROXIES (comma-separated IPs/CIDRs of reverse proxies whose X-Forwarded-For is believed; set it with -tls off, or every client shares the proxy's rate limit).

Behind a reverse proxy (Caddy)

-tls off (JETTY_TLS=off) turns off ACME: no Let's Encrypt, no :80 listener, and the UI is served as plain HTTP on :8080 (change it with -https). The proxy terminates TLS for the UI. The agent gateway on :7443 is mutual TLS with Jetty's private CA, so agents must reach it directly. Don't route it through the proxy.

jetty.example.com {
	reverse_proxy 127.0.0.1:8080
}
docker run -d --name jetty-server --restart unless-stopped \
  -e JETTY_DOMAIN=jetty.example.com -e JETTY_TLS=off \
  -e JETTY_TRUSTED_PROXIES=172.16.0.0/12 \
  -p 127.0.0.1:8080:8080 -p 7443:7443 \
  -v jetty_jetty-data:/var/lib/jetty \
  forgejo.kube.in.ua/jetty/jetty-server:latest

Set JETTY_TRUSTED_PROXIES to the address Caddy connects from: 127.0.0.1 when Caddy runs on the host and Jetty doesn't run in Docker, or the Docker bridge network when it does (as above). Agents enroll through Caddy's public certificate as usual.

Add a host

UI → Enrollment → Create enrollment token, then on the Docker host, with deploy/agent.compose.yaml:

JETTY_DOMAIN=jetty.example.com JETTY_TOKEN=jt1.… JETTY_AGENT_HOSTNAME=$(hostname) docker compose -f agent.compose.yaml up -d

The token is used once; afterwards the agent runs from its stored certificate. Optional: JETTY_AGENT_NAME (display name). Or with docker run:

docker run -d --name jetty-agent --restart unless-stopped \
  -e JETTY_SERVER=https://jetty.example.com \
  -e JETTY_TOKEN=jt1.… \
  -e JETTY_HOSTNAME=$(hostname) \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v /var/lib/jetty-agent:/var/lib/jetty-agent \
  forgejo.kube.in.ua/jetty/jetty-agent:latest

Mount /var/lib/jetty-agent at the same path on the host. Compose stacks live in /var/lib/jetty-agent/stacks/<name>/, so relative bind mounts in a compose file resolve to real host paths.

Without Docker: jetty-agent enroll -server … -token …, then jetty-agent run.

Server and agent on the same host

deploy/single-host.compose.yaml runs both. It makes the server's domain a network alias, so the agent reaches the server over the compose network and doesn't need the host to reach its own public IP.

cd deploy
JETTY_DOMAIN=jetty.example.com JETTY_ACME_EMAIL=you@example.com docker compose -f single-host.compose.yaml up -d jetty-server
# sign in, create an enrollment token, then:
JETTY_DOMAIN=jetty.example.com JETTY_TOKEN=jt1.… docker compose -f single-host.compose.yaml up -d jetty-agent

It uses the same project name and data volume as server.compose.yaml, so an existing server keeps its state when you switch files. Optional: JETTY_AGENT_NAME, JETTY_AGENT_HOSTNAME.

Image updates

For every image reference used by a container, the agent asks the Docker daemon for the registry descriptor (GET /distribution/{ref}/json, a manifest lookup only, using the daemon's credentials). It compares that with the image's local RepoDigests. To apply an update, redeploy the stack with Always pull images checked.

CI and images

.forgejo/workflows/ci.yaml runs on every push to main, every v* tag and every pull request. It checks gofmt, go vet and go test -race, then builds both images for linux/amd64 and linux/arm64. Pushes publish them to the Forgejo container registry as <forgejo host>/<owner>/jetty-server and <forgejo host>/<owner>/jetty-agent:

Push Tags
main latest, main, sha-<commit>
v1.2.3 1.2.3, 1.2

Pull requests build the images without publishing them. To build one yourself: docker build --target server -t jetty-server . (or --target agent).

It needs a runner with the docker label whose jobs can reach a Docker daemon, and a repository secret REGISTRY_TOKEN: a personal access token with the write:package scope. If the repository belongs to a user, it is that user's token. If it belongs to an organization, which can't have tokens, it is the token of a member whose team can write the organization's packages.

Layout

.forgejo/workflows                  CI: tests, image builds
cmd/jetty-server, cmd/jetty-agent   entrypoints
internal/pki                        private CA, CSR signing
internal/protocol                   token format, shared types
internal/server                     API, auth, store (JSON file), gateway, embedded web UI
internal/agent                      enrollment, tunnel, Docker client, stacks, update checks