Skip to content

Commit d5f9ee8

Browse files
committed
Verify HTTPS chain TLS and rotate admin keys through verified bootstrap
1 parent fbf4503 commit d5f9ee8

13 files changed

Lines changed: 309 additions & 7 deletions

File tree

‎.github/workflows/integration.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ jobs:
4747
- name: Verify authenticated SOCKS5 and MASQUE TCP and UDP
4848
run: uv run --frozen python tests/integration/transports.py --gost "$RUNNER_TEMP/megaproxy-gost/gost"
4949

50+
- name: Verify HTTPS knock, server chains and Jump
51+
run: uv run --frozen python tests/integration/https.py --gost "$RUNNER_TEMP/megaproxy-gost/gost"
52+
5053
- name: Verify HTTPS config API through nginx
5154
run: |
5255
sudo apt-get update

‎docs/en/README.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,11 +161,18 @@ reserved names and paths are rejected in `removed_ssh`. The administrative
161161
revokes the old key. Changing `admin.user` requires bootstrap through a working
162162
account: the new administrator is verified before retiring the previous managed
163163
account and sudo access, preserving its files.
164+
For key rotation, set the new `public_key`/`private_key_file`, the current admin as
165+
`bootstrap_user`, and the old private key as `bootstrap_private_key_file`.
166+
Bootstrap verifies the new login before revoking the old key; mismatched public
167+
and private keys are rejected.
164168

165169
Inventory and exports are replaced atomically with mode `0600`. Export validation
166170
runs before replacing an existing file; unknown country codes become empty strings.
167171
Port conflicts and HTTPS without routes are rejected before connecting to a host.
168172
Docker cleanup only removes old GOST/Certbot images, without global prune operations.
173+
HTTPS chains verify the exit certificate and hostname. CI uses a local CA to test
174+
knock/chain overrides, nested TLS Jump, and rejection of bad credentials, certificates
175+
or unavailable exits, without external domains or ACME for these checks.
169176

170177
| enabled | generate_profile | Result |
171178
| --- | --- | --- |

‎docs/ru/README.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,11 +170,18 @@ SSH-аккаунты могут изменяться и удаляться то
170170
старый ключ. При смене `admin.user` bootstrap сначала проверяет нового
171171
администратора, затем удаляет прежний управляемый аккаунт и sudo-доступ, сохраняя
172172
его файлы. Для миграции укажите `bootstrap_user` с действующим доступом.
173+
Для ротации ключа задайте новую пару `public_key`/`private_key_file`, текущего
174+
администратора в `bootstrap_user`, а прежний приватный ключ — в
175+
`bootstrap_private_key_file`. Bootstrap проверяет новый вход перед отзывом старого
176+
ключа; несовпадающая пара публичного и приватного ключей отклоняется.
173177

174178
Инвентарь и экспорт записываются атомарно с правами `0600`. Экспорт проверяется
175179
до замены прежнего файла; неизвестный код страны становится пустой строкой.
176180
Конфликты портов и HTTPS без маршрутов отклоняются до подключения к хосту.
177181
Docker cleanup удаляет только старые образы GOST/Certbot, без глобального prune.
182+
HTTPS-цепочки проверяют сертификат и имя выхода. CI использует локальную CA и
183+
проверяет knock, override цепочки, TLS Jump, отказ при неправильных credentials,
184+
сертификате или недоступном выходе; внешние домены и ACME для этих тестов не нужны.
178185

179186
| enabled | generate_profile | Результат |
180187
| --- | --- | --- |

‎roles/admin/tasks/account.yml‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,22 @@
2525
mode: "0700"
2626

2727
- 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+
when: megaproxy_bootstrap_phase | default('') == 'account'
35+
no_log: true
36+
37+
- name: Replace verified administrative public key
2838
ansible.builtin.copy:
2939
dest: "{{ megaproxy_admin_account.home }}/.ssh/authorized_keys"
3040
content: "{{ megaproxy_admin.public_key }}\n"
3141
owner: "{{ megaproxy_admin.user }}"
3242
mode: "0600"
43+
when: megaproxy_bootstrap_phase | default('') != 'account'
3344
no_log: true
3445

3546
- name: Allow non-interactive administration through sudo

‎roles/admin/tasks/policy.yml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
---
2+
- name: Finalize verified administrative key after bootstrap
3+
ansible.builtin.include_tasks: account.yml
4+
when: megaproxy_bootstrap_phase | default('') == 'policy'
5+
26
- name: Read current SSH connection context
37
ansible.builtin.command: printenv SSH_CONNECTION
48
become: false

