From 232d80b0ae45486de5711e2be636b63ca7ec1b8a Mon Sep 17 00:00:00 2001 From: eric Date: Thu, 9 Apr 2026 21:01:21 -0500 Subject: [PATCH] 's' --- README.md | 92 --- android13/README.md | 280 +++++++ android13/patch_bootstrap.py | 143 ++++ android13/patch_bootstrap_setupwizard.py | 20 + android13/redroid13.sh | 427 +++++++++++ d2ypp | 1 + neko/README.md | 111 +++ neko/deploy-neko.sh | 932 +++++++++++++++++++++++ neko/run_remote_deploy.py | 97 +++ 9 files changed, 2011 insertions(+), 92 deletions(-) delete mode 100644 README.md create mode 100644 android13/README.md create mode 100644 android13/patch_bootstrap.py create mode 100644 android13/patch_bootstrap_setupwizard.py create mode 100644 android13/redroid13.sh create mode 160000 d2ypp create mode 100644 neko/README.md create mode 100644 neko/deploy-neko.sh create mode 100644 neko/run_remote_deploy.py diff --git a/README.md b/README.md deleted file mode 100644 index 377c37f..0000000 --- a/README.md +++ /dev/null @@ -1,92 +0,0 @@ -# sh - - - -## Getting started - -To make it easy for you to get started with GitLab, here's a list of recommended next steps. - -Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)! - -## Add your files - -- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files -- [ ] [Add files using the command line](https://docs.gitlab.com/ee/gitlab-basics/add-file.html#add-a-file-using-the-command-line) or push an existing Git repository with the following command: - -``` -cd existing_repo -git remote add origin http://gitlab.nomadro.com/gitlab-instance-0a899031/sh.git -git branch -M main -git push -uf origin main -``` - -## Integrate with your tools - -- [ ] [Set up project integrations](http://gitlab.nomadro.com/gitlab-instance-0a899031/sh/-/settings/integrations) - -## Collaborate with your team - -- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/) -- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html) -- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically) -- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/) -- [ ] [Automatically merge when pipeline succeeds](https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html) - -## Test and Deploy - -Use the built-in continuous integration in GitLab. - -- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/index.html) -- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing(SAST)](https://docs.gitlab.com/ee/user/application_security/sast/) -- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html) -- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/) -- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html) - -*** - -# Editing this README - -When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thank you to [makeareadme.com](https://www.makeareadme.com/) for this template. - -## Suggestions for a good README -Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information. - -## Name -Choose a self-explaining name for your project. - -## Description -Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors. - -## Badges -On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge. - -## Visuals -Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method. - -## Installation -Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection. - -## Usage -Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README. - -## Support -Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc. - -## Roadmap -If you have ideas for releases in the future, it is a good idea to list them in the README. - -## Contributing -State if you are open to contributions and what your requirements are for accepting them. - -For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self. - -You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser. - -## Authors and acknowledgment -Show your appreciation to those who have contributed to the project. - -## License -For open source projects, say how it is licensed. - -## Project status -If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers. diff --git a/android13/README.md b/android13/README.md new file mode 100644 index 0000000..478a378 --- /dev/null +++ b/android13/README.md @@ -0,0 +1,280 @@ +# redroid 13 Control Kit + +This folder contains the local control entrypoint for the remote redroid 13 environment. +The goal is to keep one script and one workflow that can: + +- build the image on the server +- launch one or many redroid instances +- bootstrap Magisk and Google framework settings +- check whether a running instance is actually usable +- expose a web UI for Docker management + +## Architecture + +The repository is split into two layers: + +```mermaid +flowchart LR + A["Local workstation
Windows / Git Bash"] --> B["redroid13.sh
Paramiko over SSH"] + B --> C["Remote host
/home/eric/redroid-android13"] + C --> D["Docker / Podman"] + D --> E["redroid container"] + E --> F["Android 13 system image"] + F --> G["Google framework"] + F --> H["Magisk / root"] + B --> I["Portainer web panel"] +``` + +### Local side + +`redroid13.sh` is the only file you usually run from this folder. It: + +- asks for the SSH password once +- connects to the server with `paramiko` +- executes build, launch, bootstrap, and health-check commands remotely +- resolves the best image for the server architecture + +### Remote side + +The server repo, `/home/eric/redroid-android13`, contains the actual Android build and bootstrap code: + +- `redroid.py` builds the image layers +- `run_redroid.py` launches the container +- `bootstrap_redroid.py` applies the post-boot hardening +- `stuff/magisk.py` packages Magisk +- `stuff/mindthegapps.py` packages Google apps/framework + +## Compatibility Model + +The script is designed to work with both x86_64 and arm64 hosts. + +### Image selection + +The script resolves the image automatically unless you override it. + +- x86_64 host: `redroid/redroid:13.0.0_mindthegapps_houdini_magisk` +- arm64 host: `redroid/redroid:13.0.0_mindthegapps_magisk` + +You can still override the image explicitly with `REDROID_IMAGE`. +If your arm64 build uses a different tag, set `REDROID_IMAGE_ARM64` to match it. + +### Runtime bridge + +`run_redroid.py` already resolves bridge mode from the image tag when `--bridge auto` is used. + +- `houdini` images use native bridge translation for x86 apps on x86 hosts +- arm64 images can stay on the default bridge path + +### Root handling + +`bootstrap_redroid.py` now probes root using both of the common Magisk styles: + +- `su root id` +- `su 0 id` + +That avoids the old `su -mm -c` mismatch and keeps Magisk-compatible bootstrapping stable across the current image variants. + +## What The Script Does + +The shell entrypoint supports: + +- building the Android 13 image +- starting one instance +- starting multiple instances +- bootstrapping a single container +- bootstrapping all running redroid containers +- listing containers +- checking a running instance +- starting Portainer +- showing runtime info + +It also resolves: + +- the server architecture +- the appropriate image tag +- the right ADB / Magisk bootstrap path + +## Prerequisites + +Local machine: + +- Bash +- Python 3 +- `paramiko` installed for Python 3 + +Remote server: + +- Docker or Podman +- access to `/home/eric/redroid-android13` +- working ADB server +- a redroid-compatible Linux host + +## Quick Start + +Run the script from this folder: + +```bash +./redroid13.sh +``` + +The menu is interactive and covers the common workflow: + +1. build the image +2. start one instance +3. start a batch of instances +4. bootstrap an instance +5. list containers +6. check a running instance +7. start Portainer +8. stop an instance +9. bootstrap all redroid containers +10. show runtime info +11. exit + +## Non-Interactive Usage + +The same script also supports command-style usage: + +```bash +./redroid13.sh build +./redroid13.sh start +./redroid13.sh batch +./redroid13.sh bootstrap redroid13-1 +./redroid13.sh bootstrap-all +./redroid13.sh list +./redroid13.sh redroid-list +./redroid13.sh check +./redroid13.sh portainer +./redroid13.sh info +./redroid13.sh stop redroid13-1 +``` + +## Recommended Defaults + +These defaults are tuned for a stable Android 13 desktop-style setup: + +- `REDROID_GPU_MODE=guest` +- `REDROID_ADB_INSECURE=1` +- `REDROID_DEVICE_PROFILE=pixel-7-pro` +- `REDROID_ROOT_TIMEOUT=60` +- `REDROID_BOOT_TIMEOUT=120` +- `REDROID_WAIT_TCP=120` + +That combination prioritizes boot reliability over host-GPU speed. + +## Environment Variables + +You can override the behavior without editing the script. + +| Variable | Purpose | Default | +| --- | --- | --- | +| `REDROID_SSH_HOST` | Remote server address | `192.168.2.179` | +| `REDROID_SSH_USER` | SSH user | `eric` | +| `REDROID_REMOTE_REPO` | Remote repo path | `/home/eric/redroid-android13` | +| `REDROID_IMAGE` | Force a specific image | auto-select by architecture | +| `REDROID_IMAGE_X86` | x86_64 image | `redroid/redroid:13.0.0_mindthegapps_houdini_magisk` | +| `REDROID_IMAGE_ARM64` | arm64 image | `redroid/redroid:13.0.0_mindthegapps_magisk` | +| `REDROID_ARCH_PROFILE` | `auto`, `x86_64`, or `arm64` | `auto` | +| `REDROID_DEVICE_PROFILE` | Device fingerprint preset | `pixel-7-pro` | +| `REDROID_GPU_MODE` | `guest`, `host`, or `auto` | `guest` | +| `REDROID_ADB_INSECURE` | Inject insecure adb boot props | `1` | +| `REDROID_BOOT_TIMEOUT` | Seconds to wait for Android boot | `120` | +| `REDROID_ROOT_TIMEOUT` | Seconds to wait for Magisk root | `60` | +| `REDROID_WAIT_TCP` | Seconds to wait for ADB TCP | `120` | +| `REDROID_DEFAULT_PORT` | First ADB port | `5570` | +| `REDROID_PORT_STEP` | Port step in batch mode | `1` | +| `REDROID_INSTANCE_PREFIX` | Instance name prefix | `redroid13` | +| `REDROID_DEFAULT_COUNT` | Batch size prompt default | `3` | +| `REDROID_PORTAINER_PORT` | Portainer port | `9000` | +| `REDROID_DATA_PREFIX` | Host data directory prefix | `/home/eric/data-redroid13` | + +## Multi-Instance Layout + +The script uses one host data directory per instance: + +```text +/home/eric/data-redroid13-redroid13-1 +/home/eric/data-redroid13-redroid13-2 +/home/eric/data-redroid13-redroid13-3 +``` + +Ports are assigned sequentially: + +- `5570` +- `5571` +- `5572` + +This keeps each container isolated and makes it easy to reboot or replace a single instance. + +## Bootstrap Flow + +The bootstrap phase does the important post-boot cleanup: + +- waits for Android boot completion +- waits for Magisk root when root-only actions are required +- sets provisioning flags +- disables setup wizard if the package exists +- sets mock location for shell +- applies the selected device profile through `resetprop` +- applies proxy settings +- installs the optional extreme script when requested + +That bootstrap logic is the part that keeps Google Play and Magisk behavior stable after first boot. + +## Portainer + +The script can start Portainer on the remote host. + +Once it is running: + +- open `http://:9000` +- use it to inspect containers, logs, volumes, and restart policies + +## Troubleshooting + +### ADB connects but shell fails + +Check whether the instance actually reached a usable boot state: + +```bash +./redroid13.sh check +``` + +If `sys.boot_completed` is not `1`, the container may still be booting or the guest may have stalled. + +### Magisk root is unavailable + +The bootstrap logic now retries root with the currently supported `su` syntax. + +If you still do not see root: + +- check the container logs on the remote host +- verify the image tag you selected +- make sure the container was started from the current repository revision + +### Google Play login looks broken + +That usually means one of these is still wrong: + +- device fingerprint +- setup wizard state +- provisioning state +- Google Play Services package state + +Use `./redroid13.sh check` to inspect all of those in one shot. + +### Host GPU causes boot issues + +If a container never becomes reachable over ADB, switch back to guest GPU mode: + +```bash +REDROID_GPU_MODE=guest ./redroid13.sh start +``` + +Host GPU is faster when it works, but guest GPU is the safe default for headless servers. + +## Notes + +- This folder is a control layer, not the Android build itself. +- The actual server-side fixes live in the remote repository at `/home/eric/redroid-android13`. +- The goal is stability first: Magisk root, Google framework, and repeatable multi-instance startup. diff --git a/android13/patch_bootstrap.py b/android13/patch_bootstrap.py new file mode 100644 index 0000000..95bbc10 --- /dev/null +++ b/android13/patch_bootstrap.py @@ -0,0 +1,143 @@ +from pathlib import Path +path = Path('/home/eric/redroid-android13/bootstrap_redroid.py') +text = path.read_text() +old = ''' def connect(self): + if self.mode == "adb" and ":" in self.serial: + run(["adb", "connect", self.serial], check=False) + + def has_root(self): + if self.mode == "container": + return True + if not self._root_checked or not self._root_available: + result = adb( + self.serial, + ["shell", "su", "-mm", "-c", "id"], + check=False, + capture_output=True, + ) + self._root_available = result.returncode == 0 and "uid=0(" in result.stdout + self._root_checked = self._root_available + return self._root_available + + def wait_for_root(self, timeout): + if self.mode == "container": + return True + deadline = time.time() + timeout + while time.time() < deadline: + if self.has_root(): + return True + time.sleep(2) + return False + + def exec_shell(self, command, check=True, capture_output=False, root=False): + if self.mode == "container": + return run( + [ + self.container_runtime, + "exec", + self.container_name, + "/system/bin/sh", + "-c", + command, + ], + check=check, + capture_output=capture_output, + ) + if root and self.has_root(): + return adb( + self.serial, + ["shell", "su", "-mm", "-c", command], + check=check, + capture_output=capture_output, + ) + return adb( + self.serial, + ["shell", command], + check=check, + capture_output=capture_output, + ) +''' +new = ''' def connect(self): + if self.mode == "adb" and ":" in self.serial: + run(["adb", "connect", self.serial], check=False) + + def _root_probe_args(self): + return [ + ["shell", "su", "root", "id"], + ["shell", "su", "0", "id"], + ] + + def _root_shell_args(self, command): + return [ + ["shell", "su", "root", "sh", "-c", command], + ["shell", "su", "0", "sh", "-c", command], + ] + + def has_root(self): + if self.mode == "container": + return True + if not self._root_checked or not self._root_available: + for args in self._root_probe_args(): + result = adb( + self.serial, + args, + check=False, + capture_output=True, + ) + if result.returncode == 0 and "uid=0(" in result.stdout: + self._root_available = True + self._root_checked = True + return True + self._root_available = False + self._root_checked = False + return self._root_available + + def wait_for_root(self, timeout): + if self.mode == "container": + return True + deadline = time.time() + timeout + while time.time() < deadline: + if self.has_root(): + return True + time.sleep(2) + return False + + def exec_shell(self, command, check=True, capture_output=False, root=False): + if self.mode == "container": + return run( + [ + self.container_runtime, + "exec", + self.container_name, + "/system/bin/sh", + "-c", + command, + ], + check=check, + capture_output=capture_output, + ) + if root and self.has_root(): + last_result = None + for args in self._root_shell_args(command): + result = adb( + self.serial, + args, + check=False, + capture_output=capture_output, + ) + last_result = result + if result.returncode == 0: + return result + if check and last_result is not None: + last_result.check_returncode() + return last_result + return adb( + self.serial, + ["shell", command], + check=check, + capture_output=capture_output, + ) +''' +if old not in text: + raise SystemExit('expected bootstrap_redroid.py block not found') +path.write_text(text.replace(old, new)) diff --git a/android13/patch_bootstrap_setupwizard.py b/android13/patch_bootstrap_setupwizard.py new file mode 100644 index 0000000..1acd3e9 --- /dev/null +++ b/android13/patch_bootstrap_setupwizard.py @@ -0,0 +1,20 @@ +from pathlib import Path +path = Path('/home/eric/redroid-android13/bootstrap_redroid.py') +text = path.read_text() +old = ''' try_shell(backend, "pm disable-user --user 0 com.android.setupwizard") + try_shell(backend, "pm disable-user --user 0 com.google.android.setupwizard") + try_shell(backend, "appops set com.android.shell android:mock_location allow") +''' +new = ''' for package in ("com.android.setupwizard", "com.google.android.setupwizard"): + packages = backend.exec_shell( + "pm list packages --user 0", + check=False, + capture_output=True, + ).stdout + if package in packages: + try_shell(backend, "pm disable-user --user 0 {}".format(shell_quote(package))) + try_shell(backend, "appops set com.android.shell android:mock_location allow") +''' +if old not in text: + raise SystemExit('expected setupwizard block not found') +path.write_text(text.replace(old, new)) diff --git a/android13/redroid13.sh b/android13/redroid13.sh new file mode 100644 index 0000000..511ed97 --- /dev/null +++ b/android13/redroid13.sh @@ -0,0 +1,427 @@ +#!/usr/bin/env bash +set -euo pipefail + +SSH_HOST="${REDROID_SSH_HOST:-192.168.2.179}" +SSH_USER="${REDROID_SSH_USER:-eric}" +REMOTE_REPO="${REDROID_REMOTE_REPO:-/home/eric/redroid-android13}" +REDROID_IMAGE_OVERRIDE="${REDROID_IMAGE:-}" +REDROID_IMAGE_X86="${REDROID_IMAGE_X86:-redroid/redroid:13.0.0_mindthegapps_houdini_magisk}" +REDROID_IMAGE_ARM64="${REDROID_IMAGE_ARM64:-redroid/redroid:13.0.0_mindthegapps_magisk}" +REDROID_ARCH_PROFILE="${REDROID_ARCH_PROFILE:-auto}" +DEVICE_PROFILE="${REDROID_DEVICE_PROFILE:-pixel-7-pro}" +PORTAINER_PORT="${REDROID_PORTAINER_PORT:-9000}" +DEFAULT_PORT="${REDROID_DEFAULT_PORT:-5570}" +DATA_PREFIX="${REDROID_DATA_PREFIX:-/home/eric/data-redroid13}" +PORT_STEP="${REDROID_PORT_STEP:-1}" +INSTANCE_PREFIX="${REDROID_INSTANCE_PREFIX:-redroid13}" +DEFAULT_COUNT="${REDROID_DEFAULT_COUNT:-3}" +GPU_MODE="${REDROID_GPU_MODE:-guest}" +ADB_INSECURE="${REDROID_ADB_INSECURE:-1}" +BOOT_TIMEOUT="${REDROID_BOOT_TIMEOUT:-120}" +ROOT_TIMEOUT="${REDROID_ROOT_TIMEOUT:-60}" +WAIT_TCP="${REDROID_WAIT_TCP:-120}" +RESOLVED_IMAGE="" +RESOLVED_ARCH="" + +if ! command -v python3 >/dev/null 2>&1; then + echo "python3 is required." >&2 + exit 1 +fi + +if ! python3 - <<'PY' >/dev/null 2>&1 +import importlib.util +raise SystemExit(0 if importlib.util.find_spec("paramiko") else 1) +PY +then + echo "python3 paramiko is required." >&2 + exit 1 +fi + +read -r -s -p "SSH password for ${SSH_USER}@${SSH_HOST}: " SSH_PASSWORD +echo + +sq() { + printf '%q' "$1" +} + +remote_exec() { + local remote_cmd="$1" + SSH_HOST="$SSH_HOST" \ + SSH_USER="$SSH_USER" \ + SSH_PASSWORD="$SSH_PASSWORD" \ + SSH_CMD="$remote_cmd" \ + python3 - <<'PY' +import os +import sys + +import paramiko + +host = os.environ["SSH_HOST"] +user = os.environ["SSH_USER"] +password = os.environ["SSH_PASSWORD"] +cmd = os.environ["SSH_CMD"] + +client = paramiko.SSHClient() +client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +client.connect( + hostname=host, + username=user, + password=password, + timeout=20, + banner_timeout=20, + auth_timeout=20, +) +stdin, stdout, stderr = client.exec_command(cmd, get_pty=True) +out = stdout.read().decode(errors="replace") +err = stderr.read().decode(errors="replace") +if out: + sys.stdout.write(out) +if err: + sys.stderr.write(err) +status = stdout.channel.recv_exit_status() +client.close() +sys.exit(status) +PY +} + +remote_repo_cmd() { + local inner="$1" + remote_exec "cd $(sq "$REMOTE_REPO") && ${inner}" +} + +normalize_arch() { + case "${1,,}" in + x86_64|amd64) + printf '%s' "x86_64" + ;; + aarch64|arm64) + printf '%s' "arm64" + ;; + *) + printf '%s' "unknown" + ;; + esac +} + +detect_remote_arch() { + remote_exec "uname -m | tr -d '\r'" +} + +resolve_image() { + if [[ -n "$RESOLVED_IMAGE" ]]; then + printf '%s' "$RESOLVED_IMAGE" + return 0 + fi + + if [[ -n "$REDROID_IMAGE_OVERRIDE" ]]; then + RESOLVED_IMAGE="$REDROID_IMAGE_OVERRIDE" + RESOLVED_ARCH="custom" + printf '%s' "$RESOLVED_IMAGE" + return 0 + fi + + local arch="${REDROID_ARCH_PROFILE}" + if [[ "$arch" == "auto" ]]; then + arch="$(normalize_arch "$(detect_remote_arch)")" + else + arch="$(normalize_arch "$arch")" + fi + + case "$arch" in + x86_64) + RESOLVED_IMAGE="$REDROID_IMAGE_X86" + RESOLVED_ARCH="x86_64" + ;; + arm64) + RESOLVED_IMAGE="$REDROID_IMAGE_ARM64" + RESOLVED_ARCH="arm64" + ;; + *) + RESOLVED_IMAGE="$REDROID_IMAGE_X86" + RESOLVED_ARCH="x86_64" + ;; + esac + + printf '%s' "$RESOLVED_IMAGE" +} + +resolve_arch() { + if [[ -z "$RESOLVED_IMAGE" ]]; then + resolve_image >/dev/null + fi + printf '%s' "${RESOLVED_ARCH:-x86_64}" +} + +remote_docker_ps() { + remote_exec "docker ps --format 'table {{.Names}}\\t{{.Image}}\\t{{.Ports}}\\t{{.Status}}'" +} + +remote_redroid_ps() { + local image + image="$(resolve_image)" + remote_exec "docker ps --format '{{.Names}} {{.Image}} {{.Ports}} {{.Status}}' | awk -v image=$(sq "$image") '\$2 == image || index(\$2, \"redroid/redroid:\") == 1 { print }'" +} + +remote_redroid_names() { + local image + image="$(resolve_image)" + remote_exec "docker ps --format '{{.Names}} {{.Image}}' | awk -v image=$(sq "$image") '\$2 == image || index(\$2, \"redroid/redroid:\") == 1 {print \$1}'" +} + +show_runtime_info() { + local image arch + image="$(resolve_image)" + arch="$(resolve_arch)" + printf 'Remote arch profile: %s\n' "$arch" + printf 'Resolved image: %s\n' "$image" + printf 'Device profile: %s\n' "$DEVICE_PROFILE" + printf 'GPU mode: %s\n' "$GPU_MODE" + printf 'ADB insecure: %s\n' "$ADB_INSECURE" +} + +build_image() { + remote_repo_cmd "env -u REDROID_MAGISK_APK -u REDROID_MAGISK_URL REDROID_OFFLINE=1 REDROID_MAGISK_BOOT_COMPLETE=1 python3 redroid.py -a 13.0.0 -mtg -i -m --offline --no-compat-check" +} + +make_instance_name() { + local prefix="$1" + local index="$2" + printf '%s-%s' "$prefix" "$index" +} + +make_data_dir() { + local prefix="$1" + local name="$2" + printf '%s-%s' "$prefix" "$name" +} + +start_single_instance() { + local name="$1" + local port="$2" + local data_dir="$3" + local image arch + + local adb_arg="" + if [[ "$ADB_INSECURE" != "0" ]]; then + adb_arg="--adb-insecure" + fi + + image="$(resolve_image)" + arch="$(resolve_arch)" + remote_repo_cmd "python3 run_redroid.py --image $(sq "$image") --bridge auto --device-profile $(sq "$DEVICE_PROFILE") --gpu-mode $(sq "$GPU_MODE") --data-dir $(sq "$data_dir") --port $(sq "$port") --name $(sq "$name") --replace --keep-container --wait-tcp $(sq "$WAIT_TCP") ${adb_arg}" + bootstrap_instance "$name" + echo "Local ADB connect:" + echo " adb connect ${SSH_HOST}:${port}" + echo "Resolved arch/image: ${arch} / ${image}" +} + +start_instance() { + local name port data_dir + read -r -p "Instance name [${INSTANCE_PREFIX}-1]: " name + name="${name:-${INSTANCE_PREFIX}-1}" + + read -r -p "ADB port [${DEFAULT_PORT}]: " port + port="${port:-$DEFAULT_PORT}" + + read -r -p "Data dir [${DATA_PREFIX}-${name}]: " data_dir + data_dir="${data_dir:-${DATA_PREFIX}-${name}}" + + start_single_instance "$name" "$port" "$data_dir" +} + +start_batch_instances() { + local count start_port prefix data_prefix + read -r -p "How many instances [${DEFAULT_COUNT}]: " count + count="${count:-$DEFAULT_COUNT}" + + read -r -p "Start ADB port [${DEFAULT_PORT}]: " start_port + start_port="${start_port:-$DEFAULT_PORT}" + + read -r -p "Name prefix [${INSTANCE_PREFIX}]: " prefix + prefix="${prefix:-$INSTANCE_PREFIX}" + + read -r -p "Data prefix [${DATA_PREFIX}]: " data_prefix + data_prefix="${data_prefix:-$DATA_PREFIX}" + + local i name port data_dir + for ((i = 1; i <= count; i++)); do + name="$(make_instance_name "$prefix" "$i")" + port="$((start_port + (i - 1) * PORT_STEP))" + data_dir="$(make_data_dir "$data_prefix" "$name")" + echo "Starting ${name} on port ${port}..." + start_single_instance "$name" "$port" "$data_dir" + echo + done + + local open_panel + read -r -p "Start Portainer web panel now? [Y/n]: " open_panel + case "${open_panel:-Y}" in + n|N|no|NO|No) ;; + *) start_portainer ;; + esac +} + +bootstrap_instance() { + local name="${1:?instance name required}" + remote_repo_cmd "python3 bootstrap_redroid.py --container-name $(sq "$name") --container-runtime docker --device-profile $(sq "$DEVICE_PROFILE") --proxy-host none --timeout $(sq "$BOOT_TIMEOUT") --root-timeout $(sq "$ROOT_TIMEOUT")" +} + +bootstrap_all_instances() { + local names + names="$(remote_redroid_names)" + if [[ -z "$names" ]]; then + echo "No running redroid containers found." + return 0 + fi + + local name + while IFS= read -r name; do + [[ -z "$name" ]] && continue + echo "Bootstrapping ${name}..." + bootstrap_instance "$name" + echo + done <<< "$names" +} + +check_instance() { + local port + read -r -p "ADB port [${DEFAULT_PORT}]: " port + port="${port:-$DEFAULT_PORT}" + + remote_exec " + set +e + adb connect 127.0.0.1:${port} >/dev/null 2>&1 + adb -s 127.0.0.1:${port} shell getprop sys.boot_completed + adb -s 127.0.0.1:${port} shell settings get global device_provisioned + adb -s 127.0.0.1:${port} shell settings get secure user_setup_complete + adb -s 127.0.0.1:${port} shell settings get global http_proxy + adb -s 127.0.0.1:${port} shell getprop ro.build.fingerprint + adb -s 127.0.0.1:${port} shell getprop ro.product.model + adb -s 127.0.0.1:${port} shell su root id 2>/dev/null || adb -s 127.0.0.1:${port} shell su 0 id 2>/dev/null + adb -s 127.0.0.1:${port} shell cmd package resolve-activity --brief -a android.intent.action.MAIN -c android.intent.category.LAUNCHER io.github.huskydg.magisk 2>/dev/null | tail -n 2 || true + adb -s 127.0.0.1:${port} shell pm path io.github.huskydg.magisk 2>/dev/null || true + adb -s 127.0.0.1:${port} shell pm list packages 2>/dev/null | grep -E 'com.google.android.gms|com.google.android.setupwizard|io.github.huskydg.magisk' | sort || true + adb -s 127.0.0.1:${port} shell dumpsys package com.google.android.gms 2>/dev/null | grep -E 'versionName=|pkg=|enabled=' | head -n 10 || true + " +} + +start_portainer() { + remote_exec " + docker volume create portainer_data >/dev/null + docker rm -f portainer >/dev/null 2>&1 || true + docker run -d \ + --name portainer \ + --restart unless-stopped \ + -p ${PORTAINER_PORT}:9000 \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v portainer_data:/data \ + portainer/portainer-ce:latest + " + echo "Portainer: http://${SSH_HOST}:${PORTAINER_PORT}" +} + +stop_instance() { + local name + read -r -p "Container name [redroid13-1]: " name + name="${name:-redroid13-1}" + remote_exec "docker rm -f $(sq "$name")" +} + +menu() { + while true; do + cat <" >&2 + exit 1 + fi + bootstrap_instance "$2" + ;; + list) + remote_docker_ps + ;; + redroid-list|rlist) + remote_redroid_ps + ;; + info|runtime) + show_runtime_info + ;; + check) + check_instance + ;; + status) + check_instance + ;; + portainer) + start_portainer + ;; + panel|web) + start_portainer + ;; + stop) + if [[ $# -lt 2 ]]; then + echo "Usage: $0 stop " >&2 + exit 1 + fi + remote_exec "docker rm -f $(sq "$2")" + ;; + bootstrap-all|ball) + bootstrap_all_instances + ;; + ""|menu) + menu + ;; + *) + cat <|bootstrap-all|ball|list|redroid-list|check|status|portainer|info|runtime|stop ] +EOF + exit 1 + ;; +esac diff --git a/d2ypp b/d2ypp new file mode 160000 index 0000000..090578a --- /dev/null +++ b/d2ypp @@ -0,0 +1 @@ +Subproject commit 090578a27adf89bedb923b1cc28b58d0c30f36ee diff --git a/neko/README.md b/neko/README.md new file mode 100644 index 0000000..18ff75f --- /dev/null +++ b/neko/README.md @@ -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` diff --git a/neko/deploy-neko.sh b/neko/deploy-neko.sh new file mode 100644 index 0000000..eccec91 --- /dev/null +++ b/neko/deploy-neko.sh @@ -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='[""]' +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 <