From d87c77dc135d68a69d403149a62b8ee5eaf06481 Mon Sep 17 00:00:00 2001 From: Veillax <126523099+Veillax@users.noreply.github.com> Date: Mon, 26 Oct 2026 03:05:34 -0600 Subject: [PATCH] Add Gitea Actions CI and release workflows and deployment assets Co-Authored-By: Claude Sonnet 5.5 --- .dockerignore | 3 ++ .gitea/workflows/ci.yml | 50 ++++++++++++++++++++++++ .gitea/workflows/release.yml | 62 ++++++++++++++++++++++++++++++ .gitignore | 1 + Dockerfile | 20 ++++++++++ README.md | 34 +++++++++++++++++ cmd/bamboo/main.go | 9 +++++ deploy/bamboo.service | 29 ++++++++++++++ deploy/build.sh | 50 ++++++++++++++++++++++++ deploy/docker-compose.yml | 23 ++++++++++++ deploy/install.sh | 73 ++++++++++++++++++++++++++++++++++++ deploy/publish-release.sh | 60 +++++++++++++++++++++++++++++ 12 files changed, 414 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitea/workflows/release.yml create mode 100644 Dockerfile create mode 100644 deploy/bamboo.service create mode 100644 deploy/build.sh create mode 100644 deploy/docker-compose.yml create mode 100644 deploy/install.sh create mode 100644 deploy/publish-release.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f5b8e79 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +# The image only packages the binaries that deploy/build.sh already built. +* +!dist/bin diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..89b1c62 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,50 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + env: + GOPRIVATE: git.veillax.com + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + # Go fetches the other sharks modules straight from this server. This is harmless + # for public repositories and is what makes private ones work. + - name: Let Go fetch modules from this server + run: git config --global url."https://x-access-token:${{ secrets.GITHUB_TOKEN }}@git.veillax.com/".insteadOf "https://git.veillax.com/" + + - name: Formatting + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "These files need gofmt:" + echo "$unformatted" + exit 1 + fi + + - name: Shell scripts parse + run: | + for f in deploy/*.sh; do sh -n "$f"; done + + - name: Vet + run: go vet ./... + + - name: Test + run: go test ./... + + # Catches platform-specific breakage before a release is tagged. + - name: Every release platform compiles + run: | + for t in linux/amd64 linux/arm64 linux/arm windows/amd64 darwin/amd64 darwin/arm64; do + echo "building for $t" + GOOS=${t%/*} GOARCH=${t#*/} GOARM=7 CGO_ENABLED=0 go build -o /dev/null ./cmd/bamboo + done diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..2b60972 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,62 @@ +name: Release + +# Push a tag such as v0.2.0 to build and publish a release: +# git tag -a v0.2.0 -m "bamboo v0.2.0" && git push origin v0.2.0 +on: + push: + tags: ['v*'] + +jobs: + release: + runs-on: ubuntu-latest + env: + GOPRIVATE: git.veillax.com + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # release notes come from the history since the previous tag + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Let Go fetch modules from this server + run: git config --global url."https://x-access-token:${{ secrets.GITHUB_TOKEN }}@git.veillax.com/".insteadOf "https://git.veillax.com/" + + - name: Test + run: | + go vet ./... + go test ./... + + - name: Build release archives + run: sh deploy/build.sh "${GITHUB_REF_NAME}" + + # Optional secret RELEASE_TOKEN: an access token that can write releases, for + # servers where the automatic job token can't. + - name: Publish the release + env: + TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }} + run: sh deploy/publish-release.sh "${GITHUB_REF_NAME}" + + # Skipped when the repository variable PUBLISH_DOCKER is "false", or fails alone + # if this runner has no Docker: the release above is already published by then. + # Optional secret REGISTRY_TOKEN (and variable REGISTRY_USER): an access token + # with package write permission, for servers where the job token can't push. + - name: Build and push the container image + if: ${{ vars.PUBLISH_DOCKER != 'false' }} + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN || secrets.GITHUB_TOKEN }} + REGISTRY_USER: ${{ vars.REGISTRY_USER || github.actor }} + run: | + host=${GITHUB_SERVER_URL#*://} + owner=$(echo "$GITHUB_REPOSITORY_OWNER" | tr 'A-Z' 'a-z') + image="$host/$owner/bamboo" + echo "$REGISTRY_TOKEN" | docker login "$host" -u "$REGISTRY_USER" --password-stdin + # a container-driver builder can build several architectures in one go + docker buildx create --name release --driver docker-container --use >/dev/null 2>&1 || docker buildx use release + tags="-t $image:$GITHUB_REF_NAME" + case "$GITHUB_REF_NAME" in + *-*) ;; # pre-releases (v1.0.0-rc1) don't move :latest + *) tags="$tags -t $image:latest" ;; + esac + docker buildx build --platform linux/amd64,linux/arm64 $tags --push . diff --git a/.gitignore b/.gitignore index c4a3465..42af6ef 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ bin/ *-data/ .env wg.key +dist/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4423af0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +# syntax=docker/dockerfile:1 +# Packages the binaries deploy/build.sh already built (dist/bin/linux_/bamboo), +# so the arm64 image needs no emulation. Build it with: sh deploy/build.sh && docker buildx build . +FROM --platform=$BUILDPLATFORM alpine:3.22 AS prep +RUN apk add --no-cache ca-certificates \ + && mkdir /data && chown 65532:65532 /data + +FROM scratch +ARG TARGETARCH +LABEL org.opencontainers.image.title="Bamboo" \ + org.opencontainers.image.description="Site agent: connects a private network to the edge over WireGuard" \ + org.opencontainers.image.source="https://git.veillax.com/Veillax/bamboo" \ + org.opencontainers.image.licenses="AGPL-3.0-only" +COPY --from=prep /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +COPY --from=prep --chown=65532:65532 /data /data +COPY dist/bin/linux_${TARGETARCH}/bamboo /bamboo +USER 65532:65532 +ENV BAMBOO_DATA=/data +VOLUME ["/data"] +ENTRYPOINT ["/bamboo"] diff --git a/README.md b/README.md index d9aaa93..54be7c8 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,40 @@ Settings and secrets can live in a `.env` file instead of the command line; see - **Docker discovery:** with `-docker`, lists running containers (and their ports and networks) so you can pick one as a target in the dashboard. - **Mail relay:** for a mail egress configured in the dashboard, opens a small SMTP relay on this machine (default `127.0.0.1:2525`). Point your mail server at it (Postfix: `relayhost = [127.0.0.1]:2525`) and each outgoing message leaves from the edge's public IP, so reverse DNS, SPF and your HELO name line up. Supports `AUTH PLAIN/LOGIN`, requires a password if it listens beyond `127.0.0.1`, relays one destination domain per transaction (extra domains get `452` so your server resends them separately), and passes your server's queue and retries through unchanged. 50 MB message limit. +## Deploying + +Releases are built by Gitea Actions. Pushing a tag such as `v0.2.0` runs `.gitea/workflows/release.yml`, which tests the code, builds Bamboo for Linux (amd64, arm64, armv7), Windows (amd64) and macOS (amd64, arm64), publishes the archives with a `SHA256SUMS` file on the Releases page, and pushes a multi-architecture container image to this server's registry (`git.veillax.com/veillax/bamboo`). Every push and pull request runs `.gitea/workflows/ci.yml`: formatting, vet, tests, and a check that every release platform still compiles. + +**On a Linux server (systemd)** + +``` +curl -LO https://git.veillax.com/Veillax/bamboo/releases/download/v0.1.0/bamboo_v0.1.0_linux_amd64.tar.gz +curl -LO https://git.veillax.com/Veillax/bamboo/releases/download/v0.1.0/SHA256SUMS +sha256sum -c --ignore-missing SHA256SUMS +tar xzf bamboo_v0.1.0_linux_amd64.tar.gz && cd bamboo_v0.1.0_linux_amd64 +sudo ./install.sh # installs the binary and a hardened systemd service +sudoedit /etc/bamboo/bamboo.env # your settings and secrets +sudo systemctl start bamboo +``` + +Use `linux_arm64` or `linux_arm` for Raspberry Pi and other ARM machines. To upgrade, repeat the same steps: your settings and data are kept. `sudo ./install.sh --uninstall` removes the service and binary but leaves `/etc/bamboo` and `/var/lib/bamboo` alone. `bamboo -version` tells you which build is running. + +**With Docker** + +``` +cp .env.example .env # fill it in +docker compose -f deploy/docker-compose.yml up -d +``` + +The image is `git.veillax.com/veillax/bamboo:` (and `:latest` for full releases). The compose file is in the repository and inside each Linux archive. + +**Setting up the build (once)** + +- A Gitea Actions runner with the label `ubuntu-latest`, and Docker on it if you want the container image. +- Nothing else is required: the workflows use the automatic job token. If your server doesn't let that token publish releases or packages, add the secrets `RELEASE_TOKEN` and `REGISTRY_TOKEN` (access tokens with release and package write permission) and optionally the variable `REGISTRY_USER`. +- To skip the container image, set the repository variable `PUBLISH_DOCKER` to `false`. +- Build locally with `sh deploy/build.sh` (archives in `dist/`). + ## Build and test ``` diff --git a/cmd/bamboo/main.go b/cmd/bamboo/main.go index a2242c6..7683334 100644 --- a/cmd/bamboo/main.go +++ b/cmd/bamboo/main.go @@ -13,6 +13,9 @@ import ( "git.veillax.com/Veillax/reef/dotenv" ) +// version is set at build time (deploy/build.sh). +var version = "dev" + func env(k, def string) string { if v := os.Getenv(k); v != "" { return v @@ -31,11 +34,17 @@ func main() { data := flag.String("data", env("BAMBOO_DATA", "./bamboo-data"), "directory for this agent's WireGuard key") dockerOn := flag.Bool("docker", os.Getenv("BAMBOO_DOCKER") == "1", "report local Docker containers so they can be picked as targets in the UI") flag.String("env", ".env", "file to read secrets and settings from") + showVersion := flag.Bool("version", false, "print the version and exit") flag.Parse() + if *showVersion { + os.Stdout.WriteString("bamboo " + version + "\n") + return + } if *hh == "" || *token == "" { flag.Usage() os.Exit(2) } + log.Printf("bamboo %s starting", version) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() b, err := bamboo.New(bamboo.Config{Hammerhead: strings.TrimRight(*hh, "/"), Token: *token, DataDir: *data, Docker: *dockerOn}) diff --git a/deploy/bamboo.service b/deploy/bamboo.service new file mode 100644 index 0000000..3ca444a --- /dev/null +++ b/deploy/bamboo.service @@ -0,0 +1,29 @@ +[Unit] +Description=Bamboo: site agent that connects a private network to the edge +Documentation=https://git.veillax.com/Veillax/bamboo +After=network-online.target +Wants=network-online.target + +[Service] +User=bamboo +Group=bamboo +EnvironmentFile=-/etc/bamboo/bamboo.env +ExecStart=/usr/local/bin/bamboo -data /var/lib/bamboo +WorkingDirectory=/var/lib/bamboo +Restart=on-failure +RestartSec=3 + +# It makes outbound connections only, so it needs no privileges at all. +NoNewPrivileges=true +ProtectSystem=strict +ReadWritePaths=/var/lib/bamboo +ProtectHome=true +PrivateTmp=true +ProtectKernelTunables=true +ProtectControlGroups=true + +# To list this machine's Docker containers (BAMBOO_DOCKER=1), let the service read +# the Docker socket: sudo usermod -aG docker bamboo then restart it. + +[Install] +WantedBy=multi-user.target diff --git a/deploy/build.sh b/deploy/build.sh new file mode 100644 index 0000000..b865674 --- /dev/null +++ b/deploy/build.sh @@ -0,0 +1,50 @@ +#!/bin/sh +# Builds bamboo for every release platform and packs the archives into dist/. +# +# deploy/build.sh [version] version defaults to `git describe` +# +# Output: +# dist/bin/_/bamboo the raw binaries (the Docker image uses the linux ones) +# dist/bamboo___.tar.gz or .zip +# dist/SHA256SUMS +# +# Set TARGETS="linux/amd64 linux/arm64" to build fewer platforms. +set -eu +NAME=bamboo +cd "$(dirname "$0")/.." +VERSION=${1:-$(git describe --tags --always 2>/dev/null || echo dev)} +TARGETS=${TARGETS:-"linux/amd64 linux/arm64 linux/arm windows/amd64 darwin/amd64 darwin/arm64"} + +rm -rf dist +mkdir -p dist/bin dist/stage +for t in $TARGETS; do + os=${t%/*} + arch=${t#*/} + ext="" + [ "$os" = windows ] && ext=".exe" + out="dist/bin/${os}_${arch}" + mkdir -p "$out" + echo "building $NAME $VERSION for $os/$arch" + CGO_ENABLED=0 GOOS=$os GOARCH=$arch GOARM=7 \ + go build -trimpath -ldflags "-s -w -X main.version=$VERSION" -o "$out/$NAME$ext" "./cmd/$NAME" + + pkg="${NAME}_${VERSION}_${os}_${arch}" + stage="dist/stage/$pkg" + mkdir -p "$stage" + cp "$out/$NAME$ext" "$stage/" + for f in LICENSE README.md .env.example; do + [ -f "$f" ] && cp "$f" "$stage/" + done + if [ "$os" = linux ]; then + cp deploy/install.sh "deploy/$NAME.service" deploy/docker-compose.yml "$stage/" + chmod +x "$stage/install.sh" + fi + if [ "$os" = windows ] && command -v zip >/dev/null 2>&1; then + (cd dist/stage && zip -qr "../$pkg.zip" "$pkg") + else + tar -C dist/stage -czf "dist/$pkg.tar.gz" "$pkg" + fi +done +rm -rf dist/stage +(cd dist && sha256sum $(ls | grep -E '\.(tar\.gz|zip)$') > SHA256SUMS) +echo "done: $(ls dist | grep -cE '\.(tar\.gz|zip)$') archives in dist/" diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..24f3698 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,23 @@ +# Run Bamboo with Docker inside your home or private network: +# cp .env.example .env (then set BAMBOO_HAMMERHEAD and BAMBOO_TOKEN) +# docker compose up -d +# +# Bamboo only makes outbound connections, so nothing needs opening. Host networking +# lets it reach services on this machine (127.0.0.1) as well as the rest of your LAN; +# switch it off if your targets are all on other machines. +services: + bamboo: + image: git.veillax.com/veillax/bamboo:latest + restart: unless-stopped + network_mode: host + env_file: .env + volumes: + - bamboo-data:/data + # To list this machine's Docker containers as targets (BAMBOO_DOCKER=1), share the + # socket and let the container's user read it (find the group id: getent group docker): + # - /var/run/docker.sock:/var/run/docker.sock:ro + # group_add: + # - "999" + +volumes: + bamboo-data: diff --git a/deploy/install.sh b/deploy/install.sh new file mode 100644 index 0000000..ee0a3e3 --- /dev/null +++ b/deploy/install.sh @@ -0,0 +1,73 @@ +#!/bin/sh +# Installs bamboo as a systemd service from the files in this folder. +# +# sudo ./install.sh install or upgrade (your settings and data are kept) +# sudo ./install.sh --start the same, then start (or restart) the service +# sudo ./install.sh --uninstall remove the service and binary (keeps the settings and data) +# +# Files: /usr/local/bin/bamboo, /etc/systemd/system/bamboo.service, +# /etc/bamboo/bamboo.env (your settings and secrets), /var/lib/bamboo (data). +# Set DESTDIR=/some/dir to stage everything there without touching the system. +set -eu +NAME=bamboo +HERE=$(cd "$(dirname "$0")" && pwd) +ROOT=${DESTDIR:-} +BIN=$ROOT/usr/local/bin/$NAME +UNIT=$ROOT/etc/systemd/system/$NAME.service +CONF=$ROOT/etc/$NAME +ENVFILE=$CONF/$NAME.env +DATA=$ROOT/var/lib/$NAME + +say() { printf '%s\n' "$*"; } +real() { [ -z "${DESTDIR:-}" ]; } # are we touching the real system? + +if real; then + [ "$(id -u)" = 0 ] || { say "Run this as root: sudo $0 $*"; exit 1; } + command -v systemctl > /dev/null 2>&1 || { say "systemd wasn't found. Run $NAME directly (see the README) or use Docker."; exit 1; } +fi + +if [ "${1:-}" = "--uninstall" ]; then + if real; then + systemctl disable --now "$NAME" > /dev/null 2>&1 || true + fi + rm -f "$UNIT" "$BIN" + if real; then + systemctl daemon-reload || true + fi + say "Removed $NAME. Left in place: $CONF and $DATA (delete them yourself if you want them gone)." + exit 0 +fi + +[ -f "$HERE/$NAME" ] || { say "Can't find the $NAME binary next to this script."; exit 1; } + +if real && ! id "$NAME" > /dev/null 2>&1; then + useradd --system --home-dir "/var/lib/$NAME" --shell /usr/sbin/nologin "$NAME" + say "Created the system user '$NAME'." +fi + +mkdir -p "$ROOT/usr/local/bin" "$ROOT/etc/systemd/system" "$CONF" "$DATA" +install -m 0755 "$HERE/$NAME" "$BIN.new" +mv -f "$BIN.new" "$BIN" # atomic, so upgrading a running service is safe +install -m 0644 "$HERE/$NAME.service" "$UNIT" +if [ ! -f "$ENVFILE" ]; then + install -m 0600 "$HERE/.env.example" "$ENVFILE" + say "Created $ENVFILE. Edit it before starting." +fi +if real; then + chown -R "$NAME:$NAME" "$DATA" +fi + +if real; then + systemctl daemon-reload + systemctl enable "$NAME" > /dev/null 2>&1 + if [ "${1:-}" = "--start" ] || systemctl is-active --quiet "$NAME"; then + systemctl restart "$NAME" + say "$NAME is running." + fi +fi + +say "Installed $("$BIN" -version 2> /dev/null || echo "$NAME")." +say "Next:" +say " 1. edit $ENVFILE" +say " 2. systemctl start $NAME (or run this again with --start)" +say " 3. journalctl -u $NAME -f to watch it" diff --git a/deploy/publish-release.sh b/deploy/publish-release.sh new file mode 100644 index 0000000..94b4fa6 --- /dev/null +++ b/deploy/publish-release.sh @@ -0,0 +1,60 @@ +#!/bin/sh +# Creates (or updates) the Gitea release for a tag and uploads what deploy/build.sh made. +# +# TOKEN= deploy/publish-release.sh v0.2.0 +# +# In Gitea Actions GITHUB_SERVER_URL and GITHUB_REPOSITORY are already set. Safe to +# re-run: an existing release is reused and assets with the same name are replaced. +set -eu +TAG=${1:?usage: publish-release.sh } +: "${TOKEN:?set TOKEN to a Gitea access token that can write releases}" +SERVER=${GITHUB_SERVER_URL:-${GITEA_SERVER_URL:-https://git.veillax.com}} +REPO=${GITHUB_REPOSITORY:-Veillax/bamboo} +API="$SERVER/api/v1/repos/$REPO" +AUTH="Authorization: token $TOKEN" + +# a JSON helper without needing jq: any python will do +PY=python3 +python3 -c 'pass' > /dev/null 2>&1 || PY=python +json() { "$PY" -c "$1"; } + +# release notes: the commit subjects since the previous tag +prev=$(git describe --tags --abbrev=0 "$TAG^" 2>/dev/null || true) +if [ -n "$prev" ]; then + notes=$(git log --pretty='format:- %s' "$prev..$TAG") +else + notes=$(git log --pretty='format:- %s' "$TAG") +fi +prerelease=false +case "$TAG" in *-*) prerelease=true ;; esac + +body=$(NOTES="$notes" TAG="$TAG" PRE="$prerelease" json ' +import json, os +print(json.dumps({"tag_name": os.environ["TAG"], "name": os.environ["TAG"], "body": os.environ["NOTES"], + "draft": False, "prerelease": os.environ["PRE"] == "true"}))') + +# reuse the release when a failed job is re-run +id=$(curl -fsS -H "$AUTH" "$API/releases/tags/$TAG" 2>/dev/null | json 'import sys, json; print(json.load(sys.stdin)["id"])' 2>/dev/null || true) +if [ -z "$id" ]; then + echo "creating release $TAG" + id=$(curl -fsS -X POST -H "$AUTH" -H 'Content-Type: application/json' -d "$body" "$API/releases" | json 'import sys, json; print(json.load(sys.stdin)["id"])') +else + echo "release $TAG already exists (id $id): replacing its files" +fi + +existing=$(curl -fsS -H "$AUTH" "$API/releases/$id/assets" | json 'import sys, json +for a in json.load(sys.stdin): + print(a["id"], a["name"])') + +for f in dist/*.tar.gz dist/*.zip dist/SHA256SUMS; do + [ -f "$f" ] || continue + name=$(basename "$f") + echo "$existing" | while read -r aid aname; do + if [ "$aname" = "$name" ]; then + curl -fsS -X DELETE -H "$AUTH" "$API/releases/$id/assets/$aid" > /dev/null + fi + done + echo "uploading $name" + curl -fsS -X POST -H "$AUTH" -F "attachment=@$f" "$API/releases/$id/assets?name=$name" > /dev/null +done +echo "published $SERVER/$REPO/releases/tag/$TAG"