Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 168 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
name: CI

# Runs on every pull request that touches shell code. The workflow deliberately requests
# no secrets and a read-only token: it executes code from pull requests, including forks,
# so it uses `pull_request` (never `pull_request_target`) and grants nothing beyond
# reading the repository.
on:
push:
branches: [main]
paths:
- '**.sh'
- 'tests/**'
- '.github/workflows/ci.yml'
pull_request:
paths:
- '**.sh'
- 'tests/**'
- '.github/workflows/ci.yml'
workflow_dispatch:

permissions:
contents: read

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
lint:
name: Syntax and lint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6

# Pure parser check — reports the file and line of any syntax error without
# executing anything. This cannot false-positive, so it is a hard gate.
- name: Parse every shell script
run: |
set -euo pipefail
for f in mtproxymax.sh install.sh tests/*.sh tests/integration/*.sh; do
bash -n "$f"
echo "ok $f"
done

# Errors only. A 19k-line script carries a large backlog of style warnings, and
# gating on those on day one would make this job permanently red and therefore
# ignored. Warnings are still worth having locally: run `shellcheck mtproxymax.sh`
# without -S to see them. Tighten this to `warning` once the backlog is worked down.
- name: shellcheck (errors only)
run: |
shellcheck -S error mtproxymax.sh install.sh tests/*.sh tests/integration/*.sh

unit-tests:
name: Unit tests (${{ matrix.image }})
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
# Report every distro rather than stopping at the first one that fails — the
# interesting signal is which platforms differ.
fail-fast: false
matrix:
# The install command is per-image and explicit rather than auto-detected: some
# images ship bash but still lack tools the suite needs (fedora:41 has bash but no
# `diff`, which silently failed one assertion until it was added here).
#
# Alpine deliberately installs ONLY bash. Adding coreutils/diffutils would shadow
# busybox and hide exactly the differences this row exists to catch.
#
# `quarantine` lists tests already known to fail on that image. They still run and
# their failures are printed, but they do not fail the build. Every entry here is a
# real, unfixed defect — delete the entry when the underlying bug is fixed.
include:
- image: debian:12
install: 'apt-get update -qq && apt-get install -y -qq bash diffutils'
quarantine: 'test_client_mss.sh'
- image: ubuntu:22.04
install: 'apt-get update -qq && apt-get install -y -qq bash diffutils'
quarantine: 'test_client_mss.sh'
- image: ubuntu:24.04
install: 'apt-get update -qq && apt-get install -y -qq bash diffutils'
quarantine: 'test_client_mss.sh'
# Alpine carries three known failures, all busybox divergences on a platform the
# README lists as supported. Each is fixed by an open PR — remove the entry once
# that PR has merged:
# test_client_mss.sh — broken on every distro, not a platform difference: it
# asserts on the stdout of generate_telemt_config, which
# takes a destination path and writes
# ${CONFIG_DIR}/config.toml instead. Fixed by #145.
# test_traffic_reset.sh — busybox `flock` has no -w, so `flock -w 5 9` fails
# while `command -v flock` succeeds, so the guard never
# fires. Two call sites failed OPEN and silently wrote
# nothing. Fixed by #146.
# test_guest.sh — `date -d "+24 hours"` is invalid on busybox and the
# `date -r <epoch>` fallback fails as well (busybox -r
# means reference file), so expiring guest links got no
# expiry. Fixed by #148.
#
# Leaving an entry in place after its fix has landed is harmless — a quarantined
# test that passes is reported as PASS — so this list is correct in any merge
# order. run-all.sh flags such entries as "! still listed as quarantined" so a
# stale one is visible rather than silently ignored.
- image: alpine:3.20
install: 'apk add --no-cache bash'
quarantine: 'test_client_mss.sh,test_traffic_reset.sh,test_guest.sh'
- image: fedora:41
install: 'dnf install -y -q bash diffutils'
quarantine: 'test_client_mss.sh'
steps:
- uses: actions/checkout@v6

# Each distro runs the same suite in a container. Alpine is the row that earns its
# keep: its busybox userland (sed, grep, mktemp, date) differs from GNU, and Alpine
# is a documented supported platform that has never been exercised by CI.
#
# Note this uses `docker run` rather than the job-level `container:` key: JavaScript
# actions such as actions/checkout run with a `node` binary *inside* the job
# container, and the runner does not inject one, so a `container:` job on these
# images fails before it can install anything.
- name: Run suite in ${{ matrix.image }}
run: |
docker run --rm -v "$PWD:/src" -w /src \
-e MTPROXYMAX_QUARANTINE="${{ matrix.quarantine }}" \
"${{ matrix.image }}" sh -c '
set -e
${{ matrix.install }}
bash tests/run-all.sh
'

systemd-integration:
name: Init integration (systemd)
# ubuntu-24.04 is a full VM with systemd as PID 1 and Docker already installed, so the
# generated unit's `Requires=docker.service` resolves against the real unit. Running
# this in a container instead would need --privileged --cgroupns=host, and a systemd
# container is not a faithful enough substitute for the real thing.
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v6

- name: Confirm the runner really is systemd
# If this ever prints something other than "systemd", the job below would be
# testing nothing, so make that visible up front.
run: |
ps -p 1 -o comm=
systemctl is-system-running || true
sudo systemctl start docker || true

- name: Run systemd integration test
run: sudo bash tests/integration/autostart_systemd.sh

openrc-integration:
name: Init integration (OpenRC)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6

# OpenRC in a container needs the softlevel marker (its verify_boot() otherwise
# refuses to run any service) and a bash since the image ships only busybox ash;
# the test script sets both up itself.
- name: Run OpenRC integration test (Alpine)
run: |
docker run --rm -v "$PWD:/src" -w /src alpine:3.20 sh -c '
set -e
apk add --no-cache bash openrc
bash tests/integration/autostart_openrc.sh
'
206 changes: 206 additions & 0 deletions tests/integration/autostart_openrc.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
#!/bin/bash
# Integration test: setup_autostart() / main_service_remove() against a REAL OpenRC.
#
# Covers the non-systemd path added for Alpine (#130). tests/test_telegram_service_openrc.sh
# verifies that the right rc-update/rc-service commands *would* be invoked, using stubs.
# This runs the real thing: the init script is written to the real /etc/init.d, rc-update
# really registers it in a runlevel, and rc-service really executes it.
#
# Intended to run in a throwaway Alpine container:
# docker run --rm -v "$PWD:/src" -w /src alpine:3.20 sh -c '
# apk add --no-cache bash openrc && bash tests/integration/autostart_openrc.sh'
#
# Prints SKIP and exits 0 when OpenRC is not present, so it is harmless to run anywhere.

set -o pipefail

if [ "${BASH_VERSINFO[0]:-0}" -lt 4 ]; then
echo "SKIP: bash 4+ required (got ${BASH_VERSION:-unknown})" >&2
exit 0
fi

if [ ! -x /sbin/openrc-run ] || ! command -v rc-service >/dev/null 2>&1; then
echo "SKIP: OpenRC not present on this host" >&2
exit 0
fi

if [ "$(id -u)" -ne 0 ]; then
echo "ERROR: must run as root — this writes to /etc/init.d." >&2
echo " sudo bash $0" >&2
exit 1
fi

# OpenRC's verify_boot() refuses to run any service unless it believes the system was
# booted by OpenRC. In a container nothing booted it, so the marker has to be created.
SOFTLEVEL_CREATED=0
if [ ! -e /run/openrc/softlevel ]; then
mkdir -p /run/openrc
: >/run/openrc/softlevel
SOFTLEVEL_CREATED=1
fi

REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
INITD="/etc/init.d/mtproxymax"
RUNLEVEL_LINK="/etc/runlevels/default/mtproxymax"
DOCKER_STUB="/etc/init.d/docker"
DOCKER_STUB_CREATED=0
FAKE="/usr/local/bin/mtproxymax"
FAKE_LOG="/tmp/mtproxymax-fake.log"
FAKE_BACKUP="/tmp/mtproxymax-fake.backup"

FAKE_WAS_PRESENT=0
if [ -e "$FAKE" ]; then
cp -a "$FAKE" "$FAKE_BACKUP" 2>/dev/null && FAKE_WAS_PRESENT=1
fi

# The generated init script declares `depend() { need docker; }`. OpenRC refuses to start
# a service whose hard dependencies cannot be resolved (`ERROR: mtproxymax needs service(s)
# docker`), so a stub is required for the start assertion below to exercise OUR script
# rather than failing on the host's dependency graph.
if [ ! -e "$DOCKER_STUB" ]; then
cat >"$DOCKER_STUB" <<'DOCKER_EOF'
#!/sbin/openrc-run
description="Docker stub (MTProxyMax integration test)"

start() {
ebegin "Starting docker (stub)"
eend 0
}

stop() {
ebegin "Stopping docker (stub)"
eend 0
}
DOCKER_EOF
chmod +x "$DOCKER_STUB"
DOCKER_STUB_CREATED=1
fi

cleanup() {
rc-service mtproxymax stop >/dev/null 2>&1
rc-update del mtproxymax default >/dev/null 2>&1
rm -f "$INITD" "$RUNLEVEL_LINK"
[ "$DOCKER_STUB_CREATED" -eq 1 ] && rm -f "$DOCKER_STUB"
[ "$SOFTLEVEL_CREATED" -eq 1 ] && rm -f /run/openrc/softlevel
if [ "$FAKE_WAS_PRESENT" -eq 1 ]; then
cp -a "$FAKE_BACKUP" "$FAKE" 2>/dev/null
else
rm -f "$FAKE"
fi
rm -f "$FAKE_BACKUP"
}
trap cleanup EXIT

cat >"$FAKE" <<'FAKE_EOF'
#!/bin/bash
# Test double installed by tests/integration/autostart_openrc.sh, so that the generated
# init script's start()/stop() have something real to invoke.
printf '%s\n' "$*" >> /tmp/mtproxymax-fake.log
exit 0
FAKE_EOF
chmod +x "$FAKE"
: >"$FAKE_LOG"

MTPROXYMAX_SOURCE_ONLY=true source "$REPO_ROOT/mtproxymax.sh"
set +e

TESTS_RUN=0
TESTS_FAILED=0

assert_eq() {
local name="$1" want="$2" got="$3"
TESTS_RUN=$((TESTS_RUN + 1))
if [ "$got" = "$want" ]; then
printf ' PASS %s\n' "$name"
else
printf ' FAIL %s (got=%q want=%q)\n' "$name" "$got" "$want"
TESTS_FAILED=$((TESTS_FAILED + 1))
fi
}

assert_ne() {
local name="$1" unwanted="$2" got="$3"
TESTS_RUN=$((TESTS_RUN + 1))
if [ "$got" != "$unwanted" ]; then
printf ' PASS %s\n' "$name"
else
printf ' FAIL %s (unexpectedly %q)\n' "$name" "$got"
TESTS_FAILED=$((TESTS_FAILED + 1))
fi
}

assert_cmd_ok() {
local name="$1"
shift
local out rc
out=$("$@" 2>&1)
rc=$?
# OpenRC tries to enrol each service in cgroup v1 hierarchies that are read-only in a
# container, emitting one "can't create /sys/fs/cgroup/.../tasks" line per controller.
# That is environmental noise rather than test signal, and it buries the real error.
out=$(printf '%s\n' "$out" | grep -v '/sys/fs/cgroup/.*/tasks')
TESTS_RUN=$((TESTS_RUN + 1))
if [ "$rc" -eq 0 ]; then
printf ' PASS %s\n' "$name"
else
printf ' FAIL %s (exit %d)\n' "$name" "$rc"
printf '%s\n' "$out" | sed 's/^/ | /'
TESTS_FAILED=$((TESTS_FAILED + 1))
fi
}

