feat(headscale): add staged 0.29.2 upgrade

Add 0.28.0 as the required intermediate release, migrate removed 0.29 configuration safely, preserve policy behavior, and harden the container to the upstream permission model.
This commit is contained in:
okxlin
2026-07-23 12:07:05 +08:00
parent 998c01255c
commit 2f118eb2b3
20 changed files with 976 additions and 2 deletions
+3
View File
@@ -0,0 +1,3 @@
CONTAINER_NAME="headscale"
PANEL_APP_PORT_HTTP="40183"
SUBNET="172.18.0.241"
+35
View File
@@ -0,0 +1,35 @@
additionalProperties:
formFields:
- default: 40183
edit: true
envKey: PANEL_APP_PORT_HTTP
labelEn: Port (Corresponding to internal 8080)
labelZh: 端口 (对应内部 8080)
label:
en: 'Port (Corresponding to internal 8080)'
zh: '端口 (对应内部 8080)'
zh-Hant: '埠(對應內部 8080'
ja: 'ポート(内部 8080 に対応)'
ko: '포트(내부 8080에 대응)'
ru: 'Порт (соответствует внутреннему 8080)'
ms: 'Port (sepadan dengan dalaman 8080)'
pt-br: 'Porta (correspondente à interna 8080)'
required: true
rule: paramPort
type: number
- default: 172.18.0.241
edit: true
envKey: SUBNET
labelEn: 1panel-network Subnet IP (View the docker network to obtain the CIDR block)
labelZh: 1panel-network 子网 IP (查看docker网络获取网段)
label:
en: '1panel-network Subnet IP (View the docker network to obtain the CIDR block)'
zh: '1panel-network 子网 IP (查看docker网络获取网段)'
zh-Hant: '1panel-network 子網 IP(查看 docker 網路取得網段)'
ja: '1panel-network サブネット IPdocker ネットワークで CIDR ブロックを確認)'
ko: '1panel-network 서브넷 IP(도커 네트워크에서 CIDR 블록 확인)'
ru: 'IP подсети 1panel-network (посмотрите сеть Docker, чтобы получить блок CIDR)'
ms: 'IP subnet 1panel-network (lihat rangkaian docker untuk mendapatkan blok CIDR)'
pt-br: 'IP de sub-rede 1panel-network (veja a rede docker para obter o bloco CIDR)'
required: true
type: text
@@ -0,0 +1,76 @@
---
server_url: https://myheadscale.example.com:443 # Change to your public Headscale URL.
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 0.0.0.0:9090
grpc_listen_addr: 0.0.0.0:50443
grpc_allow_insecure: false
noise:
private_key_path: /var/lib/headscale/noise_private.key
prefixes:
v4: 100.64.0.0/10
v6: fd7a:115c:a1e0::/48
allocation: sequential
derp:
server:
enabled: false
region_id: 999
region_code: headscale
region_name: Headscale Embedded DERP
verify_clients: true
stun_listen_addr: 0.0.0.0:3478
private_key_path: /var/lib/headscale/derp_server_private.key
automatically_add_embedded_derp_region: true
urls:
- https://controlplane.tailscale.com/derpmap/default
paths: []
auto_update_enabled: true
update_frequency: 24h
disable_check_updates: true
ephemeral_node_inactivity_timeout: 30m
database:
type: sqlite
sqlite:
path: /var/lib/headscale/db.sqlite
write_ahead_log: true
acme_url: https://acme-v02.api.letsencrypt.org/directory
acme_email: ""
tls_letsencrypt_hostname: ""
tls_letsencrypt_cache_dir: /var/lib/headscale/cache
tls_letsencrypt_challenge_type: HTTP-01
tls_letsencrypt_listen: ":http"
tls_cert_path: ""
tls_key_path: ""
log:
format: text
level: info
policy:
mode: file
path: ""
dns:
override_local_dns: false
nameservers:
global:
- 223.5.5.5
- 1.1.1.1
split: {}
search_domains: []
extra_records: []
magic_dns: true
base_domain: example.com
unix_socket: /var/run/headscale/headscale.sock
unix_socket_permission: "0770"
logtail:
enabled: false
randomize_client_port: true
@@ -0,0 +1,33 @@
# /etc/headscale/derp.yaml
regions:
900:
regionid: 900
regioncode: thk
regionname: Tencent Hongkong
nodes:
- name: 900a
regionid: 900
hostname: xxxx
ipv4: xxxx #非必须
stunport: 3478
stunonly: false
derpport: 40184
- name: 900b
regionid: 900
hostname: xxxx
ipv4: xxxx
stunport: 3478
stunonly: false
derpport: 12345
901:
regionid: 901
regioncode: tsh
regionname: Tencent Shanghai
nodes:
- name: 901a
regionid: 901
hostname: xxxx
ipv4: xxxx
stunport: 3478
stunonly: false
derpport: 40184
+28
View File
@@ -0,0 +1,28 @@
services:
headscale:
container_name: ${CONTAINER_NAME}
restart: always
networks:
1panel-network:
ipv4_address: ${SUBNET}
ports:
- "${PANEL_APP_PORT_HTTP}:8080"
read_only: true
tmpfs:
- /var/run/headscale
volumes:
- "./data/config:/etc/headscale:ro"
- "./data/data:/var/lib/headscale"
healthcheck:
test: ["CMD", "headscale", "health"]
interval: 10s
timeout: 5s
retries: 5
command: ['serve']
image: headscale/headscale:v0.28.0
labels:
createdBy: "Apps"
networks:
1panel-network:
external: true
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
mkdir -p ./data/config ./data/data
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
docker compose down --remove-orphans
+372
View File
@@ -0,0 +1,372 @@
#!/usr/bin/env bash
set -euo pipefail
CONFIG_FILE="${CONFIG_FILE:-./data/config/config.yaml}"
clean_value() {
local value="$1"
value="${value%% #*}"
value="${value%\"}"
value="${value#\"}"
value="${value%\'}"
value="${value#\'}"
printf '%s\n' "$value"
}
yaml_quote() {
local value="$1"
value="${value//\\/\\\\}"
value="${value//\"/\\\"}"
printf '"%s"' "$value"
}
scalar_any() {
local key="$1"
local default="$2"
local value
value="$(sed -n -E "s/^[[:space:]]*${key}:[[:space:]]*//p" "$CONFIG_FILE" | head -n 1 || true)"
if [[ -n "$value" ]]; then
clean_value "$value"
else
printf '%s\n' "$default"
fi
}
section_scalar() {
local section="$1"
local key="$2"
local default="$3"
local value
value="$(awk -v section="$section" -v key="$key" '
/^[[:space:]]*#/ { next }
/^[^[:space:]][^:]*:/ {
in_section = ($1 == section ":")
next
}
in_section && $0 ~ "^[[:space:]]+" key ":[[:space:]]*" {
sub("^[[:space:]]+" key ":[[:space:]]*", "")
print
exit
}
' "$CONFIG_FILE" || true)"
if [[ -n "$value" ]]; then
clean_value "$value"
else
printf '%s\n' "$default"
fi
}
first_prefix() {
local family="$1"
local default="$2"
local value
value="$(section_scalar prefixes "$family" "")"
if [[ -z "$value" && "$family" == "v4" ]]; then
value="$(awk '
/^[[:space:]]*#/ { next }
/^[^[:space:]][^:]*:/ {
in_prefixes = ($1 == "ip_prefixes:")
next
}
in_prefixes && /^[[:space:]]*-[[:space:]]*[0-9]+\./ {
sub(/^[[:space:]]*-[[:space:]]*/, "")
print
exit
}
' "$CONFIG_FILE" || true)"
elif [[ -z "$value" ]]; then
value="$(awk '
/^[[:space:]]*#/ { next }
/^[^[:space:]][^:]*:/ {
in_prefixes = ($1 == "ip_prefixes:")
next
}
in_prefixes && /^[[:space:]]*-[[:space:]]*[0-9A-Fa-f:]+\/[0-9]+/ && $0 !~ /\./ {
sub(/^[[:space:]]*-[[:space:]]*/, "")
print
exit
}
' "$CONFIG_FILE" || true)"
fi
if [[ -n "$value" ]]; then
clean_value "$value"
else
printf '%s\n' "$default"
fi
}
dns_global_lines() {
local lines
lines="$(awk '
/^[[:space:]]*#/ { next }
/^[^[:space:]][^:]*:/ {
if (in_dns && $1 != "dns_config:" && $1 != "dns:") exit
in_dns = ($1 == "dns_config:" || $1 == "dns:")
in_nameservers = 0
in_global = 0
next
}
in_dns && /^[[:space:]]+nameservers:[[:space:]]*(#.*)?$/ {
in_nameservers = 1
next
}
in_nameservers && /^[[:space:]]+[a-zA-Z_]+:[[:space:]]*/ && $1 != "global:" {
exit
}
in_nameservers && $1 == "global:" {
in_global = 1
next
}
in_nameservers && in_global && /^[[:space:]]+[a-zA-Z_]+:[[:space:]]*/ {
exit
}
in_nameservers && /^[[:space:]]*-[[:space:]]*/ {
sub(/^[[:space:]]*-[[:space:]]*/, "")
sub(/[[:space:]]+#.*$/, "")
gsub(/^["'\''"]|["'\''"]$/, "")
print " - " $0
}
' "$CONFIG_FILE" || true)"
if [[ -n "$lines" ]]; then
printf '%s\n' "$lines"
else
printf '%s\n' " - 223.5.5.5" " - 1.1.1.1"
fi
}
dns_child_block() {
local old_key="$1"
local new_key="$2"
local extra_indent="$3"
local default="$4"
local block
block="$(awk -v old_key="$old_key" -v new_key="$new_key" -v extra_indent="$extra_indent" '
/^[[:space:]]*#/ {
if (found) {
print extra_indent $0
}
next
}
/^[^[:space:]][^:]*:/ {
if (found) {
exit
}
in_dns = ($1 == "dns_config:" || $1 == "dns:")
next
}
in_dns && !found && $0 ~ "^[[:space:]][[:space:]]" old_key ":[[:space:]]*" {
found = 1
sub("^[[:space:]][[:space:]]" old_key ":", " " new_key ":")
print extra_indent $0
next
}
found {
if ($0 ~ "^[[:space:]][[:space:]][A-Za-z_][A-Za-z0-9_]*:[[:space:]]*" && $0 !~ "^[[:space:]][[:space:]][[:space:]][[:space:]]") {
exit
}
print extra_indent $0
}
' "$CONFIG_FILE" || true)"
if [[ -n "$block" ]]; then
printf '%s\n' "$block"
else
printf '%s\n' "$default"
fi
}
app_root_from_config() {
local config_dir
local data_dir
config_dir="$(dirname "$CONFIG_FILE")"
data_dir="$(dirname "$config_dir")"
if [[ "$(basename "$config_dir")" == "config" && "$(basename "$data_dir")" == "data" ]]; then
dirname "$data_dir"
else
printf '.\n'
fi
}
sqlite_host_path() {
local container_path="$1"
case "$container_path" in
/var/lib/headscale/*)
printf '%s/data/data/%s\n' "$APP_ROOT" "${container_path#/var/lib/headscale/}"
;;
/*)
printf '%s\n' "$container_path"
;;
*)
printf '%s/%s\n' "$APP_ROOT" "$container_path"
;;
esac
}
backup_sqlite_database() {
local db_type="$1"
local db_path="$2"
local normalized_type="${db_type,,}"
if [[ "$normalized_type" == "postgres" || "$normalized_type" == "postgresql" ]]; then
return 0
fi
local host_path
host_path="$(sqlite_host_path "$db_path")"
if [[ ! -f "$host_path" ]]; then
echo "SQLite database ${host_path} not found; skipped database backup"
return 0
fi
local backup_dir
backup_dir="$(dirname "$host_path")/backup-$(date +%Y%m%d%H%M%S)"
mkdir -p "$backup_dir"
local copied=0
local candidate
for candidate in "$host_path" "$host_path-wal" "$host_path-shm"; do
if [[ -f "$candidate" ]]; then
cp "$candidate" "$backup_dir/"
copied=1
fi
done
if [[ "$copied" -eq 1 ]]; then
echo "Backed up Headscale SQLite database files to ${backup_dir}"
else
rmdir "$backup_dir" 2>/dev/null || true
fi
}
if [[ ! -f "$CONFIG_FILE" ]]; then
echo "$CONFIG_FILE not found; skipped Headscale config migration"
exit 0
fi
if ! grep -qE '^(acl_policy_path|dns_config:|ip_prefixes:|db_type:|db_path:|db_host:|db_port:|db_name:|db_user:|db_pass:|db_ssl:|private_key_path:|node_update_check_interval:)' "$CONFIG_FILE"; then
echo "Headscale config already uses the 0.27+ layout; skipped migration"
exit 0
fi
backup="${CONFIG_FILE}.bak-$(date +%Y%m%d%H%M%S)"
cp "$CONFIG_FILE" "$backup"
APP_ROOT="$(app_root_from_config)"
prefix_v4="$(first_prefix v4 "100.64.0.0/10")"
prefix_v6="$(first_prefix v6 "fd7a:115c:a1e0::/48")"
db_type="$(scalar_any db_type "$(section_scalar database type "sqlite")")"
db_path="$(scalar_any db_path "$(section_scalar sqlite path "/var/lib/headscale/db.sqlite")")"
db_host="$(scalar_any db_host "localhost")"
db_port="$(scalar_any db_port "5432")"
db_name="$(scalar_any db_name "headscale")"
db_user="$(scalar_any db_user "foo")"
db_pass="$(scalar_any db_pass "bar")"
db_ssl="$(scalar_any db_ssl "false")"
policy_path="$(scalar_any acl_policy_path "$(section_scalar policy path "")")"
dns_override="$(scalar_any override_local_dns "false")"
dns_magic="$(scalar_any magic_dns "true")"
dns_base="$(scalar_any base_domain "example.com")"
dns_nameservers="$(dns_global_lines)"
dns_split="$(dns_child_block restricted_nameservers split " " " split: {}")"
dns_search_domains="$(dns_child_block domains search_domains "" " search_domains: []")"
dns_extra_records="$(dns_child_block extra_records extra_records "" " extra_records: []")"
backup_sqlite_database "$db_type" "$db_path"
tmp="${CONFIG_FILE}.tmp-$$"
awk '
function is_top_level_key() {
return $0 ~ /^[^[:space:]#][^:]*:/
}
skip_old_block && is_top_level_key() {
skip_old_block = 0
}
!skip_old_block && $0 ~ /^(private_key_path|node_update_check_interval|db_type|db_path|db_host|db_port|db_name|db_user|db_pass|db_ssl|acl_policy_path):/ {
next
}
!skip_old_block && $0 ~ /^(ip_prefixes|dns_config):/ {
skip_old_block = 1
next
}
!skip_old_block {
print
}
' "$CONFIG_FILE" > "$tmp"
mv "$tmp" "$CONFIG_FILE"
append_prefixes() {
grep -qE '^prefixes:' "$CONFIG_FILE" && return 0
cat >> "$CONFIG_FILE" <<EOF
prefixes:
v4: ${prefix_v4}
v6: ${prefix_v6}
allocation: sequential
EOF
}
append_database() {
grep -qE '^database:' "$CONFIG_FILE" && return 0
local normalized_type="${db_type,,}"
if [[ "$normalized_type" == "postgres" || "$normalized_type" == "postgresql" ]]; then
cat >> "$CONFIG_FILE" <<EOF
database:
type: postgres
postgres:
host: $(yaml_quote "$db_host")
port: ${db_port}
name: $(yaml_quote "$db_name")
user: $(yaml_quote "$db_user")
pass: $(yaml_quote "$db_pass")
ssl: ${db_ssl}
EOF
else
cat >> "$CONFIG_FILE" <<EOF
database:
type: sqlite
sqlite:
path: $(yaml_quote "$db_path")
write_ahead_log: true
EOF
fi
}
append_policy() {
grep -qE '^policy:' "$CONFIG_FILE" && return 0
cat >> "$CONFIG_FILE" <<EOF
policy:
mode: file
path: "${policy_path}"
EOF
}
append_dns() {
grep -qE '^dns:' "$CONFIG_FILE" && return 0
cat >> "$CONFIG_FILE" <<EOF
dns:
override_local_dns: ${dns_override}
nameservers:
global:
${dns_nameservers}
${dns_split}
${dns_search_domains}
${dns_extra_records}
magic_dns: ${dns_magic}
base_domain: ${dns_base}
EOF
}
append_prefixes
append_database
append_policy
append_dns
echo "Migrated Headscale config for 0.27+ layout; backup: ${backup}"
+3
View File
@@ -0,0 +1,3 @@
CONTAINER_NAME="headscale"
PANEL_APP_PORT_HTTP="40183"
SUBNET="172.18.0.241"
+35
View File
@@ -0,0 +1,35 @@
additionalProperties:
formFields:
- default: 40183
edit: true
envKey: PANEL_APP_PORT_HTTP
labelEn: Port (Corresponding to internal 8080)
labelZh: 端口 (对应内部 8080)
label:
en: 'Port (Corresponding to internal 8080)'
zh: '端口 (对应内部 8080)'
zh-Hant: '埠(對應內部 8080'
ja: 'ポート(内部 8080 に対応)'
ko: '포트(내부 8080에 대응)'
ru: 'Порт (соответствует внутреннему 8080)'
ms: 'Port (sepadan dengan dalaman 8080)'
pt-br: 'Porta (correspondente à interna 8080)'
required: true
rule: paramPort
type: number
- default: 172.18.0.241
edit: true
envKey: SUBNET
labelEn: 1panel-network Subnet IP (View the docker network to obtain the CIDR block)
labelZh: 1panel-network 子网 IP (查看docker网络获取网段)
label:
en: '1panel-network Subnet IP (View the docker network to obtain the CIDR block)'
zh: '1panel-network 子网 IP (查看docker网络获取网段)'
zh-Hant: '1panel-network 子網 IP(查看 docker 網路取得網段)'
ja: '1panel-network サブネット IPdocker ネットワークで CIDR ブロックを確認)'
ko: '1panel-network 서브넷 IP(도커 네트워크에서 CIDR 블록 확인)'
ru: 'IP подсети 1panel-network (посмотрите сеть Docker, чтобы получить блок CIDR)'
ms: 'IP subnet 1panel-network (lihat rangkaian docker untuk mendapatkan blok CIDR)'
pt-br: 'IP de sub-rede 1panel-network (veja a rede docker para obter o bloco CIDR)'
required: true
type: text
@@ -0,0 +1,78 @@
---
server_url: https://myheadscale.example.com:443 # Change to your public Headscale URL.
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 0.0.0.0:9090
grpc_listen_addr: 0.0.0.0:50443
grpc_allow_insecure: false
noise:
private_key_path: /var/lib/headscale/noise_private.key
prefixes:
v4: 100.64.0.0/10
v6: fd7a:115c:a1e0::/48
allocation: sequential
derp:
server:
enabled: false
region_id: 999
region_code: headscale
region_name: Headscale Embedded DERP
verify_clients: true
stun_listen_addr: 0.0.0.0:3478
private_key_path: /var/lib/headscale/derp_server_private.key
automatically_add_embedded_derp_region: true
urls:
- https://controlplane.tailscale.com/derpmap/default
paths: []
auto_update_enabled: true
update_frequency: 24h
disable_check_updates: true
node:
expiry: 0
ephemeral:
inactivity_timeout: 30m
database:
type: sqlite
sqlite:
path: /var/lib/headscale/db.sqlite
write_ahead_log: true
acme_url: https://acme-v02.api.letsencrypt.org/directory
acme_email: ""
tls_letsencrypt_hostname: ""
tls_letsencrypt_cache_dir: /var/lib/headscale/cache
tls_letsencrypt_challenge_type: HTTP-01
tls_letsencrypt_listen: ":http"
tls_cert_path: ""
tls_key_path: ""
log:
format: text
level: info
policy:
mode: file
path: /etc/headscale/policy.hujson
dns:
override_local_dns: false
nameservers:
global:
- 223.5.5.5
- 1.1.1.1
split: {}
search_domains: []
extra_records: []
magic_dns: true
base_domain: example.com
unix_socket: /var/run/headscale/headscale.sock
unix_socket_permission: "0770"
logtail:
enabled: false
@@ -0,0 +1,33 @@
# /etc/headscale/derp.yaml
regions:
900:
regionid: 900
regioncode: thk
regionname: Tencent Hongkong
nodes:
- name: 900a
regionid: 900
hostname: xxxx
ipv4: xxxx #非必须
stunport: 3478
stunonly: false
derpport: 40184
- name: 900b
regionid: 900
hostname: xxxx
ipv4: xxxx
stunport: 3478
stunonly: false
derpport: 12345
901:
regionid: 901
regioncode: tsh
regionname: Tencent Shanghai
nodes:
- name: 901a
regionid: 901
hostname: xxxx
ipv4: xxxx
stunport: 3478
stunonly: false
derpport: 40184
@@ -0,0 +1,3 @@
{
"randomizeClientPort": true,
}
+28
View File
@@ -0,0 +1,28 @@
services:
headscale:
container_name: ${CONTAINER_NAME}
restart: always
networks:
1panel-network:
ipv4_address: ${SUBNET}
ports:
- "${PANEL_APP_PORT_HTTP}:8080"
read_only: true
tmpfs:
- /var/run/headscale
volumes:
- "./data/config:/etc/headscale:ro"
- "./data/data:/var/lib/headscale"
healthcheck:
test: ["CMD", "headscale", "health"]
interval: 10s
timeout: 5s
retries: 5
command: ['serve']
image: headscale/headscale:v0.29.2
labels:
createdBy: "Apps"
networks:
1panel-network:
external: true
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
mkdir -p ./data/config ./data/data
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
docker compose down --remove-orphans
+200
View File
@@ -0,0 +1,200 @@
#!/usr/bin/env bash
set -euo pipefail
CONFIG_FILE="${CONFIG_FILE:-./data/config/config.yaml}"
clean_value() {
local value="$1"
value="${value%% #*}"
value="${value%\"}"
value="${value#\"}"
value="${value%\'}"
value="${value#\'}"
printf '%s\n' "$value"
}
top_level_scalar() {
local key="$1"
local value
value="$(sed -n -E "s/^${key}:[[:space:]]*//p" "$CONFIG_FILE" | head -n 1 || true)"
clean_value "$value"
}
section_scalar() {
local section="$1"
local key="$2"
local value
value="$(awk -v section="$section" -v key="$key" '
/^[[:space:]]*#/ { next }
/^[^[:space:]][^:]*:/ {
in_section = ($1 == section ":")
next
}
in_section && $0 ~ "^[[:space:]]+" key ":[[:space:]]*" {
sub("^[[:space:]]+" key ":[[:space:]]*", "")
print
exit
}
' "$CONFIG_FILE" || true)"
clean_value "$value"
}
ensure_node_timeout() {
local source_file="$1"
local output_file="$2"
local timeout="$3"
if grep -qE '^node:[[:space:]]*($|#)' "$source_file"; then
if awk '
/^[^[:space:]#][^:]*:/ {
in_node = ($1 == "node:")
next
}
in_node && /^[[:space:]]+inactivity_timeout:[[:space:]]*/ { found = 1 }
END { exit(found ? 0 : 1) }
' "$source_file"; then
cp "$source_file" "$output_file"
return 0
fi
if awk '
/^[^[:space:]#][^:]*:/ {
in_node = ($1 == "node:")
next
}
in_node && /^ ephemeral:[[:space:]]*($|#)/ { found = 1 }
END { exit(found ? 0 : 1) }
' "$source_file"; then
awk -v timeout="$timeout" '
/^[^[:space:]#][^:]*:/ { in_node = ($1 == "node:") }
{ print }
in_node && /^ ephemeral:[[:space:]]*($|#)/ && !inserted {
print " inactivity_timeout: " timeout
inserted = 1
}
' "$source_file" > "$output_file"
return 0
fi
awk -v timeout="$timeout" '
function is_top_level(line) {
return line ~ /^[^[:space:]#][^:]*:/
}
in_node && is_top_level($0) && !inserted {
print " ephemeral:"
print " inactivity_timeout: " timeout
inserted = 1
in_node = 0
}
{ print }
$0 ~ /^node:[[:space:]]*($|#)/ { in_node = 1 }
END {
if (in_node && !inserted) {
print " ephemeral:"
print " inactivity_timeout: " timeout
}
}
' "$source_file" > "$output_file"
return 0
fi
cp "$source_file" "$output_file"
cat >> "$output_file" <<EOF
node:
expiry: 0
ephemeral:
inactivity_timeout: ${timeout}
EOF
}
if [[ ! -f "$CONFIG_FILE" ]]; then
echo "$CONFIG_FILE not found; skipped Headscale 0.29 configuration migration"
exit 0
fi
randomize_client_port="$(top_level_scalar randomize_client_port)"
ephemeral_timeout="$(top_level_scalar ephemeral_node_inactivity_timeout)"
if [[ -z "$randomize_client_port" && -z "$ephemeral_timeout" ]]; then
echo "Headscale configuration already uses the 0.29 layout; skipped migration"
exit 0
fi
policy_mode="$(section_scalar policy mode)"
policy_path="$(section_scalar policy path)"
policy_mode="${policy_mode:-file}"
config_dir="$(dirname "$CONFIG_FILE")"
policy_file="${POLICY_FILE:-${config_dir}/policy.hujson}"
set_default_policy=0
case "${randomize_client_port,,}" in
true|yes|1)
if [[ "${policy_mode,,}" != "file" ]]; then
echo "Headscale 0.29 moves randomize_client_port into the policy. Add randomizeClientPort to the database policy before upgrading." >&2
exit 1
fi
if [[ -n "$policy_path" ]]; then
echo "Headscale 0.29 moves randomize_client_port into the policy. Add randomizeClientPort to ${policy_path} before upgrading, then remove randomize_client_port from config.yaml." >&2
exit 1
fi
set_default_policy=1
;;
esac
backup="${CONFIG_FILE}.bak-$(date +%Y%m%d%H%M%S)"
if [[ -e "$backup" ]]; then
backup="${backup}-$$"
fi
cp -p "$CONFIG_FILE" "$backup"
base_tmp="${CONFIG_FILE}.base-$$"
final_tmp="${CONFIG_FILE}.tmp-$$"
policy_tmp="${policy_file}.tmp-$$"
cleanup() {
rm -f "$base_tmp" "$final_tmp" "$policy_tmp"
}
trap cleanup EXIT
awk -v set_default_policy="$set_default_policy" '
/^[^[:space:]#][^:]*:/ { in_policy = ($1 == "policy:") }
/^(randomize_client_port|ephemeral_node_inactivity_timeout):[[:space:]]*/ { next }
set_default_policy && in_policy && /^[[:space:]]+path:[[:space:]]*/ {
print " path: /etc/headscale/policy.hujson"
path_set = 1
next
}
{ print }
END {
if (set_default_policy && !path_set) {
exit 42
}
}
' "$CONFIG_FILE" > "$base_tmp" || {
echo "Could not migrate policy.path in $CONFIG_FILE; restored original configuration" >&2
exit 1
}
if [[ -n "$ephemeral_timeout" ]]; then
ensure_node_timeout "$base_tmp" "$final_tmp" "$ephemeral_timeout"
else
cp "$base_tmp" "$final_tmp"
fi
if [[ "$set_default_policy" -eq 1 ]]; then
expected_policy=$'{\n "randomizeClientPort": true,\n}\n'
if [[ -e "$policy_file" ]]; then
if [[ "$(< "$policy_file")" != "${expected_policy%$'\n'}" ]]; then
echo "$policy_file already exists; add randomizeClientPort to that policy manually before upgrading." >&2
exit 1
fi
else
printf '%s' "$expected_policy" > "$policy_tmp"
chmod 600 "$policy_tmp"
mv "$policy_tmp" "$policy_file"
fi
fi
chmod --reference="$CONFIG_FILE" "$final_tmp"
mv "$final_tmp" "$CONFIG_FILE"
echo "Migrated Headscale configuration for 0.29; backup: ${backup}"
+33 -2
View File
@@ -5,12 +5,31 @@ Tailscale 控制服务器的开源自托管实现。
英文说明:An open source, self-hosted implementation of the Tailscale control server.
## 产品介绍
Headscale 是 Tailscale 控制服务器的开源自托管实现,用于集中管理自有 Tailnet 节点。
## 主要功能
- 管理用户、节点、预授权密钥和 API 密钥。
- 提供 ACL、MagicDNS、DERP 配置和节点注册能力。
- 使用 SQLite 持久化控制面数据,配置和数据目录可备份迁移。
## 访问说明
安装完成后,通过表单中的 `PANEL_APP_PORT_HTTP` 访问 Headscale HTTP 服务;CLI 管理命令可在 1Panel 容器终端中执行。
## Introduction
Headscale is an open-source, self-hosted implementation of the Tailscale control server for managing a private Tailnet.
## Features
- Manage users, nodes, pre-authentication keys, and API keys.
- Provide ACL, MagicDNS, DERP, and node registration capabilities.
- Persist control-plane data in SQLite with backup-friendly configuration and data directories.
## 部署说明
- 本应用使用 Docker Compose 在 1Panel 中部署。
- 应用分类:工具。
- 支持架构:amd64。
- 可选版本:`0.23.0-alpha3``0.26.1``0.27.1`
- 安装后应用表单中的端口访问 Web UI、SSH 或对应服务。
- 可选版本:`0.23.0-alpha3``0.26.1``0.27.1``0.28.0``0.29.2`
- 安装后通过应用表单中的端口访问 Headscale HTTP 控制服务。
## 端口
| 变量 | 说明 | 默认值 | 必填 |
@@ -29,6 +48,18 @@ Tailscale 控制服务器的开源自托管实现。
- `0.27.1` 最低支持的 Tailscale 客户端版本为 `v1.64.0`,旧客户端需要先升级。
- 版本目录内的 `scripts/upgrade.sh` 会自动备份并迁移旧版 `config.yaml`;如手动改过 Postgres、DNS、ACL 等配置,升级后请核对 `database``dns``policy` 配置块。
升级到 `0.29.2` 前请特别注意:
- Headscale 强制按次版本顺序升级。商店内必须依次完成 `0.27.1 -> 0.28.0 -> 0.29.2`,不能从 `0.27.1` 直接跳到 `0.29.2`
- `0.28.0` 最低支持 Tailscale 客户端 `v1.74.0``0.29.2` 最低支持 `v1.80.0`;请先升级所有客户端。
- 每一步升级前都应备份 `./data/config`,以及 `db.sqlite``db.sqlite-wal``db.sqlite-shm`(如存在)。`0.28.0` 会执行节点标签、预授权密钥和数据库结构迁移。
- `0.29.x` 改变了 ACL 通配符和主机名处理规则。使用自定义 ACL、非标准地址段或 `proto:icmp` 的用户必须先核对官方变更说明。
- `0.29.2` 的升级脚本会备份 `config.yaml`,并将旧的 `randomize_client_port: true` 迁移到 `policy.hujson`。如果已经使用自定义文件或数据库策略,脚本会停止而不会覆盖策略;请先在现有策略中加入 `randomizeClientPort`,再移除旧配置项后重试。
## 安全说明
- `0.28.0``0.29.2` 按上游容器部署方式使用只读根文件系统、只读配置挂载和临时 `/var/run/headscale`
- 新版本不再申请 `NET_ADMIN``NET_RAW``SYS_MODULE`,也不设置容器网络转发 sysctl;Headscale 是控制面服务,不承担客户端数据面转发。
## 配置项
| 变量 | 说明 | 默认值 | 必填 |
| --- | --- | --- | --- |