diff --git a/CHANGELOG.md b/CHANGELOG.md index 5699bb3..a3d2fbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,11 @@ Each release lists its changes in these groups, in this order: ## [Unreleased] +### Added + +- `actions/connect-wireguard`, a composite action that brings a WireGuard tunnel up on a Gitea Actions runner for a deployment, with the guide `guides/vpn-wireguard.md`. Experimental. +- `actions/connect-tailscale`, a composite action for Gitea Actions that joins a Tailscale network for a deployment (the Gitea counterpart of the marketplace action `tailscale/github-action`), with the guide `guides/vpn-tailscale.md`. Experimental. + ## [2.1.1] - 2026-10-14 ### Fixed diff --git a/actions/connect-tailscale/action.yml b/actions/connect-tailscale/action.yml new file mode 100644 index 0000000..9952188 --- /dev/null +++ b/actions/connect-tailscale/action.yml @@ -0,0 +1,77 @@ +name: Connect Tailscale +description: >- + Joins a Tailscale network (tailnet) from a Gitea Actions runner, so the next + steps of the job (a deployment) can reach hosts on the tailnet. Written for + Gitea Actions; it replaces the GitHub marketplace action tailscale/github-action, + which is not verified on Gitea. Run it again with action "down" in a last step + with if always(). + +inputs: + action: + description: "up joins the tailnet; down logs the node out and stops tailscaled" + required: false + default: "up" + authkey: + description: "A Tailscale auth key (a secret). Use an ephemeral, tagged, pre-authorized key. Give authkey or oauth-secret, not both" + required: false + default: "" + oauth-secret: + description: "The secret of a Tailscale OAuth client with the scope auth_keys (a secret). Needs tags" + required: false + default: "" + tags: + description: "ACL tags of the node, comma separated, e.g. tag:ci. Required with oauth-secret" + required: false + default: "" + hostname: + description: "Name of the node in the tailnet. Empty uses the runner name" + required: false + default: "" + version: + description: "Tailscale version to install when tailscale is not on the runner, e.g. 1.80.2. Pin it; latest is allowed" + required: false + default: "latest" + userspace-networking: + description: >- + true runs tailscaled without a TUN device (no NET_ADMIN needed); tailnet + hosts are then reached through the SOCKS5 proxy localhost:1055, e.g. ssh -o + ProxyCommand="nc -X 5 -x localhost:1055 %h %p". false creates a TUN device. + required: false + default: "false" + args: + description: "Extra arguments for tailscale up, e.g. --accept-routes. Never put a secret here" + required: false + default: "" + timeout: + description: "Seconds to wait for the node to come up" + required: false + default: "60" + health-check-host: + description: "Optional tailnet host that must answer a Tailscale ping, else the step fails" + required: false + default: "" + +runs: + using: composite + steps: + - name: Mask the secrets + shell: bash + env: + TS_AUTHKEY: ${{ inputs.authkey }} + TS_OAUTH_SECRET: ${{ inputs.oauth-secret }} + run: bash "${{ gitea.action_path }}/connect-tailscale.sh" mask + + - name: Run the Tailscale step + shell: bash + env: + TS_ACTION: ${{ inputs.action }} + TS_AUTHKEY: ${{ inputs.authkey }} + TS_OAUTH_SECRET: ${{ inputs.oauth-secret }} + TS_TAGS: ${{ inputs.tags }} + TS_HOSTNAME: ${{ inputs.hostname }} + TS_VERSION: ${{ inputs.version }} + TS_USERSPACE: ${{ inputs.userspace-networking }} + TS_ARGS: ${{ inputs.args }} + TS_TIMEOUT: ${{ inputs.timeout }} + TS_HEALTH_CHECK_HOST: ${{ inputs.health-check-host }} + run: bash "${{ gitea.action_path }}/connect-tailscale.sh" "$TS_ACTION" diff --git a/actions/connect-tailscale/connect-tailscale.sh b/actions/connect-tailscale/connect-tailscale.sh new file mode 100644 index 0000000..f673e84 --- /dev/null +++ b/actions/connect-tailscale/connect-tailscale.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +# Steps of the composite action actions/connect-tailscale/action.yml. +# +# connect-tailscale.sh mask print ::add-mask:: for every secret input +# connect-tailscale.sh up install tailscale if needed, start tailscaled and join the tailnet +# connect-tailscale.sh down log the node out and stop tailscaled +# +# Settings come from the environment, never from interpolated expressions: +# TS_AUTHKEY, TS_OAUTH_SECRET, TS_TAGS, TS_HOSTNAME, TS_VERSION, TS_USERSPACE, +# TS_ARGS, TS_TIMEOUT, TS_HEALTH_CHECK_HOST. TS_TAILSCALE, TS_TAILSCALED, +# TS_SUDO and TS_STATE_DIR name the commands and the work folder, for the tests. +# The auth key is passed to tailscale in a file of mode 600, never on a command +# line. Every failure is printed as ::error::[category] message. + +TS_TAILSCALE="${TS_TAILSCALE:-tailscale}" +TS_TAILSCALED="${TS_TAILSCALED:-tailscaled}" +SUDO="${TS_SUDO-sudo}" +STATE_DIR="${TS_STATE_DIR:-${RUNNER_TEMP:-/tmp}/tailscale}" + +fail() { + echo "::error::[$1] $2" + exit 1 +} + +mask_secrets() { + local value + for value in "${TS_AUTHKEY:-}" "${TS_OAUTH_SECRET:-}"; do + [ -n "$value" ] && echo "::add-mask::$value" + done + return 0 +} + +validate_inputs() { + if [ -n "${TS_AUTHKEY:-}" ] && [ -n "${TS_OAUTH_SECRET:-}" ]; then + fail configuration "give authkey or oauth-secret, not both." + fi + if [ -z "${TS_AUTHKEY:-}" ] && [ -z "${TS_OAUTH_SECRET:-}" ]; then + fail configuration "give authkey or oauth-secret." + fi + if [ -n "${TS_OAUTH_SECRET:-}" ] && [ -z "${TS_TAGS:-}" ]; then + fail configuration "tags is required with oauth-secret (for example tag:ci)." + fi + if [ -n "${TS_TAGS:-}" ]; then + [[ "$TS_TAGS" =~ ^tag:[a-zA-Z0-9-]+(,tag:[a-zA-Z0-9-]+)*$ ]] || + fail configuration "tags must look like tag:ci or tag:ci,tag:deploy." + fi + [[ "$TS_TIMEOUT" =~ ^[0-9]+$ ]] || + fail configuration "timeout must be a number of seconds." + [[ "$TS_VERSION" =~ ^(latest|[0-9]+\.[0-9]+\.[0-9]+)$ ]] || + fail configuration "version must be latest or like 1.80.2." + case "$TS_USERSPACE" in + true | false) ;; + *) fail configuration "userspace-networking must be true or false." ;; + esac + [[ "${TS_HOSTNAME:-}" =~ ^[a-zA-Z0-9-]*$ ]] || + fail configuration "hostname may hold letters, digits and hyphens only." +} + +machine_arch() { + case "$(uname -m)" in + x86_64 | amd64) echo amd64 ;; + aarch64 | arm64) echo arm64 ;; + *) fail environment "no Tailscale build for $(uname -m)." ;; + esac +} + +install_tools() { + command -v "$TS_TAILSCALE" >/dev/null 2>&1 && command -v "$TS_TAILSCALED" >/dev/null 2>&1 && return 0 + local arch url tmp + arch="$(machine_arch)" + url="https://pkgs.tailscale.com/stable/tailscale_${TS_VERSION}_${arch}.tgz" + echo "Installing Tailscale $TS_VERSION" + tmp="$(mktemp -d)" + curl -fsSL "$url" -o "$tmp/tailscale.tgz" || + fail environment "could not download $url." + tar -xzf "$tmp/tailscale.tgz" -C "$tmp" --strip-components=1 || + fail environment "could not unpack the Tailscale archive." + $SUDO install -m 755 "$tmp/tailscale" "$tmp/tailscaled" /usr/local/bin/ || + fail environment "could not install tailscale (root or sudo is needed)." + rm -rf "$tmp" +} + +start_daemon() { + local args=(--state=mem:) + if [ "$TS_USERSPACE" = "true" ]; then + args+=(--tun=userspace-networking --socks5-server=localhost:1055) + fi + mkdir -p "$STATE_DIR" + $SUDO nohup "$TS_TAILSCALED" "${args[@]}" >"$STATE_DIR/tailscaled.log" 2>&1 & +} + +# write_key_file: the auth key in a file of mode 600; prints its path +write_key_file() { + local file="$STATE_DIR/authkey" key + if [ -n "${TS_OAUTH_SECRET:-}" ]; then + key="${TS_OAUTH_SECRET}?ephemeral=true&preauthorized=true" + else + key="$TS_AUTHKEY" + fi + mkdir -p "$STATE_DIR" + (umask 077 && printf '%s' "$key" >"$file") + echo "$file" +} + +tailnet_up() { + validate_inputs + install_tools + start_daemon + local keyfile up_args extra + keyfile="$(write_key_file)" + up_args=("--auth-key=file:$keyfile" "--timeout=${TS_TIMEOUT}s") + [ -z "${TS_HOSTNAME:-}" ] || up_args+=("--hostname=$TS_HOSTNAME") + [ -z "${TS_TAGS:-}" ] || up_args+=("--advertise-tags=$TS_TAGS") + # TS_ARGS is split on spaces on purpose: it holds flags, never a secret + # shellcheck disable=SC2206 + extra=(${TS_ARGS:-}) + if ! $SUDO "$TS_TAILSCALE" up "${up_args[@]}" "${extra[@]}"; then + rm -f "$keyfile" + fail connection "tailscale up failed; the last lines of tailscaled: $(tail -n 5 "$STATE_DIR/tailscaled.log" 2>/dev/null | tr '\n' ' ')" + fi + rm -f "$keyfile" + if [ -n "${TS_HEALTH_CHECK_HOST:-}" ]; then + "$TS_TAILSCALE" ping --c 3 --timeout 10s "$TS_HEALTH_CHECK_HOST" >/dev/null 2>&1 || + fail connection "health-check-host '$TS_HEALTH_CHECK_HOST' does not answer on the tailnet." + fi + echo "Tailscale is up." +} + +tailnet_down() { + $SUDO "$TS_TAILSCALE" logout 2>/dev/null || echo "The node was not logged in." + $SUDO "$TS_TAILSCALE" down 2>/dev/null || true + $SUDO pkill -x "$(basename "$TS_TAILSCALED")" 2>/dev/null || true + rm -rf "$STATE_DIR" + echo "Tailscale is down." +} + +main() { + set -uo pipefail + TS_USERSPACE="${TS_USERSPACE:-false}" + TS_TIMEOUT="${TS_TIMEOUT:-60}" + TS_VERSION="${TS_VERSION:-latest}" + case "${1:-}" in + mask) mask_secrets ;; + up) tailnet_up ;; + down) tailnet_down ;; + *) fail configuration "unknown command '${1:-}'; use mask, up or down." ;; + esac +} + +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + main "$@" +fi diff --git a/actions/connect-wireguard/action.yml b/actions/connect-wireguard/action.yml new file mode 100644 index 0000000..c6075f8 --- /dev/null +++ b/actions/connect-wireguard/action.yml @@ -0,0 +1,85 @@ +name: Connect WireGuard +description: >- + Brings up a WireGuard tunnel on a Gitea Actions runner, so the next steps of + the job (a deployment) can reach hosts on a private network. Run it again + with action "down" in a last step with if always(). + +inputs: + action: + description: "up brings the tunnel up; down takes it down and removes the configuration" + required: false + default: "up" + config: + description: >- + A complete wg-quick configuration, stored as a Gitea secret. When set, the + individual inputs below are ignored. + required: false + default: "" + private-key: + description: "Private key of this runner (a secret)" + required: false + default: "" + address: + description: "Address of this runner inside the tunnel, e.g. 10.8.0.2/32" + required: false + default: "" + peer-public-key: + description: "Public key of the WireGuard server" + required: false + default: "" + endpoint: + description: "Address of the WireGuard server as host:port" + required: false + default: "" + allowed-ips: + description: "Networks routed through the tunnel, comma separated. Never 0.0.0.0/0 unless the deployment needs it" + required: false + default: "" + preshared-key: + description: "Optional preshared key (a secret)" + required: false + default: "" + dns: + description: "Optional DNS server for the tunnel" + required: false + default: "" + persistent-keepalive: + description: "Seconds between keepalive packets, 0 turns them off" + required: false + default: "25" + interface: + description: "Name of the interface" + required: false + default: "wg0" + health-check-host: + description: "Optional host behind the tunnel that must answer a ping, else the step fails" + required: false + default: "" + +runs: + using: composite + steps: + - name: Mask the secrets + shell: bash + env: + WG_CONFIG: ${{ inputs.config }} + WG_PRIVATE_KEY: ${{ inputs.private-key }} + WG_PRESHARED_KEY: ${{ inputs.preshared-key }} + run: bash "${{ gitea.action_path }}/connect-wireguard.sh" mask + + - name: Run the WireGuard step + shell: bash + env: + WG_ACTION: ${{ inputs.action }} + WG_CONFIG: ${{ inputs.config }} + WG_PRIVATE_KEY: ${{ inputs.private-key }} + WG_ADDRESS: ${{ inputs.address }} + WG_PEER_PUBLIC_KEY: ${{ inputs.peer-public-key }} + WG_ENDPOINT: ${{ inputs.endpoint }} + WG_ALLOWED_IPS: ${{ inputs.allowed-ips }} + WG_PRESHARED_KEY: ${{ inputs.preshared-key }} + WG_DNS: ${{ inputs.dns }} + WG_KEEPALIVE: ${{ inputs.persistent-keepalive }} + WG_INTERFACE: ${{ inputs.interface }} + WG_HEALTH_CHECK_HOST: ${{ inputs.health-check-host }} + run: bash "${{ gitea.action_path }}/connect-wireguard.sh" "$WG_ACTION" diff --git a/actions/connect-wireguard/connect-wireguard.sh b/actions/connect-wireguard/connect-wireguard.sh new file mode 100644 index 0000000..1579fa9 --- /dev/null +++ b/actions/connect-wireguard/connect-wireguard.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# Steps of the composite action actions/connect-wireguard/action.yml. +# +# connect-wireguard.sh mask print ::add-mask:: for every secret input +# connect-wireguard.sh up write the configuration and bring the tunnel up +# connect-wireguard.sh down take the tunnel down and delete the configuration +# +# Settings come from the environment, never from interpolated expressions: +# WG_CONFIG, WG_PRIVATE_KEY, WG_ADDRESS, WG_PEER_PUBLIC_KEY, WG_ENDPOINT, +# WG_ALLOWED_IPS, WG_PRESHARED_KEY, WG_DNS, WG_KEEPALIVE, WG_INTERFACE, +# WG_HEALTH_CHECK_HOST. WG_DIR is the configuration directory (/etc/wireguard +# by default); WG_QUICK, WG_SUDO and WG_PING name the commands, for the tests. +# Every failure is printed as ::error::[category] message. A key is never printed. + +WG_DIR="${WG_DIR:-/etc/wireguard}" +WG_QUICK="${WG_QUICK:-wg-quick}" +WG_PING="${WG_PING:-ping}" +SUDO="${WG_SUDO-sudo}" + +fail() { + echo "::error::[$1] $2" + exit 1 +} + +mask_secrets() { + local value line + for value in "${WG_PRIVATE_KEY:-}" "${WG_PRESHARED_KEY:-}"; do + [ -n "$value" ] && echo "::add-mask::$value" + done + # every line of a full configuration is masked, so a key cannot leak in pieces + while IFS= read -r line; do + line="${line#"${line%%[![:space:]]*}"}" + [ -n "$line" ] && echo "::add-mask::$line" + done <<<"${WG_CONFIG:-}" + return 0 +} + +check_interface() { + [[ "$WG_INTERFACE" =~ ^[a-zA-Z0-9_=+.-]{1,15}$ ]] || + fail configuration "interface '$WG_INTERFACE' is not a valid interface name." +} + +# render_config: the wg-quick configuration on stdout +render_config() { + if [ -n "${WG_CONFIG:-}" ]; then + printf '%s\n' "$WG_CONFIG" + return 0 + fi + local name + for name in WG_PRIVATE_KEY WG_ADDRESS WG_PEER_PUBLIC_KEY WG_ENDPOINT WG_ALLOWED_IPS; do + [ -n "${!name:-}" ] || + fail configuration "set config, or all of private-key, address, peer-public-key, endpoint and allowed-ips ($name is empty)." + done + [[ "$WG_KEEPALIVE" =~ ^[0-9]+$ ]] || + fail configuration "persistent-keepalive must be a number of seconds." + printf '[Interface]\nPrivateKey = %s\nAddress = %s\n' "$WG_PRIVATE_KEY" "$WG_ADDRESS" + [ -z "${WG_DNS:-}" ] || printf 'DNS = %s\n' "$WG_DNS" + printf '\n[Peer]\nPublicKey = %s\nEndpoint = %s\nAllowedIPs = %s\nPersistentKeepalive = %s\n' \ + "$WG_PEER_PUBLIC_KEY" "$WG_ENDPOINT" "$WG_ALLOWED_IPS" "$WG_KEEPALIVE" + [ -z "${WG_PRESHARED_KEY:-}" ] || printf 'PresharedKey = %s\n' "$WG_PRESHARED_KEY" +} + +install_tools() { + command -v "$WG_QUICK" >/dev/null 2>&1 && return 0 + echo "Installing wireguard-tools" + if ! { $SUDO apt-get update -qq && + $SUDO apt-get install -y -qq wireguard-tools resolvconf iproute2 iputils-ping; }; then + fail environment "wireguard-tools is not installed and could not be installed (a Debian or Ubuntu runner with root is needed)." + fi +} + +# Use the user-space implementation when the kernel has no WireGuard module, +# which is the usual case in a Gitea runner container. +choose_implementation() { + if $SUDO ip link add dev "wgprobe$$" type wireguard 2>/dev/null; then + $SUDO ip link delete dev "wgprobe$$" 2>/dev/null || true + return 0 + fi + if command -v wireguard-go >/dev/null 2>&1 || $SUDO apt-get install -y -qq wireguard-go 2>/dev/null; then + export WG_QUICK_USERSPACE_IMPLEMENTATION=wireguard-go + echo "No kernel WireGuard module; using wireguard-go." + else + fail environment "the runner has no WireGuard kernel module and wireguard-go could not be installed; the runner container needs NET_ADMIN and /dev/net/tun (see guides/vpn-wireguard.md)." + fi +} + +tunnel_up() { + check_interface + local conf="$WG_DIR/$WG_INTERFACE.conf" rendered + # on failure the captured output is the ::error:: line, which must be shown + rendered="$(render_config)" || { + echo "$rendered" + exit 1 + } + install_tools + choose_implementation + $SUDO install -d -m 700 "$WG_DIR" + (umask 077 && printf '%s\n' "$rendered" | $SUDO tee "$conf" >/dev/null) + $SUDO chmod 600 "$conf" + $SUDO "$WG_QUICK" up "$WG_INTERFACE" || + fail connection "wg-quick could not bring $WG_INTERFACE up." + if [ -n "${WG_HEALTH_CHECK_HOST:-}" ]; then + "$WG_PING" -c 3 -W 5 "$WG_HEALTH_CHECK_HOST" >/dev/null 2>&1 || + fail connection "health-check-host '$WG_HEALTH_CHECK_HOST' does not answer through the tunnel." + fi + echo "WireGuard interface $WG_INTERFACE is up." +} + +tunnel_down() { + check_interface + local conf="$WG_DIR/$WG_INTERFACE.conf" + $SUDO "$WG_QUICK" down "$WG_INTERFACE" 2>/dev/null || echo "Interface $WG_INTERFACE was not up." + $SUDO rm -f "$conf" + echo "WireGuard interface $WG_INTERFACE is down." +} + +main() { + set -uo pipefail + WG_INTERFACE="${WG_INTERFACE:-wg0}" + WG_KEEPALIVE="${WG_KEEPALIVE:-25}" + case "${1:-}" in + mask) mask_secrets ;; + up) tunnel_up ;; + down) tunnel_down ;; + *) fail configuration "unknown command '${1:-}'; use mask, up or down." ;; + esac +} + +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + main "$@" +fi diff --git a/guides/vpn-tailscale.md b/guides/vpn-tailscale.md new file mode 100644 index 0000000..dc796af --- /dev/null +++ b/guides/vpn-tailscale.md @@ -0,0 +1,123 @@ +# Reaching a tailnet with Tailscale + +The composite action `actions/connect-tailscale` joins a Tailscale network from +a Gitea Actions runner, so the next steps of the same job (a deployment over +SSH, a migration) can reach hosts on the tailnet. It does what the GitHub +marketplace action `tailscale/github-action` does, written for Gitea Actions: +that action is built for GitHub and has not been verified on Gitea. It is +**experimental**: it lives on the branch `wireguard-vpn-action` until a trial +shows it is wanted. For a plain tunnel without a third-party service, see +[WireGuard](vpn-wireguard.md). + +A node belongs to one job: every job that deploys joins on its own and logs out +in a last step. It cannot be a reusable workflow, because a called workflow runs +as a separate job. + +## Use it + +```yaml +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4.2.2 + + - name: Connect Tailscale + uses: TirSystem/github-action/actions/connect-tailscale@v2 + with: + oauth-secret: ${{ secrets.TS_OAUTH_SECRET }} + tags: tag:ci + version: 1.80.2 + health-check-host: deploy-host + + - name: Deploy + run: ssh deploy@deploy-host ./deploy.sh + + - name: Disconnect Tailscale + if: always() + uses: TirSystem/github-action/actions/connect-tailscale@v2 + with: + action: down +``` + +If the short form does not resolve on your instance (Gitea looks for the action +on the host set in `DEFAULT_ACTIONS_URL`), use the full address: +`https://git.tirsystem.com/TirSystem/github-action/actions/connect-tailscale@v2`. +To try the branch before it is released, replace `@v2` with +`@wireguard-vpn-action`. + +## Inputs + +| Input | Default | Meaning | +| --- | --- | --- | +| `action` | `up` | `up` or `down` | +| `authkey` | | A Tailscale auth key, as a secret. Give this or `oauth-secret` | +| `oauth-secret` | | The secret of an OAuth client with the scope `auth_keys`, as a secret. Needs `tags` | +| `tags` | | ACL tags of the node, `tag:ci` or `tag:ci,tag:deploy`. Required with `oauth-secret` | +| `hostname` | runner name | Name of the node in the tailnet | +| `version` | `latest` | The version to install when the runner has no `tailscale`. Pin it | +| `userspace-networking` | `false` | `true` runs without a TUN device | +| `args` | | Extra flags for `tailscale up`, for example `--accept-routes`. Never a secret | +| `timeout` | `60` | Seconds to wait for the node | +| `health-check-host` | | A tailnet host that must answer a Tailscale ping | + +## Set it up + +1. In the Tailscale admin console define a tag for CI (`tag:ci`) in the policy + file, with the owners that may use it, and an access rule that lets + `tag:ci` reach only the hosts and ports the deployment needs. +2. Create either an **OAuth client** with the scope `auth_keys` (write) for that + tag, or an **auth key** that is reusable only when needed, ephemeral, + pre-authorized and tagged. The OAuth client is preferred: it does not expire + the way a key does, and each run gets a fresh ephemeral key. +3. Store the secret as a secret of the organization, with a name that does not + start with `GITEA_` or `GITHUB_`. +4. Rotate the secret when a person with access leaves. + +The node is ephemeral and its state is kept in memory, so it disappears from the +tailnet when the job ends, even if the last step does not run. + +## The runner + +- The job runs as root or can use `sudo`. The action downloads the Tailscale + binaries from `pkgs.tailscale.com` when `tailscale` is not on the runner. + Pin `version`; a runner image that already contains Tailscale skips the + download. +- **TUN mode** (the default) needs the capability `NET_ADMIN` and the device + `/dev/net/tun` in the runner container: in the `act_runner` configuration set + under `container:` `options: "--cap-add=NET_ADMIN --device=/dev/net/tun"`. +- **Userspace mode** (`userspace-networking: true`) needs neither, but the + tailnet is then reached only through a SOCKS5 proxy on `localhost:1055`: + + ```bash + ssh -o ProxyCommand="nc -X 5 -x localhost:1055 %h %p" deploy@deploy-host + ``` + + Tools that cannot use a proxy do not work in this mode. +- Use a dedicated runner with a label of its own (for example `vpn`) for jobs + that join a tailnet. +- The daemon is started in the background of the "Run the Tailscale step". That + it stays alive for the following steps of the job has to be confirmed on the + runner in use; the first trial must do so. + +## What the action does + +- masks the secrets in the log first; +- passes the auth key to `tailscale up` in a file of mode 600 (`--auth-key=file:`), + never on a command line, and deletes the file at once; +- prints errors as `::error::[category] message`, with the categories + `configuration`, `environment` and `connection`; +- on `down` logs the node out, stops `tailscaled` and deletes its work folder. + +The `file:` form of `--auth-key` needs a recent Tailscale; with an old pinned +version `tailscale up` reports an invalid key. + +## Troubleshooting + +| Message | Cause | +| --- | --- | +| `[configuration] give authkey or oauth-secret` | Neither was given, or both | +| `[configuration] tags is required` | `oauth-secret` without `tags` | +| `[environment] could not download` | The runner cannot reach `pkgs.tailscale.com`, or the version does not exist | +| `[connection] tailscale up failed` | A wrong or expired key, a tag the client may not use, or a missing TUN device (try `userspace-networking`); the last lines of `tailscaled` follow | +| `[connection] health-check-host ... does not answer` | The access rule does not let `tag:ci` reach the host | diff --git a/guides/vpn-wireguard.md b/guides/vpn-wireguard.md new file mode 100644 index 0000000..1578f61 --- /dev/null +++ b/guides/vpn-wireguard.md @@ -0,0 +1,105 @@ +# Reaching a private network with WireGuard + +The composite action `actions/connect-wireguard` brings a WireGuard tunnel up on +a Gitea Actions runner, so the next steps of the same job (a deployment over +SSH, a database migration) can reach hosts that are not on the internet. It is +the WireGuard counterpart of the Tailscale action. It is **experimental**: it +lives on a branch until a trial shows it is wanted. + +A tunnel belongs to one job: every job that deploys brings its own tunnel up +and takes it down. It cannot be a reusable workflow, because a called workflow +runs as a separate job. + +## Use it + +```yaml +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4.2.2 + + - name: Connect the VPN + uses: TirSystem/github-action/actions/connect-wireguard@v2 + with: + config: ${{ secrets.WG_CONFIG }} + health-check-host: 10.8.0.1 + + - name: Deploy + run: ssh deploy@10.8.0.10 ./deploy.sh + + - name: Disconnect the VPN + if: always() + uses: TirSystem/github-action/actions/connect-wireguard@v2 + with: + action: down +``` + +If the short form does not resolve on your instance (Gitea looks for the action +on the host set in `DEFAULT_ACTIONS_URL`), use the full address: +`https://git.tirsystem.com/TirSystem/github-action/actions/connect-wireguard@v2`. + +To try the branch before it is released, replace `@v2` with +`@wireguard-vpn-action`. + +## Inputs + +| Input | Default | Meaning | +| --- | --- | --- | +| `action` | `up` | `up` or `down` | +| `config` | | A complete wg-quick file, as a secret. When set, the fields below are ignored | +| `private-key`, `address`, `peer-public-key`, `endpoint`, `allowed-ips` | | The fields of a configuration, all five needed without `config` | +| `preshared-key`, `dns` | | Optional | +| `persistent-keepalive` | `25` | Seconds | +| `interface` | `wg0` | Interface name | +| `health-check-host` | | A host behind the tunnel; the step fails when it does not answer a ping | + +## Set it up + +1. On the WireGuard server add one peer for CI, with its own key pair and an + address inside the tunnel (`10.8.0.2/32`). Let it reach only the hosts the + deployment needs. +2. Store the configuration (or the private key) as a secret of the + organization, never in the repository. Give the secret a name that does not + start with `GITEA_` or `GITHUB_`. +3. Set `allowed-ips` to the private network only. `0.0.0.0/0` sends all of the + job's traffic, including the checkout, through the tunnel. +4. Rotate the key when a person with access to the secret leaves. + +## The runner must allow it + +A tunnel needs a network interface, so the runner needs more than a plain job +container: + +- the job runs as root or can use `sudo` (the action installs + `wireguard-tools` with `apt` when it is missing, so a Debian or Ubuntu image); +- the container has the capability `NET_ADMIN` and the device `/dev/net/tun`. + In the `act_runner` configuration set under `container:` + `options: "--cap-add=NET_ADMIN --device=/dev/net/tun"`; +- without the kernel module the action uses `wireguard-go` (user space), which + needs the same capability and device. + +Use a dedicated runner with a label of its own (for example `vpn`) and put +`runs-on: vpn` on the jobs that need it, so the other jobs do not get the extra +privilege. + +## What the action does + +- masks the keys in the log before anything else; +- writes `/etc/wireguard/.conf` with mode 600 and brings it up with + `wg-quick`; +- prints errors as `::error::[category] message`, with the categories + `configuration`, `environment` and `connection`; +- on `down` takes the interface down and deletes the file. + +It never prints a key. Do not turn on shell tracing (`set -x`) in the steps +that follow. + +## Troubleshooting + +| Message | Cause | +| --- | --- | +| `[configuration] ... is empty` | Neither `config` nor all five fields were given | +| `[environment] ... NET_ADMIN and /dev/net/tun` | The runner container lacks the capability or the device | +| `[connection] wg-quick could not bring wg0 up` | A wrong key, endpoint or a firewall; run `wg show` on the server | +| `[connection] health-check-host ... does not answer` | The tunnel is up but the host is not in `allowed-ips`, or is blocked | diff --git a/tests/test_connect_tailscale.py b/tests/test_connect_tailscale.py new file mode 100644 index 0000000..7c9e077 --- /dev/null +++ b/tests/test_connect_tailscale.py @@ -0,0 +1,165 @@ +"""Offline tests for the composite action actions/connect-tailscale. + +The shell script is run with stub tailscale and tailscaled commands (no sudo, +no network), so the arguments, the key file, the masking and the failure +messages are checked without touching the runner. +""" + +import os +import shutil +import stat +import subprocess +from pathlib import Path + +import pytest +import yaml + +from workflow_source import REPO_ROOT + +ACTION_DIR = REPO_ROOT / "actions" / "connect-tailscale" +ACTION = ACTION_DIR / "action.yml" +SCRIPT = ACTION_DIR / "connect-tailscale.sh" +BASH = shutil.which("bash") + +pytestmark = pytest.mark.skipif(BASH is None, reason="bash is needed to run the action script") + +AUTHKEY = "tskey-auth-SECRETSECRETSECRET" +OAUTH = "tskey-client-SECRETSECRETSECRET" + +# the stub records its arguments and, for `up`, the content of the key file +TAILSCALE_STUB = """#!/usr/bin/env bash +echo "$*" >> "$STUB_LOG" +case "$1" in + up) + for a in "$@"; do + case "$a" in --auth-key=file:*) cat "${a#--auth-key=file:}" > "$STUB_KEY" ;; esac + done + exit "${UP_EXIT:-0}" ;; + ping) exit "${PING_EXIT:-0}" ;; +esac +""" + + +@pytest.fixture(scope="module") +def document() -> dict: + return yaml.safe_load(ACTION.read_text(encoding="utf-8")) + + +def make_stub(path: Path, body: str) -> None: + path.write_text(body, encoding="utf-8") + path.chmod(path.stat().st_mode | stat.S_IXUSR) + + +def run(tmp_path: Path, command: str, **env: str) -> subprocess.CompletedProcess: + make_stub(tmp_path / "tailscale", TAILSCALE_STUB) + make_stub(tmp_path / "tailscaled", "#!/usr/bin/env bash\necho \"$*\" >> \"$STUB_LOG\"\n") + full = { + "PATH": f"{tmp_path}:{os.environ['PATH']}", + "TS_SUDO": "", + "TS_STATE_DIR": str(tmp_path / "state"), + "STUB_LOG": str(tmp_path / "log"), + "STUB_KEY": str(tmp_path / "key"), + **env, + } + return subprocess.run( + [BASH, str(SCRIPT), command], env=full, capture_output=True, text=True, check=False + ) + + +def log(tmp_path: Path) -> str: + return (tmp_path / "log").read_text(encoding="utf-8") + + +class TestDefinition: + def test_is_a_composite_action(self, document): + assert document["runs"]["using"] == "composite" + + def test_inputs_are_the_contract(self, document): + assert set(document["inputs"]) == { + "action", "authkey", "oauth-secret", "tags", "hostname", "version", + "userspace-networking", "args", "timeout", "health-check-host", + } + + def test_uses_gitea_contexts_only(self): + text = ACTION.read_text(encoding="utf-8") + assert "gitea.action_path" in text + assert "github." not in text.replace("tailscale/github-action", "").replace("GitHub", "") + + def test_no_input_is_interpolated_into_a_run_line(self, document): + for step in document["runs"]["steps"]: + assert "inputs." not in step["run"] + + +class TestUp: + def test_joins_with_an_auth_key_file_not_the_key_on_the_command_line(self, tmp_path): + result = run(tmp_path, "up", TS_AUTHKEY=AUTHKEY, TS_TAGS="tag:ci", TS_HOSTNAME="ci-1") + assert result.returncode == 0, result.stdout + result.stderr + calls = log(tmp_path) + assert AUTHKEY not in calls + assert AUTHKEY not in result.stdout + result.stderr + assert (tmp_path / "key").read_text(encoding="utf-8") == AUTHKEY + assert "--advertise-tags=tag:ci" in calls + assert "--hostname=ci-1" in calls + assert not (tmp_path / "state" / "authkey").exists() + + def test_an_oauth_secret_becomes_an_ephemeral_preauthorized_key(self, tmp_path): + result = run(tmp_path, "up", TS_OAUTH_SECRET=OAUTH, TS_TAGS="tag:ci") + assert result.returncode == 0, result.stdout + assert (tmp_path / "key").read_text(encoding="utf-8") == f"{OAUTH}?ephemeral=true&preauthorized=true" + + def test_userspace_networking_starts_the_daemon_without_a_tun_device(self, tmp_path): + run(tmp_path, "up", TS_AUTHKEY=AUTHKEY, TS_USERSPACE="true") + assert "--tun=userspace-networking --socks5-server=localhost:1055" in log(tmp_path) + + def test_extra_args_are_passed(self, tmp_path): + run(tmp_path, "up", TS_AUTHKEY=AUTHKEY, TS_ARGS="--accept-routes --shields-up") + assert "--accept-routes --shields-up" in log(tmp_path) + + @pytest.mark.parametrize( + ("env", "message"), + [ + ({}, "give authkey or oauth-secret"), + ({"TS_AUTHKEY": AUTHKEY, "TS_OAUTH_SECRET": OAUTH}, "not both"), + ({"TS_OAUTH_SECRET": OAUTH}, "tags is required"), + ({"TS_AUTHKEY": AUTHKEY, "TS_TAGS": "ci"}, "tags must look like"), + ({"TS_AUTHKEY": AUTHKEY, "TS_TIMEOUT": "soon"}, "timeout"), + ({"TS_AUTHKEY": AUTHKEY, "TS_VERSION": "1.x"}, "version"), + ({"TS_AUTHKEY": AUTHKEY, "TS_USERSPACE": "yes"}, "userspace-networking"), + ({"TS_AUTHKEY": AUTHKEY, "TS_HOSTNAME": "bad name"}, "hostname"), + ], + ) + def test_bad_inputs_fail_before_anything_starts(self, tmp_path, env, message): + result = run(tmp_path, "up", **env) + assert result.returncode == 1 + assert "::error::[configuration]" in result.stdout + assert message in result.stdout + assert not (tmp_path / "log").exists() + + def test_a_failing_up_fails_and_deletes_the_key_file(self, tmp_path): + result = run(tmp_path, "up", TS_AUTHKEY=AUTHKEY, UP_EXIT="1") + assert result.returncode == 1 + assert "::error::[connection]" in result.stdout + assert not (tmp_path / "state" / "authkey").exists() + assert AUTHKEY not in result.stdout + + def test_a_failing_health_check_fails_the_step(self, tmp_path): + result = run(tmp_path, "up", TS_AUTHKEY=AUTHKEY, TS_HEALTH_CHECK_HOST="db", PING_EXIT="1") + assert result.returncode == 1 + assert "health-check-host" in result.stdout + + +class TestDownAndMask: + def test_down_logs_out_and_removes_the_state(self, tmp_path): + run(tmp_path, "up", TS_AUTHKEY=AUTHKEY) + result = run(tmp_path, "down") + assert result.returncode == 0 + assert "logout" in log(tmp_path) + assert not (tmp_path / "state").exists() + + def test_mask_prints_every_secret(self, tmp_path): + result = run(tmp_path, "mask", TS_AUTHKEY=AUTHKEY, TS_OAUTH_SECRET=OAUTH) + assert f"::add-mask::{AUTHKEY}" in result.stdout + assert f"::add-mask::{OAUTH}" in result.stdout + + def test_unknown_command_fails(self, tmp_path): + assert run(tmp_path, "sideways").returncode == 1 diff --git a/tests/test_connect_wireguard.py b/tests/test_connect_wireguard.py new file mode 100644 index 0000000..fc60031 --- /dev/null +++ b/tests/test_connect_wireguard.py @@ -0,0 +1,147 @@ +"""Offline tests for the composite action actions/connect-wireguard. + +The shell script is run with stub commands (no sudo, no wg-quick, no network), +so the generated configuration, the masking and the failure messages are +checked without touching the runner. +""" + +import os +import shutil +import stat +import subprocess +from pathlib import Path + +import pytest +import yaml + +from workflow_source import REPO_ROOT + +ACTION_DIR = REPO_ROOT / "actions" / "connect-wireguard" +ACTION = ACTION_DIR / "action.yml" +SCRIPT = ACTION_DIR / "connect-wireguard.sh" +BASH = shutil.which("bash") + +pytestmark = pytest.mark.skipif(BASH is None, reason="bash is needed to run the action script") + +KEY = "PRIVATEKEYPRIVATEKEYPRIVATEKEYPRIVATEKEY00=" + + +@pytest.fixture(scope="module") +def document() -> dict: + return yaml.safe_load(ACTION.read_text(encoding="utf-8")) + + +def run(tmp_path: Path, command: str, **env: str) -> subprocess.CompletedProcess: + """Run the script with a stub wg-quick that records its arguments.""" + stub = tmp_path / "wg-quick" + stub.write_text('#!/usr/bin/env bash\necho "$*" >> "$STUB_LOG"\n', encoding="utf-8") + stub.chmod(stub.stat().st_mode | stat.S_IXUSR) + ping = tmp_path / "ping" + ping.write_text("#!/usr/bin/env bash\nexit ${PING_EXIT:-0}\n", encoding="utf-8") + ping.chmod(ping.stat().st_mode | stat.S_IXUSR) + (tmp_path / "etc").mkdir(exist_ok=True) + full = { + "PATH": os.environ["PATH"], + "WG_DIR": str(tmp_path / "etc"), + "WG_QUICK": str(stub), + "WG_PING": str(ping), + "WG_SUDO": "", + "STUB_LOG": str(tmp_path / "log"), + # pretend the kernel has WireGuard and the tools are present + "WG_INTERFACE": "wg0", + **env, + } + # `ip link add` fails in the test environment, so wireguard-go must exist + go = tmp_path / "wireguard-go" + go.write_text("#!/usr/bin/env bash\n", encoding="utf-8") + go.chmod(go.stat().st_mode | stat.S_IXUSR) + full["PATH"] = f"{tmp_path}:{full['PATH']}" + return subprocess.run( + [BASH, str(SCRIPT), command], env=full, capture_output=True, text=True, check=False + ) + + +FIELDS = { + "WG_PRIVATE_KEY": KEY, + "WG_ADDRESS": "10.8.0.2/32", + "WG_PEER_PUBLIC_KEY": "PEERPUBLICKEY=", + "WG_ENDPOINT": "vpn.example.org:51820", + "WG_ALLOWED_IPS": "10.8.0.0/24", +} + + +class TestDefinition: + def test_is_a_composite_action(self, document): + assert document["runs"]["using"] == "composite" + + def test_inputs_are_the_contract(self, document): + assert set(document["inputs"]) == { + "action", "config", "private-key", "address", "peer-public-key", "endpoint", + "allowed-ips", "preshared-key", "dns", "persistent-keepalive", "interface", + "health-check-host", + } + + def test_uses_gitea_contexts_only(self): + text = ACTION.read_text(encoding="utf-8") + assert "gitea.action_path" in text + assert "github." not in text + + def test_no_secret_is_interpolated_into_a_run_line(self, document): + for step in document["runs"]["steps"]: + assert "inputs." not in step["run"] + + +class TestUp: + def test_writes_a_private_config_and_brings_the_interface_up(self, tmp_path): + result = run(tmp_path, "up", **FIELDS) + assert result.returncode == 0, result.stdout + result.stderr + conf = tmp_path / "etc" / "wg0.conf" + text = conf.read_text(encoding="utf-8") + assert f"PrivateKey = {KEY}" in text + assert "AllowedIPs = 10.8.0.0/24" in text + assert "PersistentKeepalive = 25" in text + if os.name != "nt": # Windows has no POSIX file modes + assert stat.S_IMODE(conf.stat().st_mode) == 0o600 + assert (tmp_path / "log").read_text(encoding="utf-8").strip() == "up wg0" + + def test_a_full_config_is_used_as_given(self, tmp_path): + result = run(tmp_path, "up", WG_CONFIG="[Interface]\nPrivateKey = abc\n") + assert result.returncode == 0, result.stdout + assert (tmp_path / "etc" / "wg0.conf").read_text(encoding="utf-8").startswith("[Interface]") + + def test_the_key_is_never_printed(self, tmp_path): + result = run(tmp_path, "up", **FIELDS) + assert KEY not in result.stdout + result.stderr + + def test_a_missing_field_fails_with_its_name(self, tmp_path): + fields = {k: v for k, v in FIELDS.items() if k != "WG_ENDPOINT"} + result = run(tmp_path, "up", **fields) + assert result.returncode == 1 + assert "::error::[configuration]" in result.stdout + assert "WG_ENDPOINT" in result.stdout + + def test_a_bad_interface_name_fails(self, tmp_path): + result = run(tmp_path, "up", **{**FIELDS, "WG_INTERFACE": "../etc"}) + assert result.returncode == 1 + assert "interface" in result.stdout + + def test_a_failing_health_check_fails_the_step(self, tmp_path): + result = run(tmp_path, "up", **FIELDS, WG_HEALTH_CHECK_HOST="10.8.0.1", PING_EXIT="1") + assert result.returncode == 1 + assert "::error::[connection]" in result.stdout + + +class TestDownAndMask: + def test_down_removes_the_config(self, tmp_path): + run(tmp_path, "up", **FIELDS) + result = run(tmp_path, "down") + assert result.returncode == 0 + assert not (tmp_path / "etc" / "wg0.conf").exists() + + def test_mask_prints_every_secret(self, tmp_path): + result = run(tmp_path, "mask", WG_PRIVATE_KEY=KEY, WG_CONFIG="[Interface]\nPrivateKey = abc") + assert f"::add-mask::{KEY}" in result.stdout + assert "::add-mask::PrivateKey = abc" in result.stdout + + def test_unknown_command_fails(self, tmp_path): + assert run(tmp_path, "sideways").returncode == 1