‎roles/https_proxy/templates/gost.yml.j2‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,4 +99,7 @@ chains:
9999
password: {{ route.chain.password | to_json }}
100100
dialer:
101101
type: http2
102+
tls:
103+
secure: true
104+
serverName: {{ route.chain.host | to_json }}
102105
{% endfor %}

‎roles/ssh_proxy/tasks/validate_accounts.yml‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,16 @@
11
---
2+
- name: Inspect administrative group membership
3+
ansible.builtin.command:
4+
argv: [id, -Gn, --, "{{ megaproxy_admin.user }}"]
5+
register: megaproxy_admin_groups
6+
changed_when: false
7+
check_mode: false
8+
9+
- name: Keep administrative access outside the restricted proxy group
10+
ansible.builtin.assert:
11+
that: "'megaproxy' not in megaproxy_admin_groups.stdout.split()"
12+
fail_msg: "The administrative account must not belong to the proxy group"
13+
214
- name: Require dedicated and safe SSH proxy logins
315
ansible.builtin.assert:
416
that:

‎src/megaproxy_server/bootstrap.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@
1212

1313
from .inventory import LOCAL_SSH_ARGS, ROOT, ansible_inventory, save
1414
from .models import Host, Inventory
15+
from .secrets import public_key
16+
17+
18+
def validate_admin_key(host: Host) -> None:
19+
private = Path(host.admin.private_key_file).expanduser()
20+
if not private.is_file():
21+
raise ValueError("Administrative private_key_file is unavailable")
22+
if host.admin.public_key and public_key(private).split()[:2] != host.admin.public_key.split()[:2]:
23+
raise ValueError("Administrative public_key must match private_key_file; use bootstrap_private_key_file for the old key during rotation")
1524

1625

1726
def check_admin(host: Host, *, report_error: bool = False) -> bool:
@@ -76,6 +85,7 @@ def bootstrap(path: Path, inventory: Inventory, limit: str | None = None) -> int
7685
host = inventory.hosts[name]
7786
if not host.admin.bootstrap_user:
7887
continue
88+
validate_admin_key(host)
7989
print(f'Bootstrapping administrative access: {name}', flush=True)
8090
if not check_admin(host):
8191
password = None

‎src/megaproxy_server/cli.py‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import questionary
1010
from pydantic import ValidationError
1111

12-
from .bootstrap import bootstrap
12+
from .bootstrap import bootstrap, validate_admin_key
1313
from .export import export_profiles, jump_candidates
1414
from .generate import generate
1515
from .hooks import run_hooks
@@ -87,7 +87,16 @@ def choose_inventory(explicit: str | None, force_create: bool = False) -> Path:
8787

8888

8989
def run_ansible(path: Path, playbook: str, *, check: bool = False, limit: str | None = None, tags: str | None = None) -> int:
90-
generated = write_ansible_inventory(path, load(path), prepare_config_api=playbook == "site.yml")
90+
inventory = load(path)
91+
generated = write_ansible_inventory(path, inventory, prepare_config_api=playbook == "site.yml")
92+
if playbook == "site.yml" and not check:
93+
from ansible.inventory.manager import InventoryManager
94+
from ansible.parsing.dataloader import DataLoader
95+
selected = InventoryManager(loader=DataLoader(), sources=[str(generated)])
96+
if limit:
97+
selected.subset(limit)
98+
for target in selected.get_hosts("all"):
99+
validate_admin_key(inventory.hosts[target.name])
91100
command = ["ansible-playbook", "-i", str(generated), str(ROOT / "playbooks" / playbook)]
92101
if check:
93102
command += ["--check", "--diff"]

