Skip to content

Commit ebf4b91

Browse files
authored
Serve randomized decoy sites with robots.txt (#2)
* Serve randomized decoy sites with robots.txt for masked proxies * Run Debian host provisioning on PRs and pushes * Pass CI SSH identity and known hosts to ProxyJump * Diagnose two-host SSH jump authentication failures * Reload SSH after successful configuration validation
1 parent 3a89e26 commit ebf4b91

16 files changed

Lines changed: 175 additions & 36 deletions

File tree

‎.github/workflows/integration.yml‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@ jobs:
4444

4545
two-host-jump:
4646
name: Debian 12 two-host Jump
47-
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
4847
runs-on: ubuntu-latest
4948
timeout-minutes: 35
5049
steps:

‎docs/en/README.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,11 @@ that wait for `407` before sending credentials. Keep it disabled unless tested w
149149
clients. A route can override the entry setting; `knock` allows selected hostnames to receive the
150150
normal challenge.
151151

152+
Masked routes serve a static site using GOST's built-in loopback file server. A standard-library
153+
Python script generates a random heading and abstract SVG once. Repeated `apply` runs keep the
154+
site; remove `/opt/megaproxy/decoy/index.html` to regenerate it on the next `apply`.
155+
The page includes `noindex,nofollow`; `/robots.txt` contains `User-agent: *` and `Disallow: /`.
156+
152157
## HTTPS certificates and chains
153158

154159
Domain certificates are issued with Certbot standalone ACME. An entry certificate contains its

‎docs/ru/README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,12 @@ Probe resistance подавляет обычный запрос аутентиф
155155
нужных клиентов. Маршрут может переопределить настройку entry; `knock` разрешает обычный запрос
156156
аутентификации для выбранных hostname-ов.
157157

158+
При включённом маскировании GOST отдаёт статический сайт через встроенный файловый сервер
159+
на loopback. Python без дополнительных библиотек один раз создаёт случайный заголовок и
160+
абстрактную SVG-картинку. Повторный `apply` сохраняет сайт; удаление
161+
`/opt/megaproxy/decoy/index.html` приводит к новой генерации при следующем `apply`.
162+
Страница содержит `noindex,nofollow`, а `/robots.txt` — `User-agent: *` и `Disallow: /`.
163+
158164
## HTTPS-сертификаты и chains
159165

160166
Доменные сертификаты выпускаются Certbot в standalone-режиме. Сертификат entry-сервера содержит его

‎playbooks/verify.yml‎

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,17 +87,16 @@
8787
['curl', '--silent', '--show-error', '--fail', '--max-time', '15',
8888
'--write-out', '\n%{http_code}']
8989
+ (['--insecure'] if megaproxy_services.https.certificate == 'self-signed' else [])
90-
+ ['https://' ~ item.hostname ~ ':' ~ (item.port | string) ~ '/']
90+
+ ['https://' ~ item.hostname ~ ':' ~ (megaproxy_services.https.port | string) ~ '/']
9191
}}
9292
register: decoy_response
9393
changed_when: false
9494
delegate_to: localhost
9595
become: false
96-
loop: "{{ megaproxy_https_public_routes | default([]) }}"
96+
loop: "{{ megaproxy_https_public_routes | default([]) | selectattr('probe_resistance_enabled') | list }}"
9797
when:
9898
- megaproxy_services.https is defined
9999
- megaproxy_services.https.enabled | bool
100-
- megaproxy_services.https.probe_resistance.enabled | bool
101100

102101
- name: Ensure decoy does not disclose proxy software
103102
ansible.builtin.assert:
@@ -107,9 +106,33 @@
107106
- "'proxy-authenticate' not in (item.stdout | lower)"
108107
- "'gost' not in (item.stdout | lower)"
109108
- "'megaproxy' not in (item.stdout | lower)"
109+
- "'content=\"noindex,nofollow\"' in item.stdout"
110110
loop: "{{ decoy_response.results | default([]) }}"
111111
when: megaproxy_services.https is defined and megaproxy_services.https.enabled | bool
112112

113+
- name: Read robots.txt from masked routes
114+
ansible.builtin.command:
115+
argv: >-
116+
{{
117+
['curl', '--silent', '--show-error', '--fail', '--max-time', '15']
118+
+ (['--insecure'] if megaproxy_services.https.certificate == 'self-signed' else [])
119+
+ ['https://' ~ item.hostname ~ ':' ~ (megaproxy_services.https.port | string) ~ '/robots.txt']
120+
}}
121+
loop: "{{ megaproxy_https_public_routes | default([]) | selectattr('probe_resistance_enabled') | list }}"
122+
register: robots_responses
123+
changed_when: false
124+
delegate_to: localhost
125+
become: false
126+
when: megaproxy_services.https is defined and megaproxy_services.https.enabled | bool
127+
128+
- name: Require robots.txt to disallow crawling
129+
ansible.builtin.assert:
130+
that:
131+
- >-
132+
item.stdout == 'User-agent: *\nDisallow: /'
133+
loop: "{{ robots_responses.results | default([]) }}"
134+
when: item is not skipped
135+
113136
- name: Probe CONNECT without credentials
114137
ansible.builtin.command:
115138
argv:

