diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml
index bda2826..12f008f 100644
--- a/.github/workflows/integration.yml
+++ b/.github/workflows/integration.yml
@@ -44,7 +44,6 @@ jobs:
two-host-jump:
name: Debian 12 two-host Jump
- if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 35
steps:
diff --git a/docs/en/README.md b/docs/en/README.md
index 671d008..870b11d 100644
--- a/docs/en/README.md
+++ b/docs/en/README.md
@@ -149,6 +149,11 @@ that wait for `407` before sending credentials. Keep it disabled unless tested w
clients. A route can override the entry setting; `knock` allows selected hostnames to receive the
normal challenge.
+Masked routes serve a static site using GOST's built-in loopback file server. A standard-library
+Python script generates a random heading and abstract SVG once. Repeated `apply` runs keep the
+site; remove `/opt/megaproxy/decoy/index.html` to regenerate it on the next `apply`.
+The page includes `noindex,nofollow`; `/robots.txt` contains `User-agent: *` and `Disallow: /`.
+
## HTTPS certificates and chains
Domain certificates are issued with Certbot standalone ACME. An entry certificate contains its
diff --git a/docs/ru/README.md b/docs/ru/README.md
index 6fe756e..4f8bc4c 100644
--- a/docs/ru/README.md
+++ b/docs/ru/README.md
@@ -155,6 +155,12 @@ Probe resistance подавляет обычный запрос аутентиф
нужных клиентов. Маршрут может переопределить настройку entry; `knock` разрешает обычный запрос
аутентификации для выбранных hostname-ов.
+При включённом маскировании GOST отдаёт статический сайт через встроенный файловый сервер
+на loopback. Python без дополнительных библиотек один раз создаёт случайный заголовок и
+абстрактную SVG-картинку. Повторный `apply` сохраняет сайт; удаление
+`/opt/megaproxy/decoy/index.html` приводит к новой генерации при следующем `apply`.
+Страница содержит `noindex,nofollow`, а `/robots.txt` — `User-agent: *` и `Disallow: /`.
+
## HTTPS-сертификаты и chains
Доменные сертификаты выпускаются Certbot в standalone-режиме. Сертификат entry-сервера содержит его
diff --git a/playbooks/verify.yml b/playbooks/verify.yml
index ce1b656..20ac693 100644
--- a/playbooks/verify.yml
+++ b/playbooks/verify.yml
@@ -87,17 +87,16 @@
['curl', '--silent', '--show-error', '--fail', '--max-time', '15',
'--write-out', '\n%{http_code}']
+ (['--insecure'] if megaproxy_services.https.certificate == 'self-signed' else [])
- + ['https://' ~ item.hostname ~ ':' ~ (item.port | string) ~ '/']
+ + ['https://' ~ item.hostname ~ ':' ~ (megaproxy_services.https.port | string) ~ '/']
}}
register: decoy_response
changed_when: false
delegate_to: localhost
become: false
- loop: "{{ megaproxy_https_public_routes | default([]) }}"
+ loop: "{{ megaproxy_https_public_routes | default([]) | selectattr('probe_resistance_enabled') | list }}"
when:
- megaproxy_services.https is defined
- megaproxy_services.https.enabled | bool
- - megaproxy_services.https.probe_resistance.enabled | bool
- name: Ensure decoy does not disclose proxy software
ansible.builtin.assert:
@@ -107,9 +106,33 @@
- "'proxy-authenticate' not in (item.stdout | lower)"
- "'gost' not in (item.stdout | lower)"
- "'megaproxy' not in (item.stdout | lower)"
+ - "'content=\"noindex,nofollow\"' in item.stdout"
loop: "{{ decoy_response.results | default([]) }}"
when: megaproxy_services.https is defined and megaproxy_services.https.enabled | bool
+ - name: Read robots.txt from masked routes
+ ansible.builtin.command:
+ argv: >-
+ {{
+ ['curl', '--silent', '--show-error', '--fail', '--max-time', '15']
+ + (['--insecure'] if megaproxy_services.https.certificate == 'self-signed' else [])
+ + ['https://' ~ item.hostname ~ ':' ~ (megaproxy_services.https.port | string) ~ '/robots.txt']
+ }}
+ loop: "{{ megaproxy_https_public_routes | default([]) | selectattr('probe_resistance_enabled') | list }}"
+ register: robots_responses
+ changed_when: false
+ delegate_to: localhost
+ become: false
+ when: megaproxy_services.https is defined and megaproxy_services.https.enabled | bool
+
+ - name: Require robots.txt to disallow crawling
+ ansible.builtin.assert:
+ that:
+ - >-
+ item.stdout == 'User-agent: *\nDisallow: /'
+ loop: "{{ robots_responses.results | default([]) }}"
+ when: item is not skipped
+
- name: Probe CONNECT without credentials
ansible.builtin.command:
argv:
diff --git a/roles/admin/handlers/main.yml b/roles/admin/handlers/main.yml
index 610c646..bd42c7e 100644
--- a/roles/admin/handlers/main.yml
+++ b/roles/admin/handlers/main.yml
@@ -1,7 +1,6 @@
---
- name: Validate and reload administrative ssh policy
ansible.builtin.command: /usr/sbin/sshd -t
- changed_when: false
notify: Reload ssh after administrative policy
- name: Reload ssh after administrative policy
diff --git a/roles/https_proxy/files/generate-decoy.py b/roles/https_proxy/files/generate-decoy.py
new file mode 100644
index 0000000..20e8bc5
--- /dev/null
+++ b/roles/https_proxy/files/generate-decoy.py
@@ -0,0 +1,47 @@
+"""Generate a small static site using only the Python standard library."""
+
+import random
+import sys
+from pathlib import Path
+
+
+def generate(directory):
+ directory = Path(directory)
+ directory.mkdir(parents=True, exist_ok=True)
+ title = f"{random.choice(('Quiet', 'Soft', 'Distant', 'Golden', 'Hidden', 'Open'))} {random.choice(('Horizons', 'Shapes', 'Reflections', 'Gardens', 'Waves', 'Spaces'))}"
+ hue = random.randrange(360)
+ shapes = []
+ for _ in range(18):
+ x, y, radius = random.randrange(800), random.randrange(480), random.randrange(30, 180)
+ color = f"hsl({(hue + random.randrange(90)) % 360},55%,65%)"
+ shapes.append(f'')
+ image = (
+ '\n'
+ )
+ (directory / 'art.svg').write_text(image, encoding='utf-8')
+ (directory / 'robots.txt').write_text('User-agent: *\nDisallow: /\n', encoding='utf-8')
+ html = f'''
+
+
+
+
+
+ {title}
+
+
+
+
+ {title}
+ A small study of colour, form, and light.
+
+
+
+
+'''
+ (directory / 'index.html').write_text(html, encoding='utf-8')
+
+
+if __name__ == '__main__':
+ generate(sys.argv[1])
diff --git a/roles/https_proxy/tasks/main.yml b/roles/https_proxy/tasks/main.yml
index 5ac3834..52fe9b7 100644
--- a/roles/https_proxy/tasks/main.yml
+++ b/roles/https_proxy/tasks/main.yml
@@ -96,14 +96,20 @@
- megaproxy_services.https.certificate == "ip-acme"
- not megaproxy_certificate.stat.exists
-- name: Install active-probe decoy page
- ansible.builtin.template:
- src: decoy.html.j2
- dest: /opt/megaproxy/decoy.html
+- name: Create decoy directory
+ ansible.builtin.file:
+ path: /opt/megaproxy/decoy
+ state: directory
owner: root
group: root
- mode: "0644"
- notify: Restart MegaProxy GOST
+ mode: "0755"
+
+- name: Generate a random static decoy site
+ ansible.builtin.script:
+ cmd: generate-decoy.py /opt/megaproxy/decoy
+ executable: /usr/bin/python3
+ creates: /opt/megaproxy/decoy/index.html
+ when: megaproxy_services.https.routes | selectattr('probe_resistance.enabled') | list | length > 0
- name: Install secret GOST configuration
ansible.builtin.template:
diff --git a/roles/https_proxy/templates/decoy.html.j2 b/roles/https_proxy/templates/decoy.html.j2
deleted file mode 100644
index 6a2aa1d..0000000
--- a/roles/https_proxy/templates/decoy.html.j2
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
-
- {{ megaproxy_services.https.probe_resistance.site_title }}
-
-
-
-
- {{ megaproxy_services.https.probe_resistance.site_title }}
- Notes, small projects, and occasional updates.
- There is nothing public here at the moment. Please check back later.
-
-
-
-
diff --git a/roles/https_proxy/templates/gost.yml.j2 b/roles/https_proxy/templates/gost.yml.j2
index ff66333..eb4e858 100644
--- a/roles/https_proxy/templates/gost.yml.j2
+++ b/roles/https_proxy/templates/gost.yml.j2
@@ -10,7 +10,7 @@ services:
{% endif %}
metadata:
{% if route.probe_resistance.enabled %}
- probeResist: "file:/opt/megaproxy/decoy.html"
+ probeResist: "host:127.0.0.1:18080"
{% if route.probe_resistance.knock %}
knock: {{ route.probe_resistance.knock | join(',') | to_json }}
{% endif %}
@@ -28,6 +28,16 @@ services:
maxVersion: VersionTLS13
alpn: [h2, http/1.1]
{% endfor %}
+{% if megaproxy_services.https.routes | selectattr('probe_resistance.enabled') | list %}
+ - name: decoy
+ addr: "127.0.0.1:18080"
+ handler:
+ type: file
+ metadata:
+ dir: /opt/megaproxy/decoy
+ listener:
+ type: tcp
+{% endif %}
authers:
- name: megaproxy-users
diff --git a/roles/https_proxy/templates/megaproxy-gost.service.j2 b/roles/https_proxy/templates/megaproxy-gost.service.j2
index f001e02..aeed41f 100644
--- a/roles/https_proxy/templates/megaproxy-gost.service.j2
+++ b/roles/https_proxy/templates/megaproxy-gost.service.j2
@@ -7,7 +7,7 @@ Wants=network-online.target
[Service]
Type=simple
ExecStartPre=-/usr/bin/docker rm -f megaproxy-gost
-ExecStart=/usr/bin/docker run --name megaproxy-gost --network host --read-only --cap-drop ALL --security-opt no-new-privileges --memory 256m --pids-limit 128 -v /opt/megaproxy/gost.yml:/etc/gost/gost.yml:ro -v /opt/megaproxy/decoy.html:/opt/megaproxy/decoy.html:ro -v {{ megaproxy_certificate_volume }} gogost/gost:{{ megaproxy_services.https.gost_version }} -C /etc/gost/gost.yml
+ExecStart=/usr/bin/docker run --name megaproxy-gost --network host --read-only --cap-drop ALL --security-opt no-new-privileges --memory 256m --pids-limit 128 -v /opt/megaproxy/gost.yml:/etc/gost/gost.yml:ro -v /opt/megaproxy/decoy:/opt/megaproxy/decoy:ro -v {{ megaproxy_certificate_volume }} gogost/gost:{{ megaproxy_services.https.gost_version }} -C /etc/gost/gost.yml
ExecStop=/usr/bin/docker stop -t 10 megaproxy-gost
Restart=on-failure
RestartSec=5
diff --git a/roles/ssh_proxy/handlers/main.yml b/roles/ssh_proxy/handlers/main.yml
index fab8bd7..e6434fe 100644
--- a/roles/ssh_proxy/handlers/main.yml
+++ b/roles/ssh_proxy/handlers/main.yml
@@ -1,7 +1,6 @@
---
- name: Validate and reload ssh
ansible.builtin.command: sshd -t
- changed_when: false
notify: Reload ssh
- name: Reload ssh
diff --git a/src/megaproxy_server/inventory.py b/src/megaproxy_server/inventory.py
index 7513e3a..e6c2fcb 100644
--- a/src/megaproxy_server/inventory.py
+++ b/src/megaproxy_server/inventory.py
@@ -202,7 +202,7 @@ def ansible_inventory(inventory: Inventory) -> dict[str, Any]:
routes = https_routes(inventory, name)
services["https"]["routes"] = routes
variables_public_routes = [
- {"name": route["name"], "hostname": route["hostname"], "port": https.port, "backend_port": route["port"]}
+ {"name": route["name"], "hostname": route["hostname"], "port": https.port, "backend_port": route["port"], "probe_resistance_enabled": route["probe_resistance"]["enabled"]}
for route in routes
]
services["https"]["machine_auth"] = (
diff --git a/tests/integration/lxd.sh b/tests/integration/lxd.sh
index 6f058e3..0cbbe15 100755
--- a/tests/integration/lxd.sh
+++ b/tests/integration/lxd.sh
@@ -69,6 +69,16 @@ done
export ANSIBLE_HOST_KEY_CHECKING=True
export ANSIBLE_SSH_ARGS="-o UserKnownHostsFile=$work_dir/known_hosts"
+# ProxyJump's child ssh reads -F too, but does not inherit command-line -i/-o options.
+cat > "$work_dir/ssh_config" </dev/null
-sudo lxc exec megaproxy-ci-one -- systemctl reload ssh
if ! ssh -vvv -fNT -M -S "$control_socket" -i "$key_file" -o IdentitiesOnly=yes -o ExitOnForwardFailure=yes -o UserKnownHostsFile="$work_dir/known_hosts" -L 18443:example.com:443 "mp-ci@$ip_one"; then
sudo lxc exec megaproxy-ci-one -- getent passwd mp-ci || true
sudo lxc exec megaproxy-ci-one -- passwd -S mp-ci || true
@@ -114,7 +122,11 @@ ssh -S "$control_socket" -O exit "mp-ci@$ip_one"
if [[ "$host_count" -ge 2 ]]; then
jump_socket="$work_dir/jump-control"
- ssh -fNT -M -S "$jump_socket" -i "$key_file" -o IdentitiesOnly=yes -o ExitOnForwardFailure=yes -o UserKnownHostsFile="$work_dir/known_hosts" -o "ProxyJump=mp-ci@$ip_one" -L 19443:example.com:443 "mp-ci@$ip_two"
+ if ! ssh -vvv -F "$work_dir/ssh_config" -fNT -M -S "$jump_socket" -o ExitOnForwardFailure=yes -o "ProxyJump=mp-ci@$ip_one" -L 19443:example.com:443 "mp-ci@$ip_two"; then
+ sudo lxc exec megaproxy-ci-two -- journalctl -u ssh --since=-2min --no-pager
+ sudo lxc exec megaproxy-ci-two -- namei -l /etc/ssh/megaproxy_authorized_keys/mp-ci
+ exit 1
+ fi
echo | openssl s_client -connect 127.0.0.1:19443 -servername example.com -verify_return_error >/dev/null
ssh -S "$jump_socket" -O exit "mp-ci@$ip_two"
fi
diff --git a/tests/test_decoy.py b/tests/test_decoy.py
new file mode 100644
index 0000000..558389d
--- /dev/null
+++ b/tests/test_decoy.py
@@ -0,0 +1,15 @@
+import runpy
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+
+def test_generated_decoy(tmp_path):
+ script = Path(__file__).resolve().parents[1] / 'roles/https_proxy/files/generate-decoy.py'
+ generate = runpy.run_path(str(script))['generate']
+ generate(tmp_path)
+ html = (tmp_path / 'index.html').read_text()
+ assert 'content="noindex,nofollow"' in html
+ assert '' in html and 'src="/art.svg"' in html
+ assert (tmp_path / 'robots.txt').read_text() == 'User-agent: *\nDisallow: /\n'
+ image = ET.parse(tmp_path / 'art.svg').getroot()
+ assert len(image.findall('{http://www.w3.org/2000/svg}circle')) == 18
diff --git a/tests/test_inventory.py b/tests/test_inventory.py
index 22617a1..0f2ce00 100644
--- a/tests/test_inventory.py
+++ b/tests/test_inventory.py
@@ -167,6 +167,9 @@ def test_route_probe_override_is_independent() -> None:
route = https_routes(inventory, "entry")[0]
assert route["probe_resistance"]["enabled"] is True
assert route["probe_resistance"]["knock"] == ["private.example"]
+ public_route = ansible_inventory(inventory)["all"]["hosts"]["entry"]["megaproxy_https_public_routes"][0]
+ assert public_route["probe_resistance_enabled"] is True
+ assert "chain" not in public_route
def test_https_chain_title_falls_back_to_entry_title() -> None:
diff --git a/tests/test_ssh_handlers.py b/tests/test_ssh_handlers.py
new file mode 100644
index 0000000..1d19773
--- /dev/null
+++ b/tests/test_ssh_handlers.py
@@ -0,0 +1,33 @@
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+from ruamel.yaml import YAML
+
+
+def test_ssh_validation_notifies_reload(tmp_path):
+ root = Path(__file__).resolve().parents[1]
+ yaml = YAML(typ="safe")
+ tasks, handlers, markers = [], [], []
+ for role in ("admin", "ssh_proxy"):
+ validation, reload = yaml.load((root / f"roles/{role}/handlers/main.yml").read_text())[:2]
+ validation["ansible.builtin.command"] = "true"
+ marker = tmp_path / role
+ markers.append(marker)
+ reload.pop("ansible.builtin.systemd_service")
+ reload["ansible.builtin.copy"] = {"dest": str(marker), "content": "reloaded"}
+ tasks.append({"ansible.builtin.debug": {"msg": role}, "changed_when": True,
+ "notify": validation["name"]})
+ handlers.extend((validation, reload))
+ playbook = tmp_path / "handlers.yml"
+ with playbook.open("w") as stream:
+ yaml.dump([{"hosts": "all", "gather_facts": False, "tasks": tasks,
+ "handlers": handlers}], stream)
+ environment = os.environ.copy()
+ environment["ANSIBLE_REMOTE_TEMP"] = str(tmp_path / "remote-tmp")
+ environment["PATH"] = str(Path(sys.executable).parent) + os.pathsep + environment["PATH"]
+ result = subprocess.run(["ansible-playbook", "-i", "localhost,", "-c", "local", str(playbook)],
+ cwd=root, env=environment, capture_output=True, text=True)
+ assert result.returncode == 0, result.stdout + result.stderr
+ assert all(marker.read_text() == "reloaded" for marker in markers)