diff --git a/README.md b/README.md index 9ed6fa9..f0f68a7 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,15 @@ Docker、Headscale、公钥注入和安全配置后续由 Komodo 任务处理。 `tailscale.sh` 使用显式版本、已准备的本地 DEB、SHA-256、HTTPS 控制端和节点名称。首次接入还需 root 所有、0600 权限的短期单次授权 key 文件;执行后清除临时 key 文件。既有 Running 节点直接复核身份,不需要新 key。 -Headscale 0.29.6 从预授权 key 授予节点标签,拒绝预授权注册请求同时携带客户端 `--advertise-tags`。在控制端创建 key 时指定授权标签,`tailscale.sh --tags tag:NAME,...` 核对接入后实际获得的完整标签集合。控制端、名称或标签不一致时停止,不重置节点身份。服务 /32 地址、精确路由批准和业务访问另外验证。 +Headscale 0.29.6 从预授权 key 授予节点标签,拒绝预授权注册请求同时携带客户端 `--advertise-tags`。在控制端创建 key 时指定授权标签,`tailscale.sh --tags tag:ENV-ROLE,...` 必须核对接入后实际获得的完整标签集合。控制端、名称或标签不一致时停止,不重置节点身份。 + +跨主机服务直接使用 Headscale 分配的 `100.64.0.0/10`。脚本禁用接收子网路由及 Tailscale SSH;发现已有路由发布或出口节点选择时停止,不隐式撤销。公开网络回执保存到 `/var/lib/platform-bootstrap/tailnet.json`,包含节点 ID、公开 node key、IPv4、MagicDNS 与标签,先由控制端和 Headscale 核对,再填写集群端点并签发 TLS。重跑允许 node key 原生轮换,地址/ID/名称漂移要求单独维护。 + +## host 网络与防火墙 + +所有运行服务的 Docker 模板使用 host 网络,业务服务监听 `0.0.0.0`,由 Debian nftables 控制输入端口。`host-init.sh` 安装 nftables,不自动开放端口或替换现有防火墙。通过固定 release 的 `firewall.sh --check|--apply --policy /run/platform-secrets/firewall.json` 应用已登记的主机策略;文件必须为 root 0600,策略包含准确主机名、恢复 SSH 来源、公网端口和 Tailnet 来源/端口。 + +规则仅管理 `inet platform_host`,INPUT 默认拒绝、同时校验 `tailscale0` 与精确源 IPv4;不清空全局规则。`--apply` 安排 180 秒自动回退,从新的 SSH transport 验证后执行 `--confirm --sha256 ` 才持久化,并建立 Docker/Tailscale 的防火墙启动依赖。通过 sudo 确认时保留 `SSH_CONNECTION` 环境变量。详细 JSON 格式、端口矩阵、Headscale 标签关系与现有集群迁移约束在 platform 的 `docs/network.md`。 ## 独立手工升级 @@ -56,7 +64,7 @@ resume/verify 可使用同一固定版本 URL 再次下载执行,或保存该 ## 发布 Caddy 同时提供 /install.sh 稳定入口与 /releases/<40位小写Git提交>/install.sh 固定版本入口;生产接入命令固定使用后者。 -发布脚本只提取 `install.sh`、`upgrade-debian12-13.sh`、`host-init.sh`、`tailscale.sh`、README、artifacts.lock 及清单列出的 vendor 文件,生成 VERSION、SHA256SUMS;不暴露 .git 或工作区其他文件。 +发布脚本只提取 `install.sh`、`upgrade-debian12-13.sh`、`host-init.sh`、`tailscale.sh`、`firewall.sh`、README、artifacts.lock 及清单列出的 vendor 文件,生成 VERSION、SHA256SUMS;不暴露 .git 或工作区其他文件。 源代码与修复就在此仓库维护,无生成器、内嵌 Python 或第二份安装源码。 ## 固定资源 diff --git a/firewall.sh b/firewall.sh new file mode 100644 index 0000000..7d70c31 --- /dev/null +++ b/firewall.sh @@ -0,0 +1,224 @@ +#!/usr/bin/env bash +# Own one nftables table. Never flush Docker, Tailscale or the host ruleset. +set -euo pipefail +export LC_ALL=C +fail() { printf 'ERROR: %s\n' "$*" >&2; return 1; } +validate_policy() { + jq -e ' + def ip: + type == "string" and test("^(0|[1-9][0-9]{0,2})(\\.(0|[1-9][0-9]{0,2})){3}$") and + (split(".") | all(.[]; tonumber <= 255)); + def tailip: ip and (split(".") | map(tonumber) | .[0] == 100 and .[1] >= 64 and .[1] <= 127); + def ports: type == "array" and all(.[]; type == "number" and . == floor and . >= 1 and . <= 65535); + (keys == ["hostname","management","public_tcp","public_udp","tailnet","version"]) and .version == 1 and + (.hostname | type == "string" and length <= 63 and test("^(dev|test|stage|prod)-[a-z][a-z0-9]{0,15}-[a-z][a-z0-9]{0,15}(-[a-z][a-z0-9]{0,15})?-(0[1-9]|[1-9][0-9])$")) and + (.public_tcp | ports) and (.public_udp | ports) and + (.management | keys == ["port","sources"]) and ([.management.port] | ports) and + (.management.sources | type == "array" and all(.[]; ip and (tailip | not))) and + (.tailnet | type == "array" and all(.[]; + keys == ["sources","tcp","udp"] and (.sources | type == "array" and length > 0 and all(.[]; tailip)) and + (.tcp | ports) and (.udp | ports) and ((.tcp + .udp) | length > 0))) and + ((.management.sources | length > 0) or + ( .management.port as $ssh | any(.tailnet[]; .tcp | index($ssh) != null))) and + (.management.port as $ssh | (.public_tcp | index($ssh) == null)) + ' "$1" >/dev/null || { fail 'Invalid firewall policy: exact IPv4 sources, ports, hostname and an explicit SSH path required'; return 1; } +} +render_policy() { + local policy=$1 ports row sources protocol + validate_policy "$policy" || return 1 + cat <<'NFT' +add table inet platform_host +delete table inet platform_host +table inet platform_host { + chain input { + type filter hook input priority 10; policy drop; + iifname "lo" accept + iifname != "tailscale0" ip saddr 100.64.0.0/10 drop + ct state invalid drop + ct state established,related accept + meta l4proto icmp accept + meta l4proto ipv6-icmp accept + udp sport 67 udp dport 68 accept + ip6 saddr fe80::/10 udp sport 547 udp dport 546 accept +NFT + sources=$(jq -r '.management.sources | join(", ")' "$policy") + if [[ -n "$sources" ]]; then + printf ' iifname != "tailscale0" ip saddr { %s } tcp dport %s accept\n' "$sources" "$(jq -r .management.port "$policy")" + fi + for row in tcp udp; do + ports=$(jq -r --arg field "public_$row" '.[$field] | unique | map(tostring) | join(", ")' "$policy") + if [[ -n "$ports" ]]; then printf ' iifname != "tailscale0" %s dport { %s } accept\n' "$row" "$ports"; fi + done + # Match the decrypted interface AND Headscale-assigned source; no LAN spoofing. + while IFS= read -r row; do + sources=$(jq -r '.sources | unique | join(", ")' <<< "$row") + for protocol in tcp udp; do + ports=$(jq -r --arg protocol "$protocol" '.[$protocol] | unique | map(tostring) | join(", ")' <<< "$row") + if [[ -n "$ports" ]]; then printf ' iifname "tailscale0" ip saddr { %s } %s dport { %s } accept\n' "$sources" "$protocol" "$ports"; fi + done + done < <(jq -c '.tailnet[]' "$policy") + cat <<'NFT' + } + chain forward { + type filter hook forward priority 10; policy accept; + iifname "tailscale0" drop + oifname "tailscale0" drop + } +} +NFT +} +trusted_path() { + local path=$1 + [[ "$path" == /* && "$path" != *'/../'* && "$path" != *'/./'* ]] || { fail 'Absolute trusted path required'; return 1; } + while [[ "$path" != / ]]; do + [[ ! -L "$path" ]] || { fail 'Symlink in firewall path'; return 1; } + if [[ -e "$path" ]]; then + [[ $(stat -c %u "$path") == 0 ]] && (( (8#$(stat -c %a "$path") & 0022) == 0 )) || { fail 'Firewall path must be root owned without group/other write permission'; return 1; } + fi + path=$(dirname -- "$path") + done +} +save_boot_gate() { + local state=$1 file + install -d -m 0700 "$state/boot" + for file in platform-firewall.service docker.service.d/20-platform-firewall.conf tailscaled.service.d/20-platform-firewall.conf; do + trusted_path "/etc/systemd/system/$file" + install -d -m 0700 "$state/boot/$(dirname -- "$file")" + if [[ -f "/etc/systemd/system/$file" ]]; then + cp -p -- "/etc/systemd/system/$file" "$state/boot/$file" + else rm -f -- "$state/boot/$file"; fi + done + rm -f -- "$state/boot/changed" "$state/boot/enabled" "$state/boot/active" + if systemctl is-enabled --quiet platform-firewall.service; then touch "$state/boot/enabled"; fi + if systemctl is-active --quiet platform-firewall.service; then touch "$state/boot/active"; fi +} +install_boot_gate() { + local directory + for directory in /etc/platform-firewall /etc/systemd/system/docker.service.d /etc/systemd/system/tailscaled.service.d; do + trusted_path "$directory"; install -d -m 0755 "$directory" + done + trusted_path /etc/systemd/system/platform-firewall.service + cat > /etc/systemd/system/platform-firewall.service <<'UNIT' +[Unit] +Description=Platform host service firewall +After=nftables.service +Before=docker.service tailscaled.service +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/usr/sbin/nft -f /etc/platform-firewall/active.nft +ExecReload=/usr/sbin/nft -f /etc/platform-firewall/active.nft +[Install] +WantedBy=multi-user.target +UNIT + for directory in docker tailscaled; do + trusted_path "/etc/systemd/system/$directory.service.d/20-platform-firewall.conf" + cat > "/etc/systemd/system/$directory.service.d/20-platform-firewall.conf" <<'UNIT' +[Unit] +Requires=platform-firewall.service +After=platform-firewall.service +UNIT + done + systemctl daemon-reload + systemctl enable platform-firewall.service >/dev/null + systemctl restart platform-firewall.service +} +main() { + local action='' policy='' expected='' state=/run/platform-firewall task digest previous connection + while (($#)); do + case "$1" in + --help|-h) echo 'Usage: bash firewall.sh --render|--check|--apply --policy ROOT_0600_JSON | --confirm --sha256 SHA'; return 0;; + --render|--check|--apply|--confirm) [[ -z "$action" ]] || return 1; action=$1; shift;; + --policy|--sha256) (($# >= 2)) || return 1; case "$1" in --policy) policy=$2;; --sha256) expected=$2;; esac; shift 2;; + *) fail "Unknown argument: $1"; return 1;; + esac + done + if [[ "$action" == --render ]]; then render_policy "$policy"; return; fi + umask 077 + [[ $EUID == 0 && -d /run/systemd/system ]] || { fail 'Native root systemd host required'; return 1; } + for task in nft jq sha256sum flock systemctl systemd-run; do command -v "$task" >/dev/null || { fail "Missing command: $task"; return 1; }; done + trusted_path "$state"; install -d -m 0700 "$state" + trusted_path "$state/lock" + exec 9>>"$state/lock"; flock -n 9 || { fail 'Firewall operation in progress'; return 1; } + if [[ "$action" == --confirm ]]; then + [[ "$expected" =~ ^[a-f0-9]{64}$ && -f "$state/pending.nft" ]] || { fail 'Exact pending policy digest required'; return 1; } + digest=$(sha256sum "$state/pending.nft"); digest=${digest%% *} + [[ "$expected" == "$digest" ]] || { fail 'Pending policy differs'; return 1; } + connection=${SSH_CONNECTION:-} + [[ -n "$connection" && "$connection" != "$(cat "$state/original-ssh")" ]] || { fail 'Confirm from a new SSH transport after checking allowed and denied ports'; return 1; } + nft -s list table inet platform_host > "$state/current.nft" + cmp -s "$state/current.nft" "$state/applied.nft" || { fail 'Active policy changed after apply'; return 1; } + trusted_path /etc/platform-firewall/active.nft + trusted_path /etc/platform-firewall/active.nft.pending + install -d -m 0755 /etc/platform-firewall + install -m 0600 "$state/pending.nft" /etc/platform-firewall/active.nft.pending + mv -fT /etc/platform-firewall/active.nft.pending /etc/platform-firewall/active.nft + touch "$state/boot/changed" + install_boot_gate + rm -f -- "$state/pending.nft" + systemctl stop platform-firewall-rollback.timer + echo 'Firewall confirmed and persisted; Docker/Tailscale startup now requires it.' + return + fi + [[ "$action" == --check || "$action" == --apply ]] || { fail 'Choose render, check, apply or confirm'; return 1; } + trusted_path "$policy" + [[ -f "$policy" && $(stat -c %a "$policy") == 600 ]] || { fail 'Use a root-owned 0600 JSON policy file'; return 1; } + validate_policy "$policy" + [[ $(jq -r .hostname "$policy") == "$(hostname)" ]] || { fail 'Policy belongs to another host'; return 1; } + task=$(mktemp -d "$state/check-XXXXXXXX") + local cleanup + printf -v cleanup 'rm -rf -- %q' "$task"; trap "$cleanup" EXIT + render_policy "$policy" > "$task/rules.nft" + nft --check --file "$task/rules.nft" + digest=$(sha256sum "$task/rules.nft"); digest=${digest%% *} + if [[ "$action" == --check ]]; then echo "Firewall policy checked: $digest"; return; fi + [[ ! -f "$state/pending.nft" ]] || { fail 'A policy is pending confirmation; inspect it or wait for rollback'; return 1; } + # Rollback only our table, preserving concurrent native Tailscale/Docker rules. + printf 'add table inet platform_host\ndelete table inet platform_host\n' > "$task/rollback.nft" + if nft list table inet platform_host >/dev/null 2>&1; then nft -s list table inet platform_host >> "$task/rollback.nft"; fi + install -m 0600 "$task/rollback.nft" "$state/rollback.nft" + # Save the durable version too, in case confirmation fails midway. + trusted_path /etc/platform-firewall/active.nft + if [[ -f /etc/platform-firewall/active.nft ]]; then + install -m 0600 /etc/platform-firewall/active.nft "$state/previous.nft" + else rm -f -- "$state/previous.nft"; fi + save_boot_gate "$state" + cat > "$state/rollback.sh" <<'ROLLBACK' +#!/usr/bin/env bash +set -euo pipefail +exec 9>>/run/platform-firewall/lock +flock 9 +test -f /run/platform-firewall/pending.nft || exit 0 +if test -f /run/platform-firewall/previous.nft; then + install -m 0600 /run/platform-firewall/previous.nft /etc/platform-firewall/active.nft +else + rm -f /etc/platform-firewall/active.nft +fi +if test -f /run/platform-firewall/boot/changed; then + if ! test -f /run/platform-firewall/boot/active; then systemctl stop platform-firewall.service; fi + if ! test -f /run/platform-firewall/boot/enabled; then systemctl disable platform-firewall.service; fi + for file in platform-firewall.service docker.service.d/20-platform-firewall.conf tailscaled.service.d/20-platform-firewall.conf; do + if test -f "/run/platform-firewall/boot/$file"; then + cp -p -- "/run/platform-firewall/boot/$file" "/etc/systemd/system/$file" + else rm -f -- "/etc/systemd/system/$file"; fi + done + systemctl daemon-reload + if test -f /run/platform-firewall/boot/enabled; then systemctl enable platform-firewall.service; fi + if test -f /run/platform-firewall/boot/active; then systemctl start platform-firewall.service; fi +fi +/usr/sbin/nft -f /run/platform-firewall/rollback.nft +rm -f /run/platform-firewall/pending.nft +ROLLBACK + chmod 0700 "$state/rollback.sh" + install -m 0600 "$task/rules.nft" "$state/pending.nft" + printf '%s\n' "${SSH_CONNECTION:-}" > "$state/original-ssh" + systemctl stop platform-firewall-rollback.timer platform-firewall-rollback.service 2>/dev/null || true + systemctl reset-failed platform-firewall-rollback.service 2>/dev/null || true + if ! systemd-run --quiet --collect --unit=platform-firewall-rollback --on-active=180s /bin/bash "$state/rollback.sh"; then + rm -f -- "$state/pending.nft"; fail 'Rollback timer could not be armed; no rules changed'; return 1 + fi + nft --file "$state/pending.nft" + nft -s list table inet platform_host > "$state/applied.nft" + printf 'Policy applied temporarily. Confirm from a NEW SSH connection within 180 seconds: --confirm --sha256 %s\n' "$digest" +} +if [[ ${BASH_SOURCE[0]:-} == "$0" || -z ${BASH_SOURCE[0]:-} ]]; then main "$@"; fi diff --git a/host-init.sh b/host-init.sh index e35e037..f2c216e 100644 --- a/host-init.sh +++ b/host-init.sh @@ -5,7 +5,7 @@ export LC_ALL=C fail() { printf 'ERROR: %s\n' "$*" >&2; return 1; } ensure_baseline_packages() { local package missing=() - for package in ca-certificates curl jq git openssl openssh-client iproute2; do + for package in ca-certificates curl jq git openssl openssh-client iproute2 nftables; do if [[ $(dpkg-query -W -f='${db:Status-Status}' "$package" 2>/dev/null) != installed ]]; then missing+=("$package") fi diff --git a/tailscale.sh b/tailscale.sh index 092e18e..c2d3c42 100644 --- a/tailscale.sh +++ b/tailscale.sh @@ -26,11 +26,40 @@ validate_tags() { # after the first letter, with no DNS length or final-character limit. [[ -z "$1" || "$1" =~ ^tag:[A-Za-z][A-Za-z0-9-]*(,tag:[A-Za-z][A-Za-z0-9-]*)*$ ]] || { fail 'Invalid authorized tags'; return 1; } } +validate_tailnet_status() { + jq -e ' + def tailip: + split(".") | select(length == 4) | map(tonumber) | + .[0] == 100 and .[1] >= 64 and .[1] <= 127 and + all(.[]; . >= 0 and . <= 255); + .BackendState == "Running" and (.Self.ID | type == "string" and length > 0) and + (.Self.DNSName | type == "string" and length > 0) and + ([.Self.TailscaleIPs[] | select(contains(":") | not)] | length == 1 and all(.[]; tailip)) + ' "$1" >/dev/null || { fail 'Running Tailscale identity with one 100.64.0.0/10 IPv4 and MagicDNS name required'; return 1; } +} +write_tailnet_receipt() { + local status=$1 url=$2 name=$3 destination=$4 pending + validate_tailnet_status "$status" || return 1 + [[ ! -L "$destination" && ! -L "$(dirname -- "$destination")" ]] || { fail 'Symlink in tailnet receipt path'; return 1; } + pending=$(mktemp "${destination}.XXXXXXXX") + jq --arg url "$url" --arg name "$name" '{version: 1, control_url: $url, hostname: $name, + node_id: .Self.ID, node_public_key: .Self.PublicKey, + ipv4: (.Self.TailscaleIPs[] | select(contains(":") | not)), + dns_name: (.Self.DNSName | rtrimstr(".")), tags: ((.Self.Tags // []) | sort)}' "$status" > "$pending" + if [[ -e "$destination" ]] && ! jq -e --slurpfile candidate "$pending" ' + .control_url == $candidate[0].control_url and .hostname == $candidate[0].hostname and + .node_id == $candidate[0].node_id and .ipv4 == $candidate[0].ipv4 and + .dns_name == $candidate[0].dns_name and .tags == $candidate[0].tags' "$destination" >/dev/null; then + rm -f -- "$pending"; fail 'Tailnet identity/address changed; reconcile the existing member, never silently rebind'; return 1 + fi + chmod 0600 "$pending" + mv -fT -- "$pending" "$destination" +} main() { local version='' package='' sha='' login='' login_port='' name='' auth='' tags='' action='' while (($#)); do case "$1" in - --help|-h) echo 'Usage: bash tailscale.sh --check|--apply --version VERSION --package LOCAL_DEB --sha256 SHA --login-server HTTPS_URL --hostname NAME [--auth-key-file /run/platform-secrets/KEY] [--tags tag:NAME,...]'; return 0;; + --help|-h) echo 'Usage: bash tailscale.sh --check|--apply --version VERSION --package LOCAL_DEB --sha256 SHA --login-server HTTPS_URL --hostname NAME --tags tag:ENV-ROLE,... [--auth-key-file /run/platform-secrets/KEY]'; return 0;; --check|--apply) [[ -z "$action" ]] || return 1; action=$1; shift;; --version|--package|--sha256|--login-server|--hostname|--auth-key-file|--tags) (($# >= 2)) || return 1 @@ -52,6 +81,7 @@ main() { fail 'Explicit HTTPS Headscale URL required'; return 1 fi validate_tags "$tags" + [[ -n "$tags" ]] || { fail 'Explicit expected Headscale authorization tags required'; return 1; } [[ -f "$package" && ! -L "$package" ]] || { fail 'Prepared Tailscale DEB required'; return 1; } printf '%s %s\n' "$sha" "$package" | sha256sum -c - >/dev/null [[ $(dpkg-deb -f "$package" Package) == tailscale && $(dpkg-deb -f "$package" Version) == "$version" && $(dpkg-deb -f "$package" Architecture) == amd64 ]] || { fail 'Package identity or architecture differs'; return 1; } @@ -79,7 +109,7 @@ main() { echo 'Existing connected identity reused; enrollment key not consumed.' elif [[ "$state" == NeedsLogin ]]; then [[ -n "$auth" ]] || { fail 'Provide a short-lived single-use Headscale enrollment key'; return 1; } - local up_args=(up --login-server="$login" --hostname="$name" --accept-routes=true --auth-key="file:$auth") + local up_args=(up --login-server="$login" --hostname="$name" --accept-routes=false --ssh=false --auth-key="file:$auth") # Headscale 0.29.6 takes pre-authenticated node tags from the key. # It rejects client RequestTags for every pre-auth-key registration. if ! tailscale "${up_args[@]}" > "$task/enroll-output" 2>&1; then @@ -87,13 +117,24 @@ main() { fi else fail 'Existing backend is not ready for first enrollment; inspect it without resetting identity'; return 1; fi tailscale status --json > "$task/status.json" || { fail 'Tailscale enrollment status is unavailable'; return 1; } - jq -e '.BackendState == "Running" and (.Self.TailscaleIPs | length > 0)' "$task/status.json" >/dev/null || { fail 'Tailscale did not reach Running with an address'; return 1; } + validate_tailnet_status "$task/status.json" if [[ -n "$tags" ]]; then jq -e --arg expected "$tags" '($expected | split(",") | sort) == ((.Self.Tags // []) | sort)' "$task/status.json" >/dev/null || { fail 'Headscale authorization tags differ; inspect the pre-auth key and node without resetting identity'; return 1; } fi tailscale debug prefs > "$task/prefs.json" || { fail 'Tailscale preferences are unavailable'; return 1; } jq -e --arg url "$login" --arg name "$name" '(.ControlURL | if type == "string" then rtrimstr("/") else "" end) == $url and .Hostname == $name' "$task/prefs.json" >/dev/null || { fail 'Enrolled node belongs to another controller or name'; return 1; } - echo 'Node enrollment checked. Service /32 addressing, exact route approval and cross-host access still require separate verification.' + jq -e '((.AdvertiseRoutes // []) | length == 0) and ((.ExitNodeID // "") == "") and + ((.ExitNodeIP // "") == "")' "$task/prefs.json" >/dev/null || { + fail 'Existing advertised routes or exit node need explicit network migration; identity preserved'; return 1; + } + tailscale set --accept-routes=false --ssh=false + tailscale debug prefs > "$task/prefs.json" + jq -e '.RouteAll == false and .RunSSH == false' "$task/prefs.json" >/dev/null || { fail 'Tailnet-only preferences were not applied'; return 1; } + [[ ! -L /var/lib/platform-bootstrap ]] || { fail 'Symlink in receipt directory'; return 1; } + install -d -m 0700 /var/lib/platform-bootstrap + write_tailnet_receipt "$task/status.json" "$login" "$name" /var/lib/platform-bootstrap/tailnet.json + cat /var/lib/platform-bootstrap/tailnet.json + echo 'Tailnet identity recorded. Confirm this address with Headscale before generating cluster endpoints and TLS certificates; test firewall access separately.' rm -rf -- "$task" [[ -z "$auth" ]] || rm -f -- "$auth" trap - EXIT