assert_file_contains() {
local name="$1" needle="$2" file="$3"
TESTS_RUN=$((TESTS_RUN + 1))
if grep -qF -- "$needle" "$file" 2>/dev/null; then
printf ' PASS %s\n' "$name"
else
printf ' FAIL %s (missing %q in %s)\n' "$name" "$needle" "$file"
TESTS_FAILED=$((TESTS_FAILED + 1))
fi
}

file_state() { [ -e "$1" ] && echo present || echo absent; }

# --- the host must actually be what we think it is ----------------------------
assert_eq "host detects openrc" "openrc" "$(detect_init_system)"

# --- install ------------------------------------------------------------------
setup_autostart >/dev/null 2>&1
assert_eq "setup_autostart succeeds" "0" "$?"
assert_eq "init script written" "present" "$(file_state "$INITD")"
assert_eq "init script is executable" "yes" "$([ -x "$INITD" ] && echo yes || echo no)"
assert_eq "init script has openrc-run shebang" "#!/sbin/openrc-run" "$(head -n1 "$INITD" 2>/dev/null)"

# The dependency declarations matter independently of whether they can be resolved here;
# rc-service refuses to start a service with unmet hard deps, so this is load-bearing.
assert_file_contains "declares need docker" "need docker" "$INITD"
assert_file_contains "declares need net" "need net" "$INITD"

