This commit is contained in:
eric
2026-04-09 21:01:21 -05:00
parent f8b84d84c9
commit 232d80b0ae
9 changed files with 2011 additions and 92 deletions

111
neko/README.md Normal file
View File

@@ -0,0 +1,111 @@
# Neko 一键部署脚本
当前目录包含两个文件:
- `deploy-neko.sh`: 运行在 Linux 服务器上的交互式部署脚本。
- `run_remote_deploy.py`: 在本机 Windows 上通过 SSH 上传并启动 `deploy-neko.sh`
## 已实现的目标
1. `x86_64` 默认部署 `ghcr.io/m1k1o/neko/google-chrome:latest``arm64` 自动切换 `ghcr.io/m1k1o/neko/chromium:latest`
2. 自动覆盖 Neko 镜像自带的 Chromium/Chrome policy 为 `{}`,去掉默认企业管控。
3. 首次启动通过 `initial_preferences/master_preferences` 预装 Tampermonkey并打开扩展开发者模式。
4. 默认账号固定为:
- 普通用户: `live / 12345678`
- 管理员: `admin / 12345678`
5. 每个实例独立目录、独立 profile、独立下载目录、独立用户文件、独立会话文件。
6. 自动探测下一个可用 HTTP 端口、Display 编号、WebRTC UDP 端口段。
7. 交互菜单内置实例 `创建 / 列表 / 启动 / 停止 / 重启 / 删除`,以及可选 `Portainer` Web 控制面板。
8. 默认尝试放行服务器本机防火墙端口(`ufw` / `firewalld`)。
## 服务端直接使用
```bash
chmod +x ./deploy-neko.sh
./deploy-neko.sh
```
常用命令:
```bash
./deploy-neko.sh list
./deploy-neko.sh create
./deploy-neko.sh start --name neko-1
./deploy-neko.sh stop --name neko-1
./deploy-neko.sh restart --name neko-1
./deploy-neko.sh remove --name neko-1
./deploy-neko.sh portainer
```
如果想先生成配置、不真正启动容器:
```bash
./deploy-neko.sh create --name smoke-test --yes --dry-run
```
## Windows 本地一键上传并运行
本机已经有 `python``paramiko` 时,直接执行:
```powershell
python .\run_remote_deploy.py
```
也可以显式指定:
```powershell
python .\run_remote_deploy.py --host 192.168.2.179 --user eric
```
密码可用环境变量传入:
```powershell
$env:NEKO_REMOTE_PASSWORD="你的SSH密码"
python .\run_remote_deploy.py
```
## 目录结构
默认所有实例都落在:
```text
~/neko-instances/<实例名>/
```
每个实例至少包含:
- `docker-compose.yml`
- `browser.conf`
- `browser-prefs.json`
- `policies.json`
- `members.json`
- `sessions.json`
- `profile/`
- `downloads/`
## 我对 YouTube 直播环境的建议
脚本已经尽量把容器环境往“真实长期使用的浏览器”方向收:
- 使用真 Chromex86而不是无头浏览器。
- 不使用 Selenium、Puppeteer、`--enable-automation``--remote-debugging-port` 这类明显自动化痕迹。
- 关闭 Neko 默认浏览器管控,保留独立持久化 profile让 Cookie、登录态、浏览历史长期稳定。
- 保持固定实例、固定账号、固定 profile不要频繁删容器和清 profile。
- 让服务器 `TZ`、语言、登录地区、出口 IP 长期稳定。
但要直接说明: `Google/YouTube 是否降权` 不能靠容器脚本“保证”。最关键的还是:
- 出口 IP 质量,优先住宅/稳定商宽,尽量避免脏数据中心 IP。
- 账号历史和行为稳定性不频繁换设备特征、IP、语言、时区。
- 不要频繁重新登录、清 Cookie、重建 profile。
- 如果有反向代理,尽量上 HTTPS避免纯 HTTP 下的会话和风控问题。
## 额外说明
- 现在脚本为了满足“权限尽量放开”的要求,容器使用了 `privileged: true``seccomp=unconfined`。功能上最省事,但安全边界最弱,只建议在你自己可控的服务器上使用。
- 如果后续你要继续极限优化,我建议下一步增加:
- 反向代理 + HTTPS
- 住宅代理 / 固定出口 IP
- 按实例自动生成二级域名
- GPU 加速镜像切换逻辑
- 自动备份 `profile/``members.json`

932
neko/deploy-neko.sh Normal file
View File

@@ -0,0 +1,932 @@
#!/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 "$@"

97
neko/run_remote_deploy.py Normal file
View File

@@ -0,0 +1,97 @@
#!/usr/bin/env python3
import argparse
import getpass
import os
import posixpath
import shlex
import sys
import threading
import time
import paramiko
def pump_output(channel, stop_flag):
while not stop_flag["stop"]:
if channel.recv_ready():
data = channel.recv(4096)
if not data:
break
sys.stdout.buffer.write(data)
sys.stdout.buffer.flush()
continue
if channel.exit_status_ready():
if not channel.recv_ready():
break
time.sleep(0.05)
stop_flag["stop"] = True
def main():
parser = argparse.ArgumentParser(description="Upload deploy-neko.sh and run it interactively on a remote Linux server.")
parser.add_argument("--host", default="192.168.2.179")
parser.add_argument("--user", default="eric")
parser.add_argument("--password", default=os.environ.get("NEKO_REMOTE_PASSWORD"))
parser.add_argument("--local-script", default=os.path.join(os.path.dirname(__file__), "deploy-neko.sh"))
parser.add_argument("--remote-dir", default="~/neko-tools")
parser.add_argument("--args", default="", help="Optional arguments passed to deploy-neko.sh")
args = parser.parse_args()
if not args.password:
args.password = getpass.getpass("SSH password: ")
local_script = os.path.abspath(args.local_script)
if not os.path.isfile(local_script):
raise SystemExit(f"Script not found: {local_script}")
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(hostname=args.host, username=args.user, password=args.password, timeout=15)
sftp = client.open_sftp()
remote_home = sftp.normalize(".")
remote_dir = args.remote_dir.replace("~", remote_home, 1)
try:
sftp.stat(remote_dir)
except IOError:
client.exec_command(f"mkdir -p {shlex.quote(remote_dir)}")
time.sleep(0.2)
remote_script = posixpath.join(remote_dir, "deploy-neko.sh")
sftp.put(local_script, remote_script)
sftp.chmod(remote_script, 0o755)
sftp.close()
channel = client.invoke_shell(width=180, height=40)
stop_flag = {"stop": False}
worker = threading.Thread(target=pump_output, args=(channel, stop_flag), daemon=True)
worker.start()
time.sleep(0.2)
quoted_remote_dir = shlex.quote(remote_dir)
quoted_remote_script = shlex.quote(remote_script)
trailing_args = f" {args.args.strip()}" if args.args.strip() else ""
channel.send(f"cd {quoted_remote_dir}\n")
channel.send(f"bash {quoted_remote_script}{trailing_args}\n")
try:
while not stop_flag["stop"]:
line = sys.stdin.readline()
if not line:
break
channel.send(line)
except KeyboardInterrupt:
pass
finally:
time.sleep(0.2)
try:
channel.send("\n")
except Exception:
pass
stop_flag["stop"] = True
worker.join(timeout=1)
channel.close()
client.close()
if __name__ == "__main__":
main()