933 lines
26 KiB
Bash
933 lines
26 KiB
Bash
#!/usr/bin/env bash
|
|
set -Eeuo pipefail
|
|
IFS=$'\n\t'
|
|
|
|
SCRIPT_VERSION="2026-04-08"
|
|
DEFAULT_BASE_DIR="${HOME}/neko-instances"
|
|
DEFAULT_HTTP_PORT_START=8080
|
|
DEFAULT_DISPLAY_START=99
|
|
DEFAULT_EPR_START=56000
|
|
DEFAULT_EPR_SIZE=100
|
|
DEFAULT_SCREEN="1920x1080@60"
|
|
DEFAULT_LANG="C.UTF-8"
|
|
DEFAULT_TZ="UTC"
|
|
DEFAULT_ACCESS_IP=""
|
|
DEFAULT_MEMBER_PASSWORD="12345678"
|
|
DEFAULT_ADMIN_PASSWORD="12345678"
|
|
DEFAULT_LIVE_USERNAME="live"
|
|
DEFAULT_ADMIN_USERNAME="admin"
|
|
TAMPERMONKEY_ID="dhdgffkkebhmkfjojejmpbldmpobfkfo"
|
|
TAMPERMONKEY_UPDATE_URL="https://clients2.google.com/service/update2/crx"
|
|
TAMPERMONKEY_KEY="MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDjiyuc6OWY8gaVTe+b16fH2BBe0PQLMeUpEXSQvyv5a/6OiQ1D8bBLTfLvApD3zT2MZoXWu2KUILdkyg5OC/Tru8m+Js6e3RjHY9Rqbvnh8CJQgTJ+63L5w9aLsTvA2fqdDfhw8Mnl1GMcJd/RI/ZiBEm0stog0ZfyQjD1jpSEXQIDAQAB"
|
|
TAMPERMONKEY_API_PERMS='["alarms","clipboardWrite","contextMenus","cookies","idle","notifications","storage","tabs","unlimitedStorage","webNavigation","webRequest","webRequestBlocking","scripting","declarativeNetRequestWithHostAccess","offscreen","userScripts"]'
|
|
TAMPERMONKEY_MANIFEST_PERMS='["notifications","unlimitedStorage","tabs","idle","webNavigation","webRequest","webRequestBlocking","storage","contextMenus","chrome://favicon/","clipboardWrite","cookies","alarms","declarativeNetRequestWithHostAccess","scripting","userScripts","offscreen"]'
|
|
TAMPERMONKEY_HOST_PERMS='["<all_urls>"]'
|
|
TAMPERMONKEY_OPTIONAL_PERMS='["downloads"]'
|
|
DRY_RUN=false
|
|
YES=false
|
|
BASE_DIR="${DEFAULT_BASE_DIR}"
|
|
CLI_NAME=""
|
|
|
|
COMPOSE_BIN=()
|
|
|
|
info() { printf '[INFO] %s\n' "$*"; }
|
|
warn() { printf '[WARN] %s\n' "$*" >&2; }
|
|
die() { printf '[ERROR] %s\n' "$*" >&2; exit 1; }
|
|
|
|
need_cmd() {
|
|
command -v "$1" >/dev/null 2>&1 || die "Missing command: $1"
|
|
}
|
|
|
|
detect_compose() {
|
|
if docker compose version >/dev/null 2>&1; then
|
|
COMPOSE_BIN=(docker compose)
|
|
elif command -v docker-compose >/dev/null 2>&1; then
|
|
COMPOSE_BIN=(docker-compose)
|
|
else
|
|
die "docker compose / docker-compose not found"
|
|
fi
|
|
}
|
|
|
|
compose_run() {
|
|
"${COMPOSE_BIN[@]}" "$@"
|
|
}
|
|
|
|
detect_tz() {
|
|
local tz=""
|
|
tz="$(timedatectl show --property=Timezone --value 2>/dev/null || true)"
|
|
if [[ -z "$tz" && -f /etc/timezone ]]; then
|
|
tz="$(< /etc/timezone)"
|
|
fi
|
|
if [[ -z "$tz" && -L /etc/localtime ]]; then
|
|
tz="$(readlink /etc/localtime | sed 's#^.*/zoneinfo/##')"
|
|
fi
|
|
printf '%s' "${tz:-$DEFAULT_TZ}"
|
|
}
|
|
|
|
detect_primary_ip() {
|
|
local ip=""
|
|
if command -v ip >/dev/null 2>&1; then
|
|
ip="$(ip route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1); exit}}')"
|
|
fi
|
|
if [[ -z "$ip" ]]; then
|
|
ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
|
|
fi
|
|
printf '%s' "$ip"
|
|
}
|
|
|
|
detect_public_ip() {
|
|
local ip=""
|
|
if command -v curl >/dev/null 2>&1; then
|
|
ip="$(curl -4fsS --max-time 3 https://api.ipify.org 2>/dev/null || true)"
|
|
elif command -v wget >/dev/null 2>&1; then
|
|
ip="$(wget -4qO- --timeout=3 https://api.ipify.org 2>/dev/null || true)"
|
|
fi
|
|
printf '%s' "$ip"
|
|
}
|
|
|
|
detect_arch() {
|
|
case "$(uname -m)" in
|
|
x86_64|amd64) printf 'amd64' ;;
|
|
aarch64|arm64) printf 'arm64' ;;
|
|
*) die "Unsupported architecture: $(uname -m)" ;;
|
|
esac
|
|
}
|
|
|
|
default_browser_for_arch() {
|
|
case "$1" in
|
|
amd64) printf 'google-chrome' ;;
|
|
arm64) printf 'chromium' ;;
|
|
*) die "Unknown architecture: $1" ;;
|
|
esac
|
|
}
|
|
|
|
default_image_for_browser() {
|
|
case "$1" in
|
|
google-chrome) printf 'ghcr.io/m1k1o/neko/google-chrome:latest' ;;
|
|
chromium) printf 'ghcr.io/m1k1o/neko/chromium:latest' ;;
|
|
*) die "Unknown browser type: $1" ;;
|
|
esac
|
|
}
|
|
|
|
default_display_start_for_browser() {
|
|
case "$1" in
|
|
google-chrome) printf '100' ;;
|
|
chromium) printf '99' ;;
|
|
*) printf '%s' "$DEFAULT_DISPLAY_START" ;;
|
|
esac
|
|
}
|
|
|
|
browser_bin_for() {
|
|
case "$1" in
|
|
google-chrome) printf '/usr/bin/google-chrome' ;;
|
|
chromium) printf '/usr/bin/chromium' ;;
|
|
*) die "Unknown browser type: $1" ;;
|
|
esac
|
|
}
|
|
|
|
profile_path_for() {
|
|
case "$1" in
|
|
google-chrome) printf '/home/neko/.config/google-chrome' ;;
|
|
chromium) printf '/home/neko/.config/chromium' ;;
|
|
*) die "Unknown browser type: $1" ;;
|
|
esac
|
|
}
|
|
|
|
policy_target_for() {
|
|
case "$1" in
|
|
google-chrome) printf '/etc/opt/chrome/policies/managed/policies.json' ;;
|
|
chromium) printf '/etc/chromium/policies/managed/policies.json' ;;
|
|
*) die "Unknown browser type: $1" ;;
|
|
esac
|
|
}
|
|
|
|
supervisor_target_for() {
|
|
case "$1" in
|
|
google-chrome) printf '/etc/neko/supervisord/google-chrome.conf' ;;
|
|
chromium) printf '/etc/neko/supervisord/chromium.conf' ;;
|
|
*) die "Unknown browser type: $1" ;;
|
|
esac
|
|
}
|
|
|
|
initial_prefs_target_for() {
|
|
case "$1" in
|
|
google-chrome) printf '/opt/google/chrome/initial_preferences' ;;
|
|
chromium) printf '/etc/chromium/initial_preferences' ;;
|
|
*) die "Unknown browser type: $1" ;;
|
|
esac
|
|
}
|
|
|
|
legacy_prefs_target_for() {
|
|
case "$1" in
|
|
google-chrome) printf '/opt/google/chrome/master_preferences' ;;
|
|
chromium) printf '/etc/chromium/master_preferences' ;;
|
|
*) die "Unknown browser type: $1" ;;
|
|
esac
|
|
}
|
|
|
|
container_name_for() {
|
|
printf 'neko-%s' "$1"
|
|
}
|
|
|
|
sanitize_name() {
|
|
local raw="$1"
|
|
if [[ ! "$raw" =~ ^[a-zA-Z0-9._-]+$ ]]; then
|
|
die "Instance name may only contain letters, numbers, dots, underscores, and dashes"
|
|
fi
|
|
printf '%s' "$raw"
|
|
}
|
|
|
|
prompt() {
|
|
local label="$1" default="$2" result=""
|
|
read -r -p "$label [$default]: " result || true
|
|
printf '%s' "${result:-$default}"
|
|
}
|
|
|
|
confirm() {
|
|
local label="$1"
|
|
local answer=""
|
|
read -r -p "$label [y/N]: " answer || true
|
|
[[ "$answer" =~ ^[Yy]([Ee][Ss])?$ ]]
|
|
}
|
|
|
|
port_in_use() {
|
|
local port="$1"
|
|
if command -v ss >/dev/null 2>&1; then
|
|
ss -Htan "( sport = :$port )" 2>/dev/null | grep -q .
|
|
return $?
|
|
fi
|
|
python3 - "$port" <<'PY' >/dev/null 2>&1
|
|
import socket, sys
|
|
port = int(sys.argv[1])
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
try:
|
|
s.bind(("0.0.0.0", port))
|
|
except OSError:
|
|
raise SystemExit(1)
|
|
raise SystemExit(0)
|
|
PY
|
|
}
|
|
|
|
udp_port_in_use() {
|
|
local port="$1"
|
|
if command -v ss >/dev/null 2>&1; then
|
|
ss -Huan "( sport = :$port )" 2>/dev/null | grep -q .
|
|
return $?
|
|
fi
|
|
python3 - "$port" <<'PY' >/dev/null 2>&1
|
|
import socket, sys
|
|
port = int(sys.argv[1])
|
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
|
try:
|
|
s.bind(("0.0.0.0", port))
|
|
except OSError:
|
|
raise SystemExit(1)
|
|
raise SystemExit(0)
|
|
PY
|
|
}
|
|
|
|
next_free_http_port() {
|
|
local port="${1:-$DEFAULT_HTTP_PORT_START}"
|
|
while port_in_use "$port"; do
|
|
port=$((port + 1))
|
|
done
|
|
printf '%s' "$port"
|
|
}
|
|
|
|
next_free_display() {
|
|
local display="${1:-$DEFAULT_DISPLAY_START}"
|
|
while true; do
|
|
if ! grep -Rqs "^DISPLAY_NUM=${display}$" "$BASE_DIR" 2>/dev/null && ! running_container_uses_display "$display"; then
|
|
printf '%s' "$display"
|
|
return 0
|
|
fi
|
|
display=$((display + 1))
|
|
done
|
|
}
|
|
|
|
running_container_uses_display() {
|
|
local wanted="$1"
|
|
local cid current
|
|
while read -r cid; do
|
|
[[ -n "$cid" ]] || continue
|
|
current="$(
|
|
docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$cid" 2>/dev/null \
|
|
| awk -F= '/^DISPLAY=:/ {gsub(/^:/,"",$2); gsub(/\.0$/,"",$2); print $2; exit}'
|
|
)"
|
|
if [[ "$current" == "$wanted" ]]; then
|
|
return 0
|
|
fi
|
|
done < <(docker ps -q)
|
|
return 1
|
|
}
|
|
|
|
next_free_epr_start() {
|
|
local start="${1:-$DEFAULT_EPR_START}"
|
|
local end=""
|
|
while true; do
|
|
end=$((start + DEFAULT_EPR_SIZE - 1))
|
|
local conflict=false port
|
|
for port in $(seq "$start" "$end"); do
|
|
if udp_port_in_use "$port"; then
|
|
conflict=true
|
|
break
|
|
fi
|
|
done
|
|
if [[ "$conflict" == false ]] && ! grep -Rqs "^EPR_FROM=${start}$" "$BASE_DIR" 2>/dev/null; then
|
|
printf '%s' "$start"
|
|
return 0
|
|
fi
|
|
start=$((start + DEFAULT_EPR_SIZE))
|
|
done
|
|
}
|
|
|
|
ensure_base_layout() {
|
|
mkdir -p "$BASE_DIR"
|
|
}
|
|
|
|
write_instance_env() {
|
|
local path="$1"
|
|
cat > "$path/instance.env" <<EOF
|
|
INSTANCE_NAME=${INSTANCE_NAME}
|
|
CONTAINER_NAME=${CONTAINER_NAME}
|
|
BROWSER=${BROWSER}
|
|
IMAGE=${IMAGE}
|
|
HTTP_PORT=${HTTP_PORT}
|
|
DISPLAY_NUM=${DISPLAY_NUM}
|
|
EPR_FROM=${EPR_FROM}
|
|
EPR_TO=${EPR_TO}
|
|
ACCESS_IP=${ACCESS_IP}
|
|
TZ_VALUE=${TZ_VALUE}
|
|
LANG_VALUE=${LANG_VALUE}
|
|
PROFILE_PATH=${PROFILE_PATH}
|
|
EOF
|
|
}
|
|
|
|
write_members_json() {
|
|
local path="$1"
|
|
cat > "$path/members.json" <<EOF
|
|
{
|
|
"${DEFAULT_ADMIN_USERNAME}": {
|
|
"password": "${DEFAULT_ADMIN_PASSWORD}",
|
|
"profile": {
|
|
"name": "Administrator",
|
|
"is_admin": true,
|
|
"can_login": true,
|
|
"can_connect": true,
|
|
"can_watch": true,
|
|
"can_host": true,
|
|
"can_share_media": true,
|
|
"can_access_clipboard": true,
|
|
"sends_inactive_cursor": true,
|
|
"can_see_inactive_cursors": true,
|
|
"plugins": {}
|
|
}
|
|
},
|
|
"${DEFAULT_LIVE_USERNAME}": {
|
|
"password": "${DEFAULT_MEMBER_PASSWORD}",
|
|
"profile": {
|
|
"name": "Live",
|
|
"is_admin": false,
|
|
"can_login": true,
|
|
"can_connect": true,
|
|
"can_watch": true,
|
|
"can_host": true,
|
|
"can_share_media": true,
|
|
"can_access_clipboard": true,
|
|
"sends_inactive_cursor": true,
|
|
"can_see_inactive_cursors": true,
|
|
"plugins": {}
|
|
}
|
|
}
|
|
}
|
|
EOF
|
|
}
|
|
|
|
write_sessions_json() {
|
|
local path="$1"
|
|
printf '[]\n' > "$path/sessions.json"
|
|
}
|
|
|
|
write_policies_json() {
|
|
local path="$1"
|
|
printf '{}\n' > "$path/policies.json"
|
|
}
|
|
|
|
write_browser_prefs_json() {
|
|
local path="$1"
|
|
cat > "$path/browser-prefs.json" <<EOF
|
|
{
|
|
"distribution": {
|
|
"skip_first_run_ui": true,
|
|
"make_chrome_default": false,
|
|
"make_chrome_default_for_user": false,
|
|
"suppress_first_run_default_browser_prompt": true,
|
|
"create_all_shortcuts": false
|
|
},
|
|
"browser": {
|
|
"check_default_browser": false
|
|
},
|
|
"bookmark_bar": {
|
|
"show_on_all_tabs": true
|
|
},
|
|
"download": {
|
|
"default_directory": "/home/neko/Downloads",
|
|
"prompt_for_download": false
|
|
},
|
|
"extensions": {
|
|
"ui": {
|
|
"developer_mode": true
|
|
},
|
|
"settings": {
|
|
"${TAMPERMONKEY_ID}": {
|
|
"location": 1,
|
|
"manifest": {
|
|
"key": "${TAMPERMONKEY_KEY}",
|
|
"name": "Tampermonkey",
|
|
"short_name": "Tampermonkey",
|
|
"description": "Change the web at will with userscripts",
|
|
"manifest_version": 3,
|
|
"minimum_chrome_version": "120",
|
|
"version": "0.0",
|
|
"update_url": "${TAMPERMONKEY_UPDATE_URL}",
|
|
"permissions": ${TAMPERMONKEY_MANIFEST_PERMS},
|
|
"host_permissions": ${TAMPERMONKEY_HOST_PERMS},
|
|
"optional_permissions": ${TAMPERMONKEY_OPTIONAL_PERMS},
|
|
"incognito": "split",
|
|
"offline_enabled": true
|
|
},
|
|
"granted_permissions": {
|
|
"api": ${TAMPERMONKEY_API_PERMS},
|
|
"explicit_host": ${TAMPERMONKEY_HOST_PERMS},
|
|
"manifest_permissions": [],
|
|
"scriptable_host": []
|
|
},
|
|
"path": "${TAMPERMONKEY_ID}\\\\0.0",
|
|
"state": 1
|
|
}
|
|
}
|
|
},
|
|
"homepage_is_newtabpage": false,
|
|
"homepage": "https://www.google.com/ncr",
|
|
"session": {
|
|
"restore_on_startup": 4,
|
|
"startup_urls": [
|
|
"https://www.google.com/ncr"
|
|
]
|
|
},
|
|
"intl": {
|
|
"accept_languages": "en-US,en,zh-CN,zh"
|
|
},
|
|
"credentials_enable_service": false,
|
|
"credentials_enable_autosignin": false
|
|
}
|
|
EOF
|
|
}
|
|
|
|
write_browser_conf() {
|
|
local path="$1"
|
|
local program_name browser_bin
|
|
program_name="${BROWSER}"
|
|
browser_bin="$(browser_bin_for "$BROWSER")"
|
|
cat > "$path/browser.conf" <<EOF
|
|
[program:${program_name}]
|
|
environment=HOME="/home/%(ENV_USER)s",USER="%(ENV_USER)s",DISPLAY="%(ENV_DISPLAY)s",TZ="%(ENV_TZ)s",LANG="%(ENV_LANG)s",LC_ALL="%(ENV_LC_ALL)s"
|
|
command=${browser_bin}
|
|
--window-position=0,0
|
|
--display=%(ENV_DISPLAY)s
|
|
--user-data-dir=${PROFILE_PATH}
|
|
--password-store=basic
|
|
--no-first-run
|
|
--no-default-browser-check
|
|
--start-maximized
|
|
--disable-background-timer-throttling
|
|
--disable-backgrounding-occluded-windows
|
|
--disable-renderer-backgrounding
|
|
--disable-gpu
|
|
stopsignal=INT
|
|
autorestart=true
|
|
priority=800
|
|
user=%(ENV_USER)s
|
|
stdout_logfile=/var/log/neko/${program_name}.log
|
|
stdout_logfile_maxbytes=100MB
|
|
stdout_logfile_backups=10
|
|
redirect_stderr=true
|
|
|
|
[program:openbox]
|
|
environment=HOME="/home/%(ENV_USER)s",USER="%(ENV_USER)s",DISPLAY="%(ENV_DISPLAY)s"
|
|
command=/usr/bin/openbox --config-file /etc/neko/openbox.xml
|
|
autorestart=true
|
|
priority=300
|
|
user=%(ENV_USER)s
|
|
stdout_logfile=/var/log/neko/openbox.log
|
|
stdout_logfile_maxbytes=100MB
|
|
stdout_logfile_backups=10
|
|
redirect_stderr=true
|
|
EOF
|
|
}
|
|
|
|
write_supervisord_main_conf() {
|
|
local path="$1"
|
|
cat > "$path/supervisord.conf" <<'EOF'
|
|
[supervisord]
|
|
nodaemon=true
|
|
user=root
|
|
pidfile=/var/run/supervisord.pid
|
|
logfile=/dev/null
|
|
logfile_maxbytes=0
|
|
loglevel=debug
|
|
|
|
[include]
|
|
files=/etc/neko/supervisord/*.conf
|
|
|
|
[program:x-server]
|
|
environment=HOME="/home/%(ENV_USER)s",USER="%(ENV_USER)s"
|
|
command=/usr/bin/X %(ENV_DISPLAY)s -config /etc/neko/xorg.conf -noreset -nolisten tcp
|
|
autorestart=true
|
|
priority=300
|
|
user=%(ENV_USER)s
|
|
stdout_logfile=/var/log/neko/xorg.log
|
|
stdout_logfile_maxbytes=100MB
|
|
stdout_logfile_backups=10
|
|
redirect_stderr=true
|
|
|
|
[program:pulseaudio]
|
|
environment=HOME="/home/%(ENV_USER)s",USER="%(ENV_USER)s",DISPLAY="%(ENV_DISPLAY)s"
|
|
command=/usr/bin/pulseaudio --log-level=error --disallow-module-loading --disallow-exit --exit-idle-time=-1
|
|
autorestart=true
|
|
priority=300
|
|
user=%(ENV_USER)s
|
|
stdout_logfile=/var/log/neko/pulseaudio.log
|
|
stdout_logfile_maxbytes=100MB
|
|
stdout_logfile_backups=10
|
|
redirect_stderr=true
|
|
|
|
[program:neko]
|
|
environment=HOME="/home/%(ENV_USER)s",USER="%(ENV_USER)s",DISPLAY="%(ENV_DISPLAY)s"
|
|
command=/bin/sh -lc 'for i in $(seq 1 100); do [ -S /tmp/xf86-input-neko.sock ] && break; sleep 0.2; done; exec /usr/bin/neko serve --server.static "/var/www"'
|
|
stopsignal=INT
|
|
stopwaitsecs=3
|
|
autorestart=true
|
|
priority=800
|
|
user=%(ENV_USER)s
|
|
stdout_logfile=/var/log/neko/neko.log
|
|
stdout_logfile_maxbytes=100MB
|
|
stdout_logfile_backups=10
|
|
redirect_stderr=true
|
|
|
|
[unix_http_server]
|
|
file=/var/run/supervisor.sock
|
|
chmod=0770
|
|
chown=root:neko
|
|
|
|
[supervisorctl]
|
|
serverurl=unix:///var/run/supervisor.sock
|
|
|
|
[rpcinterface:supervisor]
|
|
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
|
|
EOF
|
|
}
|
|
|
|
write_compose_yaml() {
|
|
local path="$1"
|
|
local policy_target supervisor_target prefs_primary prefs_legacy
|
|
policy_target="$(policy_target_for "$BROWSER")"
|
|
supervisor_target="$(supervisor_target_for "$BROWSER")"
|
|
prefs_primary="$(initial_prefs_target_for "$BROWSER")"
|
|
prefs_legacy="$(legacy_prefs_target_for "$BROWSER")"
|
|
|
|
cat > "$path/docker-compose.yml" <<EOF
|
|
services:
|
|
${CONTAINER_NAME}:
|
|
image: ${IMAGE}
|
|
container_name: ${CONTAINER_NAME}
|
|
restart: unless-stopped
|
|
network_mode: host
|
|
shm_size: "8gb"
|
|
privileged: true
|
|
security_opt:
|
|
- seccomp=unconfined
|
|
environment:
|
|
DISPLAY: ":${DISPLAY_NUM}.0"
|
|
TZ: "${TZ_VALUE}"
|
|
LANG: "${LANG_VALUE}"
|
|
LC_ALL: "${LANG_VALUE}"
|
|
NEKO_DESKTOP_SCREEN: "${SCREEN_VALUE}"
|
|
NEKO_SERVER_BIND: ":${HTTP_PORT}"
|
|
NEKO_MEMBER_PROVIDER: "file"
|
|
NEKO_MEMBER_FILE_PATH: "/etc/neko/members.json"
|
|
NEKO_MEMBER_FILE_HASH: "false"
|
|
NEKO_SESSION_FILE: "/opt/neko/sessions.json"
|
|
NEKO_SESSION_COOKIE_ENABLED: "true"
|
|
NEKO_SESSION_COOKIE_SECURE: "false"
|
|
NEKO_SESSION_COOKIE_HTTP_ONLY: "true"
|
|
NEKO_SESSION_COOKIE_EXPIRATION: "168h0m0s"
|
|
NEKO_SESSION_LOCKED_LOGINS: "false"
|
|
NEKO_SESSION_LOCKED_CONTROLS: "false"
|
|
NEKO_SESSION_CONTROL_PROTECTION: "false"
|
|
NEKO_SESSION_IMPLICIT_HOSTING: "true"
|
|
NEKO_SESSION_INACTIVE_CURSORS: "false"
|
|
NEKO_SESSION_MERCIFUL_RECONNECT: "true"
|
|
NEKO_SESSION_HEARTBEAT_INTERVAL: "120"
|
|
NEKO_WEBRTC_EPR: "${EPR_FROM}-${EPR_TO}"
|
|
NEKO_WEBRTC_ICELITE: "true"
|
|
NEKO_WEBRTC_NAT1TO1: "${ACCESS_IP}"
|
|
NEKO_WEBRTC_ICESERVERS_FRONTEND: '[{"urls":["stun:stun.l.google.com:19302"]},{"urls":["stun:stun.l.google.com:19305"]}]'
|
|
NEKO_WEBRTC_ICESERVERS_BACKEND: '[{"urls":["stun:stun.l.google.com:19302"]}]'
|
|
NEKO_PLUGINS_ENABLED: "true"
|
|
NEKO_PLUGINS_DIR: "/etc/neko/plugins/"
|
|
volumes:
|
|
- "${path}/profile:${PROFILE_PATH}"
|
|
- "${path}/downloads:/home/neko/Downloads"
|
|
- "${path}/members.json:/etc/neko/members.json"
|
|
- "${path}/sessions.json:/opt/neko/sessions.json"
|
|
- "${path}/policies.json:${policy_target}:ro"
|
|
- "${path}/browser-prefs.json:${prefs_primary}:ro"
|
|
- "${path}/browser-prefs.json:${prefs_legacy}:ro"
|
|
- "${path}/browser.conf:${supervisor_target}:ro"
|
|
- "${path}/supervisord.conf:/etc/neko/supervisord.conf:ro"
|
|
EOF
|
|
}
|
|
|
|
prepare_instance_dir() {
|
|
local path="$1"
|
|
mkdir -p "$path/profile" "$path/downloads"
|
|
write_instance_env "$path"
|
|
write_members_json "$path"
|
|
write_sessions_json "$path"
|
|
write_policies_json "$path"
|
|
write_browser_prefs_json "$path"
|
|
write_browser_conf "$path"
|
|
write_supervisord_main_conf "$path"
|
|
write_compose_yaml "$path"
|
|
|
|
chmod 644 "$path/browser.conf" "$path/browser-prefs.json" "$path/docker-compose.yml" "$path/instance.env" "$path/policies.json" "$path/supervisord.conf"
|
|
chmod 664 "$path/members.json" "$path/sessions.json"
|
|
chown -R 1000:1000 "$path/profile" "$path/downloads" "$path/members.json" "$path/sessions.json" 2>/dev/null || true
|
|
}
|
|
|
|
open_firewall_ports() {
|
|
local http_port="$1" epr_from="$2" epr_to="$3"
|
|
if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q '^Status: active'; then
|
|
if ! $DRY_RUN; then
|
|
ufw allow "${http_port}/tcp" >/dev/null || true
|
|
ufw allow "${epr_from}:${epr_to}/udp" >/dev/null || true
|
|
fi
|
|
info "Attempted to add UFW rules: TCP ${http_port}, UDP ${epr_from}-${epr_to}"
|
|
return 0
|
|
fi
|
|
|
|
if command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then
|
|
if ! $DRY_RUN; then
|
|
firewall-cmd --permanent --add-port="${http_port}/tcp" >/dev/null || true
|
|
firewall-cmd --permanent --add-port="${epr_from}-${epr_to}/udp" >/dev/null || true
|
|
firewall-cmd --reload >/dev/null || true
|
|
fi
|
|
info "Attempted to add firewalld rules: TCP ${http_port}, UDP ${epr_from}-${epr_to}"
|
|
return 0
|
|
fi
|
|
|
|
warn "No active UFW/firewalld detected. Make sure TCP ${http_port} and UDP ${epr_from}-${epr_to} are allowed by your firewall/security group."
|
|
}
|
|
|
|
list_instances() {
|
|
ensure_base_layout
|
|
local found=false dir name status env_file
|
|
for dir in "$BASE_DIR"/*; do
|
|
[[ -d "$dir" ]] || continue
|
|
env_file="$dir/instance.env"
|
|
[[ -f "$env_file" ]] || continue
|
|
found=true
|
|
# shellcheck disable=SC1090
|
|
source "$env_file"
|
|
status="missing"
|
|
if docker inspect "$CONTAINER_NAME" >/dev/null 2>&1; then
|
|
status="$(docker inspect -f '{{.State.Status}}' "$CONTAINER_NAME" 2>/dev/null || printf 'unknown')"
|
|
fi
|
|
printf '%-18s %-10s http://%s:%s UDP %s-%s %s\n' \
|
|
"$INSTANCE_NAME" "$status" "$ACCESS_IP" "$HTTP_PORT" "$EPR_FROM" "$EPR_TO" "$IMAGE"
|
|
done
|
|
if [[ "$found" == false ]]; then
|
|
info "No managed Neko instances found"
|
|
fi
|
|
}
|
|
|
|
pick_instance() {
|
|
local name="${1:-}"
|
|
if [[ -n "$name" ]]; then
|
|
name="$(sanitize_name "$name")"
|
|
else
|
|
list_instances
|
|
read -r -p "Instance name: " name
|
|
name="$(sanitize_name "$name")"
|
|
fi
|
|
INSTANCE_DIR="${BASE_DIR}/${name}"
|
|
[[ -f "${INSTANCE_DIR}/instance.env" ]] || die "Instance not found: ${name}"
|
|
# shellcheck disable=SC1090
|
|
source "${INSTANCE_DIR}/instance.env"
|
|
}
|
|
|
|
instance_create() {
|
|
ensure_base_layout
|
|
local arch browser image suggested_name suggested_ip public_ip
|
|
arch="$(detect_arch)"
|
|
browser="$(default_browser_for_arch "$arch")"
|
|
image="$(default_image_for_browser "$browser")"
|
|
public_ip="$(detect_public_ip)"
|
|
suggested_ip="${DEFAULT_ACCESS_IP:-${public_ip:-$(detect_primary_ip)}}"
|
|
[[ -n "$suggested_ip" ]] || suggested_ip="127.0.0.1"
|
|
local count
|
|
count="$(find "$BASE_DIR" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l | tr -d ' ')"
|
|
suggested_name="neko-$((count + 1))"
|
|
|
|
INSTANCE_NAME="${CLI_NAME:-$suggested_name}"
|
|
if ! $YES; then
|
|
INSTANCE_NAME="$(prompt 'Instance name' "$INSTANCE_NAME")"
|
|
fi
|
|
INSTANCE_NAME="$(sanitize_name "$INSTANCE_NAME")"
|
|
INSTANCE_DIR="${BASE_DIR}/${INSTANCE_NAME}"
|
|
[[ ! -e "$INSTANCE_DIR" ]] || die "Instance directory already exists: $INSTANCE_DIR"
|
|
|
|
BROWSER="$browser"
|
|
IMAGE="$image"
|
|
PROFILE_PATH="$(profile_path_for "$BROWSER")"
|
|
CONTAINER_NAME="$(container_name_for "$INSTANCE_NAME")"
|
|
HTTP_PORT="$(next_free_http_port)"
|
|
DISPLAY_NUM="$(next_free_display "$(default_display_start_for_browser "$BROWSER")")"
|
|
EPR_FROM="$(next_free_epr_start)"
|
|
EPR_TO=$((EPR_FROM + DEFAULT_EPR_SIZE - 1))
|
|
ACCESS_IP="$suggested_ip"
|
|
TZ_VALUE="$(detect_tz)"
|
|
LANG_VALUE="$DEFAULT_LANG"
|
|
SCREEN_VALUE="$DEFAULT_SCREEN"
|
|
|
|
if ! $YES; then
|
|
ACCESS_IP="$(prompt 'NAT1TO1 / access IP' "$ACCESS_IP")"
|
|
HTTP_PORT="$(prompt 'HTTP port' "$HTTP_PORT")"
|
|
DISPLAY_NUM="$(prompt 'X display number' "$DISPLAY_NUM")"
|
|
EPR_FROM="$(prompt 'WebRTC UDP range start' "$EPR_FROM")"
|
|
EPR_TO=$((EPR_FROM + DEFAULT_EPR_SIZE - 1))
|
|
TZ_VALUE="$(prompt 'Timezone' "$TZ_VALUE")"
|
|
LANG_VALUE="$(prompt 'Locale' "$LANG_VALUE")"
|
|
SCREEN_VALUE="$(prompt 'Screen size' "$SCREEN_VALUE")"
|
|
fi
|
|
|
|
mkdir -p "$INSTANCE_DIR"
|
|
prepare_instance_dir "$INSTANCE_DIR"
|
|
|
|
info "Instance files generated: $INSTANCE_DIR"
|
|
info "Browser: ${BROWSER} | Image: ${IMAGE}"
|
|
info "Access URL: http://${ACCESS_IP}:${HTTP_PORT}"
|
|
info "Login: ${DEFAULT_LIVE_USERNAME}/${DEFAULT_MEMBER_PASSWORD} | Admin: ${DEFAULT_ADMIN_USERNAME}/${DEFAULT_ADMIN_PASSWORD}"
|
|
|
|
if $DRY_RUN; then
|
|
info "dry-run mode: skipped pull/up/firewall operations"
|
|
return 0
|
|
fi
|
|
|
|
info "Pulling image..."
|
|
compose_run -f "$INSTANCE_DIR/docker-compose.yml" pull
|
|
info "Starting container..."
|
|
compose_run -f "$INSTANCE_DIR/docker-compose.yml" up -d
|
|
open_firewall_ports "$HTTP_PORT" "$EPR_FROM" "$EPR_TO"
|
|
|
|
info "Deployment complete"
|
|
printf 'URL: http://%s:%s\n' "$ACCESS_IP" "$HTTP_PORT"
|
|
printf 'Live: %s / %s\n' "$DEFAULT_LIVE_USERNAME" "$DEFAULT_MEMBER_PASSWORD"
|
|
printf 'Admin: %s / %s\n' "$DEFAULT_ADMIN_USERNAME" "$DEFAULT_ADMIN_PASSWORD"
|
|
}
|
|
|
|
instance_start() {
|
|
pick_instance "${CLI_NAME:-}"
|
|
if $DRY_RUN; then
|
|
info "dry-run: skipped start for $INSTANCE_NAME"
|
|
return 0
|
|
fi
|
|
compose_run -f "$INSTANCE_DIR/docker-compose.yml" up -d
|
|
}
|
|
|
|
instance_stop() {
|
|
pick_instance "${CLI_NAME:-}"
|
|
if $DRY_RUN; then
|
|
info "dry-run: skipped stop for $INSTANCE_NAME"
|
|
return 0
|
|
fi
|
|
compose_run -f "$INSTANCE_DIR/docker-compose.yml" stop
|
|
}
|
|
|
|
instance_restart() {
|
|
pick_instance "${CLI_NAME:-}"
|
|
if $DRY_RUN; then
|
|
info "dry-run: skipped restart for $INSTANCE_NAME"
|
|
return 0
|
|
fi
|
|
compose_run -f "$INSTANCE_DIR/docker-compose.yml" restart
|
|
}
|
|
|
|
instance_remove() {
|
|
pick_instance "${CLI_NAME:-}"
|
|
if ! $YES; then
|
|
confirm "Delete ${INSTANCE_NAME} and all persistent data?" || return 0
|
|
fi
|
|
if ! $DRY_RUN; then
|
|
compose_run -f "$INSTANCE_DIR/docker-compose.yml" down -v --remove-orphans || true
|
|
rm -rf "$INSTANCE_DIR"
|
|
fi
|
|
info "Deleted instance: $INSTANCE_NAME"
|
|
}
|
|
|
|
install_portainer() {
|
|
ensure_base_layout
|
|
local portainer_dir http_port https_port
|
|
portainer_dir="${BASE_DIR}/portainer"
|
|
http_port="$(next_free_http_port 19000)"
|
|
https_port="$(next_free_http_port 19443)"
|
|
mkdir -p "${portainer_dir}/data"
|
|
cat > "${portainer_dir}/docker-compose.yml" <<EOF
|
|
services:
|
|
portainer:
|
|
image: portainer/portainer-ce:lts
|
|
container_name: neko-portainer
|
|
restart: unless-stopped
|
|
ports:
|
|
- "${http_port}:9000"
|
|
- "${https_port}:9443"
|
|
volumes:
|
|
- /var/run/docker.sock:/var/run/docker.sock
|
|
- ${portainer_dir}/data:/data
|
|
EOF
|
|
if $DRY_RUN; then
|
|
info "dry-run: generated Portainer config at ${portainer_dir}/docker-compose.yml"
|
|
return 0
|
|
fi
|
|
compose_run -f "${portainer_dir}/docker-compose.yml" up -d
|
|
if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q '^Status: active'; then
|
|
ufw allow "${http_port}/tcp" >/dev/null || true
|
|
ufw allow "${https_port}/tcp" >/dev/null || true
|
|
fi
|
|
if command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then
|
|
firewall-cmd --permanent --add-port="${http_port}/tcp" >/dev/null || true
|
|
firewall-cmd --permanent --add-port="${https_port}/tcp" >/dev/null || true
|
|
firewall-cmd --reload >/dev/null || true
|
|
fi
|
|
info "Portainer started"
|
|
printf 'HTTP: http://%s:%s\n' "$(detect_primary_ip)" "$http_port"
|
|
printf 'HTTPS: https://%s:%s\n' "$(detect_primary_ip)" "$https_port"
|
|
}
|
|
|
|
show_summary() {
|
|
cat <<EOF
|
|
Neko Interactive Deployer ${SCRIPT_VERSION}
|
|
Base dir: ${BASE_DIR}
|
|
EOF
|
|
}
|
|
|
|
show_help() {
|
|
cat <<'EOF'
|
|
Usage:
|
|
./deploy-neko.sh open interactive menu
|
|
./deploy-neko.sh list list instances
|
|
./deploy-neko.sh create create instance
|
|
./deploy-neko.sh start --name x start instance
|
|
./deploy-neko.sh stop --name x stop instance
|
|
./deploy-neko.sh restart --name x
|
|
./deploy-neko.sh remove --name x
|
|
./deploy-neko.sh portainer install/start Portainer panel
|
|
|
|
Global options:
|
|
--base-dir PATH
|
|
--name NAME
|
|
--yes
|
|
--dry-run
|
|
--help
|
|
EOF
|
|
}
|
|
|
|
interactive_menu() {
|
|
local choice=""
|
|
while true; do
|
|
show_summary
|
|
cat <<'EOF'
|
|
1. Create Neko instance
|
|
2. List instances
|
|
3. Start instance
|
|
4. Stop instance
|
|
5. Restart instance
|
|
6. Delete instance
|
|
7. Start Portainer control panel
|
|
0. Exit
|
|
EOF
|
|
read -r -p "Select action: " choice || true
|
|
case "$choice" in
|
|
1) instance_create ;;
|
|
2) list_instances ;;
|
|
3) CLI_NAME=""; instance_start ;;
|
|
4) CLI_NAME=""; instance_stop ;;
|
|
5) CLI_NAME=""; instance_restart ;;
|
|
6) CLI_NAME=""; instance_remove ;;
|
|
7) install_portainer ;;
|
|
0) exit 0 ;;
|
|
*) warn "Invalid selection: $choice" ;;
|
|
esac
|
|
printf '\n'
|
|
done
|
|
}
|
|
|
|
parse_args() {
|
|
COMMAND="${1:-menu}"
|
|
if [[ $# -gt 0 ]]; then
|
|
shift
|
|
fi
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--base-dir)
|
|
BASE_DIR="$2"
|
|
shift 2
|
|
;;
|
|
--name)
|
|
CLI_NAME="$2"
|
|
shift 2
|
|
;;
|
|
--yes|-y)
|
|
YES=true
|
|
shift
|
|
;;
|
|
--dry-run)
|
|
DRY_RUN=true
|
|
shift
|
|
;;
|
|
--help|-h)
|
|
show_help
|
|
exit 0
|
|
;;
|
|
*)
|
|
die "Unknown argument: $1"
|
|
;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
main() {
|
|
need_cmd docker
|
|
detect_compose
|
|
parse_args "$@"
|
|
|
|
case "$COMMAND" in
|
|
menu) interactive_menu ;;
|
|
list) list_instances ;;
|
|
create) instance_create ;;
|
|
start) instance_start ;;
|
|
stop) instance_stop ;;
|
|
restart) instance_restart ;;
|
|
remove|delete|rm) instance_remove ;;
|
|
portainer) install_portainer ;;
|
|
help|--help|-h) show_help ;;
|
|
*)
|
|
die "Unknown command: $COMMAND"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
main "$@"
|