‎tests/integration/https.py‎

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
"""Check HTTPS masking, knock, server chains and nested TLS Jump on loopback."""
2+
import argparse
3+
import base64
4+
import json
5+
import socket
6+
import ssl
7+
import subprocess
8+
import tempfile
9+
import time
10+
from copy import deepcopy
11+
from pathlib import Path
12+
from socketserver import ThreadingTCPServer
13+
from threading import Thread
14+
15+
from jinja2 import Environment, StrictUndefined
16+
from ruamel.yaml import YAML
17+
18+
from megaproxy_server.inventory import ROOT, ansible_inventory
19+
from megaproxy_server.models import Inventory
20+
from transports import PASSWORD, USERNAME, TcpEcho, port, receive
21+
22+
23+
class NestedTLS:
24+
"""A TLS stream inside an existing TLS CONNECT tunnel, using stdlib MemoryBIO."""
25+
def __init__(self, outer, context):
26+
self.outer = outer
27+
self.incoming, self.outgoing = ssl.MemoryBIO(), ssl.MemoryBIO()
28+
self.tls = context.wrap_bio(self.incoming, self.outgoing, server_hostname="localhost")
29+
while True:
30+
try:
31+
self.tls.do_handshake()
32+
self.flush()
33+
break
34+
except ssl.SSLWantReadError:
35+
self.pump()
36+
37+
def flush(self):
38+
if self.outgoing.pending:
39+
self.outer.sendall(self.outgoing.read())
40+
41+
def pump(self):
42+
self.flush()
43+
data = self.outer.recv(16384)
44+
if not data:
45+
raise EOFError("Nested TLS tunnel closed")
46+
self.incoming.write(data)
47+
48+
def sendall(self, data):
49+
while data:
50+
written = self.tls.write(data)
51+
data = data[written:]
52+
self.flush()
53+
54+
def recv(self, size):
55+
while True:
56+
try:
57+
return self.tls.read(size)
58+
except ssl.SSLWantReadError:
59+
self.pump()
60+
61+
62+
def connect(sock, host, target_port, *, password=PASSWORD):
63+
auth = base64.b64encode(f"{USERNAME}:{password}".encode()).decode() if password is not None else None
64+
headers = f"CONNECT {host}:{target_port} HTTP/1.1\r\nHost: {host}:{target_port}\r\n"
65+
if auth:
66+
headers += f"Proxy-Authorization: Basic {auth}\r\n"
67+
sock.sendall((headers + "\r\n").encode())
68+
response = b""
69+
while not response.endswith(b"\r\n\r\n"):
70+
response += receive(sock, 1)
71+
assert len(response) < 16384
72+
return int(response.split(b" ", 2)[1])
73+
74+
75+
def main():
76+
parser = argparse.ArgumentParser(description=__doc__)
77+
parser.add_argument("--gost", required=True)
78+
args = parser.parse_args()
79+
echo = ThreadingTCPServer(("127.0.0.1", 0), TcpEcho)
80+
Thread(target=echo.serve_forever, daemon=True).start()
81+
processes, logs = [], []
82+
yaml = YAML(typ="safe")
83+
try:
84+
with tempfile.TemporaryDirectory(prefix="megaproxy-https-") as directory:
85+
root = Path(directory)
86+
subprocess.run(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1",
87+
"-subj", "/CN=localhost", "-addext", "subjectAltName=DNS:localhost,DNS:chain.localhost",
88+
"-keyout", str(root / "privkey.pem"), "-out", str(root / "fullchain.pem")], check=True, capture_output=True)
89+
entry_port, exit_port, chain_port = port(), port(), port()
90+
admin = {"user": "deploy", "private_key_file": "/unused", "public_key": "ssh-ed25519 TEST"}
91+
inv = Inventory.model_validate({"users": {"https": [{"name": USERNAME, "password": PASSWORD}]},
92+
"settings": {"https_chains_enabled": True, "https_chain_domain": "localhost",
93+
"https_chain_pairs": [{"entry": "entry", "exit": "exit", "title": "CHAIN", "country_code": "US",
94+
"hostname": "chain.localhost", "probe_resistance": {"enabled": True, "knock": ["chain.localhost"]}}]},
95+
"hosts": {
96+
"entry": {"address": "127.0.0.1", "admin": admin, "services": {"https": {
97+
"title": "ENTRY", "endpoint": "localhost", "port": entry_port, "certificate": "domain",
98+
"acme_email": "test@example.com", "chain_entry": True,
99+
"probe_resistance": {"enabled": True, "knock": ["localhost"]}}}},
100+
"exit": {"address": "127.0.0.1", "admin": admin, "services": {"https": {
101+
"title": "EXIT", "endpoint": "localhost", "port": exit_port, "certificate": "domain",
102+
"acme_email": "test@example.com", "chain_exit": True, "chain_password": "machine-password-123"}}}}})
103+
env = Environment(undefined=StrictUndefined)
104+
env.filters["to_json"] = json.dumps
105+
template = env.from_string((ROOT / "roles/https_proxy/templates/gost.yml.j2").read_text())
106+
variables = ansible_inventory(inv)["all"]["hosts"]
107+
configs = {}
108+
for name, public in (("entry", entry_port), ("exit", exit_port)):
109+
config = yaml.load(template.render(**dict(variables[name], megaproxy_container_certificate_directory=str(root))))
110+
# Distinct loopback ports replace separate host network namespaces and HAProxy.
111+
for service in config["services"]:
112+
if service["name"] == "direct":
113+
service["addr"] = f"127.0.0.1:{public}"
114+
elif service["name"] == "via-exit":
115+
service["addr"] = f"127.0.0.1:{chain_port}"
116+
elif service["name"] == "decoy":
117+
decoy_port = port()
118+
service["addr"] = f"127.0.0.1:{decoy_port}"
119+
service["handler"]["metadata"]["dir"] = str(root)
120+
for candidate in config["services"]:
121+
metadata = candidate["handler"].get("metadata", {})
122+
if "probeResist" in metadata:
123+
metadata["probeResist"] = f"host:127.0.0.1:{decoy_port}"
124+
for chain in config.get("chains") or []:
125+
chain["hops"][0]["nodes"][0]["dialer"]["tls"]["caFile"] = str(root / "fullchain.pem")
126+
configs[name] = config
127+
(root / "index.html").write_text("A local decoy page\n")
128+
129+
def start(name, config, ready):
130+
path = root / f"{name}.yml"
131+
with path.open("w") as stream:
132+
yaml.dump(config, stream)
133+
log = (root / f"{name}.log").open("w+")
134+
logs.append(log)
135+
process = subprocess.Popen([args.gost, "-C", str(path)], stdout=log, stderr=log)
136+
processes.append(process)
137+
for _ in range(100):
138+
assert process.poll() is None, f"{name} stopped"
139+
try:
140+
with socket.create_connection(("127.0.0.1", ready), timeout=.1):
141+
return process
142+
except OSError:
143+
time.sleep(.05)
144+
raise AssertionError(f"{name} did not start")
145+
146+
exit_process = start("exit", configs["exit"], exit_port)
147+
entry_process = start("entry", configs["entry"], chain_port)
148+
context = ssl.create_default_context(cafile=str(root / "fullchain.pem"))
149+
150+
def check(proxy_port, *, hostname="localhost", target="127.0.0.1", password=PASSWORD, accepted=True):
151+
with context.wrap_socket(socket.create_connection(("127.0.0.1", proxy_port), timeout=5), server_hostname=hostname) as sock:
152+
status = connect(sock, target, echo.server_address[1], password=password)
153+
if accepted is True:
154+
assert status == 200, status
155+
sock.sendall(b"HTTPS transport check")
156+
assert receive(sock, 21) == b"HTTPS transport check"
157+
elif accepted is False:
158+
assert status != 200
159+
return status
160+
161+
assert check(entry_port, target="localhost", password=None, accepted=False) == 407
162+
assert check(chain_port, hostname="chain.localhost", target="chain.localhost", password=None, accepted=False) == 407
163+
assert check(entry_port, password=None, accepted=None) != 407
164+
check(entry_port)
165+
check(chain_port, hostname="chain.localhost")
166+
with context.wrap_socket(socket.create_connection(("127.0.0.1", entry_port), timeout=5), server_hostname="localhost") as outer:
167+
assert connect(outer, "localhost", exit_port) == 200
168+
inner = NestedTLS(outer, context)
169+
assert connect(inner, "127.0.0.1", echo.server_address[1]) == 200
170+
inner.sendall(b"HTTPS Jump check")
171+
assert receive(inner, 16) == b"HTTPS Jump check"
172+
for mutation in ("password", "certificate"):
173+
entry_process.terminate()
174+
entry_process.wait(timeout=5)
175+
bad = deepcopy(configs["entry"])
176+
node = bad["chains"][0]["hops"][0]["nodes"][0]
177+
if mutation == "password":
178+
node["connector"]["auth"]["password"] = "incorrect-machine-password"
179+
else:
180+
node["dialer"]["tls"]["serverName"] = "wrong.example"
181+
entry_process = start(f"entry-bad-{mutation}", bad, chain_port)
182+
check(chain_port, hostname="chain.localhost", accepted=False)
183+
check(entry_port) # Direct route still works, without bypassing a broken chain.
184+
entry_process.terminate()
185+
entry_process.wait(timeout=5)
186+
start("entry-restored", configs["entry"], chain_port)
187+
exit_process.terminate()
188+
exit_process.wait(timeout=5)
189+
check(chain_port, hostname="chain.localhost", accepted=False)
190+
check(entry_port)
191+
print("HTTPS passed: masking, knock and override, trusted server chain, nested TLS Jump, rejected machine auth/TLS and unavailable exit without bypass.")
192+
except Exception:
193+
for log in logs:
194+
log.seek(0)
195+
print(Path(log.name).name, log.read()[-4000:])
196+
raise
197+
finally:
198+
for process in processes:
199+
if process.poll() is None:
200+
process.terminate()
201+
process.wait(timeout=5)
202+
for log in logs:
203+
log.close()
204+
echo.shutdown()
205+
echo.server_close()
206+
207+
208+
if __name__ == "__main__":
209+
main()

0 commit comments

Comments
 (0)