#!/usr/bin/env bash
# Nodebuf Manager installer. The release builder pins the immutable manifest below.
set -Eeuo pipefail
# Credential generation/display must never be traced or auto-exported.
set +x
set +a
umask 077
NB_VERSION='0.7.3'
MANIFEST_SHA256='56f4eb1c6bcf6b1a16f873ae12d9ef35b0fd25494d51c3cc516d01e9d3d87b41'
SOURCE='https://nodebuf.com/downloads/nb/v0.7.3'
ROOT='/opt/nodebuf-manager'
DATA='/var/lib/nodebuf-manager'
UNIT='/etc/systemd/system/nodebuf-manager.service'
BBR_UNIT='/etc/systemd/system/nodebuf-manager-bbr-modules.service'
UPDATE_UNIT='/etc/systemd/system/nodebuf-manager-update.service'
UPDATER_ROOT='/var/lib/nodebuf-manager-updater'
INSTALL_LOCK='/run/nodebuf-manager-install.lock'
COMMAND='/usr/local/bin/nb'
CORE_VERSION='1.14.3'
ACTION='install'
BUNDLE=''
SERVER=''
USERNAME='admin'
PORT='39090'
PASSWORD_STDIN=0
NO_START=0
SKIP_DEPS=0
PURGE=0
TMP=''
SWITCHED=0
OLD_CURRENT=''
NEW_RELEASE=''
FRESH_INSTALL=0
PORT_EXPLICIT=0
REUSE_DATA=0
DATA_CREATED=0
ACCESS_MODE=''
ACCESS_EXPLICIT=0
PANEL_ORIGIN=''
# A same-named inherited variable may already carry Bash's export attribute.
unset GENERATED_PASSWORD
GENERATED_PASSWORD=''

say(){ printf '\033[1;36m[Nodebuf]\033[0m %s\n' "$*"; }
fail(){ printf '[Nodebuf] %s\n' "$*" >&2; exit 1; }
help(){ cat <<'HELP'
Nodebuf sing-box Manager · nb

  bash singbox.sh install
  bash singbox.sh install --bundle /path/to/verified-bundle --server HOST
  bash singbox.sh install --reuse-data --bundle /path/to/verified-bundle
  bash singbox.sh upgrade --bundle /path/to/verified-bundle
  bash singbox.sh rollback
  bash singbox.sh access --local-only
  bash singbox.sh access --public-http --server YOUR_PUBLIC_IP_OR_DOMAIN
  bash singbox.sh uninstall [--purge]

Options:
  --server HOST          Optional public IP/domain override (new installs auto-detect).
  --username USER        Local panel administrator (default: admin).
  --port PORT            Panel port (default: 39090).
  --local-only           Bind only 127.0.0.1; access through SSH forwarding.
  --public-http          Enable IP/domain HTTP access; configure HTTPS promptly.
  --password-stdin       Set a custom initial password from stdin (default: generated).
  --reuse-data            Reinstall retained private data; preserve accounts, nodes and usage.
  --bundle DIRECTORY     Install verified local release archives without download.
  --no-start             Install/initialize for an image; do not call systemctl.
  --skip-deps            Do not install OS dependencies (all must already exist).
  --purge                Uninstall data only after an interactive PURGE confirmation.
  -h, --help             Show this help.

New installs auto-detect the public IP and generate an administrator password.
The password is shown only after installation succeeds; nb password changes it.
New installs provide public HTTP bootstrap access.
Only if detection fails, supply --server YOUR_PUBLIC_IP_OR_DOMAIN. Configure HTTPS promptly.
Upgrades and retained-data reinstalls preserve the previous access mode.
Existing sing-box services and firewall tables are never adopted or overwritten.
HELP
}
while (($#)); do
 case "$1" in
  install|upgrade|rollback|uninstall|access) ACTION="$1";shift;;
  --bundle|--server|--username|--port) (($#>=2))||fail 'ARGUMENT_MISSING';key="$1";value="$2";shift 2;case "$key" in --bundle)BUNDLE="$value";;--server)SERVER="$value";;--username)USERNAME="$value";;--port)PORT="$value";PORT_EXPLICIT=1;;esac;;
  --password-stdin)PASSWORD_STDIN=1;shift;;
  --reuse-data)REUSE_DATA=1;shift;;
  --local-only|--public-http) ((ACCESS_EXPLICIT==0))||fail 'PANEL_ACCESS_CONFLICT';ACCESS_EXPLICIT=1;if [[ "$1" == --local-only ]];then ACCESS_MODE=loopback;else ACCESS_MODE=public;fi;shift;;
  --no-start)NO_START=1;shift;;
  --skip-deps)SKIP_DEPS=1;shift;;
  --purge)PURGE=1;shift;;
  -h|--help)help;exit 0;;
  *)fail 'ARGUMENT_INVALID';;
 esac
