diff --git a/luci-app-tailscale-ng/.deploy/README.md b/luci-app-tailscale-ng/.deploy/README.md deleted file mode 100644 index 4d671d328..000000000 --- a/luci-app-tailscale-ng/.deploy/README.md +++ /dev/null @@ -1,77 +0,0 @@ -# Deploy Tooling - -This folder contains a deploy workflow to push files on router for testing based on `bash + ssh/scp`. - -## Files - -- `.deploy/deploy.sh` - main deploy script (single pipeline mode) -- `.deploy/mapping.tsv` - explicit file mapping (`src -> dst -> mode`) -- `.deploy/target.env.example` - target configuration template -- `.deploy/target.env` - local router access settings (ignored by git) - -## Requirements - -- Bash (Linux/macOS, or Git Bash/WSL on Windows) -- OpenSSH client tools in PATH: `ssh`, `scp` -- Git in PATH (used to validate executable bit in repository index) -- SSH key access to router - -## Mapping Format (`mapping.tsv`) - -Each row contains three columns separated by one or more tabs: - -1. local path relative to `.deploy/` -2. absolute remote path on router -3. mode (for example `644`, `755`) - -Notes: - -- Empty lines are allowed. -- Comment lines start with `#`. -- You can align columns using extra tab characters. - -## Target Configuration (`target.env`) - -Create local config first: - -```bash -cp deploy/target.env.example deploy/target.env -``` - -Expected variables: - -- `ROUTER_HOST` -- `ROUTER_USER` -- `ROUTER_PORT` -- `SSH_KEY_PATH` (optional) - -## SSH Key Bootstrap - -- If `SSH_KEY_PATH` is set, deploy uses that key. -- If `SSH_KEY_PATH` is empty, deploy uses project key `.deploy/.ssh/id_ed25519`. -- If the project key does not exist, deploy prompts to generate it on first run. -- Deploy tries to publish the public key to `/etc/dropbear/authorized_keys`. -- If auto-publish is not possible, deploy exits with exact manual commands to install the key on router. -- For non-interactive runs, set `.deploy_AUTO_YES=1` to auto-confirm key generation. - -## Usage - -Run from `luci-app-tailscale-ng`: - -```bash -./deploy/deploy.sh -./deploy/deploy.sh deploy -./deploy/deploy.sh clean -``` - -Modes: -- `deploy` (default) - auth + copy + chmod + rpcd restart -- `clean` - auth + remove mapped target files only - -## Behavior - -- Running `./deploy/deploy.sh` is same as `./deploy/deploy.sh deploy`. -- Uploads mapped files via `scp -p` -- Applies `chmod` only when remote mode differs from expected mode -- Runs post-sync commands after copy/perms (deploy mode only) -- Post-sync commands are defined in `.deploy.sh` (currently `rpcd restart`) diff --git a/luci-app-tailscale-ng/.deploy/deploy.sh b/luci-app-tailscale-ng/.deploy/deploy.sh deleted file mode 100644 index 52df087db..000000000 --- a/luci-app-tailscale-ng/.deploy/deploy.sh +++ /dev/null @@ -1,374 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -APP_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)" -MAPPING_FILE="$SCRIPT_DIR/mapping.tsv" -TARGET_ENV_FILE="$SCRIPT_DIR/target.env" -DEFAULT_PROJECT_KEY="$SCRIPT_DIR/.ssh/id_ed25519" -RUNTIME_KEY_TO_CLEAN="" - -declare -a MAP_SRC=() -declare -a MAP_DST=() -declare -a MAP_MODE=() - -POST_SYNC_COMMANDS=( - "/etc/init.d/rpcd restart >/dev/null 2>&1 || true" -) - -usage() { - cat <<'EOF' -Usage: - ./deploy/deploy.sh - ./deploy/deploy.sh deploy - ./deploy/deploy.sh clean -EOF -} - -log() { printf '%s\n' "$*"; } -step() { printf '[STEP] %s\n' "$*"; } - -die() { - printf 'ERROR: %s\n' "$*" >&2 - exit 1 -} - -require_cmd() { command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"; } - -trim() { - local s="$1" - s="${s#"${s%%[![:space:]]*}"}" - s="${s%"${s##*[![:space:]]}"}" - printf '%s' "$s" -} - -is_tty() { [[ -t 0 && -t 1 ]]; } - -remote_quote() { printf "'%s'" "${1//\'/\'\\\'\'}"; } - -cleanup_runtime_key() { - if [[ -n "$RUNTIME_KEY_TO_CLEAN" && -f "$RUNTIME_KEY_TO_CLEAN" ]]; then - rm -f "$RUNTIME_KEY_TO_CLEAN" 2>/dev/null || true - fi -} - -load_target_env() { - [[ -f "$TARGET_ENV_FILE" ]] || die "Target config is missing: $TARGET_ENV_FILE" - # shellcheck source=/dev/null - source "$TARGET_ENV_FILE" - - ROUTER_HOST="$(trim "${ROUTER_HOST:-}")" - ROUTER_USER="$(trim "${ROUTER_USER:-root}")" - ROUTER_PORT="$(trim "${ROUTER_PORT:-22}")" - SSH_KEY_PATH="$(trim "${SSH_KEY_PATH:-}")" - - [[ -n "$ROUTER_HOST" ]] || die "ROUTER_HOST is required in $TARGET_ENV_FILE" - [[ -n "$ROUTER_USER" ]] || die "ROUTER_USER is required in $TARGET_ENV_FILE" - [[ -n "$ROUTER_PORT" ]] || die "ROUTER_PORT is required in $TARGET_ENV_FILE" -} - -resolve_key_path() { - local p - if [[ -n "$SSH_KEY_PATH" ]]; then - p="$SSH_KEY_PATH" - else - p="$DEFAULT_PROJECT_KEY" - fi - if [[ "$p" != /* ]]; then - p="$APP_ROOT/$p" - fi - printf '%s' "$p" -} - -confirm_generate_key() { - local path="$1" - if [[ "${DEPLOY_AUTO_YES:-0}" == "1" ]]; then - return 0 - fi - if ! is_tty; then - die "SSH key is missing: $path. Generate it manually: ssh-keygen -t ed25519 -N '' -f '$path' -C 'luci-app-tailscale-deploy'" - fi - printf 'Project SSH key not found: %s\n' "$path" - printf 'Generate it now? [y/N]: ' - local answer - read -r answer - [[ "$answer" == "y" || "$answer" == "Y" ]] || die "SSH key generation declined. Aborting." -} - -ensure_local_key() { - local key_path="$1" - local key_dir - key_dir="$(dirname -- "$key_path")" - mkdir -p "$key_dir" - if [[ ! -f "$key_path" ]]; then - confirm_generate_key "$key_path" - ssh-keygen -t ed25519 -N "" -f "$key_path" -C "luci-app-tailscale-deploy" >/dev/null - log "Generated SSH key: $key_path" - fi - [[ -f "${key_path}.pub" ]] || die "Public key is missing: ${key_path}.pub" -} - -runtime_key_path() { - local key_path="$1" - local perm - perm="$(stat -c '%a' "$key_path" 2>/dev/null || true)" - if [[ "$perm" == "600" || "$perm" == "400" ]]; then - printf '%s' "$key_path" - return 0 - fi - local runtime_dir="$HOME/.ssh" - local runtime_key="$runtime_dir/luci-app-tailscale-deploy-${$}.key" - mkdir -p "$runtime_dir" - cat "$key_path" > "$runtime_key" - chmod 700 "$runtime_dir" 2>/dev/null || true - chmod 600 "$runtime_key" 2>/dev/null || true - RUNTIME_KEY_TO_CLEAN="$runtime_key" - printf '%s' "$runtime_key" -} - -build_ssh_opts() { - local key_path="$1" - SSH_OPTS=( - -p "$ROUTER_PORT" - -i "$key_path" - -o BatchMode=yes - -o ConnectTimeout=8 - -o ConnectionAttempts=1 - ) - SCP_OPTS=( - -P "$ROUTER_PORT" - -i "$key_path" - -p - -o BatchMode=yes - -o ConnectTimeout=8 - -o ConnectionAttempts=1 - ) -} - -remote() { ssh "${SSH_OPTS[@]}" "$ROUTER_USER@$ROUTER_HOST" "$1"; } - -print_manual_key_install() { - local key_path="$1" - local pub - pub="$(cat "${key_path}.pub")" - printf '\nManual SSH key installation required for %s@%s.\n' "$ROUTER_USER" "$ROUTER_HOST" >&2 - printf 'Run on router:\n' >&2 - printf 'mkdir -p /etc/dropbear\n' >&2 - printf 'touch /etc/dropbear/authorized_keys\n' >&2 - printf "grep -qxF %s /etc/dropbear/authorized_keys || echo %s >> /etc/dropbear/authorized_keys\n" "$(remote_quote "$pub")" "$(remote_quote "$pub")" >&2 - printf 'chmod 700 /etc/dropbear\n' >&2 - printf 'chmod 600 /etc/dropbear/authorized_keys\n\n' >&2 - printf 'Public key:\n%s\n\n' "$pub" >&2 -} - -ensure_router_key_auth() { - local key_path="$1" - local pub - pub="$(cat "${key_path}.pub")" - if remote "true" >/dev/null 2>&1; then - return 0 - fi - if ssh -p "$ROUTER_PORT" -o ConnectTimeout=8 -o ConnectionAttempts=1 "$ROUTER_USER@$ROUTER_HOST" \ - "mkdir -p /etc/dropbear; touch /etc/dropbear/authorized_keys; grep -qxF $(remote_quote "$pub") /etc/dropbear/authorized_keys || echo $(remote_quote "$pub") >> /etc/dropbear/authorized_keys; chmod 700 /etc/dropbear; chmod 600 /etc/dropbear/authorized_keys" >/dev/null 2>&1; then - if remote "true" >/dev/null 2>&1; then - log "Project key published to router: ${key_path}.pub" - return 0 - fi - fi - print_manual_key_install "$key_path" - die "Router key authentication is not configured for key: $key_path" -} - -load_mapping() { - [[ -f "$MAPPING_FILE" ]] || die "Mapping file is missing: $MAPPING_FILE" - MAP_SRC=() - MAP_DST=() - MAP_MODE=() - - local line_no=0 line src dst mode src_abs - while IFS= read -r line || [[ -n "$line" ]]; do - line_no=$((line_no + 1)) - line="$(trim "$line")" - [[ -z "$line" ]] && continue - [[ "${line:0:1}" == "#" ]] && continue - - IFS=$'\t' read -r src dst mode _extra <<<"$line" - src="$(trim "${src:-}")" - dst="$(trim "${dst:-}")" - mode="$(trim "${mode:-}")" - - [[ -n "$src" && -n "$dst" && -n "$mode" ]] || die "Invalid mapping at line $line_no in $MAPPING_FILE" - [[ "$dst" == /* ]] || die "Destination path must be absolute at line $line_no: $dst" - [[ "$mode" =~ ^[0-7]{3,4}$ ]] || die "Invalid mode at line $line_no: $mode" - - src_abs="$SCRIPT_DIR/$src" - [[ -f "$src_abs" ]] || die "Source file not found at line $line_no: $src_abs" - - MAP_SRC+=("$src") - MAP_DST+=("$dst") - MAP_MODE+=("$mode") - done <"$MAPPING_FILE" - - [[ ${#MAP_SRC[@]} -gt 0 ]] || die "Mapping has no entries: $MAPPING_FILE" -} - -validate_git_exec_bits() { - local i src mode repo_rel git_mode - for ((i=0; i<${#MAP_SRC[@]}; i++)); do - src="${MAP_SRC[$i]}" - mode="${MAP_MODE[$i]}" - [[ "$mode" == "755" || "$mode" == "0755" ]] || continue - repo_rel="${src#../}" - git_mode="$(git -C "$APP_ROOT" ls-files --stage -- "$repo_rel" | awk '{print $1}')" - [[ -n "$git_mode" ]] || die "Expected executable file is not tracked by git: $repo_rel" - [[ "$git_mode" == "100755" ]] || die "Expected git mode 100755 for $repo_rel, got $git_mode" - done -} - -ensure_remote_dirs() { - local i dir - for ((i=0; i<${#MAP_DST[@]}; i++)); do - dir="$(dirname -- "${MAP_DST[$i]}")" - remote "mkdir -p $(remote_quote "$dir")" - done -} - -copy_files() { - local i src_abs dst - step "Copy: ${#MAP_SRC[@]} files -> target" - ensure_remote_dirs - for ((i=0; i<${#MAP_SRC[@]}; i++)); do - src_abs="$SCRIPT_DIR/${MAP_SRC[$i]}" - dst="${MAP_DST[$i]}" - log "COPY ${MAP_SRC[$i]} -> $dst" - scp "${SCP_OPTS[@]}" "$src_abs" "$ROUTER_USER@$ROUTER_HOST:$dst" >/dev/null - done -} - -symbolic_to_octal() { - local s="$1" p d1=0 d2=0 d3=0 - p="${s:1:9}" - [[ "${p:0:1}" == "r" ]] && d1=$((d1 + 4)) - [[ "${p:1:1}" == "w" ]] && d1=$((d1 + 2)) - [[ "${p:2:1}" =~ [xst] ]] && d1=$((d1 + 1)) - [[ "${p:3:1}" == "r" ]] && d2=$((d2 + 4)) - [[ "${p:4:1}" == "w" ]] && d2=$((d2 + 2)) - [[ "${p:5:1}" =~ [xst] ]] && d2=$((d2 + 1)) - [[ "${p:6:1}" == "r" ]] && d3=$((d3 + 4)) - [[ "${p:7:1}" == "w" ]] && d3=$((d3 + 2)) - [[ "${p:8:1}" =~ [xt] ]] && d3=$((d3 + 1)) - printf '%d%d%d' "$d1" "$d2" "$d3" -} - -remote_mode() { - local dst="$1" out - out="$(remote "if command -v stat >/dev/null 2>&1; then stat -c '%a' $(remote_quote "$dst") 2>/dev/null; else ls -ld $(remote_quote "$dst") 2>/dev/null | awk '{print \$1}'; fi" || true)" - out="$(printf '%s' "$out" | tr -d '\r\n')" - if [[ "$out" =~ ^[0-7]{3,4}$ ]]; then - printf '%s' "${out#0}" - return 0 - fi - if [[ "$out" =~ ^[-dlcbps][rwxstST-]{9}$ ]]; then - symbolic_to_octal "$out" - return 0 - fi - printf '' -} - -set_perms() { - step "Permissions: set modes for ${#MAP_DST[@]} files on target" - local i dst expected actual normalized - for ((i=0; i<${#MAP_DST[@]}; i++)); do - dst="${MAP_DST[$i]}" - expected="${MAP_MODE[$i]}" - normalized="${expected#0}" - actual="$(remote_mode "$dst")" - if [[ -z "$actual" ]]; then - log "CHMOD $normalized $dst" - remote "chmod $expected $(remote_quote "$dst")" - continue - fi - if [[ "$actual" != "$normalized" ]]; then - log "CHMOD $normalized $dst" - remote "chmod $expected $(remote_quote "$dst")" - else - log "MODE_OK $normalized $dst" - fi - done -} - -run_post_sync() { - step "Post: running post-sync commands" - local cmd - for cmd in "${POST_SYNC_COMMANDS[@]}"; do - log "POST $cmd" - remote "$cmd" - done -} - -run_uci_defaults() { - step "Defaults: running /etc/uci-defaults/40_luci-app-tailscale-ng" - remote "if [ -f /etc/uci-defaults/40_luci-app-tailscale-ng ]; then sh /etc/uci-defaults/40_luci-app-tailscale-ng; else echo 'SKIP: /etc/uci-defaults/40_luci-app-tailscale-ng not found'; fi" -} - -clean_target_files() { - step "Clean: remove mapped files on target" - local i dst - for ((i=0; i<${#MAP_DST[@]}; i++)); do - dst="${MAP_DST[$i]}" - log "REMOVE $dst" - remote "rm -f $(remote_quote "$dst")" - done -} - -main() { - trap cleanup_runtime_key EXIT - local run_mode="deploy" - if [[ $# -gt 1 ]]; then - die "Too many arguments. Use: ./deploy/deploy.sh [deploy|clean]" - fi - if [[ $# -eq 1 ]]; then - case "$1" in - -h|--help) - usage - exit 0 - ;; - deploy|clean) - run_mode="$1" - ;; - *) - die "Unknown mode: $1. Use: deploy or clean" - ;; - esac - fi - - require_cmd ssh - require_cmd scp - require_cmd git - - load_mapping - validate_git_exec_bits - load_target_env - - local active_key effective_key - active_key="$(resolve_key_path)" - ensure_local_key "$active_key" - effective_key="$(runtime_key_path "$active_key")" - build_ssh_opts "$effective_key" - ensure_router_key_auth "$active_key" - - step "Auth: checking SSH access to $ROUTER_USER@$ROUTER_HOST" - remote "true" >/dev/null - if [[ "$run_mode" == "clean" ]]; then - clean_target_files - log "DONE: clean completed successfully" - return 0 - fi - copy_files - set_perms - run_uci_defaults - run_post_sync - log "DONE: deploy pipeline completed successfully" -} - -main "$@" diff --git a/luci-app-tailscale-ng/.deploy/mapping.tsv b/luci-app-tailscale-ng/.deploy/mapping.tsv deleted file mode 100644 index 5475f9c5f..000000000 --- a/luci-app-tailscale-ng/.deploy/mapping.tsv +++ /dev/null @@ -1,10 +0,0 @@ -# local_path_from_deploy_dirremote_absolute_pathmode -# You can use multiple TABs between columns for visual alignment. - -../htdocs/luci-static/resources/view/tailscale-ng/index.js /www/luci-static/resources/view/tailscale-ng/index.js 644 -../root/etc/config/luci-app-tailscale-ng /etc/config/luci-app-tailscale-ng 644 -../root/etc/hotplug.d/iface/40-luci-app-tailscale-ng /etc/hotplug.d/iface/40-luci-app-tailscale-ng 755 -../root/etc/init.d/luci-app-tailscale-ng /etc/init.d/luci-app-tailscale-ng 755 -../root/etc/uci-defaults/40_luci-app-tailscale-ng /etc/uci-defaults/40_luci-app-tailscale-ng 755 -../root/usr/share/luci/menu.d/luci-app-tailscale-ng.json /usr/share/luci/menu.d/luci-app-tailscale-ng.json 644 -../root/usr/share/rpcd/acl.d/luci-app-tailscale-ng.json /usr/share/rpcd/acl.d/luci-app-tailscale-ng.json 644 diff --git a/luci-app-tailscale-ng/.deploy/target.env.example b/luci-app-tailscale-ng/.deploy/target.env.example deleted file mode 100644 index f31aceebf..000000000 --- a/luci-app-tailscale-ng/.deploy/target.env.example +++ /dev/null @@ -1,5 +0,0 @@ -ROUTER_HOST=192.168.1.1 -ROUTER_USER=root -ROUTER_PORT=22 -# Optional. If empty, deploy uses project key: deploy/.ssh/id_ed25519 -# SSH_KEY_PATH=deploy/.ssh/id_ed25519 diff --git a/luci-app-tailscale-ng/.dev/build-packages/README.md b/luci-app-tailscale-ng/.dev/build-packages/README.md new file mode 100644 index 000000000..a275ac114 --- /dev/null +++ b/luci-app-tailscale-ng/.dev/build-packages/README.md @@ -0,0 +1,70 @@ +# Build Packages + +Build installable OpenWrt packages with [build-packages.sh](build-packages.sh), the same script used by [GitHub Actions](../../.github/workflows/releases.yml). No push or release is required. + +Run the commands below from the repository root in Bash. + +## Requirements + +- Ubuntu 24.04 x86_64, including under WSL2, running as a regular user. +- A clean Git working tree, including untracked files. The script builds the current commit. + +## Setup + +Install the build dependencies once: + +```bash +sudo apt-get update +sudo apt-get install -y git build-essential flex bison gawk gcc-multilib g++-multilib gettext libncurses-dev libssl-dev python3-setuptools rsync unzip zlib1g-dev file wget curl zstd +``` + +Commit the source changes you want to build. SDK versions and checksums are fixed in the script; the SDK is downloaded on first use. + +## Usage + +Build APK packages for OpenWrt 25.12: + +```bash +JOBS=2 bash .dev/build-packages/build-packages.sh apk +``` + +For IPK packages targeting OpenWrt 24.10: + +```bash +JOBS=2 bash .dev/build-packages/build-packages.sh ipk +``` + +Outputs go to `.dev/build-packages/artifacts/apk/` or `.dev/build-packages/artifacts/ipk/`: the application package, Chinese translation, `SHA256SUMS` and `build-info.txt`. The metadata records the source commit and SDK details. + +Packages are architecture independent. Obtain native dependencies such as `tailscale` from the router's own feeds. + +## Verification + +For an APK build, check the output checksums and source commit: + +```bash +cd .dev/build-packages/artifacts/apk +sha256sum --check SHA256SUMS +cat build-info.txt +cd ../../../.. +``` + +Back up the router's Tailscale configuration and authorization state before testing. Transfer both packages and follow the [manual installation instructions](../../README.md#manual-installation). Local APK packages are unsigned and require `--allow-untrusted`. + +Check installed versions, preserved settings and login, service operation, and the translated LuCI page. If Software hides the translation package, select **Display LuCI translation packages → all**. + +## Configuration + +- `BUILD_CACHE_DIR` — SDK download and build cache directory; defaults to `${XDG_CACHE_HOME:-$HOME/.cache}/luci-app-tailscale-ng`. +- `OUTPUT_DIR` — Package and metadata output directory; defaults to `.dev/build-packages/artifacts//`. +- `JOBS` — Parallel build jobs; defaults to the number of available CPUs. + +Keep the SDK cache on a Linux filesystem at a path without spaces. The default output directories are ignored by Git. + +## Cleanup + +Delete the SDK cache directory for a fresh SDK, or `.dev/build-packages/artifacts/` to discard generated packages. + +## CI and Releases + +The [workflow](../../.github/workflows/releases.yml) builds both formats for relevant pushes to `main`, pull requests and manual runs. A successful run on `main` publishes a release when `PKG_VERSION` differs from the latest release. Update `PKG_VERSION` for each new release and keep `PKG_RELEASE:=1` fixed. Release tags and titles use `v`. diff --git a/luci-app-tailscale-ng/scripts/build-packages.sh b/luci-app-tailscale-ng/.dev/build-packages/build-packages.sh similarity index 96% rename from luci-app-tailscale-ng/scripts/build-packages.sh rename to luci-app-tailscale-ng/.dev/build-packages/build-packages.sh index 303e18abf..45589077d 100755 --- a/luci-app-tailscale-ng/scripts/build-packages.sh +++ b/luci-app-tailscale-ng/.dev/build-packages/build-packages.sh @@ -23,9 +23,9 @@ case "$format" in *) printf 'Usage: %s [apk|ipk]\n' "$0" >&2; exit 1 ;; esac -source_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cache_dir="${BUILD_CACHE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/luci-app-tailscale-ng}" -output_dir="${OUTPUT_DIR:-$source_dir/artifacts/$format}" +output_dir="${OUTPUT_DIR:-$source_dir/.dev/build-packages/artifacts/$format}" jobs="${JOBS:-$(nproc)}" if [[ "$(id -u)" == 0 ]]; then diff --git a/luci-app-tailscale-ng/.dev/capture-screenshots/README.md b/luci-app-tailscale-ng/.dev/capture-screenshots/README.md new file mode 100644 index 000000000..a1c7dc965 --- /dev/null +++ b/luci-app-tailscale-ng/.dev/capture-screenshots/README.md @@ -0,0 +1,49 @@ +# Capture Screenshots + +The [capture.mjs](capture.mjs) script regenerates README screenshots to keep them up to date as the application's LuCI interface evolves. It automatically captures the application tabs at a consistent width, with height determined by their content. Synthetic data replaces router-specific information to avoid exposing personal details or private network information. + +Run the commands below from the repository root. + +## Requirements + +- Node.js 20 or newer, npm, Bash and the SSH tools required by [Router SSH](../router-ssh/README.md). +- A reachable OpenWrt 25 router with Tailscale NG installed and LuCI using English and the Bootstrap theme, accessible over HTTP. +- SSH access that can manage temporary `rpcd` sessions. The default Router SSH user is `root`. + +## Setup + +Configure access following [Router SSH Setup](../router-ssh/README.md#setup), then install the capture dependencies: + +```shell +npm --prefix .dev/capture-screenshots ci --ignore-scripts +npm --prefix .dev/capture-screenshots exec -- playwright install chromium --only-shell +``` + +The deployed application JS must match the working tree, including the package version. Use [Deploy Files](../deploy-files/README.md) to update it before capture when needed. + +## Usage + +```shell +npm --prefix .dev/capture-screenshots run capture +``` + +Successful runs replace `docs/images/tailscale-ng-auth.png`, `docs/images/tailscale-ng-settings.png` and `docs/images/tailscale-ng-status.png` directly, updating the images referenced by the main README. Failed capture checks leave the existing PNGs unchanged. The technical report is saved to `.dev/capture-screenshots/report.json` and ignored by Git. + +The script loads the router's HTML, styles, JS and English translations. It substitutes the page title and router brand, mocks RPC data, menu and release checks, and blocks unknown requests. Router configuration is preserved. A separate browser context and a temporary five-minute LuCI session are created for each run; the session is removed on exit. + +## Verification + +The run checks deployed JS, hostname replacement, page errors, horizontal overflow, configuration preservation and session cleanup. It captures each tab twice around a background status refresh and requires identical PNG hashes. Unknown HTTP/RPC requests are also checked for blocking. + +Check `success: true` in `report.json` and review the three PNGs in `docs/images/` for complete content and synthetic values. Width is fixed; no height is imposed on the full-page images. Other LuCI themes, versions, languages or operating systems may render differently. + +## Configuration + +- `BASH_PATH` — Optional Bash executable. Windows defaults to Git Bash under `ProgramFiles`; other systems use `bash` from `PATH`. +- `LUCI_URL` — Optional HTTP origin, including a custom port. Defaults to `http://` from Router SSH's `target.env`. + +Synthetic devices, users, addresses and command outputs belong in `fixtures.mjs`. Keep the JSON and text responses consistent. Viewport width and browser settings are defined in `capture.mjs`; the package version is read from `Makefile`. + +## Cleanup + +Delete `.dev/capture-screenshots/report.json` to discard the technical report, or `.dev/capture-screenshots/node_modules/` to remove local dependencies. Both are ignored by Git. PNGs in `docs/images/` are tracked project documentation. Playwright stores its downloaded browser in the user's cache outside the repository. diff --git a/luci-app-tailscale-ng/.dev/capture-screenshots/capture.mjs b/luci-app-tailscale-ng/.dev/capture-screenshots/capture.mjs new file mode 100644 index 000000000..8f184a2bd --- /dev/null +++ b/luci-app-tailscale-ng/.dev/capture-screenshots/capture.mjs @@ -0,0 +1,233 @@ +import { chromium } from 'playwright'; +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { readFile, mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { rpcReply, menu, identity } from './fixtures.mjs'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(here, '../..'); +const bash = process.env.BASH_PATH || (process.platform === 'win32' ? path.join(process.env.ProgramFiles || 'C:/Program Files', 'Git/bin/bash.exe') : 'bash'); +const width = 1280; +const imageDir = path.join(root, 'docs/images'); +const reportFile = path.join(here, 'report.json'); +const sha = value => createHash('sha256').update(value).digest('hex'); +const report = { width, screenshots: [], mockedCalls: {}, unexpected: [], pageErrors: [], blockedProbes: [] }; +let probing = false; +const realHostLabels = new Set(); +const captures = new Map(); +let completed = false; + +function router(script) { + const res = spawnSync(bash, ['.dev/router-ssh/router-ssh.sh', 'exec-command'], { + cwd: root, input: `${script}\n# End of input\n`, encoding: 'utf8', timeout: 30000, windowsHide: true + }); + if (res.error || res.status !== 0) throw new Error('Router SSH operation failed'); + return res.stdout.trim(); +} + +const envResult = spawnSync(bash, ['-c', 'source .dev/router-ssh/target.env; printf "%s" "$ROUTER_HOST"'], { + cwd: root, encoding: 'utf8', timeout: 10000, windowsHide: true +}); +if (envResult.status !== 0 || !envResult.stdout.trim()) throw new Error('Cannot read router target'); +const host = envResult.stdout.trim(); +const base = process.env.LUCI_URL || `http://${host.includes(':') ? `[${host}]` : host}`; +const origin = new URL(base).origin; +if (new URL(base).protocol !== 'http:') throw new Error('Screenshot capture currently requires HTTP LuCI access'); +const pagePath = '/cgi-bin/luci/admin/vpn/tailscale-ng'; +const sourcePath = '/luci-static/resources/view/tailscale-ng/index.js'; +const workingSource = await readFile(path.join(root, 'htdocs/luci-static/resources/view/tailscale-ng/index.js'), 'utf8'); +const makefile = await readFile(path.join(root, 'Makefile'), 'utf8'); +const version = /^PKG_VERSION:=(.+)$/m.exec(makefile)?.[1].trim(); +if (!version) throw new Error('Missing package version'); +const expectedSource = workingSource.replaceAll('@APP_VERSION@', version); +const configHashes = () => router('sha256sum /etc/config/luci /etc/config/tailscale /etc/config/luci-app-tailscale-ng'); +const before = configHashes(); +let session; +let browser; +let context; +let cleanupError; + +try { + // The real session only authorizes page rendering. No real ubus/file/uci rights are granted. + const auth = JSON.parse(router(`ucode - <<'UCODE' +import { connect } from 'ubus'; +let b = connect(); +let s = b.call('session', 'create', { timeout: 300 }); +if (!s?.ubus_rpc_session) die('Session creation failed'); +let id = s.ubus_rpc_session; +b.call('session', 'set', { ubus_rpc_session: id, values: { username: 'root', token: id } }); +b.call('session', 'grant', { ubus_rpc_session: id, scope: 'access-group', objects: [ ['luci-base', 'read'], ['luci-app-tailscale-ng', 'read'], ['luci-app-tailscale-ng', 'write'] ] }); +let fileAccess = b.call('session', 'access', { ubus_rpc_session: id, scope: 'ubus', object: 'file', function: 'exec' }); +let uciAccess = b.call('session', 'access', { ubus_rpc_session: id, scope: 'ubus', object: 'uci', function: 'set' }); +print({ id, realRpcDenied: fileAccess?.access == false && uciAccess?.access == false }); +UCODE`)); + session = auth.id; + if (!/^[0-9a-f]{32}$/.test(session || '')) throw new Error('Invalid temporary session'); + if (!auth.realRpcDenied) throw new Error('Screenshot session unexpectedly permits real mutations'); + report.realRpcDenied = true; + browser = await chromium.launch({ headless: true }); + report.browserVersion = browser.version(); + context = await browser.newContext({ + viewport: { width, height: 900 }, deviceScaleFactor: 1, + locale: 'en-US', timezoneId: 'UTC', colorScheme: 'light', serviceWorkers: 'block' + }); + await context.addCookies([{ name: 'sysauth_http', value: session, url: origin, httpOnly: true, sameSite: 'Strict' }]); + await context.route('**/*', async route => { + const req = route.request(); + const url = new URL(req.url()); + try { + if (url.origin === origin && (url.pathname.startsWith('/cgi-bin/luci/admin/ubus') || url.pathname === '/ubus/')) { + const body = req.postDataJSON(); + const reply = item => { + if (item.method === 'list') return { jsonrpc: '2.0', id: item.id, result: [0, {}] }; + if (item.method !== 'call') throw new Error(`Unmapped RPC envelope: ${item.method}`); + const [, object, method, args = {}] = item.params; + const key = `${object}.${method}`; + report.mockedCalls[key] = (report.mockedCalls[key] || 0) + 1; + return { jsonrpc: '2.0', id: item.id, result: [0, rpcReply(object, method, args)] }; + }; + return await route.fulfill({ json: Array.isArray(body) ? body.map(reply) : reply(body) }); + } + if (url.origin === 'https://api.github.com') { + return await route.fulfill({ json: { tag_name: `v${version}`, draft: false, prerelease: false } }); + } + if (url.origin === origin && url.pathname === '/cgi-bin/luci/admin/menu') + return await route.fulfill({ json: { children: menu } }); + if (url.origin === origin && url.pathname === '/cgi-bin/luci/admin/translations/en' && req.method() === 'GET') + return await route.fulfill({ response: await route.fetch() }); + if (url.origin === origin && url.pathname === pagePath && req.method() === 'GET') { + const response = await route.fetch({ maxRedirects: 0 }); + if (response.status() !== 200) throw new Error(`LuCI document HTTP ${response.status()}`); + const originalHtml = await response.text(); + const brandPattern = /(]*\bclass=["'][^"']*\bbrand\b[^"']*["'][^>]*>)([\s\S]*?)(<\/a>)/gi; + const html = originalHtml + .replace(/[\s\S]*?<\/title>/i, '<title>OpenWrt - Tailscale NG') + .replace(brandPattern, (_, open, label, close) => { + if (label.trim() !== 'OpenWrt') realHostLabels.add(label.replace(/<[^>]*>/g, '').trim()); + return `${open}OpenWrt${close}`; + }); + if (!html.includes('tailscale-ng/index')) throw new Error('Authenticated LuCI view missing'); + return await route.fulfill({ response, body: html }); + } + if (url.origin === origin && url.pathname.startsWith('/luci-static/') && req.method() === 'GET') { + if (url.pathname === sourcePath) { + const response = await route.fetch(); + if (await response.text() !== expectedSource) throw new Error('Router JS differs from working tree: use Deploy Files first'); + report.sourceSha256 = sha(expectedSource); + return await route.fulfill({ response }); + } + return await route.fulfill({ response: await route.fetch() }); + } + throw new Error(`Unmapped request: ${req.method()} ${url.pathname}`); + } catch (error) { + (probing ? report.blockedProbes : report.unexpected).push(error.message.replaceAll(session, '[session]').replaceAll(origin, '[router]')); + await route.abort(); + } + }); + const page = await context.newPage(); + report.consoleErrors = []; + report.failedResponses = []; + page.on('console', msg => { + if (!probing && msg.type() === 'error') report.consoleErrors.push(msg.text().replaceAll(session, '[session]').replaceAll(origin, '[router]')); + }); + page.on('response', res => { + if (res.status() >= 400) report.failedResponses.push({ path: new URL(res.url()).pathname, status: res.status() }); + }); + page.on('pageerror', error => report.pageErrors.push(error.message.replaceAll(session, '[session]').replaceAll(origin, '[router]'))); + await page.clock.setFixedTime(new Date('2026-01-15T12:00:00Z')); + await page.goto(`${origin}${pagePath}`, { waitUntil: 'domcontentloaded' }); + const waitReady = async () => { + await page.locator('h2').filter({ hasText: 'Tailscale NG' }).waitFor({ timeout: 20000 }); + await page.getByText(identity.displayName, { exact: true }).waitFor({ state: 'attached', timeout: 20000 }); + await page.locator('#topmenu').getByText('VPN', { exact: true }).waitFor(); + await page.evaluate(() => document.fonts.ready); + if (report.unexpected.length) throw new Error(report.unexpected.join('; ')); + if (report.pageErrors.length) throw new Error('Page JavaScript error'); + }; + await waitReady(); + // Explicit conditions, rather than networkidle: LuCI deliberately polls in the background. + for (let pass = 1; pass <= 2; pass++) { + for (const [tab, label] of [['auth', 'Authentication'], ['settings', 'Settings'], ['status', 'Status']]) { + await page.locator(`.cbi-tabmenu li[data-tab="${tab}"] a`).click(); + await page.locator(`.cbi-tabmenu li.cbi-tab[data-tab="${tab}"]`).waitFor(); + if (tab === 'status') { + await page.locator('#tailscale_status').filter({ hasText: identity.hostname }).waitFor(); + await page.locator('#tailscale_dns_status').filter({ hasText: identity.domain }).waitFor(); + await page.locator('#tailscale_netcheck').filter({ hasText: '203.0.113.10' }).waitFor(); + } + await page.evaluate(() => { document.activeElement?.blur(); window.scrollTo(0, 0); }); + await page.mouse.move(0, 0); + await page.evaluate(() => document.fonts.ready); + const visible = await page.locator('body').innerText(); + if (await page.locator('a.brand').innerText() !== 'OpenWrt' || [...realHostLabels].some(label => label && visible.includes(label))) + throw new Error('Router hostname was not replaced'); + if (await page.locator('html').evaluate(el => el.scrollWidth) > width) + throw new Error('Horizontal overflow would change capture width'); + if (report.unexpected.length || report.pageErrors.length || report.consoleErrors.length || report.failedResponses.length) + throw new Error('Capture blocked by unexpected request, failed resource or page error'); + const pixels = await page.screenshot({ fullPage: true, animations: 'disabled', caret: 'hide', scale: 'css' }); + const filename = `tailscale-ng-${tab}.png`; + if (pass === 1) captures.set(filename, pixels); + const entry = { file: filename, pass, label, width: pixels.readUInt32BE(16), height: pixels.readUInt32BE(20), sha256: sha(pixels) }; + if (entry.width !== width) throw new Error('Unexpected screenshot width'); + report.screenshots.push(entry); + } + if (pass === 1) { + const callsBeforePoll = report.mockedCalls['file.exec']; + const deadline = Date.now() + 20000; + while (report.mockedCalls['file.exec'] < callsBeforePoll + 6) { + if (Date.now() > deadline) throw new Error('Expected background status refresh did not occur'); + await new Promise(resolve => setTimeout(resolve, 250)); + } + report.backgroundRefreshObserved = true; + } + } + report.identicalRepeatedCaptures = ['auth', 'settings', 'status'].every(tab => + report.screenshots.find(s => s.file === `tailscale-ng-${tab}.png` && s.pass === 1).sha256 === + report.screenshots.find(s => s.file === `tailscale-ng-${tab}.png` && s.pass === 2).sha256); + if (!report.identicalRepeatedCaptures) throw new Error('Repeated captures differ'); + probing = true; + // These deliberately unmapped operations must fail before reaching the router. + const blocked = await page.evaluate(async () => { + const attempts = [ + ['/ubus/', { method: 'POST', body: JSON.stringify({ jsonrpc: '2.0', id: 9001, method: 'call', params: ['screenshot-probe', 'file', 'exec', { command: '/bin/false', params: [] }] }) }], + ['/cgi-bin/luci/admin/screenshot-unmapped-probe', { method: 'GET' }] + ]; + return await Promise.all(attempts.map(async ([url, options]) => { + try { await fetch(url, options); return false; } catch { return true; } + })); + }); + report.unknownRequestsBlocked = blocked.every(Boolean) && report.blockedProbes.length === 2; + if (!report.unknownRequestsBlocked) throw new Error('Unknown request was not blocked'); + report.hostnameReplaced = true; + completed = true; +} finally { + await context?.close().catch(() => {}); + await browser?.close().catch(() => {}); + if (session) { + try { + router(`ubus call session destroy '{"ubus_rpc_session":"${session}"}'`); + const remaining = router(`ubus call session list '{"ubus_rpc_session":"${session}"}' 2>/dev/null || true`); + if (remaining.includes(session)) throw new Error('Temporary session still exists'); + report.temporarySessionDestroyed = true; + } catch { cleanupError = new Error('Temporary session cleanup failed'); } + } + report.configsUnchanged = before === configHashes(); + report.success = completed && report.configsUnchanged && report.temporarySessionDestroyed === true && !cleanupError; + if (report.success) { + await mkdir(imageDir, { recursive: true }); + for (const [filename, pixels] of captures) await writeFile(path.join(imageDir, filename), pixels); + } + await writeFile(reportFile, JSON.stringify(report, null, 2) + '\n'); + if (report.success) { + for (const shot of report.screenshots.filter(shot => shot.pass === 1)) + console.log(`${shot.file}: ${shot.width} x ${shot.height}`); + console.log(`Capture checks passed. Images: ${path.relative(root, imageDir)}; report: ${path.relative(root, reportFile)}`); + } else { + console.error(`Capture failed; previous PNGs were not replaced. See ${path.relative(root, reportFile)}`); + } + if (cleanupError) throw cleanupError; + if (!report.configsUnchanged) throw new Error('Router configuration changed'); +} diff --git a/luci-app-tailscale-ng/.dev/capture-screenshots/fixtures.mjs b/luci-app-tailscale-ng/.dev/capture-screenshots/fixtures.mjs new file mode 100644 index 000000000..6b1f8d099 --- /dev/null +++ b/luci-app-tailscale-ng/.dev/capture-screenshots/fixtures.mjs @@ -0,0 +1,106 @@ +export const identity = { + hostname: 'openwrt-home-main', + displayName: 'router-admin', + domain: 'tailabcd12.ts.net', + ipv4: '100.101.10.25', + ipv6: 'fd7a:115c:a1e0::1025' +}; +const dnsName = `${identity.hostname}.${identity.domain}`; + +export const configs = { + luci: { + main: { '.name': 'main', '.type': 'core', '.anonymous': false, '.index': 0, lang: 'en', mediaurlbase: '/luci-static/bootstrap-light' }, + apply: { '.name': 'apply', '.type': 'internal', '.anonymous': false, '.index': 1, rollback: '90', holdoff: '4', timeout: '5', display: '1.5' } + }, + 'luci-app-tailscale-ng': { + settings: { + '.name': 'settings', '.type': 'settings', '.anonymous': false, '.index': 0, + enabled: '1', accept_dns: '1', accept_routes: '0', advertise_exit_node: '0', + login_server: '', authkey: '', advertise_routes: [], flags: [] + } + }, + tailscale: { + settings: { + '.name': 'settings', '.type': 'settings', '.anonymous': false, '.index': 0, + enabled: '0', port: '41641', state_file: '/etc/tailscale/tailscaled.state', + fw_mode: 'nftables', log_stdout: '1', log_stderr: '1' + } + }, + network: { + lan: { + '.name': 'lan', '.type': 'interface', '.anonymous': false, '.index': 0, + proto: 'static', ipaddr: '192.168.10.1', netmask: '255.255.255.0' + } + } +}; + +const statusJson = { + BackendState: 'Running', AuthURL: '', + Self: { UserID: '1000', HostName: identity.hostname, DNSName: `${dnsName}.`, TailscaleIPs: [identity.ipv4, identity.ipv6], OS: 'linux' }, + User: { + '1000': { ID: '1000', DisplayName: identity.displayName, LoginName: 'router-admin@example.com' }, + '1001': { ID: '1001', DisplayName: 'user-main', LoginName: 'user-main@example.com' }, + '1002': { ID: '1002', DisplayName: 'infra', LoginName: 'infra@example.com' } + }, + Peer: { + phone: { UserID: '1001', HostName: 'pixel-8-pro', DNSName: `pixel-8-pro.${identity.domain}.`, TailscaleIPs: ['100.109.44.12'], OS: 'android', Online: false, ExitNodeOption: false }, + laptop: { UserID: '1001', HostName: 'thinkpad-x1', DNSName: `thinkpad-x1.${identity.domain}.`, TailscaleIPs: ['100.97.201.77'], OS: 'linux', Online: true, ExitNodeOption: false, TxBytes: 4120, RxBytes: 9981 }, + macbook: { UserID: '1001', HostName: 'macbook-air', DNSName: `macbook-air.${identity.domain}.`, TailscaleIPs: ['100.76.33.90'], OS: 'macOS', Online: true, ExitNodeOption: false }, + exit: { UserID: '1002', HostName: 'homelab-node', DNSName: `homelab-node.${identity.domain}.`, TailscaleIPs: ['100.120.5.199'], OS: 'linux', Online: true, ExitNodeOption: true, PrimaryRoutes: ['192.168.20.0/24'] } + } +}; + +const commands = new Map([ + ['tailscale version', '1.98.3\n'], + ['tailscale status --json', JSON.stringify(statusJson)], + ['tailscale status', `${identity.ipv4} openwrt-home-main router-admin@ linux -\n100.109.44.12 pixel-8-pro user-main@ android offline, last seen 2h ago\n100.97.201.77 thinkpad-x1 user-main@ linux active; tx 4120 rx 9981\n100.76.33.90 macbook-air user-main@ macOS -\n100.120.5.199 homelab-node infra@ linux idle; offers exit node\n`], + ['tailscale ip', `${identity.ipv4}\n${identity.ipv6}\n`], + ['ip addr show tailscale0', `9: tailscale0: mtu 1280 qdisc fq_codel state UNKNOWN qlen 500\n link/none\n inet ${identity.ipv4}/32 scope global tailscale0\n valid_lft forever preferred_lft forever\n inet6 ${identity.ipv6}/128 scope global\n valid_lft forever preferred_lft forever\n inet6 fe80::7b3d:5e1f:2c90:4a11/64 scope link flags 800\n valid_lft forever preferred_lft forever\n`], + ['tailscale netcheck', `Report:\n * Time: 2026-01-15T12:00:00Z\n * UDP: true\n * IPv4: yes, 203.0.113.10:41641\n * IPv6: no, but OS has support\n * MappingVariesByDestIP: false\n * PortMapping:\n * Nearest DERP: Amsterdam\n * DERP latency:\n - ams: 12.4ms (Amsterdam)\n - fra: 20.1ms (Frankfurt)\n - par: 25.8ms (Paris)\n - lhr: 31.2ms (London)\n - nyc: 89.6ms (New York City)\n`], + ['tailscale dns status', `=== 'Use Tailscale DNS' status ===\n\nTailscale DNS: enabled.\n\nTailscale is configured to handle DNS queries on this device.\nRun 'tailscale set --accept-dns=false' to revert to your system default DNS resolver.\n\n=== MagicDNS configuration ===\n\nThis is the DNS configuration provided by the coordination server to this device.\n\nMagicDNS: enabled tailnet-wide (suffix = ${identity.domain})\n\nOther devices in your tailnet can reach this device at ${dnsName}.\n\nResolvers (in preference order):\n (no resolvers configured, system default will be used: see 'System DNS configuration' below)\n\nSplit DNS Routes:\n - ts.net. -> 199.247.155.53\n - ts.net. -> 2620:111:8007::53\n\nSearch Domains:\n - ${identity.domain}\n\n=== System DNS configuration ===\n\nThis is the DNS configuration that Tailscale believes your operating system is using.\nTailscale may use this configuration if 'Override Local DNS' is disabled in the admin console,\nor if no resolvers are provided by the coordination server.\n\nNameservers:\n - 127.0.0.1\n - ::1\n\nSearch domains:\n - lan\n\n[this is a preliminary version of this command; the output format may change in the future]\n`] +]); + +export function rpcReply(object, method, args) { + if (object === 'service' && method === 'list' && args.name === 'tailscale') + return { tailscale: { instances: { instance1: { running: true } } } }; + if (object === 'system' && method === 'board') + return { hostname: identity.hostname, system: 'Example router', model: 'Example router', release: { distribution: 'OpenWrt', version: '25.12.5' } }; + if (object === 'uci' && method === 'get' && configs[args.config]) + return { values: structuredClone(configs[args.config]) }; + if (object === 'uci' && method === 'changes') return { changes: {} }; + if (object === 'session' && method === 'access') + return args.object ? { access: true } : { + 'access-group': { 'luci-base': ['read', 'write'], 'luci-app-tailscale-ng': ['read', 'write'] }, + uci: { 'luci-app-tailscale-ng': ['read', 'write'], tailscale: ['read', 'write'], network: ['read'] } + }; + if (object === 'luci' && method === 'getFeatures') return {}; + if (object === 'file' && method === 'list' && args.path === '/www/luci-static/resources/preload') + return { entries: [] }; + if (object === 'luci' && method === 'getInitList') + return { 'luci-app-tailscale-ng': { enabled: true }, tailscale: { enabled: false } }; + if (object === 'file' && method === 'exec') { + const command = [args.command?.split('/').pop(), ...(args.params || [])].join(' '); + if (commands.has(command)) return { code: 0, stdout: commands.get(command), stderr: '' }; + } + throw new Error(`Unmapped RPC: ${object}.${method} (${object === 'uci' ? args.config : object === 'file' && method === 'list' ? args.path : Object.keys(args || {}).sort().join(',')})`); +} + +export const menu = { + admin: { + title: 'Administration', order: 10, action: { type: 'firstchild' }, children: { + status: { title: 'Status', order: 10, action: { type: 'firstchild' }, children: { overview: { title: 'Overview', action: { type: 'view', path: 'status/overview' } } } }, + system: { title: 'System', order: 20, action: { type: 'firstchild' }, children: { system: { title: 'System', action: { type: 'view', path: 'system/system' } } } }, + network: { title: 'Network', order: 30, action: { type: 'firstchild' }, children: { network: { title: 'Interfaces', action: { type: 'view', path: 'network/interfaces' } } } }, + vpn: { title: 'VPN', order: 40, action: { type: 'firstchild' }, children: { 'tailscale-ng': { title: 'Tailscale NG', action: { type: 'view', path: 'tailscale-ng/index' } } } }, + logout: { title: 'Log out', order: 90, action: { type: 'call' } } + } + } +}; + +function satisfy(nodes) { + for (const node of Object.values(nodes)) { + node.satisfied = true; + if (node.children) satisfy(node.children); + } +} +satisfy(menu); diff --git a/luci-app-tailscale-ng/.dev/capture-screenshots/package-lock.json b/luci-app-tailscale-ng/.dev/capture-screenshots/package-lock.json new file mode 100644 index 000000000..a48454ecf --- /dev/null +++ b/luci-app-tailscale-ng/.dev/capture-screenshots/package-lock.json @@ -0,0 +1,40 @@ +{ + "name": "luci-app-tailscale-ng-screenshots", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "luci-app-tailscale-ng-screenshots", + "dependencies": { + "playwright": "1.65.0" + } + }, + "node_modules/playwright": { + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.65.0.tgz", + "integrity": "sha512-F3AMWcR44qMBt4kq+6Kb6mX7bjGXshktKFOPULd7reED6ZJWevjbNRqysP7q+AATE9kdPEZBibex1BYsvpVoeA==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.65.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.65.0.tgz", + "integrity": "sha512-vNSKgH6YqtN7cdT/L334zo0a+YwkrpicyrHJlWWPIm/Q+Nn2SWInil01oT0mbu96U4GgoRtCYQD9CzJQgju+lA==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/luci-app-tailscale-ng/.dev/capture-screenshots/package.json b/luci-app-tailscale-ng/.dev/capture-screenshots/package.json new file mode 100644 index 000000000..f8fcfd8c8 --- /dev/null +++ b/luci-app-tailscale-ng/.dev/capture-screenshots/package.json @@ -0,0 +1,7 @@ +{ + "name": "luci-app-tailscale-ng-screenshots", + "private": true, + "type": "module", + "scripts": { "capture": "node capture.mjs" }, + "dependencies": { "playwright": "1.65.0" } +} diff --git a/luci-app-tailscale-ng/.dev/deploy-files/README.md b/luci-app-tailscale-ng/.dev/deploy-files/README.md new file mode 100644 index 000000000..72ea162ff --- /dev/null +++ b/luci-app-tailscale-ng/.dev/deploy-files/README.md @@ -0,0 +1,49 @@ +# Deploy Files + +The [deploy-files.sh](deploy-files.sh) script deploys working-tree application changes to a test router without building a package. It copies mapped files, applies and verifies permissions, and runs the application's post-deploy steps. + +Run the commands below from the repository root in Bash (Git Bash on Windows). + +## Requirements + +- Bash, Git and the OpenSSH tools required by [Router SSH](../router-ssh/README.md). +- An OpenWrt router with LuCI and `tailscale` installed. + +## Setup + +Configure router access following [Router SSH Setup](../router-ssh/README.md#setup). Deployment uses its target config, keys and authentication flow. + +## Usage + +Deploy the mapped files: + +```bash +bash .dev/deploy-files/deploy-files.sh +``` + +The default `deploy` mode validates the mapping and router requirements, copies files, applies and verifies permissions, then substitutes the application version, runs the application's UCI defaults and restarts `rpcd`. Existing application configuration is preserved. Dependencies are not installed. + +Add `--interactive` in your own terminal if router access needs password-based key installation. The option is passed to Router SSH; ordinary runs never prompt for passwords. + +Use [Router SSH](../router-ssh/README.md#usage) directly to run commands or scripts, upload or download other files, or keep a shell session open. + +## Verification + +Review the output for copied and preserved files and failed operations. Check that the updated LuCI page loads, settings and login are preserved, and the Tailscale service works. + +## Configuration + +Each row in [mapping.tsv](mapping.tsv) contains tab-separated columns: + +1. Local path relative to `.dev/deploy-files/`. +2. Absolute destination file path on the router. +3. File permissions, such as `644` or `755`. +4. Policy: `overwrite` (default if omitted) or `keep-if-exists`. + +`keep-if-exists` copies only missing files; a failed remote check stops deployment. Blank lines, `#` comments and extra tabs for alignment are supported. Duplicate destinations are rejected. + +Router requirements and post-deploy commands are defined at the top of [deploy-files.sh](deploy-files.sh). Connection settings belong in Router SSH's local `target.env`. + +## Cleanup + +Run `bash .dev/deploy-files/deploy-files.sh clean` to remove mapped `overwrite` files from the router. It preserves `keep-if-exists` files and does not restore a previous installation. Failed deployments may leave partially updated files; review the error before repeating the operation. diff --git a/luci-app-tailscale-ng/.dev/deploy-files/deploy-files.sh b/luci-app-tailscale-ng/.dev/deploy-files/deploy-files.sh new file mode 100644 index 000000000..55f0931dc --- /dev/null +++ b/luci-app-tailscale-ng/.dev/deploy-files/deploy-files.sh @@ -0,0 +1,223 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +APP_ROOT="$(cd -- "$SCRIPT_DIR/../.." && pwd)" +MAPPING_FILE="$SCRIPT_DIR/mapping.tsv" +ROUTER_SSH="$SCRIPT_DIR/../router-ssh/router-ssh.sh" +RUN_MODE=deploy +INTERACTIVE=0 +MUTATION_STARTED=0 +CURRENT_STEP=validation +ROUTER_OPTIONS=() +MAP_SRC=() +MAP_DST=() +MAP_MODE=() +MAP_POLICY=() + +APP_VERSION="$(sed -n 's/^PKG_VERSION:=//p' "$APP_ROOT/Makefile" | tr -d '\r')" +ROUTER_TOOLS=(uci tailscale scp ls awk) +POST_SYNC_COMMANDS=( + "sed -i 's/@APP_VERSION@/$APP_VERSION/g' /www/luci-static/resources/view/tailscale-ng/index.js" + "sh /etc/uci-defaults/40_luci-app-tailscale-ng" + "/etc/init.d/rpcd restart" +) + +usage() { + cat <<'EOF' +Usage: + bash .dev/deploy-files/deploy-files.sh [deploy|clean] [--interactive] + +Deploy is the default. It copies mapped files, applies permissions and runs +post-deploy commands. Clean removes overwrite files and preserves protected files. +Router access and file transfers are handled by ../router-ssh/router-ssh.sh. +Use Router SSH directly for commands, scripts, downloads and open sessions. +--interactive passes through to Router SSH for password-based key installation +in your own terminal. Ordinary runs never prompt for a password. +Exit codes: 0 = completed, 1 = local error, 2 = user action required. +Router SSH failures retain their original exit status. +EOF +} + +log() { printf '%s\n' "$*" >&2; } +step() { CURRENT_STEP="$*"; printf '[STEP] %s\n' "$*" >&2; } +die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } +require_cmd() { command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"; } +trim() { + local value="$1" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + printf '%s' "$value" +} +remote_quote() { printf "'%s'" "${1//\'/\'\\\'\'}"; } +router() { bash "$ROUTER_SSH" "${ROUTER_OPTIONS[@]}" "$@"; } +remote() { router exec-command -- "$1"; } + +cleanup() { + local status=$? + if [[ "$status" != 0 && "$MUTATION_STARTED" == 1 ]]; then + log "ERROR: $RUN_MODE stopped during: $CURRENT_STEP. Router files may already have changed." + fi + return 0 +} + +load_mapping() { + [[ -f "$MAPPING_FILE" ]] || die "Mapping file is missing: $MAPPING_FILE" + local line_no=0 line src dst mode policy extra src_abs previous + while IFS= read -r line || [[ -n "$line" ]]; do + line_no=$((line_no + 1)) + line="$(trim "$line")" + [[ -n "$line" && "${line:0:1}" != '#' ]] || continue + IFS=$'\t' read -r src dst mode policy extra <<< "$line" + src="$(trim "${src:-}")" + dst="$(trim "${dst:-}")" + mode="$(trim "${mode:-}")" + policy="$(trim "${policy:-overwrite}")" + [[ -n "$src" && -n "$dst" && -n "$mode" && -z "${extra:-}" ]] || die "Invalid mapping at line $line_no: expected source, destination, mode and optional policy" + [[ "$dst" == /* && "$dst" != / && "$dst" != */ ]] || die "Destination must be an absolute file path at line $line_no: $dst" + [[ "$mode" =~ ^[0-7]{3,4}$ ]] || die "Invalid mode at line $line_no: $mode" + [[ "$policy" == overwrite || "$policy" == keep-if-exists ]] || die "Invalid policy at line $line_no: $policy" + for previous in "${MAP_DST[@]}"; do [[ "$previous" != "$dst" ]] || die "Duplicate destination at line $line_no: $dst"; done + src_abs="$SCRIPT_DIR/$src" + if [[ "$RUN_MODE" == deploy ]]; then + [[ -f "$src_abs" ]] || die "Source file not found at line $line_no: $src_abs" + fi + MAP_SRC+=("$src") + MAP_DST+=("$dst") + MAP_MODE+=("$mode") + MAP_POLICY+=("$policy") + done < "$MAPPING_FILE" + [[ ${#MAP_SRC[@]} -gt 0 ]] || die "Mapping has no entries: $MAPPING_FILE" +} + +warn_git_exec_bits() { + local i src mode git_mode + for ((i=0; i<${#MAP_SRC[@]}; i++)); do + mode="${MAP_MODE[$i]}" + [[ "$mode" == 755 || "$mode" == 0755 ]] || continue + src="${MAP_SRC[$i]#../../}" + git_mode="$(git -C "$APP_ROOT" ls-files --stage -- "$src" | awk '{print $1}')" + if [[ "$git_mode" != 100755 ]]; then log "WARN git mode for mapped executable is ${git_mode:-untracked}, expected 100755: $src"; fi + done +} + +check_router() { + step "Preflight: checking the router before $RUN_MODE" + local command="[ -f /etc/openwrt_release ] || { echo 'ERROR: target is not an OpenWrt router' >&2; exit 1; };" + if [[ "$RUN_MODE" == deploy ]]; then + command+=" for tool in ${ROUTER_TOOLS[*]}; do command -v \"\$tool\" >/dev/null 2>&1 || { echo \"ERROR: missing router command: \$tool\" >&2; exit 1; }; done;" + command+=" [ -d /www/luci-static/resources ] && [ -x /etc/init.d/rpcd ] || { echo 'ERROR: LuCI or rpcd is missing' >&2; exit 1; }" + fi + remote "$command" +} + +copy_files() { + local i src_abs dst policy state command='set -e;' + step "Copy: ${#MAP_SRC[@]} mapped files -> router" + for dst in "${MAP_DST[@]}"; do + command+=" mkdir -p $(remote_quote "${dst%/*}/");" + done + remote "$command" + for ((i=0; i<${#MAP_SRC[@]}; i++)); do + src_abs="$SCRIPT_DIR/${MAP_SRC[$i]}" + dst="${MAP_DST[$i]}" + policy="${MAP_POLICY[$i]}" + if [[ "$policy" == keep-if-exists ]]; then + state="$(remote "if [ -f $(remote_quote "$dst") ] && [ ! -L $(remote_quote "$dst") ]; then printf exists; elif [ -e $(remote_quote "$dst") ] || [ -L $(remote_quote "$dst") ]; then echo 'ERROR: protected path is not a regular file' >&2; exit 1; else printf missing; fi")" + case "$state" in + exists) log "KEEP_EXISTS ${MAP_SRC[$i]} -> $dst"; continue ;; + missing) log "COPY_MISSING ${MAP_SRC[$i]} -> $dst" ;; + *) die "Unexpected response when checking protected file: $dst" ;; + esac + else + log "COPY ${MAP_SRC[$i]} -> $dst" + fi + router put-files -- "$src_abs" "$dst" + done +} + +symbolic_mode() { + local value="$((8#$1))" symbols=rwxrwxrwx result='' bit index character + for ((bit=8; bit>=0; bit--)); do + index=$((8 - bit)) + if (( value & (1 << bit) )); then character="${symbols:$index:1}"; else character=-; fi + case "$bit" in + 6) if (( value & 04000 )); then if [[ "$character" == x ]]; then character=s; else character=S; fi; fi ;; + 3) if (( value & 02000 )); then if [[ "$character" == x ]]; then character=s; else character=S; fi; fi ;; + 0) if (( value & 01000 )); then if [[ "$character" == x ]]; then character=t; else character=T; fi; fi ;; + esac + result+="$character" + done + printf '%s' "$result" +} + +set_perms() { + step "Permissions: apply and verify mapped modes" + local i dst mode expected command='set -e;' + for ((i=0; i<${#MAP_DST[@]}; i++)); do + dst="${MAP_DST[$i]}" + mode="$(printf '%o' "$((8#${MAP_MODE[$i]}))")" + expected="$(symbolic_mode "${MAP_MODE[$i]}")" + command+=" chmod $mode $(remote_quote "$dst");" + command+=" [ \"\$(ls -ld $(remote_quote "$dst") | awk '{print substr(\$1,2,9)}')\" = '$expected' ] || { echo $(remote_quote "ERROR: permission verification failed: $dst") >&2; exit 1; };" + done + remote "$command" +} + +run_post_sync() { + step "Post: running post-deploy commands" + local cmd command='set -e;' + for cmd in "${POST_SYNC_COMMANDS[@]}"; do + log "POST $cmd" + command+=" $cmd;" + done + remote "$command" +} + +clean_target_files() { + step "Clean: remove mapped overwrite files" + local i dst command='set -e;' + for ((i=0; i<${#MAP_DST[@]}; i++)); do + dst="${MAP_DST[$i]}" + if [[ "${MAP_POLICY[$i]}" == keep-if-exists ]]; then log "KEEP_CLEAN_SKIP $dst"; continue; fi + log "REMOVE $dst" + command+=" rm -f -- $(remote_quote "$dst");" + done + remote "$command" +} + +main() { + trap cleanup EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + local mode_seen=0 + while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) usage; return 0 ;; + --interactive) INTERACTIVE=1 ;; + deploy|clean) + [[ "$mode_seen" == 0 ]] || die "Specify only one mode: deploy or clean" + RUN_MODE="$1"; mode_seen=1 + ;; + *) die "Unknown argument: $1. Use Router SSH for commands, scripts and transfers; use --help for deployment usage." ;; + esac + shift + done + [[ -f "$ROUTER_SSH" ]] || die "Router SSH tooling is missing: $ROUTER_SSH" + [[ "$INTERACTIVE" == 0 ]] || ROUTER_OPTIONS+=(--interactive) + load_mapping + if [[ "$RUN_MODE" == deploy ]]; then require_cmd git; warn_git_exec_bits; fi + check_router + MUTATION_STARTED=1 + if [[ "$RUN_MODE" == clean ]]; then + clean_target_files + log 'DONE: clean completed successfully' + return 0 + fi + copy_files + set_perms + run_post_sync + log 'DONE: deploy completed successfully' +} + +main "$@" diff --git a/luci-app-tailscale-ng/.dev/deploy-files/mapping.tsv b/luci-app-tailscale-ng/.dev/deploy-files/mapping.tsv new file mode 100644 index 000000000..916ac2f05 --- /dev/null +++ b/luci-app-tailscale-ng/.dev/deploy-files/mapping.tsv @@ -0,0 +1,16 @@ +# local_path_from_deploy_dirremote_absolute_pathmodepolicy +# +# Formatting: keep columns left-aligned — space-pad each to its widest value, then one tab (loader trims). Re-align all rows after editing. +# +# Policies: +# - overwrite: always copy local file over remote file +# - keep-if-exists: copy only when remote file does not exist + +../../htdocs/luci-static/resources/view/tailscale-ng/index.js /www/luci-static/resources/view/tailscale-ng/index.js 644 overwrite +../../root/usr/share/luci/menu.d/luci-app-tailscale-ng.json /usr/share/luci/menu.d/luci-app-tailscale-ng.json 644 overwrite +../../root/usr/share/rpcd/acl.d/luci-app-tailscale-ng.json /usr/share/rpcd/acl.d/luci-app-tailscale-ng.json 644 overwrite +../../root/usr/libexec/luci-app-tailscale-ng-state /usr/libexec/luci-app-tailscale-ng-state 755 overwrite +../../root/etc/init.d/luci-app-tailscale-ng /etc/init.d/luci-app-tailscale-ng 755 overwrite +../../root/etc/hotplug.d/iface/40-luci-app-tailscale-ng /etc/hotplug.d/iface/40-luci-app-tailscale-ng 755 overwrite +../../root/etc/uci-defaults/40_luci-app-tailscale-ng /etc/uci-defaults/40_luci-app-tailscale-ng 755 overwrite +../../root/etc/config/luci-app-tailscale-ng /etc/config/luci-app-tailscale-ng 644 keep-if-exists diff --git a/luci-app-tailscale-ng/.dev/router-ssh/README.md b/luci-app-tailscale-ng/.dev/router-ssh/README.md new file mode 100644 index 000000000..988722848 --- /dev/null +++ b/luci-app-tailscale-ng/.dev/router-ssh/README.md @@ -0,0 +1,68 @@ +# Router SSH Tooling + +The [router-ssh.sh](router-ssh.sh) script provides general-purpose SSH access for running commands and scripts, transferring files and working in an open remote shell. For application updates on a test router, use [Deploy Files](../deploy-files/README.md), which handles file deployment together with the application's required post-deploy steps. + +Run the commands below from the repository root in Bash (Git Bash on Windows). + +## Requirements + +- Bash and an OpenSSH client (`ssh`). File transfers also need `scp`; key generation needs `ssh-keygen`. +- A reachable router with SSH access. Application packages and mapping files are not required. + +## Setup + +Create a local router config: + +```bash +cp .dev/router-ssh/target.env.example .dev/router-ssh/target.env +``` + +Set `ROUTER_HOST`, `ROUTER_USER` and `ROUTER_PORT`. The template defaults to `root` on port `22`. + +SSH tries the configured key, project key and normal SSH identities or agent during the actual operation. No separate network probe is performed. If authentication is rejected, the script preserves an existing key or creates a project key and prints its public part with installation instructions. Add it under **LuCI → System → Administration → SSH-Keys**, then repeat the operation. + +Alternatively, add `--interactive` in your own terminal to install the public key using the router password and continue. Ordinary runs never prompt for passwords. Connection errors and failures after authentication do not cause automatic retries. + +## Usage + +### Individual Operations + +```bash +bash .dev/router-ssh/router-ssh.sh exec-command 'logread -l 20' +bash .dev/router-ssh/router-ssh.sh exec-command < diagnostic.sh +bash .dev/router-ssh/router-ssh.sh put-files ./helper /tmp/helper +bash .dev/router-ssh/router-ssh.sh get-files /tmp/debug.log ./debug.log +``` + +Remote file paths must be absolute. Transfers accept multiple sources when the destination is a directory; add `--recursive` to copy directories. Individual commands preserve remote stdout, stderr and the exit status. Local connection messages go to stderr. + +### Open Session + +For multiple commands, prefer `open-session`: it keeps one SSH connection open without reauthenticating for each command. + +```bash +bash .dev/router-ssh/router-ssh.sh open-session +``` + +In a terminal, this opens the router's normal interactive shell. Enter commands as needed and close it with `exit` or Ctrl+D. The working directory and variables persist within the session. Ctrl+C can interrupt a running remote command without closing the shell. + +With redirected streams, the same mode runs `sh -s` without a prompt or terminal formatting. An agent can keep stdin open, send commands incrementally and read their output without reconnecting. Exit or close stdin to end the session. The process returns the shell's final exit status; individual command statuses can be read through `$?`. + +File transfers remain independent `put-files` and `get-files` calls. They do not reuse the open shell. A disconnected session is not automatically reopened and its commands are not replayed. + +## Verification + +Run `exec-command 'uname -n'` to check the target. In an open session, verify the working directory with `pwd` and the last command's status with `echo $?`. A local setup error returns `1`, required user action returns `2`, and SSH may return `255` for a connection error or remote exit `255`. + +## Configuration + +- `SSH_KEY_PATH` — Optional key to include first; relative paths start at the repository root. On Windows, both `D:/...` and `D:\...` paths are accepted. An explicitly configured missing file is an error. +- `.ssh/id_ed25519` — Project key next to the script, used if present. + +The script sets `ServerAliveInterval=15` and `ServerAliveCountMax=3` by default to detect unresponsive SSH connections. + +Normal SSH configuration and agent access remain available. New host keys are accepted automatically; changed host keys stop the connection. Local `target.env` and `.ssh/` are ignored by the project's root `.gitignore`. + +## Cleanup + +Temporary key copies and SSH diagnostics are removed when the operation or session ends. Files transferred to the router remain until explicitly removed. diff --git a/luci-app-tailscale-ng/.dev/router-ssh/router-ssh.sh b/luci-app-tailscale-ng/.dev/router-ssh/router-ssh.sh new file mode 100644 index 000000000..10c92c7a7 --- /dev/null +++ b/luci-app-tailscale-ng/.dev/router-ssh/router-ssh.sh @@ -0,0 +1,424 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +APP_ROOT="$(cd -- "$SCRIPT_DIR/../.." && pwd)" +TARGET_ENV_FILE="$SCRIPT_DIR/target.env" +DEFAULT_PROJECT_KEY="$SCRIPT_DIR/.ssh/id_ed25519" +SSH_KEY_COMMENT="luci-app-tailscale-ssh" +EFFECTIVE_KEY_PATH="" +AUTH_ERROR_FILE="" +AUTH_METHOD="" +SELECTED_KEY="" +PUBLIC_KEY="" +INTERACTIVE=0 +OPERATION="" +EXEC_COMMAND="" +EXEC_STDIN=0 +RECURSIVE=0 +OP_ARGS=() +SESSION_DIR="" +CLIENT_LOG="" +IDENTITY_OPTS=() +IDENTITY_ORIGINALS=() +IDENTITY_INDEX=0 + +usage() { + cat <<'EOF' +Usage: + router-ssh.sh [--interactive] exec-command [--] 'command on the router' + router-ssh.sh [--interactive] exec-command < script.sh + router-ssh.sh [--interactive] put-files [-r|--recursive] [--] LOCAL... REMOTE + router-ssh.sh [--interactive] get-files [-r|--recursive] [--] REMOTE... LOCAL + router-ssh.sh [--interactive] open-session + +Read target.env next to this script. Remote file paths must be absolute. +Multiple sources require a destination directory. -r includes directories. +SSH tries the configured identities during the actual operation. +There is no preliminary network probe, and operations are never retried after authentication. +open-session keeps one remote shell until exit or EOF. It uses a terminal when +stdin and stdout are terminals; otherwise commands can be streamed over stdin. +Ordinary runs never prompt for passwords; --interactive requires your terminal. +Exit codes: 1 = local error, 2 = user action required. Commands and sessions +preserve the remote status (255 also indicates an SSH failure); transfers return SCP status. +EOF +} + +log() { printf '%s\n' "$*" >&2; } +step() { printf '[STEP] %s\n' "$*" >&2; } +die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } +require_cmd() { command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"; } +trim() { + local s="$1" + s="${s#"${s%%[![:space:]]*}"}" + s="${s%"${s##*[![:space:]]}"}" + printf '%s' "$s" +} +is_tty() { [[ -t 0 && -t 1 ]]; } +remote_quote() { printf "'%s'" "${1//\'/\'\\\'\'}"; } +load_target_env() { + [[ -f "$TARGET_ENV_FILE" ]] || die "Target config is missing: $TARGET_ENV_FILE" + # shellcheck source=/dev/null + source "$TARGET_ENV_FILE" + + ROUTER_HOST="$(trim "${ROUTER_HOST:-}")" + ROUTER_USER="$(trim "${ROUTER_USER:-root}")" + ROUTER_PORT="$(trim "${ROUTER_PORT:-22}")" + SSH_KEY_PATH="$(trim "${SSH_KEY_PATH:-}")" + + [[ -n "$ROUTER_HOST" ]] || die "ROUTER_HOST is required in $TARGET_ENV_FILE" + [[ -n "$ROUTER_USER" ]] || die "ROUTER_USER is required in $TARGET_ENV_FILE" + [[ "$ROUTER_HOST" =~ ^[a-zA-Z0-9_:][a-zA-Z0-9_.:%-]*$ ]] || die "Invalid ROUTER_HOST: $ROUTER_HOST" + [[ "$ROUTER_USER" =~ ^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$ ]] || die "Invalid ROUTER_USER: $ROUTER_USER" + [[ "$ROUTER_PORT" =~ ^[0-9]{1,5}$ ]] || die "Invalid ROUTER_PORT: $ROUTER_PORT" + (( 10#$ROUTER_PORT >= 1 && 10#$ROUTER_PORT <= 65535 )) || die "ROUTER_PORT must be between 1 and 65535" +} + +resolve_key_path() { + local_path "${SSH_KEY_PATH:-$DEFAULT_PROJECT_KEY}" "$APP_ROOT" +} + +prepare_runtime_key() { + local key_path="$1" target + IDENTITY_INDEX=$((IDENTITY_INDEX + 1)) + target="$SESSION_DIR/identity-$IDENTITY_INDEX" + cat "$key_path" > "$target" + chmod 600 "$target" + if [[ -f "${key_path}.pub" ]]; then printf '%s\n' "$(< "${key_path}.pub")" > "${target}.pub"; fi + if [[ -f "${key_path}-cert.pub" ]]; then printf '%s\n' "$(< "${key_path}-cert.pub")" > "${target}-cert.pub"; fi + EFFECTIVE_KEY_PATH="$target" + if command -v cygpath >/dev/null 2>&1; then EFFECTIVE_KEY_PATH="$(cygpath -w "$target")"; fi +} + +build_ssh_opts() { + SSH_OPTS=(-p "$ROUTER_PORT" "${CONNECTION_OPTS[@]}" -o BatchMode=yes -o PreferredAuthentications=publickey -o LogLevel=DEBUG1 "${IDENTITY_OPTS[@]}") + SCP_OPTS=(-O -P "$ROUTER_PORT" -p -S "$SESSION_DIR/ssh-client" "${CONNECTION_OPTS[@]}" -o BatchMode=yes -o PreferredAuthentications=publickey -o LogLevel=DEBUG1 "${IDENTITY_OPTS[@]}") +} + +ensure_public_key() { + local key_path="$1" public_file="${1}.pub" + require_cmd ssh-keygen + if [[ ! -f "$key_path" ]]; then + [[ "$key_path" == "$DEFAULT_PROJECT_KEY" ]] || die "Configured SSH key does not exist: $key_path" + # Key generation is the last resort, after every existing access method failed. + (umask 077; mkdir -p "$(dirname -- "$key_path")"; ssh-keygen -t ed25519 -N "" -f "$key_path" -C "$SSH_KEY_COMMENT" > /dev/null) + log "Generated project key: $key_path" + fi + prepare_runtime_key "$key_path" + if PUBLIC_KEY="$(ssh-keygen -y -P '' -f "$EFFECTIVE_KEY_PATH" 2> "$AUTH_ERROR_FILE")"; then + : + elif [[ "$INTERACTIVE" == 1 ]]; then + log "Unlock the existing SSH key in this terminal if prompted." + PUBLIC_KEY="$(ssh-keygen -y -f "$EFFECTIVE_KEY_PATH")" || die "Cannot read the existing SSH key: $key_path" + elif [[ -f "$public_file" ]] && ssh-keygen -lf "$public_file" > /dev/null 2>&1; then + PUBLIC_KEY="$(cat "$public_file")" + log "The existing key may need unlocking in ssh-agent before deployment can succeed." + else + cat "$AUTH_ERROR_FILE" >&2 + printf 'ACTION REQUIRED: cannot read the public part of the existing key. Unlock it in ssh-agent or use --interactive in your terminal. The requested operation has not started.\n' >&2 + exit 2 + fi + [[ "$(printf '%s\n' "$PUBLIC_KEY" | awk 'NF { n++ } END { print n+0 }')" == 1 ]] || die "Expected one public key: $public_file" + # Derive a missing .pub file instead of replacing an existing private key. + if [[ ! -f "$public_file" ]] || [[ "$(cat "$public_file")" != "$PUBLIC_KEY" ]]; then + (umask 077; printf '%s\n' "$PUBLIC_KEY" > "$public_file") + fi +} + +print_retry_command() { + printf ' bash %q' "$SCRIPT_DIR/router-ssh.sh" >&2 + [[ "${1:-}" != interactive ]] || printf ' --interactive' >&2 + printf ' %s' "$OPERATION" >&2 + [[ "$RECURSIVE" == 0 ]] || printf ' --recursive' >&2 + printf ' --' >&2 + if [[ "$EXEC_STDIN" == 1 ]]; then + printf ' < your-script.sh' >&2 + else + if [[ ${#OP_ARGS[@]} -gt 0 ]]; then printf ' %q' "${OP_ARGS[@]}" >&2; fi + fi + printf '\n' >&2 +} +print_access_help() { + local key_path="$1" + printf '\nACTION REQUIRED: SSH access to %s@%s:%s is not configured.\n' "$ROUTER_USER" "$ROUTER_HOST" "$ROUTER_PORT" >&2 + printf 'The requested operation has not started.\nPublic key file: %s.pub\n\n%s\n\n' "$key_path" "$PUBLIC_KEY" >&2 + printf 'Option 1: open LuCI -> System -> Administration -> SSH-Keys.\nPaste the public key shown above, click Add key, then repeat:\n' >&2 + print_retry_command + printf '\nOption 2: run in your own terminal and enter the router password when SSH asks:\n' >&2 + if [[ "$EXEC_STDIN" == 1 ]]; then + printf ' bash %q --interactive exec-command true\n' "$SCRIPT_DIR/router-ssh.sh" >&2 + printf 'Then repeat the original script invocation.\n' >&2 + else + print_retry_command interactive + fi +} + +install_key_interactively() { + local command + log "SSH access is not configured. Enter the router password in this terminal when SSH asks." + command="set -e; umask 077; mkdir -p /etc/dropbear; touch /etc/dropbear/authorized_keys; if grep -qxF $(remote_quote "$PUBLIC_KEY") /etc/dropbear/authorized_keys; then :; else result=\$?; [ \"\$result\" -eq 1 ] || exit \"\$result\"; printf '%s\\n' $(remote_quote "$PUBLIC_KEY") >> /etc/dropbear/authorized_keys; fi; chmod 700 /etc/dropbear; chmod 600 /etc/dropbear/authorized_keys" + # shellcheck disable=SC2029 + if ! LC_ALL=C ssh -p "$ROUTER_PORT" "${CONNECTION_OPTS[@]}" -o BatchMode=no -o PreferredAuthentications=keyboard-interactive,password -o PubkeyAuthentication=no "$ROUTER_USER@$ROUTER_HOST" "$command"; then + die "Interactive SSH key installation failed. The requested operation has not started." + fi + log "Public key installed. Continuing the requested operation without a separate probe." +} + +run_remote_command() { + local command="$EXEC_COMMAND" status=0 + [[ "$EXEC_STDIN" == 0 ]] || command='sh -s' + step "Exec: running the remote command ($AUTH_METHOD)" + # Preserve user stdin and remote output without executing a preliminary probe. + # shellcheck disable=SC2029 + LC_ALL=C ssh -E "$CLIENT_LOG" "${SSH_OPTS[@]}" "$ROUTER_USER@$ROUTER_HOST" "$command" || status=$? + return "$status" +} + +run_open_session() { + local -a options=() + local i status=0 + # The common non-terminal setting belongs to individual commands and SCP. + # An actual shell session chooses its terminal mode from the caller's streams. + for ((i=0; i<${#SSH_OPTS[@]}; i++)); do + if [[ "${SSH_OPTS[$i]}" == -o && "${SSH_OPTS[$((i+1))]:-}" == RequestTTY=no ]]; then + i=$((i+1)) + continue + fi + options+=("${SSH_OPTS[$i]}") + done + if is_tty; then + step "Session: opening a terminal on $ROUTER_USER@$ROUTER_HOST; use exit or Ctrl+D to close" + LC_ALL=C ssh -E "$CLIENT_LOG" "${options[@]}" -tt "$ROUTER_USER@$ROUTER_HOST" || status=$? + else + step "Session: opening a command stream on $ROUTER_USER@$ROUTER_HOST; use exit or EOF to close" + LC_ALL=C ssh -E "$CLIENT_LOG" "${options[@]}" -T "$ROUTER_USER@$ROUTER_HOST" 'sh -s' || status=$? + fi + return "$status" +} + +local_path() { + local value="$1" base="${2:-$PWD}" + if command -v cygpath >/dev/null 2>&1; then value="$(cygpath -u "$value")"; fi + [[ "$value" == /* ]] || value="$base/$value" + printf '%s' "$value" +} + +remote_source_path() { + # Legacy SCP checks returned names against this path as a shell pattern. + # Backslash escapes protect both the remote shell and that filename check. + local value="$1" character result="" i + for ((i=0; i<${#value}; i++)); do + character="${value:i:1}" + case "$character" in + [a-zA-Z0-9_./-]) result+="$character" ;; + *) result+="\\$character" ;; + esac + done + printf '%s' "$result" +} + +transfer_files() { + local host="$ROUTER_HOST" destination source status=0 + local -a files=() options=() + [[ "$host" != *:* ]] || host="[$host]" + options=("${SCP_OPTS[@]}") + [[ "$RECURSIVE" == 0 ]] || options+=(-r) + destination="${OP_ARGS[${#OP_ARGS[@]}-1]}" + if [[ "$OPERATION" == put-files ]]; then + for source in "${OP_ARGS[@]:0:${#OP_ARGS[@]}-1}"; do files+=("$(local_path "$source")"); done + destination="$ROUTER_USER@$host:$(remote_quote "$destination")" + else + for source in "${OP_ARGS[@]:0:${#OP_ARGS[@]}-1}"; do files+=("$ROUTER_USER@$host:$(remote_source_path "$source")"); done + destination="$(local_path "$destination")" + fi + step "$OPERATION: transferring ${#files[@]} source(s) ($AUTH_METHOD)" + LC_ALL=C scp "${options[@]}" -- "${files[@]}" "$destination" || status=$? + return "$status" +} + +parse_args() { + if [[ "${1:-}" == -h || "${1:-}" == --help ]]; then usage; exit 0; fi + if [[ "${1:-}" == --interactive ]]; then INTERACTIVE=1; shift; fi + OPERATION="${1:-}" + [[ $# -gt 0 ]] || { usage >&2; exit 1; } + shift + case "$OPERATION" in exec-command|put-files|get-files|open-session) ;; *) die "Unknown command: $OPERATION. Use --help." ;; esac + while [[ $# -gt 0 ]]; do + case "$1" in + --interactive) INTERACTIVE=1; shift ;; + -r|--recursive) + [[ "$OPERATION" == put-files || "$OPERATION" == get-files ]] || die "--recursive only applies to file transfers" + RECURSIVE=1; shift + ;; + --) shift; break ;; + *) break ;; + esac + done + OP_ARGS=("$@") + if [[ "$OPERATION" == open-session ]]; then + [[ $# == 0 ]] || die "open-session accepts no command arguments; send commands through stdin" + elif [[ "$OPERATION" == exec-command ]]; then + [[ $# -le 1 ]] || die "exec-command accepts one quoted command, or a script on stdin." + if [[ $# == 0 ]]; then + EXEC_STDIN=1 + [[ ! -t 0 ]] || die "Provide a command, or redirect a script on stdin." + else + EXEC_COMMAND="$1" + [[ -n "$EXEC_COMMAND" ]] || die "The remote command must not be empty" + fi + else + [[ $# -ge 2 ]] || die "$OPERATION requires source(s) and a destination" + require_cmd scp + local source destination="${OP_ARGS[${#OP_ARGS[@]}-1]}" + for source in "${OP_ARGS[@]}"; do + [[ "$source" != *$'\n'* && "$source" != *$'\r'* ]] || die "Legacy SCP does not support filenames containing newlines" + done + if [[ "$OPERATION" == put-files ]]; then + [[ "$destination" == /* ]] || die "Remote destination must be an absolute path" + for source in "${OP_ARGS[@]:0:${#OP_ARGS[@]}-1}"; do + source="$(local_path "$source")" + if [[ -d "$source" ]]; then + [[ "$RECURSIVE" == 1 ]] || die "Directory upload requires --recursive: $source" + else + [[ -f "$source" ]] || die "Local source does not exist: $source" + fi + done + else + for source in "${OP_ARGS[@]:0:${#OP_ARGS[@]}-1}"; do [[ "$source" == /* ]] || die "Remote source must be an absolute path: $source"; done + fi + fi +} + +main() { + trap cleanup EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + parse_args "$@" + if [[ "$INTERACTIVE" == 1 ]] && ! is_tty; then + printf 'ACTION REQUIRED: --interactive needs your own terminal. The requested operation has not started.\n' >&2 + exit 2 + fi + require_cmd ssh + load_target_env + init_direct_state + prepare_identities + local status=0 + run_operation || status=$? + [[ "$status" != 0 ]] || return 0 + if ! authentication_rejected; then report_failure "$status"; return "$status"; fi + + # Only a client-side authentication rejection proves the operation never ran. + ensure_public_key "$SELECTED_KEY" + if [[ "$INTERACTIVE" == 0 ]]; then print_access_help "$SELECTED_KEY"; exit 2; fi + install_key_interactively + prepare_identities + status=0 + run_operation || status=$? + [[ "$status" != 0 ]] || return 0 + if authentication_rejected; then print_access_help "$SELECTED_KEY"; exit 2; fi + report_failure "$status" + return "$status" +} + +init_direct_state() { + mkdir -p "$HOME/.ssh" + SESSION_DIR="$(mktemp -d "$HOME/.ssh/router-ssh.XXXXXX")" + CLIENT_LOG="$SESSION_DIR/client.log" + AUTH_ERROR_FILE="$SESSION_DIR/key-error.log" + export ROUTER_SSH_CLIENT_LOG="$CLIENT_LOG" + if [[ "$OPERATION" == put-files || "$OPERATION" == get-files ]]; then + ROUTER_SSH_REAL_CLIENT="$(command -v ssh)" + export ROUTER_SSH_REAL_CLIENT + # shellcheck disable=SC2016 # These variables belong to the generated wrapper. + printf '%s\n' '#!/usr/bin/env bash' 'set -euo pipefail' 'exec "$ROUTER_SSH_REAL_CLIENT" -E "$ROUTER_SSH_CLIENT_LOG" "$@"' > "$SESSION_DIR/ssh-client" + chmod 700 "$SESSION_DIR/ssh-client" + fi +} + +cleanup() { + if [[ -n "$SESSION_DIR" ]]; then + rm -f -- "$SESSION_DIR/"* + rmdir -- "$SESSION_DIR" + fi + return 0 +} + +add_identity() { + local candidate="$1" copy_key="${2:-0}" previous + [[ "$candidate" != \~/* ]] || candidate="$HOME/${candidate:2}" + if [[ "$candidate" == [a-zA-Z]:* ]] && command -v cygpath >/dev/null 2>&1; then candidate="$(cygpath -u "$candidate")"; fi + [[ -f "$candidate" ]] || return 0 + for previous in "${IDENTITY_ORIGINALS[@]}"; do [[ "$previous" != "$candidate" ]] || return 0; done + IDENTITY_ORIGINALS+=("$candidate") + EFFECTIVE_KEY_PATH="$candidate" + if [[ "$copy_key" == 1 ]]; then prepare_runtime_key "$candidate"; fi + IDENTITY_OPTS+=(-i "$EFFECTIVE_KEY_PATH") +} + +prepare_identities() { + local explicit="" line + IDENTITY_OPTS=() + IDENTITY_ORIGINALS=() + CONNECTION_OPTS=( + -o StrictHostKeyChecking=accept-new + -o ConnectTimeout=8 + -o ConnectionAttempts=1 + # Send SSH keepalives after 15s of silence; allow 3 unanswered requests. + -o ServerAliveInterval=15 + -o ServerAliveCountMax=3 + -o ControlMaster=no + -o ControlPath=none + -o ClearAllForwardings=yes + -o RequestTTY=no + ) + if [[ -n "$SSH_KEY_PATH" ]]; then + explicit="$(resolve_key_path)" + [[ -f "$explicit" ]] || die "SSH_KEY_PATH does not point to a file: $explicit. Correct the config; no key was generated." + add_identity "$explicit" 1 + fi + add_identity "$DEFAULT_PROJECT_KEY" 1 + # -G evaluates local SSH configuration without making a network connection. + # Preserve default identity files which adding an explicit -i would replace. + if ! LC_ALL=C ssh -G -p "$ROUTER_PORT" "${CONNECTION_OPTS[@]}" "$ROUTER_USER@$ROUTER_HOST" > "$SESSION_DIR/effective-config" 2> "$AUTH_ERROR_FILE"; then + cat "$AUTH_ERROR_FILE" >&2 + die "Cannot evaluate local SSH configuration" + fi + while IFS= read -r line; do + case "$line" in identityfile\ *) add_identity "${line#identityfile }" ;; esac + done < "$SESSION_DIR/effective-config" + SELECTED_KEY="${explicit:-$DEFAULT_PROJECT_KEY}" + AUTH_METHOD="SSH identities and agent" + build_ssh_opts +} + +run_operation() { + : > "$CLIENT_LOG" + case "$OPERATION" in + exec-command) run_remote_command ;; + open-session) run_open_session ;; + put-files|get-files) transfer_files ;; + esac +} + +authentication_completed() { + grep -Eq 'Authenticated to |Authentication succeeded \(' "$CLIENT_LOG" +} + +authentication_rejected() { + ! authentication_completed && grep -Eq 'Permission denied|Authentication failed|No more authentication methods' "$CLIENT_LOG" +} + +report_failure() { + local status="$1" + if ! authentication_completed || [[ "$status" == 255 ]]; then + grep -Ev '^(debug[123]: |Authenticated to )' "$CLIENT_LOG" >&2 || true + fi + if [[ "$status" == 255 ]]; then + log "ERROR: SSH returned 255. The operation may have started; it was not retried." + elif [[ "$OPERATION" == put-files || "$OPERATION" == get-files ]]; then + log "ERROR: transfer failed; partial destination files may exist. It was not retried." + fi + return 0 +} + +main "$@" diff --git a/luci-app-tailscale-ng/.dev/router-ssh/target.env.example b/luci-app-tailscale-ng/.dev/router-ssh/target.env.example new file mode 100644 index 000000000..6a99ceff9 --- /dev/null +++ b/luci-app-tailscale-ng/.dev/router-ssh/target.env.example @@ -0,0 +1,7 @@ +ROUTER_HOST=192.168.1.1 +ROUTER_USER=root +ROUTER_PORT=22 +# Optional key to try first, absolute or relative to the repository root. +# Then try the existing project key and normal SSH configuration/agent. +# SSH_KEY_PATH=.dev/router-ssh/.ssh/id_ed25519 +# Windows example: SSH_KEY_PATH='D:/SSH Keys/router' diff --git a/luci-app-tailscale-ng/.gitignore b/luci-app-tailscale-ng/.gitignore index 919576b5c..510223ea5 100644 --- a/luci-app-tailscale-ng/.gitignore +++ b/luci-app-tailscale-ng/.gitignore @@ -1,4 +1,10 @@ -.deploy/target.env -.deploy/.ssh/ -.private -/artifacts/ +# Development tooling +/.dev/build-packages/artifacts/ +/.dev/capture-screenshots/node_modules/ +/.dev/capture-screenshots/report.json +/.dev/capture-screenshots/*-debug.log* +/.dev/**/target.env +/.dev/**/.ssh/ + +# Local files +/.private diff --git a/luci-app-tailscale-ng/Makefile b/luci-app-tailscale-ng/Makefile index 0670ea5f0..69512538b 100644 --- a/luci-app-tailscale-ng/Makefile +++ b/luci-app-tailscale-ng/Makefile @@ -6,14 +6,19 @@ include $(TOPDIR)/rules.mk LUCI_TITLE:=LuCI for Tailscale (NG) +LUCI_DESCRIPTION:=LuCI web interface for managing Tailscale on OpenWrt routers LUCI_DEPENDS:=+tailscale LUCI_PKGARCH:=all +LUCI_MAINTAINER:=vad-b -PKG_VERSION:=0.1.2 - -# match the i18n pkg version to the main pkg version -PKG_RELEASE:=4 +PKG_VERSION:=0.1.3 +PKG_RELEASE:=5 PKG_PO_VERSION:=$(PKG_VERSION)-r$(PKG_RELEASE) +PKG_LICENSE:=GPL-3.0-only + +define Build/Prepare/luci-app-tailscale-ng + $(SED) 's/@APP_VERSION@/$(PKG_VERSION)/g' $(PKG_BUILD_DIR)/htdocs/luci-static/resources/view/tailscale-ng/index.js +endef include $(TOPDIR)/feeds/luci/luci.mk diff --git a/luci-app-tailscale-ng/README.md b/luci-app-tailscale-ng/README.md index 45f1a9753..8cac8f768 100644 --- a/luci-app-tailscale-ng/README.md +++ b/luci-app-tailscale-ng/README.md @@ -67,6 +67,7 @@ This means you can install and remove the package at any time without breaking y - Configure listen port, state file path, firewall mode - Login status with one-click authentication link - Configure control server URL and pre-authentication keys +- Download and restore the Tailscale authentication state file - Accept DNS / Accept subnet routes - Advertise as exit node or select a remote exit node (with auto-detection of exit nodes) - Advertise local subnets (with auto-detection of LAN/WAN networks) @@ -80,8 +81,7 @@ This means you can install and remove the package at any time without breaking y - Auto-create network interfaces and firewall zones/rules for Tailscale - Import existing Tailscale settings on first install (read current running parameters and populate the config) -- Backup and restore of the Tailscale state file -- Update Tailscale directly from the web UI (both standard and size-optimized builds for memory-constrained devices) +- Update Tailscale service directly from the web UI (both standard and size-optimized builds for memory-constrained devices) ## Manual Installation @@ -98,12 +98,17 @@ opkg install /tmp/luci-app-tailscale-ng_*.ipk Download the latest `.apk` package from [Releases](https://github.com/vad-b/luci-app-tailscale-ng/releases), upload it to the router's `/tmp` directory, then: ```shell -apk add --allow-untrusted --upgrade /tmp/luci-app-tailscale-ng_*.apk +apk add --allow-untrusted --upgrade /tmp/luci-app-tailscale-ng-*.apk ``` Simplified Chinese translation is also available as a separate `luci-i18n-tailscale-ng-zh-cn` package in both `.apk` and `.ipk` formats in [Releases](https://github.com/vad-b/luci-app-tailscale-ng/releases). -For local development builds and testing, see [Local package builds](docs/local-build.md). +## Development Tooling + +- [Router SSH Tooling](.dev/router-ssh/README.md) — Configure router access, run commands, transfer files, and work in an open SSH session. +- [Deploy Files](.dev/deploy-files/README.md) — Push working-tree files to the router for testing without building a package. +- [Build Packages](.dev/build-packages/README.md) — Build `.apk` and `.ipk` packages locally using the same script as CI. +- [Capture Screenshots](.dev/capture-screenshots/README.md) — Capture LuCI pages with a consistent width and synthetic data. ## Credits diff --git a/luci-app-tailscale-ng/docs/images/tailscale-ng-auth.png b/luci-app-tailscale-ng/docs/images/tailscale-ng-auth.png index cbc95bf70..83e280096 100644 Binary files a/luci-app-tailscale-ng/docs/images/tailscale-ng-auth.png and b/luci-app-tailscale-ng/docs/images/tailscale-ng-auth.png differ diff --git a/luci-app-tailscale-ng/docs/images/tailscale-ng-settings.png b/luci-app-tailscale-ng/docs/images/tailscale-ng-settings.png index db249b982..cb6b9a6d1 100644 Binary files a/luci-app-tailscale-ng/docs/images/tailscale-ng-settings.png and b/luci-app-tailscale-ng/docs/images/tailscale-ng-settings.png differ diff --git a/luci-app-tailscale-ng/docs/images/tailscale-ng-status.png b/luci-app-tailscale-ng/docs/images/tailscale-ng-status.png index 72d31d25d..0fff3217b 100644 Binary files a/luci-app-tailscale-ng/docs/images/tailscale-ng-status.png and b/luci-app-tailscale-ng/docs/images/tailscale-ng-status.png differ diff --git a/luci-app-tailscale-ng/docs/local-build.md b/luci-app-tailscale-ng/docs/local-build.md deleted file mode 100644 index cbe15db66..000000000 --- a/luci-app-tailscale-ng/docs/local-build.md +++ /dev/null @@ -1,65 +0,0 @@ -# Local package builds - -Build locally with the same script used by GitHub Actions. No push or release -is needed. - -## Prepare - -Use Ubuntu 24.04 x86_64, including under WSL2, as a regular user. Run the -commands below in the Linux shell. Install dependencies once: - -```sh -sudo apt-get update -sudo apt-get install -y git build-essential flex bison gawk gcc-multilib g++-multilib gettext libncurses-dev libssl-dev python3-setuptools rsync unzip zlib1g-dev file wget curl zstd -``` - -## Build - -Commit the changes to test locally: the script builds the current commit and -requires a clean working tree, including untracked files. From the repository -root, build APK packages for OpenWrt 25.12: - -```sh -JOBS=2 bash scripts/build-packages.sh apk -``` - -For IPK packages targeting OpenWrt 24.10, use: - -```sh -JOBS=2 bash scripts/build-packages.sh ipk -``` - -Outputs go to `artifacts/apk/` or `artifacts/ipk/`: the application package, -Chinese translation, `SHA256SUMS` and `build-info.txt` with the source commit -and SDK details. SDK versions and checksums are fixed in the build script. - -The build host must be x86_64; the two output packages are architecture -independent. Obtain native dependencies such as Tailscale from the router's -own feeds. - -## Check and test - -For an APK build, verify the outputs and source commit: - -```sh -cd artifacts/apk -sha256sum --check SHA256SUMS -cat build-info.txt -cd ../.. -``` - -Back up the router's Tailscale configuration and authorization state before -testing. Transfer both packages and install them using the -[manual installation instructions](../README.md#manual-installation). -Local APK packages are unsigned and require `--allow-untrusted`. - -Check installed versions, preserved settings and login, service operation, -and the translated LuCI page. If Software hides the translation package, -select **Display LuCI translation packages → all**. - -## Cache - -The first build is slower; later builds reuse `~/.cache/luci-app-tailscale-ng`. -Keep this cache on a Linux filesystem at a path without spaces. Delete that -directory for a fresh SDK, or `artifacts/` to discard generated packages. -Optional overrides are `BUILD_CACHE_DIR`, `OUTPUT_DIR` and `JOBS`. diff --git a/luci-app-tailscale-ng/htdocs/luci-static/resources/view/tailscale-ng/index.js b/luci-app-tailscale-ng/htdocs/luci-static/resources/view/tailscale-ng/index.js index 595bc410b..0b954ec97 100644 --- a/luci-app-tailscale-ng/htdocs/luci-static/resources/view/tailscale-ng/index.js +++ b/luci-app-tailscale-ng/htdocs/luci-static/resources/view/tailscale-ng/index.js @@ -30,12 +30,62 @@ const callInitAction = rpc.declare({ expect: { result: false } }); +const callSystemBoard = rpc.declare({ + object: 'system', + method: 'board', + expect: { '': {} } +}); + // Helpers // Cached service list result to avoid duplicate calls let cachedServiceList = null; let latestTsInfo = null; +const APP_VERSION = '@APP_VERSION@'; +const STATE_HELPER = '/usr/libexec/luci-app-tailscale-ng-state'; +const STATE_DOWNLOAD_FILE = '/tmp/tailscale-auth-state.download'; +const STATE_UPLOAD_FILE = '/tmp/tailscale-auth-state.upload'; + +function isNewerAppVersion(candidate, current) { + const pattern = /^v?(\d+)\.(\d+)\.(\d+)$/; + const candidateParts = pattern.exec(candidate); + const currentParts = pattern.exec(current); + if (!candidateParts || !currentParts) + return false; + + for (let i = 1; i <= 3; i++) { + const next = Number(candidateParts[i]); + const previous = Number(currentParts[i]); + if (next !== previous) + return next > previous; + } + return false; +} + +async function getAvailableAppUpdate() { + const controller = new AbortController(); + const timeout = setTimeout(function() { controller.abort(); }, 10000); + try { + const response = await fetch('https://api.github.com/repos/vad-b/luci-app-tailscale-ng/releases/latest', { + 'credentials': 'omit', + 'referrerPolicy': 'no-referrer', + 'signal': controller.signal + }); + if (!response.ok) + throw new Error('GitHub HTTP ' + response.status); + const release = await response.json(); + if (release.draft || release.prerelease || !isNewerAppVersion(release.tag_name, APP_VERSION)) + return null; + return { + version: release.tag_name.replace(/^v/, ''), + url: 'https://github.com/vad-b/luci-app-tailscale-ng/releases/tag/' + encodeURIComponent(release.tag_name) + }; + } finally { + clearTimeout(timeout); + } +} + async function getServiceStatus() { try { cachedServiceList = await callServiceList('tailscale'); @@ -71,12 +121,13 @@ async function getInterfaceSubnets() { } } -async function getTailscaleInfo(isRunning) { +async function getTailscaleInfo(isRunning, systemHostname) { const info = { isRunning: isRunning, backendState: undefined, authURL: undefined, displayName: undefined, + nodeName: systemHostname, onlineExitNodes: [], subnetRoutes: [] }; @@ -92,6 +143,13 @@ async function getTailscaleInfo(isRunning) { info.backendState = status.BackendState; info.authURL = status.AuthURL; + if (status.Self) { + const tailscaleNodeName = status.Self.HostName || + (status.Self.DNSName ? status.Self.DNSName.replace(/\.$/, '').split('.')[0] : undefined); + + if (tailscaleNodeName) + info.nodeName = tailscaleNodeName; + } if (info.backendState === 'Running' && status.Self && status.User) { info.displayName = status.User[status.Self.UserID]?.DisplayName; @@ -154,12 +212,175 @@ async function getStatusData() { return data; } +function stateOperationError(res, fallback) { + if (res && res.code === 0) + return null; + + const helperMessages = { + 10: _('The configured Tailscale state file path must be absolute.'), + 11: _('The Tailscale state file was not found.'), + 12: _('The Tailscale state file is empty.'), + 13: _('The Tailscale state file exceeds the 8 MiB size limit.'), + 14: _('The Tailscale state file must contain a valid JSON object.'), + 15: _('The Tailscale state file does not contain a machine key.') + }; + + if (res && typeof(res.code) === 'number' && Object.prototype.hasOwnProperty.call(helperMessages, res.code)) + return new Error(helperMessages[res.code]); + + const error = new Error(fallback); + const detail = res && (res.stderr || res.stdout); + error.details = typeof(detail) === 'string' ? detail.trim() : ''; + return error; +} + +function sanitizeFilenamePart(value, fallback) { + const sanitized = String(value || '') + .replace(/[^A-Za-z0-9._-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 64); + + return sanitized || fallback; +} + +function buildStateDownloadName(info) { + const now = new Date(); + const pad = function(value) { return String(value).padStart(2, '0'); }; + const stamp = String(now.getUTCFullYear()) + + pad(now.getUTCMonth() + 1) + + pad(now.getUTCDate()) + '-' + + pad(now.getUTCHours()) + + pad(now.getUTCMinutes()) + + pad(now.getUTCSeconds()); + const nodeName = sanitizeFilenamePart(info?.nodeName, 'openwrt'); + + return 'tailscale-auth-state-' + nodeName + '-' + stamp + '.state'; +} + +async function handleDownloadAuthenticationState(info) { + ui.showModal(_('Preparing Tailscale state file...'), [ + E('p', { 'class': 'spinning' }, _('The download will start when the Tailscale state file is ready.')) + ]); + + try { + const prepareRes = await fs.exec(STATE_HELPER, ['prepare-download']); + const prepareError = stateOperationError(prepareRes, _('Unable to prepare the Tailscale state file.')); + if (prepareError) + throw prepareError; + + const blob = await fs.read_direct(STATE_DOWNLOAD_FILE, 'blob'); + const url = URL.createObjectURL(blob); + const link = E('a', { + 'href': url, + 'download': buildStateDownloadName(info), + 'style': 'display:none' + }); + + document.body.appendChild(link); + link.click(); + link.remove(); + setTimeout(function() { URL.revokeObjectURL(url); }, 1000); + ui.hideModal(); + } catch (e) { + ui.hideModal(); + ui.addNotification(null, [ + E('p', {}, [_('Download failed: %s').format(e.message)]), + e.details ? E('pre', {}, [e.details]) : '' + ], 'error'); + } finally { + await L.resolveDefault(fs.remove(STATE_DOWNLOAD_FILE)); + } +} + +async function handleUploadAuthenticationState() { + try { + const upload = ui.uploadFile(STATE_UPLOAD_FILE); + const uploadPrompt = document.querySelector('.modal p'); + if (uploadPrompt) { + uploadPrompt.insertAdjacentElement('afterend', E('p', {}, [ + _('Select a Tailscale authentication state file. This device\'s current Tailscale authentication state will only be replaced after confirmation.') + ])); + } + await upload; + + const validateRes = await fs.exec(STATE_HELPER, ['validate-upload']); + const validateError = stateOperationError(validateRes, _('The uploaded file is not a valid Tailscale authentication state file.')); + if (validateError) + throw validateError; + + if (!confirm(_('Replace this device\'s current Tailscale authentication state with this file? The Tailscale settings saved on this router will be applied. If "Tailscale Enabled" is off, the service will remain stopped. Do not use the same Tailscale state file on two devices at once.'))) + return; + + ui.showModal(_('Restoring Tailscale state...'), [ + E('p', { 'class': 'spinning' }, _('Replacing this device\'s Tailscale authentication state and applying the Tailscale settings saved on this router.')) + ]); + + const restoreRes = await fs.exec(STATE_HELPER, ['restore-upload']); + const restoreError = stateOperationError(restoreRes, _('Unable to restore the Tailscale state file.')); + if (restoreError) + throw restoreError; + + location.reload(); + } catch (e) { + ui.hideModal(); + if (e.message !== _('Upload has been cancelled')) { + ui.addNotification(null, [ + E('p', {}, [_('Restore failed: %s').format(e.message)]), + e.details ? E('pre', {}, [e.details]) : '' + ], 'error'); + } + } finally { + await L.resolveDefault(fs.remove(STATE_UPLOAD_FILE)); + } +} + async function handleServiceAction(action, message) { ui.showModal(null, [E('p', { 'class': 'spinning' }, message)]); await callInitAction('luci-app-tailscale-ng', action); location.reload(); } +function handleStartService(enabledOption, sectionId) { + if (enabledOption.cfgvalue(sectionId) === '1') + return handleServiceAction('start', _('Starting Tailscale service...')); + + const enabledWidget = enabledOption.getUIElement(sectionId); + const enabledCheckbox = enabledWidget?.node?.querySelector('input[type="checkbox"]'); + const canEnable = !!(enabledCheckbox && !enabledCheckbox.disabled); + + const buttons = [ + E('button', { + 'type': 'button', + 'class': 'btn', + 'click': ui.hideModal + }, _('Cancel')) + ]; + + if (canEnable) { + buttons.push(E('button', { + 'type': 'button', + 'class': 'btn cbi-button-positive', + 'click': function() { + ui.hideModal(); + if (!enabledCheckbox.checked) + enabledCheckbox.click(); + enabledCheckbox.focus(); + } + }, _('Enable Tailscale'))); + } + + ui.showModal(_('Tailscale is disabled'), [ + E('p', {}, [ + _('The Tailscale service is disabled in the saved configuration.'), + E('br'), + _('Turn on "Tailscale Enabled", then click "Save & Apply" to start the service.') + ]), + E('div', { 'class': 'right' }, buttons) + ]); + + return Promise.resolve(); +} + function handleLogoutAndClean() { if (!confirm(_('Are you sure you want to log out and clean the local state?'))) return Promise.resolve(); @@ -256,6 +477,7 @@ const icons = { function renderIconButton(icon, text, className, onClick) { return E('button', { + 'type': 'button', 'class': 'btn ' + className, 'click': onClick, 'style': 'display: inline-flex; align-items: center;' @@ -293,8 +515,9 @@ const ServiceStatusValue = form.DummyValue.extend({ __name__: 'ServiceStatusValue', isRunning: false, tsInfo: null, + enabledOption: null, - renderWidget: function() { + renderWidget: function(sectionId) { const isRunning = this.isRunning; const info = this.tsInfo || null; @@ -325,7 +548,7 @@ const ServiceStatusValue = form.DummyValue.extend({ buttonsDiv.appendChild(renderIconButton( icons.start(), _('Start'), 'cbi-button-positive', ui.createHandlerFn(this, function() { - return handleServiceAction('start', _('Starting Tailscale service...')); + return handleStartService(this.enabledOption, sectionId); }) )); } @@ -462,16 +685,84 @@ const DynamicLogoutButton = form.Button.extend({ } }); +function showAppUpdate(update) { + const installCommand = 'wget -qO- https://raw.githubusercontent.com/vad-b/luci-app-tailscale-ng/main/install.sh | sh'; + const commandBlock = E('pre', { + 'style': 'white-space:pre-wrap;overflow-wrap:anywhere;box-sizing:border-box;margin:0;padding:12px 48px 12px 12px' + }, [installCommand]); + const copyIcon = createIcon([ + svgEl('rect', { 'x': '9', 'y': '9', 'width': '13', 'height': '13', 'rx': '2' }), + svgEl('path', { 'd': 'M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1' }) + ]); + copyIcon.setAttribute('aria-hidden', 'true'); + const copyButton = E('button', { + 'type': 'button', + 'class': 'btn', + 'title': _('Copy command'), + 'aria-label': _('Copy command'), + 'style': 'position:absolute;top:6px;right:6px;width:28px;height:28px;padding:4px;box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center', + 'click': async function(ev) { + const button = ev.currentTarget; + let copied = false; + try { + await navigator.clipboard.writeText(installCommand); + copied = true; + } catch (e) {} + if (!copied) { + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(commandBlock); + selection.removeAllRanges(); + selection.addRange(range); + try { + copied = document.execCommand('copy'); + } catch (e) {} + } + button.textContent = copied ? '✓' : '!'; + button.setAttribute('title', copied ? _('Copied') : _('Copy manually')); + button.setAttribute('aria-label', copied ? _('Copied') : _('Copy manually')); + } + }, [copyIcon]); + const modal = ui.showModal(_('Update available'), [ + E('p', {}, [_('Current version'), ': ', E('strong', {}, ['v' + APP_VERSION])]), + E('p', {}, [ + _('New version'), ': ', E('strong', {}, ['v' + update.version]), ' · ', + E('a', { + 'href': update.url, + 'target': '_blank', + 'rel': 'noopener' + }, [_('Release notes'), ' ↗']) + ]), + E('p', {}, [_('To update, run this command as root in an SSH session on your router:')]), + E('div', { 'style': 'position:relative;margin-bottom:1em' }, [commandBlock, copyButton]), + E('div', { 'class': 'right' }, [ + E('button', { + 'type': 'button', + 'class': 'btn', + 'click': ui.hideModal + }, _('Close')) + ]) + ]); + modal.style.maxWidth = '560px'; +} + // Main view return view.extend({ load: function() { - return getServiceStatus().then(function(isRunning) { + return Promise.all([ + getServiceStatus(), + L.resolveDefault(callSystemBoard(), {}) + ]).then(function(results) { + const isRunning = results[0]; + const systemHostname = results[1]?.hostname; + return Promise.all([ Promise.resolve(isRunning), - getTailscaleInfo(isRunning), + getTailscaleInfo(isRunning, systemHostname), getInterfaceSubnets(), - fs.exec('/usr/sbin/tailscale', ['version']) + fs.exec('/usr/sbin/tailscale', ['version']), + Promise.resolve(systemHostname) ]); }); }, @@ -482,6 +773,7 @@ return view.extend({ latestTsInfo = tsInfo; const interfaceSubnets = data[2]; const versionRes = data[3]; + const systemHostname = data[4]; const tsVersion = (versionRes && versionRes.code === 0 && versionRes.stdout) ? versionRes.stdout.trim().split('\n')[0] : null; @@ -500,14 +792,15 @@ return view.extend({ s.anonymous = true; s.addremove = false; - o = s.option(form.Flag, 'enabled', _('Tailscale Enabled')); - o.description = _('Start Tailscale service on boot.'); - o.default = '0'; - o.rmempty = false; + const enabledOption = s.option(form.Flag, 'enabled', _('Tailscale Enabled')); + enabledOption.description = _('Start Tailscale service on boot.'); + enabledOption.default = '0'; + enabledOption.rmempty = false; o = s.option(ServiceStatusValue, '_service_status', _('Tailscale Status')); o.isRunning = isRunning; o.tsInfo = tsInfo; + o.enabledOption = enabledOption; o = s.option(form.DummyValue, '_tailscale_version', _('Tailscale Version')); o.renderWidget = function() { @@ -555,6 +848,26 @@ return view.extend({ return handleLogoutAndClean(); }; + // Authentication state backup and restore + o = s.taboption('auth', SectionTitle, '_auth_state_title'); + o.title = _('Authentication State'); + + o = s.taboption('auth', form.Button, '_download_state', _('Download State')); + o.inputtitle = _('Download'); + o.inputstyle = 'action'; + o.description = _('Download this device\'s Tailscale authentication state file. It contains credentials, so keep it secure.'); + o.onclick = function() { + return handleDownloadAuthenticationState(latestTsInfo || tsInfo); + }; + + o = s.taboption('auth', form.Button, '_restore_state', _('Restore State')); + o.inputtitle = _('Upload & Restore'); + o.inputstyle = 'negative'; + o.description = _('Replace this device\'s Tailscale authentication state with an uploaded file. The Tailscale settings saved on this router will then be applied. If "Tailscale Enabled" is off, the service will remain stopped. Do not use the same Tailscale state file on two devices at once.'); + o.onclick = function() { + return handleUploadAuthenticationState(); + }; + // Settings tab: General o = s.taboption('settings', SectionTitle, '_general_title'); o.title = _('General'); @@ -714,7 +1027,7 @@ return view.extend({ // Poll service status and login status poll.add(async function() { const running = await getServiceStatus(); - const info = await getTailscaleInfo(running); + const info = await getTailscaleInfo(running, systemHostname); latestTsInfo = info; // Update service status display @@ -743,6 +1056,43 @@ return view.extend({ }, 15); return m.render().then(function(mapNode) { + const heading = mapNode.querySelector('h2'); + if (heading) { + const githubIcon = svgEl('svg', { + 'width': '14', + 'height': '14', + 'viewBox': '0 0 16 16', + 'fill': 'currentColor', + 'aria-hidden': 'true', + 'style': 'vertical-align:-0.125em;margin-right:0.3em' + }, [svgEl('path', { + 'd': 'M6.766 11.328c-2.063-.25-3.516-1.734-3.516-3.656 0-.781.281-1.625.75-2.188-.203-.515-.172-1.609.063-2.062.625-.078 1.468.25 1.968.703.594-.187 1.219-.281 1.985-.281.765 0 1.39.094 1.953.265.484-.437 1.344-.765 1.969-.687.218.422.25 1.515.046 2.047.5.593.766 1.39.766 2.203 0 1.922-1.453 3.375-3.547 3.64.531.344.89 1.094.89 1.954v1.625c0 .468.391.734.86.547C13.781 14.359 16 11.53 16 8.03 16 3.61 12.406 0 7.984 0 3.563 0 0 3.61 0 8.031a7.88 7.88 0 0 0 5.172 7.422c.422.156.828-.125.828-.547v-1.25c-.219.094-.5.156-.75.156-1.031 0-1.64-.562-2.078-1.609-.172-.422-.36-.672-.719-.719-.187-.015-.25-.093-.25-.187 0-.188.313-.328.625-.328.453 0 .844.281 1.25.86.313.452.64.655 1.031.655s.641-.14 1-.5c.266-.265.47-.5.657-.656' + })]); + const metadata = E('span', { + 'style': 'display:inline-block;margin-left:16px;font-size:13px;font-weight:400;line-height:18px;white-space:nowrap' + }, [ + 'v' + APP_VERSION + ' · ', + E('a', { + 'href': 'https://github.com/vad-b/luci-app-tailscale-ng', + 'target': '_blank', + 'rel': 'noopener' + }, [githubIcon, 'GitHub']) + ]); + heading.appendChild(metadata); + getAvailableAppUpdate().then(function(update) { + if (!update) + return; + metadata.appendChild(document.createTextNode(' · ')); + metadata.appendChild(E('button', { + 'type': 'button', + 'style': 'display:inline-block;padding:1px 7px;border:1px solid #fcd34d;border-radius:4px;background:#fef3c7;color:#713f12;font-family:inherit;font-size:12px;font-weight:400;line-height:18px;vertical-align:baseline;cursor:pointer', + 'click': function() { showAppUpdate(update); } + }, [_('Update available: %s').format('v' + update.version)])); + }).catch(function(error) { + console.warn('Unable to check for application updates:', error); + }); + } + // Start loading status data immediately in background (fire and forget) loadStatusData(); updateExitNodeOptions(tsInfo); @@ -752,7 +1102,7 @@ return view.extend({ // This handles the case when service just started if (tsInfo.backendState !== 'Running' && isRunning) { const quickUpdateLogin = async function() { - const info = await getTailscaleInfo(true); + const info = await getTailscaleInfo(true, systemHostname); latestTsInfo = info; // Keep service status block in sync with quick login updates. diff --git a/luci-app-tailscale-ng/po/templates/luci-app-tailscale-ng.pot b/luci-app-tailscale-ng/po/templates/luci-app-tailscale-ng.pot index cc716994d..fda63371f 100644 --- a/luci-app-tailscale-ng/po/templates/luci-app-tailscale-ng.pot +++ b/luci-app-tailscale-ng/po/templates/luci-app-tailscale-ng.pot @@ -1,103 +1,156 @@ msgid "" msgstr "Content-Type: text/plain; charset=UTF-8" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:199 -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:618 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:420 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:931 msgid "-- None --" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:587 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:900 msgid "Accept DNS" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:588 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:901 msgid "Accept DNS configuration from the control server." msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:601 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:914 msgid "Accept Subnet Routes" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:602 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:915 msgid "Accept subnet routes that other nodes advertise." msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:648 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:961 msgid "Additional Flags" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:596 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:909 msgid "Advertise Exit Node" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:606 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:919 msgid "Advertised Routes" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:164 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:385 msgid "Are you sure you want to log out and clean the local state?" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:522 -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:528 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:815 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:821 msgid "Authentication" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:534 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:853 +msgid "Authentication State" +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:827 msgid "Base URL of login (control) server." msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:283 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:356 +msgid "Cancel" +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:505 msgid "Checking login..." msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:368 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:591 msgid "Checking status..." msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:355 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:578 msgid "Click here to log in" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:131 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:743 +msgid "Close" +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:189 msgid "Command exited with code" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:585 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:722 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:723 +msgid "Copied" +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:701 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:702 +msgid "Copy command" +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:722 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:723 +msgid "Copy manually" +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:727 +msgid "Current version" +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:898 msgid "DNS" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:552 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:845 msgid "" "Disconnect from login server, expire current login, and flush local state " "cache." msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:173 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:856 +msgid "Download" +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:855 +msgid "Download State" +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:287 +msgid "Download failed: %s" +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:858 +msgid "" +"Download this device's Tailscale authentication state file. It contains " +"credentials, so keep it secure." +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:369 +msgid "Enable Tailscale" +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:394 msgid "Error:" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:151 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:209 msgid "Error: unable to load status data." msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:616 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:929 msgid "Exit Node" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:646 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:959 msgid "Extra" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:650 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:963 msgid "Extra flags in format --flag=value. See %s for available options." msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:575 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:888 msgid "Firewall Mode" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:560 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:873 msgid "General" msgstr "" @@ -105,209 +158,335 @@ msgstr "" msgid "Grant access to Tailscale NG configuration" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:562 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:875 msgid "Listen Port" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:387 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:610 msgid "Loading..." msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:279 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:501 msgid "Logged in" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:281 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:503 msgid "Logged out" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:630 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:943 msgid "Logging" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:167 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:388 msgid "Logging out and cleaning state..." msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:634 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:947 msgid "Logging program activities." msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:640 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:953 msgid "Logging program errors and exceptions." msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:533 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:826 msgid "Login Server" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:530 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:823 msgid "Login Status" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:549 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:842 msgid "Logout" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:546 -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:550 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:839 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:843 msgid "Logout and Clean" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:363 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:586 msgid "Manage in Admin Console" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:514 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:807 msgid "N/A" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:391 -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:707 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:729 +msgid "New version" +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:614 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:1020 msgid "No output available. Service may not be running." msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:270 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:492 msgid "Not running" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:597 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:910 msgid "Offer to be an exit node for outbound internet traffic." msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:571 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:884 msgid "Path to state file. WARNING: Changing this requires reauthentication." msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:538 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:831 msgid "Pre-authentication Key" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:313 -msgid "Restart" +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:261 +msgid "Preparing Tailscale state file..." +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:734 +msgid "Release notes" +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:866 +msgid "" +"Replace this device's Tailscale authentication state with an uploaded file. " +"The Tailscale settings saved on this router will then be applied. If " +"\"Tailscale Enabled\" is off, the service will remain stopped. Do not use " +"the same Tailscale state file on two devices at once." +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:311 +msgid "" +"Replace this device's current Tailscale authentication state with this file? " +"The Tailscale settings saved on this router will be applied. If \"Tailscale " +"Enabled\" is off, the service will remain stopped. Do not use the same " +"Tailscale state file on two devices at once." msgstr "" #: htdocs/luci-static/resources/view/tailscale-ng/index.js:315 +msgid "" +"Replacing this device's Tailscale authentication state and applying the " +"Tailscale settings saved on this router." +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:536 +msgid "Restart" +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:538 msgid "Restarting Tailscale service..." msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:594 -msgid "Routing" -msgstr "" - -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:273 -msgid "Running" -msgstr "" - -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:617 -msgid "Select an online machine to use as an exit node." -msgstr "" - -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:577 -msgid "Select the firewall backend to use." -msgstr "" - -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:366 -msgid "Service not running" -msgstr "" - -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:539 -msgid "Set the machine authorization key." -msgstr "" - -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:523 -msgid "Settings" -msgstr "" - -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:326 -msgid "Start" -msgstr "" - -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:504 -msgid "Start Tailscale service on boot." +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:863 +msgid "Restore State" msgstr "" #: htdocs/luci-static/resources/view/tailscale-ng/index.js:328 +msgid "Restore failed: %s" +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:314 +msgid "Restoring Tailscale state..." +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:907 +msgid "Routing" +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:495 +msgid "Running" +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:301 +msgid "" +"Select a Tailscale authentication state file. This device's current " +"Tailscale authentication state will only be replaced after confirmation." +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:930 +msgid "Select an online machine to use as an exit node." +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:890 +msgid "Select the firewall backend to use." +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:589 +msgid "Service not running" +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:832 +msgid "Set the machine authorization key." +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:816 +msgid "Settings" +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:549 +msgid "Start" +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:796 +msgid "Start Tailscale service on boot." +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:345 msgid "Starting Tailscale service..." msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:569 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:882 msgid "State File" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:524 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:817 msgid "Status" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:638 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:951 msgid "StdErr Logging" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:632 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:945 msgid "StdOut Logging" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:319 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:542 msgid "Stop" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:321 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:544 msgid "Stopping Tailscale service..." msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:607 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:920 msgid "" "Subnets to advertise to other Tailscale nodes, e.g. 192.168.1.0/24." msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:681 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:994 msgid "Tailscale DNS Status" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:503 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:795 msgid "Tailscale Enabled" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:663 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:976 msgid "Tailscale IP" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:669 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:982 msgid "Tailscale Interface" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:492 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:784 #: root/usr/share/luci/menu.d/luci-app-tailscale-ng.json:3 msgid "Tailscale NG" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:675 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:988 msgid "Tailscale Netcheck" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:508 -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:657 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:800 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:970 msgid "Tailscale Status" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:512 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:805 msgid "Tailscale Version" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:651 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:964 msgid "Tailscale documentation" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:493 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:785 msgid "Tailscale is a cross-platform and easy to use virtual LAN." msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:564 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:372 +msgid "Tailscale is disabled" +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:374 +msgid "The Tailscale service is disabled in the saved configuration." +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:225 +msgid "The Tailscale state file does not contain a machine key." +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:223 +msgid "The Tailscale state file exceeds the 8 MiB size limit." +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:222 +msgid "The Tailscale state file is empty." +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:224 +msgid "The Tailscale state file must contain a valid JSON object." +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:221 +msgid "The Tailscale state file was not found." +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:220 +msgid "The configured Tailscale state file path must be absolute." +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:262 +msgid "The download will start when the Tailscale state file is ready." +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:307 +msgid "The uploaded file is not a valid Tailscale authentication state file." +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:736 +msgid "To update, run this command as root in an SSH session on your router:" +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:376 +msgid "" +"Turn on \"Tailscale Enabled\", then click \"Save & Apply\" to start the " +"service." +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:877 msgid "UDP port to listen on for WireGuard and peer-to-peer traffic." msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:540 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:267 +msgid "Unable to prepare the Tailscale state file." +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:319 +msgid "Unable to restore the Tailscale state file." +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:726 +msgid "Update available" +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:1090 +msgid "Update available: %s" +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:864 +msgid "Upload & Restore" +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:326 +msgid "Upload has been cancelled" +msgstr "" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:833 msgid "" "Use a non-reusable auth key and disable key expiry for trusted machines via " "the provider admin console." diff --git a/luci-app-tailscale-ng/po/zh_Hans/luci-app-tailscale-ng.po b/luci-app-tailscale-ng/po/zh_Hans/luci-app-tailscale-ng.po index 55139d3e6..e4d8432e7 100644 --- a/luci-app-tailscale-ng/po/zh_Hans/luci-app-tailscale-ng.po +++ b/luci-app-tailscale-ng/po/zh_Hans/luci-app-tailscale-ng.po @@ -6,10 +6,10 @@ # Language: zh_Hans (Simplified Chinese) msgid "" msgstr "" -"Project-Id-Version: luci-app-tailscale-ng 0.1.2\n" +"Project-Id-Version: luci-app-tailscale-ng 0.1.3\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-08-15 00:00+0000\n" -"PO-Revision-Date: 2026-08-15 00:00+0000\n" +"PO-Revision-Date: 2026-11-08 11:34+0000\n" "Last-Translator: \n" "Language-Team: Chinese (Simplified)\n" "Language: zh_Hans\n" @@ -18,103 +18,156 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:199 -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:618 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:420 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:931 msgid "-- None --" msgstr "-- 无 --" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:587 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:900 msgid "Accept DNS" msgstr "接受 DNS" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:588 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:901 msgid "Accept DNS configuration from the control server." msgstr "接受来自控制服务器的 DNS 配置" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:601 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:914 msgid "Accept Subnet Routes" msgstr "接受子网路由" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:602 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:915 msgid "Accept subnet routes that other nodes advertise." msgstr "接受其他节点通告的子网路由" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:648 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:961 msgid "Additional Flags" msgstr "附加参数" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:596 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:909 msgid "Advertise Exit Node" msgstr "通告出口节点" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:606 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:919 msgid "Advertised Routes" msgstr "通告路由" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:164 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:385 msgid "Are you sure you want to log out and clean the local state?" msgstr "确定要注销并清除本地状态吗?" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:522 -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:528 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:815 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:821 msgid "Authentication" msgstr "认证" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:534 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:853 +msgid "Authentication State" +msgstr "认证状态" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:827 msgid "Base URL of login (control) server." msgstr "登录(控制)服务器的基础 URL" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:283 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:356 +msgid "Cancel" +msgstr "取消" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:505 msgid "Checking login..." msgstr "正在检查登录状态..." -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:368 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:591 msgid "Checking status..." msgstr "正在检查状态..." -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:355 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:578 msgid "Click here to log in" msgstr "点击此处登录" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:131 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:743 +msgid "Close" +msgstr "关闭" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:189 msgid "Command exited with code" msgstr "命令退出代码:" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:585 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:722 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:723 +msgid "Copied" +msgstr "已复制" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:701 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:702 +msgid "Copy command" +msgstr "复制命令" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:722 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:723 +msgid "Copy manually" +msgstr "手动复制" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:727 +msgid "Current version" +msgstr "当前版本" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:898 msgid "DNS" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:552 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:845 msgid "" "Disconnect from login server, expire current login, and flush local state " "cache." msgstr "断开与登录服务器的连接,使当前登录失效并清除本地状态缓存" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:173 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:856 +msgid "Download" +msgstr "下载" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:855 +msgid "Download State" +msgstr "下载状态" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:287 +msgid "Download failed: %s" +msgstr "下载失败:%s" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:858 +msgid "" +"Download this device's Tailscale authentication state file. It contains " +"credentials, so keep it secure." +msgstr "下载本设备的 Tailscale 认证状态文件。该文件包含凭据,请妥善保管" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:369 +msgid "Enable Tailscale" +msgstr "启用 Tailscale" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:394 msgid "Error:" msgstr "错误:" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:151 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:209 msgid "Error: unable to load status data." msgstr "错误:无法加载状态数据" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:616 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:929 msgid "Exit Node" msgstr "出口节点" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:646 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:959 msgid "Extra" msgstr "其他" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:650 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:963 msgid "Extra flags in format --flag=value. See %s for available options." msgstr "格式为 --flag=value 的附加参数。可用选项请参阅 %s" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:575 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:888 msgid "Firewall Mode" msgstr "防火墙模式" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:560 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:873 msgid "General" msgstr "常规" @@ -122,210 +175,347 @@ msgstr "常规" msgid "Grant access to Tailscale NG configuration" msgstr "授予对 Tailscale NG 配置的访问权限" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:562 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:875 msgid "Listen Port" msgstr "监听端口" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:387 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:610 msgid "Loading..." msgstr "加载中..." -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:279 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:501 msgid "Logged in" msgstr "已登录" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:281 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:503 msgid "Logged out" msgstr "已注销" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:630 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:943 msgid "Logging" msgstr "日志" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:167 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:388 msgid "Logging out and cleaning state..." msgstr "正在注销并清除状态..." -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:634 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:947 msgid "Logging program activities." msgstr "记录程序的活动日志" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:640 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:953 msgid "Logging program errors and exceptions." msgstr "记录程序的错误和异常日志" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:533 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:826 msgid "Login Server" msgstr "登录服务器" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:530 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:823 msgid "Login Status" msgstr "登录状态" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:549 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:842 msgid "Logout" msgstr "注销" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:546 -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:550 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:839 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:843 msgid "Logout and Clean" msgstr "注销并清理" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:363 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:586 msgid "Manage in Admin Console" msgstr "在管理控制台中管理" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:514 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:807 msgid "N/A" msgstr "不适用" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:391 -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:707 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:729 +msgid "New version" +msgstr "新版本" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:614 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:1020 msgid "No output available. Service may not be running." msgstr "没有可用输出。服务可能未在运行" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:270 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:492 msgid "Not running" msgstr "未运行" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:597 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:910 msgid "Offer to be an exit node for outbound internet traffic." msgstr "作为出口节点为出站互联网流量提供服务" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:571 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:884 msgid "Path to state file. WARNING: Changing this requires reauthentication." msgstr "状态文件的路径。警告:更改此设置需要重新认证" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:538 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:831 msgid "Pre-authentication Key" msgstr "预认证密钥" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:313 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:261 +msgid "Preparing Tailscale state file..." +msgstr "正在准备 Tailscale 状态文件..." + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:734 +msgid "Release notes" +msgstr "发行说明" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:866 +msgid "" +"Replace this device's Tailscale authentication state with an uploaded file. " +"The Tailscale settings saved on this router will then be applied. If " +"\"Tailscale Enabled\" is off, the service will remain stopped. Do not use " +"the same Tailscale state file on two devices at once." +msgstr "" +"使用上传的文件替换本设备的 Tailscale 认证状态,随后应用此路由器上保存的 " +"Tailscale 设置。如果“启用 Tailscale”选项处于关闭状态,服务将保持停止。请勿在" +"两台设备上同时使用同一个 Tailscale 状态文件" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:311 +msgid "" +"Replace this device's current Tailscale authentication state with this file? " +"The Tailscale settings saved on this router will be applied. If \"Tailscale " +"Enabled\" is off, the service will remain stopped. Do not use the same " +"Tailscale state file on two devices at once." +msgstr "" +"是否使用此文件替换本设备当前的 Tailscale 认证状态?此路由器上保存的 " +"Tailscale 设置将被应用。如果“启用 Tailscale”选项处于关闭状态,服务将保持停" +"止。请勿在两台设备上同时使用同一个 Tailscale 状态文件" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:315 +msgid "" +"Replacing this device's Tailscale authentication state and applying the " +"Tailscale settings saved on this router." +msgstr "" +"正在替换本设备的 Tailscale 认证状态,并应用此路由器上保存的 Tailscale 设置" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:536 msgid "Restart" msgstr "重启" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:315 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:538 msgid "Restarting Tailscale service..." msgstr "正在重启 Tailscale 服务..." -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:594 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:863 +msgid "Restore State" +msgstr "恢复状态" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:328 +msgid "Restore failed: %s" +msgstr "恢复失败:%s" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:314 +msgid "Restoring Tailscale state..." +msgstr "正在恢复 Tailscale 状态..." + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:907 msgid "Routing" msgstr "路由" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:273 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:495 msgid "Running" msgstr "运行中" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:617 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:301 +msgid "" +"Select a Tailscale authentication state file. This device's current " +"Tailscale authentication state will only be replaced after confirmation." +msgstr "" +"选择 Tailscale 认证状态文件。只有确认后,才会替换本设备当前的 Tailscale 认证" +"状态" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:930 msgid "Select an online machine to use as an exit node." msgstr "选择一台在线设备作为出口节点" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:577 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:890 msgid "Select the firewall backend to use." msgstr "选择要使用的防火墙后端" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:366 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:589 msgid "Service not running" msgstr "服务未运行" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:539 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:832 msgid "Set the machine authorization key." msgstr "设置设备授权密钥" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:523 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:816 msgid "Settings" msgstr "设置" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:326 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:549 msgid "Start" msgstr "启动" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:504 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:796 msgid "Start Tailscale service on boot." msgstr "开机时启动 Tailscale 服务" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:328 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:345 msgid "Starting Tailscale service..." msgstr "正在启动 Tailscale 服务..." -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:569 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:882 msgid "State File" msgstr "状态文件" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:524 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:817 msgid "Status" msgstr "状态" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:638 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:951 msgid "StdErr Logging" msgstr "标准错误日志" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:632 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:945 msgid "StdOut Logging" msgstr "标准输出日志" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:319 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:542 msgid "Stop" msgstr "停止" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:321 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:544 msgid "Stopping Tailscale service..." msgstr "正在停止 Tailscale 服务..." -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:607 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:920 msgid "" "Subnets to advertise to other Tailscale nodes, e.g. 192.168.1.0/24." msgstr "向其他 Tailscale 节点通告的子网,例如 192.168.1.0/24" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:681 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:994 msgid "Tailscale DNS Status" msgstr "Tailscale DNS 状态" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:503 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:795 msgid "Tailscale Enabled" msgstr "启用 Tailscale" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:663 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:976 msgid "Tailscale IP" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:669 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:982 msgid "Tailscale Interface" msgstr "Tailscale 接口" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:492 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:784 #: root/usr/share/luci/menu.d/luci-app-tailscale-ng.json:3 msgid "Tailscale NG" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:675 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:988 msgid "Tailscale Netcheck" msgstr "" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:508 -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:657 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:800 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:970 msgid "Tailscale Status" msgstr "Tailscale 状态" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:512 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:805 msgid "Tailscale Version" msgstr "Tailscale 版本" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:651 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:964 msgid "Tailscale documentation" msgstr "Tailscale 文档" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:493 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:785 msgid "Tailscale is a cross-platform and easy to use virtual LAN." msgstr "Tailscale 是一个跨平台且易于使用的虚拟局域网" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:564 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:372 +msgid "Tailscale is disabled" +msgstr "Tailscale 已禁用" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:374 +msgid "The Tailscale service is disabled in the saved configuration." +msgstr "已保存的配置中未启用 Tailscale 服务" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:225 +msgid "The Tailscale state file does not contain a machine key." +msgstr "Tailscale 状态文件不包含设备密钥" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:223 +msgid "The Tailscale state file exceeds the 8 MiB size limit." +msgstr "Tailscale 状态文件超过 8 MiB 大小限制" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:222 +msgid "The Tailscale state file is empty." +msgstr "Tailscale 状态文件为空" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:224 +msgid "The Tailscale state file must contain a valid JSON object." +msgstr "Tailscale 状态文件必须包含有效的 JSON 对象" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:221 +msgid "The Tailscale state file was not found." +msgstr "未找到 Tailscale 状态文件" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:220 +msgid "The configured Tailscale state file path must be absolute." +msgstr "配置的 Tailscale 状态文件路径必须为绝对路径" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:262 +msgid "The download will start when the Tailscale state file is ready." +msgstr "Tailscale 状态文件准备就绪后将开始下载" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:307 +msgid "The uploaded file is not a valid Tailscale authentication state file." +msgstr "上传的文件不是有效的 Tailscale 认证状态文件" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:736 +msgid "To update, run this command as root in an SSH session on your router:" +msgstr "要更新,请以 root 用户身份通过 SSH 登录路由器后运行以下命令:" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:376 +msgid "" +"Turn on \"Tailscale Enabled\", then click \"Save & Apply\" to start the " +"service." +msgstr "开启“启用 Tailscale”选项,然后点击“保存并应用”以启动服务" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:877 msgid "UDP port to listen on for WireGuard and peer-to-peer traffic." msgstr "用于监听 WireGuard 与点对点流量的 UDP 端口" -#: htdocs/luci-static/resources/view/tailscale-ng/index.js:540 +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:267 +msgid "Unable to prepare the Tailscale state file." +msgstr "无法准备 Tailscale 状态文件" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:319 +msgid "Unable to restore the Tailscale state file." +msgstr "无法恢复 Tailscale 状态文件" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:726 +msgid "Update available" +msgstr "有可用更新" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:1090 +msgid "Update available: %s" +msgstr "有可用更新:%s" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:864 +msgid "Upload & Restore" +msgstr "上传并恢复" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:326 +msgid "Upload has been cancelled" +msgstr "上传已被取消" + +#: htdocs/luci-static/resources/view/tailscale-ng/index.js:833 msgid "" "Use a non-reusable auth key and disable key expiry for trusted machines via " "the provider admin console." -msgstr "对于受信任的设备,请通过服务商管理控制台使用不可重复使用的认证密钥并禁用密钥过期" +msgstr "" +"对于受信任的设备,请通过服务商管理控制台使用不可重复使用的认证密钥并禁用密钥" +"过期" diff --git a/luci-app-tailscale-ng/root/etc/uci-defaults/40_luci-app-tailscale-ng b/luci-app-tailscale-ng/root/etc/uci-defaults/40_luci-app-tailscale-ng index 09fc80aa3..378264558 100755 --- a/luci-app-tailscale-ng/root/etc/uci-defaults/40_luci-app-tailscale-ng +++ b/luci-app-tailscale-ng/root/etc/uci-defaults/40_luci-app-tailscale-ng @@ -4,5 +4,5 @@ /etc/init.d/luci-app-tailscale-ng enable /etc/init.d/luci-app-tailscale-ng start >/dev/null 2>&1 || true -rm -f /tmp/luci-indexcache +rm -f /tmp/luci-indexcache* exit 0 diff --git a/luci-app-tailscale-ng/root/usr/libexec/luci-app-tailscale-ng-state b/luci-app-tailscale-ng/root/usr/libexec/luci-app-tailscale-ng-state new file mode 100755 index 000000000..8f2672261 --- /dev/null +++ b/luci-app-tailscale-ng/root/usr/libexec/luci-app-tailscale-ng-state @@ -0,0 +1,131 @@ +#!/bin/sh + +# Copyright (C) 2026 vad-b +# SPDX-License-Identifier: GPL-3.0-only + +set -eu +umask 077 + +DOWNLOAD_FILE='/tmp/tailscale-auth-state.download' +UPLOAD_FILE='/tmp/tailscale-auth-state.upload' +DEFAULT_STATE_FILE='/etc/tailscale/tailscaled.state' +MAX_UPLOAD_SIZE=8388608 +TEMP_FILE='' + +fail() { + local code="$1" + shift + printf '%s\n' "$*" >&2 + exit "$code" +} + +run() { + "$@" >&2 || fail 1 OPERATION_FAILED +} + +cleanup() { + if [ -n "$TEMP_FILE" ]; then + rm -f "$TEMP_FILE" || true + fi +} + +trap cleanup EXIT +trap 'exit 1' INT TERM + +get_state_file() { + local state_file code + + if state_file="$(uci -q get tailscale.settings.state_file)"; then + : + else + code="$?" + [ "$code" -eq 1 ] || fail 1 STATE_CONFIG_READ_FAILED + fi + [ -n "$state_file" ] || state_file="$DEFAULT_STATE_FILE" + + case "$state_file" in + /*) ;; + *) fail 10 STATE_PATH_INVALID ;; + esac + + printf '%s\n' "$state_file" +} + +validate_state_file() { + local file size first_char code machine_key_type machine_key + file="$1" + + [ -f "$file" ] || fail 11 STATE_NOT_FOUND + [ -s "$file" ] || fail 12 STATE_EMPTY + [ -r "$file" ] || fail 1 STATE_READ_FAILED + + size="$(wc -c < "$file")" || fail 1 STATE_READ_FAILED + [ "$size" -le "$MAX_UPLOAD_SIZE" ] || fail 13 STATE_TOO_LARGE + + first_char="$(LC_ALL=C sed -n '/[^[:space:]]/ { s/^[[:space:]]*\(.\).*/\1/; p; q; }' "$file")" || fail 1 STATE_READ_FAILED + [ "$first_char" = "{" ] || fail 14 STATE_JSON_INVALID + command -v jsonfilter >/dev/null 2>&1 || fail 1 JSONFILTER_NOT_FOUND + if jsonfilter -i "$file" -e '@' >/dev/null 2>&1; then + : + else + code="$?" + case "$code" in + 1|126) fail 14 STATE_JSON_INVALID ;; + *) fail 1 JSONFILTER_FAILED ;; + esac + fi + + machine_key_type="$(jsonfilter -q -i "$file" -t '@._machinekey')" || fail 15 STATE_MACHINE_KEY_MISSING + [ "$machine_key_type" = 'string' ] || fail 15 STATE_MACHINE_KEY_MISSING + machine_key="$(jsonfilter -q -i "$file" -e '@._machinekey')" || fail 1 JSONFILTER_FAILED + [ -n "$machine_key" ] || fail 15 STATE_MACHINE_KEY_MISSING +} + +prepare_download() { + local state_file + state_file="$(get_state_file)" + + validate_state_file "$state_file" + + TEMP_FILE="${DOWNLOAD_FILE}.$$" + run rm -f "$TEMP_FILE" + run cp "$state_file" "$TEMP_FILE" + run chmod 600 "$TEMP_FILE" + run mv -f "$TEMP_FILE" "$DOWNLOAD_FILE" + TEMP_FILE='' +} + +validate_upload() { + validate_state_file "$UPLOAD_FILE" +} + +restore_upload() { + local state_file state_dir + + validate_state_file "$UPLOAD_FILE" + state_file="$(get_state_file)" + state_dir="${state_file%/*}" + [ -n "$state_dir" ] || state_dir='/' + + [ -d "$state_dir" ] || run mkdir -p "$state_dir" + TEMP_FILE="$state_dir/.tailscaled.state.restore.$$" + + run cp "$UPLOAD_FILE" "$TEMP_FILE" + run chmod 600 "$TEMP_FILE" + run chown 0:0 "$TEMP_FILE" + + run /etc/init.d/tailscale stop + run mv -f "$TEMP_FILE" "$state_file" + TEMP_FILE='' + run rm -f "$UPLOAD_FILE" "$DOWNLOAD_FILE" + run /etc/init.d/luci-app-tailscale-ng start +} + +[ "$#" -eq 1 ] || fail 2 "Usage: $0 {prepare-download|validate-upload|restore-upload}" + +case "${1:-}" in + prepare-download) prepare_download ;; + validate-upload) validate_upload ;; + restore-upload) restore_upload ;; + *) fail 2 "Usage: $0 {prepare-download|validate-upload|restore-upload}" ;; +esac diff --git a/luci-app-tailscale-ng/root/usr/share/rpcd/acl.d/luci-app-tailscale-ng.json b/luci-app-tailscale-ng/root/usr/share/rpcd/acl.d/luci-app-tailscale-ng.json index 327a9dcc6..4f21fc21e 100644 --- a/luci-app-tailscale-ng/root/usr/share/rpcd/acl.d/luci-app-tailscale-ng.json +++ b/luci-app-tailscale-ng/root/usr/share/rpcd/acl.d/luci-app-tailscale-ng.json @@ -2,19 +2,31 @@ "luci-app-tailscale-ng": { "description": "Grant access to Tailscale NG configuration", "read": { + "cgi-io": [ "download" ], "file": { "/sys/class/net/tailscale0/*": [ "read" ], "/sbin/ip": [ "exec" ], + "/tmp/tailscale-auth-state.download": [ "read" ], "/usr/sbin/tailscale": [ "exec" ] }, "ubus": { "luci": [ "getInitList", "setInitAction" ], - "service": [ "list" ] + "service": [ "list" ], + "system": [ "board" ] }, "uci": [ "tailscale", "luci-app-tailscale-ng" ] }, "write": { + "cgi-io": [ "upload" ], + "file": { + "/tmp/tailscale-auth-state.download": [ "write" ], + "/tmp/tailscale-auth-state.upload": [ "write" ], + "/usr/libexec/luci-app-tailscale-ng-state prepare-download": [ "exec" ], + "/usr/libexec/luci-app-tailscale-ng-state restore-upload": [ "exec" ], + "/usr/libexec/luci-app-tailscale-ng-state validate-upload": [ "exec" ] + }, "ubus": { + "file": [ "exec", "remove" ], "luci": [ "setInitAction" ] }, "uci": [ "tailscale", "luci-app-tailscale-ng" ] diff --git a/shunt/Makefile b/shunt/Makefile index f79f9b0ad..d6968ddde 100644 --- a/shunt/Makefile +++ b/shunt/Makefile @@ -5,8 +5,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=shunt -PKG_VERSION:=0.4.2 -PKG_RELEASE:=13 +PKG_VERSION:=0.4.5 +PKG_RELEASE:=14 PKG_LICENSE:=GPL-3.0-or-later PKG_LICENSE_FILES:= PKG_MAINTAINER:=Dirk Brenken diff --git a/shunt/README.md b/shunt/README.md index 2f7dfe4ae..42679a1a7 100644 --- a/shunt/README.md +++ b/shunt/README.md @@ -182,6 +182,7 @@ Logging goes to syslog under the tag `shunt`, so `logread -e shunt` shows everyt | rp_filter_manage | `0` | set rp_filter=2 on shunt's own policy devices, at start and on ifup | | poll_interval | `300` | seconds between poll cycles, at least 30 | | entry_ttl | `1200` | ceiling for the lifetime of learned elements, at least 60 | +| learn_limit | `4096` | most addresses one learned set holds, 64 to 65536; see [How addresses are learned](#how-addresses-are-learned) | | snoop | `1` | enable the passive DNS observer | | snoop_device | `br-lan` | LAN devices to observe, a list, one entry per segment | | track_ip | | addresses to probe through every policy interface, a list; none means no tracking, see [Health tracking](#health-tracking-track_ip) | @@ -567,6 +568,7 @@ What that means in practice: * **One entry per layer 2 segment.** A guest or IoT VLAN on its own device never carries the answers of the main LAN, so it needs its own `snoop_device` entry. A device that cannot be opened costs one warning; the others keep running. * **The router's own lookups are not seen.** poll's queries leave through the uplink, not the LAN device. * Not seen either: DNS over TCP, DNS on a port other than 53, and a second stacked VLAN tag. +* **An observed segment is a trusted one.** snoop takes what crosses the device for what the resolver said - it cannot tell a DNS answer from a frame built to look like one. A host on an observed segment can therefore put addresses of its choosing into the learned set of any domain policy whose pattern it can name: into a `route` policy that sends more traffic through the tunnel, into a `bypass` policy it takes traffic out of the policies below, a killswitch included. Passive observation has no way around this, so do not observe a segment you would not let near the policy - a guest or IoT network is better covered by `src_dev` with addresses, ports or `dst_file`. What is bounded is the damage: a learned set holds at most `learn_limit` addresses, 4096 unless configured, counted per policy and address family - so a flooded set costs a bounded amount of memory and leaves every other policy learning as before. An address beyond the limit is not learned until older ones expire, which for the policy means it takes the normal uplink; the log names the full set once per poll interval, and `ubus call shunt status` counts the addresses turned away per policy under `capped`. A policy with a very large list or several hundred polled names can come near the default honestly - if `capped` shows up without an attacker, raise the limit. A name in a `domain` list that never resolves is reported once, by name and policy: @@ -710,7 +712,6 @@ routing table 8000 + , for tproxy: local default d chain mk_ the mark statement of the interface in use, for a policy with several interfaces, with fallback 'block' (plus a tcp reset while all are dead), or with track_ip and fallback 'main' -/etc/iproute2/rt_tables.d/shunt.conf the table name mapping, for `ip route show` only /var/run/shunt.learned learned addresses across a restart or reload, deleted on start ``` @@ -815,7 +816,7 @@ A number that keeps climbing means something on the box flushes nftables repeate ### When a policy stops applying after a reconnect -The kernel removes routes from a policy table when the interface goes down, after which the fwmark rule falls through to `main` while every counter keeps counting. shunt handles this on two levels: a ubus listener on `network.interface` rebuilds the route half on ifup/ifdown, and every poll tick replays the route commands as a keeper. Learned sets survive both. If ubus is unavailable, only the keeper remains, so recovery takes up to one `poll_interval`. +The kernel removes routes from a policy table when the interface goes down, after which the fwmark rule falls through to `main` while every counter keeps counting. shunt handles this on two levels: a ubus listener on `network.interface` rebuilds the route half on ifup/ifdown, and every poll tick replays the route commands as a keeper. Learned sets survive both. The rebuild is applied on top of what is there - a rule that exists stays, a route is replaced in place, and only the default route of an interface that is down now is taken out - so an interface event, whichever interface it is about, opens no gap in which marked traffic would take the main table. If ubus is unavailable, only the keeper remains, so recovery takes up to one `poll_interval`. ### Debug logging @@ -836,6 +837,7 @@ These are consequences of the design, stated rather than worked around: * **Wildcards require snoop.** poll can only resolve names it was given, and `*.example.com` is not a name. * **One CDN address serves many domains.** If a policy routes `example.com` and the address behind it also serves a thousand other sites, those sites follow the same policy. This is unsolvable at layer 3 by anything that routes on addresses. * **The first connection to a newly seen address takes the old path.** snoop learns from the response the client is reading at that moment, so the client's SYN is usually out before the element reaches the set - a new address is written right away, but that still takes an `nft` run, and a client connects within milliseconds of its answer. Holding the answer back until the set is written would put shunt between client and resolver, which is exactly what snoop avoids. That connection stays where it started, by design: a flow's route is decided on its first packet and kept on the conntrack entry, so the switch happens at a connection boundary and never mid-stream - see [What shunt creates on the system](#what-shunt-creates-on-the-system). The next connection to the same host starts on the policy interface. Listing the entry point explicitly closes the gap, because poll warms it before any client asks. +* **What snoop learns is only as trustworthy as the observed segment.** A forged answer is learned like a real one, see [How addresses are learned](#how-addresses-are-learned); each learned set is capped at `learn_limit` addresses. * **DNS over TCP is not observed.** Port 53 over TCP needs reassembly, which is out of scope; answers large enough to force TCP are rare in the traffic shunt cares about. * **Route and rule application is best effort.** At boot a tunnel interface may not exist yet, so its default route cannot be added and the policy's `fallback` applies: with `main` the rule falls through to the main table, with `block` it hits the prohibit route, which needs no interface. The missing routes follow on the interface's ifup event, no restart needed, and the log says so in one info line naming the interfaces. A route that fails for any other reason - or through an interface netifd does not know at all - is a warning, one line per distinct reason. * **No interface hotplug.** A device that appears later is picked up on the next `ifup` event or within one poll interval, not immediately. diff --git a/shunt/files/shunt.config b/shunt/files/shunt.config index bfd376b91..16e6301d1 100644 --- a/shunt/files/shunt.config +++ b/shunt/files/shunt.config @@ -2,6 +2,7 @@ config global option enabled '1' option poll_interval '300' option entry_ttl '1200' + option learn_limit '4096' option snoop '1' option debug '0' option rp_filter_manage '0' diff --git a/shunt/files/shunt.rpcd b/shunt/files/shunt.rpcd index 1f6ff8809..96eced319 100644 --- a/shunt/files/shunt.rpcd +++ b/shunt/files/shunt.rpcd @@ -7,7 +7,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Copyright (c) 2026 Dirk Brenken -import { popen, readfile } from 'fs'; +import { popen, readfile, stat } from 'fs'; const ubus = require('ubus'); const rtnl = require('rtnl'); @@ -19,9 +19,9 @@ const RT = rtnl.const; import { load as config_load, parse as config_parse } from 'shunt.config'; import { resolve as netifd_resolve } from 'shunt.netifd'; import { compile as match_compile } from 'shunt.match'; -import { load as files_load } from 'shunt.list_file'; +import { load as files_load, resolve as file_resolve } from 'shunt.list_file'; import { action_name, compile as nft_compile } from 'shunt.nft'; -import { compile as route_compile } from 'shunt.route'; +import { compile as route_compile, conf_dev } from 'shunt.route'; import { names as poll_names } from 'shunt.poll'; import { tproxy as tproxy_available, br_netfilter, ipv6 } from 'shunt.probe'; @@ -39,8 +39,36 @@ function daemon_status() { return r ?? null; } +// What tells whether a list file has to be read again: the file itself is +// not opened for it. +function file_stamps(policies) { + let out = []; + + for (let p in (policies ?? [])) + for (let name in [ ...(p?.domain_files ?? []), ...(p?.dst_files ?? []) ]) { + let path = file_resolve(name); + let st = (path != null) ? stat(path) : null; + + push(out, [ name, st?.mtime, st?.size, st?.ino ]); + } + + return out; +} + +// The last rendering and what it was rendered from. rpcd keeps this script +// loaded, so the variable lives from one call to the next. +let rendered = null; + // The same sequence as the daemon's build_state(), minus the logging: two // readings of one config file are how a status view starts to lie. +// +// The status page asks every few seconds, and reading the list files and +// compiling a matcher and a ruleset from them is what a start of the daemon +// costs - seconds with a large list, in a process that answers every other +// LuCI call as well. So the rendering is kept and done again only when +// something it is made from has changed: the configuration, what netifd +// says about the interfaces, the kernel probes, or a list file. And only +// what the status reports is kept - the counts, not the lists. function render() { let sections = config_load(); @@ -60,16 +88,42 @@ function render() { cfg.policies = netifd_resolve(cfg.policies, dump); + let opts = { tproxy: tproxy_available(cfg.policies), + br_netfilter: br_netfilter(), track: length(cfg.global.track_ips) > 0 }; + let v6 = ipv6(); + let sig = sprintf('%J', [ cfg, opts, v6, file_stamps(cfg.policies) ]); + + if (rendered?.sig == sig) + return rendered.st; + let f = files_load(cfg.policies); cfg.policies = f.policies; let m = match_compile(cfg.policies); - let n = nft_compile(cfg.policies, { tproxy: tproxy_available(cfg.policies), - br_netfilter: br_netfilter(), track: length(cfg.global.track_ips) > 0 }); - let r = route_compile(cfg.policies, n.marks, { ipv6: ipv6() }); + let n = nft_compile(cfg.policies, opts); + let r = route_compile(cfg.policies, n.marks, { ipv6: v6 }); - return { cfg, files: f, matcher: m, nft: n, route: r }; + let st = { + global: cfg.global, + marks: n.marks, + poll_names: length(poll_names(cfg.policies)), + policies: map(cfg.policies, (p) => ({ + name: p.name, + action: p.action, + interface: p.interface, + fallback: p.fallback, + members: p.members ? map(p.members, (mb) => ({ dev: mb.dev })) : null, + domains: length(p.domains ?? []), + file_domains: length(p.file_domains ?? []), + file_dst: length(p.file_dst ?? []) + })), + issues: [ ...cfg.issues, ...f.issues, ...m.issues, ...n.issues, ...r.issues ] + }; + + rendered = { sig, st }; + + return st; } // Policy devices whose marked traffic the kernel would drop: max(all, ), @@ -93,7 +147,7 @@ function rp_filter_blocked(policies) { for (let mb in (p.members ?? [ { dev: p.interface } ])) { let dev = mb.dev; - if (!length(dev ?? '') || seen[dev]) + if (!conf_dev(dev) || seen[dev]) continue; seen[dev] = true; @@ -125,9 +179,24 @@ function rp_filter_strict() { return strict; } -function nft_table() { - let fh = popen(sprintf('nft -j list table %s %s 2>/dev/null', - TABLE_FAMILY, TABLE_NAME), 'r'); +// Whether the table is in the kernel, and nothing more: terse, so nft does +// not fetch a single element for an answer of one bit. +function nft_applied() { + return system([ '/bin/sh', '-c', 'exec "$0" "$@" >/dev/null 2>&1', + 'nft', '-t', 'list', 'table', TABLE_FAMILY, TABLE_NAME ]) == 0; +} + +// How many elements of one set a report carries. A set filled from a +// dst_file can hold a hundred thousand prefixes; all of them in one answer +// is megabytes through rpcd and a page the browser does not survive. What +// is wanted from a set that size is one address, and that is what `search` +// is for. +const REPORT_MAX = 250; + +const RE_SET = /^[a-z][46]?_[A-Za-z0-9_]{1,24}$/; + +function nft_json(args) { + let fh = popen(sprintf('nft -j %s 2>/dev/null', args), 'r'); if (!fh) return null; @@ -139,38 +208,64 @@ function nft_table() { if (!length(out ?? '')) return null; - let j = json(out); - - return j?.nftables ? j : null; + return json(out)?.nftables ?? null; } -function nft_sets(table) { - let out = {}; +// The names of the sets in the table, without a single element: terse. +function nft_set_names() { + let items = nft_json(sprintf('-t list table %s %s', TABLE_FAMILY, TABLE_NAME)); - for (let item in (table?.nftables ?? [])) { - let s = item?.set; + if (items == null) + return null; - if (!s?.name) + let names = []; + + for (let item in items) + if (type(item?.set?.name) == 'string' && match(item.set.name, RE_SET)) + push(names, item.set.name); + + return names; +} + +// What a report says about one set, from the set as nft lists it: how much +// it holds in all, how much of that the search matches, and of the matches +// the entries with the most traffic. +function set_report(set, search) { + let hot = [], cold = [], total = 0; + + for (let e in (set?.elem ?? [])) { + let v = e?.elem ?? e; + let val = v?.val ?? v; + // nft merges adjacent prefixes of an interval set, and what is no + // longer one prefix comes back as a range. + let addr = (type(val) != 'object') ? val + : val.prefix ? sprintf('%s/%d', val.prefix.addr, val.prefix.len) + : (type(val.range) == 'array') ? join('-', val.range) + : null; + + total++; + + if (search != null && index(lc(`${addr}`), search) != 0) continue; - let elems = []; - - for (let e in (s.elem ?? [])) { - let v = e?.elem ?? e; - let val = v?.val ?? v; - - push(elems, { - addr: (type(val) == 'object') ? (val.prefix ? sprintf('%s/%d', val.prefix.addr, val.prefix.len) : null) : val, - expires: v?.expires, - packets: v?.counter?.packets, - bytes: v?.counter?.bytes - }); - } - - out[s.name] = elems; + push(v?.counter?.packets ? hot : cold, { + addr, + expires: v?.expires, + packets: v?.counter?.packets, + bytes: v?.counter?.bytes + }); } - return out; + let matched = length(hot) + length(cold); + + // Only the entries that saw traffic are worth ordering, and in a set + // too large to show they are the few among the many. + if (matched > REPORT_MAX && length(hot) > 1) + sort(hot, (a, b) => b.packets - a.packets); + + let elems = slice([ ...hot, ...slice(cold, 0, REPORT_MAX) ], 0, REPORT_MAX); + + return { elems, total, matched }; } function kernel_rules(marks) { @@ -212,35 +307,34 @@ function kernel_rules(marks) { return out; } +// How many routes each policy table holds. One dump per address family, +// counted by table - not one per table and family, each of which the kernel +// answers with the whole FIB anyway. function kernel_routes(marks) { - let out = {}; + let count = {}, failed = false; - for (let m in (marks ?? [])) { - let n = 0; + for (let fam in [ RT.AF_INET, RT.AF_INET6 ]) { + let res = rtnl.request(RT.RTM_GETROUTE, RT.NLM_F_DUMP, { family: fam }); - if (m.rt_table == null) - continue; - - for (let fam in [ RT.AF_INET, RT.AF_INET6 ]) { - let res = rtnl.request(RT.RTM_GETROUTE, RT.NLM_F_DUMP, - { family: fam, table: m.rt_table }); - - if (res == null) { - n = null; - break; - } - - // A kernel without a family's dump handler answers with - // every family it has - IPv4 under an IPv6 request on a kernel - // without IPv6 - so the family is checked, not assumed. - for (let r in res) - if (r?.table == m.rt_table && r?.family == fam) - n++; + if (res == null) { + failed = true; + break; } - out[sprintf('%d', m.mark)] = n; + // A kernel without a family's dump handler answers with every + // family it has - IPv4 under an IPv6 request on a kernel without + // IPv6 - so the family is checked, not assumed. + for (let r in res) + if (r?.family == fam && r?.table != null) + count[r.table] = (count[r.table] ?? 0) + 1; } + let out = {}; + + for (let m in (marks ?? [])) + if (m.rt_table != null) + out[sprintf('%d', m.mark)] = failed ? null : (count[m.rt_table] ?? 0); + return out; } @@ -256,15 +350,14 @@ return { return { error: 'cannot read /etc/config/shunt' }; let svc = daemon_status(); - let table = nft_table(); - let rules = kernel_rules(st.nft.marks); - let routes = kernel_routes(st.nft.marks); + let rules = kernel_rules(st.marks); + let routes = kernel_routes(st.marks); let policies = []; - for (let m in st.nft.marks) { + for (let m in st.marks) { let p = null; - for (let c in st.cfg.policies) + for (let c in st.policies) if (c.name == m.name) p = c; @@ -281,9 +374,9 @@ return { members: (m.action == 'route') ? length(p?.members ?? [ 1 ]) : null, fallback: (m.action != 'bypass') ? p?.fallback : null, tproxy_port: m.port, - domains: length(p?.domains ?? []), - file_domains: length(p?.file_domains ?? []), - file_dst: length(p?.file_dst ?? []), + domains: p?.domains ?? 0, + file_domains: p?.file_domains ?? 0, + file_dst: p?.file_dst ?? 0, rules: (m.action != 'bypass' && rules) ? length(rules[sprintf('%d', m.mark)] ?? []) : null, routes: (m.action != 'bypass') ? routes[sprintf('%d', m.mark)] : null @@ -292,49 +385,69 @@ return { return { running: (svc != null), - applied: (table != null), + applied: nft_applied(), service: svc, - global: st.cfg.global, + global: st.global, policies, - poll_names: length(poll_names(st.cfg.policies)), - rp_filter_blocked: rp_filter_blocked(st.cfg.policies), + poll_names: st.poll_names, + rp_filter_blocked: rp_filter_blocked(st.policies), rp_filter_strict: rp_filter_strict(), - issues: [ - ...st.cfg.issues, - ...st.files.issues, - ...st.matcher.issues, - ...st.nft.issues, - ...st.route.issues - ] + issues: st.issues }; } }, + // `sets` has the elements, at most REPORT_MAX of each set, and + // `info` says for every set how many it holds and how many of + // them the search matched. sets: { - args: { policy: '' }, + args: { policy: '', search: '' }, call: function(req) { - let table = nft_table(); - - if (!table) - return { sets: {} }; - - let all = nft_sets(table); let want = req.args?.policy; + let search = req.args?.search; - if (!length(want ?? '')) - return { sets: all }; + search = (type(search) == 'string' && length(trim(search))) + ? lc(substr(trim(search), 0, 64)) : null; - let out = {}; + let sets = {}, info = {}; - // m_ and i_ carry no family digit, every other kind does. - for (let name in all) { - let at = (index('mi', substr(name, 0, 1)) >= 0) ? 2 : 3; + function report(set) { + let r = set_report(set, search); - if (substr(name, at) == want) - out[name] = all[name]; + sets[set.name] = r.elems; + info[set.name] = { total: r.total, matched: r.matched }; } - return { sets: out }; + // Every policy: the whole table in one listing. One policy: + // its sets one by one, so that it does not pay for the sets + // of all the others. + if (!length(want ?? '')) { + for (let item in (nft_json(sprintf('list table %s %s', + TABLE_FAMILY, TABLE_NAME)) ?? [])) + if (type(item?.set?.name) == 'string') + report(item.set); + } + else + for (let name in (nft_set_names() ?? [])) { + // m_ and i_ carry no family digit, every other kind does. + let at = (index('mi', substr(name, 0, 1)) >= 0) ? 2 : 3; + + if (substr(name, at) != want) + continue; + + for (let item in (nft_json(sprintf('list set %s %s %s', + TABLE_FAMILY, TABLE_NAME, name)) ?? [])) + if (item?.set?.name == name) + report(item.set); + } + + // What a learned set holds at most, so that the report can + // tell a full set from a large one. + let sections = config_load(); + let cfg = (sections != null) ? config_parse(sections) : null; + + return { sets, info, max: REPORT_MAX, + learn_limit: cfg?.global?.learn_limit }; } } } diff --git a/shunt/files/shunt.uc b/shunt/files/shunt.uc index f43467815..e77f5f156 100755 --- a/shunt/files/shunt.uc +++ b/shunt/files/shunt.uc @@ -19,7 +19,8 @@ import { PATH as LEARNED, fingerprints as learned_fps, save as learned_save, import { action_name, compile as nft_compile, flip as nft_flip, active as nft_active, refresh, teardown, addr_family, TABLE } from 'shunt.nft'; -import { compile as route_compile, state_ops as route_state_ops } from 'shunt.route'; +import { compile as route_compile, state_ops as route_state_ops, + stale as route_stale, route_key, conf_dev } from 'shunt.route'; import { open as rt_open, exec as rt_exec, describe as rt_describe } from 'shunt.rt'; import { open as snoop_open, observe, RECV_LEN } from 'shunt.snoop'; import { names as poll_names, plan as poll_plan, @@ -190,6 +191,23 @@ function queue_writes(st, writes, now) { if (!d) continue; + // Anybody on an observed segment can put a frame on the wire that + // reads as a DNS answer, so what a set learns is bounded - see + // dedupe.uc. Counted per policy, both families together, and said + // once per set and poll interval. + if (d == 'full') { + let pname = substr(w.set, 3); + + st.stats.capped[pname] = (st.stats.capped[pname] ?? 0) + 1; + + if (!st.full_warned[w.set]) { + st.full_warned[w.set] = true; + log('warn', sprintf('set %s is full, learn_limit is %d - new addresses are not learned until older ones expire', + w.set, st.state.cfg.global.learn_limit)); + } + continue; + } + st.pending[`${w.set}/${w.addr}`] = w; if (d == 'new') fresh++; @@ -240,11 +258,13 @@ function apply(state) { if (!nft_pipe(state.nft.setup, 'setup')) return false; - if (length(state.route.rt_tables)) { - mkdir('/etc/iproute2/rt_tables.d', 0o755); - if (!writefile(RT_TABLES, state.route.rt_tables)) - log('warn', sprintf('cannot write %s: %s', RT_TABLES, fs_error())); - } + // Releases up to 0.4.2 kept a name for every table in this file, for + // `ip route show table ` with iproute2 installed. Nothing of shunt + // ever read it, BusyBox ip cannot, and it cost flash writes on every + // start and stop - so it is gone, and what an earlier release left + // behind is removed once. + if (readfile(RT_TABLES) != null) + unlink(RT_TABLES); rt_quiet(state.route.del); @@ -295,17 +315,15 @@ function flush(state) { rt_quiet(state.route.del); nft_pipe(teardown(), 'teardown'); - - if (readfile(RT_TABLES) != null) - unlink(RT_TABLES); } function rp_read(k) { return trim(readfile(`/proc/sys/net/ipv4/conf/${k}/rp_filter`) ?? ''); } -// Distinct, existing policy devices. Deduped so a device shared by several -// policies is set or reported once. +// Distinct policy devices, as far as the name is one a /proc path may be +// built from - see conf_dev() in route.uc. Deduped so a device shared by +// several policies is set or reported once. function policy_devices(policies) { let seen = {}, out = []; @@ -317,7 +335,7 @@ function policy_devices(policies) { for (let mb in (p.members ?? [ { dev: p.interface } ])) { let dev = mb.dev; - if (length(dev ?? '') && !seen[dev]) { + if (conf_dev(dev) && !seen[dev]) { seen[dev] = true; push(out, dev); } @@ -470,8 +488,8 @@ function drain_writes(st) { if (!length(due)) return; - let r = refresh(due, st.state.cfg.global.entry_ttl, - st.interval + WRITE_SLACK); + let slack = st.interval + WRITE_SLACK; + let r = refresh(due, st.state.cfg.global.entry_ttl, slack); report('refresh', r.issues); if (!length(r.batch)) @@ -481,11 +499,37 @@ function drain_writes(st) { let ok = nft_pipe(r.batch, 'refresh'); let cost = mono() - t0; - if (ok) - debug(sprintf('%d element(s) written in %ds', length(due), cost)); - else if (ensure_table(st) == 'recreated') + if (ok) { for (let w in due) - st.pending[`${w.set}/${w.addr}`] = w; + st.cache.written(w.set, w.addr, slack); + + debug(sprintf('%d element(s) written in %ds', length(due), cost)); + } + else + switch (ensure_table(st)) { + case 'recreated': + for (let w in due) + st.pending[`${w.set}/${w.addr}`] = w; + break; + + // The table is there and the batch was refused all the same. The + // cache has every element of it on record as written, and would + // hold back the next answer for the same address until half its + // lifetime is over - with the address in no set all that time. So + // the batch goes out once more with the next write, and what fails + // a second time is forgotten: the next answer or poll cycle brings + // it back as new. + case 'ok': + for (let w in due) { + if (w.retried) + st.cache.forget(w.set, w.addr); + else { + w.retried = true; + st.pending[`${w.set}/${w.addr}`] = w; + } + } + break; + } let want = cost * WRITE_FACTOR; @@ -646,11 +690,12 @@ function run() { mkdir(FILES_DIR, 0o755); - let cache = dedupe_create(state.cfg.global.entry_ttl); + let cache = dedupe_create(state.cfg.global.entry_ttl, + state.cfg.global.learn_limit); let targets = poll_names(state.cfg.policies); let stats = { started: time(), resolv: false, snoop: [], - matched: 0, drops: {}, reapplied: 0, + matched: 0, drops: {}, reapplied: 0, capped: {}, files: { count: state.files.files, entries: state.files.entries, addresses: state.files.addresses, refreshed: null } }; @@ -669,7 +714,7 @@ function run() { let wq = { state, cache, stats, pending: {}, lost: false, interval: WRITE_MIN, timer: null, fresh_at: 0, fresh_wait: false, - later: null }; + later: null, full_warned: {} }; // The deferred run of drain_fresh(). The timer is kept and re-armed, not // created per run, so nothing depends on how a given ucode release keeps @@ -770,6 +815,7 @@ function run() { poll_cycle(); cache.prune(mono()); + wq.full_warned = {}; } wq.timer = uloop.interval(WRITE_MIN * 1000, () => drain_writes(wq)); @@ -824,8 +870,20 @@ function run() { apply_dead(state, wq, false); r = route_compile(resolved, state.nft.marks, route_opts(state)); - rt_quiet(state.route.del); - rt_quiet(r.add); + // Applied on top of what is there, not after taking it out: a + // rule that exists answers EEXIST, a route is replaced in place. + // Only what the new rendering no longer has is removed, and + // that after the new one is in - see stale() in route.uc. + let rt = rtnl(), failed = {}; + + for (let op in r.add) { + let why = rt ? rt_exec(rt, op) : 'ucode-mod-rtnl missing'; + + if (why != null && op.cmd == 'newroute') + failed[route_key(op.msg)] = true; + } + + rt_quiet(route_stale(state.route.add, r.add, failed)); state.route = r; @@ -888,6 +946,7 @@ function run() { }, reapplied: stats.reapplied, dedupe: cache.size(), + capped: stats.capped, files: stats.files, track: track_status(), active: nft_active(state.nft.chains, state.opts.dead), diff --git a/shunt/src/config.uc b/shunt/src/config.uc index 8a07e6795..f36520371 100644 --- a/shunt/src/config.uc +++ b/shunt/src/config.uc @@ -12,6 +12,7 @@ export const DEFAULTS = { enabled: true, poll_interval: 300, entry_ttl: 1200, + learn_limit: 4096, snoop: true, snoop_devices: [ 'br-lan' ], debug: false, @@ -21,7 +22,14 @@ export const DEFAULTS = { export const MIN = { poll_interval: 30, - entry_ttl: 60 + entry_ttl: 60, + learn_limit: 64 +}; + +// The limit on a learned set is a bound on memory; one without a ceiling of +// its own would not be one. +export const MAX = { + learn_limit: 65536 }; function to_bool(v, dflt) { @@ -109,6 +117,11 @@ export function parse(sections) { n, MIN[key])); n = MIN[key]; } + if (MAX[key] != null && n > MAX[key]) { + reject(section, key, sprintf('%d above maximum, clamped to %d', + n, MAX[key])); + n = MAX[key]; + } g[key] = n; } @@ -132,6 +145,7 @@ export function parse(sections) { bool_opt(s.name ?? 'global', v, 'rp_filter_manage'); num_opt(s.name ?? 'global', v, 'poll_interval'); num_opt(s.name ?? 'global', v, 'entry_ttl'); + num_opt(s.name ?? 'global', v, 'learn_limit'); if (v.snoop_device != null) { let devs = []; diff --git a/shunt/src/dedupe.uc b/shunt/src/dedupe.uc index 3017599aa..ab9a0552f 100644 --- a/shunt/src/dedupe.uc +++ b/shunt/src/dedupe.uc @@ -6,8 +6,51 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Copyright (c) 2026 Dirk Brenken -export function create(entry_ttl) { - let exp = {}; +// `limit` is how many pairs one set may have on record - the global option +// learn_limit. Nothing a network asks for should come near it; it is there +// because snoop cannot tell a DNS answer from a frame made to look like one, +// and without a bound a host on an observed segment could grow the sets, and +// this table with them, until the router is out of memory. Counted per set, +// so that filling one leaves every other policy, and the other address +// family of the same one, learning as before. +// +// The kernel keeps an element a little past the lifetime on record - the +// slack the daemon puts on every timeout, see written(). A lapsed pair keeps +// its place in the count until the kernel has dropped it too: freed at the +// lifetime, the place would go to a new address while the kernel still +// holds the old one, and a set under a steady flood would hold up to twice +// the limit. +export function create(entry_ttl, limit) { + let exp = {}, gone = {}, count = {}, total = 0, pruned = null; + + // The key is `set/addr`; neither part can contain a slash. + function set_of(k) { + return substr(k, 0, index(k, '/')); + } + + function drop(k) { + let set = set_of(k); + + delete exp[k]; + delete gone[k]; + total--; + + if (--count[set] <= 0) + delete count[set]; + } + + function prune(now) { + let n = 0; + + for (let k in exp) { + if (now >= (gone[k] ?? exp[k])) { + drop(k); + n++; + } + } + + return n; + } // A write is due when it moves the expiry by at least half of the // lifetime it carries, in either direction: an answer with a long TTL @@ -17,7 +60,10 @@ export function create(entry_ttl) { // the pair is unknown or already past its expiry ('new' - prune runs // only once per poll cycle, so a lapsed pair can still be on record) or // only moves an expiry that is still running ('move'); the caller - // writes the first kind without waiting. + // writes the first kind without waiting. With its set at the limit an + // unknown pair is 'full' and not recorded; the lapsed pairs are cleared + // out first, at most once a second, since prune otherwise runs once per + // poll cycle. function due(set, addr, now, ttl) { let k = `${set}/${addr}`; let t = ttl ?? entry_ttl; @@ -28,32 +74,52 @@ export function create(entry_ttl) { if (d * 2 < t) return false; + if (cur == null) { + if (limit && count[set] >= limit && pruned !== now) { + pruned = now; + prune(now); + } + + if (limit && count[set] >= limit) + return 'full'; + + count[set] = (count[set] ?? 0) + 1; + total++; + } + exp[k] = e; return (cur == null || cur <= now) ? 'new' : 'move'; } - function prune(now) { - let n = 0; + // A pair the kernel took, with the slack its timeout got on top of the + // lifetime: it is in the kernel until then, and counted until then. + function written(set, addr, slack) { + let k = `${set}/${addr}`; - for (let k in exp) { - if (now >= exp[k]) { - delete exp[k]; - n++; - } - } + if (exp[k] != null) + gone[k] = exp[k] + slack; + } - return n; + // A pair whose write the kernel did not take: off the record, so the + // next answer for it is due again. + function forget(set, addr) { + let k = `${set}/${addr}`; + + if (exp[k] != null) + drop(k); } // Dropped wholesale when the table had to be re-created: the kernel has // no elements any more, so every pair is due again regardless of age. function reset() { exp = {}; + gone = {}; + count = {}; + total = 0; } // Every pair still running, with its absolute expiry - what the kernel - // holds, as far as shunt wrote it. The key is `set/addr`; neither part - // can contain a slash. + // holds, as far as shunt wrote it. function dump(now) { let out = []; @@ -69,8 +135,8 @@ export function create(entry_ttl) { } function size() { - return length(keys(exp)); + return total; } - return { due, prune, reset, dump, size }; + return { due, prune, written, forget, reset, dump, size }; }; diff --git a/shunt/src/route.uc b/shunt/src/route.uc index 233e0344d..6b34c0207 100644 --- a/shunt/src/route.uc +++ b/shunt/src/route.uc @@ -57,12 +57,38 @@ function default_route(fam, table, iface, gw) { return route; } +// Whether a name can stand for one device under /proc/sys/net/ipv4/conf: an +// interface name and nothing else - no path that leads out of that directory, +// and not `all` or `default`, which are entries there but not devices, so +// that a policy naming one of them never changes the setting of every +// interface. +export function conf_dev(dev) { + return type(dev) == 'string' && match(dev, RE_IFACE) != null && + dev != 'all' && dev != 'default'; +}; + +// A rule is deleted by its priority and its mark, never by the priority +// alone: the kernel takes every attribute left out as a wildcard, and a rule +// of somebody else's that happens to sit on one of shunt's priorities is not +// shunt's to remove. Priority and mark are both derived from the policy's +// index, so every rule a released version ever added still matches. +function rule_del(family, priority, fwmark, fwmask) { + return { cmd: 'delrule', msg: { family, priority, fwmark, fwmask } }; +} + +// What tells one route of a policy table from another: the kernel replaces a +// route of the same destination and metric in place, and every route shunt +// renders is a default route. +export function route_key(msg) { + return sprintf('%d/%d/%d', msg?.family, msg?.table, msg?.priority ?? 0); +}; + export function compile(policies, marks, opts) { let mask = opts?.mask ?? DEFAULTS.mask; // Without the IPv6 family the kernel refuses every IPv6 operation, so // none is rendered; the nft side loads either way. let fams = (opts?.ipv6 === false) ? [ '4' ] : [ '4', '6' ]; - let add = [], del = [], tables = [], issues = []; + let add = [], del = [], issues = []; // A tracked device gets a probe table with the default route of its first // policy, and a rule that leads the device's probe mark there. It is @@ -102,8 +128,6 @@ export function compile(policies, marks, opts) { if (to_bool(p.keep_local, false) === true) reject(p.name, p.keep_local, 'keep_local does not apply to tproxy, ignored'); - push(tables, sprintf('%d\tshunt_%s', m.rt_table, m.name)); - for (let fam in fams) { let family = AF[fam]; @@ -117,8 +141,8 @@ export function compile(policies, marks, opts) { fwmark: m.mark, fwmask: mask, table: m.rt_table } }); unshift(del, { cmd: 'flush', msg: { family, table: m.rt_table } }); - unshift(del, { cmd: 'delrule', msg: { family, priority: m.rt_prio } }); - unshift(del, { cmd: 'delrule', msg: { family, priority: m.rt_prio_local } }); + unshift(del, rule_del(family, m.rt_prio, m.mark, mask)); + unshift(del, rule_del(family, m.rt_prio_local, m.mark, mask)); } continue; } @@ -191,15 +215,10 @@ export function compile(policies, marks, opts) { let mgw = many ? { '4': mb.gw4, '6': mb.gw6 } : gw; let table = m.rt_table; - push(tables, many - ? sprintf('%d\tshunt_%s_%s', table, m.name, name) - : sprintf('%d\tshunt_%s', table, m.name)); - let pr = probe_of[name]; if (pr && !probed[name]) { probed[name] = true; - push(tables, sprintf('%d\tshunt_track_%s', pr.rt_table, name)); for (let fam in fams) { push(add, { cmd: 'newroute', @@ -209,7 +228,7 @@ export function compile(policies, marks, opts) { fwmark: pr.mark, fwmask: mask, table: pr.rt_table } }); unshift(del, { cmd: 'flush', msg: { family: AF[fam], table: pr.rt_table } }); - unshift(del, { cmd: 'delrule', msg: { family: AF[fam], priority: pr.rt_prio } }); + unshift(del, rule_del(AF[fam], pr.rt_prio, pr.mark, mask)); } } @@ -248,10 +267,10 @@ export function compile(policies, marks, opts) { fwmark: m.mark, fwmask: mask, table } }); unshift(del, { cmd: 'flush', msg: { family, table } }); - unshift(del, { cmd: 'delrule', msg: { family, priority: m.rt_prio } }); + unshift(del, rule_del(family, m.rt_prio, m.mark, mask)); // Deleted whether or not it is rendered now: keep_local may // have been on when the running ruleset was applied. - unshift(del, { cmd: 'delrule', msg: { family, priority: m.rt_prio_local } }); + unshift(del, rule_del(family, m.rt_prio_local, m.mark, mask)); } } } @@ -259,7 +278,6 @@ export function compile(policies, marks, opts) { return { add, del, - rt_tables: length(tables) ? join('\n', tables) + '\n' : '', issues }; }; @@ -279,3 +297,37 @@ export function state_ops(policies, marks, opts, name) { return ops; }; + +// What is left to remove once a new rendering has been applied on top of +// the old one. The new routes replace the old ones in place and the rules are +// the same ones again, so nothing has to go first - and nothing may: with the +// rules or a default route gone, even for the moment it takes to put them +// back, marked traffic takes the main table, a blocking policy leaks, and +// masquerade resets every running flow whose packet falls into that gap. +// Left over is only what the new rendering no longer has - the default route +// of an interface that is dead now - and an old route whose replacement the +// kernel refused: a wrong route is not better than the fallback. +export function stale(old_ops, new_ops, failed) { + let routes = {}, rules = {}, ops = []; + + for (let op in (new_ops ?? [])) { + if (op?.cmd == 'newroute') + routes[route_key(op.msg)] = sprintf('%J', op.msg); + else if (op?.cmd == 'newrule') + rules[sprintf('%J', op.msg)] = true; + } + + for (let op in (old_ops ?? [])) { + if (op?.cmd == 'newroute') { + let k = route_key(op.msg); + + if (routes[k] == null || + (failed?.[k] && routes[k] != sprintf('%J', op.msg))) + push(ops, { cmd: 'delroute', msg: op.msg }); + } + else if (op?.cmd == 'newrule' && !rules[sprintf('%J', op.msg)]) + push(ops, { cmd: 'delrule', msg: op.msg }); + } + + return ops; +};