diff --git a/AGENTS.md b/AGENTS.md index 2dc5c6d..e4b059a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/Dockerfile b/Dockerfile index b1ffe34..2cc450d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 \ diff --git a/compose.yaml b/compose.yaml index 53d894c..f4aeeab 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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" diff --git a/docker/guide_gateway.py b/docker/guide_gateway.py index 1952627..b24e56c 100644 --- a/docker/guide_gateway.py +++ b/docker/guide_gateway.py @@ -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, ) diff --git a/docker/nanodns.ini b/docker/nanodns.ini new file mode 100644 index 0000000..893c760 --- /dev/null +++ b/docker/nanodns.ini @@ -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 diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index fb222e6..512635a 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -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 +(), 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 diff --git a/docs/PS5_SETUP.md b/docs/PS5_SETUP.md index ce8fe82..8c51890 100644 --- a/docs/PS5_SETUP.md +++ b/docs/PS5_SETUP.md @@ -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 +(), 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