diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f13b9f0..1716911 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -19,6 +19,20 @@ jobs: -v "$GITHUB_WORKSPACE/src/usr/local/emhttp/plugins/netbird:/usr/local/emhttp/plugins/netbird:ro" \ -w /work php:8.2-cli php tests/rosenpass-test.php + profile-fallback-tests: + name: Profile fallback regression tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Exercise CLI profile fallbacks without the JSON gateway + run: | + docker run --rm --network none -e NETBIRD_TEST_CONTAINER=1 \ + -v "$GITHUB_WORKSPACE:/work:ro" \ + -v "$GITHUB_WORKSPACE/src/usr/local/emhttp/plugins/netbird:/usr/local/emhttp/plugins/netbird:ro" \ + -w /work php:8.2-cli php tests/profile-fallback-test.php + php-cs-fixer: name: PHP-CS-Fixer runs-on: ubuntu-latest diff --git a/src/usr/local/emhttp/plugins/netbird/include/common.php b/src/usr/local/emhttp/plugins/netbird/include/common.php index 83cf334..e93f4ca 100644 --- a/src/usr/local/emhttp/plugins/netbird/include/common.php +++ b/src/usr/local/emhttp/plugins/netbird/include/common.php @@ -446,15 +446,37 @@ function listProfilesCli(): array if ($rc !== 0) { return []; } + return parseProfileList($out); +} + +/** + * Parse current NAME/ACTIVE tables and legacy marker-first profile lists. + * + * @return array + */ +function parseProfileList(string $out): array +{ $profiles = []; + $table = false; foreach (explode("\n", $out) as $line) { $line = trim($line); - // Skip the header line "Found N profiles:" and blanks. - if ($line === '' || stripos($line, 'Found') === 0) { + if ($line === '') { + continue; + } + if (!$table && preg_match('/^NAME\s+ACTIVE$/', $line)) { + $table = true; continue; } - // Lines are "✓ name" (active) or "✗ name" (passive). - if (preg_match('/^(✓|✗)\s+(.+)$/u', $line, $m)) { + if ($table) { + // The active marker follows the name; inactive rows have no marker. + // Preserve spaces in names created outside the plugin. + $active = preg_match('/^(.+?)\s+✓$/u', $line, $m) === 1; + $profiles[] = [ + 'name' => $active ? $m[1] : $line, + 'active' => $active, + ]; + } elseif (preg_match('/^(✓|✗)\s+(.+)$/u', $line, $m)) { + // Older binaries print "Found N profiles:" then "✓ name"/"✗ name". $profiles[] = [ 'name' => $m[2], 'active' => $m[1] === '✓', diff --git a/src/usr/local/emhttp/plugins/netbird/scripts/apply.sh b/src/usr/local/emhttp/plugins/netbird/scripts/apply.sh index f0b9ae9..15d1ac8 100755 --- a/src/usr/local/emhttp/plugins/netbird/scripts/apply.sh +++ b/src/usr/local/emhttp/plugins/netbird/scripts/apply.sh @@ -153,7 +153,13 @@ if [ "$MODE" = "ensure" ]; then # Prefer the JSON gateway for both checks; fall back to scraping CLI text # when the daemon predates it or was started without the socket. ACTIVE=$(nb_api GetActiveProfile | sed -n 's/.*"profileName":"\([^"]*\)".*/\1/p') - [ -n "$ACTIVE" ] || ACTIVE=$("$NB" profile list 2>/dev/null | awk '/^✓/{print $2}') + # Share the WebGUI's parser for both NAME/ACTIVE tables and older CLI lists. + [ -n "$ACTIVE" ] || ACTIVE=$(php -r ' + require "/usr/local/emhttp/plugins/netbird/include/common.php"; + foreach (Netbird\listProfilesCli() as $profile) { + if ($profile["active"]) { echo $profile["name"]; break; } + } + ' 2>/dev/null) # getFullPeerStatus is required: without it the daemon omits fullStatus # (and with it managementState) from the response entirely. STATUS_JSON=$(nb_api Status '{"getFullPeerStatus":true}') diff --git a/tests/README.md b/tests/README.md index 7e6a3ad..e14832c 100644 --- a/tests/README.md +++ b/tests/README.md @@ -11,3 +11,18 @@ The tests exercise the installed shell guard and PHP status helpers with a temporary fake NetBird CLI. They cover unset timestamps in different timezones, handshake freshness and peer capability, malformed responses, persistent fallback, and a hung status process. No host networking or real daemon is used. + +Run the CLI profile fallback tests in a disposable container: + +```sh +docker run --rm --network none -e NETBIRD_TEST_CONTAINER=1 \ + -v "$PWD:/work:ro" \ + -v "$PWD/src/usr/local/emhttp/plugins/netbird:/usr/local/emhttp/plugins/netbird:ro" \ + -w /work php:8.2-cli php tests/profile-fallback-test.php +``` + +These tests install a fake CLI, service script, and configuration inside the +container. They cover current NAME/ACTIVE tables, legacy marker-first lists, +inactive profiles, whitespace, CLI failures, and the real apply script's no-op +save behavior when the JSON gateway is unavailable. Do not run them on a live +Unraid host. diff --git a/tests/profile-fallback-test.php b/tests/profile-fallback-test.php new file mode 100644 index 0000000..cab5a58 --- /dev/null +++ b/tests/profile-fallback-test.php @@ -0,0 +1,123 @@ + ["NAME ACTIVE\ndefault ✓\ncompat080 \n", [ + ['name' => 'default', 'active' => true], ['name' => 'compat080', 'active' => false], + ]], + 'active profile after inactive rows' => ["NAME ACTIVE\ndefault\nwork-test ✓\n", [ + ['name' => 'default', 'active' => false], ['name' => 'work-test', 'active' => true], + ]], + 'legacy list' => ["Found 2 profiles:\n✗ default\n✓ work-test\n", [ + ['name' => 'default', 'active' => false], ['name' => 'work-test', 'active' => true], + ]], + 'tabs, CRLF and names with spaces' => ["NAME\tACTIVE\r\nHome Lab\t✓\r\nFoundry backup\t\r\n\r\n", [ + ['name' => 'Home Lab', 'active' => true], ['name' => 'Foundry backup', 'active' => false], + ]], + 'legacy names with spaces' => ["Found 2 profiles:\n✓ Home Lab\n✗ backup site\n", [ + ['name' => 'Home Lab', 'active' => true], ['name' => 'backup site', 'active' => false], + ]], + 'no active profile' => ["NAME ACTIVE\ndefault\n", [['name' => 'default', 'active' => false]]], + 'header only' => ["NAME ACTIVE\n", []], + 'empty legacy list' => ["Found 0 profiles:\n", []], + 'empty output' => ['', []], + 'unrecognized output' => ["Error: daemon unavailable\n", []], +]; + +$tmp = sys_get_temp_dir() . '/netbird-profiles-' . bin2hex(random_bytes(6)); +mkdir($tmp, 0700); +putenv('NB_TEST_PROFILES=' . $tmp . '/profiles'); +putenv('NB_TEST_CALLS=' . $tmp . '/calls'); +putenv('NB_TEST_PROFILE_RC=0'); +file_put_contents(Netbird\NETBIRD_BIN, <<<'SH' +#!/bin/sh +printf '%s\n' "$*" >> "$NB_TEST_CALLS" +case "$*" in + 'profile list') cat "$NB_TEST_PROFILES"; exit "$NB_TEST_PROFILE_RC" ;; + status) echo 'Management: Connected' ;; + 'profile select '*) exit 0 ;; + *) exit 1 ;; +esac +SH +); +chmod(Netbird\NETBIRD_BIN, 0700); + +foreach ($cases as $name => [$output, $expected]) { + file_put_contents($tmp . '/profiles', $output); + check($name . ' CLI fallback', Netbird\listProfilesCli(), $expected); + check($name . ' without JSON gateway', Netbird\listProfiles(), $expected); + $active = ''; + foreach ($expected as $profile) { + if ($profile['active']) { + $active = $profile['name']; + break; + } + } + check($name . ' active profile', Netbird\activeProfile(), $active); +} + +// A failed CLI must not make partial output look like a usable profile list. +file_put_contents($tmp . '/profiles', $cases['v0.80 table'][0]); +putenv('NB_TEST_PROFILE_RC=1'); +check('failed CLI ignores partial output', Netbird\listProfilesCli(), []); +check('failed CLI has no active profile', Netbird\activeProfile(), ''); +putenv('NB_TEST_PROFILE_RC=0'); + +mkdir(dirname(Netbird\CFG_FILE), 0700, true); +file_put_contents(Netbird\CFG_FILE, "ENABLE_NETBIRD=\"1\"\nMANAGE_DNS=\"0\"\nENABLE_ROSENPASS=\"0\"\n"); +mkdir(dirname(Netbird\RC_SCRIPT), 0700, true); +file_put_contents(Netbird\RC_SCRIPT, "#!/bin/sh\nexit 0\n"); +chmod(Netbird\RC_SCRIPT, 0700); +$socket = stream_socket_server('unix:///var/run/netbird.sock', $errno, $error); +if ($socket === false) { + throw new RuntimeException($error); +} + +// Exercise the real apply script with no JSON gateway: a save of the connected +// active profile must stop after the status check, without selecting or upping. +foreach (['v0.80 table' => 'default', 'active profile after inactive rows' => 'work-test', 'legacy list' => 'work-test'] as $name => $active) { + file_put_contents($tmp . '/profiles', $cases[$name][0]); + file_put_contents($tmp . '/calls', ''); + $process = proc_open(['timeout', '-k', '1', '10', 'bash', $plugin . '/scripts/apply.sh', $active, 'ensure'], + [0 => ['file', '/dev/null', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + $out = stream_get_contents($pipes[1]); + $err = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + check($name . ' apply exit (' . trim($out . $err) . ')', proc_close($process), 0); + $result = json_decode(file_get_contents(Netbird\RESULT_FILE), true, 512, JSON_THROW_ON_ERROR); + check($name . ' apply success', $result['ok'], true); + check($name . ' apply no-op', $result['message'], 'no change (already connected)'); + check($name . ' does not reconnect', file_get_contents($tmp . '/calls'), "profile list\nstatus\n"); +} +fclose($socket); + +echo "$checks checks, $failures failures\n"; +exit($failures > 0 ? 1 : 0);