mirror of
https://github.com/simplex-chat/simplexmq.git
synced 2026-09-01 22:29:01 +00:00
some security hardening
This commit is contained in:
+62
-17
@@ -168,6 +168,22 @@ distinguish these: it is also true for a name nobody ever registered, since
|
||||
Subnames report the status of the 2LD they sit under, which is the useful
|
||||
answer — a subname is only as valid as the name above it.
|
||||
|
||||
### Errors
|
||||
|
||||
Every non-2xx body carries a stable `error` code to branch on and a human
|
||||
`message`, alongside the subject (`name` or `address`):
|
||||
|
||||
```jsonc
|
||||
{"name": "alice.testing", "error": "unregistered",
|
||||
"message": "this name has never been registered",
|
||||
"status": "unregistered", "expires": null, "graceEnds": null}
|
||||
```
|
||||
|
||||
Codes: `tldNotConfigured`, `notFullyQualified`, `unregistered`, `grace`,
|
||||
`expired`, `noResolver`, `badAddress`, `badOffset`, `noRegistrarConfigured`,
|
||||
`unauthorized`, `noSuchRoute`, `upstreamError`. For a name whose registration
|
||||
is the problem, the code equals `status`.
|
||||
|
||||
### Status codes
|
||||
|
||||
| Status | Meaning |
|
||||
@@ -176,6 +192,7 @@ answer — a subname is only as valid as the name above it.
|
||||
| 400 | TLD not configured, or not a fully-qualified name |
|
||||
| 404 | never registered (`unregistered`), or registered with no resolver set (`noResolver`) |
|
||||
| 410 | registration has lapsed — `status` says whether it is still renewable |
|
||||
| 401 | `Authorization` missing or wrong, when a secret is configured |
|
||||
| 502 | upstream RPC error / reth not synced |
|
||||
|
||||
### `GET /owned-by/<address>`
|
||||
@@ -215,19 +232,45 @@ thing to read.
|
||||
Requires `SNRC_REGISTRAR_<TLD>`; with none configured the endpoint answers 400
|
||||
rather than an empty list.
|
||||
|
||||
### Configuring registries
|
||||
### Configuring addresses
|
||||
|
||||
Defaults to mainnet `.testing` (`0x03f438…`); `.simplex` is unset until
|
||||
deployed. Override per TLD via env on the `resolver` service in
|
||||
`docker-compose.yml` (`SNRC_REGISTRY_TESTING` / `SNRC_REGISTRY_SIMPLEX`), or as
|
||||
env vars for the standalone script.
|
||||
Two maps, both per TLD. The **registry** answers *who owns this node* and is
|
||||
what `/resolve` reads; it defaults to mainnet `.testing`, with `.simplex` unset
|
||||
until deployed. The **registrar** is the ERC-721 that can be asked the reverse
|
||||
and when a name expires — it is what `/owned-by` and every expiry field are
|
||||
read from. Without a registrar for a TLD, `/resolve` still works and reports
|
||||
`"status": "unknown"`, and `/owned-by` answers 400.
|
||||
|
||||
`SNRC_REGISTRAR_<TLD>` is the matching ERC-721 registrar, and is what `/owned-by`
|
||||
and the expiry status are read from — the registry answers *who owns this node*,
|
||||
the registrar is the NFT that can be asked the reverse and when it expires.
|
||||
Without it `/resolve` still works and reports `"status": "unknown"`, and
|
||||
`/owned-by` answers 400. `SNRC_MAX_OWNED` bounds one `/owned-by` response
|
||||
(default 256).
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `SNRC_REGISTRY_<TLD>` | ENS registry; resolution |
|
||||
| `SNRC_REGISTRAR_<TLD>` | ERC-721 registrar; `/owned-by`, expiry and status |
|
||||
| `SNRC_MAX_OWNED` | names per `/owned-by` page (default 256) |
|
||||
|
||||
Set them on the `resolver` service in `docker-compose.yml`, or as env vars for
|
||||
the standalone script.
|
||||
|
||||
### Hardening
|
||||
|
||||
The script binds `127.0.0.1` by default; `docker-compose.yml` sets `0.0.0.0`
|
||||
because it must listen on the container bridge, and publishes the port to host
|
||||
loopback only. Anything beyond loopback wants `SNRC_AUTH_BEARER` (or
|
||||
`SNRC_AUTH_BASIC`, `user:password`) — the header is compared in constant time,
|
||||
and it is the header the smp-server's `HttpResolver` already sends. Unset means
|
||||
no check.
|
||||
|
||||
`SNRC_CACHE_TTL` (default 15s) memoises `eth_call` by target and calldata, which
|
||||
matters because one `/resolve` is 15 upstream calls and one `/owned-by` page can
|
||||
be hundreds; set it to `0` to disable. `SNRC_MAX_RPC_BYTES` (default 2 MiB)
|
||||
refuses an oversized JSON-RPC response rather than reading it.
|
||||
|
||||
`/health` reports the RPC URL and both address maps, so **do not expose it** —
|
||||
a hosted RPC URL usually carries the provider key in its path. 502 bodies name
|
||||
the exception type only, and the detail goes to the log, for the same reason.
|
||||
|
||||
`http.server` is a development server. This deployment is loopback-only and
|
||||
that is the posture it is written for; anything public wants a real server in
|
||||
front of it.
|
||||
|
||||
## Every case, and what comes back
|
||||
|
||||
@@ -253,10 +296,10 @@ any lookup, and carry none of the three.
|
||||
| Lapsed, still in grace | 410 | `grace` | `expires` (when it lapsed), `graceEnds` (last moment its owner can renew) |
|
||||
| Lapsed, past grace | 410 | `expired` | same fields; anyone may register it now |
|
||||
| Never registered | 404 | `unregistered` | `expires` and `graceEnds` are `null` |
|
||||
| TLD has no registry configured | 400 | — | `configured_tlds`, listing the ones that are |
|
||||
| TLD has no registry configured | 400 | — | `error: tldNotConfigured`, plus `configuredTlds` |
|
||||
| TLD has no *registrar* configured | 200 / 404 | `unknown` | resolves as it otherwise would; expiry cannot be read, so `expires` and `graceEnds` are `null` |
|
||||
| Not fully qualified (`alice`) | 400 | — | `error` naming the expected form |
|
||||
| RPC unreachable or node unsynced | 502 | — | `error` with the underlying exception type |
|
||||
| RPC unreachable or node unsynced | 502 | — | `error: upstreamError`; the detail goes to the log, not the body |
|
||||
|
||||
A name in grace still has its records on chain — expiry is lazy — but the
|
||||
resolver answers 410 rather than serving them, so a stale name cannot be
|
||||
@@ -274,11 +317,13 @@ and a registrar is configured; the interesting variation is per entry.
|
||||
| Address holds a name past grace | 200 | entry with `status` `expired`; still listed, because the holder is who needs to know |
|
||||
| Address holds nothing | 200 | `names: []` — an answer, not an error |
|
||||
| Token whose label was never recorded | 200 | entry with `"name": null` and its `labelhash`; the token is real, the name is not recoverable from chain state |
|
||||
| Address holds more than `SNRC_MAX_OWNED` in a TLD | 200 | first 256, and `truncated: true` |
|
||||
| Address holds more than `SNRC_MAX_OWNED` in a TLD | 200 | one page, `truncated: true` and `nextOffset` to resume from |
|
||||
| Several TLDs configured | 200 | all of them merged, sorted by TLD then name; `checkedTlds` says which were asked |
|
||||
| Malformed address | 400 | `error`; no RPC call is made |
|
||||
| No registrar configured for any TLD | 400 | `error` and `configured_tlds: []` — distinct from "holds nothing" |
|
||||
| RPC unreachable or node unsynced | 502 | `error` with the underlying exception type |
|
||||
| Malformed address | 400 | `error: badAddress`; no RPC call is made |
|
||||
| Negative or non-numeric `?offset=` | 400 | `error: badOffset` |
|
||||
| `?offset=` past the end | 200 | `names: []` and `nextOffset: null` |
|
||||
| No registrar configured for any TLD | 400 | `error: noRegistrarConfigured`, `configuredTlds: []` — distinct from "holds nothing" |
|
||||
| RPC unreachable or node unsynced | 502 | `error: upstreamError`; the detail goes to the log, not the body |
|
||||
|
||||
Names are **not** filtered by expiry. Enumeration on the registrar is
|
||||
maintained on transfer, mint and burn but deliberately not on expiry, so a
|
||||
|
||||
@@ -144,6 +144,8 @@ services:
|
||||
condition: service_started
|
||||
environment:
|
||||
SNRC_RPC: http://reth:8545
|
||||
# The script defaults to 127.0.0.1; inside a container it has to listen
|
||||
# on the bridge, and the port below is still published to loopback only.
|
||||
SNRC_BIND: 0.0.0.0
|
||||
# Registry addresses cascade through the script's own defaults
|
||||
# (mainnet `.testing`; `.simplex` unconfigured). Set explicitly here
|
||||
@@ -155,6 +157,12 @@ services:
|
||||
# SNRC_REGISTRAR_TESTING: 0x...
|
||||
# SNRC_REGISTRAR_SIMPLEX: 0x...
|
||||
# SNRC_MAX_OWNED: 256
|
||||
# SNRC_CACHE_TTL: 15 # seconds to memoise eth_call; 0 disables
|
||||
# SNRC_MAX_RPC_BYTES: 2097152 # refuse a larger JSON-RPC response
|
||||
# Shared secret the caller must present. Unset = no check, which is
|
||||
# right while the port is published to loopback only.
|
||||
# SNRC_AUTH_BEARER: <token>
|
||||
# SNRC_AUTH_BASIC: <user>:<password>
|
||||
ports:
|
||||
- "127.0.0.1:8000:8000"
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -40,7 +40,7 @@ Environment:
|
||||
SNRC_REGISTRY_SIMPLEX ENSRegistry for the .simplex deployment
|
||||
(default: empty — TLD not yet deployed)
|
||||
SNRC_PORT Listen port (default: 8000)
|
||||
SNRC_BIND Bind address (default: 0.0.0.0)
|
||||
SNRC_BIND Bind address (default: 127.0.0.1; compose sets 0.0.0.0)
|
||||
|
||||
Each TLD is a separate SNRC deployment with its own ENSRegistry; the
|
||||
resolver dispatches by the queried name's rightmost label.
|
||||
@@ -58,19 +58,21 @@ Addresses are returned in each chain's canonical presentation:
|
||||
Unrecognised payloads fall back to `0x`-prefixed raw hex.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import unquote, urlparse
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from eth_hash.auto import keccak
|
||||
|
||||
RPC = os.environ.get("SNRC_RPC", "http://127.0.0.1:8545")
|
||||
BIND = os.environ.get("SNRC_BIND", "0.0.0.0")
|
||||
BIND = os.environ.get("SNRC_BIND", "127.0.0.1")
|
||||
PORT = int(os.environ.get("SNRC_PORT", "8000"))
|
||||
|
||||
# Each TLD is its own SNRC deployment with its own ENSRegistry. Dispatch
|
||||
@@ -86,6 +88,15 @@ REGISTRIES = {
|
||||
"simplex": os.environ.get("SNRC_REGISTRY_SIMPLEX", ""), # not deployed yet
|
||||
}
|
||||
|
||||
# Shared secret the caller must present. Unset means no check - correct for a
|
||||
# loopback deployment, and the reason the check exists at all is that the
|
||||
# Haskell client has always been able to send `Authorization` and nothing here
|
||||
# ever read it, so configuring auth protected nothing.
|
||||
# SNRC_AUTH_BEARER=<token> -> Authorization: Bearer <token>
|
||||
# SNRC_AUTH_BASIC=<user>:<password> -> Authorization: Basic base64(user:pass)
|
||||
AUTH_BEARER = os.environ.get("SNRC_AUTH_BEARER", "")
|
||||
AUTH_BASIC = os.environ.get("SNRC_AUTH_BASIC", "")
|
||||
|
||||
# The BaseRegistrar (ERC-721) per TLD, used for owner -> names. Separate from
|
||||
# the registry above: the registry answers "who owns this node", the registrar
|
||||
# is the NFT that can be asked the reverse. Not a proxy, so the address in
|
||||
@@ -112,6 +123,20 @@ ZERO_ADDR = "0x0000000000000000000000000000000000000000"
|
||||
|
||||
# ---------- RPC + ABI helpers (mirrors ens-lookup.py shape) ----------
|
||||
|
||||
# A JSON-RPC body larger than this is refused rather than read. The Haskell
|
||||
# client that calls this resolver caps its own reads for the same reason; an
|
||||
# upstream that is compromised or simply misconfigured must not be able to
|
||||
# decide how much memory this process allocates.
|
||||
MAX_RPC_BYTES = int(os.environ.get("SNRC_MAX_RPC_BYTES", str(2 * 1024 * 1024)))
|
||||
|
||||
# eth_call answers change at block cadence, not per request, so repeating one
|
||||
# within a few seconds asks the node a question it has already answered. One
|
||||
# /resolve is 15 calls and one /owned-by can be hundreds, which makes this the
|
||||
# difference between a warm resolver and a busy node.
|
||||
CACHE_TTL = float(os.environ.get("SNRC_CACHE_TTL", "15"))
|
||||
_CALL_CACHE = {}
|
||||
|
||||
|
||||
def rpc(method, params):
|
||||
body = json.dumps(
|
||||
{"jsonrpc": "2.0", "method": method, "params": params, "id": 1}
|
||||
@@ -126,7 +151,11 @@ def rpc(method, params):
|
||||
"User-Agent": "snrc-resolve/1.0",
|
||||
},
|
||||
)
|
||||
res = json.loads(urlopen(req, timeout=15).read())
|
||||
with urlopen(req, timeout=15) as r:
|
||||
raw = r.read(MAX_RPC_BYTES + 1)
|
||||
if len(raw) > MAX_RPC_BYTES:
|
||||
raise RuntimeError(f"RPC response exceeds {MAX_RPC_BYTES} bytes")
|
||||
res = json.loads(raw)
|
||||
if "error" in res:
|
||||
raise RuntimeError(res["error"])
|
||||
return res["result"]
|
||||
@@ -145,7 +174,28 @@ def selector(signature: str) -> str:
|
||||
|
||||
|
||||
def eth_call(to: str, data: str) -> str:
|
||||
return rpc("eth_call", [{"to": to, "data": data}, "latest"])
|
||||
"""A read against `latest`, memoised for CACHE_TTL seconds.
|
||||
|
||||
Keyed on the call itself, so the cache is shared across endpoints: a name
|
||||
resolved just after it was listed costs nothing the second time. Set
|
||||
SNRC_CACHE_TTL=0 to disable.
|
||||
"""
|
||||
if CACHE_TTL <= 0:
|
||||
return rpc("eth_call", [{"to": to, "data": data}, "latest"])
|
||||
key = (to, data)
|
||||
now = time.monotonic()
|
||||
hit = _CALL_CACHE.get(key)
|
||||
if hit and hit[0] > now:
|
||||
return hit[1]
|
||||
result = rpc("eth_call", [{"to": to, "data": data}, "latest"])
|
||||
# Evict lazily: this only grows while requests are arriving, and a sweep
|
||||
# on write keeps it proportional to traffic rather than to uptime.
|
||||
if len(_CALL_CACHE) > 4096:
|
||||
for k, v in list(_CALL_CACHE.items()):
|
||||
if v[0] <= now:
|
||||
del _CALL_CACHE[k]
|
||||
_CALL_CACHE[key] = (now + CACHE_TTL, result)
|
||||
return result
|
||||
|
||||
|
||||
def decode_address(hex_data: str) -> str:
|
||||
@@ -178,6 +228,37 @@ def decode_string(hex_data: str) -> str:
|
||||
return raw.decode("utf-8", errors="replace") if raw else ""
|
||||
|
||||
|
||||
def expected_auth_header() -> str:
|
||||
"""The Authorization value this resolver requires, or "" for none."""
|
||||
if AUTH_BEARER:
|
||||
return "Bearer " + AUTH_BEARER
|
||||
if AUTH_BASIC:
|
||||
return "Basic " + base64.b64encode(AUTH_BASIC.encode()).decode()
|
||||
return ""
|
||||
|
||||
|
||||
def auth_ok(header: str) -> bool:
|
||||
"""Constant-time compare, so a wrong token cannot be found a byte at a time."""
|
||||
expected = expected_auth_header()
|
||||
if not expected:
|
||||
return True
|
||||
return hmac.compare_digest(header or "", expected)
|
||||
|
||||
|
||||
def upstream_error(subject: dict, e: Exception) -> dict:
|
||||
"""A 502 body that names the failure without quoting the exception.
|
||||
|
||||
urlopen puts the URL it failed on into its message, and SNRC_RPC may carry
|
||||
a provider key, so the text goes to the log and a type goes to the caller.
|
||||
"""
|
||||
print(f"upstream error: {type(e).__name__}: {e}", file=sys.stderr)
|
||||
return {
|
||||
**subject,
|
||||
"error": "upstreamError",
|
||||
"message": f"upstream RPC failed ({type(e).__name__})",
|
||||
}
|
||||
|
||||
|
||||
def is_address(value: str) -> bool:
|
||||
return (
|
||||
len(value) == 42
|
||||
@@ -440,8 +521,9 @@ def resolve(name: str):
|
||||
configured = [k for k, v in REGISTRIES.items() if v]
|
||||
return 400, {
|
||||
"name": name,
|
||||
"error": f"TLD '{tld}' is not configured on this resolver",
|
||||
"configured_tlds": configured,
|
||||
"error": "tldNotConfigured",
|
||||
"message": f"TLD '{tld}' is not configured on this resolver",
|
||||
"configuredTlds": configured,
|
||||
}
|
||||
|
||||
node = namehash(name)
|
||||
@@ -458,7 +540,8 @@ def resolve(name: str):
|
||||
"status": "unregistered",
|
||||
"expires": None,
|
||||
"graceEnds": None,
|
||||
"error": "this name has never been registered",
|
||||
"error": "unregistered",
|
||||
"message": "this name has never been registered",
|
||||
}
|
||||
if reg["status"] in ("grace", "expired"):
|
||||
return 410, {
|
||||
@@ -466,7 +549,8 @@ def resolve(name: str):
|
||||
"status": reg["status"],
|
||||
"expires": reg["expires"],
|
||||
"graceEnds": reg["graceEnds"],
|
||||
"error": (
|
||||
"error": reg["status"],
|
||||
"message": (
|
||||
"this registration expired and can be renewed by its owner"
|
||||
if reg["status"] == "grace"
|
||||
else "this registration expired and is open to anyone"
|
||||
@@ -481,7 +565,8 @@ def resolve(name: str):
|
||||
"status": "noResolver",
|
||||
"expires": reg["expires"],
|
||||
"graceEnds": reg["graceEnds"],
|
||||
"error": "no resolver set for this name",
|
||||
"error": "noResolver",
|
||||
"message": "no resolver set for this name",
|
||||
}
|
||||
|
||||
owner_raw = eth_call(registry, selector("owner(bytes32)") + node_hex)
|
||||
@@ -590,7 +675,7 @@ def name_status(name: str):
|
||||
}
|
||||
|
||||
|
||||
def owned_by(address: str):
|
||||
def owned_by(address: str, offset: int = 0):
|
||||
"""Every live name an address holds, across every configured TLD.
|
||||
|
||||
Read straight off the ERC-721 registrar rather than from logs: the token
|
||||
@@ -619,14 +704,19 @@ def owned_by(address: str):
|
||||
becomes the second.
|
||||
"""
|
||||
if not is_address(address):
|
||||
return 400, {"address": address, "error": "expected a 0x-prefixed 20-byte address"}
|
||||
return 400, {
|
||||
"address": address,
|
||||
"error": "badAddress",
|
||||
"message": "expected a 0x-prefixed 20-byte address",
|
||||
}
|
||||
|
||||
configured = {t: r for t, r in REGISTRARS.items() if r}
|
||||
if not configured:
|
||||
return 400, {
|
||||
"address": address,
|
||||
"error": "no registrar is configured on this resolver",
|
||||
"configured_tlds": [],
|
||||
"error": "noRegistrarConfigured",
|
||||
"message": "no registrar is configured on this resolver",
|
||||
"configuredTlds": [],
|
||||
}
|
||||
|
||||
now = int(time.time())
|
||||
@@ -636,10 +726,11 @@ def owned_by(address: str):
|
||||
held = decode_uint(
|
||||
eth_call(registrar, selector("balanceOf(address)") + encode_address(address))
|
||||
)
|
||||
if held > MAX_OWNED:
|
||||
first = min(offset, held)
|
||||
last = min(first + MAX_OWNED, held)
|
||||
if last < held:
|
||||
truncated = True
|
||||
held = MAX_OWNED
|
||||
for i in range(held):
|
||||
for i in range(first, last):
|
||||
token = decode_uint(
|
||||
eth_call(
|
||||
registrar,
|
||||
@@ -662,7 +753,7 @@ def owned_by(address: str):
|
||||
{
|
||||
"name": (label + "." + tld) if label else None,
|
||||
"tld": tld,
|
||||
"labelhash": hex(token),
|
||||
"labelhash": "0x" + format(token, "064x"),
|
||||
"expires": expires,
|
||||
"graceEnds": expires + grace if expires else None,
|
||||
"status": expiry_status(expires, grace, now),
|
||||
@@ -673,6 +764,11 @@ def owned_by(address: str):
|
||||
return 200, {
|
||||
"address": address,
|
||||
"names": names,
|
||||
"offset": offset,
|
||||
# `nextOffset` is the cursor to resume from, or null when the listing
|
||||
# is complete - so "there is more" and "here is how to get it" are the
|
||||
# same answer rather than a flag with no way to act on it.
|
||||
"nextOffset": offset + MAX_OWNED if truncated else None,
|
||||
"truncated": truncated,
|
||||
"checkedTlds": sorted(configured),
|
||||
}
|
||||
@@ -680,15 +776,36 @@ def owned_by(address: str):
|
||||
|
||||
# ---------- HTTP layer ----------
|
||||
|
||||
# Bumped when the response shape changes, so a client can tell "this resolver
|
||||
# does not report that" from "that is not knowable for this name".
|
||||
API_VERSION = 2
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self): # noqa: N802 - http.server contract
|
||||
path = urlparse(self.path).path
|
||||
parts = [unquote(p) for p in path.split("/") if p]
|
||||
parsed = urlparse(self.path)
|
||||
parts = [unquote(p) for p in parsed.path.split("/") if p]
|
||||
|
||||
if not auth_ok(self.headers.get("Authorization")):
|
||||
self._respond(
|
||||
401,
|
||||
{"error": "unauthorized", "message": "missing or invalid Authorization"},
|
||||
)
|
||||
return
|
||||
|
||||
if parts == ["health"]:
|
||||
self._respond(
|
||||
200,
|
||||
{"ok": True, "rpc": RPC, "registries": REGISTRIES},
|
||||
{
|
||||
"ok": True,
|
||||
"version": API_VERSION,
|
||||
"rpc": RPC,
|
||||
"registries": REGISTRIES,
|
||||
# /owned-by and every expiry field are read from these, so
|
||||
# an operator who configured only the registries can see
|
||||
# here why status reads "unknown".
|
||||
"registrars": REGISTRARS,
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
@@ -698,31 +815,42 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self._respond(
|
||||
400,
|
||||
{
|
||||
"error": "expected fully-qualified name, e.g. /resolve/alice.testing",
|
||||
"got": name,
|
||||
"name": name,
|
||||
"error": "notFullyQualified",
|
||||
"message": "expected a fully-qualified name, e.g. alice.testing",
|
||||
},
|
||||
)
|
||||
return
|
||||
try:
|
||||
status, body = resolve(name)
|
||||
except Exception as e: # surface upstream errors as 502
|
||||
status, body = 502, {"name": name, "error": f"{type(e).__name__}: {e}"}
|
||||
status, body = 502, upstream_error({"name": name}, e)
|
||||
self._respond(status, body)
|
||||
return
|
||||
|
||||
if len(parts) == 2 and parts[0] == "owned-by":
|
||||
address = parts[1].strip().lower()
|
||||
try:
|
||||
status, body = owned_by(address)
|
||||
offset = int(parse_qs(parsed.query).get("offset", ["0"])[0])
|
||||
if offset < 0:
|
||||
raise ValueError("offset must not be negative")
|
||||
except ValueError as e:
|
||||
self._respond(
|
||||
400, {"address": address, "error": "badOffset", "message": str(e)}
|
||||
)
|
||||
return
|
||||
try:
|
||||
status, body = owned_by(address, offset)
|
||||
except Exception as e: # surface upstream errors as 502
|
||||
status, body = 502, {"address": address, "error": f"{type(e).__name__}: {e}"}
|
||||
status, body = 502, upstream_error({"address": address}, e)
|
||||
self._respond(status, body)
|
||||
return
|
||||
|
||||
self._respond(
|
||||
404,
|
||||
{
|
||||
"error": "not found",
|
||||
"error": "noSuchRoute",
|
||||
"message": "not found",
|
||||
"routes": ["/health", "/resolve/<name>", "/owned-by/<address>"],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -145,9 +145,10 @@ class OwnedByTests(unittest.TestCase):
|
||||
snrc.eth_call = self._fake_chain([(11, "", future)])
|
||||
_, body = snrc.owned_by(self.OWNER)
|
||||
self.assertEqual(body["names"][0]["name"], None)
|
||||
self.assertEqual(body["names"][0]["labelhash"], hex(11))
|
||||
# a labelhash is bytes32, not the shortest integer literal that fits
|
||||
self.assertEqual(body["names"][0]["labelhash"], "0x" + "0" * 63 + "b")
|
||||
|
||||
def test_enumeration_is_bounded_and_says_so(self):
|
||||
def test_enumeration_is_bounded_and_offers_a_cursor(self):
|
||||
future = int(time.time()) + 86400
|
||||
snrc.MAX_OWNED, keep = 2, snrc.MAX_OWNED
|
||||
try:
|
||||
@@ -157,21 +158,50 @@ class OwnedByTests(unittest.TestCase):
|
||||
_, body = snrc.owned_by(self.OWNER)
|
||||
self.assertEqual(len(body["names"]), 2)
|
||||
self.assertTrue(body["truncated"])
|
||||
# a flag with no way to act on it is a dead end, so it carries one
|
||||
self.assertEqual(body["nextOffset"], 2)
|
||||
finally:
|
||||
snrc.MAX_OWNED = keep
|
||||
|
||||
def test_the_cursor_walks_the_whole_list_without_repeats(self):
|
||||
future = int(time.time()) + 86400
|
||||
snrc.MAX_OWNED, keep = 2, snrc.MAX_OWNED
|
||||
try:
|
||||
snrc.eth_call = self._fake_chain(
|
||||
[(i, "n%d" % i, future) for i in range(1, 6)]
|
||||
)
|
||||
seen, offset = [], 0
|
||||
while offset is not None:
|
||||
_, body = snrc.owned_by(self.OWNER, offset)
|
||||
seen += [n["name"] for n in body["names"]]
|
||||
offset = body["nextOffset"]
|
||||
self.assertEqual(sorted(seen), sorted("n%d.testing" % i for i in range(1, 6)))
|
||||
self.assertEqual(len(seen), len(set(seen)))
|
||||
finally:
|
||||
snrc.MAX_OWNED = keep
|
||||
|
||||
def test_an_offset_past_the_end_is_an_empty_page_not_an_error(self):
|
||||
future = int(time.time()) + 86400
|
||||
snrc.eth_call = self._fake_chain([(1, "only", future)])
|
||||
status, body = snrc.owned_by(self.OWNER, 99)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(body["names"], [])
|
||||
self.assertIsNone(body["nextOffset"])
|
||||
|
||||
def test_a_malformed_address_is_refused_before_any_rpc(self):
|
||||
snrc.eth_call = lambda *a: self.fail("must not reach the chain")
|
||||
status, body = snrc.owned_by("0xnope")
|
||||
self.assertEqual(status, 400)
|
||||
self.assertIn("address", body["error"])
|
||||
self.assertEqual(body["error"], "badAddress")
|
||||
self.assertIn("address", body["message"])
|
||||
|
||||
def test_no_configured_registrar_is_an_error_not_an_empty_list(self):
|
||||
snrc.REGISTRARS = {"testing": "", "simplex": ""}
|
||||
snrc.eth_call = lambda *a: self.fail("must not reach the chain")
|
||||
status, body = snrc.owned_by(self.OWNER)
|
||||
self.assertEqual(status, 400)
|
||||
self.assertEqual(body["configured_tlds"], [])
|
||||
self.assertEqual(body["error"], "noRegistrarConfigured")
|
||||
self.assertEqual(body["configuredTlds"], [])
|
||||
|
||||
|
||||
class NameStatusTests(unittest.TestCase):
|
||||
@@ -277,6 +307,74 @@ class NameStatusTests(unittest.TestCase):
|
||||
self.assertEqual(set(snrc.name_status("alice.testing")), keys)
|
||||
|
||||
|
||||
class AuthTests(unittest.TestCase):
|
||||
"""The Haskell client has always been able to send `Authorization`; until
|
||||
now nothing here read it, so configuring auth protected nothing."""
|
||||
|
||||
def setUp(self):
|
||||
self._saved = (snrc.AUTH_BEARER, snrc.AUTH_BASIC)
|
||||
|
||||
def tearDown(self):
|
||||
snrc.AUTH_BEARER, snrc.AUTH_BASIC = self._saved
|
||||
|
||||
def test_no_secret_configured_accepts_anything(self):
|
||||
snrc.AUTH_BEARER = snrc.AUTH_BASIC = ""
|
||||
self.assertTrue(snrc.auth_ok(""))
|
||||
self.assertTrue(snrc.auth_ok("Bearer whatever"))
|
||||
|
||||
def test_bearer_accepts_only_the_configured_token(self):
|
||||
snrc.AUTH_BEARER, snrc.AUTH_BASIC = "sekrit", ""
|
||||
self.assertTrue(snrc.auth_ok("Bearer sekrit"))
|
||||
self.assertFalse(snrc.auth_ok("Bearer sekri"))
|
||||
self.assertFalse(snrc.auth_ok("Bearer sekrit2"))
|
||||
self.assertFalse(snrc.auth_ok(""))
|
||||
self.assertFalse(snrc.auth_ok(None))
|
||||
|
||||
def test_basic_matches_what_the_haskell_client_builds(self):
|
||||
snrc.AUTH_BEARER, snrc.AUTH_BASIC = "", "user:pass"
|
||||
# HttpResolver.hs: "Basic " <> base64(user <> ":" <> password)
|
||||
self.assertEqual(snrc.expected_auth_header(), "Basic dXNlcjpwYXNz")
|
||||
self.assertTrue(snrc.auth_ok("Basic dXNlcjpwYXNz"))
|
||||
self.assertFalse(snrc.auth_ok("Basic bm9wZQ=="))
|
||||
|
||||
|
||||
class CallCacheTests(unittest.TestCase):
|
||||
"""One /resolve is 15 upstream calls and one /owned-by can be hundreds, so
|
||||
repeating a call the node just answered is the cost worth removing."""
|
||||
|
||||
def setUp(self):
|
||||
self._saved = (snrc.eth_call, snrc.rpc, snrc.CACHE_TTL, dict(snrc._CALL_CACHE))
|
||||
snrc._CALL_CACHE.clear()
|
||||
|
||||
def tearDown(self):
|
||||
snrc.eth_call, snrc.rpc, snrc.CACHE_TTL, cache = self._saved
|
||||
snrc._CALL_CACHE.clear()
|
||||
snrc._CALL_CACHE.update(cache)
|
||||
|
||||
def test_a_repeated_call_asks_the_node_once(self):
|
||||
calls = []
|
||||
snrc.rpc = lambda method, params: calls.append(params) or "0x2a"
|
||||
snrc.CACHE_TTL = 60
|
||||
self.assertEqual(snrc.eth_call("0xto", "0xdata"), "0x2a")
|
||||
self.assertEqual(snrc.eth_call("0xto", "0xdata"), "0x2a")
|
||||
self.assertEqual(len(calls), 1)
|
||||
|
||||
def test_different_calls_are_not_confused(self):
|
||||
snrc.rpc = lambda method, params: params[0]["data"]
|
||||
snrc.CACHE_TTL = 60
|
||||
self.assertEqual(snrc.eth_call("0xto", "0xaa"), "0xaa")
|
||||
self.assertEqual(snrc.eth_call("0xto", "0xbb"), "0xbb")
|
||||
self.assertEqual(snrc.eth_call("0xother", "0xaa"), "0xaa")
|
||||
|
||||
def test_zero_ttl_disables_it(self):
|
||||
calls = []
|
||||
snrc.rpc = lambda method, params: calls.append(1) or "0x"
|
||||
snrc.CACHE_TTL = 0
|
||||
snrc.eth_call("0xto", "0xdata")
|
||||
snrc.eth_call("0xto", "0xdata")
|
||||
self.assertEqual(len(calls), 2)
|
||||
|
||||
|
||||
class SplitLinksTests(unittest.TestCase):
|
||||
"""`split_links` decodes the multi-URL convention for simplex.contact /
|
||||
simplex.channel text records. Reuses the same rule the dApp's
|
||||
|
||||
Reference in New Issue
Block a user