# The invariant openrc_enable_service() checks: rc-update's exit status is not trusted,
# the runlevel symlink actually landing is. Guards the #130 bug class.
assert_eq "runlevel symlink created" "present" "$(file_state "$RUNLEVEL_LINK")"

# --- start / stop -------------------------------------------------------------
# OpenRC keeps a dependency cache; without it a service's state is indeterminate and
# rc-service refuses every start with "already starting". On a real host the cache is
# built during boot; in a container nothing boots OpenRC, so it has to be built by hand.
# Note this must come *after* setup_autostart, so the new service is in the graph.
rc-update -u >/dev/null 2>&1

assert_cmd_ok "rc-service start succeeds" rc-service mtproxymax start
assert_eq "start() reached the mtproxymax binary" "start" "$(head -n1 "$FAKE_LOG" 2>/dev/null)"

assert_cmd_ok "rc-service stop succeeds" rc-service mtproxymax stop
assert_eq "stop() reached the mtproxymax binary" "stop" "$(tail -n1 "$FAKE_LOG" 2>/dev/null)"

# --- remove -------------------------------------------------------------------
main_service_remove >/dev/null 2>&1
assert_eq "main_service_remove succeeds" "0" "$?"
assert_eq "init script removed" "absent" "$(file_state "$INITD")"
assert_eq "runlevel symlink removed" "absent" "$(file_state "$RUNLEVEL_LINK")"
assert_ne "service no longer registered with rc-update" "mtproxymax" \
"$(rc-update show default 2>/dev/null | awk '{print $1}' | grep -x mtproxymax)"

printf '\n%d tests, %d failures\n' "$TESTS_RUN" "$TESTS_FAILED"
[ "$TESTS_FAILED" -eq 0 ]
Loading