Fix the review findings in connect-wireguard

The wireguard-go choice now survives sudo (it is passed through env), sudo is
used only when the job is not root, a configuration field with a line break is
refused, only the key values of a full configuration are masked, the key file
is removed when wg-quick fails, and the guide calls @main until the release.

Refs #184
Task: MIL-012#3
This commit is contained in:
2026-10-19 12:01:00 +08:00
parent f0d9d79f4c
commit 9e1cfa5fa8
3 changed files with 92 additions and 22 deletions
+40 -11
View File
@@ -15,7 +15,17 @@
WG_DIR="${WG_DIR:-/etc/wireguard}" WG_DIR="${WG_DIR:-/etc/wireguard}"
WG_QUICK="${WG_QUICK:-wg-quick}" WG_QUICK="${WG_QUICK:-wg-quick}"
WG_PING="${WG_PING:-ping}" WG_PING="${WG_PING:-ping}"
SUDO="${WG_SUDO-sudo}" # sudo is used only when the job is not root: a job container runs as root and
# often has no sudo. WG_SUDO overrides it (empty for none).
if [ -n "${WG_SUDO+x}" ]; then
SUDO="$WG_SUDO"
elif [ "$(id -u)" = "0" ]; then
SUDO=""
else
SUDO="sudo"
fi
# variables that must reach wg-quick through sudo, which resets the environment
WG_QUICK_ENV=()
fail() { fail() {
echo "::error::[$1] $2" echo "::error::[$1] $2"
@@ -23,14 +33,24 @@ fail() {
} }
mask_secrets() { mask_secrets() {
local value line local value line key
for value in "${WG_PRIVATE_KEY:-}" "${WG_PRESHARED_KEY:-}"; do for value in "${WG_PRIVATE_KEY:-}" "${WG_PRESHARED_KEY:-}"; do
[ -n "$value" ] && echo "::add-mask::$value" [ -n "$value" ] && echo "::add-mask::$value"
done done
# every line of a full configuration is masked, so a key cannot leak in pieces # a full configuration: mask the value of every PrivateKey and PresharedKey
# line, and nothing else, so the rest of the log stays readable
while IFS= read -r line; do while IFS= read -r line; do
line="${line#"${line%%[![:space:]]*}"}" line="${line//$'␍'/}"
[ -n "$line" ] && echo "::add-mask::$line" key="${line%%=*}"
key="${key//[[:space:]]/}"
case "${key,,}" in
privatekey | presharedkey)
value="${line#*=}"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
[ -n "$value" ] && echo "::add-mask::$value"
;;
esac
done <<<"${WG_CONFIG:-}" done <<<"${WG_CONFIG:-}"
return 0 return 0
} }
@@ -51,6 +71,11 @@ render_config() {
[ -n "${!name:-}" ] || [ -n "${!name:-}" ] ||
fail configuration "set config, or all of private-key, address, peer-public-key, endpoint and allowed-ips ($name is empty)." fail configuration "set config, or all of private-key, address, peer-public-key, endpoint and allowed-ips ($name is empty)."
done done
for name in WG_PRIVATE_KEY WG_ADDRESS WG_PEER_PUBLIC_KEY WG_ENDPOINT WG_ALLOWED_IPS WG_PRESHARED_KEY WG_DNS; do
# a line break would add directives (PostUp runs as root) to the file
[[ "${!name:-}" != *[[:cntrl:]]* ]] ||
fail configuration "$name must be one line without control characters."
done
[[ "$WG_KEEPALIVE" =~ ^[0-9]+$ ]] || [[ "$WG_KEEPALIVE" =~ ^[0-9]+$ ]] ||
fail configuration "persistent-keepalive must be a number of seconds." fail configuration "persistent-keepalive must be a number of seconds."
printf '[Interface]\nPrivateKey = %s\nAddress = %s\n' "$WG_PRIVATE_KEY" "$WG_ADDRESS" printf '[Interface]\nPrivateKey = %s\nAddress = %s\n' "$WG_PRIVATE_KEY" "$WG_ADDRESS"
@@ -77,7 +102,7 @@ choose_implementation() {
return 0 return 0
fi fi
if command -v wireguard-go >/dev/null 2>&1 || $SUDO apt-get install -y -qq wireguard-go 2>/dev/null; then 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 WG_QUICK_ENV=(WG_QUICK_USERSPACE_IMPLEMENTATION=wireguard-go)
echo "No kernel WireGuard module; using wireguard-go." echo "No kernel WireGuard module; using wireguard-go."
else 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)." 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)."
@@ -97,8 +122,12 @@ tunnel_up() {
$SUDO install -d -m 700 "$WG_DIR" $SUDO install -d -m 700 "$WG_DIR"
(umask 077 && printf '%s\n' "$rendered" | $SUDO tee "$conf" >/dev/null) (umask 077 && printf '%s\n' "$rendered" | $SUDO tee "$conf" >/dev/null)
$SUDO chmod 600 "$conf" $SUDO chmod 600 "$conf"
$SUDO "$WG_QUICK" up "$WG_INTERFACE" || # env keeps the userspace variable through sudo; the file holds the key, so
# it is removed when the interface does not come up
if ! $SUDO env ${WG_QUICK_ENV[@]+"${WG_QUICK_ENV[@]}"} "$WG_QUICK" up "$WG_INTERFACE"; then
$SUDO rm -f "$conf"
fail connection "wg-quick could not bring $WG_INTERFACE up." fail connection "wg-quick could not bring $WG_INTERFACE up."
fi
if [ -n "${WG_HEALTH_CHECK_HOST:-}" ]; then if [ -n "${WG_HEALTH_CHECK_HOST:-}" ]; then
"$WG_PING" -c 3 -W 5 "$WG_HEALTH_CHECK_HOST" >/dev/null 2>&1 || "$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." fail connection "health-check-host '$WG_HEALTH_CHECK_HOST' does not answer through the tunnel."
@@ -119,10 +148,10 @@ main() {
WG_INTERFACE="${WG_INTERFACE:-wg0}" WG_INTERFACE="${WG_INTERFACE:-wg0}"
WG_KEEPALIVE="${WG_KEEPALIVE:-25}" WG_KEEPALIVE="${WG_KEEPALIVE:-25}"
case "${1:-}" in case "${1:-}" in
mask) mask_secrets ;; mask) mask_secrets ;;
up) tunnel_up ;; up) tunnel_up ;;
down) tunnel_down ;; down) tunnel_down ;;
*) fail configuration "unknown command '${1:-}'; use mask, up or down." ;; *) fail configuration "unknown command '${1:-}'; use mask, up or down." ;;
esac esac
} }
+16 -10
View File
@@ -4,7 +4,8 @@ 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 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 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 the WireGuard counterpart of the Tailscale action. It is **experimental**: it
lives on a branch until a trial shows it is wanted. is on `main` but not released, and it has not yet run on a runner with a real
tunnel (MIL-012).
A tunnel belongs to one job: every job that deploys brings its own tunnel up 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 and takes it down. It cannot be a reusable workflow, because a called workflow
@@ -20,7 +21,7 @@ jobs:
- uses: actions/checkout@v4.2.2 - uses: actions/checkout@v4.2.2
- name: Connect the VPN - name: Connect the VPN
uses: TirSystem/github-action/actions/connect-wireguard@v2 uses: TirSystem/github-action/actions/connect-wireguard@main
with: with:
config: ${{ secrets.WG_CONFIG }} config: ${{ secrets.WG_CONFIG }}
health-check-host: 10.8.0.1 health-check-host: 10.8.0.1
@@ -30,17 +31,17 @@ jobs:
- name: Disconnect the VPN - name: Disconnect the VPN
if: always() if: always()
uses: TirSystem/github-action/actions/connect-wireguard@v2 uses: TirSystem/github-action/actions/connect-wireguard@main
with: with:
action: down action: down
``` ```
If the short form does not resolve on your instance (Gitea looks for the action 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: on the host set in `DEFAULT_ACTIONS_URL`), use the full address:
`https://git.tirsystem.com/TirSystem/github-action/actions/connect-wireguard@v2`. `https://git.tirsystem.com/TirSystem/github-action/actions/connect-wireguard@main`.
To try the branch before it is released, replace `@v2` with Until release 2.2.0 the actions exist only on `main`, so the examples call
`@wireguard-vpn-action`. `@main`; `@v2` does not contain them yet. After the release, use `@v2`.
## Inputs ## Inputs
@@ -71,8 +72,9 @@ To try the branch before it is released, replace `@v2` with
A tunnel needs a network interface, so the runner needs more than a plain job A tunnel needs a network interface, so the runner needs more than a plain job
container: container:
- the job runs as root or can use `sudo` (the action installs - the job runs as root, or `sudo` is available (the action uses `sudo` only
`wireguard-tools` with `apt` when it is missing, so a Debian or Ubuntu image); when the job is not root, and 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`. - the container has the capability `NET_ADMIN` and the device `/dev/net/tun`.
In the `act_runner` configuration set under `container:` In the `act_runner` configuration set under `container:`
`options: "--cap-add=NET_ADMIN --device=/dev/net/tun"`; `options: "--cap-add=NET_ADMIN --device=/dev/net/tun"`;
@@ -85,9 +87,13 @@ privilege.
## What the action does ## What the action does
- masks the keys in the log before anything else; - masks the keys in the log before anything else (the values of `PrivateKey`
and `PresharedKey` of a full configuration, and the key inputs);
- refuses an input that holds a line break, because an extra line in the file
could add a `PostUp` command that runs as root;
- writes `/etc/wireguard/<interface>.conf` with mode 600 and brings it up with - writes `/etc/wireguard/<interface>.conf` with mode 600 and brings it up with
`wg-quick`; `wg-quick` (through `env`, so the `wireguard-go` choice survives `sudo`); the
file is removed again when the interface does not come up;
- prints errors as `::error::[category] message`, with the categories - prints errors as `::error::[category] message`, with the categories
`configuration`, `environment` and `connection`; `configuration`, `environment` and `connection`;
- on `down` takes the interface down and deletes the file. - on `down` takes the interface down and deletes the file.
+36 -1
View File
@@ -131,6 +131,40 @@ class TestUp:
assert "::error::[connection]" in result.stdout assert "::error::[connection]" in result.stdout
def make_exec(path: Path, body: str) -> Path:
path.write_text(body, encoding="utf-8")
path.chmod(path.stat().st_mode | stat.S_IXUSR)
return path
class TestEnvironment:
def test_the_userspace_variable_reaches_wg_quick_through_sudo(self, tmp_path):
# a sudo that resets the environment, as the real one does
sudo = make_exec(
tmp_path / "sudo-reset",
'#!/usr/bin/env bash\nexec env -i "PATH=$PATH" "STUB_LOG=$STUB_LOG" "$@"\n',
)
quick = make_exec(
tmp_path / "quick-env",
'#!/usr/bin/env bash\necho "$WG_QUICK_USERSPACE_IMPLEMENTATION $*" >> "$STUB_LOG"\n',
)
result = run(tmp_path, "up", **FIELDS, WG_SUDO=str(sudo), WG_QUICK=str(quick))
assert result.returncode == 0, result.stdout + result.stderr
assert "wireguard-go up wg0" in (tmp_path / "log").read_text(encoding="utf-8")
def test_a_field_with_a_line_break_is_refused(self, tmp_path):
result = run(tmp_path, "up", **{**FIELDS, "WG_ALLOWED_IPS": "10.8.0.0/24\nPostUp = id"})
assert result.returncode == 1
assert "WG_ALLOWED_IPS must be one line" in result.stdout
assert not (tmp_path / "etc" / "wg0.conf").exists()
def test_the_key_file_is_removed_when_wg_quick_fails(self, tmp_path):
failing = make_exec(tmp_path / "quick-fail", "#!/usr/bin/env bash\nexit 1\n")
result = run(tmp_path, "up", **FIELDS, WG_QUICK=str(failing))
assert result.returncode == 1
assert not (tmp_path / "etc" / "wg0.conf").exists()
class TestDownAndMask: class TestDownAndMask:
def test_down_removes_the_config(self, tmp_path): def test_down_removes_the_config(self, tmp_path):
run(tmp_path, "up", **FIELDS) run(tmp_path, "up", **FIELDS)
@@ -141,7 +175,8 @@ class TestDownAndMask:
def test_mask_prints_every_secret(self, tmp_path): def test_mask_prints_every_secret(self, tmp_path):
result = run(tmp_path, "mask", WG_PRIVATE_KEY=KEY, WG_CONFIG="[Interface]\nPrivateKey = abc") result = run(tmp_path, "mask", WG_PRIVATE_KEY=KEY, WG_CONFIG="[Interface]\nPrivateKey = abc")
assert f"::add-mask::{KEY}" in result.stdout assert f"::add-mask::{KEY}" in result.stdout
assert "::add-mask::PrivateKey = abc" in result.stdout assert "::add-mask::abc" in result.stdout
assert "[Interface]" not in result.stdout # only the key values are masked
def test_unknown_command_fails(self, tmp_path): def test_unknown_command_fails(self, tmp_path):
assert run(tmp_path, "sideways").returncode == 1 assert run(tmp_path, "sideways").returncode == 1