feat(network): use Tailnet identities and guarded host firewall
This commit is contained in:
49
tailscale.sh
49
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
|
||||
|
||||
Reference in New Issue
Block a user