Adopt nanoDNS-style INI config for the guide gateway

Replace the gateway's env-var DNS policy with docker/nanodns.ini, modelled on
https://github.com/drakmor/nanoDNS#config: [general] (redirect_url,
allowed_clients), [upstream] (ordered resolver list + timeout_ms), [overrides]
(mask = IPv4 spoof) and [exceptions] (masks forwarded upstream). Masks use
shell-style wildcards and match case-insensitively; resolution order is
exceptions -> overrides -> NXDOMAIN.

The unmatched-name behaviour is deliberately inverted from upstream nanoDNS:
names matching no rule return NXDOMAIN (default-deny) instead of being forwarded,
so PSN, updates and telemetry stay blocked. The source-client allowlist now lives
in [general] allowed_clients. The INI is baked into the image; CONFIG_FILE can
point at a mounted file for per-host overrides.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
2026-10-11 13:38:29 +01:00
co-authored by Claude Opus 4.8
parent 9f28b8e2a1
commit 50dd2d8266
7 changed files with 237 additions and 72 deletions
+2
View File
@@ -22,6 +22,8 @@ before including them in an image.
- `compose.yaml`: hardened local deployment.
- `docker/server.py`: static HTTP server and security headers.
- `docker/guide_gateway.py`: restricted DNS plus HTTP/HTTPS User Guide redirect.
- `docker/nanodns.ini`: nanoDNS-style DNS policy (overrides, exceptions, upstreams,
allowed client VLANs) read by `guide_gateway.py`; default-deny for unmatched names.
- `payloads.sha256`: approved payload digests for the pinned submodule commit.
- `docs/PS5_SETUP.md`: user and operator guide.
- `docs/HANDOFF.md`: current infrastructure state and durable session handoff.
+1
View File
@@ -25,6 +25,7 @@ COPY app/offsets /srv/public/offsets
COPY app/payloads /srv/public/payloads
COPY docker/server.py /srv/server.py
COPY docker/guide_gateway.py /srv/guide_gateway.py
COPY docker/nanodns.ini /srv/nanodns.ini
COPY --from=certificates /certificates /srv/certificates
RUN cd /srv/public && sha256sum -c /tmp/payloads.sha256 \
+3 -6
View File
@@ -17,12 +17,9 @@ services:
ps5-guide-gateway:
image: relapse-exploit:local
command: ["python", "/srv/guide_gateway.py"]
environment:
GATEWAY_IP: "${GATEWAY_IP:-192.168.20.30}"
REDIRECT_URL: "${GUIDE_REDIRECT_URL:-http://192.168.20.30:8000/}"
FORWARDED_DOMAINS: "${FORWARDED_DOMAINS:-homebrew.page,itsplk.github.io,store.spectrumlibrary.online,api.github.com,github.com,raw.githubusercontent.com,release-assets.githubusercontent.com,prosperopatches.com,cdn.prosperopatches.com,image.api.playstation.com,gmedia.playstation.com}"
UPSTREAM_DNS: "${UPSTREAM_DNS:-1.1.1.1}"
ALLOWED_CLIENTS: "${ALLOWED_CLIENTS:-192.168.40.0/28,192.168.20.0/24,192.168.10.0/27}"
# DNS policy (overrides, exceptions, upstreams, allowed client VLANs) lives in
# docker/nanodns.ini, baked into the image. Set CONFIG_FILE to point at a
# different mounted file for per-host customisation.
ports:
- "${GATEWAY_BIND_IP:-192.168.20.30}:53:8053/udp"
- "${GATEWAY_BIND_IP:-192.168.20.30}:53:8053/tcp"
+123 -34
View File
@@ -1,3 +1,5 @@
import configparser
import fnmatch
import http.server
import ipaddress
import os
@@ -9,38 +11,96 @@ import struct
import threading
CONFIG_FILE = os.environ.get("CONFIG_FILE", "/srv/nanodns.ini")
DNS_PORT = int(os.environ.get("DNS_PORT", "8053"))
HTTP_PORT = int(os.environ.get("HTTP_PORT", "8081"))
HTTPS_PORT = int(os.environ.get("HTTPS_PORT", "8443"))
GATEWAY_IP = ipaddress.IPv4Address(os.environ.get("GATEWAY_IP", "192.168.20.30"))
REDIRECT_URL = os.environ.get("REDIRECT_URL", f"http://{GATEWAY_IP}:8000/")
TARGET_DOMAIN = "manuals.playstation.net"
# Exact names forwarded to the upstream resolver (homebrew store: catalog + downloads).
FORWARDED_DOMAINS = frozenset(
name.strip().lower()
for name in os.environ.get(
"FORWARDED_DOMAINS",
"homebrew.page,itsplk.github.io,store.spectrumlibrary.online,api.github.com,github.com,raw.githubusercontent.com,release-assets.githubusercontent.com,prosperopatches.com,cdn.prosperopatches.com,image.api.playstation.com,gmedia.playstation.com",
).split(",")
if name.strip()
)
UPSTREAM_DNS = str(ipaddress.ip_address(os.environ.get("UPSTREAM_DNS", "1.1.1.1")))
UPSTREAM_TIMEOUT = 3.0
# Source networks allowed to query the DNS service. Defaults to the console VLAN
# plus the server and trusted-management VLANs. Accepts a comma-separated mix of
# individual addresses and CIDR ranges; empty disables the filter and serves
# every client.
ALLOWED_CLIENTS = tuple(
ipaddress.ip_network(entry.strip(), strict=False)
for entry in os.environ.get(
"ALLOWED_CLIENTS",
"192.168.40.0/28,192.168.20.0/24,192.168.10.0/27",
).split(",")
if entry.strip()
)
CERTIFICATE = "/srv/certificates/manuals.crt"
PRIVATE_KEY = "/srv/certificates/manuals.key"
# Built-in defaults, used when the config file (or a given key) is absent. They
# mirror the reviewed production policy: the User Guide domain is spoofed to the
# local gateway, the reviewed store/update hosts are forwarded upstream, and
# every other name returns NXDOMAIN (default-deny).
DEFAULT_REDIRECT_URL = "http://192.168.20.30:8000/"
DEFAULT_ALLOWED_CLIENTS = "192.168.40.0/28, 192.168.20.0/24, 192.168.10.0/27"
DEFAULT_UPSTREAMS = "1.1.1.1"
DEFAULT_TIMEOUT_MS = "3000"
DEFAULT_OVERRIDES = (("manuals.playstation.net", "192.168.20.30"),)
DEFAULT_EXCEPTIONS = (
"homebrew.page",
"itsplk.github.io",
"store.spectrumlibrary.online",
"api.github.com",
"github.com",
"raw.githubusercontent.com",
"release-assets.githubusercontent.com",
"prosperopatches.com",
"cdn.prosperopatches.com",
"image.api.playstation.com",
"gmedia.playstation.com",
)
def _split(value):
return [item for item in value.replace(",", " ").split() if item]
def load_config(path):
parser = configparser.ConfigParser(
allow_no_value=True,
inline_comment_prefixes=("#", ";"),
)
# Keep mask/address case verbatim; query names are lowercased before lookup.
parser.optionxform = str
parser.read(path)
general = parser["general"] if parser.has_section("general") else {}
redirect_url = general.get("redirect_url") or DEFAULT_REDIRECT_URL
allowed_raw = general.get("allowed_clients") or DEFAULT_ALLOWED_CLIENTS
allowed_clients = tuple(
ipaddress.ip_network(entry, strict=False) for entry in _split(allowed_raw)
)
upstream = parser["upstream"] if parser.has_section("upstream") else {}
servers_raw = (
upstream.get("server") or upstream.get("servers") or DEFAULT_UPSTREAMS
)
upstreams = tuple(
str(ipaddress.ip_address(server)) for server in _split(servers_raw)
)
timeout = max(0.1, int(upstream.get("timeout_ms") or DEFAULT_TIMEOUT_MS) / 1000.0)
override_items = (
parser.items("overrides")
if parser.has_section("overrides")
else DEFAULT_OVERRIDES
)
overrides = tuple(
(mask.lower(), ipaddress.IPv4Address(address))
for mask, address in override_items
if address is not None
)
exception_items = (
parser.items("exceptions")
if parser.has_section("exceptions")
else ((mask, None) for mask in DEFAULT_EXCEPTIONS)
)
exceptions = tuple(mask.lower() for mask, _ in exception_items)
return redirect_url, allowed_clients, upstreams, timeout, overrides, exceptions
(
REDIRECT_URL,
ALLOWED_CLIENTS,
UPSTREAMS,
UPSTREAM_TIMEOUT,
OVERRIDES,
EXCEPTIONS,
) = load_config(CONFIG_FILE)
def client_allowed(address):
if not ALLOWED_CLIENTS:
@@ -52,6 +112,17 @@ def client_allowed(address):
return any(client in network for network in ALLOWED_CLIENTS)
def match_any(name, masks):
return any(fnmatch.fnmatchcase(name, mask) for mask in masks)
def override_for(name):
for mask, address in OVERRIDES:
if fnmatch.fnmatchcase(name, mask):
return address
return None
def parse_question(packet):
if len(packet) < 17:
raise ValueError("short DNS query")
@@ -104,12 +175,12 @@ def receive_exactly(connection, wanted):
return b"".join(chunks)
def forward_query(packet, use_tcp=False):
family = socket.AF_INET6 if ":" in UPSTREAM_DNS else socket.AF_INET
def _forward_one(packet, server, use_tcp):
family = socket.AF_INET6 if ":" in server else socket.AF_INET
socket_type = socket.SOCK_STREAM if use_tcp else socket.SOCK_DGRAM
with socket.socket(family, socket_type) as upstream:
upstream.settimeout(UPSTREAM_TIMEOUT)
upstream.connect((UPSTREAM_DNS, 53))
upstream.connect((server, 53))
if use_tcp:
upstream.sendall(struct.pack("!H", len(packet)) + packet)
response_length = struct.unpack("!H", receive_exactly(upstream, 2))[0]
@@ -123,6 +194,16 @@ def forward_query(packet, use_tcp=False):
return response
def forward_query(packet, use_tcp=False):
last_error = OSError("no upstream resolver configured")
for server in UPSTREAMS:
try:
return _forward_one(packet, server, use_tcp)
except (OSError, ValueError) as error:
last_error = error
raise last_error
def dns_response(packet, use_tcp=False):
if len(packet) < 12:
return b""
@@ -137,21 +218,25 @@ def dns_response(packet, use_tcp=False):
return struct.pack("!HHHHHH", transaction_id, response_flags | 1, 0, 0, 0, 0)
question = packet[12:question_end]
if name in FORWARDED_DOMAINS:
# Exceptions bypass overrides and are forwarded upstream (nanoDNS semantics).
if match_any(name, EXCEPTIONS):
try:
return forward_query(packet, use_tcp=use_tcp)
except (OSError, ValueError) as error:
print(f"DNS upstream failure for {name}: {error}", flush=True)
return error_response(packet, 2) # SERVFAIL
if name != TARGET_DOMAIN:
address = override_for(name)
if address is None:
# Default-deny: a name matching no rule returns NXDOMAIN.
return struct.pack("!HHHHHH", transaction_id, response_flags | 3, 1, 0, 0, 0) + question
if qclass == 1 and qtype in (1, 255):
answer = (
b"\xc0\x0c"
+ struct.pack("!HHIH", 1, 1, 60, 4)
+ GATEWAY_IP.packed
+ address.packed
)
return struct.pack("!HHHHHH", transaction_id, response_flags, 1, 1, 0, 0) + question + answer
@@ -274,9 +359,13 @@ if __name__ == "__main__":
serve(http, "http-redirect")
serve(https, "https-redirect")
overrides_summary = (
", ".join(f"{mask}->{address}" for mask, address in OVERRIDES) or "none"
)
print(
f"PS5 guide gateway: {TARGET_DOMAIN} -> {GATEWAY_IP}; redirect -> {REDIRECT_URL}; "
f"forward {','.join(sorted(FORWARDED_DOMAINS))} -> {UPSTREAM_DNS}; "
"PS5 guide gateway (nanoDNS-style, default-deny): "
f"redirect -> {REDIRECT_URL}; overrides {overrides_summary}; "
f"forward {','.join(EXCEPTIONS) or 'none'} -> {','.join(UPSTREAMS) or 'none'}; "
f"clients {','.join(str(network) for network in ALLOWED_CLIENTS) or 'any'}",
flush=True,
)
+47
View File
@@ -0,0 +1,47 @@
# PS5 User Guide DNS gateway -- nanoDNS-style policy.
#
# Config model adapted from https://github.com/drakmor/nanoDNS#config, but the
# unmatched-name behaviour is deliberately inverted to default-DENY.
#
# Resolution order for each query:
# 1. the source address must fall inside [general] allowed_clients, otherwise
# the query is dropped and logged as "denied";
# 2. a name matching an [exceptions] mask is forwarded to [upstream];
# 3. otherwise a name matching an [overrides] mask is answered with its IPv4;
# 4. otherwise NXDOMAIN. Unlike upstream nanoDNS, names that match no rule are
# NOT forwarded, which keeps PSN, updates and telemetry blocked by default.
#
# Masks use shell-style wildcards: * ? [abc] [a-z] [!0-9]
# Names are matched case-insensitively.
[general]
redirect_url = http://192.168.20.30:8000/
# Source networks allowed to query this resolver (comma/space/newline separated).
# Consoles VLAN /28, server VLAN /24, trusted management VLAN /27.
allowed_clients = 192.168.40.0/28, 192.168.20.0/24, 192.168.10.0/27
[upstream]
# Resolvers tried in order; the first valid answer wins. Several may be listed,
# separated by commas, spaces or newlines. Must be IP addresses, not hostnames.
server = 1.1.1.1
timeout_ms = 3000
[overrides]
# mask = IPv4 -- matched names are answered with this address (A records).
manuals.playstation.net = 192.168.20.30
[exceptions]
# One mask per line (no address) -- matched names are forwarded upstream. These
# are the reviewed homebrew-store, PS5 Payload Manager, Spectrum Library and
# BlackBox endpoints. Everything not listed here or in [overrides] is NXDOMAIN.
homebrew.page
itsplk.github.io
store.spectrumlibrary.online
api.github.com
github.com
raw.githubusercontent.com
release-assets.githubusercontent.com
prosperopatches.com
cdn.prosperopatches.com
image.api.playstation.com
gmedia.playstation.com
+35 -19
View File
@@ -182,26 +182,42 @@ the local container. The local gateway was therefore added. Configure the PS5 wi
primary DNS `192.168.20.30` and secondary DNS `0.0.0.0`, then restart the console and
accept the self-signed certificate warning in the User Guide.
The gateway provides allowlisted recursive DNS forwarding only for `homebrew.page`,
`itsplk.github.io`, `store.spectrumlibrary.online`, `api.github.com`, `github.com`
`raw.githubusercontent.com`, `release-assets.githubusercontent.com`,
`prosperopatches.com`, `cdn.prosperopatches.com`, `image.api.playstation.com` and
`gmedia.playstation.com`. Only those names and
`manuals.playstation.net` resolve; all other names receive NXDOMAIN. This permits
the reviewed fixed endpoints of the homebrew store, PS5 Payload Manager, Spectrum
Library and BlackBox while continuing to block PSN and update resolution. The PS5
sends DNS only to `net-utils01`; that host must be allowed to reach the configured
upstream resolver on UDP/TCP 53.
The gateway's DNS policy follows the nanoDNS config model
(<https://github.com/drakmor/nanoDNS#config>), with the unmatched-name behaviour
deliberately inverted to default-deny. The policy lives in `docker/nanodns.ini`,
baked into the image, with these sections:
The gateway additionally restricts which source clients it answers. `ALLOWED_CLIENTS`
defaults to the console VLAN `192.168.40.0/28`, the server VLAN `192.168.20.0/24`,
and the trusted management VLAN `192.168.10.0/27`; queries from any other source
address are dropped and logged as `denied`, on both UDP and TCP. The server and
trusted VLANs are included so operators can test and administer the resolver; the
consoles themselves only need the `/28`. The value accepts a comma-separated mix of
individual addresses and CIDR ranges, and an empty value disables the filter. This
keeps the service off the rest of the LAN and prevents arbitrary hosts from using it
as an open resolver for the allowlisted names.
- `[general]` — `redirect_url` and `allowed_clients`.
- `[upstream]` — one or more resolver IPs (`server`, comma/space/newline
separated, tried in order) and a `timeout_ms` budget.
- `[overrides]` — `mask = IPv4`; matched names are answered with that A record.
`manuals.playstation.net` maps to `192.168.20.30`.
- `[exceptions]` — one mask per line; matched names are forwarded upstream. These
are the reviewed homebrew-store, PS5 Payload Manager, Spectrum Library and
BlackBox endpoints (`homebrew.page`, `itsplk.github.io`,
`store.spectrumlibrary.online`, `api.github.com`, `github.com`,
`raw.githubusercontent.com`, `release-assets.githubusercontent.com`,
`prosperopatches.com`, `cdn.prosperopatches.com`, `image.api.playstation.com`,
`gmedia.playstation.com`).
Masks use shell-style wildcards (`*`, `?`, `[abc]`, `[a-z]`, `[!0-9]`) and match
case-insensitively. Resolution order per query is: exceptions (forward) →
overrides (spoof) → NXDOMAIN. Unlike upstream nanoDNS, a name matching no rule is
**not** forwarded; it returns NXDOMAIN, which keeps PSN, updates and telemetry
blocked by default. The PS5 sends DNS only to `net-utils01`; that host must be
allowed to reach the configured upstream resolver on UDP/TCP 53.
The gateway additionally restricts which source clients it answers, via
`[general] allowed_clients`. It defaults to the console VLAN `192.168.40.0/28`,
the server VLAN `192.168.20.0/24`, and the trusted management VLAN
`192.168.10.0/27`; queries from any other source address are dropped and logged
as `denied`, on both UDP and TCP. The server and trusted VLANs are included so
operators can test and administer the resolver; the consoles themselves only need
the `/28`. The value accepts a comma/space/newline mix of individual addresses and
CIDR ranges, and an empty value disables the filter. This keeps the service off the
rest of the LAN and prevents arbitrary hosts from using it as an open resolver for
the allowlisted names. Editing the policy requires an image rebuild, or pointing
`CONFIG_FILE` at a mounted file.
The Payload Manager review used upstream commit
`e4e72dc9d831dd09ee8fabc222ad1e7f5139050e` and the official catalog current on
+26 -13
View File
@@ -154,6 +154,17 @@ O Compose inclui um DNS controlado no `net-utils01` que:
- apresente um gateway HTTPS local e redirecione o browser para o exploit em
`http://192.168.20.30:8000/`.
A política de DNS segue o modelo de configuração do nanoDNS
(<https://github.com/drakmor/nanoDNS#config>), mas com o comportamento para nomes
não correspondidos invertido para **default-deny** (NXDOMAIN em vez de reencaminhar).
Vive no ficheiro `docker/nanodns.ini`, incluído na imagem, com as secções
`[general]` (`redirect_url`, `allowed_clients`), `[upstream]` (`server`,
`timeout_ms`), `[overrides]` (`máscara = IPv4`, faz spoof) e `[exceptions]` (uma
máscara por linha, reencaminha para o upstream). As máscaras aceitam wildcards
shell (`*`, `?`, `[abc]`, `[a-z]`, `[!0-9]`) e a ordem de resolução é
exceptions → overrides → NXDOMAIN. Para alterar a política, editar o INI e
reconstruir a imagem (ou apontar `CONFIG_FILE` para um ficheiro montado).
Nesse cenário, a PS5 usaria:
```text
@@ -171,20 +182,22 @@ definir `GATEWAY_BIND_IP`, `GATEWAY_IP` e `GUIDE_REDIRECT_URL` antes de executar
Compose. Também é possível bloquear o acesso WAN da PS5 no router, mantendo apenas
a comunicação LAN; esta continua a ser uma camada adicional mais forte do que DNS.
O resolvedor upstream predefinido é `1.1.1.1`. Pode ser substituído por um endereço
DNS controlado na rede através de `UPSTREAM_DNS`; o valor deve ser um endereço IP,
não um hostname. A firewall deve permitir UDP e TCP `53` de `net-utils01` para esse
resolvedor. A consola continua a precisar apenas de UDP/TCP `53` para
`192.168.20.30`; não deve consultar diretamente um DNS público.
O resolvedor upstream predefinido é `1.1.1.1`, definido em `[upstream] server` no
`docker/nanodns.ini`. Podem listar-se vários (separados por vírgulas/espaços/linhas),
tentados por ordem; os valores devem ser endereços IP, não hostnames. A firewall deve
permitir UDP e TCP `53` de `net-utils01` para esse resolvedor. A consola continua a
precisar apenas de UDP/TCP `53` para `192.168.20.30`; não deve consultar diretamente
um DNS público.
O gateway só responde a consultas DNS vindas das redes listadas em `ALLOWED_CLIENTS`.
Por defeito inclui a VLAN das consolas `192.168.40.0/28`, a VLAN de servers
`192.168.20.0/24` e a VLAN de gestão/confiança `192.168.10.0/27` (estas duas últimas
para o operador poder testar e administrar). O valor aceita uma lista separada por
vírgulas de endereços individuais e/ou intervalos CIDR — por exemplo,
`192.168.40.13,192.168.40.14` para trancar apenas às duas PS5 autorizadas (PS5FAT e
PS5PRO). Um valor vazio desativa o filtro e serve todos os clientes. Consultas de
origens não permitidas são descartadas e registadas como `denied`.
O gateway só responde a consultas DNS vindas das redes listadas em
`[general] allowed_clients`. Por defeito inclui a VLAN das consolas
`192.168.40.0/28`, a VLAN de servers `192.168.20.0/24` e a VLAN de gestão/confiança
`192.168.10.0/27` (estas duas últimas para o operador poder testar e administrar). O
valor aceita uma lista separada por vírgulas/espaços de endereços individuais e/ou
intervalos CIDR — por exemplo, `192.168.40.13 192.168.40.14` para trancar apenas às
duas PS5 autorizadas (PS5FAT e PS5PRO). Um valor vazio desativa o filtro e serve todos
os clientes. Consultas de origens não permitidas são descartadas e registadas como
`denied`.
O acesso WAN da consola pode ficar limitado a TCP `443` para os domínios
permitidos. Não são necessárias regras WAN de entrada. Se faltarem ícones, consultar