Merge pull request 'Add the connect-wireguard and connect-tailscale actions (MIL-012, unverified on a runner)' (#194) from wireguard-vpn-action into main
Validate workflows / validate (push) Successful in 36s
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 5s

Reviewed-on: #194
This commit was merged in pull request #194.
This commit is contained in:
2026-10-19 05:27:01 +02:00
9 changed files with 990 additions and 0 deletions
+5
View File
@@ -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
+77
View File
@@ -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"
@@ -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
+85
View File
@@ -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"
@@ -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
+123
View File
@@ -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 |
+105
View File
@@ -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/<interface>.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 |
+165
View File
@@ -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
+147
View File
@@ -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