Skip to content

Commit 3a89e26

Browse files
authored
Add interactive on-server shell installer (#1)
1 parent e623c4b commit 3a89e26

12 files changed

Lines changed: 431 additions & 12 deletions

File tree

‎README.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,19 @@ Documentation: **[English](docs/en/README.md)** · **[Русский](docs/ru/RE
1111

1212
## Quick start
1313

14+
To configure the Debian/Ubuntu server you are logged into, run the interactive installer:
15+
16+
```shell
17+
curl -fsSL https://raw.githubusercontent.com/andre487/MegaProxyServer/main/install.sh | sudo bash
18+
```
19+
20+
It installs dependencies and starts the setup wizard. Keep your SSH session open and save the
21+
administrative key on your computer before confirming provisioning. See the
22+
[English](docs/en/README.md#install-on-the-server) or
23+
[Russian](docs/ru/README.md#установка-на-самом-сервере) walkthrough.
24+
25+
### Manage servers from another machine
26+
1427
Requirements: macOS or Linux, Python 3.12+, `uv`, OpenSSH, and a Debian or Ubuntu server reachable through a
1528
sudo-capable SSH account.
1629

‎docs/en/README.md‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,56 @@ Public-IP ACME certificates require Certbot 5.4+ and Let's Encrypt's short-lived
2222

2323
## First deployment
2424

25+
### Install on the server
26+
27+
Connect to Debian/Ubuntu with an interactive terminal (`ssh -t`) or use the server console.
28+
You need root or sudo, curl and internet access. Install curl if necessary:
29+
30+
```shell
31+
sudo apt-get update && sudo apt-get install -y curl ca-certificates
32+
```
33+
34+
Start the installer (omit sudo when logged in as root):
35+
36+
```shell
37+
curl -fsSL https://raw.githubusercontent.com/andre487/MegaProxyServer/main/install.sh | sudo bash
38+
```
39+
40+
To inspect it first, download with `curl -fsSL URL -o install.sh`, review the file and run
41+
`sudo bash install.sh`, using the URL above.
42+
43+
1. Confirm dependency installation. The checkout lives in `/opt/megaproxy-server`; Python and
44+
project dependencies are installed automatically.
45+
2. Enter a host name, its **public** IP or DNS name, administrator and current SSH port. Choose or
46+
generate an administrative key, then select HTTPS and/or SSH, certificates and proxy users.
47+
For domain ACME, point DNS at this server beforehand. Open the required provider firewall ports.
48+
3. Set and save an Ansible Vault password; subsequent invocations will need it.
49+
4. Copy the displayed administrative private key to your computer before applying. With existing
50+
root access, use another local terminal: `scp root@SERVER:/PATH/TO/KEY ./megaproxy-admin`, then
51+
`chmod 600 ./megaproxy-admin`. With a sudo account, transfer securely using sudo to read the
52+
file; do not make it publicly readable on the server.
53+
5. Confirm that the key is saved. Setup creates the administrator locally, checks a fresh SSH login
54+
and sudo through loopback, disables root/password administrative login, configures services and
55+
the host firewall, verifies provisioning and exports client configurations.
56+
6. Keep the original session open and test login **from your computer**:
57+
`ssh -i ./megaproxy-admin -p PORT ADMIN@SERVER`. Securely retrieve
58+
`/opt/megaproxy-server/.generated/configs/MegaProxy.json` using sudo access.
59+
60+
If you cancelled before applying or setup failed, rerun the installer or resume with:
61+
62+
```shell
63+
sudo /opt/megaproxy-server/mega-proxy setup-local
64+
```
65+
66+
Existing inventory is reused; the installer does not automatically update an existing checkout.
67+
For maintenance, run `sudo /opt/megaproxy-server/mega-proxy` with the desired command.
68+
Inventory, keys and exports stay on the server; keep private backups. In this mode `local: true`
69+
routes administrative SSH through loopback while client exports retain the public address.
70+
Only use this inventory on that server. To move control elsewhere, remove `local: true` and update
71+
key paths. Local setup handles one server; multi-host management is described below.
72+
73+
### Manage from another machine
74+
2575
```shell
2676
./mega-proxy inventory
2777
./mega-proxy bootstrap

‎docs/ru/README.md‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,59 @@ ACME-сертификаты на публичный IP требуют Certbot 5.
2222

2323
## Первое развёртывание
2424

25+
### Установка на самом сервере
26+
27+
Подключитесь к Debian/Ubuntu по SSH с терминалом (`ssh -t`) либо откройте консоль сервера.
28+
Нужны root или `sudo`, установленный `curl` и доступ в интернет. Если `curl` отсутствует:
29+
30+
```shell
31+
sudo apt-get update && sudo apt-get install -y curl ca-certificates
32+
```
33+
34+
Запустите мастер (для root уберите `sudo`):
35+
36+
```shell
37+
curl -fsSL https://raw.githubusercontent.com/andre487/MegaProxyServer/main/install.sh | sudo bash
38+
```
39+
40+
Если хотите сначала прочитать скрипт, скачайте его через `curl -fsSL URL -o install.sh`,
41+
просмотрите и выполните `sudo bash install.sh`, подставив URL из команды выше.
42+
43+
1. Подтвердите установку зависимостей. Репозиторий сохраняется в `/opt/megaproxy-server`;
44+
Python и зависимости проекта устанавливаются автоматически.
45+
2. Укажите имя сервера, его **публичный** IP или DNS, административный логин и текущий SSH-порт.
46+
Выберите или создайте административный ключ, затем HTTPS и/или SSH, сертификат и пользователей.
47+
Для доменного ACME заранее направьте DNS на этот сервер. Откройте нужные порты у провайдера.
48+
3. Задайте пароль Ansible Vault и сохраните его: он понадобится для следующих запусков.
49+
4. Мастер покажет путь к административному приватному ключу. Скопируйте его на свой компьютер
50+
через ещё открытый административный доступ. Для root-доступа можно использовать во втором
51+
локальном терминале `scp root@SERVER:/ПУТЬ/К/КЛЮЧУ ./megaproxy-admin`, затем
52+
`chmod 600 ./megaproxy-admin`. При входе через sudo-пользователя используйте защищённую передачу
53+
с чтением файла через `sudo`; не делайте ключ общедоступным на сервере.
54+
5. Только после сохранения ключа подтвердите применение. Мастер создаёт администратора локально,
55+
проверяет независимый SSH-вход и sudo через loopback, отключает root/парольный административный
56+
вход, настраивает сервисы и firewall, проверяет их и создаёт клиентские конфигурации.
57+
6. Не закрывая исходную сессию, проверьте вход **со своего компьютера**:
58+
`ssh -i ./megaproxy-admin -p PORT ADMIN@SERVER`. Заберите клиентский файл
59+
`/opt/megaproxy-server/.generated/configs/MegaProxy.json` через защищённый канал с sudo.
60+
61+
Если остановились перед применением или установка завершилась ошибкой, повторите команду
62+
установщика либо запустите:
63+
64+
```shell
65+
sudo /opt/megaproxy-server/mega-proxy setup-local
66+
```
67+
68+
Существующий inventory используется повторно; установщик не обновляет checkout автоматически.
69+
Для дальнейшей работы выполняйте `sudo /opt/megaproxy-server/mega-proxy` с нужной командой.
70+
Inventory, ключи и экспорты остаются на сервере; сохраняйте их резервные копии приватно.
71+
В этом режиме `local: true` направляет управляющий SSH на loopback, а публичный адрес используется
72+
в клиентских конфигурациях. Такой inventory предназначен для запуска только на этом сервере;
73+
при переносе управления на другую машину уберите `local: true` и исправьте пути к ключам.
74+
Мастер локальной установки настраивает один сервер; управление несколькими хостами описано ниже.
75+
76+
### Управление с другой машины
77+
2578
```shell
2679
./mega-proxy inventory
2780
./mega-proxy bootstrap

‎install.sh‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
#!/usr/bin/env bash
2+
# Keep execution after the complete function definition for curl | bash.
3+
main() {
4+
set -euo pipefail
5+
umask 077
6+
if [[ ${1:-} == --help ]]; then
7+
echo 'Usage: curl -fsSL https://raw.githubusercontent.com/andre487/MegaProxyServer/main/install.sh | sudo bash'
8+
echo 'Interactive Debian/Ubuntu installation into /opt/megaproxy-server. Requires a terminal.'
9+
echo 'Set MEGAPROXY_REF to select a branch or tag for a fresh checkout (default: main).'
10+
return
11+
fi
12+
if [[ $# -ne 0 ]]; then
13+
echo 'Unexpected arguments; use --help.' >&2
14+
return 2
15+
fi
16+
if [[ $EUID -ne 0 ]]; then
17+
echo 'Run as root: curl -fsSL URL | sudo bash' >&2
18+
return 1
19+
fi
20+
if [[ ! -r /etc/os-release ]]; then
21+
echo 'Only Debian and Ubuntu are supported.' >&2
22+
return 1
23+
fi
24+
. /etc/os-release
25+
case "$ID" in
26+
debian|ubuntu) ;;
27+
*) echo 'Only Debian and Ubuntu are supported.' >&2; return 1 ;;
28+
esac
29+
# stdin contains the script when piped; all prompts must read the terminal.
30+
if ! exec </dev/tty; then
31+
echo 'An interactive terminal is required (connect with ssh -t).' >&2
32+
return 1
33+
fi
34+
local install_dir=/opt/megaproxy-server answer
35+
echo "Install dependencies and MegaProxyServer into $install_dir, then start the setup wizard."
36+
read -r -p 'Continue? [y/N] ' answer
37+
[[ "$answer" == [yY] ]] || return 0
38+
apt-get update
39+
DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl git python3 python3-venv openssh-server sudo
40+
systemctl enable --now ssh
41+
if [[ ! -e "$install_dir" ]]; then
42+
git clone --depth 1 --branch "${MEGAPROXY_REF:-main}" https://github.com/andre487/MegaProxyServer.git "$install_dir"
43+
elif [[ ! -f "$install_dir/src/megaproxy_server/local_setup.py" ]]; then
44+
echo "$install_dir already exists without local setup support; move it aside or update it manually." >&2
45+
return 1
46+
fi
47+
cd "$install_dir"
48+
if ! command -v uv >/dev/null 2>&1; then
49+
python3 -m venv .bootstrap
50+
.bootstrap/bin/pip install 'uv==0.12.5'
51+
ln -s "$install_dir/.bootstrap/bin/uv" /usr/local/bin/uv
52+
fi
53+
uv sync --frozen --python 3.12
54+
./mega-proxy setup-local
55+
}
56+
57+
main "$@"

‎src/megaproxy_server/bootstrap.py‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
from ruamel.yaml import YAML
1212

13-
from .inventory import ROOT, ansible_inventory, save
13+
from .inventory import LOCAL_SSH_ARGS, ROOT, ansible_inventory, save
1414
from .models import Host, Inventory
1515

1616

@@ -20,10 +20,11 @@ def check_admin(host: Host, *, report_error: bool = False) -> bool:
2020
'-o', 'PreferredAuthentications=publickey',
2121
'-o', 'ControlMaster=no', '-o', 'ControlPath=none', '-o', 'ConnectTimeout=10',
2222
'-p', str(host.admin.port), '-i', str(Path(host.admin.private_key_file).expanduser()),
23+
*(shlex.split(LOCAL_SSH_ARGS) if host.local else []),
2324
*shlex.split(os.environ.get('ANSIBLE_SSH_ARGS', '')),
2425
*shlex.split(os.environ.get('ANSIBLE_SSH_COMMON_ARGS', '')),
2526
*shlex.split(os.environ.get('ANSIBLE_SSH_EXTRA_ARGS', '')),
26-
'-l', host.admin.user, '--', host.address, 'sudo -n true',
27+
'-l', host.admin.user, '--', '127.0.0.1' if host.local else host.address, 'sudo -n true',
2728
]
2829
try:
2930
result = sp.run(command, capture_output=True, text=True, timeout=30)
@@ -52,6 +53,11 @@ def run_phase(inventory: Inventory, name: str, phase: str, password: str | None
5253
variables['ansible_become_password'] = password
5354
variables['ansible_ssh_common_args'] = '-o PubkeyAuthentication=no -o PreferredAuthentications=password,keyboard-interactive'
5455
variables['megaproxy_bootstrap_phase'] = phase
56+
if host.local and phase == 'account':
57+
if os.geteuid() != 0:
58+
raise ValueError('Local bootstrap must run as root')
59+
variables['ansible_connection'] = 'local'
60+
variables['ansible_python_interpreter'] = sys.executable
5561
# Bootstrap passwords exist only in a private temporary directory for this subprocess.
5662
with tempfile.TemporaryDirectory(prefix='megaproxy-bootstrap-') as directory:
5763
path = Path(directory) / 'inventory.yml'
@@ -73,7 +79,7 @@ def bootstrap(path: Path, inventory: Inventory, limit: str | None = None) -> int
7379
print(f'Bootstrapping administrative access: {name}', flush=True)
7480
if not check_admin(host):
7581
password = None
76-
if host.admin.bootstrap_auth == 'password':
82+
if host.admin.bootstrap_auth == 'password' and not host.local:
7783
password = getpass.getpass(f'Initial SSH password for {host.admin.bootstrap_user}@{host.address}: ')
7884
if not password:
7985
raise ValueError('Initial SSH password must not be empty')

‎src/megaproxy_server/cli.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ def parser() -> argparse.ArgumentParser:
3232
sub = result.add_subparsers(dest="command")
3333
commands = []
3434
commands.append(sub.add_parser("inventory", help="Create a new inventory"))
35+
commands.append(sub.add_parser("setup-local", help="Interactively provision this server (root)"))
3536
commands.append(sub.add_parser("add-host", help="Add hosts to the existing inventory"))
3637
initial = sub.add_parser("bootstrap", help="Create and verify permanent administrative access")
3738
initial.add_argument("--limit", action=ExtendLimit, help="Exact host names, comma-separated or repeated")
@@ -134,6 +135,9 @@ def main() -> None:
134135
try:
135136
if args.command == "check":
136137
raise SystemExit(run_checks())
138+
if args.command == "setup-local":
139+
from .local_setup import setup_local
140+
raise SystemExit(setup_local(discover(args.inventory) or DEFAULT_INVENTORY))
137141
path = choose_inventory(args.inventory, args.command == "inventory")
138142
if args.command == "inventory":
139143
print(f"Created {path}")

‎src/megaproxy_server/inventory.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import ipaddress
66
import os
77
import re
8+
import shlex
89
from pathlib import Path
910
from typing import Any
1011

@@ -15,6 +16,10 @@
1516
from .models import Inventory
1617

1718
ROOT = Path(__file__).resolve().parents[2]
19+
LOCAL_SSH_ARGS = (
20+
"-o StrictHostKeyChecking=yes -o UserKnownHostsFile="
21+
+ shlex.quote(str(ROOT / ".secrets/local-known-hosts"))
22+
)
1823
DEFAULT_INVENTORY = Path.cwd() / "inventory.yml"
1924
POINTER_FILE = Path.cwd() / "inventory-path"
2025
_vault_secret: VaultSecret | None = None
@@ -215,13 +220,15 @@ def ansible_inventory(inventory: Inventory) -> dict[str, Any]:
215220
}
216221
)
217222
variables: dict[str, Any] = {
218-
"ansible_host": host.address,
223+
"ansible_host": "127.0.0.1" if host.local else host.address,
219224
"ansible_user": host.admin.bootstrap_user or host.admin.user,
220225
"ansible_port": host.admin.port,
221226
"megaproxy_admin": host.admin.model_dump(mode="json", exclude_none=True),
222227
"megaproxy_settings": inventory.settings.model_dump(mode="json"),
223228
"megaproxy_services": services,
224229
}
230+
if host.local:
231+
variables["ansible_ssh_common_args"] = LOCAL_SSH_ARGS
225232
if https and https.enabled:
226233
variables["megaproxy_https_public_routes"] = variables_public_routes
227234
variables["ansible_ssh_private_key_file"] = (
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
from __future__ import annotations
2+
3+
import os
4+
from pathlib import Path
5+
6+
import questionary
7+
8+
from .bootstrap import bootstrap
9+
from .generate import generate
10+
from .inventory import ROOT, load
11+
from .wizard import create_inventory
12+
13+
14+
def setup_local(path: Path) -> int:
15+
from .cli import run_ansible
16+
17+
if os.geteuid() != 0:
18+
raise ValueError('Local setup must run as root; use sudo')
19+
os.umask(0o077)
20+
inventory = load(path) if path.exists() else create_inventory(path, local=True)
21+
if len(inventory.hosts) != 1 or not next(iter(inventory.hosts.values())).local:
22+
raise ValueError('Local setup requires an inventory with exactly one local host')
23+
host = next(iter(inventory.hosts.values()))
24+
# Trust the host's own keys, without a network scan or disabling SSH verification.
25+
keys = list(Path('/etc/ssh').glob('ssh_host_*_key.pub'))
26+
if not keys:
27+
raise ValueError('No SSH host keys found in /etc/ssh; start openssh-server first')
28+
known_hosts = ROOT / '.secrets/local-known-hosts'
29+
known_hosts.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
30+
known_hosts.write_text(''.join(
31+
f'127.0.0.1,[127.0.0.1]:{host.admin.port} {key.read_text().strip()}\n'
32+
for key in keys
33+
))
34+
known_hosts.chmod(0o600)
35+
print(f'Inventory: {path}\nAdministrative private key: {host.admin.private_key_file}')
36+
print('Copy this private key securely to your computer before continuing. Keep this session open.')
37+
print(f'Future SSH login: {host.admin.user}@{host.address}, port {host.admin.port}')
38+
print('Setup disables root/password administrative SSH login and configures the host firewall.')
39+
if not questionary.confirm('Key saved outside this server; apply configuration now?', default=False).ask():
40+
print('Configuration saved. Run setup-local again to continue.')
41+
return 0
42+
code = bootstrap(path, inventory)
43+
if code:
44+
return code
45+
for playbook in ('site.yml', 'verify.yml'):
46+
code = run_ansible(path, playbook)
47+
if code:
48+
return code
49+
output = ROOT / '.generated/configs'
50+
generate(path, output)
51+
print(f'Setup verified. Client configurations (contain secrets): {output}')
52+
return 0

‎src/megaproxy_server/models.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ def any_enabled(self) -> Services:
112112

113113
class Host(BaseModel):
114114
address: str
115+
local: bool = False
115116
admin: AdminAccess = Field(default_factory=AdminAccess)
116117
services: Services
117118

0 commit comments

Comments
 (0)