Files
gitlab-instance-0a899031_sh/neko/deploy-neko.sh
2026-04-09 21:01:21 -05:00

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 "$@"