chore: база
This commit is contained in:
1 parent
93790da617
commit
5f53cadf89
39 files changed
+1458
-19
No files matched your search
@@ -28,3 +28,37 @@
|
||||
- 20Gb диска
|
||||
|
||||
Делается под Debian 13.
|
||||
|
||||
## Использование
|
||||
|
||||
Зависимости: ansible-core, ansible-runner (и cryptography, которая приходит с ansible-core). Django не нужен.
|
||||
На машине, откуда идет управление, должен быть `ssh`. `sshpass` не нужен: вход по паролю идет через `ssh_askpass`.
|
||||
|
||||
```python
|
||||
from serverus import Host, Server, generate_keypair, generate_password, generate_ssh_port
|
||||
|
||||
keys = generate_keypair()
|
||||
root_password = generate_password()
|
||||
ssh_port = generate_ssh_port()
|
||||
|
||||
# свежий сервер: вход по паролю на 22 порт
|
||||
server = Server(Host('1.2.3.4', private_key=keys.private_key))
|
||||
result = server.bootstrap('пароль от провайдера', keys.public_key, root_password, ssh_port)
|
||||
|
||||
# дальше только по ключу на новом порту
|
||||
server = Server(Host('1.2.3.4', port=ssh_port, private_key=keys.private_key))
|
||||
server.facts().data # {'uname': ..., 'distribution': 'Debian', 'version': '13'}
|
||||
server.release(generate_password())
|
||||
```
|
||||
|
||||
Каждая операция возвращает `Result(ok, status, data, error)`. Ключи, пароли и артефакты ansible живут во временной папке только на время запуска. Хранить ключи и пароли - задача вызывающей стороны.
|
||||
|
||||
- `bootstrap` - проверяет Debian 13, прописывает ключ, меняет пароль root, обновляет систему, включает nftables и переносит ssh на новый порт с входом только по ключу. Порт меняется в два шага с проверкой входа по ключу, чтобы не потерять доступ.
|
||||
- `release` - ставит новый пароль root, сбрасывает nftables, удаляет drop-in sshd (возвращаются настройки провайдера) и authorized_keys.
|
||||
- `facts` - `uname -a` и версия ОС.
|
||||
|
||||
Настройки ssh пишутся в `/etc/ssh/sshd_config.d/00-serverus.conf`, основной конфиг не трогаем.
|
||||
|
||||
Плейбуки лежат в `playbooks/`, общие шаги в `playbooks/tasks/`. Новая операция - это плейбук и метод в `Server` или его наследнике.
|
||||
|
||||
Тесты: `python -m unittest serverus.tests` из `src/`.
|
||||
@@ -0,0 +1,15 @@
|
||||
from .host import Host
|
||||
from .runner import Result, run_playbook
|
||||
from .credentials import KeyPair, generate_keypair, generate_password, generate_ssh_port
|
||||
from .server import Server
|
||||
|
||||
__all__ = [
|
||||
'Host',
|
||||
'KeyPair',
|
||||
'Result',
|
||||
'Server',
|
||||
'generate_keypair',
|
||||
'generate_password',
|
||||
'generate_ssh_port',
|
||||
'run_playbook',
|
||||
]
|
||||
@@ -0,0 +1,41 @@
|
||||
import secrets
|
||||
import string
|
||||
from dataclasses import dataclass
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
PASSWORD_ALPHABET = string.ascii_letters + string.digits
|
||||
SSH_PORT_RANGE = (20000, 60000)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KeyPair:
|
||||
private_key: str
|
||||
public_key: str
|
||||
|
||||
def __repr__(self):
|
||||
return f'KeyPair({self.public_key})'
|
||||
|
||||
|
||||
def generate_keypair(comment: str = 'serverus') -> KeyPair:
|
||||
key = Ed25519PrivateKey.generate()
|
||||
private = key.private_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.OpenSSH,
|
||||
serialization.NoEncryption(),
|
||||
).decode()
|
||||
public = key.public_key().public_bytes(
|
||||
serialization.Encoding.OpenSSH,
|
||||
serialization.PublicFormat.OpenSSH,
|
||||
).decode()
|
||||
return KeyPair(private_key=private, public_key=f'{public} {comment}')
|
||||
|
||||
|
||||
def generate_password(length: int = 32) -> str:
|
||||
return ''.join(secrets.choice(PASSWORD_ALPHABET) for _ in range(length))
|
||||
|
||||
|
||||
def generate_ssh_port() -> int:
|
||||
low, high = SSH_PORT_RANGE
|
||||
return low + secrets.randbelow(high - low)
|
||||
@@ -0,0 +1,16 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Host:
|
||||
"""Адрес сервера и способ входа на него."""
|
||||
|
||||
address: str
|
||||
port: int = 22
|
||||
user: str = 'root'
|
||||
private_key: str | None = None
|
||||
"""Приватный ключ в формате OpenSSH. Без него вход возможен только по паролю."""
|
||||
|
||||
def __repr__(self):
|
||||
# ключ не должен попадать в логи
|
||||
return f'Host({self.user}@{self.address}:{self.port})'
|
||||
@@ -0,0 +1,72 @@
|
||||
# extravars: login_password, public_key, new_password, ssh_port
|
||||
#
|
||||
# Порт ssh меняется в два шага, чтобы не потерять сервер:
|
||||
# сначала sshd слушает старый и новый порт, после проверки входа по ключу на новом порту старый закрывается.
|
||||
|
||||
- name: Prepare fresh server (password login)
|
||||
hosts: target
|
||||
gather_facts: true
|
||||
vars:
|
||||
ansible_password: "{{ login_password }}"
|
||||
tasks:
|
||||
- name: Require Debian 13
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- ansible_facts.distribution == 'Debian'
|
||||
- ansible_facts.distribution_major_version == '13'
|
||||
fail_msg: "Supported only Debian 13, got {{ ansible_facts.distribution }} {{ ansible_facts.distribution_version }}"
|
||||
|
||||
- name: Create .ssh directory
|
||||
ansible.builtin.file:
|
||||
path: /root/.ssh
|
||||
state: directory
|
||||
mode: "0700"
|
||||
|
||||
- name: Authorize key
|
||||
ansible.builtin.lineinfile:
|
||||
path: /root/.ssh/authorized_keys
|
||||
line: "{{ public_key }}"
|
||||
create: true
|
||||
mode: "0600"
|
||||
|
||||
- ansible.builtin.import_tasks: tasks/root_password.yml
|
||||
|
||||
- name: Upgrade system
|
||||
ansible.builtin.apt:
|
||||
update_cache: true
|
||||
upgrade: dist
|
||||
|
||||
- ansible.builtin.import_tasks: tasks/firewall.yml
|
||||
vars:
|
||||
firewall_tcp_ports: [ "{{ ansible_port }}", "{{ ssh_port }}" ]
|
||||
|
||||
- ansible.builtin.import_tasks: tasks/sshd.yml
|
||||
vars:
|
||||
sshd_ports: [ "{{ ansible_port }}", "{{ ssh_port }}" ]
|
||||
sshd_keys_only: false
|
||||
|
||||
- name: Harden ssh (key login on new port)
|
||||
hosts: target
|
||||
gather_facts: false
|
||||
vars:
|
||||
ansible_port: "{{ ssh_port }}"
|
||||
tasks:
|
||||
- name: Check key login on new port
|
||||
ansible.builtin.wait_for_connection:
|
||||
timeout: 60
|
||||
|
||||
- ansible.builtin.import_tasks: tasks/firewall.yml
|
||||
vars:
|
||||
firewall_tcp_ports: [ "{{ ssh_port }}" ]
|
||||
|
||||
- ansible.builtin.import_tasks: tasks/sshd.yml
|
||||
vars:
|
||||
sshd_ports: [ "{{ ssh_port }}" ]
|
||||
sshd_keys_only: true
|
||||
|
||||
- name: Reset ssh connection
|
||||
ansible.builtin.meta: reset_connection
|
||||
|
||||
- name: Check hardened ssh
|
||||
ansible.builtin.wait_for_connection:
|
||||
timeout: 60
|
||||
@@ -0,0 +1,16 @@
|
||||
- name: Collect server facts
|
||||
hosts: target
|
||||
gather_facts: true
|
||||
tasks:
|
||||
- name: Read uname
|
||||
ansible.builtin.command: uname -a
|
||||
register: uname
|
||||
changed_when: false
|
||||
|
||||
- name: Return facts
|
||||
ansible.builtin.set_stats:
|
||||
aggregate: false
|
||||
data:
|
||||
uname: "{{ uname.stdout }}"
|
||||
distribution: "{{ ansible_facts.distribution }}"
|
||||
version: "{{ ansible_facts.distribution_major_version }}"
|
||||
@@ -0,0 +1,39 @@
|
||||
# extravars: new_password
|
||||
- name: Release server to owner
|
||||
hosts: target
|
||||
gather_facts: false
|
||||
tasks:
|
||||
- ansible.builtin.import_tasks: tasks/root_password.yml
|
||||
|
||||
- name: Restore default nftables config
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/nftables.conf
|
||||
mode: "0755"
|
||||
content: |
|
||||
#!/usr/sbin/nft -f
|
||||
flush ruleset
|
||||
|
||||
- name: Flush and disable nftables
|
||||
ansible.builtin.systemd_service:
|
||||
name: nftables
|
||||
enabled: false
|
||||
state: stopped
|
||||
|
||||
- name: Remove sshd drop-in
|
||||
ansible.builtin.file:
|
||||
path: /etc/ssh/sshd_config.d/00-serverus.conf
|
||||
state: absent
|
||||
|
||||
- name: Validate sshd config
|
||||
ansible.builtin.command: /usr/sbin/sshd -t
|
||||
changed_when: false
|
||||
|
||||
- name: Remove authorized keys
|
||||
ansible.builtin.file:
|
||||
path: /root/.ssh/authorized_keys
|
||||
state: absent
|
||||
|
||||
- name: Restart ssh
|
||||
ansible.builtin.systemd_service:
|
||||
name: ssh
|
||||
state: restarted
|
||||
@@ -0,0 +1,18 @@
|
||||
# vars: firewall_tcp_ports, firewall_udp_ports (optional)
|
||||
- name: Install nftables
|
||||
ansible.builtin.apt:
|
||||
name: nftables
|
||||
state: present
|
||||
|
||||
- name: Write nftables config
|
||||
ansible.builtin.template:
|
||||
src: templates/nftables.conf.j2
|
||||
dest: /etc/nftables.conf
|
||||
mode: "0755"
|
||||
validate: /usr/sbin/nft -c -f %s
|
||||
|
||||
- name: Apply nftables
|
||||
ansible.builtin.systemd_service:
|
||||
name: nftables
|
||||
enabled: true
|
||||
state: restarted
|
||||
@@ -0,0 +1,6 @@
|
||||
# vars: new_password
|
||||
- name: Set root password
|
||||
ansible.builtin.shell: chpasswd
|
||||
args:
|
||||
stdin: "root:{{ new_password }}"
|
||||
no_log: true
|
||||
@@ -0,0 +1,16 @@
|
||||
# vars: sshd_ports, sshd_keys_only
|
||||
- name: Write sshd drop-in
|
||||
ansible.builtin.template:
|
||||
src: templates/sshd.conf.j2
|
||||
dest: /etc/ssh/sshd_config.d/00-serverus.conf
|
||||
mode: "0644"
|
||||
validate: /usr/sbin/sshd -t -f %s
|
||||
|
||||
- name: Validate full sshd config
|
||||
ansible.builtin.command: /usr/sbin/sshd -t
|
||||
changed_when: false
|
||||
|
||||
- name: Restart ssh
|
||||
ansible.builtin.systemd_service:
|
||||
name: ssh
|
||||
state: restarted
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/sbin/nft -f
|
||||
# {{ ansible_managed }}
|
||||
|
||||
flush ruleset
|
||||
|
||||
table inet filter {
|
||||
chain input {
|
||||
type filter hook input priority filter; policy drop;
|
||||
|
||||
iif "lo" accept
|
||||
ct state established,related accept
|
||||
ct state invalid drop
|
||||
meta l4proto { icmp, ipv6-icmp } accept
|
||||
|
||||
tcp dport { {{ firewall_tcp_ports | join(', ') }} } accept
|
||||
{% if firewall_udp_ports | default([]) %}
|
||||
udp dport { {{ firewall_udp_ports | join(', ') }} } accept
|
||||
{% endif %}
|
||||
}
|
||||
|
||||
chain forward {
|
||||
type filter hook forward priority filter; policy drop;
|
||||
}
|
||||
|
||||
chain output {
|
||||
type filter hook output priority filter; policy accept;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# {{ ansible_managed }}
|
||||
# Удаление этого файла возвращает настройки ssh, которые были до serverus.
|
||||
{% for port in sshd_ports %}
|
||||
Port {{ port }}
|
||||
{% endfor %}
|
||||
{% if sshd_keys_only %}
|
||||
PermitRootLogin prohibit-password
|
||||
PasswordAuthentication no
|
||||
KbdInteractiveAuthentication no
|
||||
AuthenticationMethods publickey
|
||||
{% endif %}
|
||||
@@ -0,0 +1,95 @@
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
import ansible_runner
|
||||
|
||||
from .host import Host
|
||||
|
||||
PLAYBOOKS_DIR = Path(__file__).resolve().parent / 'playbooks'
|
||||
TARGET = 'target'
|
||||
# ansible-playbook ставится рядом с интерпретатором, но venv может быть не активирован
|
||||
PATH = os.pathsep.join([str(Path(sys.executable).parent), os.environ.get('PATH', '')])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Result:
|
||||
ok: bool
|
||||
status: str
|
||||
data: dict = field(default_factory=dict)
|
||||
"""Данные, которые плейбук вернул через set_stats."""
|
||||
error: str = ''
|
||||
|
||||
|
||||
class Runner(Protocol):
|
||||
def __call__(self, playbook: str, host: Host, extravars: dict) -> Result: ...
|
||||
|
||||
|
||||
def run_playbook(playbook: str, host: Host, extravars: dict) -> Result:
|
||||
"""Выполняет плейбук из serverus/playbooks на одном хосте.
|
||||
|
||||
Все файлы запуска (ключ, extravars, артефакты) живут во временной папке и удаляются после выполнения.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory(prefix='serverus-') as private_dir:
|
||||
result = ansible_runner.run(
|
||||
private_data_dir=private_dir,
|
||||
project_dir=str(PLAYBOOKS_DIR),
|
||||
playbook=f'{playbook}.yml',
|
||||
inventory={'all': {'hosts': {TARGET: _host_vars(host, private_dir)}}},
|
||||
extravars=extravars,
|
||||
envvars={
|
||||
'ANSIBLE_NOCOLOR': '1',
|
||||
'PATH': PATH,
|
||||
# свои ssh control сокеты на каждый запуск: иначе чужое открытое соединение пропустит без авторизации
|
||||
'ANSIBLE_SSH_CONTROL_PATH_DIR': os.path.join(private_dir, 'cp'),
|
||||
},
|
||||
quiet=True,
|
||||
)
|
||||
events = list(result.events)
|
||||
with result.stdout as stdout:
|
||||
output = stdout.read()
|
||||
ok = result.status == 'successful'
|
||||
return Result(
|
||||
ok=ok,
|
||||
status=result.status,
|
||||
data=_stats_data(events),
|
||||
error='' if ok else _error(events) or output.strip()[-1000:],
|
||||
)
|
||||
|
||||
|
||||
def _host_vars(host: Host, private_dir: str) -> dict:
|
||||
host_vars = {
|
||||
'ansible_host': host.address,
|
||||
'ansible_port': host.port,
|
||||
'ansible_user': host.user,
|
||||
'ansible_python_interpreter': 'auto_silent',
|
||||
# вход по паролю без sshpass
|
||||
'ansible_ssh_password_mechanism': 'ssh_askpass',
|
||||
# TODO: хранить known_hosts сервера и включить проверку ключа хоста
|
||||
'ansible_ssh_common_args': '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR',
|
||||
}
|
||||
if host.private_key:
|
||||
key_file = Path(private_dir) / 'id_host'
|
||||
key_file.write_text(host.private_key.rstrip('\n') + '\n')
|
||||
os.chmod(key_file, 0o600)
|
||||
host_vars['ansible_ssh_private_key_file'] = str(key_file)
|
||||
return host_vars
|
||||
|
||||
|
||||
def _stats_data(events: list[dict]) -> dict:
|
||||
for event in reversed(events):
|
||||
if event.get('event') == 'playbook_on_stats':
|
||||
return event.get('event_data', {}).get('artifact_data', {}) or {}
|
||||
return {}
|
||||
|
||||
|
||||
def _error(events: list[dict]) -> str:
|
||||
for event in reversed(events):
|
||||
if event.get('event') in ('runner_on_failed', 'runner_on_unreachable'):
|
||||
event_data = event.get('event_data', {})
|
||||
res = event_data.get('res', {})
|
||||
return f"{event_data.get('task', '')}: {res.get('msg') or res.get('stderr') or 'failed'}"
|
||||
return ''
|
||||
@@ -0,0 +1,39 @@
|
||||
from .host import Host
|
||||
from .runner import Result, Runner, run_playbook
|
||||
|
||||
|
||||
class Server:
|
||||
"""Фасад над плейбуками базовой настройки сервера.
|
||||
|
||||
Специализации (панель, прокси, докер-платформа) наследуют этот класс и добавляют свои операции.
|
||||
"""
|
||||
|
||||
def __init__(self, host: Host, runner: Runner = run_playbook):
|
||||
self.host = host
|
||||
self._runner = runner
|
||||
|
||||
def run(self, playbook: str, **extravars) -> Result:
|
||||
return self._runner(playbook, self.host, extravars)
|
||||
|
||||
def facts(self) -> Result:
|
||||
"""Возвращает data: {'uname': ..., 'distribution': ..., 'version': ...}."""
|
||||
return self.run('facts')
|
||||
|
||||
def bootstrap(self, password: str, public_key: str, new_password: str, ssh_port: int) -> Result:
|
||||
"""Первичная настройка свежего сервера (Debian 13).
|
||||
|
||||
Входит по паролю на host.port, прописывает public_key, меняет пароль root на new_password, обновляет систему,
|
||||
переносит ssh на ssh_port с входом только по ключу и включает nftables.
|
||||
Для проверки входа по ключу host.private_key должен быть парой к public_key.
|
||||
"""
|
||||
return self.run(
|
||||
'bootstrap',
|
||||
login_password=password,
|
||||
public_key=public_key,
|
||||
new_password=new_password,
|
||||
ssh_port=ssh_port,
|
||||
)
|
||||
|
||||
def release(self, new_password: str) -> Result:
|
||||
"""Возвращает сервер владельцу: откатывает ssh и nftables, удаляет ключи, ставит пароль root new_password."""
|
||||
return self.run('release', new_password=new_password)
|
||||
@@ -0,0 +1,65 @@
|
||||
import unittest
|
||||
|
||||
from . import Host, Result, Server, generate_keypair, generate_password, generate_ssh_port, run_playbook
|
||||
from .credentials import SSH_PORT_RANGE
|
||||
|
||||
|
||||
class FakeRunner:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def __call__(self, playbook, host, extravars):
|
||||
self.calls.append((playbook, host, extravars))
|
||||
return Result(ok=True, status='successful')
|
||||
|
||||
|
||||
class CredentialsTests(unittest.TestCase):
|
||||
def test_keypair(self):
|
||||
pair = generate_keypair('test')
|
||||
self.assertTrue(pair.private_key.startswith('-----BEGIN OPENSSH PRIVATE KEY-----'))
|
||||
self.assertTrue(pair.public_key.startswith('ssh-ed25519 '))
|
||||
self.assertTrue(pair.public_key.endswith(' test'))
|
||||
self.assertNotIn('PRIVATE', repr(pair))
|
||||
|
||||
def test_password(self):
|
||||
self.assertEqual(len(generate_password()), 32)
|
||||
self.assertNotEqual(generate_password(), generate_password())
|
||||
|
||||
def test_ssh_port(self):
|
||||
low, high = SSH_PORT_RANGE
|
||||
self.assertTrue(low <= generate_ssh_port() < high)
|
||||
|
||||
def test_host_repr_hides_key(self):
|
||||
self.assertNotIn('secret', repr(Host('1.2.3.4', private_key='secret')))
|
||||
|
||||
|
||||
class ServerTests(unittest.TestCase):
|
||||
def test_operations_call_playbooks(self):
|
||||
runner = FakeRunner()
|
||||
server = Server(Host('1.2.3.4'), runner=runner)
|
||||
server.facts()
|
||||
server.bootstrap('old', 'ssh-ed25519 AAA', 'new', 40000)
|
||||
server.release('other')
|
||||
self.assertEqual([call[0] for call in runner.calls], ['facts', 'bootstrap', 'release'])
|
||||
self.assertEqual(runner.calls[1][2], {
|
||||
'login_password': 'old',
|
||||
'public_key': 'ssh-ed25519 AAA',
|
||||
'new_password': 'new',
|
||||
'ssh_port': 40000,
|
||||
})
|
||||
|
||||
|
||||
class RunPlaybookTests(unittest.TestCase):
|
||||
def test_facts_on_localhost(self):
|
||||
result = run_playbook('facts', Host('127.0.0.1'), {'ansible_connection': 'local'})
|
||||
self.assertTrue(result.ok, result.error)
|
||||
self.assertIn('Linux', result.data['uname'])
|
||||
|
||||
def test_unreachable_host_reports_error(self):
|
||||
result = run_playbook('facts', Host('127.0.0.1', port=1, private_key='bad'), {})
|
||||
self.assertFalse(result.ok)
|
||||
self.assertTrue(result.error)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user