done
[[ "$(uname -s)" == Linux ]]||fail 'LINUX_REQUIRED'
[[ "$(id -u)" == 0 ]]||fail 'ROOT_REQUIRED'
[[ "$PORT" =~ ^[0-9]{1,5}$ ]]&&((10#$PORT>0&&10#$PORT<65536))||fail 'PANEL_PORT_INVALID'
[[ -f /etc/os-release ]]||fail 'DISTRIBUTION_UNSUPPORTED'
# This file is maintained by the local OS vendor, not downloaded input.
. /etc/os-release
[[ "${ID:-}" == debian || "${ID:-}" == ubuntu ]]||fail 'DEBIAN_OR_UBUNTU_REQUIRED'
command -v flock >/dev/null||fail 'INSTALL_LOCK_UNAVAILABLE_INSTALL_UTIL_LINUX'
if [[ -e "$INSTALL_LOCK" || -L "$INSTALL_LOCK" ]];then
 [[ -f "$INSTALL_LOCK"&&! -L "$INSTALL_LOCK" ]]&&[[ "$(stat -c '%u' "$INSTALL_LOCK")" == 0 ]]&&[[ "$(stat -c '%a' "$INSTALL_LOCK")" == 600 ]]||fail 'INSTALL_LOCK_UNSAFE'
fi
exec 9>"$INSTALL_LOCK"
chmod 0600 "$INSTALL_LOCK"
flock -n 9||fail 'INSTALL_LOCK_BUSY'
if [[ -n "${NODEBUF_UPDATER_EXPECTED_VERSION:-}" ]];then
 [[ "$NODEBUF_UPDATER_EXPECTED_VERSION" =~ ^[0-9]{1,4}\.[0-9]{1,4}\.[0-9]{1,4}$ ]]&&[[ -x "$ROOT/current/nb" ]]||fail 'CURRENT_VERSION_CHANGED'
 [[ "$("$ROOT/current/nb" version)" == "Nodebuf Server Manager $NODEBUF_UPDATER_EXPECTED_VERSION" ]]||fail 'CURRENT_VERSION_CHANGED'
fi

case "$(uname -m)" in x86_64)ARCH=amd64;CORE_SHA256=e2bdf179c15a3652955dc44867e33ca0965c9c9b91b34267dcc5a5d639a5feee;;aarch64|arm64)ARCH=arm64;CORE_SHA256=29dae24d76bea3bc62d07dd3e60cfc12003de7debbd6b768df9be9c68360174e;;*)fail 'ARCHITECTURE_UNSUPPORTED';;esac
PLATFORM="linux-$ARCH"
managed(){ [[ -f "$ROOT/.nodebuf-manager" && ! -L "$ROOT/.nodebuf-manager" ]]&&[[ "$(cat "$ROOT/.nodebuf-manager")" == 'nodebuf-manager-v1' ]]; }
bbr_unit_content(){ cat <<'BBRUNIT'
# Managed by Nodebuf Manager BBR modules
[Unit]
Description=Nodebuf BBR kernel module loader
[Service]
Type=oneshot
ExecStart=/opt/nodebuf-manager/current/nb internal-bbr-modules
User=root
Group=root
UMask=0077
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX
CapabilityBoundingSet=CAP_SYS_MODULE
LockPersonality=true
RestrictSUIDSGID=true
TimeoutStartSec=12s
TasksMax=16
MemoryMax=128M
BBRUNIT
}
managed_bbr_unit(){
 [[ -f "$BBR_UNIT"&&! -L "$BBR_UNIT" ]]&&head -n1 "$BBR_UNIT"|grep -qx '# Managed by Nodebuf Manager BBR modules'
}
backup_bbr_unit(){
 if [[ -f "$BBR_UNIT" ]];then cp -- "$BBR_UNIT" "$TMP/previous.bbr-modules.service";else : >"$TMP/previous.bbr-modules.absent";fi
}
restore_bbr_unit(){
 if [[ -f "$TMP/previous.bbr-modules.service" ]];then cp -- "$TMP/previous.bbr-modules.service" "$BBR_UNIT";chmod 0644 "$BBR_UNIT"
 elif [[ -f "$TMP/previous.bbr-modules.absent" ]]&&managed_bbr_unit;then rm -f -- "$BBR_UNIT";fi
}
save_bbr_release(){
 local release_dir="$1"
 rm -f -- "$release_dir/bbr-modules.service" "$release_dir/bbr-modules.absent"
 if [[ -f "$BBR_UNIT" ]];then cp -- "$BBR_UNIT" "$release_dir/bbr-modules.service";else : >"$release_dir/bbr-modules.absent";fi
}
restore_bbr_release(){
 local release_dir="$1"
 if [[ -f "$release_dir/bbr-modules.service" ]];then cp -- "$release_dir/bbr-modules.service" "$BBR_UNIT";chmod 0644 "$BBR_UNIT"
 elif managed_bbr_unit;then rm -f -- "$BBR_UNIT";fi
}
write_bbr_unit(){
 bbr_unit_content >"$TMP/new.bbr-modules.service"
 install -m 0644 "$TMP/new.bbr-modules.service" "$BBR_UNIT.next-$$"
 mv -Tf -- "$BBR_UNIT.next-$$" "$BBR_UNIT"
}
update_unit_content(){ cat <<'UPDATEUNIT'
# Managed by Nodebuf Manager signed updater
[Unit]
Description=Nodebuf verified release installer
After=network-online.target
[Service]
Type=oneshot
ExecStart=/opt/nodebuf-manager/current/nb internal-release-apply
WorkingDirectory=/var/lib/nodebuf-manager-updater
User=root
Group=root
UMask=0077
TimeoutStartSec=9min
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=read-only
ProtectSystem=strict
ReadWritePaths=/opt/nodebuf-manager /var/lib/nodebuf-manager /var/lib/nodebuf-manager-updater /etc/systemd/system /usr/local/bin /etc/sysctl.d /run/nodebuf-manager-install.lock
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
LockPersonality=true
RestrictSUIDSGID=true
TasksMax=128
MemoryMax=768M
UPDATEUNIT
}
managed_update_unit(){
 [[ -f "$UPDATE_UNIT"&&! -L "$UPDATE_UNIT" ]]&&head -n1 "$UPDATE_UNIT"|grep -qx '# Managed by Nodebuf Manager signed updater'
}
backup_update_unit(){
 if [[ -f "$UPDATE_UNIT" ]];then cp -- "$UPDATE_UNIT" "$TMP/previous.update.service";else : >"$TMP/previous.update.absent";fi
}
restore_update_unit(){
 if [[ -f "$TMP/previous.update.service" ]];then cp -- "$TMP/previous.update.service" "$UPDATE_UNIT";chmod 0644 "$UPDATE_UNIT"
 elif [[ -f "$TMP/previous.update.absent" ]]&&managed_update_unit;then rm -f -- "$UPDATE_UNIT";fi
}
save_update_release(){
 local release_dir="$1"
 rm -f -- "$release_dir/update.service" "$release_dir/update.absent"
 if [[ -f "$UPDATE_UNIT" ]];then cp -- "$UPDATE_UNIT" "$release_dir/update.service";else : >"$release_dir/update.absent";fi
}
restore_update_release(){
 local release_dir="$1"
 if [[ -f "$release_dir/update.service" ]];then cp -- "$release_dir/update.service" "$UPDATE_UNIT";chmod 0644 "$UPDATE_UNIT"
 elif managed_update_unit;then rm -f -- "$UPDATE_UNIT";fi
}
write_update_unit(){
 install -d -m 0700 "$UPDATER_ROOT"
 update_unit_content >"$TMP/new.update.service"
 install -m 0644 "$TMP/new.update.service" "$UPDATE_UNIT.next-$$"
 mv -Tf -- "$UPDATE_UNIT.next-$$" "$UPDATE_UNIT"
}
if [[ -e "$ROOT" || -L "$ROOT" ]];then managed||fail 'INSTALL_DIRECTORY_CONFLICT';fi
if [[ -e "$UNIT" || -L "$UNIT" ]];then managed&&[[ ! -L "$UNIT" ]]&&head -n1 "$UNIT"|grep -qx '# Managed by Nodebuf Manager'||fail 'SERVICE_CONFLICT';fi
if [[ -e "$BBR_UNIT" || -L "$BBR_UNIT" ]];then
 managed&&managed_bbr_unit||fail 'BBR_SERVICE_CONFLICT'
 bbr_owner="$(stat -c '%u' "$BBR_UNIT")";bbr_mode="$(stat -c '%a' "$BBR_UNIT")"
 [[ "$bbr_owner" == 0 ]]&&(( (8#$bbr_mode & 022)==0 ))||fail 'BBR_SERVICE_CONFLICT'
fi
[[ ! -e "$BBR_UNIT.d"&&! -L "$BBR_UNIT.d" ]]||fail 'BBR_SERVICE_CONFLICT'
if [[ -e "$UPDATE_UNIT" || -L "$UPDATE_UNIT" ]];then
 managed&&managed_update_unit||fail 'UPDATE_SERVICE_CONFLICT'
 update_owner="$(stat -c '%u' "$UPDATE_UNIT")";update_mode="$(stat -c '%a' "$UPDATE_UNIT")"
 [[ "$update_owner" == 0 ]]&&(( (8#$update_mode & 022)==0 ))||fail 'UPDATE_SERVICE_CONFLICT'
fi
[[ ! -e "$UPDATE_UNIT.d"&&! -L "$UPDATE_UNIT.d" ]]||fail 'UPDATE_SERVICE_CONFLICT'
# Refuse units and drop-ins in every standard systemd layer. A foreign runtime
# override must never replace the fixed privileged helper after daemon-reload.
for unit_root in /run/systemd/system /usr/local/lib/systemd/system /usr/lib/systemd/system /lib/systemd/system;do
 for unit_path in "$unit_root/nodebuf-manager-update.service" "$unit_root/nodebuf-manager-update.service.d";do
  [[ ! -e "$unit_path"&&! -L "$unit_path" ]]||fail 'UPDATE_SERVICE_CONFLICT'
 done
done
if [[ -e "$UPDATER_ROOT" || -L "$UPDATER_ROOT" ]];then
 [[ -d "$UPDATER_ROOT"&&! -L "$UPDATER_ROOT" ]]&&[[ "$(stat -c '%u' "$UPDATER_ROOT")" == 0 ]]&&[[ "$(stat -c '%a' "$UPDATER_ROOT")" == 700 ]]||fail 'UPDATE_WORKSPACE_UNSAFE'
fi

if [[ -e "$COMMAND" || -L "$COMMAND" ]];then [[ -L "$COMMAND" ]]&&[[ "$(readlink "$COMMAND")" == "$ROOT/current/nb" ]]&&managed||fail 'NB_COMMAND_CONFLICT';fi
if [[ "$ACTION" != install ]];then
 managed||fail 'NOT_INSTALLED'
 if ((PORT_EXPLICIT==0))&&[[ -f "$UNIT" ]];then
  stored_port="$(sed -n 's/.*--listen [^ ]*:\([0-9][0-9]*\).*/\1/p' "$UNIT")"
  [[ "$stored_port" =~ ^[0-9]{1,5}$ ]]&&PORT="$stored_port"
 fi
fi
if ((NO_START==1))&&[[ -S "$DATA/manager.sock" ]];then fail 'IMAGE_MODE_MANAGER_SOCKET_PRESENT_STOP_SERVICE_FIRST';fi
if ((NO_START==0));then command -v systemctl >/dev/null||fail 'SYSTEMD_REQUIRED';[[ -d /run/systemd/system ]]||fail 'SYSTEMD_NOT_RUNNING';fi

backup_previous_link(){
 if [[ -L "$ROOT/previous" ]];then readlink "$ROOT/previous" >"$TMP/previous.link"
 elif [[ -e "$ROOT/previous" ]];then fail 'PREVIOUS_RELEASE_INVALID'
 else : >"$TMP/previous.link.absent";fi
}
restore_previous_link(){
 if [[ -f "$TMP/previous.link" ]];then
  ln -s "$(cat "$TMP/previous.link")" "$ROOT/.restore-previous-$$"&&mv -Tf "$ROOT/.restore-previous-$$" "$ROOT/previous"
 elif [[ -f "$TMP/previous.link.absent"&&-L "$ROOT/previous" ]];then rm -f -- "$ROOT/previous";fi
}
cleanup(){
 code=$?
 unset GENERATED_PASSWORD
 if ((code!=0&&SWITCHED==1))&&[[ -n "$OLD_CURRENT" ]];then
  ln -s "$OLD_CURRENT" "$ROOT/.rollback-current-$$" && mv -Tf "$ROOT/.rollback-current-$$" "$ROOT/current"
  if [[ -n "$TMP"&&-f "$TMP/previous.service" ]];then cp -- "$TMP/previous.service" "$UNIT";fi
  if [[ -n "$TMP" ]];then restore_bbr_unit;restore_update_unit;restore_previous_link;fi
  if [[ -n "$TMP"&&-f "$TMP/previous-install.json" ]];then cp -- "$TMP/previous-install.json" "$DATA/install.json";chmod 0600 "$DATA/install.json";fi
  if ((NO_START==0));then systemctl daemon-reload >/dev/null 2>&1||true;systemctl restart nodebuf-manager.service >/dev/null 2>&1||true;fi
  printf '[Nodebuf] INSTALL_ROLLED_BACK\n' >&2
 fi
 if ((code!=0&&FRESH_INSTALL==1));then
  if ((REUSE_DATA==1))&&[[ -n "$TMP"&&-f "$TMP/previous-install.json" ]];then cp -- "$TMP/previous-install.json" "$DATA/install.json";chmod 0600 "$DATA/install.json";fi
  if ((NO_START==0));then systemctl stop nodebuf-manager.service >/dev/null 2>&1||true;systemctl disable nodebuf-manager.service >/dev/null 2>&1||true;fi
  if [[ -L "$COMMAND"&&"$(readlink "$COMMAND")" == "$ROOT/current/nb" ]];then rm -f -- "$COMMAND";fi
  if [[ -f "$UNIT" ]]&&head -n1 "$UNIT"|grep -qx '# Managed by Nodebuf Manager';then rm -f -- "$UNIT";fi
  if managed_bbr_unit;then rm -f -- "$BBR_UNIT";fi
  if managed_update_unit;then rm -f -- "$UPDATE_UNIT";fi
  if ((DATA_CREATED==1));then [[ ! -d "$DATA"||-L "$DATA" ]]||rm -rf -- "$DATA";fi
  managed&&rm -rf -- "$ROOT"
 fi
 # A failed upgrade may have copied a complete or partial new release before
 # switching the current link. Remove only this invocation's newly allocated
 # directory after rollback, never a retained or still-referenced version.
 if ((code!=0))&&[[ -n "$NEW_RELEASE" ]]&&managed;then
  if [[ "$(readlink "$ROOT/current" 2>/dev/null||true)" != "$NEW_RELEASE"&&"$(readlink "$ROOT/previous" 2>/dev/null||true)" != "$NEW_RELEASE"&&-d "$ROOT/$NEW_RELEASE"&&! -L "$ROOT/$NEW_RELEASE" ]];then
   rm -rf -- "$ROOT/$NEW_RELEASE"
  fi
 fi
 [[ -z "$TMP" ]]||rm -rf -- "$TMP"
 exit "$code"
}
trap cleanup EXIT
stop_service(){ if ((NO_START==0));then systemctl stop nodebuf-manager.service;fi; }
start_service(){
 if ((NO_START==0));then
  systemctl daemon-reload
  systemctl enable nodebuf-manager.service >/dev/null
  systemctl restart nodebuf-manager.service
  for ((attempt=0;attempt<20;attempt++));do
   if curl --proxy '' --noproxy '*' --fail --silent --max-time 1 "http://127.0.0.1:$PORT/api/health" >/dev/null;then return;fi
   if curl --proxy '' --noproxy '*' --fail --silent --max-time 1 "http://[::1]:$PORT/api/health" >/dev/null;then return;fi
   sleep 1
  done
  fail 'PANEL_HEALTH_CHECK_FAILED'
 fi
}
clean_network(){
 python3 - <<'PY'
import json,subprocess,time

def run(args):
 try:p=subprocess.run(args,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,timeout=10)
 except (OSError,subprocess.TimeoutExpired):raise SystemExit('NETWORK_CLEANUP_UNAVAILABLE')
 if p.returncode:return None
 try:return json.loads(p.stdout)
 except (ValueError,TypeError):raise SystemExit('NETWORK_CLEANUP_INVALID')
def mutate(args):
 try:subprocess.run(args,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,check=True,timeout=10)
 except (OSError,subprocess.SubprocessError):raise SystemExit('NETWORK_CLEANUP_FAILED')
def police(data):
 found={}
 def visit(value):
  if isinstance(value,list):
   for item in value:visit(item)
  elif isinstance(value,dict):
   if value.get('kind')=='police' and isinstance(value.get('index'),int):found[value['index']]=value.get('bind',-1)
   for item in value.values():visit(item)
 visit(data)
 return found
inventory=run(['nft','-j','list','tables'])
if inventory is None:raise SystemExit('NETWORK_CLEANUP_UNAVAILABLE')
exists=any(x.get('table',{}).get('family')=='inet' and x.get('table',{}).get('name')=='nb_manager' for x in inventory.get('nftables',[]))
state=run(['nft','-j','list','table','inet','nb_manager']) if exists else None
owned=state and any(x.get('table',{}).get('comment')=='nodebuf-manager-v1' for x in state.get('nftables',[]))
if owned:
 claimed=any(x.get('counter',{}).get('name')=='nb_tc_owner' for x in state.get('nftables',[]))
 selected=set()
 if claimed:
  interfaces=run(['ip','-j','link','show'])
  if interfaces is None:raise SystemExit('NETWORK_CLEANUP_UNAVAILABLE')
  for iface in interfaces:
   name=iface.get('ifname','')
   if not name or '/' in name:continue
   q=run(['tc','-j','qdisc','show','dev',name])
   if q is None:raise SystemExit('NETWORK_CLEANUP_UNAVAILABLE')
   if any(x.get('root') and x.get('kind')=='htb' and x.get('handle')=='7b00:' for x in q):
    ingress=any(x.get('kind')=='ingress' and x.get('handle')=='ffff:' for x in q)
    if ingress:
     filters=run(['tc','-j','filter','show','dev',name,'parent','ffff:'])
     if filters is None:raise SystemExit('NETWORK_CLEANUP_UNAVAILABLE')
     indexes=set(police(filters))
     if any(not 0x4e420000<index<=0x4e42ffff for index in indexes):raise SystemExit('NETWORK_CLEANUP_CONFLICT')
     selected.update(indexes)
    mutate(['tc','qdisc','del','dev',name,'root','handle','7b00:'])
    if ingress:mutate(['tc','qdisc','del','dev',name,'ingress'])
 if selected:
  deadline=time.monotonic()+2
  while True:
   data=run(['tc','-j','actions','ls','action','police'])
   if data is None:raise SystemExit('NETWORK_CLEANUP_UNAVAILABLE')
   current=police(data)
   if all(current.get(index,0)==0 for index in selected):
    for index in sorted(selected):
     if index in current:mutate(['tc','actions','delete','action','police','index',str(index)])
    break
   if time.monotonic()>deadline:raise SystemExit('NETWORK_CLEANUP_CONFLICT')
   time.sleep(.025)
 mutate(['nft','delete','table','inet','nb_manager'])
PY
}
if [[ "$ACTION" == uninstall ]];then
 if ((PURGE==1));then [[ -t 0 ]]||fail 'PURGE_REQUIRES_TERMINAL_CONFIRMATION';printf 'Permanently delete Nodebuf nodes, credentials and usage? Type PURGE: ';read -r confirmation;[[ "$confirmation" == PURGE ]]||fail 'PURGE_CANCELLED';fi
 stop_service
 if ((NO_START==0))&&managed_bbr_unit;then systemctl stop nodebuf-manager-bbr-modules.service;fi
 if ((NO_START==0));then systemctl disable nodebuf-manager.service >/dev/null 2>&1||true;fi
 clean_network
 rm -f -- "$UNIT" "$COMMAND"
 if managed_bbr_unit;then rm -f -- "$BBR_UNIT";fi
 if managed_update_unit;then rm -f -- "$UPDATE_UNIT";fi
 if ((NO_START==0));then systemctl daemon-reload;fi
 rm -rf -- "$ROOT"
 if ((PURGE==1));then [[ -d "$DATA"&&! -L "$DATA" ]]||fail 'DATA_DIRECTORY_UNSAFE';rm -rf -- "$DATA";say 'Uninstalled. Local data removed.';else say "Uninstalled. Nodes, credentials and usage retained in $DATA";fi
 exit 0
fi
rollback_auth_guard(){
 python3 - "$DATA/manager.db" "$target_version" <<'AUTHROLLBACK'
import json,re,sqlite3,sys
from pathlib import Path
m=re.fullmatch(r'Nodebuf Server Manager (\d+)\.(\d+)\.(\d+)',sys.argv[2].strip())
if not m:raise SystemExit('ROLLBACK_TARGET_INVALID')
try:
 with sqlite3.connect(Path(sys.argv[1]).resolve().as_uri()+'?mode=ro',uri=True) as db:
  row=db.execute("SELECT value FROM meta WHERE key='auth.security.v1'").fetchone()
  state=json.loads(row[0]) if row else {'enabled':False}
  if not isinstance(state,dict) or not isinstance(state.get('enabled'),bool):raise ValueError()
except Exception:raise SystemExit('ROLLBACK_AUTH_STATE_UNAVAILABLE')
if state['enabled'] and tuple(map(int,m.groups()))<(0,5,0):raise SystemExit('ROLLBACK_TOTP_UNSUPPORTED_DISABLE_EXPLICITLY_FIRST')
AUTHROLLBACK
}
if [[ "$ACTION" == rollback ]];then
 [[ -L "$ROOT/previous" ]]||fail 'ROLLBACK_UNAVAILABLE'
 previous="$(readlink "$ROOT/previous")";[[ "$previous" =~ ^releases/[0-9][0-9A-Za-z._-]*$ ]]&&[[ -x "$ROOT/$previous/nb"&&-x "$ROOT/$previous/sing-box" ]]||fail 'ROLLBACK_TARGET_INVALID'
 OLD_CURRENT="$(readlink "$ROOT/current")"
 [[ "$OLD_CURRENT" =~ ^releases/[0-9][0-9A-Za-z._-]*$ ]]||fail 'CURRENT_RELEASE_INVALID'
 target_version="$("$ROOT/$previous/nb" version)"
 rollback_auth_guard
 TMP="$(mktemp -d /tmp/nodebuf-install.XXXXXXXX)"
 backup_previous_link
 cp -- "$UNIT" "$TMP/previous.service"
 backup_bbr_unit
 backup_update_unit
 if [[ -f "$DATA/install.json" ]];then cp -- "$DATA/install.json" "$TMP/previous-install.json";fi
 # Save both service and access metadata with the current release so an older
 # binary is never restarted with new flags it does not understand.
 cp -- "$UNIT" "$ROOT/$OLD_CURRENT/manager.service"
 save_bbr_release "$ROOT/$OLD_CURRENT";save_update_release "$ROOT/$OLD_CURRENT"
 if [[ -f "$DATA/install.json" ]];then cp -- "$DATA/install.json" "$ROOT/$OLD_CURRENT/install.json";fi
 SWITCHED=1
 stop_service
 # Recheck after the final running manager request has drained.
 rollback_auth_guard
 ln -s "$previous" "$ROOT/.next-current-$$";mv -Tf "$ROOT/.next-current-$$" "$ROOT/current";SWITCHED=1
 ln -s "$OLD_CURRENT" "$ROOT/.next-previous-$$";mv -Tf "$ROOT/.next-previous-$$" "$ROOT/previous"
 if [[ -f "$ROOT/$previous/manager.service" ]];then cp -- "$ROOT/$previous/manager.service" "$UNIT";fi
 restore_bbr_release "$ROOT/$previous"
 restore_update_release "$ROOT/$previous"
 if [[ -f "$ROOT/$previous/install.json" ]];then cp -- "$ROOT/$previous/install.json" "$DATA/install.json";chmod 0600 "$DATA/install.json";fi
 stored_port="$(sed -n 's/.*--listen [^ ]*:\([0-9][0-9]*\).*/\1/p' "$UNIT")"
 [[ "$stored_port" =~ ^[0-9]{1,5}$ ]]&&PORT="$stored_port"
 start_service;SWITCHED=0;say 'Rollback complete. Existing data retained.';exit 0
fi
if [[ "$ACTION" == install ]]&&managed;then fail 'ALREADY_INSTALLED_USE_UPGRADE';fi
[[ -z "$SERVER" || "$SERVER" =~ ^[-A-Za-z0-9:.]+$ ]]||fail 'SERVER_INVALID'
[[ "$USERNAME" =~ ^[A-Za-z0-9_.-]{3,64}$ ]]||fail 'USERNAME_INVALID'
if ((SKIP_DEPS==0));then
 say 'Preparing operating-system dependencies…'
 export DEBIAN_FRONTEND=noninteractive
 apt-get update -qq
 apt-get install -y -qq ca-certificates curl python3 tar gzip nftables iproute2 openssl procps kmod util-linux >/dev/null
fi
for dependency in curl python3 tar sha256sum ss install nft tc ip sysctl modprobe flock;do command -v "$dependency" >/dev/null||fail "DEPENDENCY_MISSING_$dependency";done
if [[ "$ACTION" == install ]];then
 if ((REUSE_DATA==1));then
  python3 - "$DATA" <<'REUSECHECK'
import json,os,stat,sys
root=sys.argv[1]
for path,is_directory in [(root,True),(os.path.join(root,'manager.db'),False)]:
 try:s=os.lstat(path)
 except OSError:raise SystemExit('REUSE_DATA_UNAVAILABLE')
 if s.st_uid!=os.geteuid() or s.st_mode&0o077 or not (stat.S_ISDIR(s.st_mode) if is_directory else stat.S_ISREG(s.st_mode)):
  raise SystemExit('REUSE_DATA_UNSAFE')
REUSECHECK
  if ((PORT_EXPLICIT==0))&&[[ -f "$DATA/install.json"&&! -L "$DATA/install.json" ]];then
   stored_port="$(python3 - "$DATA/install.json" <<'READPORT'
import json,os,stat,sys
s=os.lstat(sys.argv[1])
if s.st_uid!=os.geteuid() or s.st_mode&0o077 or s.st_size>4096:raise SystemExit('INSTALL_SETTINGS_UNSAFE')
with open(sys.argv[1]) as f:p=json.load(f).get('panelPort',39090)
if not isinstance(p,int) or isinstance(p,bool) or not 0<p<65536:raise SystemExit('INSTALL_SETTINGS_INVALID')
print(p)
READPORT
)";PORT="$stored_port"
  fi
 else
  if [[ -e "$DATA" || -L "$DATA" ]];then fail 'DATA_DIRECTORY_EXISTS_USE_REUSE_DATA';fi
 fi
 if ss -H -ltn "sport = :$PORT"|grep -q .;then fail 'PANEL_PORT_IN_USE';fi
fi
discover_public_server(){
 python3 - <<'PUBLICIP'
import ipaddress,json,subprocess,sys


def public_ip(value,family=None):
    # A discovered address is never a hostname, private address or zone-scoped IP.
    if not isinstance(value,str) or len(value)>64 or '%' in value:
        return None
    try:
        address=ipaddress.ip_address(value)
    except ValueError:
        return None
    if (family and address.version!=family) or not address.is_global or address.is_multicast or address.is_reserved or getattr(address,'ipv4_mapped',None):
        return None
    return str(address)


def ip_json(*args):
    try:
        result=subprocess.run(['ip','-j',*args],stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,timeout=2,check=True)
        if len(result.stdout)>131072:
            return []
        value=json.loads(result.stdout)
        return value if isinstance(value,list) else []
    except (OSError,ValueError,subprocess.SubprocessError):
        return []


def local_address(family,interfaces):
    candidates=[]
    for interface in interfaces:
        for entry in interface.get('addr_info',[]):
            if entry.get('scope')!='global' or entry.get('preferred_life_time')==0 or any(entry.get(flag) or flag in entry.get('flags',[]) for flag in ['tentative','dadfailed','deprecated']):
                continue
            address=public_ip(entry.get('local'),family)
            if address:
                candidates.append(address)
    destination='1.1.1.1' if family==4 else '2606:4700:4700::1111'
    routes=ip_json('-'+str(family),'route','get',destination)
    for route in routes:
        source=public_ip(route.get('prefsrc') or route.get('src'),family)
        if source in candidates:
            return source
    # No usable default route: do not advertise an unrelated interface address.
    return None


def external_address(family):
    url='https://api.ipify.org' if family==4 else 'https://api6.ipify.org'
    process=None
    try:
        process=subprocess.Popen(['curl','--ipv'+str(family),'--proxy','','--noproxy','*','--proto','=https','--max-redirs','0','--connect-timeout','2','--max-time','4','--fail','--silent',url],stdout=subprocess.PIPE,stderr=subprocess.DEVNULL)
        value=process.stdout.read(65)
        if len(value)>64:
            return None
        if process.wait(timeout=1)!=0:
            return None
        # Permit a trailing newline, never whitespace within the address.
        return public_ip(value.decode('ascii').strip(),family)
    except (OSError,UnicodeError,subprocess.SubprocessError):
        return None
    finally:
        if process is not None:
            if process.poll() is None:
                process.kill()
            process.wait()
            process.stdout.close()


def discover():
    interfaces=ip_json('address','show','up','scope','global')
    return local_address(4,interfaces) or external_address(4) or local_address(6,interfaces) or external_address(6)


if __name__=='__main__':
    address=discover()
    if not address:
        sys.exit(1)
    print(address)
PUBLICIP
}
if [[ "$ACTION" == install&&-z "$SERVER" ]]&&((REUSE_DATA==0));then
 say 'Detecting this server’s public IP / 正在自动识别本机公网 IP…'
 if SERVER="$(discover_public_server)";then
  say "Server address / 服务器地址: $SERVER"
 else
  say 'Public IP detection failed / 未能识别公网 IP；请检查网络，或使用 --server 指定本机公网 IP 或域名。'
  if [[ -t 0 ]]&&((PASSWORD_STDIN==0));then
   read -r -p 'Server public IP or domain / 本机公网 IP 或域名: ' SERVER
   [[ -n "$SERVER" ]]||fail 'PUBLIC_IP_DETECTION_FAILED: retry with --server YOUR_PUBLIC_IP_OR_DOMAIN'
  else fail 'PUBLIC_IP_DETECTION_FAILED: retry with --server YOUR_PUBLIC_IP_OR_DOMAIN';fi
 fi
fi
[[ -z "$SERVER" || "$SERVER" =~ ^[-A-Za-z0-9:.]+$ ]]||fail 'SERVER_INVALID'
if ((ACCESS_EXPLICIT==0));then
 if [[ "$ACTION" == install ]]&&((REUSE_DATA==0));then ACCESS_MODE=public
 else
  mapfile -t access_settings < <(python3 - "$DATA/install.json" "$UNIT" <<'READACCESS'
import json,os,stat,sys,shlex
mode='loopback';origin=''
try:
 s=os.lstat(sys.argv[1])
 if not stat.S_ISREG(s.st_mode) or s.st_uid!=os.geteuid() or s.st_mode&0o077 or s.st_size>4096:raise SystemExit('INSTALL_SETTINGS_UNSAFE')
 with open(sys.argv[1]) as f:settings=json.load(f)
 mode=settings.get('panelAccess','loopback');origin=settings.get('panelOrigin','')
 if mode not in ('loopback','public') or not isinstance(origin,str) or any(c.isspace() for c in origin):raise SystemExit('INSTALL_SETTINGS_INVALID')
except FileNotFoundError:pass
print(mode);print(origin)
READACCESS
)
  ((${#access_settings[@]}==2))||fail 'INSTALL_SETTINGS_INVALID'
  ACCESS_MODE="${access_settings[0]}";PANEL_ORIGIN="${access_settings[1]}"
 fi
fi
if [[ "$ACCESS_MODE" == public ]];then
 PANEL_ORIGIN="$(python3 - "$SERVER" "$PANEL_ORIGIN" "$PORT" <<'PUBLICORIGIN'
import ipaddress,re,sys,urllib.parse
host=sys.argv[1]
if not host:
 try:host=urllib.parse.urlsplit(sys.argv[2]).hostname or ''
 except ValueError:raise SystemExit('PANEL_SERVER_REQUIRED')
if not host:raise SystemExit('PANEL_SERVER_REQUIRED: use --server YOUR_PUBLIC_IP_OR_DOMAIN with --public-http')
try:
 ip=ipaddress.ip_address(host)
 if ip.is_unspecified:raise SystemExit('PANEL_SERVER_INVALID')
 host=f'[{ip}]' if ip.version==6 else str(ip)
except ValueError:
 if len(host)>253 or not all(re.fullmatch(r'[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?',part) for part in host.split('.')):raise SystemExit('PANEL_SERVER_INVALID')
 host=host.lower()
print(f'http://{host}:{int(sys.argv[3])}')
PUBLICORIGIN
)"||fail 'PANEL_SERVER_INVALID'
 LISTEN_ADDRESS="0.0.0.0:$PORT"
 if [[ "$PANEL_ORIGIN" == http://\[* ]];then LISTEN_ADDRESS="[::]:$PORT";fi
 ACCESS_ARGS=" --origin $PANEL_ORIGIN --allow-http"
else
 PANEL_ORIGIN="http://127.0.0.1:$PORT"
 LISTEN_ADDRESS="127.0.0.1:$PORT"
 ACCESS_ARGS=''
fi
save_install_settings(){
python3 - "$DATA/install.json" "$PORT" "$ACCESS_MODE" "$PANEL_ORIGIN" <<'SAVEINSTALL'
import json,os,stat,sys,tempfile
path=sys.argv[1];settings={}
try:
 s=os.lstat(path)
 if not stat.S_ISREG(s.st_mode) or s.st_uid!=os.geteuid() or s.st_mode&0o077:raise SystemExit('INSTALL_SETTINGS_UNSAFE')
 with open(path) as f:settings=json.load(f)
except FileNotFoundError:pass
settings.update(panelPort=int(sys.argv[2]),panelAccess=sys.argv[3],panelOrigin=sys.argv[4])
fd,tmp=tempfile.mkstemp(prefix='.install-',dir=os.path.dirname(path))
try:
 with os.fdopen(fd,'w') as f:json.dump(settings,f);f.flush();os.fsync(f.fileno())
 os.replace(tmp,path)
finally:
 if os.path.exists(tmp):os.unlink(tmp)
SAVEINSTALL
}
if [[ "$ACTION" == access ]];then
 ((ACCESS_EXPLICIT==1))||fail 'PANEL_ACCESS_MODE_REQUIRED'
 serve_help="$("$ROOT/current/nb" serve --help 2>&1||true)"
 [[ "$serve_help" == *-allow-http* ]]||fail 'UPGRADE_REQUIRED_FOR_PUBLIC_ACCESS'
 TMP="$(mktemp -d /tmp/nodebuf-install.XXXXXXXX)"
 OLD_CURRENT="$(readlink "$ROOT/current")"
 [[ "$OLD_CURRENT" =~ ^releases/[0-9][0-9A-Za-z._-]*$ ]]||fail 'CURRENT_RELEASE_INVALID'
 cp -- "$UNIT" "$TMP/previous.service"
 if [[ -f "$DATA/install.json" ]];then cp -- "$DATA/install.json" "$TMP/previous-install.json";fi
 SWITCHED=1
 python3 - "$UNIT" "$LISTEN_ADDRESS" "$PANEL_ORIGIN" "$ACCESS_MODE" <<'CHANGEACCESS'
from pathlib import Path
import shlex,sys
p=Path(sys.argv[1]);lines=p.read_text().splitlines()
indices=[i for i,line in enumerate(lines) if line.startswith('ExecStart=')]
if len(indices)!=1:raise SystemExit('SERVICE_COMMAND_INVALID')
i=indices[0];args=shlex.split(lines[i][len('ExecStart='):])
if args[:2]!=['/opt/nodebuf-manager/current/nb','serve']:raise SystemExit('SERVICE_COMMAND_INVALID')
kept=args[:2];j=2
while j<len(args):
 if args[j] in ('--listen','--origin'):
  if j+1>=len(args):raise SystemExit('SERVICE_COMMAND_INVALID')
  j+=2
 elif args[j]=='--allow-http':j+=1
 else:kept.append(args[j]);j+=1
kept+=['--listen',sys.argv[2]]
if sys.argv[4]=='public':kept+=['--origin',sys.argv[3],'--allow-http']
if any(any(c.isspace() for c in arg) for arg in kept):raise SystemExit('SERVICE_COMMAND_INVALID')
lines[i]='ExecStart='+' '.join(kept);p.write_text('\n'.join(lines)+'\n')
CHANGEACCESS
 save_install_settings
 start_service
 SWITCHED=0
 say "Panel access updated: $PANEL_ORIGIN"
 if [[ "$ACCESS_MODE" == public ]];then say 'WARNING / 安全提醒：HTTP 不加密密码和会话，请尽快在“域名与 HTTPS”启用 HTTPS。';fi
 exit 0
fi
[[ "$MANIFEST_SHA256" =~ ^[0-9a-f]{64}$ ]]||fail 'RELEASE_MANIFEST_NOT_PINNED'
TMP="$(mktemp -d /tmp/nodebuf-install.XXXXXXXX)"
if [[ -n "$BUNDLE" ]];then [[ -d "$BUNDLE" ]]||fail 'BUNDLE_INVALID';cp -- "$BUNDLE/manifest.json" "$TMP/manifest.json";else curl --fail --location --proto '=https' --proto-redir '=https' --silent --show-error --max-time 30 --max-filesize 1048576 "$SOURCE/manifest.json" -o "$TMP/manifest.json";fi
printf '%s  %s\n' "$MANIFEST_SHA256" "$TMP/manifest.json"|sha256sum --check --status||fail 'MANIFEST_CHECKSUM_FAILED'
python3 - "$TMP/manifest.json" "$PLATFORM" "$NB_VERSION" "$CORE_VERSION" "$CORE_SHA256" >"$TMP/assets" <<'PY'
import json,re,sys
with open(sys.argv[1],'rb') as f: raw=f.read(1048577)
if len(raw)>1048576:raise SystemExit('MANIFEST_TOO_LARGE')
m=json.loads(raw);platform,version,core,official=sys.argv[2:]
if m.get('version')!=version or m.get('coreVersion')!=core:raise SystemExit('MANIFEST_VERSION_INVALID')
for section in ['assets','cores']:
 a=m[section][platform];name=a['file'];digest=a['sha256']
 if not re.fullmatch(r'[A-Za-z0-9._-]{1,128}\.tar\.gz',name) or not re.fullmatch(r'[0-9a-f]{64}',digest):raise SystemExit('MANIFEST_ASSET_INVALID')
 if section=='cores' and (name!=f'sing-box-{core}-{platform}.tar.gz' or digest!=official):raise SystemExit('OFFICIAL_CORE_CHECKSUM_MISMATCH')
 print(name);print(digest)
PY
mapfile -t assets <"$TMP/assets"
MANAGER_FILE="${assets[0]}";MANAGER_SHA="${assets[1]}";CORE_FILE="${assets[2]}";CORE_SHA="${assets[3]}"
for filename in "$MANAGER_FILE" "$CORE_FILE";do
 if [[ -n "$BUNDLE" ]];then
  cp -- "$BUNDLE/$filename" "$TMP/$filename"
 elif ! curl --fail --location --proto '=https' --proto-redir '=https' --silent --show-error --retry 2 --connect-timeout 15 --max-time 300 --max-filesize 157286400 "$SOURCE/$filename" -o "$TMP/$filename";then
  [[ "$filename" == "$CORE_FILE" ]]||fail 'MANAGER_DOWNLOAD_FAILED'
  curl --fail --location --proto '=https' --proto-redir '=https' --silent --show-error --retry 2 --connect-timeout 15 --max-time 300 --max-filesize 157286400 "https://github.com/SagerNet/sing-box/releases/download/v$CORE_VERSION/$filename" -o "$TMP/$filename"
 fi
done
printf '%s  %s\n%s  %s\n' "$MANAGER_SHA" "$TMP/$MANAGER_FILE" "$CORE_SHA" "$TMP/$CORE_FILE"|sha256sum --check --status||fail 'ARCHIVE_CHECKSUM_FAILED'
mkdir "$TMP/release"
python3 - "$TMP/$MANAGER_FILE" "$TMP/$CORE_FILE" "$TMP/release" "$CORE_VERSION" "$PLATFORM" <<'PY'
import os,pathlib,sys,tarfile
manager,core,dest,version,platform=sys.argv[1:]
for source,wanted,target in [(manager,'nb','nb'),(core,f'sing-box-{version}-{platform}/sing-box','sing-box')]:
 with tarfile.open(source,'r:gz') as archive:
  members=archive.getmembers()
  if len(members)>1000:raise SystemExit('ARCHIVE_ENTRY_LIMIT')
  matches=[]
  for m in members:
   p=pathlib.PurePosixPath(m.name)
   if p.is_absolute() or '..' in p.parts or not(m.isfile() or m.isdir()):raise SystemExit('ARCHIVE_UNSAFE_ENTRY')
   if m.name.lstrip('./')==wanted:
    if not m.isfile() or m.size<=0 or m.size>157286400:raise SystemExit('ARCHIVE_BINARY_INVALID')
    matches.append(m)
  if len(matches)!=1:raise SystemExit('ARCHIVE_BINARY_MISSING')
  with archive.extractfile(matches[0]) as src,open(os.path.join(dest,target),'xb') as out:
   remaining=matches[0].size
   while remaining:
    chunk=src.read(min(1024*1024,remaining))
    if not chunk:raise SystemExit('ARCHIVE_TRUNCATED')
    out.write(chunk);remaining-=len(chunk)
  os.chmod(os.path.join(dest,target),0o755)
PY
[[ "$("$TMP/release/sing-box" version|head -n1)" == "sing-box version $CORE_VERSION" ]]||fail 'CORE_VERSION_INVALID'
"$TMP/release/nb" version >/dev/null
if ((REUSE_DATA==1));then
 [[ "$ACTION" == install ]]||fail 'REUSE_DATA_INSTALL_ONLY'
 if [[ -f "$DATA/install.json"&&! -L "$DATA/install.json" ]];then cp -- "$DATA/install.json" "$TMP/previous-install.json";fi
 "$TMP/release/nb" doctor --data-dir "$DATA" --core "$TMP/release/sing-box" >"$TMP/doctor.json"
 python3 - "$TMP/doctor.json" <<'DOCTORCHECK'
import json,sys
with open(sys.argv[1]) as f:d=json.load(f)
if d.get('initialized') is not True or d.get('coreExists') is not True or d.get('root') is not True:raise SystemExit('REUSE_DATA_NOT_INITIALIZED')
DOCTORCHECK
fi
backup_bbr_unit
 backup_update_unit
if [[ "$ACTION" == upgrade ]];then cp -- "$UNIT" "$TMP/previous.service";if [[ -f "$DATA/install.json"&&! -L "$DATA/install.json" ]];then cp -- "$DATA/install.json" "$TMP/previous-install.json";fi;OLD_CURRENT="$(readlink "$ROOT/current")";[[ "$OLD_CURRENT" =~ ^releases/[0-9][0-9A-Za-z._-]*$ ]]||fail 'CURRENT_RELEASE_INVALID';cp -- "$UNIT" "$ROOT/$OLD_CURRENT/manager.service";save_bbr_release "$ROOT/$OLD_CURRENT";save_update_release "$ROOT/$OLD_CURRENT";if [[ -f "$TMP/previous-install.json" ]];then cp -- "$TMP/previous-install.json" "$ROOT/$OLD_CURRENT/install.json";fi;fi
RELEASE="releases/$NB_VERSION"
[[ ! -e "$ROOT/$RELEASE"&&! -L "$ROOT/$RELEASE" ]]||fail 'RELEASE_ALREADY_INSTALLED'
if [[ "$ACTION" == install ]];then FRESH_INSTALL=1;fi
install -d -m 0755 "$ROOT" "$ROOT/releases" "$ROOT/core"
printf 'nodebuf-manager-v1\n' >"$ROOT/.nodebuf-manager"
NEW_RELEASE="$RELEASE"
mv -- "$TMP/release" "$ROOT/$RELEASE"
# Keep the exact reviewed installer with this release for offline nb uninstall.
[[ -f "${BASH_SOURCE[0]}"&&! -L "${BASH_SOURCE[0]}" ]]||fail 'INSTALLER_SOURCE_UNAVAILABLE_USE_DOWNLOADED_FILE'
[[ "$(stat -c '%s' -- "${BASH_SOURCE[0]}")" -le 1048576 ]]||fail 'INSTALLER_SOURCE_TOO_LARGE'
install -m 0644 -- "${BASH_SOURCE[0]}" "$ROOT/$RELEASE/singbox.sh"
install -m 0644 -- "$TMP/manifest.json" "$ROOT/$RELEASE/manifest.json"
read -r installer_digest installer_name < <(sha256sum -- "$ROOT/$RELEASE/singbox.sh")
printf '%s\n' "$installer_digest" >"$ROOT/$RELEASE/singbox.sh.sha256"
chmod 0644 "$ROOT/$RELEASE/singbox.sh.sha256"
generate_install_password(){
 python3 - <<'INSTALLPASSWORD'
import secrets,string
alphabet=string.ascii_letters+string.digits+'-_'
print(''.join(secrets.choice(alphabet) for _ in range(20)))
INSTALLPASSWORD
}
if [[ "$ACTION" == install ]];then
 if ((REUSE_DATA==0));then
  DATA_CREATED=1
  install -d -m 0700 "$DATA"
  init_args=(init --data-dir "$DATA" --core "$ROOT/$RELEASE/sing-box" --server "$SERVER" --username "$USERNAME")
  init_args+=(--password-stdin)
  if ((PASSWORD_STDIN==1));then
   "$ROOT/$RELEASE/nb" "${init_args[@]}"
  else
   GENERATED_PASSWORD="$(generate_install_password)"
   [[ "$GENERATED_PASSWORD" =~ ^[A-Za-z0-9_-]{20}$ ]]||fail 'PASSWORD_GENERATION_FAILED'
   printf '%s\n' "$GENERATED_PASSWORD" | "$ROOT/$RELEASE/nb" "${init_args[@]}"
  fi
 else say 'Reusing retained private data; existing account, server settings and usage are preserved.';fi
else backup_previous_link;SWITCHED=1;stop_service;fi
ln -s "$RELEASE" "$ROOT/.next-current-$$";mv -Tf "$ROOT/.next-current-$$" "$ROOT/current";SWITCHED=1
if [[ -n "$OLD_CURRENT" ]];then ln -s "$OLD_CURRENT" "$ROOT/.next-previous-$$";mv -Tf "$ROOT/.next-previous-$$" "$ROOT/previous";fi
ln -sfn "$ROOT/current/nb" "$COMMAND"
ln -sfn '../current/sing-box' "$ROOT/core/sing-box"
cat >"$UNIT" <<UNITFILE
# Managed by Nodebuf Manager
[Unit]
Description=Nodebuf sing-box Manager
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=$ROOT/current/nb serve --data-dir $DATA --core $ROOT/core/sing-box --listen $LISTEN_ADDRESS$ACCESS_ARGS
WorkingDirectory=$DATA
User=root
UMask=0077
Restart=on-failure
RestartSec=3s
TimeoutStopSec=15s
KillMode=mixed
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=$DATA /etc/sysctl.d
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_NET_RAW CAP_DAC_OVERRIDE CAP_FOWNER
LimitNOFILE=1048576
TasksMax=512
MemoryMax=1G
[Install]
WantedBy=multi-user.target
UNITFILE
chmod 0644 "$UNIT"
write_bbr_unit
write_update_unit
save_install_settings
start_service
# The installation is committed before writing optional terminal instructions.
# A closed SSH output stream must never roll back an already healthy instance.
SWITCHED=0 FRESH_INSTALL=0 DATA_CREATED=0 NEW_RELEASE=''
say '安装完成 / Installed successfully'
say '网页管理面板 / Web UI（复制到浏览器）:'
printf '%s/\n' "${PANEL_ORIGIN%/}"
if [[ "$ACTION" == install ]]&&((REUSE_DATA==0));then
 say "管理员用户名 / Username: $USERNAME"
 if [[ -n "$GENERATED_PASSWORD" ]];then
  printf '\033[1;36m[Nodebuf]\033[0m 管理员密码 / Password: %s\n' "$GENERATED_PASSWORD"
  unset GENERATED_PASSWORD
  say '请保存上述密码；安装器不保存明文密码，之后可运行 nb password 修改。'
 else say '管理员密码：使用你通过标准输入设置的密码。';fi
else say '管理员账号、密码与已有节点保持不变。';fi
say '开始使用：在浏览器打开面板地址，或在终端输入 nb 打开菜单。'
say '常用命令：nb list 查看节点 · nb status 查看状态 · nb panel 查看面板地址'
say '节点管理：nb add reality 快速创建 · nb export 节点ID singbox 导出客户端配置'
say '帮助与卸载：nb help 查看全部命令 · nb uninstall 卸载程序（默认保留节点和用量）'
if [[ "$ACCESS_MODE" == public ]];then
 say 'WARNING / 安全提醒：HTTP 不加密密码和会话，请尽快在“域名与 HTTPS”配置证书并启用 HTTPS。'
 say 'HTTPS: domain DNS → certificate → enable HTTPS (default TCP 8443). Allow the panel port in your server firewall/cloud security group.'
else
 say "Local-only access. SSH forwarding: ssh -L $PORT:127.0.0.1:$PORT root@$SERVER"
 say 'Or configure managed HTTPS.'
fi
