This commit is contained in:
+174
-13
@@ -41,7 +41,9 @@ INSTALL_HOST=${TALLYNOTE_HOST-127.0.0.1}
|
||||
INSTALL_PORT=${TALLYNOTE_PORT-3000}
|
||||
INSTALL_PUBLIC_ORIGIN=${TALLYNOTE_PUBLIC_ORIGIN-}
|
||||
INSTALL_ALLOW_INSECURE_HTTP=${TALLYNOTE_ALLOW_INSECURE_HTTP-false}
|
||||
PUBLIC_IP_URL=${TALLYNOTE_PUBLIC_IP_URL-}
|
||||
NON_INTERACTIVE=0
|
||||
NETWORK_INTERACTIVE=0
|
||||
|
||||
# The production prompt uses the controlling terminal, even when the
|
||||
# installer itself is read from `curl | sudo bash`.
|
||||
@@ -82,7 +84,9 @@ TALLYNOTE_INSTALL_REQUIRE_SIGNATURE=true and provide a public key. Use
|
||||
host. For direct IP access, pass TALLYNOTE_HOST=0.0.0.0 and an actual
|
||||
TALLYNOTE_PUBLIC_ORIGIN such as http://203.0.113.10:3000; HTTP also requires
|
||||
TALLYNOTE_ALLOW_INSECURE_HTTP=true. On a fresh terminal install, the listener
|
||||
and public URL can be selected interactively. Use --non-interactive (or
|
||||
and public URL can be selected interactively. The installer checks that the
|
||||
selected TCP port is free, suggests a public IPv4 address when exposing
|
||||
0.0.0.0, and prints the final access URL after the service starts. Use --non-interactive (or
|
||||
TALLYNOTE_NON_INTERACTIVE=true) for automation. --apply is accepted for
|
||||
backwards compatibility.
|
||||
EOF
|
||||
@@ -111,6 +115,109 @@ prompt_value() {
|
||||
PROMPT_REPLY=${reply:-$default}
|
||||
}
|
||||
|
||||
detect_public_ipv4() {
|
||||
local endpoint value octet
|
||||
local -a endpoints=()
|
||||
if [[ -n "$PUBLIC_IP_URL" ]]; then
|
||||
endpoints=("$PUBLIC_IP_URL")
|
||||
else
|
||||
# These services return the caller's address as plain text. HTTPS is
|
||||
# required, and a failure simply falls back to manual address entry.
|
||||
endpoints=(
|
||||
'https://api.ipify.org'
|
||||
'https://ifconfig.me/ip'
|
||||
'https://checkip.amazonaws.com'
|
||||
)
|
||||
fi
|
||||
command -v curl >/dev/null 2>&1 || return 1
|
||||
for endpoint in "${endpoints[@]}"; do
|
||||
[[ "$endpoint" == https://* && "$endpoint" != *[[:space:]]* && "$endpoint" != *[[:cntrl:]]* && "$endpoint" != *'@'* ]] || continue
|
||||
value=$(curl -4 --proto '=https' --tlsv1.2 --fail --silent --show-error --max-redirs 0 \
|
||||
--connect-timeout 4 --max-time 8 --max-filesize 128 "$endpoint" 2>/dev/null \
|
||||
| tr -d '[:space:]') || continue
|
||||
[[ "$value" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || continue
|
||||
IFS='.' read -r -a _public_ip_octets <<< "$value"
|
||||
for octet in "${_public_ip_octets[@]}"; do
|
||||
(( 10#$octet <= 255 )) || continue 2
|
||||
done
|
||||
printf '%s' "$value"
|
||||
return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
port_listener_state() {
|
||||
local port=$1 output status=0
|
||||
validate_listen_port "$port" >/dev/null 2>&1 || return 2
|
||||
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
if output=$(ss -H -ltn 2>/dev/null); then
|
||||
if awk -v port="$port" '$4 ~ (":" port "$") { found=1 } END { exit found ? 0 : 1 }' <<< "$output"; then
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if command -v lsof >/dev/null 2>&1; then
|
||||
output=''
|
||||
status=0
|
||||
output=$(lsof -nP -iTCP:"$port" -sTCP:LISTEN -t 2>/dev/null) || status=$?
|
||||
[[ -n "$output" ]] && return 1
|
||||
[[ "$status" == 1 && -z "$output" ]] && return 0
|
||||
[[ "$status" == 0 ]] && return 0
|
||||
fi
|
||||
|
||||
if command -v netstat >/dev/null 2>&1; then
|
||||
if output=$(netstat -lnt 2>/dev/null); then
|
||||
if awk -v port="$port" '$6 == "LISTEN" && $4 ~ (":" port "$") { found=1 } END { exit found ? 0 : 1 }' <<< "$output"; then
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
python3 - "$port" <<'PY'
|
||||
import errno
|
||||
import socket
|
||||
import sys
|
||||
|
||||
port = int(sys.argv[1])
|
||||
for family, address in ((socket.AF_INET, "0.0.0.0"), (socket.AF_INET6, "::")):
|
||||
sock = socket.socket(family, socket.SOCK_STREAM)
|
||||
try:
|
||||
if family == socket.AF_INET6:
|
||||
sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
|
||||
sock.bind((address, port))
|
||||
except OSError as error:
|
||||
if error.errno == errno.EADDRINUSE:
|
||||
sys.exit(1)
|
||||
finally:
|
||||
sock.close()
|
||||
sys.exit(0)
|
||||
PY
|
||||
status=$?
|
||||
case "$status" in
|
||||
0) return 0 ;;
|
||||
1) return 1 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
return 2
|
||||
}
|
||||
|
||||
check_requested_port() {
|
||||
local port=$1 state
|
||||
state=0
|
||||
port_listener_state "$port" || state=$?
|
||||
case "$state" in
|
||||
0) return 0 ;;
|
||||
1) die "端口 ${port} 已被占用,请选择其他端口" ;;
|
||||
*) die "无法检测端口 ${port} 是否被占用,请安装 ss、lsof、netstat 或 Python 3 后重试" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
has_network_environment() {
|
||||
[[ -n "${TALLYNOTE_HOST+x}" || -n "${TALLYNOTE_PORT+x}" || -n "${TALLYNOTE_PUBLIC_ORIGIN+x}" || -n "${TALLYNOTE_ALLOW_INSECURE_HTTP+x}" ]]
|
||||
}
|
||||
@@ -126,6 +233,7 @@ interactive_network_available() {
|
||||
|
||||
configure_network_interactively() {
|
||||
interactive_network_available || return 0
|
||||
NETWORK_INTERACTIVE=1
|
||||
|
||||
exec 9<"$PROMPT_INPUT" || die '无法打开终端输入;请使用 --non-interactive 或通过环境变量配置'
|
||||
|
||||
@@ -136,7 +244,7 @@ configure_network_interactively() {
|
||||
printf ' 2) 局域网/公网访问:0.0.0.0(需要填写实际访问地址)\n'
|
||||
} > "$PROMPT_OUTPUT"
|
||||
|
||||
local choice selected_port origin answer
|
||||
local choice selected_port origin answer port_state detected_ip default_origin
|
||||
while :; do
|
||||
prompt_value '请选择监听方式 1/2' '1'
|
||||
choice=$PROMPT_REPLY
|
||||
@@ -150,6 +258,17 @@ configure_network_interactively() {
|
||||
prompt_value '监听端口' "$INSTALL_PORT"
|
||||
selected_port=$PROMPT_REPLY
|
||||
if [[ "$selected_port" =~ ^[1-9][0-9]*$ && "$selected_port" -le 65535 ]]; then
|
||||
# A real terminal can reject an occupied port immediately. The final
|
||||
# check in main() runs again after old services have been stopped.
|
||||
if [[ -t 9 ]]; then
|
||||
port_state=0
|
||||
port_listener_state "$selected_port" || port_state=$?
|
||||
case "$port_state" in
|
||||
0) break ;;
|
||||
1) printf '端口 %s 已被占用,请输入其他端口。\n' "$selected_port" > "$PROMPT_OUTPUT"; continue ;;
|
||||
*) printf '暂时无法预检端口,安装前还会再次检查。\n' > "$PROMPT_OUTPUT"; break ;;
|
||||
esac
|
||||
fi
|
||||
break
|
||||
fi
|
||||
printf '端口必须是 1-65535 的整数,请重试。\n' > "$PROMPT_OUTPUT"
|
||||
@@ -163,8 +282,21 @@ configure_network_interactively() {
|
||||
else
|
||||
INSTALL_HOST=0.0.0.0
|
||||
INSTALL_PORT=$selected_port
|
||||
detected_ip=''
|
||||
# Test fixtures replace /dev/tty with regular files; avoid making their
|
||||
# behavior depend on an external IP lookup service.
|
||||
if [[ -t 9 ]]; then
|
||||
detected_ip=$(detect_public_ipv4 || true)
|
||||
fi
|
||||
if [[ -n "$detected_ip" ]]; then
|
||||
default_origin="http://${detected_ip}:${selected_port}"
|
||||
printf '已探测公网 IPv4:%s\n' "$detected_ip" > "$PROMPT_OUTPUT"
|
||||
else
|
||||
default_origin=''
|
||||
printf '未能自动获取公网 IPv4,请手动填写访问地址。\n' > "$PROMPT_OUTPUT"
|
||||
fi
|
||||
while :; do
|
||||
prompt_value '实际访问地址(例如 http://203.0.113.10:3000 或 https://tallynote.example.com)' ''
|
||||
prompt_value '实际访问地址(回车使用自动探测地址,也可填写域名)' "$default_origin"
|
||||
origin=$PROMPT_REPLY
|
||||
if validate_env_value "$origin" '公开访问地址' >/dev/null 2>&1 && validate_public_origin "$origin" >/dev/null 2>&1; then
|
||||
INSTALL_PUBLIC_ORIGIN=$origin
|
||||
@@ -753,7 +885,7 @@ validate_listen_host() {
|
||||
local octet
|
||||
IFS='.' read -r -a _host_octets <<< "$value"
|
||||
for octet in "${_host_octets[@]}"; do
|
||||
(( octet <= 255 )) || die "$label 必须是有效的 IPv4 地址或主机名"
|
||||
(( 10#$octet <= 255 )) || die "$label 必须是有效的 IPv4 地址或主机名"
|
||||
done
|
||||
else
|
||||
[[ "$value" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$ ]] || die "$label 必须是有效的 IPv4、IPv6 地址或主机名"
|
||||
@@ -794,7 +926,7 @@ validate_public_origin() {
|
||||
fi
|
||||
[[ -n "$host" ]] || die '公开访问地址缺少主机名'
|
||||
[[ "$host" != 0.0.0.0 && "$host" != :: && "$host" != \* ]] || die '公开访问地址不能使用通配监听地址,请填写服务器 IP 或域名'
|
||||
[[ "$host" =~ ^[A-Za-z0-9.-]+$ || "$host" =~ ^[0-9A-Fa-f:]+$ ]] || die '公开访问地址主机名无效'
|
||||
validate_listen_host "$host" '公开访问地址主机'
|
||||
if [[ -n "$origin_port" ]]; then
|
||||
[[ "$origin_port" =~ ^[0-9]{1,5}$ && "$origin_port" -ge 1 && "$origin_port" -le 65535 ]] || die '公开访问地址端口必须是 1-65535 的整数'
|
||||
fi
|
||||
@@ -867,24 +999,26 @@ validate_existing_env() {
|
||||
origin=$(read_env_value "$file" TALLYNOTE_PUBLIC_ORIGIN)
|
||||
validate_env_value "$origin" '环境文件中的公开访问地址'
|
||||
else
|
||||
origin="http://${host}:${port}"
|
||||
local origin_host=$host
|
||||
[[ "$origin_host" == *:* && "$origin_host" != \[* ]] && origin_host="[$origin_host]"
|
||||
origin="http://${origin_host}:${port}"
|
||||
fi
|
||||
validate_public_origin "$origin"
|
||||
local origin_host=${origin#*://}
|
||||
if [[ "$origin_host" == \[*\]* ]]; then
|
||||
origin_host=${origin_host#\[}
|
||||
origin_host=${origin_host%%\]*}
|
||||
local origin_host_for_policy=${origin#*://}
|
||||
if [[ "$origin_host_for_policy" == \[*\]* ]]; then
|
||||
origin_host_for_policy=${origin_host_for_policy#\[}
|
||||
origin_host_for_policy=${origin_host_for_policy%%\]*}
|
||||
else
|
||||
origin_host=${origin_host%%:*}
|
||||
origin_host_for_policy=${origin_host_for_policy%%:*}
|
||||
fi
|
||||
if [[ "$origin" == http://* && "$allow_insecure" != true ]]; then
|
||||
case "$origin_host" in
|
||||
case "$origin_host_for_policy" in
|
||||
127.0.0.1|localhost|::1) ;;
|
||||
*) die '环境文件中的公网 HTTP 访问必须显式设置 TALLYNOTE_ALLOW_INSECURE_HTTP=true' ;;
|
||||
esac
|
||||
fi
|
||||
if [[ "$origin" == http://* && "$cookie_secure" == true ]]; then
|
||||
case "$origin_host" in
|
||||
case "$origin_host_for_policy" in
|
||||
127.0.0.1|localhost|::1) ;;
|
||||
*) die '环境文件中的公网 HTTP 公开地址不能启用安全 Cookie' ;;
|
||||
esac
|
||||
@@ -973,6 +1107,9 @@ main() {
|
||||
configure_network_interactively
|
||||
validate_listen_host "$INSTALL_HOST"
|
||||
validate_listen_port "$INSTALL_PORT"
|
||||
if (( APPLY && NETWORK_INTERACTIVE )); then
|
||||
check_requested_port "$INSTALL_PORT"
|
||||
fi
|
||||
if [[ -n "${TALLYNOTE_PUBLIC_ORIGIN+x}" && -z "$INSTALL_PUBLIC_ORIGIN" ]]; then
|
||||
die 'TALLYNOTE_PUBLIC_ORIGIN 不能是空值;省略该变量以使用默认 Origin'
|
||||
fi
|
||||
@@ -1115,6 +1252,15 @@ main() {
|
||||
if [[ -e "$CONFIG_DIR/tallynote.env" ]]; then
|
||||
validate_existing_env "$CONFIG_DIR/tallynote.env"
|
||||
fi
|
||||
# During upgrades, the existing environment remains authoritative unless a
|
||||
# new port was explicitly supplied. Check the effective listener port after
|
||||
# stopping the old service so an unrelated process cannot claim it.
|
||||
local effective_port=$INSTALL_PORT
|
||||
if [[ -z "${TALLYNOTE_PORT+x}" && -f "$CONFIG_DIR/tallynote.env" ]]; then
|
||||
effective_port=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_PORT 2>/dev/null || true)
|
||||
effective_port=${effective_port:-3000}
|
||||
fi
|
||||
check_requested_port "$effective_port"
|
||||
stage_done '目录、权限和旧服务状态已准备'
|
||||
stage "解包、校验包结构并原子切换到版本 ${VERSION#v}"
|
||||
install_release "$archive" "$VERSION"
|
||||
@@ -1228,6 +1374,21 @@ main() {
|
||||
rm -rf -- "$INSTALL_WORK_DIR" 2>/dev/null || true
|
||||
INSTALL_WORK_DIR=''
|
||||
stage_done "安装完成:TallyNote ${VERSION#v}"
|
||||
local access_url access_host access_port configured_host configured_port
|
||||
access_url=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_PUBLIC_ORIGIN 2>/dev/null || true)
|
||||
if [[ -z "$access_url" ]]; then
|
||||
configured_host=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_HOST 2>/dev/null || true)
|
||||
configured_port=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_PORT 2>/dev/null || true)
|
||||
access_host=${configured_host:-$INSTALL_HOST}
|
||||
access_port=${configured_port:-$INSTALL_PORT}
|
||||
if [[ "$access_host" == 0.0.0.0 ]]; then
|
||||
access_host=$(detect_public_ipv4 || true)
|
||||
fi
|
||||
[[ -n "$access_host" ]] || access_host=$INSTALL_HOST
|
||||
[[ "$access_host" == *:* && "$access_host" != \[* ]] && access_host="[$access_host]"
|
||||
access_url="http://${access_host}:${access_port}"
|
||||
fi
|
||||
log "访问地址:$access_url"
|
||||
log '查看服务状态:systemctl status tallynote.service'
|
||||
}
|
||||
main "$@"
|
||||
|
||||
Reference in New Issue
Block a user