diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml deleted file mode 100644 index 7e0e591..0000000 --- a/.github/workflows/docker-publish.yml +++ /dev/null @@ -1,68 +0,0 @@ -name: Docker Build and Publish - -on: - push: - branches: [ main ] - paths: - - '**/*.go' - - 'go.mod' - - 'go.sum' - - 'Dockerfile' - # IMAGE_NAME follows the repository name, so renaming the repo changes where - # images are published. Nothing pushes on a rename, though, and the paths filter - # above means an unrelated commit will not do it either. This lets you publish - # the first image under a new name by hand. - workflow_dispatch: - -env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} - -jobs: - test: - uses: ./.github/workflows/test.yml - - build-and-push: - needs: test - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - - name: Log in to the Container registry - uses: docker/login-action@v2 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata (tags, labels) for Docker - id: meta - uses: docker/metadata-action@v4 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=sha,format=long - type=ref,event=branch - type=ref,event=pr - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - latest - - - name: Build and push Docker image - uses: docker/build-push-action@v4 - with: - context: . - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - platforms: linux/amd64,linux/arm64,linux/arm64/v8 - cache-from: type=gha - cache-to: type=gha,mode=max diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml new file mode 100644 index 0000000..e9432a7 --- /dev/null +++ b/.github/workflows/goreleaser.yml @@ -0,0 +1,88 @@ +name: goreleaser + +# Builds the release artifacts for an existing tag and pushes the container +# image. Called by release-pipeline.yml right after release-please cuts a tag, +# and available on its own to rebuild a tag whose image push failed. +on: + workflow_call: + inputs: + tag: + description: "Tag to build and publish the image for" + required: true + type: string + workflow_dispatch: + inputs: + tag: + description: "Existing tag to (re)build and publish the image for, e.g. v0.1.0" + required: true + type: string + +permissions: + contents: write + packages: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout tag + uses: actions/checkout@v7 + with: + ref: ${{ inputs.tag }} + # GoReleaser derives the version from the tag, so a shallow checkout + # without tags would make it fall back to a snapshot version. + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: go.mod + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to ghcr.io + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v7 + with: + distribution: goreleaser + version: "~> v2.18" + args: release --config .goreleaser.release.yml --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # release-please creates the release as a draft so it only becomes visible + # once the image it describes is actually pullable. + - name: Publish release + uses: actions/github-script@v9 + env: + TAG: ${{ inputs.tag }} + with: + script: | + const { owner, repo } = context.repo; + const tag = process.env.TAG; + + // getReleaseByTag does not return drafts, so list and match. + const releases = await github.paginate( + github.rest.repos.listReleases, + { owner, repo, per_page: 100 } + ); + const release = releases.find(r => r.tag_name === tag); + if (!release) { + throw new Error(`No release found for tag ${tag}`); + } + + await github.rest.repos.updateRelease({ + owner, + repo, + release_id: release.id, + draft: false, + }); + + console.log(`Published ${tag}: ${release.html_url}`); diff --git a/.github/workflows/release-pipeline.yml b/.github/workflows/release-pipeline.yml new file mode 100644 index 0000000..e170e77 --- /dev/null +++ b/.github/workflows/release-pipeline.yml @@ -0,0 +1,55 @@ +name: release-pipeline + +# On every push to main, release-please keeps a release PR up to date from the +# conventional-commit history. Merging that PR is what cuts a release: it tags +# the commit, drafts the release notes, and hands the tag to goreleaser, which +# builds and pushes the versioned image before the release goes public. +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + packages: write + +jobs: + release-please: + runs-on: ubuntu-latest + outputs: + release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} + steps: + - name: release + id: release + uses: googleapis/release-please-action@v5 + with: + # A PAT rather than GITHUB_TOKEN: pushes and PRs made with + # GITHUB_TOKEN do not trigger workflows, so the release PR would + # never run the tests it is meant to gate on. + token: ${{ secrets.RELEASE_PLEASE_TOKEN }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + + # Nothing gets published without the tests passing. docker-publish.yml used to + # provide this gate; the release pipeline took over its job. + test: + needs: release-please + if: ${{ needs.release-please.outputs.release_created == 'true' }} + uses: ./.github/workflows/test.yml + + # No `secrets: inherit`: the called workflow needs nothing beyond + # GITHUB_TOKEN, which reusable workflows always get, and inheriting would + # hand it RELEASE_PLEASE_TOKEN for no reason. + # + # If this job fails, the tag and the draft release already exist, so a later + # push to main will not retry it — release-please only reports + # release_created once. Re-run the goreleaser workflow directly instead; it + # takes the tag as a workflow_dispatch input for exactly this case. + goreleaser: + needs: [release-please, test] + if: ${{ needs.release-please.outputs.release_created == 'true' }} + uses: ./.github/workflows/goreleaser.yml + with: + tag: ${{ needs.release-please.outputs.tag_name }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5eca78b..97aee65 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,8 +1,8 @@ name: Test -# pull_request covers review; workflow_call lets docker-publish gate on this -# same job instead of duplicating it. Deliberately no push trigger: it would -# double-run on main, once here and once through the call. +# pull_request covers review; workflow_call lets the release pipeline gate on +# this same job instead of duplicating it. Deliberately no push trigger: it +# would double-run on main, once here and once through the call. on: pull_request: workflow_call: @@ -17,14 +17,32 @@ jobs: - name: Checkout repository uses: actions/checkout@v7 + # setup-go caches the module and build caches by default, keyed on go.sum. - name: Set up Go uses: actions/setup-go@v7 with: go-version-file: go.mod + - name: Check formatting (gofmt) + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "Files not gofmt-formatted:" + echo "$unformatted" + exit 1 + fi + - name: Vet run: go vet ./... + # staticcheck itself needs a newer Go than the one go.mod pins for + # doormouse, so let the go command fetch that toolchain for this step. + # Tests still run on the version go.mod declares. + - name: Run staticcheck + run: go run honnef.co/go/tools/cmd/staticcheck@v0.8.1 ./... + env: + GOTOOLCHAIN: auto + # -race catches the data races the ManualClock tests are built to expose; # -shuffle=on stops tests depending on declaration order. - name: Test diff --git a/.gitignore b/.gitignore index 042293f..751b0cd 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ doormouse go-wol-proxy *.migrated.toml + +# goreleaser output +dist/ diff --git a/.goreleaser.release.yml b/.goreleaser.release.yml new file mode 100644 index 0000000..949245c --- /dev/null +++ b/.goreleaser.release.yml @@ -0,0 +1,60 @@ +version: 2 + +project_name: doormouse + +before: + hooks: + - go mod download + +builds: + - id: doormouse + main: . + binary: doormouse + env: + - CGO_ENABLED=0 + flags: + - -trimpath + ldflags: + - -s -w + # doormouse is a Linux daemon that has to sit in the target's broadcast + # domain, and the container image is the only release channel, so there is + # nothing to gain from darwin/windows builds. + goos: [linux] + goarch: [amd64, arm64] + +# The container image is the only published artifact, so goreleaser neither +# builds archives nor touches the GitHub release. release-please owns the +# release and its notes; the workflow undrafts it once the image is pushed. +archives: + - formats: [binary] + +release: + disable: true + +changelog: + disable: true + +dockers_v2: + - id: image + dockerfile: Dockerfile.release + ids: [doormouse] + images: + - ghcr.io/darksworm/doormouse + # Rolling tags let a compose file track a major or minor line and still get + # patch updates. :latest stays for the quick start in the README. + tags: + - "{{ .Version }}" + - "{{ .Major }}.{{ .Minor }}" + - "{{ .Major }}" + - latest + platforms: + - linux/amd64 + - linux/arm64 + labels: + org.opencontainers.image.created: "{{ .Date }}" + org.opencontainers.image.title: "{{ .ProjectName }}" + org.opencontainers.image.description: "A reverse proxy that wakes your servers when someone knocks" + org.opencontainers.image.revision: "{{ .FullCommit }}" + org.opencontainers.image.version: "{{ .Version }}" + org.opencontainers.image.licenses: "GPL-3.0-or-later" + org.opencontainers.image.source: "https://github.com/darksworm/doormouse" diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..e18ee07 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.0.0" +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ec22215..cdcccd5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,6 +15,24 @@ Common types are: - `docs` for documentation changes - `chore` for maintenance +Commit types decide the next version number, so pick them with care. +`feat` bumps the minor version and `fix` the patch version; `docs` and `chore` +do not trigger a release. Mark a breaking change with a `!` after the type, as +in `feat!: drop the old target syntax`. + +## Releases +Releases are automatic. [release-please](https://github.com/googleapis/release-please) +reads the commits landing on `main` and keeps a release pull request open with +the next version and a generated changelog. Merging that pull request tags the +release and publishes the container image; nothing else needs doing by hand. + +The pipeline needs one secret, `RELEASE_PLEASE_TOKEN`, a personal access token +with write access to contents, pull requests and issues. When it expires, +release pull requests simply stop appearing. Run +`scripts/rotate-release-token.sh` to set it up or replace it: it opens the form, +says what to tick, checks the token can actually write to the repository before +storing it, and prints the date it expires. + ## Pull requests - Rebase on the latest `main` branch before submitting. - Keep commits focused; separate unrelated changes. diff --git a/Dockerfile.release b/Dockerfile.release new file mode 100644 index 0000000..c731652 --- /dev/null +++ b/Dockerfile.release @@ -0,0 +1,18 @@ +# Runtime image for released versions. Unlike the top-level Dockerfile, which +# compiles from source for local builds, this one only packages the binary +# GoReleaser has already cross-compiled — so there is no RUN step and no +# emulation cost when building the arm64 image on an amd64 runner. +FROM alpine:3.22 + +WORKDIR /app + +# GoReleaser dockers_v2 places each platform's binary under $TARGETPLATFORM/ +ARG TARGETPLATFORM +COPY ${TARGETPLATFORM}/doormouse /app/doormouse + +# Same contract as the source-built image: the default port, and a config +# mounted at /app/config.toml. TCP routes listen on their own ports; with +# network_mode: host they are reachable directly, otherwise publish each one. +EXPOSE 8080 + +ENTRYPOINT ["/app/doormouse", "/app/config.toml"] diff --git a/README.md b/README.md index cd8a6fe..6a9e436 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,22 @@ or checked into a config repo. A config may use one format or the other, never both. +## Container images + +Every release publishes an image to `ghcr.io/darksworm/doormouse`, built for +`linux/amd64` and `linux/arm64`. Four tags point at it: + +| Tag | Points at | +| --- | --- | +| `0.4.1` | that exact release, and never moves | +| `0.4` | the newest patch in the 0.4 line | +| `0` | the newest release in the 0.x line | +| `latest` | the newest release | + +`latest` is fine for trying doormouse out. Once it is proxying something you +care about, pin the exact version or the minor line, so an upgrade happens when +you choose it. + ## Building from source ```bash diff --git a/main.go b/main.go index 97ac649..a774c09 100644 --- a/main.go +++ b/main.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "io" - "io/ioutil" "log" "net" "net/http" @@ -487,7 +486,7 @@ func NewDefaultSSHExecutor(logger Logger) *DefaultSSHExecutor { func (s *DefaultSSHExecutor) ExecuteCommand(host, user, keyPath, command string) error { // Read private key - key, err := ioutil.ReadFile(keyPath) + key, err := os.ReadFile(keyPath) if err != nil { return fmt.Errorf("unable to read private key: %w", err) } @@ -1259,7 +1258,6 @@ func (p *ProxyService) proxyRequest(w http.ResponseWriter, r *http.Request, rout DialContext: (&net.Dialer{ Timeout: 60 * time.Second, // Increased timeout for slow connections KeepAlive: 60 * time.Second, // Increased keep-alive - DualStack: true, }).DialContext, ForceAttemptHTTP2: true, MaxIdleConns: 100, diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..64c118b --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "go", + "draft": true, + "force-tag-creation": true, + "packages": { + ".": {} + } +} diff --git a/scripts/rotate-release-token.sh b/scripts/rotate-release-token.sh new file mode 100755 index 0000000..2ab9ac6 --- /dev/null +++ b/scripts/rotate-release-token.sh @@ -0,0 +1,239 @@ +#!/usr/bin/env bash +# +# Set up or rotate RELEASE_PLEASE_TOKEN, the personal access token the release +# pipeline uses to open release pull requests and cut tags. +# +# GitHub has no API for creating a personal access token, so one paste is +# unavoidable. This script does the rest: opens the form, tells you exactly what +# to tick, then checks the token really works before installing it, so a wrong +# permission fails here rather than quietly three weeks later when a release is +# due. +# +set -euo pipefail + +REPO="${REPO:-darksworm/doormouse}" +SECRET_NAME="RELEASE_PLEASE_TOKEN" +NEW_TOKEN_URL="https://github.com/settings/personal-access-tokens/new" + +usage() { + cat < $REPO + Contents ............. Read and write (commits, tags, releases) + Pull requests ........ Read and write (the release pull request) + Issues ............... Read and write (labels on the release pull request) + +Then: + scripts/rotate-release-token.sh open the form, then prompt + pass show gh/doormouse-release | scripts/rotate-release-token.sh + scripts/rotate-release-token.sh --dry-run validate it, install nothing + scripts/rotate-release-token.sh --no-browser prompt without opening a browser + +Set REPO in the environment to target a different repository. +EOF +} + +dry_run=false +open_browser=true +for arg in "$@"; do + case "$arg" in + --dry-run) dry_run=true ;; + --no-browser) open_browser=false ;; + -h | --help) + usage + exit 0 + ;; + *) + echo "unknown argument: $arg" >&2 + usage >&2 + exit 2 + ;; + esac +done + +command -v gh >/dev/null || { + echo "error: the GitHub CLI (gh) is required" >&2 + exit 1 +} + +# Hand the URL to whatever opens links here. Backgrounded because some browsers +# hold the terminal, and best-effort because plenty of machines have no opener at +# all — over SSH, in a container. +open_url() { + if [ -n "${BROWSER:-}" ] && command -v "${BROWSER%% *}" >/dev/null 2>&1; then + "$BROWSER" "$1" >/dev/null 2>&1 & + return 0 + fi + for opener in xdg-open open wslview; do + if command -v "$opener" >/dev/null 2>&1; then + "$opener" "$1" >/dev/null 2>&1 & + return 0 + fi + done + return 1 +} + +# Read the token from a prompt when there is a terminal, otherwise from stdin so +# it can be piped out of a password manager. Never echoed, and never passed as an +# argument, so it stays out of the shell history and out of ps. +if [ -t 0 ]; then + cat >&2 < $REPO + + Repository permissions, all three set to Read and write: + Contents ........... commits the changelog, creates tags and releases + Pull requests ...... opens and updates the release pull request + Issues ............. the autorelease label on that pull request + + Metadata turns read-only by itself. Leave the rest alone; the summary + should say "3 permissions" before you generate. + +EOF + if [ "$open_browser" = true ] && open_url "$NEW_TOKEN_URL"; then + echo "Opening $NEW_TOKEN_URL" >&2 + else + echo "Open $NEW_TOKEN_URL" >&2 + fi + echo >&2 + printf 'Paste the token here (input hidden): ' >&2 + IFS= read -rs token + printf '\n' >&2 +else + # An empty stdin makes read fail, which would otherwise end the script here + # without explanation. Fall through to the "no token given" check instead. + IFS= read -r token || true +fi + +# Trim the whitespace a copy-paste tends to bring along. +token="${token#"${token%%[![:space:]]*}"}" +token="${token%"${token##*[![:space:]]}"}" + +[ -n "$token" ] || { + echo "error: no token given" >&2 + exit 1 +} + +# Authenticate as the new token rather than as whoever is running this. Both +# variable names are set because gh prefers GH_TOKEN but honours either. +as_new_token() { + GH_TOKEN="$token" GITHUB_TOKEN="$token" gh "$@" +} + +echo "==> Checking the token against $REPO" + +# .permissions.push is true only when the token holds Contents: Read and write. +# Without it release-please cannot commit the changelog or create the tag. +if ! repo_info=$(as_new_token api "repos/$REPO" -q '[.full_name, .permissions.push] | @tsv' 2>&1); then + # gh prints the API's JSON and then its own one-line summary, tacked onto the + # JSON's closing brace without a newline. The summary is the useful half. + reason=$(printf '%s\n' "$repo_info" | grep -o 'gh: .*' | head -1 || true) + [ -n "$reason" ] || reason=$(printf '%s\n' "$repo_info" | tail -1) + echo "error: the token cannot read $REPO" >&2 + echo " ${reason#gh: }" >&2 + echo " Check the resource owner, and that repository access covers $REPO." >&2 + exit 1 +fi + +full_name=${repo_info%%$'\t'*} +can_push=${repo_info##*$'\t'} + +if [ "$can_push" != "true" ]; then + echo "error: the token has read-only access to $full_name." >&2 + echo " Set Contents to 'Read and write' and try again." >&2 + exit 1 +fi +echo " can write to $full_name" + +# A token's expiry date comes back as a response header, so ask for the headers. +# A token with an expiry date carries one; one set never to expire does +# not, which is worth saying out loud rather than reporting as unknown. +# +# Captured into a variable and parsed in one command on purpose. Piping gh into +# awk and letting awk exit on the first match closes the pipe early, and under +# `set -eo pipefail` the resulting SIGPIPE takes the whole script down without a +# word — which it did, but only for tokens that actually carry the header. +headers=$(as_new_token api -i "repos/$REPO" 2>/dev/null || true) +expiry=$(awk -F': ' ' + tolower($1) == "github-authentication-token-expiration" { + sub(/\r$/, "", $2) + print $2 + }' <<<"$headers") + +if [ -n "$expiry" ]; then + echo " expires $expiry" +else + echo " no expiry reported (a token set never to expire)" +fi + +# GitHub's expiry dropdown defaults to 30 days, which is easy to accept by +# accident and means being back here in a month. Say so before storing, while +# going back to the form is still cheap. `|| true` because parsing the date +# needs GNU date, and a machine without it should not fail the rotation. +MIN_DAYS=${MIN_DAYS:-180} +if [ -n "$expiry" ]; then + expiry_epoch=$(date -d "$expiry" +%s 2>/dev/null || true) + if [ -n "$expiry_epoch" ]; then + days_left=$(((expiry_epoch - $(date +%s)) / 86400)) + if [ "$days_left" -lt "$MIN_DAYS" ]; then + echo + echo "warning: this token lasts $days_left days, less than the $MIN_DAYS this script" + echo " expects. GitHub's Expiration dropdown defaults to 30 days; pick" + echo " Custom and set a date up to a year out instead." + echo + if [ "$dry_run" = false ] && [ -t 0 ]; then + printf 'Store it anyway? [y/N] ' >&2 + IFS= read -r reply || true + case "$reply" in + y | Y | yes | YES) ;; + *) + echo "Nothing stored. Mint a longer-lived token and run this again." >&2 + exit 1 + ;; + esac + fi + fi + fi +fi + +if [ "$dry_run" = true ]; then + echo "==> --dry-run: the secret was not changed" + exit 0 +fi + +echo "==> Storing $SECRET_NAME on $REPO" +printf '%s' "$token" | gh secret set "$SECRET_NAME" --repo "$REPO" + +# Read it back, so "stored" means the API agrees rather than that gh exited 0. +# `|| true` because a hiccup listing secrets must not report the store as failed. +stored=$(gh secret list --repo "$REPO" 2>/dev/null | + awk -v n="$SECRET_NAME" '$1 == n { print }' || true) +if [ -n "$stored" ]; then + echo " $stored" +else + echo "warning: the secret was set but does not show up in the list" >&2 +fi + +cat <