Merge pull request 'Add the connect-wireguard and connect-tailscale actions (MIL-012, unverified on a runner)' (#194) from wireguard-vpn-action into main
Reviewed-on: #194
This commit was merged in pull request #194.
This commit is contained in:
@@ -20,6 +20,11 @@ Each release lists its changes in these groups, in this order:
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [2.1.1] - 2026-10-14
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 |
|
||||||
@@ -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 |
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user