diff --git a/.gitignore b/.gitignore index cd63637..d4c7bc5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ Thumbs.db # Railguard per-session traces and snapshots .railguard/ +# Stray `railguard init` starter in the repo root — the real policy lives in +# agentic-ai/Claude/railguard.yaml. Anchored so only the root file is ignored. +/railguard.yaml linux-server/adguard/work/ linux-server/adguard/conf/ @@ -39,3 +42,6 @@ linux-server/qbittorrent/downloads/ # Backup status JSON written at runtime (served to the homepage card) linux-server/backup/status/ + +# Forgejo runner status JSON + last-state written at runtime (homepage card) +linux-server/forgejo/runner-status/ diff --git a/CLAUDE.md b/CLAUDE.md index 16ec245..5eca18e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,3 +55,21 @@ phases. primary cross-platform test mechanism (only one platform can run live). - App-store packages and `priority: "none"` entries are reminders only — never auto-installed. + +## Privacy & Security + +This repo is **public**. Never commit identifying or secret information. + +- Keep these out of tracked files entirely: tailnet names / MagicDNS suffixes + (`tailXXXXXX.ts.net`), real hostnames, server IPs, usernames, emails, tokens, + auth keys, and personal absolute paths. +- Put any machine-specific or private value in a `.env` file (gitignored + repo-wide) and ship a committed `.env.example` with placeholders instead — + e.g. `linux-server/forgejo/.env.example`, `macOS/forgejo-runner/.env.example`. + Scripts read these via `${VAR:-}` and source a local `.env` when + present; they never hardcode the real value. +- In docs and configs use placeholders: ``, ``, + ``, ``. Default to `.env` whenever a value is + identifying — prefer one more env var over leaking a real value. +- When editing, scan the diff for accidentally introduced real identifiers + before committing. diff --git a/agentic-ai/Claude/railguard.yaml b/agentic-ai/Claude/railguard.yaml index e8f7aff..9cbca6f 100644 --- a/agentic-ai/Claude/railguard.yaml +++ b/agentic-ai/Claude/railguard.yaml @@ -26,6 +26,10 @@ allowlist: [] fence: enabled: true + # Opt in to project-local `.railguard.local.yaml` files, which may only ADD + # to allowed_paths (never remove denies; denied_paths always win). Grants + # nothing by itself — each project must ship its own .railguard.local.yaml. + allow_local_overrides: true allowed_paths: - "~/.claude" - "/tmp" diff --git a/linux-server/README.md b/linux-server/README.md index 3e6eee7..e90f646 100644 --- a/linux-server/README.md +++ b/linux-server/README.md @@ -135,7 +135,7 @@ The Tailscale widget uses a local OAuth proxy (`linux-server/tailscale-proxy`) t Get a TLS cert from Tailscale and configure NPM to terminate HTTPS: ```sh -tailscale cert # e.g. ollie-server..ts.net +tailscale cert # e.g. ..ts.net ``` In NPM admin (`http://:81`): @@ -289,13 +289,13 @@ In NPM admin (`http://:81`): 2. Deploy: ```sh cd linux-server/forgejo - cp .env.example .env # set FORGEJO_DOMAIN to your Tailscale hostname; optionally set FORGEJO_DATA_PATH for external drive + cp .env.example .env # set FORGEJO_DOMAIN (forgejo..ts.net) and TS_AUTHKEY; optionally FORGEJO_DATA_PATH for external drive docker compose up -d ``` - 3. Web UI at `http://:3300` — complete the setup wizard on first visit, create admin account - 4. Git over SSH on port `2222`: + 3. Runs behind a Tailscale sidecar (its own tailnet device, HTTPS via `tailscale serve`), so it's reachable only on the tailnet — no host port. Web UI at `https://forgejo..ts.net/` — complete the setup wizard on first visit, create admin account + 4. Git over SSH on port `22` (the sidecar's own tailnet device, no conflict with the host's sshd): ```sh - git clone ssh://git@:2222//.git + git clone ssh://git@forgejo..ts.net:22//.git ``` 5. Add your SSH public key in **Settings → SSH / GPG Keys** after creating your account 6. To migrate from GitHub: use Forgejo's built-in migration (**+ → New Migration → GitHub**), then optionally configure a push mirror back to GitHub under repo **Settings → Push Mirrors** while validating the setup diff --git a/linux-server/forgejo/.env.example b/linux-server/forgejo/.env.example index 654bab1..5fd9e01 100644 --- a/linux-server/forgejo/.env.example +++ b/linux-server/forgejo/.env.example @@ -7,8 +7,10 @@ TS_AUTHKEY= # Forgejo's own MagicDNS name = the sidecar `hostname:` (forgejo) + your tailnet. # Find your tailnet: tailscale status --json | jq -r '.Self.DNSName' | sed 's/\.$//' -# (that prints ollie-server..ts.net — swap the host part for `forgejo`) -FORGEJO_DOMAIN=forgejo..ts.net +# (that prints ..ts.net — swap the host part for `forgejo`) +# Quoted because runner-status.sh sources this file (the <...> placeholder would +# otherwise be read as a shell redirection). +FORGEJO_DOMAIN="forgejo..ts.net" # Where to store repositories and Forgejo config. # Default: ./data (same directory as docker-compose.yml) @@ -16,3 +18,24 @@ FORGEJO_DOMAIN=forgejo..ts.net FORGEJO_DATA_PATH=./data TZ=America/Los_Angeles + +# --- Runner status monitor (runner-status.sh) ------------------------------ +# Server-side check that the Mac mini Actions runner is connected, surfaced on +# the homepage card, Uptime Kuma, and ntfy. The runner itself lives in +# macOS/forgejo-runner/. Leave the optional vars blank to skip that surface. + +# Forgejo token that can read runners. The default API URL is the instance +# (admin) scope, so this needs an admin token; create one at +# Forgejo → Settings → Applications → Generate Token. +FORGEJO_RUNNER_API_TOKEN= +# Runner name as registered in Forgejo (Settings → Actions → Runners). +RUNNER_NAME=m4-mini +# Override only if your token is org/repo-scoped rather than instance/admin: +# FORGEJO_RUNNER_API_URL="https://forgejo..ts.net/api/v1/repos///actions/runners" + +# Uptime Kuma push monitor (optional). Create a Push monitor, paste its URL. +KUMA_PUSH_URL= +# ntfy down/recovery alerts (optional). Quoted: sourced by runner-status.sh. +NTFY_URL="https://ntfy..ts.net" +NTFY_TOPIC=server-runner +NTFY_TOKEN= diff --git a/linux-server/forgejo/docker-compose.yml b/linux-server/forgejo/docker-compose.yml index 28fdbe2..3372942 100644 --- a/linux-server/forgejo/docker-compose.yml +++ b/linux-server/forgejo/docker-compose.yml @@ -47,3 +47,16 @@ services: interval: 30s timeout: 5s retries: 3 + + # Loopback static server for the homepage "forgejo-runner" card. The host + # systemd timer (forgejo-runner-status.timer → runner-status.sh) writes + # runner-status.json here; homepage is host-networked, so localhost reaches it. + # Not user-facing — bound to loopback, no Tailscale sidecar. + forgejo-runner-status: + image: nginx:alpine + container_name: forgejo-runner-status + restart: unless-stopped + ports: + - "127.0.0.1:8098:80" + volumes: + - ./runner-status:/usr/share/nginx/html:ro diff --git a/linux-server/forgejo/forgejo-runner-status.service b/linux-server/forgejo/forgejo-runner-status.service new file mode 100644 index 0000000..9280ab2 --- /dev/null +++ b/linux-server/forgejo/forgejo-runner-status.service @@ -0,0 +1,8 @@ +[Unit] +Description=Forgejo Actions runner status check (Mac mini) +After=network-online.target docker.service +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/home/ollie/github/Computer-Setup/linux-server/forgejo/runner-status.sh diff --git a/linux-server/forgejo/forgejo-runner-status.timer b/linux-server/forgejo/forgejo-runner-status.timer new file mode 100644 index 0000000..8ae21bf --- /dev/null +++ b/linux-server/forgejo/forgejo-runner-status.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Poll the Forgejo runner status every 2 minutes + +[Timer] +OnBootSec=2min +OnUnitActiveSec=2min +Unit=forgejo-runner-status.service + +[Install] +WantedBy=timers.target diff --git a/linux-server/forgejo/runner-status.sh b/linux-server/forgejo/runner-status.sh new file mode 100755 index 0000000..88b7945 --- /dev/null +++ b/linux-server/forgejo/runner-status.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Server-side check that the Mac mini Forgejo Actions runner is connected. +# Surfaces the result three ways: a JSON file for the homepage card, an Uptime +# Kuma push, and an ntfy alert when the runner transitions offline (and when it +# recovers). Run on a 2-minute systemd timer (forgejo-runner-status.timer). +# +# "Up" means Forgejo's API currently reports the runner as idle/active — i.e. +# the server actually sees it connected, not merely that the host pings. The +# runner itself lives in macOS/forgejo-runner/. + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" + +if [[ -f "$SCRIPT_DIR/.env" ]]; then + set -a + # shellcheck disable=SC1091 + source "$SCRIPT_DIR/.env" + set +a +fi + +: "${RUNNER_NAME:=m4-mini}" +: "${FORGEJO_DOMAIN:?set FORGEJO_DOMAIN in .env}" +# Endpoint that lists runners. Defaults to the instance (admin) scope; override +# in .env if your token is org/repo-scoped instead. +: "${FORGEJO_RUNNER_API_URL:=https://${FORGEJO_DOMAIN}/api/v1/admin/actions/runners}" +: "${FORGEJO_RUNNER_API_TOKEN:?set FORGEJO_RUNNER_API_TOKEN in .env (a Forgejo token that can read runners)}" +: "${STATUS_JSON:=$SCRIPT_DIR/runner-status/runner-status.json}" +: "${STATE_FILE:=$SCRIPT_DIR/runner-status/.last-state}" +: "${KUMA_PUSH_URL:=}" +: "${NTFY_TOPIC:=server-runner}" + +command -v jq >/dev/null || { printf 'error: jq not installed (apt install jq)\n' >&2; exit 1; } + +notify() { + local title="$1" priority="$2" tags="$3" msg="$4" + [[ -n "${NTFY_URL:-}" ]] || return 0 + local args=(-fsS -H "Title: $title" -H "Priority: $priority" -H "Tags: $tags" -d "$msg") + [[ -n "${NTFY_TOKEN:-}" ]] && args+=(-H "Authorization: Bearer $NTFY_TOKEN") + curl "${args[@]}" "$NTFY_URL/$NTFY_TOPIC" >/dev/null 2>&1 || true +} + +kuma_push() { + local status="$1" msg="$2" + [[ -n "$KUMA_PUSH_URL" ]] || return 0 + curl -fsS -G \ + --data-urlencode "status=$status" \ + --data-urlencode "msg=$msg" \ + "$KUMA_PUSH_URL" >/dev/null 2>&1 || true +} + +# --- query Forgejo ---------------------------------------------------------- +resp="$(curl -fsS -m 10 -H "Authorization: token $FORGEJO_RUNNER_API_TOKEN" \ + "$FORGEJO_RUNNER_API_URL" 2>/dev/null || true)" + +# The list endpoint may return a bare array or a {runners|entries:[...]} wrapper. +runners='[]' +if [[ -n "$resp" ]]; then + runners="$(jq -c 'if type=="array" then . else (.runners // .entries // .data // []) end' <<<"$resp" 2>/dev/null || echo '[]')" +fi + +runner="$(jq -c --arg n "$RUNNER_NAME" 'map(select(.name==$n)) | .[0] // {}' <<<"$runners")" +status="$(jq -r '.status // "unknown"' <<<"$runner")" +busy="$(jq -r 'if .busy == true then true else false end' <<<"$runner")" + +state=down +case "$status" in + idle | active) state=up ;; +esac + +# --- homepage JSON ---------------------------------------------------------- +mkdir -p "$(dirname "$STATUS_JSON")" +jq -n \ + --arg state "$state" \ + --arg status "$status" \ + --arg runner "$RUNNER_NAME" \ + --argjson busy "$busy" \ + --arg checked "$(date -u +%FT%TZ)" \ + '{state:$state, status:$status, runner:$runner, busy:$busy, checked:$checked}' \ + >"$STATUS_JSON" + +# --- Uptime Kuma ------------------------------------------------------------ +if [[ "$state" == up ]]; then + kuma_push up "$RUNNER_NAME: $status" +else + kuma_push down "$RUNNER_NAME: $status — Forgejo does not see the runner" +fi + +# --- ntfy, only on a state change ------------------------------------------ +prev="$(cat "$STATE_FILE" 2>/dev/null || true)" +if [[ -z "$prev" ]]; then + printf '%s\n' "$state" >"$STATE_FILE" # seed on first run, no alert +elif [[ "$state" != "$prev" ]]; then + if [[ "$state" == down ]]; then + notify "Forgejo runner DOWN" urgent rotating_light \ + "$RUNNER_NAME is $status — Forgejo no longer sees the runner. Check the Mac mini: bash macOS/forgejo-runner/run.sh status" + else + notify "Forgejo runner recovered" default white_check_mark "$RUNNER_NAME is back ($status)" + fi + printf '%s\n' "$state" >"$STATE_FILE" +fi + +printf '[runner-status] %s: %s (%s)\n' "$RUNNER_NAME" "$state" "$status" diff --git a/linux-server/homepage/.env.example b/linux-server/homepage/.env.example index b8324dc..737557f 100644 --- a/linux-server/homepage/.env.example +++ b/linux-server/homepage/.env.example @@ -3,7 +3,7 @@ # Used in services.yaml for service hrefs — use hostname (avahi mDNS) to avoid hardcoding an IP # Access the dashboard at http://.local:3000 -HOMEPAGE_VAR_SERVER_IP=ollie-server.local +HOMEPAGE_VAR_SERVER_IP=.local # Tailscale widget uses a local proxy (linux-server/tailscale-proxy) — configure credentials there @@ -77,8 +77,8 @@ HOMEPAGE_VAR_NPM_DOMAIN=npm..ts.net TS_AUTHKEY= # Server hostname — used to build HOMEPAGE_ALLOWED_HOSTS in docker-compose.yml -HOSTNAME=ollie-server +HOSTNAME= # Server LAN IP — used in HOMEPAGE_ALLOWED_HOSTS to allow direct IP access -SERVER_IP=192.168.1.72 +SERVER_IP= # Tailscale HTTPS hostname — run: tailscale status --json | jq -r '.Self.DNSName' | sed 's/\.$//' TAILSCALE_HOSTNAME=..ts.net diff --git a/linux-server/homepage/config/services.yaml b/linux-server/homepage/config/services.yaml index 9e79d98..a2794e8 100644 --- a/linux-server/homepage/config/services.yaml +++ b/linux-server/homepage/config/services.yaml @@ -127,6 +127,83 @@ server: my-docker container: ntfy +- Storage: + - forgejo: + icon: si-forgejo + href: https://{{HOMEPAGE_VAR_FORGEJO_DOMAIN}}/ + description: Self-hosted git + server: my-docker + container: forgejo + widget: + type: gitea + url: https://{{HOMEPAGE_VAR_FORGEJO_DOMAIN}} + key: "{{HOMEPAGE_VAR_FORGEJO_TOKEN}}" + - forgejo-runner: + icon: mdi-robot + description: Mac mini CI runner + server: my-docker + container: forgejo-runner-status + widget: + type: customapi + # runner-status.sh writes this JSON; the loopback nginx in + # linux-server/forgejo serves it. homepage is host-networked. + url: http://localhost:8098/runner-status.json + refreshInterval: 60000 + mappings: + - field: state + label: Runner + format: text + - field: status + label: Status + format: text + - field: checked + label: Checked + format: relativeDate + - syncthing: + icon: si-syncthing + href: https://{{HOMEPAGE_VAR_SYNCTHING_DOMAIN}}/ + description: File sync + server: my-docker + container: syncthing + widget: + type: customapi + url: https://{{HOMEPAGE_VAR_SYNCTHING_DOMAIN}}/rest/system/status + headers: + X-API-Key: "{{HOMEPAGE_VAR_SYNCTHING_KEY}}" + mappings: + - field: uptime + label: Uptime + format: duration + - field: cpuPercent + label: CPU + format: percent + - field: alloc + label: Memory + format: bytes + - filebrowser: + icon: mdi-folder-network + href: https://{{HOMEPAGE_VAR_FILEBROWSER_DOMAIN}}/ + description: Web file manager + server: my-docker + container: filebrowser + - atvloadly: + icon: mdi-television-play + href: https://{{HOMEPAGE_VAR_ATVLOADLY_DOMAIN}}/ + description: Apple TV IPA sideloader + server: my-docker + container: atvloadly + - qbittorrent: + icon: si-qbittorrent + href: https://{{HOMEPAGE_VAR_QBITTORRENT_DOMAIN}}/ + description: BitTorrent client + server: my-docker + container: qbittorrent + widget: + type: qbittorrent + url: https://{{HOMEPAGE_VAR_QBITTORRENT_DOMAIN}} + username: "{{HOMEPAGE_VAR_QBITTORRENT_USERNAME}}" + password: "{{HOMEPAGE_VAR_QBITTORRENT_PASSWORD}}" + - NAS: - wd 14tb r/w: icon: mdi-harddisk @@ -195,59 +272,3 @@ url: http://localhost:61208 version: 4 metric: fs:/mnt/wd1tb - -- Storage: - - forgejo: - icon: si-forgejo - href: https://{{HOMEPAGE_VAR_FORGEJO_DOMAIN}}/ - description: Self-hosted git - server: my-docker - container: forgejo - widget: - type: gitea - url: https://{{HOMEPAGE_VAR_FORGEJO_DOMAIN}} - key: "{{HOMEPAGE_VAR_FORGEJO_TOKEN}}" - - syncthing: - icon: si-syncthing - href: https://{{HOMEPAGE_VAR_SYNCTHING_DOMAIN}}/ - description: File sync - server: my-docker - container: syncthing - widget: - type: customapi - url: https://{{HOMEPAGE_VAR_SYNCTHING_DOMAIN}}/rest/system/status - headers: - X-API-Key: "{{HOMEPAGE_VAR_SYNCTHING_KEY}}" - mappings: - - field: uptime - label: Uptime - format: duration - - field: cpuPercent - label: CPU - format: percent - - field: alloc - label: Memory - format: bytes - - filebrowser: - icon: mdi-folder-network - href: https://{{HOMEPAGE_VAR_FILEBROWSER_DOMAIN}}/ - description: Web file manager - server: my-docker - container: filebrowser - - atvloadly: - icon: mdi-television-play - href: https://{{HOMEPAGE_VAR_ATVLOADLY_DOMAIN}}/ - description: Apple TV IPA sideloader - server: my-docker - container: atvloadly - - qbittorrent: - icon: si-qbittorrent - href: https://{{HOMEPAGE_VAR_QBITTORRENT_DOMAIN}}/ - description: BitTorrent client - server: my-docker - container: qbittorrent - widget: - type: qbittorrent - url: https://{{HOMEPAGE_VAR_QBITTORRENT_DOMAIN}} - username: "{{HOMEPAGE_VAR_QBITTORRENT_USERNAME}}" - password: "{{HOMEPAGE_VAR_QBITTORRENT_PASSWORD}}" diff --git a/linux-server/nginx-proxy-manager/docker-compose.yml b/linux-server/nginx-proxy-manager/docker-compose.yml index 005eaf8..faffd50 100644 --- a/linux-server/nginx-proxy-manager/docker-compose.yml +++ b/linux-server/nginx-proxy-manager/docker-compose.yml @@ -31,7 +31,7 @@ services: container_name: nginx-proxy-manager restart: unless-stopped ports: - - "80:80" # HTTP proxy (redirects ollie-server.local → HTTPS) + - "80:80" # HTTP proxy (redirects .local → HTTPS) - "443:443" # HTTPS proxy - "81:81" # admin UI volumes: diff --git a/linux-server/post-install.md b/linux-server/post-install.md index 9e848f9..6e2236f 100644 --- a/linux-server/post-install.md +++ b/linux-server/post-install.md @@ -46,7 +46,7 @@ cd linux-server/homepage && docker compose restart | Variable | How to get the value | |---|---| -| `HOMEPAGE_VAR_SERVER_IP` | Your server hostname (e.g. `ollie-server.local`) | +| `HOMEPAGE_VAR_SERVER_IP` | Your server hostname (e.g. `.local`) | | `HOMEPAGE_VAR_TAILSCALE_IP` | `tailscale ip -4` | | `HOMEPAGE_VAR_ADGUARD_USER` / `_PASS` | Set after AdGuard wizard (step 5 below) | | `HOMEPAGE_VAR_SYNCTHING_KEY` | Set after Syncthing is running (step 5 below) | @@ -84,7 +84,7 @@ The Homepage Tailscale widget uses a local OAuth proxy to avoid 90-day key rotat - [ ] Get a TLS cert: ```sh tailscale cert - # e.g. tailscale cert ollie-server..ts.net + # e.g. tailscale cert ..ts.net ``` - [ ] In NPM admin (`http://:81`): - **SSL Certificates → Add Custom Certificate** — paste `.crt` and `.key` file contents; save as e.g. "tailscale cert" @@ -127,22 +127,27 @@ The Homepage Tailscale widget uses a local OAuth proxy to avoid 90-day key rotat - Create admin credentials — then add them to `homepage/.env` - [ ] Point your router's DNS to `` for network-wide filtering -### Forgejo — http://\:3300 +### Forgejo — https://\/ + +Forgejo runs behind a Tailscale sidecar (HTTPS via `tailscale serve`), so it is +reachable only on the tailnet at `https://forgejo..ts.net/` — there is +no `` host port. - [ ] Copy and edit the env file: ```sh cd linux-server/forgejo && cp .env.example .env - # Set FORGEJO_DOMAIN to your Tailscale hostname + # Set FORGEJO_DOMAIN to forgejo..ts.net + # Set TS_AUTHKEY (tailscale.com/admin/settings/keys) so the sidecar can join # Optionally set FORGEJO_DATA_PATH to an external drive path ``` - [ ] Start Forgejo: ```sh docker compose up -d ``` -- [ ] Open `http://:3300` and complete the setup wizard: +- [ ] Open `https://forgejo..ts.net/` and complete the setup wizard: - Database: SQLite (pre-set) - SSH server domain and port: pre-filled from `.env` — verify they look correct - - Application URL: should match `http://:3300` + - Application URL: should match `https://forgejo..ts.net/` - Create the admin account at the bottom of the wizard page - [ ] Generate a personal access token for the Homepage widget: - Top-right avatar → **Settings → Applications → Generate Token** — scope: all (or read-only is enough for the widget) @@ -166,14 +171,44 @@ Everything Forgejo needs to restore from scratch lives in `FORGEJO_DATA_PATH` (d > Hot backups of the SQLite database are safe with Forgejo — it uses WAL mode. A simple `cp` or `rsync` of the data directory while Forgejo is running is sufficient. +#### Runner status monitor + +A host timer (`runner-status.sh`) asks Forgejo whether the Mac mini Actions +runner (see [`../../macOS/forgejo-runner/`](../../macOS/forgejo-runner/)) is +connected, and surfaces it three ways: the homepage **forgejo-runner** card, +an Uptime Kuma push monitor, and an ntfy alert when it drops (and recovers). +Optional — skip if you aren't running CI. + +- [ ] In `forgejo/.env`, set `FORGEJO_RUNNER_API_TOKEN` (a Forgejo token that can + read runners; the default API URL is instance/admin scope) and + `RUNNER_NAME` (the name shown under **Settings → Actions → Runners**, + default `m4-mini`). Optionally set `KUMA_PUSH_URL` (create a Push monitor + in Uptime Kuma and paste its URL) and the `NTFY_*` vars. +- [ ] Start the loopback status server (shipped in `forgejo/docker-compose.yml`): + ```sh + cd linux-server/forgejo && docker compose up -d forgejo-runner-status + ``` +- [ ] Install the timer (polls every 2 minutes): + ```sh + sudo cp forgejo-runner-status.service forgejo-runner-status.timer /etc/systemd/system/ + sudo systemctl daemon-reload + sudo systemctl enable --now forgejo-runner-status.timer + ``` +- [ ] Verify: + ```sh + sudo systemctl start forgejo-runner-status.service + cat runner-status/runner-status.json # "state": "up" when connected + ``` + The homepage card reads the same JSON; Uptime Kuma shows up/down history. + #### Cloning / remotes from Mac ```sh # SSH clone (use this for all git operations on Mac) -git clone ssh://git@:2222//.git +git clone ssh://git@forgejo..ts.net:22//.git # Set as remote on an existing repo -git remote set-url origin ssh://git@:2222//.git +git remote set-url origin ssh://git@forgejo..ts.net:22//.git ``` #### Migrating an existing repo (e.g. Obsidian vault) from GitHub @@ -181,7 +216,7 @@ git remote set-url origin ssh://git@:2222//. 1. In Forgejo web UI: **+ → New Migration → GitHub** — imports history, branches, and tags 2. On Mac, point the local repo at Forgejo: ```sh - git remote set-url origin ssh://git@:2222//.git + git remote set-url origin ssh://git@forgejo..ts.net:22//.git ``` 3. Add GitHub as a push mirror for validation while you transition: - In Forgejo: repo **Settings → Git Hooks → Push Mirrors → Add Push Mirror** @@ -216,4 +251,4 @@ git remote set-url origin ssh://git@:2222//. | Cockpit | https://\:9090 | | | Tailscale Web UI | http://localhost:8088 | After `tailscale up` | | Tailscale proxy | http://localhost:8089 | Internal — used by Homepage widget | -| Forgejo | http://\:3300 | Git over SSH on port 2222 | +| Forgejo | https://forgejo.\.ts.net/ | Tailscale sidecar (HTTPS via serve); Git over SSH on port 22 | diff --git a/macOS/README.md b/macOS/README.md index be9b8f4..5d384c7 100644 --- a/macOS/README.md +++ b/macOS/README.md @@ -110,3 +110,10 @@ Install with `bash setup.sh --work` from the repo root (`macOS/setup.sh` is a th 2. Amazon Photos | [Download](https://www.amazon.com/Amazon-Photos/b?node=13234696011) | [brew](https://formulae.brew.sh/cask/amazon-photos) 1. Unlimited full-resolution photo backup for Amazon Prime members 2. Install: `brew install --cask amazon-photos` + +## Services + +- **[Forgejo Actions runner](forgejo-runner/)** — this Mac mini runs CI jobs for + the home Forgejo server over Tailscale. See + [`forgejo-runner/README.md`](forgejo-runner/README.md) for setup, the + `install`/`verify`/`run` scripts, and troubleshooting. diff --git a/macOS/forgejo-runner/.env.example b/macOS/forgejo-runner/.env.example new file mode 100644 index 0000000..ac968a0 --- /dev/null +++ b/macOS/forgejo-runner/.env.example @@ -0,0 +1,10 @@ +# Copy this file to .env and update the value. .env is gitignored. +# This file is sourced by lib.sh, so keep values quoted (the <...> placeholder +# would otherwise be parsed as a shell redirection). + +# Forgejo instance the runner connects to (Tailscale MagicDNS over HTTPS, served +# by the server's `tailscale serve` sidecar — no port). +# Must match the server's ROOT_URL host (see linux-server/forgejo/). +# Find your tailnet suffix with: +# tailscale status --json | jq -r '.MagicDNSSuffix' +FORGEJO_INSTANCE_URL="https://forgejo..ts.net" diff --git a/macOS/forgejo-runner/README.md b/macOS/forgejo-runner/README.md new file mode 100644 index 0000000..af49e05 --- /dev/null +++ b/macOS/forgejo-runner/README.md @@ -0,0 +1,94 @@ +# Forgejo Actions runner (Mac mini) + +The Mac mini (`m4-mini`) is a self-hosted [Forgejo Actions](https://forgejo.org/docs/next/user/actions/) +runner. It executes CI jobs **directly on the host** (label `macos-latest:host`, +no Docker) for repos on the home Forgejo server, reaching it over Tailscale. + +``` +┌──────────────────────┐ Tailscale ┌──────────────────────────────┐ +│ Mac mini (m4-mini) │ ───────────────────────▶│ Forgejo server │ +│ forgejo-runner │ https://forgejo │ forgejo..ts.net │ +│ (LaunchAgent) │ ..ts.net │ serve :443 → :3000 │ +└──────────────────────┘ └──────────────────────────────┘ +``` + +The server side lives in [`../../linux-server/forgejo/`](../../linux-server/forgejo/). + +## Files on the Mac mini + +| Path | What | +| --- | --- | +| `~/.local/bin/forgejo-runner` | the runner binary (built from source — see below) | +| `~/forgejo-runner-config.yml` | config; the `server.connections` block holds the instance URL + token | +| `~/Library/LaunchAgents/net.forgejo.runner.plist` | LaunchAgent: `RunAtLoad` + `KeepAlive`, starts at login and restarts on crash | +| `~/Library/Logs/forgejo-runner.log` | combined stdout/stderr | + +## Why there is no prebuilt binary + +Forgejo publishes **Linux-only** runner binaries — there is no macOS build. So +on the Mac mini the runner is compiled from source with Go (hence the +`git describe` version string like `v12.10.2+20-g22ebc7d1`). `install.sh` +handles the build. + +## Reproduce from scratch + +```bash +# 1. Build, register, and load the LaunchAgent (prompts for a registration token). +bash install.sh + +# 2. Confirm it's healthy. +bash verify.sh +``` + +`install.sh` will: +1. Build `forgejo-runner` from source (`brew install go` first if needed) and + install it to `~/.local/bin/`. +2. Generate the base config (`generate-config`) if none exists. +3. Register the runner against the Forgejo instance — get a token from + **Forgejo → Settings → Actions → Runners → Create new runner** (site, org, or + repo scope). +4. Render `net.forgejo.runner.plist` from the template and `launchctl bootstrap` + it so it runs at login. + +Useful overrides: + +```bash +RUNNER_VERSION=v12.12.0 bash install.sh +FORGEJO_INSTANCE_URL=https://forgejo..ts.net bash install.sh +bash install.sh --skip-build # reuse the existing binary +bash install.sh --skip-register # leave server.connections untouched +``` + +Find your tailnet suffix with `tailscale status --json | jq -r '.MagicDNSSuffix'`. + +## Day-to-day + +```bash +bash run.sh status # launchd state + last log lines +bash run.sh restart # after editing the config +bash run.sh tail # follow the log +bash run.sh stop|start +``` + +## Troubleshooting + +**`connection refused` / `fail to invoke Declare` in the log — the most common +failure.** The runner host is fine; the configured instance URL no longer points +at where Forgejo listens. This is exactly what happened when the server moved +from `:3300` to its own Tailscale node at `https://forgejo..ts.net` +(HTTPS via `tailscale serve`) — the runner kept dialing the old `:3300` and +`KeepAlive` restarted it in a loop. + +1. `bash verify.sh` — the instance-reachable check pinpoints it. +2. Confirm where Forgejo actually answers: + `curl -s https://forgejo..ts.net/api/v1/version` + (must return JSON, not a "Host validation failed" error — the host in the URL + has to match the server's `ROOT_URL`). +3. Fix the `url:` under `server.connections` in `~/forgejo-runner-config.yml`. +4. `bash run.sh restart`. + +**Daemon not running (`LastExitStatus` non-zero, no PID).** Check the log; it +restarts every ~10s via `KeepAlive`, so the tail shows the live error. + +**Reboot didn't bring it back.** It should (`RunAtLoad`). Verify Tailscale came +up (`tailscale status`) and the LaunchAgent is loaded (`bash run.sh status`). diff --git a/macOS/forgejo-runner/forgejo-runner-config.example.yml b/macOS/forgejo-runner/forgejo-runner-config.example.yml new file mode 100644 index 0000000..be9176f --- /dev/null +++ b/macOS/forgejo-runner/forgejo-runner-config.example.yml @@ -0,0 +1,34 @@ +# Forgejo runner config — example. +# +# The live config (~/forgejo-runner-config.yml) is the stock output of +# `forgejo-runner generate-config` PLUS the `server:` block below. install.sh +# regenerates the stock body and `register` appends the connection, so you +# normally never hand-edit this. The fields that actually matter are here. +# +# DO NOT commit a real token. The values below are placeholders. + +runner: + file: .runner + capacity: 1 # one job at a time on the Mac mini + timeout: 3h + shutdown_timeout: 3h + fetch_interval: 2s + labels: [] # labels live per-connection (below), not here + +cache: + enabled: true + port: 0 # random free port for the internal cache server + +# Each connection points the runner at one Forgejo instance. The url MUST +# match the host in the server's ROOT_URL. +# This is the field that broke after the server moved from +# :3300 to its own Tailscale node https://forgejo..ts.net +# (HTTPS via tailscale serve). +server: + connections: + home: + url: https://forgejo..ts.net + uuid: 00000000-0000-0000-0000-000000000000 # filled in by register + token: + labels: + - macos-latest:host # `:host` = run jobs directly on macOS, no Docker diff --git a/macOS/forgejo-runner/install.sh b/macOS/forgejo-runner/install.sh new file mode 100755 index 0000000..ad32bdc --- /dev/null +++ b/macOS/forgejo-runner/install.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# Build + install the Forgejo Actions runner on macOS, then register it and +# install the LaunchAgent so it starts at login and stays up. +# +# Forgejo ships no macOS binaries (Linux-only releases), so the runner is built +# from source with Go — matching the git-describe version already on the Mac +# mini (e.g. v12.10.2+...). +# +# Usage: +# bash install.sh # build, register (prompts for token), load +# bash install.sh --skip-build # reuse an existing binary +# bash install.sh --skip-register # don't touch server.connections +# RUNNER_VERSION=v12.12.0 bash install.sh +# FORGEJO_INSTANCE_URL=https://forgejo..ts.net bash install.sh +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +# shellcheck source=macOS/forgejo-runner/lib.sh +source "$HERE/lib.sh" + +RUNNER_VERSION="${RUNNER_VERSION:-v12.12.0}" +RUNNER_REPO="https://code.forgejo.org/forgejo/runner" +RUNNER_NAME="${RUNNER_NAME:-$(scutil --get LocalHostName 2>/dev/null || hostname -s)}" +RUNNER_LABELS="${RUNNER_LABELS:-macos-latest:host}" + +skip_build=false +skip_register=false +for arg in "$@"; do + case "$arg" in + --skip-build) skip_build=true ;; + --skip-register) skip_register=true ;; + *) warn "ignoring unknown flag: $arg" ;; + esac +done + +[[ "$(uname -s)" == "Darwin" ]] || die "this installer is macOS-only" +[[ "$(uname -m)" == "arm64" ]] || warn "expected arm64 (Apple Silicon); continuing anyway" + +build_runner() { + command -v go >/dev/null 2>&1 || die "Go is required to build the runner. Install with: brew install go" + local tmp + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + info "cloning $RUNNER_REPO @ $RUNNER_VERSION" + git clone --depth 1 --branch "$RUNNER_VERSION" "$RUNNER_REPO" "$tmp/runner" + info "building (this takes a minute)" + if ! make -C "$tmp/runner" build 2>/dev/null; then + ( cd "$tmp/runner" && go build -o forgejo-runner . ) + fi + mkdir -p "$(dirname "$RUNNER_BIN")" + install -m 0755 "$tmp/runner/forgejo-runner" "$RUNNER_BIN" + ok "installed $RUNNER_BIN ($("$RUNNER_BIN" --version 2>&1 | head -1))" +} + +ensure_config() { + if [[ -f "$CONFIG" ]]; then + info "config already present: $CONFIG" + return + fi + info "generating base config: $CONFIG" + "$RUNNER_BIN" generate-config >"$CONFIG" + ok "wrote default config" +} + +register_runner() { + if grep -q 'server:' "$CONFIG" && grep -q 'connections:' "$CONFIG" && grep -q 'token:' "$CONFIG"; then + info "a server.connections entry already exists in $CONFIG — skipping registration" + info "(delete that block and re-run to re-register)" + return + fi + + local url token + url="$DEFAULT_INSTANCE_URL" + printf 'Forgejo instance URL [%s]: ' "$url" + read -r reply || true + [[ -n "${reply:-}" ]] && url="$reply" + + printf 'Registration token (Forgejo → Settings → Actions → Runners → Create new runner): ' + read -rs token || true + printf '\n' + [[ -n "${token:-}" ]] || die "a registration token is required" + + info "registering '$RUNNER_NAME' (labels: $RUNNER_LABELS) against $url" + "$RUNNER_BIN" register --no-interactive \ + --config "$CONFIG" \ + --instance "$url" \ + --token "$token" \ + --name "$RUNNER_NAME" \ + --labels "$RUNNER_LABELS" + ok "registered" +} + +install_agent() { + local node_bin tmpl + node_bin="$(command -v node >/dev/null 2>&1 && dirname "$(command -v node)" || true)" + tmpl="$HERE/net.forgejo.runner.plist.template" + mkdir -p "$(dirname "$PLIST")" "$(dirname "$LOG")" + + # PATH for jobs: node (if found) + Homebrew + system. Workflows often need node. + local path_value="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" + [[ -n "$node_bin" ]] && path_value="$node_bin:$path_value" + + sed -e "s|__RUNNER_BIN__|$RUNNER_BIN|g" \ + -e "s|__CONFIG__|$CONFIG|g" \ + -e "s|__LOG__|$LOG|g" \ + -e "s|__WORKDIR__|$HOME|g" \ + -e "s|__PATH__|$path_value|g" \ + "$tmpl" >"$PLIST" + ok "wrote $PLIST" +} + +load_agent() { + local domain + domain="$(launchd_domain)" + launchctl bootout "$domain/$LABEL" 2>/dev/null || true + launchctl bootstrap "$domain" "$PLIST" + launchctl kickstart -k "$domain/$LABEL" 2>/dev/null || true + ok "loaded LaunchAgent ($LABEL)" +} + +"$skip_build" || build_runner +[[ -x "$RUNNER_BIN" ]] || die "runner binary missing at $RUNNER_BIN (run without --skip-build)" +ensure_config +"$skip_register" || register_runner +install_agent +load_agent + +printf '\nDone. Verify with:\n bash %s/verify.sh\n' "$HERE" +printf 'Tail logs with:\n bash %s/run.sh tail\n' "$HERE" diff --git a/macOS/forgejo-runner/lib.sh b/macOS/forgejo-runner/lib.sh new file mode 100755 index 0000000..a97bc29 --- /dev/null +++ b/macOS/forgejo-runner/lib.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Shared constants + helpers for the Forgejo runner scripts (install/verify/run). +# Sourced, not executed. Paths mirror the live setup on the Mac mini. +# shellcheck disable=SC2034 # constants are consumed by the sourcing scripts +set -euo pipefail + +LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" + +# Local, gitignored overrides (instance URL, etc.). Copy .env.example to .env +# and edit — keeps tailnet-specific values out of the committed repo. +[[ -f "$LIB_DIR/.env" ]] && source "$LIB_DIR/.env" + +RUNNER_BIN="${RUNNER_BIN:-$HOME/.local/bin/forgejo-runner}" +CONFIG="${FORGEJO_RUNNER_CONFIG:-$HOME/forgejo-runner-config.yml}" +PLIST="$HOME/Library/LaunchAgents/net.forgejo.runner.plist" +LOG="$HOME/Library/Logs/forgejo-runner.log" +LABEL="net.forgejo.runner" + +# Default Forgejo instance the runner talks to (Tailscale MagicDNS over HTTPS, +# terminated by the server's `tailscale serve` sidecar — no port). +# Set FORGEJO_INSTANCE_URL in .env (or the environment); the placeholder below +# is only a prompt hint. Discover your tailnet with: +# tailscale status --json | jq -r '.MagicDNSSuffix' +DEFAULT_INSTANCE_URL="${FORGEJO_INSTANCE_URL:-https://forgejo..ts.net}" + +info() { printf ' %s\n' "$*"; } +ok() { printf '\033[32m✓\033[0m %s\n' "$*"; } +warn() { printf '\033[33m!\033[0m %s\n' "$*" >&2; } +fail() { printf '\033[31m✗\033[0m %s\n' "$*" >&2; } +die() { printf 'error: %s\n' "$*" >&2; exit 1; } + +launchd_domain() { printf 'gui/%s' "$(id -u)"; } diff --git a/macOS/forgejo-runner/net.forgejo.runner.plist.template b/macOS/forgejo-runner/net.forgejo.runner.plist.template new file mode 100644 index 0000000..b4b0143 --- /dev/null +++ b/macOS/forgejo-runner/net.forgejo.runner.plist.template @@ -0,0 +1,30 @@ + + + + + Label + net.forgejo.runner + ProgramArguments + + __RUNNER_BIN__ + daemon + --config + __CONFIG__ + + EnvironmentVariables + + PATH + __PATH__ + + WorkingDirectory + __WORKDIR__ + RunAtLoad + + KeepAlive + + StandardOutPath + __LOG__ + StandardErrorPath + __LOG__ + + diff --git a/macOS/forgejo-runner/run.sh b/macOS/forgejo-runner/run.sh new file mode 100755 index 0000000..ecf4488 --- /dev/null +++ b/macOS/forgejo-runner/run.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Control the Forgejo runner LaunchAgent. +# +# Usage: bash run.sh +# start load + start the daemon +# stop stop + unload the daemon +# restart force a restart (picks up config changes) +# status show launchd state + recent log lines +# logs print the full log +# tail follow the log (Ctrl-C to stop) +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +# shellcheck source=macOS/forgejo-runner/lib.sh +source "$HERE/lib.sh" + +DOMAIN="$(launchd_domain)" +cmd="${1:-status}" + +case "$cmd" in + start) + [[ -f "$PLIST" ]] || die "LaunchAgent not installed — run: bash $HERE/install.sh" + launchctl bootstrap "$DOMAIN" "$PLIST" 2>/dev/null || launchctl kickstart "$DOMAIN/$LABEL" + ok "started $LABEL" + ;; + stop) + launchctl bootout "$DOMAIN/$LABEL" 2>/dev/null || true + ok "stopped $LABEL" + ;; + restart) + launchctl kickstart -k "$DOMAIN/$LABEL" + ok "restarted $LABEL" + ;; + status) + launchctl print "$DOMAIN/$LABEL" 2>/dev/null | grep -E '^\s*(state|pid|last exit code) ' || warn "not loaded" + printf '\n--- last 10 log lines ---\n' + [[ -f "$LOG" ]] && tail -10 "$LOG" || info "no log yet: $LOG" + ;; + logs) + [[ -f "$LOG" ]] && cat "$LOG" || die "no log at $LOG" + ;; + tail) + [[ -f "$LOG" ]] || die "no log at $LOG" + tail -f "$LOG" + ;; + *) + die "unknown command: $cmd (try: start|stop|restart|status|logs|tail)" + ;; +esac diff --git a/macOS/forgejo-runner/verify.sh b/macOS/forgejo-runner/verify.sh new file mode 100755 index 0000000..45fd1d9 --- /dev/null +++ b/macOS/forgejo-runner/verify.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Read-only health check for the Forgejo runner. Exits non-zero if any check +# fails. Mirrors the failure mode that took the runner down after a reboot: +# the configured instance URL no longer matched where Forgejo actually listens. +# +# Usage: bash verify.sh +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +# shellcheck source=macOS/forgejo-runner/lib.sh +source "$HERE/lib.sh" + +failures=0 + +printf 'Forgejo runner health check\n\n' + +# 1. Binary +if [[ -x "$RUNNER_BIN" ]]; then + ok "binary: $RUNNER_BIN ($("$RUNNER_BIN" --version 2>&1 | head -1))" +else + fail "binary missing: $RUNNER_BIN"; failures=$((failures + 1)) +fi + +# 2. Config + endpoint +configured_url="" +if [[ -f "$CONFIG" ]]; then + configured_url="$(grep -m1 -E '^[[:space:]]*url:' "$CONFIG" | sed -E 's/^[[:space:]]*url:[[:space:]]*//')" + if [[ -n "$configured_url" ]]; then + ok "config: $CONFIG (instance: $configured_url)" + else + fail "config has no server.connections url: $CONFIG"; failures=$((failures + 1)) + fi +else + fail "config missing: $CONFIG"; failures=$((failures + 1)) +fi + +# 3. LaunchAgent installed +if [[ -f "$PLIST" ]]; then + ok "LaunchAgent installed: $PLIST" +else + fail "LaunchAgent missing: $PLIST"; failures=$((failures + 1)) +fi + +# 4. Daemon loaded + running +state="$(launchctl list "$LABEL" 2>/dev/null || true)" +if [[ -n "$state" ]]; then + pid="$(printf '%s' "$state" | sed -nE 's/.*"PID" = ([0-9]+);.*/\1/p')" + exit_status="$(printf '%s' "$state" | sed -nE 's/.*"LastExitStatus" = ([0-9]+);.*/\1/p')" + if [[ -n "$pid" ]]; then + ok "daemon running (PID $pid)" + else + fail "daemon loaded but not running (LastExitStatus=${exit_status:-?}) — see: $LOG" + failures=$((failures + 1)) + fi +else + fail "daemon not loaded — run: bash $HERE/run.sh start"; failures=$((failures + 1)) +fi + +# 5. Instance reachable (the exact thing that broke) +if [[ -n "$configured_url" ]]; then + code="$(curl -sS -m 5 -o /dev/null -w '%{http_code}' "$configured_url/api/v1/version" 2>/dev/null || echo 000)" + if [[ "$code" == "200" ]]; then + ok "instance reachable: $configured_url (HTTP 200)" + else + fail "instance NOT reachable: $configured_url (HTTP $code)" + info " fix: make sure the url's host matches the server's ROOT_URL" + info " (served over HTTPS by the tailscale serve sidecar; see linux-server/forgejo/docker-compose.yml)" + failures=$((failures + 1)) + fi +fi + +# 6. Declared more recently than it last failed to connect. Compares log +# positions so stale 'connection refused' spam from before a fix doesn't +# register as a current failure. +if [[ -f "$LOG" ]]; then + last_declared="$(grep -n 'declared successfully' "$LOG" | tail -1 | cut -d: -f1)" + last_refused="$(grep -n 'connection refused' "$LOG" | tail -1 | cut -d: -f1)" + if [[ -n "$last_declared" && ( -z "$last_refused" || "$last_declared" -gt "$last_refused" ) ]]; then + ok "runner declared successfully after its last connection error" + elif [[ -n "$last_refused" ]]; then + fail "log's latest connection state is 'connection refused' — can't reach the instance" + failures=$((failures + 1)) + else + info "log present but no declare line yet: $LOG" + fi +fi + +printf '\n' +if [[ "$failures" -eq 0 ]]; then + ok "all checks passed" +else + fail "$failures check(s) failed" +fi +exit "$failures"