Add the connect-wireguard composite action for Gitea (not yet verified on a runner)

actions/connect-wireguard brings a WireGuard tunnel up for a deployment job and
takes it down again, with a guide and offline tests. The runs on a real runner
(MIL-012 task 3) are still to do.

Refs #184
Task: MIL-012#3
This commit is contained in:
2026-10-19 11:24:33 +08:00
parent 8e093821f1
commit fce3cf8c6b
4 changed files with 468 additions and 0 deletions
+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
+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 |
+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