‎roles/admin/handlers/main.yml‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
---
22
- name: Validate and reload administrative ssh policy
33
ansible.builtin.command: /usr/sbin/sshd -t
4-
changed_when: false
54
notify: Reload ssh after administrative policy
65

76
- name: Reload ssh after administrative policy
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""Generate a small static site using only the Python standard library."""
2+
3+
import random
4+
import sys
5+
from pathlib import Path
6+
7+
8+
def generate(directory):
9+
directory = Path(directory)
10+
directory.mkdir(parents=True, exist_ok=True)
11+
title = f"{random.choice(('Quiet', 'Soft', 'Distant', 'Golden', 'Hidden', 'Open'))} {random.choice(('Horizons', 'Shapes', 'Reflections', 'Gardens', 'Waves', 'Spaces'))}"
12+
hue = random.randrange(360)
13+
shapes = []
14+
for _ in range(18):
15+
x, y, radius = random.randrange(800), random.randrange(480), random.randrange(30, 180)
16+
color = f"hsl({(hue + random.randrange(90)) % 360},55%,65%)"
17+
shapes.append(f'<circle cx="{x}" cy="{y}" r="{radius}" fill="{color}" opacity="0.45"/>')
18+
image = (
19+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 480">'
20+
f'<rect width="800" height="480" fill="hsl({hue},25%,94%)"/>'
21+
+ ''.join(shapes) + '</svg>\n'
22+
)
23+
(directory / 'art.svg').write_text(image, encoding='utf-8')
24+
(directory / 'robots.txt').write_text('User-agent: *\nDisallow: /\n', encoding='utf-8')
25+
html = f'''<!doctype html>
26+
<html lang="en">
27+
<head>
28+
<meta charset="utf-8">
29+
<meta name="viewport" content="width=device-width,initial-scale=1">
30+
<meta name="robots" content="noindex,nofollow">
31+
<title>{title}</title>
32+
<style>body{{max-width:50rem;margin:8vh auto;padding:0 1.5rem;font:17px/1.6 system-ui,sans-serif;color:#28323c}}img{{width:100%;height:auto;border-radius:12px}}</style>
33+
</head>
34+
<body>
35+
<main>
36+
<h1>{title}</h1>
37+
<p>A small study of colour, form, and light.</p>
38+
<img src="/art.svg" alt="An abstract composition of overlapping colourful circles" width="800" height="480">
39+
</main>
40+
</body>
41+
</html>
42+
'''
43+
(directory / 'index.html').write_text(html, encoding='utf-8')
44+
45+
46+
if __name__ == '__main__':
47+
generate(sys.argv[1])

‎roles/https_proxy/tasks/main.yml‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -96,14 +96,20 @@
9696
- megaproxy_services.https.certificate == "ip-acme"
9797
- not megaproxy_certificate.stat.exists
9898

99-
- name: Install active-probe decoy page
100-
ansible.builtin.template:
101-
src: decoy.html.j2
102-
dest: /opt/megaproxy/decoy.html
99+
- name: Create decoy directory
100+
ansible.builtin.file:
101+
path: /opt/megaproxy/decoy
102+
state: directory
103103
owner: root
104104
group: root
105-
mode: "0644"
106-
notify: Restart MegaProxy GOST
105+
mode: "0755"
106+
107+
- name: Generate a random static decoy site
108+
ansible.builtin.script:
109+
cmd: generate-decoy.py /opt/megaproxy/decoy
110+
executable: /usr/bin/python3
111+
creates: /opt/megaproxy/decoy/index.html
112+
when: megaproxy_services.https.routes | selectattr('probe_resistance.enabled') | list | length > 0
107113

108114
- name: Install secret GOST configuration
109115
ansible.builtin.template:

‎roles/https_proxy/templates/decoy.html.j2‎

Lines changed: 0 additions & 18 deletions
This file was deleted.

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ services:
1010
{% endif %}
1111
metadata:
1212
{% if route.probe_resistance.enabled %}
13-
probeResist: "file:/opt/megaproxy/decoy.html"
13+
probeResist: "host:127.0.0.1:18080"
1414
{% if route.probe_resistance.knock %}
1515
knock: {{ route.probe_resistance.knock | join(',') | to_json }}
1616
{% endif %}
@@ -28,6 +28,16 @@ services:
2828
maxVersion: VersionTLS13
2929
alpn: [h2, http/1.1]
3030
{% endfor %}
31+
{% if megaproxy_services.https.routes | selectattr('probe_resistance.enabled') | list %}
32+
- name: decoy
33+
addr: "127.0.0.1:18080"
34+
handler:
35+
type: file
36+
metadata:
37+
dir: /opt/megaproxy/decoy
38+
listener:
39+
type: tcp
40+
{% endif %}
3141

3242
authers:
3343
- name: megaproxy-users

‎roles/https_proxy/templates/megaproxy-gost.service.j2‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Wants=network-online.target
77
[Service]
88
Type=simple
99
ExecStartPre=-/usr/bin/docker rm -f megaproxy-gost
10-
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
10+
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
1111
ExecStop=/usr/bin/docker stop -t 10 megaproxy-gost
1212
Restart=on-failure
1313
RestartSec=5

0 commit comments

Comments
 (0)