's'
This commit is contained in:
111
neko/README.md
Normal file
111
neko/README.md
Normal 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 直播环境的建议
|
||||
|
||||
脚本已经尽量把容器环境往“真实长期使用的浏览器”方向收:
|
||||
|
||||
- 使用真 Chrome(x86)而不是无头浏览器。
|
||||
- 不使用 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
932
neko/deploy-neko.sh
Normal 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
97
neko/run_remote_deploy.py
Normal 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()
|
||||
Reference in New Issue
Block a user