Episode 2: Forgejo Actions — runner, Docker sandbox, own registry, locked-down deploy
This commit is contained in:
parent
06f8e8812e
commit
0341ae777d
8 changed files with 231 additions and 1 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1 +1,2 @@
|
||||||
.env
|
.env
|
||||||
|
runner-config.yml
|
||||||
|
|
|
||||||
|
|
@ -28,3 +28,4 @@ n8n.{$DOMAIN} {
|
||||||
vpn.{$DOMAIN} {
|
vpn.{$DOMAIN} {
|
||||||
reverse_proxy wg-easy:51821
|
reverse_proxy wg-easy:51821
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
32
HARDENING.md
32
HARDENING.md
|
|
@ -92,3 +92,35 @@ Plus: enable your provider's server snapshots. Two clicks, saves your company.
|
||||||
Not covered here, on purpose (own episodes): SSO everywhere, 2FA enforcement
|
Not covered here, on purpose (own episodes): SSO everywhere, 2FA enforcement
|
||||||
per service, Netbird zero-trust mesh, central logging, CrowdSec, offsite borg.
|
per service, Netbird zero-trust mesh, central logging, CrowdSec, offsite borg.
|
||||||
Baseline first. Perfect later.
|
Baseline first. Perfect later.
|
||||||
|
|
||||||
|
## CI: the two ways to hand your server away
|
||||||
|
|
||||||
|
Both of these look like shortcuts and both give anyone who can push a branch full
|
||||||
|
control of the machine.
|
||||||
|
|
||||||
|
**1. Mounting the host Docker socket into the runner.** Every forum answer suggests
|
||||||
|
it, because it works instantly:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock # do not do this
|
||||||
|
```
|
||||||
|
|
||||||
|
A job can then start a privileged container on the host, mount `/` and read every
|
||||||
|
volume on the box, including the password manager sitting in the next container.
|
||||||
|
Use the docker-in-docker sidecar in `compose.yml` instead. It costs six lines and
|
||||||
|
the jobs talk to a daemon that has nothing of yours in it.
|
||||||
|
|
||||||
|
**2. An unrestricted SSH key for the deploy step.** If the key in your CI secrets
|
||||||
|
can run any command, the pipeline is a root shell with extra steps. Lock it to one
|
||||||
|
command in `authorized_keys`:
|
||||||
|
|
||||||
|
```
|
||||||
|
command="/usr/local/bin/deploy-myapp",no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-pty ssh-ed25519 AAAA...
|
||||||
|
```
|
||||||
|
|
||||||
|
The script receives the argument from the job in `$SSH_ORIGINAL_COMMAND`. Sanitise
|
||||||
|
it — the example in `examples/deploy-hello-ops.sh` strips everything that is not a
|
||||||
|
hex character, because it only ever expects an image tag. The deploy user needs
|
||||||
|
Docker access, so the forced command is the thing containing it; without the forced
|
||||||
|
command, membership in the `docker` group is equivalent to root.
|
||||||
|
|
|
||||||
50
README.md
50
README.md
|
|
@ -90,6 +90,56 @@ Outline → its postgres/redis.
|
||||||
**Is `latest` okay for images?**
|
**Is `latest` okay for images?**
|
||||||
For a homelab: pragmatic. For production: pin versions and read HARDENING.md.
|
For a homelab: pragmatic. For production: pin versions and read HARDENING.md.
|
||||||
|
|
||||||
|
## CI: your own runners (Episode 2)
|
||||||
|
|
||||||
|
Forgejo ships Actions. Turn it on, give it a runner, and you have GitHub Actions
|
||||||
|
with no minute counter.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Enable it (already set in compose.yml)
|
||||||
|
# FORGEJO__actions__ENABLED and repo.actions in DEFAULT_REPO_UNITS.
|
||||||
|
# Without repo.actions in the default units, the Actions tab stays hidden
|
||||||
|
# and it looks like the feature is broken. Existing repos: flip it per repo.
|
||||||
|
|
||||||
|
# 2. One shared secret for declarative registration
|
||||||
|
SECRET=$(openssl rand -hex 20)
|
||||||
|
UUID=$(python3 -c 'import uuid,sys;print(uuid.UUID(bytes=sys.argv[1][:16].encode()))' "$SECRET")
|
||||||
|
printf 'RUNNER_SECRET=%s\nRUNNER_UUID=%s\n' "$SECRET" "$UUID" >> .env
|
||||||
|
|
||||||
|
# 3. Tell Forgejo about it
|
||||||
|
docker compose exec -u git forgejo forgejo forgejo-cli actions register \
|
||||||
|
--name box-runner --labels docker --secret "$SECRET"
|
||||||
|
|
||||||
|
# 4. Fill in the runner config and start the CI profile
|
||||||
|
cp runner-config.example.yml runner-config.yml
|
||||||
|
sed -i "s|https://git.example.com|https://git.$DOMAIN|; s|\${RUNNER_UUID}|$UUID|; s|\${RUNNER_SECRET}|$SECRET|" runner-config.yml
|
||||||
|
docker compose --profile ci up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Then check Site Administration → Runners: the runner should be online with the
|
||||||
|
label `docker`.
|
||||||
|
|
||||||
|
**Four things that cost people an evening:**
|
||||||
|
|
||||||
|
- A registration **token from the web UI is not a shared secret**. The declarative
|
||||||
|
path above only accepts the secret you generated. Feed it the web token and the
|
||||||
|
runner connects and never receives a job, with no error anywhere.
|
||||||
|
- The `connections:` block lives under a top-level **`server:`** key in runner
|
||||||
|
13.x. Older examples put it under `runner:`, which fails with
|
||||||
|
`0 server connections configured`.
|
||||||
|
- **An empty `labels:` list means every job waits forever.** No error, just a
|
||||||
|
queued job and a healthy-looking runner.
|
||||||
|
- The image behind a label **must contain node** — Forgejo actions are JavaScript.
|
||||||
|
Pointing `docker` at a docker-cli image gives you
|
||||||
|
`node: executable file not found`. Use a node image and let the build job fetch
|
||||||
|
a static docker client (see `examples/ci.yml`).
|
||||||
|
|
||||||
|
`examples/ci.yml` is a complete pipeline: test, build a container, push it to
|
||||||
|
Forgejo's built-in registry, and deploy it over an SSH key that can only run one
|
||||||
|
command (`examples/deploy-hello-ops.sh`). Read the CI section of
|
||||||
|
[HARDENING.md](HARDENING.md) before you wire the deploy step — there are two
|
||||||
|
comfortable ways to hand your whole server to anyone who can push a branch.
|
||||||
|
|
||||||
## Hardening
|
## Hardening
|
||||||
|
|
||||||
Before you put real company data on this box, read **[HARDENING.md](HARDENING.md)**.
|
Before you put real company data on this box, read **[HARDENING.md](HARDENING.md)**.
|
||||||
|
|
|
||||||
43
compose.yml
43
compose.yml
|
|
@ -22,6 +22,7 @@ name: selfhostedops-stack
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
proxy:
|
proxy:
|
||||||
|
ci:
|
||||||
internal:
|
internal:
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|
@ -34,6 +35,9 @@ volumes:
|
||||||
kuma_data:
|
kuma_data:
|
||||||
n8n_data:
|
n8n_data:
|
||||||
wg_data:
|
wg_data:
|
||||||
|
docker_certs:
|
||||||
|
dind_data:
|
||||||
|
runner_data:
|
||||||
|
|
||||||
services:
|
services:
|
||||||
caddy:
|
caddy:
|
||||||
|
|
@ -53,7 +57,7 @@ services:
|
||||||
networks: [proxy]
|
networks: [proxy]
|
||||||
|
|
||||||
forgejo:
|
forgejo:
|
||||||
image: codeberg.org/forgejo/forgejo:11
|
image: codeberg.org/forgejo/forgejo:16
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
USER_UID: "1000"
|
USER_UID: "1000"
|
||||||
|
|
@ -63,6 +67,8 @@ services:
|
||||||
FORGEJO__server__SSH_PORT: "2222"
|
FORGEJO__server__SSH_PORT: "2222"
|
||||||
FORGEJO__service__DISABLE_REGISTRATION: "true"
|
FORGEJO__service__DISABLE_REGISTRATION: "true"
|
||||||
FORGEJO__openid__ENABLE_OPENID_SIGNIN: "false"
|
FORGEJO__openid__ENABLE_OPENID_SIGNIN: "false"
|
||||||
|
FORGEJO__actions__ENABLED: "true"
|
||||||
|
FORGEJO__repository__DEFAULT_REPO_UNITS: "repo.code,repo.issues,repo.pulls,repo.releases,repo.packages,repo.actions"
|
||||||
ports:
|
ports:
|
||||||
- "2222:22" # git over SSH
|
- "2222:22" # git over SSH
|
||||||
volumes:
|
volumes:
|
||||||
|
|
@ -163,3 +169,38 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- wg_data:/etc/wireguard
|
- wg_data:/etc/wireguard
|
||||||
networks: [proxy]
|
networks: [proxy]
|
||||||
|
|
||||||
|
# --- CI (episode 2) -- start with: docker compose --profile ci up -d ---
|
||||||
|
|
||||||
|
docker-in-docker:
|
||||||
|
image: data.forgejo.org/oci/docker:dind
|
||||||
|
# The hostname MUST be "docker": the TLS certificates dind generates are
|
||||||
|
# only valid for that name. Rename it and you get a handshake error that
|
||||||
|
# reads like a network problem and is not one.
|
||||||
|
hostname: docker
|
||||||
|
profiles: [ci]
|
||||||
|
privileged: true
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DOCKER_TLS_CERTDIR: /certs
|
||||||
|
volumes:
|
||||||
|
- docker_certs:/certs
|
||||||
|
- dind_data:/var/lib/docker
|
||||||
|
networks: [ci]
|
||||||
|
|
||||||
|
forgejo-runner:
|
||||||
|
image: data.forgejo.org/forgejo/runner:13.0.0
|
||||||
|
profiles: [ci]
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on: [docker-in-docker]
|
||||||
|
environment:
|
||||||
|
DOCKER_HOST: tcp://docker:2376
|
||||||
|
DOCKER_CERT_PATH: /certs/client
|
||||||
|
DOCKER_TLS_VERIFY: "1"
|
||||||
|
volumes:
|
||||||
|
- runner_data:/data
|
||||||
|
- docker_certs:/certs
|
||||||
|
- ./runner-config.yml:/data/config.yml:ro
|
||||||
|
networks: [ci, proxy]
|
||||||
|
command: forgejo-runner --config /data/config.yml daemon
|
||||||
|
|
||||||
|
|
|
||||||
52
examples/ci.yml
Normal file
52
examples/ci.yml
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
name: ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
# Two pushes in a row must not deploy over each other.
|
||||||
|
concurrency:
|
||||||
|
group: deploy-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
IMAGE: git.example.com/youruser/hello-ops
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: npm test
|
||||||
|
|
||||||
|
build:
|
||||||
|
needs: test
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
# The job image has node (it must -- actions are JavaScript) but no docker
|
||||||
|
# client. One static binary, no package manager, no daemon.
|
||||||
|
- name: Get a docker client
|
||||||
|
run: |
|
||||||
|
curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-29.7.2.tgz \
|
||||||
|
| tar xz -C /usr/local/bin --strip-components=1 docker/docker
|
||||||
|
docker version --format "{{.Client.Version}}"
|
||||||
|
- name: Log in to our own registry
|
||||||
|
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.example.com -u youruser --password-stdin
|
||||||
|
- name: Build and push
|
||||||
|
run: |
|
||||||
|
TAG=${GITHUB_SHA::7}
|
||||||
|
docker build --build-arg APP_VERSION=$TAG -t $IMAGE:$TAG -t $IMAGE:latest .
|
||||||
|
docker push $IMAGE:$TAG
|
||||||
|
docker push $IMAGE:latest
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: build
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- name: Deploy over a key that can only do one thing
|
||||||
|
run: |
|
||||||
|
install -m 700 -d ~/.ssh
|
||||||
|
echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
ssh -o StrictHostKeyChecking=no deploy@${{ secrets.DEPLOY_HOST }} ${GITHUB_SHA::7}
|
||||||
11
examples/deploy-hello-ops.sh
Executable file
11
examples/deploy-hello-ops.sh
Executable file
|
|
@ -0,0 +1,11 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# The only thing the CI key is allowed to do. Argument comes from the CI job.
|
||||||
|
set -eu
|
||||||
|
TAG=$(printf "%s" "${SSH_ORIGINAL_COMMAND:-latest}" | tr -cd "a-f0-9")
|
||||||
|
[ -n "$TAG" ] || TAG=latest
|
||||||
|
IMAGE=git.example.com/youruser/hello-ops:$TAG
|
||||||
|
docker pull "$IMAGE"
|
||||||
|
docker rm -f hello-ops 2>/dev/null || true
|
||||||
|
docker run -d --name hello-ops --restart unless-stopped \
|
||||||
|
--network selfhostedops-stack_proxy -e APP_VERSION="$TAG" "$IMAGE"
|
||||||
|
echo "deployed $TAG"
|
||||||
42
runner-config.example.yml
Normal file
42
runner-config.example.yml
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
log:
|
||||||
|
level: info
|
||||||
|
|
||||||
|
runner:
|
||||||
|
file: /data/.runner
|
||||||
|
# How many jobs may run at the same time. On a small box keep this low --
|
||||||
|
# your builds share CPU with everything else on the server.
|
||||||
|
capacity: 2
|
||||||
|
timeout: 1h
|
||||||
|
# Jobs get a docker client pointed at the sidecar, never at the host daemon.
|
||||||
|
envs:
|
||||||
|
DOCKER_HOST: tcp://docker:2376
|
||||||
|
DOCKER_TLS_VERIFY: "1"
|
||||||
|
DOCKER_CERT_PATH: /certs/client
|
||||||
|
# A label maps the name you write in "runs-on:" to the image the job runs in.
|
||||||
|
# This list is EMPTY by default -- and an empty list means every job you push
|
||||||
|
# waits forever with no error anywhere.
|
||||||
|
labels:
|
||||||
|
# The image behind a label must contain node -- Forgejo actions like
|
||||||
|
# checkout are JavaScript. A plain docker-cli image has no node and every
|
||||||
|
# JS action dies with "node: executable file not found".
|
||||||
|
- "docker:docker://code.forgejo.org/oci/node:22-bookworm"
|
||||||
|
|
||||||
|
cache:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
container:
|
||||||
|
network: host
|
||||||
|
# Jobs talk to the docker-in-docker sidecar over TLS, never to the host daemon.
|
||||||
|
options: -v /certs/client:/certs/client
|
||||||
|
valid_volumes:
|
||||||
|
- /certs/client
|
||||||
|
|
||||||
|
server:
|
||||||
|
# Declarative registration: same secret you passed to
|
||||||
|
# "forgejo forgejo-cli actions register --secret ...".
|
||||||
|
# A registration token from the web UI does NOT work here.
|
||||||
|
connections:
|
||||||
|
default:
|
||||||
|
url: https://git.example.com
|
||||||
|
uuid: ${RUNNER_UUID}
|
||||||
|
token: ${RUNNER_SECRET}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue