Skip to content

Commit 86aff4d

Browse files
committed
Add new hosts bootstrap
1 parent 1e2b2c6 commit 86aff4d

12 files changed

Lines changed: 549 additions & 98 deletions

File tree

‎docs/en/README.md‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Public-IP ACME certificates require Certbot 5.4+ and Let's Encrypt's short-lived
2424

2525
```shell
2626
./mega-proxy inventory
27+
./mega-proxy bootstrap
2728
./mega-proxy plan
2829
./mega-proxy apply
2930
./mega-proxy verify
@@ -33,9 +34,30 @@ The wizard can create administrative keys, proxy credentials and an encrypted in
3334
inventory outside the repository is remembered in the ignored `inventory-path` file. Override it
3435
at any time with `--inventory PATH`.
3536

36-
For bootstrap, `admin.bootstrap_user` is used for the initial connection. A successful full apply
37-
creates the permanent administrator, installs its key and removes `bootstrap_user` from inventory.
38-
Keep the original SSH session open until the permanent login has been tested.
37+
The wizard supports an initial password or a separate SSH key. Bootstrap prompts for the password
38+
without echoing it. The password is kept only in a private temporary connection file, removed after
39+
Ansible exits. Verify the server fingerprint and add its key to `known_hosts` first. Load
40+
passphrase-protected administrative keys with `ssh-add`.
41+
42+
`bootstrap` creates the administrator and sudo access, checks a fresh key-only login and `sudo -n`,
43+
then disables root and password-based administrative login. It verifies access again after SSH
44+
reload and immediately updates inventory per host. SSH proxy users keep password authentication.
45+
Retrying uses the administrator if already accessible, without requesting the initial password.
46+
Keep the original SSH session open until verification succeeds. `apply` automatically bootstraps
47+
selected pending hosts, including when tags are specified. Run bootstrap before planning new hosts.
48+
49+
To add a server while preserving existing settings and proxy users:
50+
51+
```shell
52+
./mega-proxy add-host
53+
./mega-proxy bootstrap --limit proxy_new
54+
./mega-proxy plan --limit proxy_new
55+
./mega-proxy apply --limit proxy_new
56+
```
57+
58+
Bootstrap accepts comma-separated exact inventory host names for `--limit`; the flag can be repeated.
59+
The fresh login check respects local SSH configuration and reports SSH or sudo errors. A later proxy
60+
installation failure does not switch completed hosts back to root.
3961

4062
## Commands and inventory
4163

‎docs/ru/README.md‎

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ ACME-сертификаты на публичный IP требуют Certbot 5.
2424

2525
```shell
2626
./mega-proxy inventory
27+
./mega-proxy bootstrap
2728
./mega-proxy plan
2829
./mega-proxy apply
2930
./mega-proxy verify
@@ -33,9 +34,32 @@ ACME-сертификаты на публичный IP требуют Certbot 5.
3334
вне репозитория сохраняется в игнорируемом `inventory-path`. Параметр `--inventory PATH` всегда имеет
3435
приоритет.
3536

36-
Для первого подключения используется `admin.bootstrap_user`. После успешного полного apply CLI
37-
создаёт постоянного администратора, устанавливает его ключ и удаляет `bootstrap_user` из inventory.
38-
Не закрывайте исходную SSH-сессию, пока не проверите постоянный логин.
37+
Для первого подключения мастер предлагает пароль или отдельный SSH-ключ. Пароль запрашивается
38+
скрыто при bootstrap и не сохраняется в постоянном inventory; временный файл подключения удаляется
39+
после запуска Ansible. До запуска проверьте fingerprint сервера и добавьте его ключ в `known_hosts`.
40+
Для административного ключа с парольной фразой предварительно используйте `ssh-add`.
41+
42+
`bootstrap` создаёт администратора с ключом и sudo, проверяет независимое подключение и `sudo -n`,
43+
затем отключает root и парольный административный вход. После перечитывания SSH-конфигурации
44+
доступ проверяется повторно, а inventory сразу переключается на администратора. Парольный вход
45+
для пользователей SSH-прокси сохраняется. При ошибке повторите bootstrap: если администратор уже
46+
доступен, первоначальный пароль больше не потребуется. Не закрывайте исходную SSH-сессию до
47+
успешной проверки. `apply` автоматически выполняет bootstrap для выбранных новых хостов, даже
48+
при указании тегов; для планирования нового хоста сначала выполните bootstrap отдельно.
49+
50+
Чтобы добавить сервер в существующий inventory:
51+
52+
```shell
53+
./mega-proxy add-host
54+
./mega-proxy bootstrap --limit proxy_new
55+
./mega-proxy plan --limit proxy_new
56+
./mega-proxy apply --limit proxy_new
57+
```
58+
59+
Для bootstrap `--limit` принимает точные имена хостов через запятую; флаг можно повторять.
60+
Проверка нового входа учитывает локальную SSH-конфигурацию и выводит причину ошибки SSH или sudo. Существующие настройки и
61+
пользователи прокси сохраняются. Завершение bootstrap фиксируется отдельно для каждого хоста,
62+
поэтому ошибка последующей установки прокси не возвращает подключение к root.
3963

4064
## Команды и inventory
4165

‎playbooks/bootstrap.yml‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
- name: Bootstrap administrative access
3+
hosts: all
4+
become: true
5+
gather_facts: true
6+
tasks:
7+
- name: Validate supported operating system
8+
ansible.builtin.assert:
9+
that:
10+
- ansible_facts['distribution'] in ['Debian', 'Ubuntu']
11+
- megaproxy_bootstrap_phase in ['account', 'policy']
12+
13+
- name: Install sudo for the administrative account
14+
ansible.builtin.apt:
15+
name: sudo
16+
state: present
17+
update_cache: true
18+
when: megaproxy_bootstrap_phase == 'account'
19+
20+
- name: Set up administrative access
21+
ansible.builtin.include_role:
22+
name: admin
23+
tasks_from: "{{ megaproxy_bootstrap_phase }}"
24+
25+
- name: Apply administrative SSH policy now
26+
ansible.builtin.meta: flush_handlers

‎roles/admin/tasks/account.yml‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
- name: Validate administrative access
3+
ansible.builtin.assert:
4+
that:
5+
- megaproxy_admin.user != "root"
6+
- megaproxy_admin.public_key | length > 0
7+
fail_msg: "A non-root administrator and its public key are required"
8+
9+
- name: Create or update administrative user
10+
ansible.builtin.user:
11+
name: "{{ megaproxy_admin.user }}"
12+
state: present
13+
create_home: true
14+
password: x
15+
update_password: on_create
16+
groups: sudo
17+
append: true
18+
register: megaproxy_admin_account
19+
20+
- name: Create administrative SSH directory
21+
ansible.builtin.file:
22+
path: "{{ megaproxy_admin_account.home }}/.ssh"
23+
state: directory
24+
owner: "{{ megaproxy_admin.user }}"
25+
mode: "0700"
26+
27+
- name: Install administrative public key
28+
ansible.builtin.lineinfile:
29+
path: "{{ megaproxy_admin_account.home }}/.ssh/authorized_keys"
30+
line: "{{ megaproxy_admin.public_key }}"
31+
create: true
32+
owner: "{{ megaproxy_admin.user }}"
33+
mode: "0600"
34+
no_log: true
35+
36+
- name: Allow non-interactive administration through sudo
37+
ansible.builtin.copy:
38+
dest: "/etc/sudoers.d/megaproxy-{{ megaproxy_admin.user }}"
39+
content: "{{ megaproxy_admin.user }} ALL=(ALL:ALL) NOPASSWD: ALL\n"
40+
owner: root
41+
group: root
42+
mode: "0440"
43+
validate: /usr/sbin/visudo -cf %s
44+

‎roles/admin/tasks/main.yml‎

Lines changed: 4 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,6 @@
11
---
2-
- name: Validate administrative access
3-
ansible.builtin.assert:
4-
that:
5-
- megaproxy_admin.user != "root"
6-
- megaproxy_admin.public_key | length > 0
7-
fail_msg: "A non-root administrator and its public key are required"
2+
- name: Configure administrative account
3+
ansible.builtin.import_tasks: account.yml
84

9-
- name: Create or update administrative user
10-
ansible.builtin.user:
11-
name: "{{ megaproxy_admin.user }}"
12-
state: present
13-
create_home: true
14-
groups: sudo
15-
append: true
16-
register: megaproxy_admin_account
17-
18-
- name: Create administrative SSH directory
19-
ansible.builtin.file:
20-
path: "{{ megaproxy_admin_account.home }}/.ssh"
21-
state: directory
22-
owner: "{{ megaproxy_admin.user }}"
23-
mode: "0700"
24-
25-
- name: Install administrative public key
26-
ansible.builtin.lineinfile:
27-
path: "{{ megaproxy_admin_account.home }}/.ssh/authorized_keys"
28-
line: "{{ megaproxy_admin.public_key }}"
29-
create: true
30-
owner: "{{ megaproxy_admin.user }}"
31-
mode: "0600"
32-
no_log: true
33-
34-
- name: Allow non-interactive administration through sudo
35-
ansible.builtin.copy:
36-
dest: "/etc/sudoers.d/megaproxy-{{ megaproxy_admin.user }}"
37-
content: "{{ megaproxy_admin.user }} ALL=(ALL:ALL) NOPASSWD: ALL\n"
38-
owner: root
39-
group: root
40-
mode: "0440"
41-
validate: /usr/sbin/visudo -cf %s
42-
43-
- name: Install global SSH authentication policy
44-
ansible.builtin.copy:
45-
dest: /etc/ssh/sshd_config.d/40-megaproxy-admin.conf
46-
content: |
47-
PermitRootLogin no
48-
PubkeyAuthentication yes
49-
PasswordAuthentication no
50-
KbdInteractiveAuthentication no
51-
owner: root
52-
group: root
53-
mode: "0644"
54-
validate: /usr/sbin/sshd -t -f %s
55-
notify: Validate and reload administrative ssh policy
5+
- name: Configure administrative policy
6+
ansible.builtin.import_tasks: policy.yml

‎roles/admin/tasks/policy.yml‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
---
2+
- name: Read current SSH connection context
3+
ansible.builtin.command: printenv SSH_CONNECTION
4+
become: false
5+
changed_when: false
6+
check_mode: false
7+
register: megaproxy_ssh_connection
8+
9+
- name: Read previous administrative SSH policy
10+
ansible.builtin.stat:
11+
path: /etc/ssh/sshd_config.d/00-megaproxy-admin.conf
12+
register: megaproxy_policy_stat
13+
14+
- name: Preserve previous administrative SSH policy
15+
ansible.builtin.slurp:
16+
src: /etc/ssh/sshd_config.d/00-megaproxy-admin.conf
17+
register: megaproxy_policy_previous
18+
when: megaproxy_policy_stat.stat.exists
19+
20+
- name: Install and validate administrative SSH policy
21+
block:
22+
- name: Install global SSH authentication policy
23+
ansible.builtin.copy:
24+
dest: /etc/ssh/sshd_config.d/00-megaproxy-admin.conf
25+
content: |
26+
PermitRootLogin no
27+
PubkeyAuthentication yes
28+
PasswordAuthentication no
29+
KbdInteractiveAuthentication no
30+
owner: root
31+
group: root
32+
mode: "0644"
33+
notify: Validate and reload administrative ssh policy
34+
35+
- name: Validate complete SSH configuration
36+
ansible.builtin.command: /usr/sbin/sshd -t
37+
changed_when: false
38+
when: not ansible_check_mode
39+
40+
- name: Inspect effective administrative SSH authentication
41+
ansible.builtin.command:
42+
argv:
43+
- /usr/sbin/sshd
44+
- -T
45+
- -C
46+
- "user={{ item }},host={{ ansible_facts['hostname'] }},addr={{ megaproxy_ssh_connection.stdout.split()[0] }},laddr={{ megaproxy_ssh_connection.stdout.split()[2] }},lport={{ megaproxy_ssh_connection.stdout.split()[3] }}"
47+
loop:
48+
- root
49+
- "{{ megaproxy_admin.user }}"
50+
register: megaproxy_effective_policy
51+
changed_when: false
52+
when: not ansible_check_mode
53+
54+
- name: Require effective administrative SSH restrictions
55+
ansible.builtin.assert:
56+
that:
57+
- "'permitrootlogin no' in item.stdout_lines"
58+
- "'pubkeyauthentication yes' in item.stdout_lines"
59+
- "'passwordauthentication no' in item.stdout_lines"
60+
- "'kbdinteractiveauthentication no' in item.stdout_lines"
61+
fail_msg: "An existing SSH configuration overrides the administrative policy"
62+
loop: "{{ megaproxy_effective_policy.results }}"
63+
loop_control:
64+
label: "{{ item.item }}"
65+
when: not ansible_check_mode
66+
rescue:
67+
- name: Restore previous administrative SSH policy
68+
ansible.builtin.copy:
69+
dest: /etc/ssh/sshd_config.d/00-megaproxy-admin.conf
70+
content: "{{ megaproxy_policy_previous.content | b64decode }}"
71+
owner: root
72+
group: root
73+
mode: "0644"
74+
when: megaproxy_policy_stat.stat.exists
75+
76+
- name: Remove rejected administrative SSH policy
77+
ansible.builtin.file:
78+
path: /etc/ssh/sshd_config.d/00-megaproxy-admin.conf
79+
state: absent
80+
when: not megaproxy_policy_stat.stat.exists
81+
82+
- name: Report rejected administrative SSH policy
83+
ansible.builtin.fail:
84+
msg: "Administrative SSH policy validation failed; the previous policy was restored"

‎src/megaproxy_server/bootstrap.py‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
from __future__ import annotations
2+
3+
import getpass
4+
import os
5+
import shlex
6+
import subprocess as sp
7+
import tempfile
8+
import sys
9+
from pathlib import Path
10+
11+
from ruamel.yaml import YAML
12+
13+
from .inventory import ROOT, ansible_inventory, save
14+
from .models import Host, Inventory
15+
16+
17+
def check_admin(host: Host, *, report_error: bool = False) -> bool:
18+
command = [
19+
'ssh', '-o', 'BatchMode=yes', '-o', 'IdentitiesOnly=yes',
20+
'-o', 'PreferredAuthentications=publickey',
21+
'-o', 'ControlMaster=no', '-o', 'ControlPath=none', '-o', 'ConnectTimeout=10',
22+
'-p', str(host.admin.port), '-i', str(Path(host.admin.private_key_file).expanduser()),
23+
*shlex.split(os.environ.get('ANSIBLE_SSH_ARGS', '')),
24+
*shlex.split(os.environ.get('ANSIBLE_SSH_COMMON_ARGS', '')),
25+
*shlex.split(os.environ.get('ANSIBLE_SSH_EXTRA_ARGS', '')),
26+
'-l', host.admin.user, '--', host.address, 'sudo -n true',
27+
]
28+
try:
29+
result = sp.run(command, capture_output=True, text=True, timeout=30)
30+
except sp.TimeoutExpired:
31+
if report_error:
32+
print('Administrative SSH probe timed out after 30 seconds', file=sys.stderr)
33+
return False
34+
if result.returncode and report_error:
35+
print(f'Administrative SSH probe failed (exit {result.returncode}):', file=sys.stderr)
36+
print(result.stderr.strip() or result.stdout.strip() or 'SSH returned no diagnostic output', file=sys.stderr)
37+
return result.returncode == 0
38+
39+
40+
def run_phase(inventory: Inventory, name: str, phase: str, password: str | None = None) -> int:
41+
host = inventory.hosts[name]
42+
data = ansible_inventory(inventory)
43+
variables = data['all']['hosts'][name]
44+
data['all']['hosts'] = {name: variables}
45+
variables['ansible_user'] = host.admin.user if phase == 'policy' else host.admin.bootstrap_user
46+
variables['ansible_ssh_private_key_file'] = (
47+
host.admin.private_key_file if phase == 'policy'
48+
else host.admin.bootstrap_private_key_file or host.admin.private_key_file
49+
)
50+
if password is not None:
51+
variables['ansible_password'] = password
52+
variables['ansible_become_password'] = password
53+
variables['ansible_ssh_common_args'] = '-o PubkeyAuthentication=no -o PreferredAuthentications=password,keyboard-interactive'
54+
variables['megaproxy_bootstrap_phase'] = phase
55+
# Bootstrap passwords exist only in a private temporary directory for this subprocess.
56+
with tempfile.TemporaryDirectory(prefix='megaproxy-bootstrap-') as directory:
57+
path = Path(directory) / 'inventory.yml'
58+
with open(path, 'w', encoding='utf-8', opener=lambda name, flags: os.open(name, flags, 0o600)) as stream:
59+
YAML().dump(data, stream)
60+
return sp.run([
61+
'ansible-playbook', '-i', str(path), str(ROOT / 'playbooks/bootstrap.yml'),
62+
], cwd=ROOT).returncode
63+
64+
65+
def bootstrap(path: Path, inventory: Inventory, limit: str | None = None) -> int:
66+
names = list(inventory.hosts) if not limit else [name.strip() for name in limit.split(',')]
67+
if not names or any(name not in inventory.hosts for name in names):
68+
raise ValueError('Bootstrap requires exact inventory host names in --limit')
69+
for name in dict.fromkeys(names):
70+
host = inventory.hosts[name]
71+
if not host.admin.bootstrap_user:
72+
continue
73+
print(f'Bootstrapping administrative access: {name}', flush=True)
74+
if not check_admin(host):
75+
password = None
76+
if host.admin.bootstrap_auth == 'password':
77+
password = getpass.getpass(f'Initial SSH password for {host.admin.bootstrap_user}@{host.address}: ')
78+
if not password:
79+
raise ValueError('Initial SSH password must not be empty')
80+
code = run_phase(inventory, name, 'account', password)
81+
password = None
82+
if code:
83+
return code
84+
if not check_admin(host, report_error=True):
85+
raise ValueError(f'Administrative key login or sudo failed for {name}; SSH policy was not changed')
86+
code = run_phase(inventory, name, 'policy')
87+
if code:
88+
return code
89+
if not check_admin(host, report_error=True):
90+
raise ValueError(f'Administrative access verification failed after SSH reload for {name}; bootstrap remains pending')
91+
host.admin.bootstrap_user = None
92+
host.admin.bootstrap_private_key_file = None
93+
host.admin.bootstrap_auth = 'key'
94+
save(path, inventory)
95+
print(f'Administrative connection activated for: {name}', flush=True)
96+
return 0

0 commit comments

Comments
 (0)