diff --git a/protocol/simplex-messaging.md b/protocol/simplex-messaging.md index f894f1047..3b2d7efd6 100644 --- a/protocol/simplex-messaging.md +++ b/protocol/simplex-messaging.md @@ -1452,45 +1452,44 @@ reads `NameRecord` from. The reference implementation forwards each RSLV to a companion REST resolver process (`scripts/resolver/snrc-resolve.py`) that queries the SNRC contract on Ethereum; alternative backings (different chains, DHT, etc.) are valid as long as they expose the documented HTTP shape (`GET -/resolve/` returning a `NameRecord` on 200, 404 / 400 for unknown names -or TLDs, 502 for upstream RPC failures) or substitute a different transport -while still returning a `NameRecord` matching the encoding below. +/v2/resolve/` returning a `NameRegistration` on 200 for every +registration shape, 400 for unknown TLDs, 502 for upstream failures) or +substitute a different transport returning the same JSON. The resolver API is +versioned separately from this protocol: `/v1/resolve/` returns a bare +`NameRecord` and is what relays before v22 call as `/resolve/`. #### Resolve name command -From v22 the `RSLV` command carries a query; below v22 it carries the name -directly, as it always did (not JSON): +The `RSLV` command carries the query as text, not JSON. A client sends the +hashed form only from v22, and the name itself below it: ```abnf -rslv = %s"RSLV" SP (query / domain) ; query from v22, domain below it -query = tld label sub -tld = %s"s" / %s"t" / %s"w" ; .simplex / .testing / a web name -label = %s"N" shortString ; the second-level label as text - / %s"H" 32*32 OCTET ; its keccak-256 -sub = length *shortString ; subname labels, parent to child +rslv = %s"RSLV" SP query +query = domain / hashed ; hashed only from v22 domain = 1*253 OCTET ; the name as text +hashed = "[" 64HEXDIG "]" tld ; keccak-256 of the second-level label +tld = %s".simplex" / %s".testing" ``` `domain` is the UTF-8 canonical fully-qualified name with the TLD always explicit (e.g. `privacy.simplex`, `test.testing`, `example.com`), bounded to 253 bytes. -**Hashed labels.** The query's second-level label is either the label itself or -the keccak-256 of it, tagged, so the two are told apart by the tag and never by -the shape of the value. +**Hashed labels.** The query is a name, or the keccak-256 of a second-level +label in ENS's bracketed form. A label can only be letters, digits and hyphens, +so `[` tells the two apart and no tag is needed. -Only the second-level label may be hashed: subname labels are needed as text to -reach the record, and a web TLD has no registry to key on. `sub..simplex` -reaches the node `sub.name.simplex` does. +Only a second-level name may be hashed. A name with subnames is sent as text: it +is resolved rather than priced, and its record names it anyway. A web TLD has no +registry to key a hash on. From v22 a client MUST send the hash. Older routers can only read the name, so a client on an older session sends the name. A router answering a hashed query does not know the label's length, so it cannot check a minimum-length policy either: the client does that, from the pricing it is sent. -The hash reaches the backing resolver as `[` + 64 lowercase hex + `]`, ENS's -encoding for a label whose text is unknown, because that is what its HTTP API -takes. That form appears nowhere in SMP. +The same form reaches the backing resolver, which is what its HTTP API takes, so +the query is one string end to end. A hashed query still answers with the name. The registrar records the plaintext label when a name is registered, keyed by the hash of that label, so a router can diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index 8ffa6a1e5..644a16a1a 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -172,16 +172,13 @@ def is_encoded_labelhash(label: str) -> bool: def node_of(name: str) -> bytes: - """namehash, decoding the 2LD's label as a labelhash wherever it sits, so - `[hash].tld` and `sub.[hash].tld` reach the nodes their names do. A bracket - label anywhere else is hashed as written.""" + """namehash, decoding a second-level labelhash so `[hash].tld` reaches the + node its name does. Only a second-level name is ever hashed; a bracket + anywhere else is hashed as written.""" labels = name.split(".") - if len(labels) < 2 or not is_encoded_labelhash(labels[-2]): + if len(labels) != 2 or not is_encoded_labelhash(labels[0]): return namehash(name) - node = keccak(namehash(labels[-1]) + bytes.fromhex(labels[-2][1:-1])) - for label in reversed(labels[:-2]): - node = keccak(node + keccak(label.encode())) - return node + return keccak(namehash(labels[1]) + bytes.fromhex(labels[0][1:-1])) # ---------- Registration status ---------- @@ -406,10 +403,9 @@ def canonical_name(name: str) -> str: registrar's record of the label fills it in.""" labels = name.split(".") registrar = REGISTRARS.get(labels[-1]) - if not registrar or len(labels) < 2 or not is_encoded_labelhash(labels[-2]): + if not registrar or len(labels) != 2 or not is_encoded_labelhash(labels[0]): return name - label = registered_label(registrar, label_token(labels[-2])) - return ".".join(labels[:-2] + [label, labels[-1]]) + return registered_label(registrar, label_token(labels[0])) + "." + labels[1] def label_token(label: str) -> int: @@ -687,6 +683,86 @@ def upstream_error(subject: dict, e: Exception) -> dict: } +def name_record(name: str): + """The NameRecord for a registered name. A name with no resolver set still + has one, with every field unset.""" + registry = REGISTRIES[name.rsplit(".", 1)[-1]] + node = node_of(name) + node_hex = node.hex() + resolver_addr = decode_address(eth_call(registry, selector("resolver(bytes32)") + node_hex)) + owner = decode_address(eth_call(registry, selector("owner(bytes32)") + node_hex)) + rec = { + "name": canonical_name(name), + "nickname": "", + "website": "", + "location": "", + "simplexContact": [], + "simplexChannel": [], + "eth": None, + "btc": None, + "xmr": None, + "dot": None, + "owner": owner, + "resolver": resolver_addr, + } + if resolver_addr == ZERO_ADDR: + return rec + texts = {} + for k in TEXT_KEYS: + try: + v = text(resolver_addr, node, k) + except RuntimeError: + v = "" + if v: + texts[k] = v + rec.update( + { + "nickname": texts.get("nickname") or texts.get("name") or texts.get("description") or "", + "website": texts.get("url", ""), + "location": texts.get("location", ""), + "simplexContact": split_links(texts.get("simplex.contact", "")), + "simplexChannel": split_links(texts.get("simplex.channel", "")), + "eth": addr_multicoin(resolver_addr, node, COIN_ETH), + "btc": addr_multicoin(resolver_addr, node, COIN_BTC), + "xmr": addr_multicoin(resolver_addr, node, COIN_XMR), + "dot": addr_multicoin(resolver_addr, node, COIN_DOT), + } + ) + return rec + + +def registration(name: str): + """The SMP protocol's NameRegistration, which the relay decodes as is. + Translating the contract's model to it is this resolver's job.""" + tld = name.rsplit(".", 1)[-1] + if not REGISTRIES.get(tld): + return 400, {"name": name, "error": "tldNotConfigured"} + reg = name_status(name) + status = reg["status"] + if status in ("registered", "grace"): + return 200, { + "type": "registered", + "expires": reg["expires"], + "graceUntil": reg["graceEnds"], + "reservedReason_": reg["reasonCode"], + "nameRecord": name_record(name), + } + if reg["reasonCode"]: + return 200, {"type": "reserved", "reservedReason": reg["reasonCode"]} + if status in ("unregistered", "expired"): + if "basePrice" not in reg: + return 502, {"name": name, "error": "noPriceOracle"} + return 200, { + "type": "available", + "pricing": { + "registrationPrices": reg["rentPrices"], + "basePrice": reg["basePrice"], + "minLabelLength": reg["minLabelLength"], + }, + } + return 502, {"name": name, "error": status} + + def resolve(name: str): tld = name.rsplit(".", 1)[-1] registry = REGISTRIES.get(tld) @@ -793,6 +869,22 @@ class Handler(BaseHTTPRequestHandler): ) return + if len(parts) == 3 and parts[0] == "v2" and parts[1] == "resolve": + name = parts[2].strip().lower() + if not name or "." not in name: + self._respond(400, {"name": name, "error": "notFullyQualified"}) + return + try: + status, body = registration(name) + except Exception as e: # surface upstream errors as 502 + status, body = 502, upstream_error({"name": name}, e) + self._respond(status, body) + return + + # /v1/resolve is an alias: relays before SMP v22 call /resolve + if parts[:2] == ["v1", "resolve"] and len(parts) == 3: + parts = ["resolve", parts[2]] + if len(parts) == 2 and parts[0] == "resolve": name = parts[1].strip().lower() if not name or "." not in name: @@ -843,7 +935,7 @@ def main(): ) for tld, addr in REGISTRIES.items(): sys.stderr.write(f" .{tld:<8s} = {addr or '(not configured)'}\n") - sys.stderr.write(" GET /resolve/ GET /health\n") + sys.stderr.write(" GET /v2/resolve/ GET /v1/resolve/ GET /health\n") try: server.serve_forever() except KeyboardInterrupt: diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index fdc25b9a5..eed40f0c0 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -147,25 +147,6 @@ class EncodedLabelhashTests(unittest.TestCase): snrc.namehash("alice.alice.testing"), ) - def test_a_hashed_2ld_under_a_subname_reaches_the_same_node(self): - """`sub.[hash].tld` must reach the node `sub.name.tld` does.""" - self.assertEqual( - snrc.node_of( - "sub." - "[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501]" - ".testing" - ), - snrc.namehash("sub.alice.testing"), - ) - self.assertEqual( - snrc.node_of( - "a.b." - "[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501]" - ".testing" - ), - snrc.namehash("a.b.alice.testing"), - ) - def test_a_0x_prefixed_label_is_taken_literally(self): name = "0x9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501.testing" self.assertEqual(snrc.node_of(name), snrc.namehash(name)) diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index 12c67e725..a76273f47 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -1057,7 +1057,7 @@ proxySMPMessage c nm proxiedRelay spKey sId flags msg = proxyOKSMPCommand c nm p proxyResolveName :: SMPClient -> NetworkRequestMode -> ProxiedRelay -> SimplexDomain -> ExceptT SMPClientError IO (Either ProxyClientError NameRegistration) proxyResolveName c nm proxiedRelay name | v >= namesSMPVersion = - proxySMPCommand c nm proxiedRelay Nothing NoEntity (RSLV (nameQuery v name)) >>= \case + proxySMPCommand c nm proxiedRelay Nothing NoEntity (RSLV (NQDomain name)) >>= \case Right (RNAME reg) | resolvedName name reg -> pure $ Right reg Right r -> throwE $ unexpectedResponse r Left e -> pure $ Left e @@ -1073,7 +1073,7 @@ proxyResolveName c nm proxiedRelay name directResolveName :: SMPClient -> NetworkRequestMode -> SimplexDomain -> ExceptT SMPClientError IO NameRegistration directResolveName c nm name | v >= namesSMPVersion = - sendProtocolCommand c nm Nothing NoEntity (Cmd SResolver (RSLV (nameQuery v name))) >>= \case + sendProtocolCommand c nm Nothing NoEntity (Cmd SResolver (RSLV (NQDomain name))) >>= \case RNAME reg | resolvedName name reg -> pure reg r -> throwE $ unexpectedResponse r | otherwise = throwE $ PCETransportError TEVersion diff --git a/src/Simplex/Messaging/Names/Record.hs b/src/Simplex/Messaging/Names/Record.hs index c13d7fccc..c9f31f490 100644 --- a/src/Simplex/Messaging/Names/Record.hs +++ b/src/Simplex/Messaging/Names/Record.hs @@ -1,3 +1,6 @@ +{-# LANGUAGE DerivingStrategies #-} +{-# LANGUAGE GeneralizedNewtypeDeriving #-} +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE StrictData #-} @@ -5,13 +8,23 @@ module Simplex.Messaging.Names.Record ( NameRecord (..), + NameRegistration (..), + NamePricing (..), + USDCents (..), + NameReservedReason (..), + oldRegistration, ) where +import Data.Aeson (FromJSON (..), ToJSON (..)) import qualified Data.Aeson as J import qualified Data.Aeson.TH as JQ +import Data.Int (Int64) +import Data.Map.Strict (Map) import Data.Text (Text) -import Simplex.Messaging.Parsers (defaultJSON, dropPrefix) +import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, sumTypeJSON) +import Simplex.Messaging.SystemTime (SystemSeconds) -- | Resolved name record returned by the names role. JSON keys match the -- resolver REST output; both FromJSON (resolver -> server) and ToJSON @@ -44,3 +57,79 @@ $( JQ.deriveJSON defaultJSON {J.omitNothingFields = False, J.fieldLabelModifier = dropPrefix "nr"} ''NameRecord ) + +-- | US cents, rounded up so a quote is never below what is charged. +newtype USDCents = USDCents Int64 + deriving (Eq, Ord, Show) + deriving newtype (ToJSON, FromJSON) + +-- | What the registry holds for a name. +data NameRegistration + = -- | Held by someone. Always carries a record, empty where none was set. + NRRegistered + { -- | absent only from a v20/v21 router, which sent the record alone + expires :: Maybe SystemSeconds, + -- | unix seconds, > expires: until here only the owner may renew + graceUntil :: Maybe SystemSeconds, + -- | held back as well, which is why it will not free up at expiry + reservedReason_ :: Maybe NameReservedReason, + nameRecord :: NameRecord + } + | -- | Held by nobody, and registrable now. + NRAvailable {pricing :: NamePricing} + | -- | Held back by the registry, and not for sale at its price. + NRReserved {reservedReason :: NameReservedReason} + deriving (Eq, Show) + +-- | Enough to price the name locally, which the router cannot do behind a hash. +data NamePricing = NamePricing + { -- | US cents per year, for the lengths the registry prices specially + registrationPrices :: Map Int USDCents, + -- | US cents per year for every other length + basePrice :: USDCents, + -- | characters; the registry refuses shorter labels + minLabelLength :: Int + } + deriving (Eq, Show) + +-- | Why the registry holds a name back. +data NameReservedReason + = -- | held for SimpleX + NRRInternal + | NRRTrademark + | NRRCommunity + | -- | added to the registry after this version, and still reserved + NRRUnknown Text + deriving (Eq, Show) + +instance TextEncoding NameReservedReason where + textEncode = \case + NRRInternal -> "internal" + NRRTrademark -> "trademark" + NRRCommunity -> "community" + NRRUnknown t -> t + textDecode = Just . reservedReasonOf + +-- | A reason this version has no word for keeps its own. +reservedReasonOf :: Text -> NameReservedReason +reservedReasonOf = \case + "internal" -> NRRInternal + "trademark" -> NRRTrademark + "community" -> NRRCommunity + t -> NRRUnknown t + +instance ToJSON NameReservedReason where + toJSON = textToJSON + toEncoding = textToEncoding + +instance FromJSON NameReservedReason where + parseJSON = textParseJSON "NameReservedReason" + +-- | What a v20/v21 router's answer amounts to. +oldRegistration :: NameRecord -> NameRegistration +oldRegistration nameRecord = + NRRegistered {expires = Nothing, graceUntil = Nothing, reservedReason_ = Nothing, nameRecord} + +$(JQ.deriveJSON defaultJSON ''NamePricing) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "NR") ''NameRegistration) diff --git a/src/Simplex/Messaging/Protocol.hs b/src/Simplex/Messaging/Protocol.hs index fbc2ae3d6..c5b6f9c35 100644 --- a/src/Simplex/Messaging/Protocol.hs +++ b/src/Simplex/Messaging/Protocol.hs @@ -274,7 +274,7 @@ import Simplex.Messaging.Agent.Store.DB (Binary (..), FromField (..), ToField (. import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Names.Record (NameRecord (..)) +import Simplex.Messaging.Names.Record import Simplex.Messaging.Parsers import Simplex.Messaging.Protocol.Types import Simplex.Messaging.Server.QueueStore.QueueInfo @@ -1626,105 +1626,6 @@ queryName = \case NQDomain d -> fullDomainName d NQHash tld h -> labelHashText h <> tldSuffix tld --- | US cents, rounded up so a quote is never below what is charged. -newtype USDCents = USDCents Int64 - deriving (Eq, Ord, Show) - deriving newtype (Encoding) - --- | What the registry holds for a name. -data NameRegistration - = -- | Held by someone. Always carries a record, empty where none was set. - NRRegistered - { -- | absent only from a v20/v21 router, which sent the record alone - expires :: Maybe SystemSeconds, - -- | unix seconds, > expires: until here only the owner may renew - graceUntil :: Maybe SystemSeconds, - -- | held back as well, which is why it will not free up at expiry - reservedReason_ :: Maybe NameReservedReason, - nameRecord :: NameRecord - } - | -- | Held by nobody, and registrable now. - NRAvailable {pricing :: NamePricing} - | -- | Held back by the registry, and not for sale at its price. - NRReserved {reservedReason :: NameReservedReason} - deriving (Eq, Show) - -instance Encoding NameRegistration where - smpEncode = \case - NRRegistered {expires, graceUntil, reservedReason_, nameRecord} -> - smpEncode ('N', expires, graceUntil, reservedReason_, ' ', Tail $ LB.toStrict $ J.encode nameRecord) - NRAvailable {pricing} -> smpEncode ('A', pricing) - NRReserved {reservedReason} -> smpEncode ('R', reservedReason) - smpP = - A.anyChar >>= \case - 'N' -> do - (expires, graceUntil, reservedReason_) <- smpP - nameRecord <- J.eitherDecodeStrict . unTail <$?> _smpP - pure NRRegistered {expires, graceUntil, reservedReason_, nameRecord} - 'A' -> NRAvailable <$> smpP - 'R' -> NRReserved <$> smpP - _ -> fail "bad NameRegistration" - --- | Enough to price the name locally, which the router cannot do behind a hash. -data NamePricing = NamePricing - { -- | US cents per year, for the lengths the registry prices specially - registrationPrices :: Map Int USDCents, - -- | US cents per year for every other length - basePrice :: USDCents, - -- | characters; the registry refuses shorter labels - minLabelLength :: Int - } - deriving (Eq, Show) - -instance Encoding NamePricing where - smpEncode NamePricing {registrationPrices, basePrice, minLabelLength} = - smpEncode (EncList $ map tier $ M.toList registrationPrices, basePrice, w16 minLabelLength) - where - tier (len, price) = (w16 len, price) - w16 = fromIntegral :: Int -> Word16 - smpP = do - (EncList tiers, basePrice, minLen) <- smpP - pure NamePricing {registrationPrices = tierMap tiers, basePrice, minLabelLength = fromIntegral (minLen :: Word16)} - where - tierMap :: [(Word16, USDCents)] -> Map Int USDCents - tierMap = M.fromList . map (\(len, price) -> (fromIntegral len, price)) - --- | Why the registry holds a name back. -data NameReservedReason - = -- | held for SimpleX - NRRInternal - | NRRTrademark - | NRRCommunity - | -- | added to the registry after this version, and still reserved - NRRUnknown Text - deriving (Eq, Show) - -instance TextEncoding NameReservedReason where - textEncode = \case - NRRInternal -> "internal" - NRRTrademark -> "trademark" - NRRCommunity -> "community" - NRRUnknown t -> t - textDecode = Just . reservedReasonOf - --- | A reason this version has no word for keeps its own. -reservedReasonOf :: Text -> NameReservedReason -reservedReasonOf = \case - "internal" -> NRRInternal - "trademark" -> NRRTrademark - "community" -> NRRCommunity - t -> NRRUnknown t - -instance Encoding NameReservedReason where - smpEncode = encodeUtf8 . textEncode - smpP = reservedReasonOf . safeDecodeUtf8 <$> A.takeTill (== ' ') - --- | What a v20/v21 router's answer amounts to. -oldRegistration :: NameRecord -> NameRegistration -oldRegistration nameRecord = - NRRegistered {expires = Nothing, graceUntil = Nothing, reservedReason_ = Nothing, nameRecord} - - -- | Name resolution error data NameErrorType = -- | the names role / resolver is not configured on this server @@ -2113,7 +2014,7 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where _ -> err PONG -> e PONG_ RNAME reg - | v >= nameAvailSMPVersion -> e (RNAME_, ' ', reg) + | v >= nameAvailSMPVersion -> e (RNAME_, ' ', Tail $ LB.toStrict $ J.encode reg) | otherwise -> case reg of NRRegistered {nameRecord} -> e (RNAME_, ' ', Tail $ LB.toStrict $ J.encode nameRecord) _ -> e (ERR_, ' ', NAME NOT_FOUND) @@ -2164,7 +2065,7 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where ERR_ -> ERR <$> _smpP PONG_ -> pure PONG RNAME_ - | v >= nameAvailSMPVersion -> RNAME <$> _smpP + | v >= nameAvailSMPVersion -> fmap RNAME . J.eitherDecodeStrict . unTail <$?> _smpP | otherwise -> fmap (RNAME . oldRegistration) . J.eitherDecodeStrict . unTail <$?> _smpP where serviceRespP resp @@ -2559,9 +2460,3 @@ $(J.deriveJSON defaultJSON ''BlockingInfo) -- run deriveJSON in one TH splice to allow mutual instance $(concat <$> mapM @[] (J.deriveJSON (sumTypeJSON id)) [''ProxyError, ''NameErrorType, ''ErrorType]) -instance ToJSON NameReservedReason where - toJSON = textToJSON - toEncoding = textToEncoding - -instance FromJSON NameReservedReason where - parseJSON = textParseJSON "NameReservedReason" diff --git a/src/Simplex/Messaging/Server/Names.hs b/src/Simplex/Messaging/Server/Names.hs index f360789cf..67c4b22bf 100644 --- a/src/Simplex/Messaging/Server/Names.hs +++ b/src/Simplex/Messaging/Server/Names.hs @@ -17,16 +17,12 @@ where import qualified Control.Exception as E import Control.Logger.Simple (logError) -import qualified Data.Map.Strict as M +import Data.Bifunctor (first) import Data.Maybe (fromMaybe) -import Data.Text (Text) import qualified Data.Text as T -import Data.Text.Encoding (encodeUtf8) -import Simplex.Messaging.Encoding.String (strDecode) -import Simplex.Messaging.Protocol (NameErrorType (..), NamePricing (..), NameQuery, NameRecord, NameRegistration (..), NameReservedReason (..), USDCents (..), oldRegistration, queryName) +import Simplex.Messaging.Protocol (NameErrorType (..), NameQuery, NameRegistration, queryName) import Simplex.Messaging.Server.Names.HttpResolver - ( NameStatusResp (..), - ResolverEnv, + ( ResolverEnv, ResolverError (..), RpcAuth (..), closeResolverEnv, @@ -34,7 +30,6 @@ import Simplex.Messaging.Server.Names.HttpResolver newResolverEnv, resolveHttp, ) -import Simplex.Messaging.SystemTime (RoundedSystemTime (..)) import System.Timeout (timeout) data NamesConfig = NamesConfig @@ -75,53 +70,7 @@ resolveName env q = do fetch :: NamesEnv -> NameQuery -> IO (Either NameErrorType NameRegistration) fetch NamesEnv {resolverEnv} q = - either (Left . mapResolverError) nameRegistration <$> resolveHttp resolverEnv (queryName q) - --- | A resolver that reports no status is an older one, which returned a record --- only for a live registration. -nameRegistration :: (Maybe NameRecord, Maybe NameStatusResp) -> Either NameErrorType NameRegistration -nameRegistration = \case - (rec_, Just ns) -> mapStatus rec_ ns - (Just rec, Nothing) -> Right (oldRegistration rec) - (Nothing, Nothing) -> Left NOT_FOUND - --- | The resolver's status words. An unknown status is not an answer. -mapStatus :: Maybe NameRecord -> NameStatusResp -> Either NameErrorType NameRegistration -mapStatus rec_ ns@NameStatusResp {nsStatus, nsExpires, nsGraceEnds, nsReasonCode, nsAuctionUntil} = - case nsStatus of - "registered" -> registered - "grace" -> registered - "unregistered" -> available - "expired" -> available - s -> Left (RESOLVER (T.take 32 s)) - where - reservedReason_ = resolverReason <$> nsReasonCode - -- A registered name always has a record, and a registration this router - -- cannot date is not one it can report. - registered = case (rec_, nsExpires, nsGraceEnds) of - (Just nameRecord, Just expires, Just graceUntil) -> - Right NRRegistered {expires = Just (RoundedSystemTime expires), graceUntil = Just (RoundedSystemTime graceUntil), reservedReason_, nameRecord} - (Nothing, _, _) -> Left (RESOLVER "no record") - _ -> Left (RESOLVER "no expiry") - available = case reservedReason_ of - Just r -> Right (NRReserved r) - Nothing -> case namePricing ns of - Just pricing -> Right NRAvailable {pricing, auctionUntil = RoundedSystemTime <$> nsAuctionUntil} - Nothing -> Left (RESOLVER "no price oracle") - --- | An unknown code still reserves the name, and travels on as itself. Cut to --- one printable token: the wire slot it goes into ends at a space. -resolverReason :: Text -> NameReservedReason -resolverReason t = either (const (NRRUnknown t')) id (strDecode (encodeUtf8 t')) - where - t' = T.take 32 (T.takeWhile (\c -> c > ' ' && c < '\DEL') t) - -namePricing :: NameStatusResp -> Maybe NamePricing -namePricing NameStatusResp {nsRentPrices, nsBasePrice, nsMinLabelLength} = do - rentPrices <- M.map USDCents <$> nsRentPrices - basePrice <- USDCents <$> nsBasePrice - minLabelLength <- nsMinLabelLength - pure NamePricing {rentPrices, basePrice, minLabelLength} + first mapResolverError <$> resolveHttp resolverEnv (queryName q) mapResolverError :: ResolverError -> NameErrorType mapResolverError = \case diff --git a/src/Simplex/Messaging/Server/Names/HttpResolver.hs b/src/Simplex/Messaging/Server/Names/HttpResolver.hs index 69d2c4e65..d079fb081 100644 --- a/src/Simplex/Messaging/Server/Names/HttpResolver.hs +++ b/src/Simplex/Messaging/Server/Names/HttpResolver.hs @@ -10,11 +10,12 @@ -- -- The Python REST resolver (see scripts/resolver/snrc-resolve.py) exposes -- --- GET /resolve/ -> 200 with a NameRecord JSON document --- 404 / 410 for names that do not resolve, the body --- saying why (reserved, lapsed, never registered) --- 400 for unknown TLDs, 502 for upstream RPC failures --- GET /health -> 200 when the resolver process is ready +-- GET /v2/resolve/ -> 200 with a NameRegistration JSON document, for +-- all three registration shapes; 400 for unknown +-- TLDs, 502 for upstream RPC failures +-- GET /v1/resolve/ -> 200 with a NameRecord, what relays before SMP +-- v22 call as /resolve +-- GET /health -> 200 when the resolver process is ready -- -- Boundary properties: -- * Response body read with `brReadSome maxResponseBytes` — adversarial @@ -27,7 +28,6 @@ module Simplex.Messaging.Server.Names.HttpResolver ( RpcAuth (..), ResolverEnv, ResolverError (..), - NameStatusResp (..), newResolverEnv, closeResolverEnv, resolveHttp, @@ -67,7 +67,7 @@ import qualified Network.HTTP.Client as HC import Network.HTTP.Client.TLS (tlsManagerSettings) import qualified Network.HTTP.Types as HT import Network.HTTP.Types.URI (urlEncode) -import Simplex.Messaging.Names.Record (NameRecord) +import Simplex.Messaging.Names.Record (NameRegistration) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix) data RpcAuth = AuthBearer Text | AuthBasic Text Text @@ -86,25 +86,6 @@ data ResolverEnv = ResolverEnv maxResponseBytes :: Int } --- | What the resolver says about a name. Only some statuses carry the fields --- below the status. -data NameStatusResp = NameStatusResp - { nsStatus :: Text, - nsExpires :: Maybe Int64, - nsGraceEnds :: Maybe Int64, - nsReasonCode :: Maybe Text, - -- | when the post-grace surcharge decays to nothing - nsAuctionUntil :: Maybe Int64, - -- | US cents per year, by label length - nsRentPrices :: Maybe (Map Int Int64), - -- | US cents per year for every other length - nsBasePrice :: Maybe Int64, - nsMinLabelLength :: Maybe Int - } - deriving (Show) - -$(JQ.deriveFromJSON defaultJSON {J.fieldLabelModifier = dropPrefix "ns"} ''NameStatusResp) - data ResolverError = HttpFailure HttpException | HttpStatusErr Int @@ -138,31 +119,20 @@ authHeader = \case let encoded = BAE.convertToBase BAE.Base64 (encodeUtf8 u <> ":" <> encodeUtf8 p) :: ByteString in ("Authorization", "Basic " <> encoded) --- | GET /resolve/, returning the record when the --- name resolves and what the resolver says about the name either way. The status --- code cannot tell an unregistered name from a reserved or lapsed one, so on the --- two codes that carry availability the body is read as well. The name is --- percent-encoded (every non-unreserved byte per RFC 3986): the resolver expects --- raw labels, so slashes/punctuation must not alter the path. -resolveHttp :: ResolverEnv -> Text -> IO (Either ResolverError (Maybe NameRecord, Maybe NameStatusResp)) -resolveHttp env name = - (>>= nameResp) <$> httpGet env ("/resolve/" <> B.unpack (urlEncode True (encodeUtf8 name))) +-- | GET /v2/resolve/, which answers with +-- NameRegistration JSON. v1 is /resolve, which answers with a NameRecord and is +-- what relays before SMP v22 call; the resolver API is versioned separately from +-- the protocol, so it only changes when its own shape does. The query is a name, +-- or a bracketed label hash, percent-encoded (every non-unreserved byte per RFC +-- 3986) so slashes and punctuation cannot alter the path. +resolveHttp :: ResolverEnv -> Text -> IO (Either ResolverError NameRegistration) +resolveHttp env q = + (>>= registration) <$> httpGet env ("/v2/resolve/" <> B.unpack (urlEncode True (encodeUtf8 q))) where - nameResp (status, bs) - | status < 400 = (,statusResp bs "status") . Just <$> first InvalidJson (J.eitherDecode bs) - | status == 404 || status == 410 = - maybe (Left $ HttpStatusErr status) (Right . (Nothing,) . Just) (statusResp bs "error") + registration (status, bs) + | status < 400 = first InvalidJson (J.eitherDecode bs) | otherwise = Left (HttpStatusErr status) --- | What the resolver says about the name, under "status" on a 200 and "error" --- on the codes that carry availability. Older resolvers send neither. -statusResp :: BL.ByteString -> Key -> Maybe NameStatusResp -statusResp bs k = case J.decode bs of - Just (J.Object o) -> do - v <- JKM.lookup k o - JT.parseMaybe J.parseJSON (J.Object (JKM.insert "status" v o)) - _ -> Nothing - -- | GET /health; success = reachable with status < 400. The body is -- size-capped but NOT decoded — the probe only checks reachability. healthHttp :: ResolverEnv -> IO (Either ResolverError ()) diff --git a/tests/AgentTests/ResolveNameTests.hs b/tests/AgentTests/ResolveNameTests.hs index dd4e23c59..8ade84e41 100644 --- a/tests/AgentTests/ResolveNameTests.hs +++ b/tests/AgentTests/ResolveNameTests.hs @@ -14,7 +14,6 @@ module AgentTests.ResolveNameTests (resolveNameTests) where import AgentTests.FunctionalAPITests (withAgent) import Control.Monad.Except (runExceptT) -import qualified Data.Aeson as J import qualified Data.ByteString.Lazy as LB import Data.List (isInfixOf) import Network.HTTP.Types (Status, status200, status404, status502) @@ -22,7 +21,7 @@ import NamesResolverServer (memCfg, memCfg2, memProxyCfg, withNames) import qualified NamesResolverServer as NRS import SMPAgentClient import SMPClient -import SMPNamesTests (testNameRecord) +import SMPNamesTests (availableBody, registeredBody, testNameRecord) import Simplex.Messaging.Agent (resolveSimplexName) import Simplex.Messaging.Agent.Client (AgentClient) import Simplex.Messaging.Agent.Env.SQLite (InitialAgentServers (..), ServerCfg, ServerRoles (..), presetServerCfg) @@ -91,17 +90,12 @@ resolveNameTests = do testAvailSuccess :: HasCallStack => IO () testAvailSuccess = - withDirectResolver (status404, availableBody) $ \c -> do + withDirectResolver (status200, availableBody) $ \c -> do r <- runExceptT $ resolveSimplexName c NRMInteractive 1 (SimplexDomain TLDSimplex "alice" []) case r of Right (SMP.NRAvailable {}) -> pure () _ -> expectationFailure $ "expected Right NRAvailable, got: " <> show r --- an unregistered name is only available if the resolver also priced it -availableBody :: LB.ByteString -availableBody = - "{\"error\":\"unregistered\",\"rentPrices\":{\"3\":12793,\"4\":3198},\"basePrice\":100,\"minLabelLength\":3}" - testDirectNotFound :: HasCallStack => IO () testDirectNotFound = withDirectResolver (status404, "{}") $ \c -> do @@ -160,7 +154,7 @@ testBackendError = testDirectSuccess :: HasCallStack => IO () testDirectSuccess = - withDirectResolver (status200, J.encode testNameRecord) $ \c -> do + withDirectResolver (status200, registeredBody testNameRecord) $ \c -> do r <- runExceptT $ resolveSimplexName c NRMInteractive 1 (SimplexDomain TLDSimplex "alice" []) case r of Right (SMP.NRRegistered {nameRecord}) -> nameRecord `shouldBe` testNameRecord diff --git a/tests/NamesResolverServer.hs b/tests/NamesResolverServer.hs index a90595206..054d55e40 100644 --- a/tests/NamesResolverServer.hs +++ b/tests/NamesResolverServer.hs @@ -47,10 +47,12 @@ withResolverServerDelayed delayMs handler action = do let (st, body) = handler (pathInfo req) send $ responseLBS st [(hContentType, "application/json")] body +-- | The resolver API is versioned on its own: v2 answers with NameRegistration +-- JSON, which is the only shape the server asks for. resolveResp :: Status -> LB.ByteString -> [Text] -> (Status, LB.ByteString) resolveResp st body = \case ["health"] -> (ok200, "{}") - ("resolve" : _) -> (st, body) + ("v2" : "resolve" : _) -> (st, body) _ -> (notFound404, "{}") testNamesConfig :: Int -> NamesConfig diff --git a/tests/RSLVTests.hs b/tests/RSLVTests.hs index a199dbb31..9ab6ec857 100644 --- a/tests/RSLVTests.hs +++ b/tests/RSLVTests.hs @@ -12,10 +12,8 @@ module RSLVTests (rslvTests) where import Control.Monad.Trans.Except (ExceptT, runExceptT) -import qualified Data.Aeson as J import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy as LB -import qualified Data.Map.Strict as M import Data.IORef (IORef, readIORef) import Data.List.NonEmpty (NonEmpty (..)) import Data.Text (Text) @@ -28,17 +26,15 @@ import SMPClient import Simplex.Messaging.Client import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String (strDecode) -import SMPNamesTests (testNameRecord) +import SMPNamesTests (availableBody, registeredBody, reservedBody, testNameRecord, testPricing) import Simplex.Messaging.Protocol ( BrokerMsg (..), Cmd (..), Command (..), CorrId (..), ErrorType (..), - NamePricing (..), + NameQuery (..), NameRegistration (..), - NameReservedReason (..), - USDCents (..), NameErrorType (..), NameReservedReason (..), SParty (..), @@ -51,7 +47,6 @@ import Simplex.Messaging.Protocol ) import qualified Simplex.Messaging.Protocol as SMP import Simplex.Messaging.SimplexName (SimplexDomain) -import Simplex.Messaging.SystemTime (RoundedSystemTime (..)) import Simplex.Messaging.Transport import Simplex.Messaging.Version (mkVersionRange) import Test.Hspec hiding (fit, it) @@ -78,7 +73,7 @@ withProxyAndResolver (st, body) runTest = sendRslv :: Transport c => THandleSMP c 'TClient -> B.ByteString -> SimplexDomain -> IO (Transmission (Either ErrorType BrokerMsg)) sendRslv h@THandle {params} corrId d = do - let TransmissionForAuth {tToSend} = encodeTransmissionForAuth params (CorrId corrId, NoEntity, Cmd SResolver (RSLV (SMP.nameQuery currentClientSMPRelayVersion d))) + let TransmissionForAuth {tToSend} = encodeTransmissionForAuth params (CorrId corrId, NoEntity, Cmd SResolver (RSLV (NQDomain d))) [Right ()] <- tPut h (Right (Nothing, tToSend) :| []) r :| _ <- tGetClient h pure r @@ -98,15 +93,14 @@ rslvTests = do it "returns RNAME with NameRecord" testRslvSuccess describe "RSLV availability (RNAME response)" $ do it "unregistered comes back AVAILABLE" testRslvAvailable - it "auction comes back with premium" testRslvAuction it "reserved comes back with the reason" testRslvReserved - it "PFWD-wrapped auction reaches the resolver" testRslvForwardedAuction + it "PFWD-wrapped availability reaches the resolver" testRslvForwardedAvailable describe "RSLV below v22" $ do it "still resolves a name to its record" testRslvOldClientRecord it "still answers NAME NOT_FOUND for a name that does not resolve" testRslvOldClientNotFound describe "hashed lookups" $ do it "RSLV sends the 2LD as its hash" testRslvSendsTheHash - it "subname labels stay text" testSubnameKeepsItsLabels + it "a name with subnames is sent as text" testSubnameKeepsItsLabels it "a record naming a different name is rejected" testRslvWrongName testRslvBackendNotFound :: IO () @@ -140,7 +134,7 @@ testRslvDisabled = testRslvVersion :: IO () testRslvVersion = - withResolverServer (status200, J.encode testNameRecord) $ do + withResolverServer (status200, registeredBody testNameRecord) $ do g <- C.newRandom ts <- getCurrentTime let srv = SMPServer testHost testPort testKeyHash @@ -173,14 +167,14 @@ testRslvForwarded = testRslvForwardedSuccess :: IO () testRslvForwardedSuccess = - withProxyAndResolver (status200, J.encode testNameRecord) $ + withProxyAndResolver (status200, registeredBody testNameRecord) $ forwardedResolveAlice >>= \r -> case r of Right (Right NRRegistered {nameRecord}) -> nameRecord `shouldBe` testNameRecord _ -> expectationFailure $ "expected Right (Right NRRegistered), got: " <> show r testRslvSuccess :: IO () testRslvSuccess = - withResolverServer (status200, J.encode testNameRecord) $ + withResolverServer (status200, registeredBody testNameRecord) $ testSMPClient @TLS $ \h -> do (corrId, _entId, resp) <- sendRslv h "rs07" (domain "alice.simplex") corrId `shouldBe` CorrId "rs07" @@ -190,22 +184,15 @@ testRslvSuccess = testRslvAvailable :: IO () testRslvAvailable = - withResolverServer (status404, availableBody) $ + withResolverServer (status200, availableBody) $ testSMPClient @TLS $ \h -> do (corrId, _entId, resp) <- sendRslv h "na01" (domain "ghost.simplex") corrId `shouldBe` CorrId "na01" - resp `shouldBe` Right (RNAME (NRAvailable auctionPricing Nothing)) - -testRslvAuction :: IO () -testRslvAuction = - withResolverServer (status410, auctionBody) $ - testSMPClient @TLS $ \h -> do - (_, _, resp) <- sendRslv h "na02" (domain "lapsed.simplex") - resp `shouldBe` Right (RNAME (NRAvailable auctionPricing (Just (RoundedSystemTime 1790294400)))) + resp `shouldBe` Right (RNAME (NRAvailable testPricing)) testRslvReserved :: IO () testRslvReserved = - withResolverServer (status404, "{\"error\":\"unregistered\",\"reasonCode\":\"trademark\"}") $ + withResolverServer (status200, reservedBody) $ testSMPClient @TLS $ \h -> do (_, _, resp) <- sendRslv h "na03" (domain "acme.simplex") resp `shouldBe` Right (RNAME (NRReserved NRRTrademark)) @@ -225,46 +212,27 @@ oldClient = do testRslvOldClientRecord :: IO () testRslvOldClientRecord = - withResolverServer (status200, J.encode testNameRecord) $ do + withResolverServer (status200, registeredBody testNameRecord) $ do pc <- oldClient r <- runExceptT' (directResolveName pc NRMInteractive (domain "alice.simplex")) r `shouldBe` NRRegistered Nothing Nothing Nothing testNameRecord testRslvOldClientNotFound :: IO () testRslvOldClientNotFound = - withResolverServer (status404, availableBody) $ do + withResolverServer (status200, availableBody) $ do pc <- oldClient r <- runExceptT (directResolveName pc NRMInteractive (domain "alice.simplex")) case r of Left (PCEProtocolError (SMP.NAME SMP.NOT_FOUND)) -> pure () _ -> expectationFailure $ "expected Left (PCEProtocolError (NAME NOT_FOUND)), got: " <> show r -testRslvForwardedAuction :: IO () -testRslvForwardedAuction = - withProxyAndResolver (status410, auctionBody) $ +testRslvForwardedAvailable :: IO () +testRslvForwardedAvailable = + withProxyAndResolver (status200, availableBody) $ forwardedResolveAlice >>= \r -> case r of - Right (Right (NRAvailable _ auctionUntil)) -> auctionUntil `shouldBe` Just (RoundedSystemTime 1790294400) + Right (Right (NRAvailable pricing)) -> pricing `shouldBe` testPricing _ -> expectationFailure $ "expected Right (Right NRAvailable), got: " <> show r -pricingJson :: LB.ByteString -pricingJson = "\"rentPrices\":{\"3\":12793,\"4\":3198},\"basePrice\":100,\"minLabelLength\":3" - -availableBody :: LB.ByteString -availableBody = "{\"error\":\"unregistered\"," <> pricingJson <> "}" - --- a name past its grace period, still inside the window where it costs a --- surcharge above the ordinary price -auctionBody :: LB.ByteString -auctionBody = "{\"error\":\"expired\",\"auctionUntil\":1790294400," <> pricingJson <> "}" - -auctionPricing :: NamePricing -auctionPricing = - NamePricing - { rentPrices = M.fromList [(3, USDCents 12793), (4, USDCents 3198)], - basePrice = USDCents 100, - minLabelLength = 3 - } - -- keccak-256("alice"), the registry key aliceHash :: Text aliceHash = "[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501]" @@ -273,7 +241,7 @@ aliceHash = "[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501]" resolvePaths :: IORef [[Text]] -> IO [[Text]] resolvePaths reqs = filter isResolve <$> readIORef reqs where - isResolve = \case ("resolve" : _) -> True; _ -> False + isResolve = \case ("v2" : "resolve" : _) -> True; _ -> False currentClient :: IO SMPClient currentClient = do @@ -285,10 +253,10 @@ currentClient = do testRslvSendsTheHash :: IO () testRslvSendsTheHash = - withResolverServerReqs (status200, J.encode testNameRecord) $ \reqs -> do + withResolverServerReqs (status200, registeredBody testNameRecord) $ \reqs -> do pc <- currentClient r <- runExceptT' (directResolveName pc NRMInteractive (domain "alice.simplex")) - resolvePaths reqs `shouldReturn` [["resolve", aliceHash <> ".simplex"]] + resolvePaths reqs `shouldReturn` [["v2", "resolve", aliceHash <> ".simplex"]] -- the client never sent the name, and the record still names it case r of NRRegistered {nameRecord} -> SMP.nrName nameRecord `shouldBe` "alice.simplex" @@ -296,16 +264,16 @@ testRslvSendsTheHash = testSubnameKeepsItsLabels :: IO () testSubnameKeepsItsLabels = - withResolverServerReqs (status404, availableBody) $ \reqs -> do + withResolverServerReqs (status200, availableBody) $ \reqs -> do pc <- currentClient _ <- runExceptT' (directResolveName pc NRMInteractive (domain "x.alice.simplex")) - resolvePaths reqs `shouldReturn` [["resolve", "x." <> aliceHash <> ".simplex"]] + resolvePaths reqs `shouldReturn` [["v2", "resolve", "x.alice.simplex"]] -- a hashed query does not tell the router the name, so the record's own name is -- checked against the one that was asked for testRslvWrongName :: IO () testRslvWrongName = - withResolverServer (status200, J.encode testNameRecord {SMP.nrName = "mallory.simplex"}) $ do + withResolverServer (status200, registeredBody testNameRecord {SMP.nrName = "mallory.simplex"}) $ do pc <- currentClient r <- runExceptT (directResolveName pc NRMInteractive (domain "alice.simplex")) case r of diff --git a/tests/SMPNamesTests.hs b/tests/SMPNamesTests.hs index 8c1795cfd..44d0118e7 100644 --- a/tests/SMPNamesTests.hs +++ b/tests/SMPNamesTests.hs @@ -3,7 +3,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} -module SMPNamesTests (smpNamesTests, testNameRecord) where +module SMPNamesTests (smpNamesTests, testNameRecord, testPricing, registeredBody, availableBody, reservedBody) where import qualified Data.Aeson as J import qualified Data.ByteString.Char8 as B @@ -18,7 +18,7 @@ import Network.HTTP.Types (status200, status400, status404, status410, status500 import NamesResolverServer (resolveResp, testNamesConfig, withResolverServer, withResolverServerDelayed) import Simplex.Messaging.Encoding (smpDecode, smpEncode) import Simplex.Messaging.Encoding.String (strDecode, strEncode) -import Simplex.Messaging.Protocol (Command (..), ErrorType (..), NameErrorType (..), NamePricing (..), NameRecord (..), NameRegistration (..), NameReservedReason (..), ProtocolEncoding (..), USDCents (..), nameQuery, queryName) +import Simplex.Messaging.Protocol (Command (..), ErrorType (..), NameErrorType (..), NamePricing (..), NameQuery (..), NameRecord (..), NameRegistration (..), NameReservedReason (..), ProtocolEncoding (..), USDCents (..)) import Simplex.Messaging.Server.Main (validateUrl) import Simplex.Messaging.Server.Names ( NamesConfig (..), @@ -28,9 +28,9 @@ import Simplex.Messaging.Server.Names resolveName, ) import Simplex.Messaging.Server.Names.HttpResolver (ResolverError (..)) -import Simplex.Messaging.SimplexName (SimplexDomain (..), SimplexTLD (..), fullDomainName) +import Simplex.Messaging.SimplexName (SimplexDomain (..), SimplexTLD (..), fullDomainName, labelHash) import Simplex.Messaging.SystemTime (RoundedSystemTime (..)) -import Simplex.Messaging.Transport (currentClientSMPRelayVersion, nameAvailSMPVersion, namesSMPVersion, serverInfoSMPVersion) +import Simplex.Messaging.Transport (nameAvailSMPVersion, serverInfoSMPVersion) import Test.Hspec testNameRecord :: NameRecord @@ -50,6 +50,23 @@ testNameRecord = nrResolver = "0x0202020202020202020202020202020202020202" } +-- | What the resolver serves on /v2/resolve. Spelled out rather than encoded +-- from the Haskell value: the literal JSON is the contract with the resolver. +registeredBody :: NameRecord -> LB.ByteString +registeredBody nameRec = + "{\"type\":\"registered\",\"expires\":1813853483,\"graceUntil\":1821629483,\"reservedReason_\":null,\"nameRecord\":" <> J.encode nameRec <> "}" + +availableBody :: LB.ByteString +availableBody = "{\"type\":\"available\",\"pricing\":{\"registrationPrices\":{\"3\":12793,\"4\":3198},\"basePrice\":100,\"minLabelLength\":3}}" + +reservedBody :: LB.ByteString +reservedBody = "{\"type\":\"reserved\",\"reservedReason\":\"trademark\"}" + +-- | What `registeredBody testNameRecord` resolves to. +registeredAlice :: NameRegistration +registeredAlice = + NRRegistered {expires = Just (RoundedSystemTime 1813853483), graceUntil = Just (RoundedSystemTime 1821629483), reservedReason_ = Nothing, nameRecord = testNameRecord} + smpNamesTests :: Spec smpNamesTests = do describe "NameRecord JSON (Protocol)" nameRecordEncodingSpec @@ -110,136 +127,82 @@ errorWireSpec = rslvWireSpec :: Spec rslvWireSpec = do it "below v22 carries the name, as it did before" $ - encodeProtocol v20 (RSLV (nameQuery v20 aliceDomain')) `shouldBe` "RSLV " <> smpEncode aliceDomain' - it "from v22 carries the query" $ - encodeProtocol v22 (RSLV (nameQuery v22 aliceDomain')) `shouldBe` "RSLV " <> smpEncode (nameQuery v22 aliceDomain') + encodeProtocol v20 (RSLV (NQDomain aliceDomain')) `shouldBe` "RSLV " <> smpEncode aliceDomain' + -- keccak-256("alice"), the same constant the resolver's own tests use + it "from v22 carries the 2LD as its hash" $ + encodeProtocol v22 (RSLV (NQDomain aliceDomain')) + `shouldBe` "RSLV [9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501].simplex" + -- the hashed query has no room for subname labels, so such a name goes as text + it "a name with subnames is not hashed" $ + encodeProtocol v22 (RSLV (NQDomain aliceDomain' {subDomain = ["x"]})) `shouldBe` "RSLV x.alice.simplex" + it "leaves a web name alone: no registry, nothing to key on" $ + encodeProtocol v22 (RSLV (NQDomain webDomain')) `shouldBe` "RSLV example.com" where v20 = serverInfoSMPVersion v22 = nameAvailSMPVersion aliceDomain' = SimplexDomain {nameTLD = TLDSimplex, domain = "alice", subDomain = []} + webDomain' = SimplexDomain {nameTLD = TLDWeb, domain = "example.com", subDomain = []} availabilitySpec :: Spec availabilitySpec = do -- one lookup answers what the name points to, whether it can be taken, and -- whether it is held back it "a registered name answers with its record and dates" $ - answers status200 (recordWith "\"status\":\"registered\",\"expires\":1813853483,\"graceEnds\":1821629483") $ - NRRegistered {expires = Just (RoundedSystemTime 1813853483), graceUntil = Just (RoundedSystemTime 1821629483), reservedReason_ = Nothing, nameRecord = testNameRecord} - it "a name in grace keeps its record" $ - answers status200 (recordWith "\"status\":\"grace\",\"expires\":1785000000,\"graceEnds\":1792776000") $ - NRRegistered {expires = Just (RoundedSystemTime 1785000000), graceUntil = Just (RoundedSystemTime 1792776000), reservedReason_ = Nothing, nameRecord = testNameRecord} + answers (registeredBody testNameRecord) registeredAlice it "a registered name can be held back too" $ - answers status200 (recordWith "\"status\":\"registered\",\"expires\":1813853483,\"graceEnds\":1821629483,\"reasonCode\":\"internal\"") $ + answers heldBackBody $ NRRegistered {expires = Just (RoundedSystemTime 1813853483), graceUntil = Just (RoundedSystemTime 1821629483), reservedReason_ = Just NRRInternal, nameRecord = testNameRecord} - it "a resolver that sends no status still answers with the record" $ - answers status200 (J.encode testNameRecord) $ - NRRegistered {expires = Nothing, graceUntil = Nothing, reservedReason_ = Nothing, nameRecord = testNameRecord} it "an unregistered name answers with the price" $ - answers status404 (jsonBody ("{\"error\":\"unregistered\"," <> pricingJson <> "}")) $ - NRAvailable {pricing = testPricing, auctionUntil = Nothing} - it "expired is available, counting down to the ordinary price" $ - answers status410 (jsonBody ("{\"error\":\"expired\",\"auctionUntil\":1790294400," <> pricingJson <> "}")) $ - NRAvailable {pricing = testPricing, auctionUntil = Just (RoundedSystemTime 1790294400)} + answers availableBody NRAvailable {pricing = testPricing} it "reserved carries the reason and no price" $ - answers status404 (jsonBody ("{\"error\":\"unregistered\",\"reasonCode\":\"trademark\"," <> pricingJson <> "}")) $ - NRReserved NRRTrademark + answers reservedBody (NRReserved NRRTrademark) -- losing the reservation would offer a name that cannot be registered it "a reason from a later version still reserves the name" $ - answers status404 "{\"error\":\"unregistered\",\"reasonCode\":\"seasonal\"}" (NRReserved (NRRUnknown "seasonal")) - -- the reason re-encodes into a slot that ends at a space, so it is cut to one - -- token - it "a reason with a space is cut at the space" $ - answers status404 "{\"error\":\"unregistered\",\"reasonCode\":\"two words\"}" (NRReserved (NRRUnknown "two")) - it "an over-long reason is truncated" $ - answers status404 (jsonBody ("{\"error\":\"unregistered\",\"reasonCode\":\"" <> replicate 100 'z' <> "\"}")) $ - NRReserved (NRRUnknown (T.replicate 32 "z")) - -- a name that cannot be dated or priced is not one this router reports on - it "a registration without expiry is a resolver error" $ - refuses status200 (recordWith "\"status\":\"registered\"") (RESOLVER "no expiry") - it "a registered name without a record is a resolver error" $ - refuses status404 "{\"error\":\"registered\",\"expires\":1813853483,\"graceEnds\":1821629483}" (RESOLVER "no record") - it "no price oracle is a resolver error" $ - refuses status404 "{\"error\":\"unregistered\"}" (RESOLVER "no price oracle") - -- only 404 and 410 carry availability, so only their bodies are read as a - -- status - it "upstream failure is a resolver error" $ - refuses status502 "{\"error\":\"upstreamError\"}" (RESOLVER "HTTP 502") - it "unconfigured TLD is not found" $ - refuses status400 "{\"error\":\"tldNotConfigured\"}" NOT_FOUND - it "unreadable status is a resolver error" $ - refuses status404 "{\"error\":\"unknown\"}" (RESOLVER "unknown") - it "long status is truncated" $ - refuses status404 (jsonBody ("{\"error\":\"" <> replicate 400 'e' <> "\"}")) (RESOLVER (T.replicate 32 "e")) - -- NOT_FOUND says the router has nothing to say, never that the name is - -- registrable - it "unreadable 404 body stays NOT_FOUND" $ - refuses status404 "gateway" NOT_FOUND - it "over-cap body is a resolver error" $ - withResolverServer (resolveResp status200 (jsonBody ("{\"status\":\"registered\",\"pad\":\"" <> replicate 400 'x' <> "\"}"))) $ \port _ -> do - env <- newNamesEnv (testNamesConfig port) {resolverMaxResponseBytes = 200} - resolveName env navlDomain `shouldReturn` Left (RESOLVER "response too large") + answers "{\"type\":\"reserved\",\"reservedReason\":\"seasonal\"}" (NRReserved (NRRUnknown "seasonal")) + -- RNAME carries the registration as JSON, so that is the encoding to hold it "every registration survives the wire" $ mapM_ - (\a -> smpDecode (smpEncode a) `shouldBe` Right a) - [ NRRegistered {expires = Just (RoundedSystemTime 1813853483), graceUntil = Just (RoundedSystemTime 1821629483), reservedReason_ = Nothing, nameRecord = testNameRecord}, + (\a -> J.eitherDecodeStrict (LB.toStrict (J.encode a)) `shouldBe` Right a) + [ registeredAlice, NRRegistered {expires = Nothing, graceUntil = Nothing, reservedReason_ = Just NRRInternal, nameRecord = testNameRecord}, - NRAvailable {pricing = testPricing, auctionUntil = Nothing}, - NRAvailable {pricing = testPricing, auctionUntil = Just (RoundedSystemTime 1790294400)}, + NRAvailable {pricing = testPricing}, NRReserved NRRInternal, NRReserved NRRTrademark, NRReserved NRRCommunity, NRReserved (NRRUnknown "seasonal") ] - -- one vocabulary: the same word on the wire, from the resolver, and in JSON - it "a reason reads the same in JSON as on the wire" $ do + -- one vocabulary: the same word from the resolver and in JSON + it "a reason reads the same in JSON as from the resolver" $ do J.encode (NRRUnknown "seasonal") `shouldBe` "\"seasonal\"" J.encode NRRTrademark `shouldBe` "\"trademark\"" where - jsonBody = LB.fromStrict . B.pack - -- the resolver returns the record and the registration status in one body - recordWith extra = LB.init (J.encode testNameRecord) <> "," <> extra <> "}" - answers st body a = resolverSays st body (Right a) - refuses st body e = resolverSays st body (Left e) - resolverSays st body expected = - withResolverServer (resolveResp st body) $ \port _ -> do + heldBackBody = + "{\"type\":\"registered\",\"expires\":1813853483,\"graceUntil\":1821629483,\"reservedReason_\":\"internal\",\"nameRecord\":" <> J.encode testNameRecord <> "}" + answers body a = + withResolverServer (resolveResp status200 body) $ \port _ -> do env <- newNamesEnv (testNamesConfig port) - resolveName env navlDomain `shouldReturn` expected - navlDomain = nameQuery namesSMPVersion SimplexDomain {nameTLD = TLDSimplex, domain = "alice", subDomain = []} + resolveName env aliceQuery `shouldReturn` Right a + aliceQuery = NQDomain SimplexDomain {nameTLD = TLDSimplex, domain = "alice", subDomain = []} -- | The .testing oracle: US cents per year by label length. testPricing :: NamePricing testPricing = NamePricing - { rentPrices = M.fromList [(3, USDCents 12793), (4, USDCents 3198)], + { registrationPrices = M.fromList [(3, USDCents 12793), (4, USDCents 3198)], basePrice = USDCents 100, minLabelLength = 3 } -pricingJson :: String -pricingJson = "\"rentPrices\":{\"3\":12793,\"4\":3198},\"basePrice\":100,\"minLabelLength\":3" - parseNameSpec :: Spec parseNameSpec = do -- the hashed form is a query, not a name: it has its own type it "a name is never a hash" $ parseN ("[" <> T.replicate 64 "b" <> "].simplex") `shouldSatisfy` isLeft - -- keccak-256("alice"), the same constant the resolver's own tests use - it "hashes the 2LD to the registry key" $ - (queryName . nameQuery currentClientSMPRelayVersion <$> parseN "alice.simplex") - `shouldBe` Right "[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501].simplex" - it "leaves subname labels as text" $ - (queryName . nameQuery currentClientSMPRelayVersion <$> parseN "x.alice.simplex") - `shouldBe` Right "x.[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501].simplex" - it "leaves a web name alone: no registry, nothing to key on" $ - (queryName . nameQuery currentClientSMPRelayVersion <$> parseN "example.com") `shouldBe` Right "example.com" - -- below v22 a router can only read the name - it "sends the name itself below v22" $ - (queryName . nameQuery namesSMPVersion <$> parseN "alice.simplex") `shouldBe` Right "alice.simplex" it "a query survives the wire" $ mapM_ (\q -> smpDecode (smpEncode q) `shouldBe` Right q) - [ nameQuery currentClientSMPRelayVersion d, - nameQuery namesSMPVersion d + [ NQDomain d, + NQHash TLDSimplex (labelHash "alice") ] it "accepts a valid simplex-TLD name" $ case parseN "privacy.simplex" of @@ -280,10 +243,10 @@ parseNameSpec = do resolverSpec :: Spec resolverSpec = do - it "returns NameRecord on 200 OK" $ - withResolverServer (resolveResp status200 (J.encode testNameRecord)) $ \port _ -> do + it "returns the registration on 200 OK" $ + withResolverServer (resolveResp status200 (registeredBody testNameRecord)) $ \port _ -> do env <- newNamesEnv (testNamesConfig port) - resolveName env aliceDomain `shouldReturn` Right (NRRegistered Nothing Nothing Nothing testNameRecord) + resolveName env aliceDomain `shouldReturn` Right registeredAlice it "returns NOT_FOUND on 404" $ withResolverServer (resolveResp status404 "{}") $ \port _ -> do @@ -315,31 +278,31 @@ resolverSpec = do env <- newNamesEnv (testNamesConfig port) resolveName env aliceDomain `shouldReturn` Left (RESOLVER "invalid response") - it "returns RESOLVER when JSON parses but isn't a NameRecord shape" $ + it "returns RESOLVER when JSON parses but isn't a NameRegistration shape" $ withResolverServer (resolveResp status200 "{}") $ \port _ -> do env <- newNamesEnv (testNamesConfig port) resolveName env aliceDomain `shouldReturn` Left (RESOLVER "invalid response") it "returns RESOLVER (timeout) when the resolver is slower than resolverTimeoutMs" $ - withResolverServerDelayed 1500 (resolveResp status200 (J.encode testNameRecord)) $ \port _ -> do + withResolverServerDelayed 1500 (resolveResp status200 (registeredBody testNameRecord)) $ \port _ -> do env <- newNamesEnv (testNamesConfig port) {resolverTimeoutMs = 300} resolveName env aliceDomain `shouldReturn` Left (RESOLVER "timeout") it "sends one HTTP request per lookup (no cache)" $ - withResolverServer (resolveResp status200 (J.encode testNameRecord)) $ \port reqs -> do + withResolverServer (resolveResp status200 (registeredBody testNameRecord)) $ \port reqs -> do env <- newNamesEnv (testNamesConfig port) _ <- resolveName env aliceDomain _ <- resolveName env aliceDomain readIORef reqs >>= \rs -> length rs `shouldBe` 2 it "addresses the resolver with the full canonical domain name" $ - withResolverServer (resolveResp status200 (J.encode testNameRecord)) $ \port reqs -> do + withResolverServer (resolveResp status200 (registeredBody testNameRecord)) $ \port reqs -> do env <- newNamesEnv (testNamesConfig port) _ <- resolveName env aliceDomain - readIORef reqs `shouldReturn` [["resolve", "alice.simplex"]] + readIORef reqs `shouldReturn` [["v2", "resolve", "alice.simplex"]] where - aliceDomain = nameQuery namesSMPVersion SimplexDomain {nameTLD = TLDSimplex, domain = "alice", subDomain = []} + aliceDomain = NQDomain SimplexDomain {nameTLD = TLDSimplex, domain = "alice", subDomain = []} healthSpec :: Spec healthSpec = do