#!/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='[""]' 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" < "$path/members.json" < "$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" < "$path/browser.conf" < "$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" </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" </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 <