diff --git a/protocol/simplex-messaging.md b/protocol/simplex-messaging.md index dbbfd6000..577e31acb 100644 --- a/protocol/simplex-messaging.md +++ b/protocol/simplex-messaging.md @@ -1560,7 +1560,8 @@ seconds since the Unix epoch. Lengths are characters. | `minLabelLength` | number | characters; the registry refuses shorter labels | A reason word is `internal`, `trademark`, `community`, or a word a later version -reserves under. The words are the same on the wire, in the backing resolver's +reserves under, at most 32 printable ASCII characters. A router truncates an +unknown word to that, since it reaches the client as a word. The words are the same on the wire, in the backing resolver's JSON and in a client's own API. `available` alone means registrable: a name the registry holds back answers diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index b2394fc12..6436e8004 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -182,6 +182,11 @@ every query. **Set `SNRC_CONTROLLER_` wherever `SNRC_REGISTRAR_` is.** Without a controller there is no oracle, so no name can be priced. +**Upgrade this service before the routers that query it.** Routers from SMP v22 +call `/v2/resolve`, which an older resolver does not serve. Every name then +answers `ERR NAME RESOLVER "HTTP 404"` until this service is upgraded, while +`/health` still reports it as ready. + ### Why a name is reserved A held-back name carries `reasonCode`, the controller's reason, and `reason`, an diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index aef8baf88..a409aaa72 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -272,7 +272,7 @@ def read_oracle_prices(controller: str, oracle: str): min_len = decode_uint(eth_call(controller, selector("minCharLength()"))) return { # lengths the registry refuses are left out rather than priced at zero - "rentPrices": {n: c for n, c in tiers.items() if n >= min_len}, + "registrationPrices": {n: c for n, c in tiers.items() if n >= min_len}, "basePrice": base, "minLabelLength": min_len, "_premiumUnknown": premium_unknown, @@ -388,22 +388,23 @@ def decode_bytes(hex_data: str) -> bytes: return raw[64:64 + length] -def registered_label(registrar: str, token: int) -> str: +def registered_label(registrar: str, token: int): """The plaintext label the registrar recorded at registration, keyed by the - hash of that label. A name registered without registerWithLabel has none, - and answers "unknown" instead.""" + hash of that label. None when the name was registered without + registerWithLabel, so the registrar cannot name it.""" raw = decode_bytes(eth_call(registrar, selector("labelOf(uint256)") + encode_uint(token))) - return raw.decode("utf-8", errors="replace") if raw else "unknown" + return raw.decode("utf-8", errors="replace") if raw else None -def canonical_name(name: str) -> str: +def canonical_name(name: str): """The name to answer with: a hashed query does not carry one, so the - registrar's record of the label fills it in.""" + registrar's record of the label fills it in. None when it recorded none.""" labels = name.split(".") registrar = REGISTRARS.get(labels[-1]) if not registrar or len(labels) != 2 or not is_encoded_labelhash(labels[0]): return name - return registered_label(registrar, label_token(labels[0])) + "." + labels[1] + label = registered_label(registrar, label_token(labels[0])) + return label + "." + labels[1] if label else None def label_token(label: str) -> int: @@ -738,12 +739,17 @@ def registration(name: str): reg = name_status(name) status = reg["status"] if status in ("registered", "grace"): + rec = name_record(name) + # the client checks that the record names what it asked about, so a + # hashed query the registrar cannot name is refused rather than answered + if rec["name"] is None: + return 502, {"name": name, "error": "labelNotRecorded"} return 200, { "type": "registered", "expires": reg["expires"], "graceUntil": reg["graceEnds"], "reservedReason": reg["reasonCode"], - "nameRecord": name_record(name), + "nameRecord": rec, } if reg["reasonCode"]: return 200, {"type": "reserved", "reservedReason": reg["reasonCode"]} @@ -753,7 +759,7 @@ def registration(name: str): return 200, { "type": "available", "pricing": { - "registrationPrices": reg["rentPrices"], + "registrationPrices": reg["registrationPrices"], "basePrice": reg["basePrice"], "minLabelLength": reg["minLabelLength"], }, @@ -800,7 +806,7 @@ def resolve(name: str): # answerable. owner = decode_address(eth_call(registry, selector("owner(bytes32)") + node_hex)) return 200, { - "name": canonical_name(name), + "name": canonical_name(name) or name, "nickname": "", "website": "", "location": "", @@ -837,7 +843,7 @@ def resolve(name: str): # use the ENSIP-5 dot convention (e.g. "simplex.contact") — only the # resolver's JSON surface camelCases them. return 200, { - "name": canonical_name(name), + "name": canonical_name(name) or name, "nickname": nickname, "website": texts.get("url", ""), "location": texts.get("location", ""), diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index 7a5111785..fbc86d56f 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -606,7 +606,7 @@ class PricingTests(unittest.TestCase): snrc.eth_call = self._chain(self._lapsed(0)) reg = snrc.name_status("acme.testing") # 1 and 2 are below minCharLength - self.assertEqual(reg["rentPrices"], {3: 1600, 4: 800, 5: 500}) + self.assertEqual(reg["registrationPrices"], {3: 1600, 4: 800, 5: 500}) self.assertEqual(reg["basePrice"], self.BASE) self.assertEqual(reg["minLabelLength"], self.MIN_LENGTH) @@ -856,12 +856,22 @@ class RegistrationV2Tests(unittest.TestCase): words += [snrc.encode_uint(length), snrc.encode_uint(cents)] return "0x" + "".join(words) - def _chain(self, expires, reserved=0, oracle=None): + @staticmethod + def _abi_bytes(value: bytes) -> str: + """head offset, length, then the payload padded to a 32-byte word.""" + pad = (-len(value)) % 32 + return ("0x" + snrc.encode_uint(0x20) + snrc.encode_uint(len(value)) + + (value + b"\x00" * pad).hex()) + + def _chain(self, expires, reserved=0, oracle=None, label=b"acme"): """The registry answers a zero resolver, so name_record returns the - empty record a registered name still has.""" + empty record a registered name still has. `label` is what the registrar + recorded for the 2LD; b"" means it recorded none.""" oracle = self.ORACLE if oracle is None else oracle def eth_call(to, data): + if data.startswith(snrc.selector("labelOf(uint256)")): + return self._abi_bytes(label) if data.startswith(snrc.selector("nameExpires(uint256)")): return "0x" + snrc.encode_uint(expires) if data.startswith(snrc.selector("GRACE_PERIOD()")): @@ -976,6 +986,21 @@ class RegistrationV2Tests(unittest.TestCase): _, body = snrc.registration("acme.testing") self.assertEqual(body["type"], expected_type) self.assertEqual(set(body), keys) + def test_a_hashed_query_the_registrar_cannot_name_is_refused(self): + """The client checks the record names what it asked about, so answering + with a record the registrar could not name would only fail there.""" + hashed = "[e29dae06ef4c3e336b7538b6d4f52ca1ecec009b1df6fb501320e11b223aeeaf]" + snrc.eth_call = self._chain(self.now + 3600, label=b"") + status, body = snrc.registration(hashed + ".testing") + self.assertEqual(status, 502) + self.assertEqual(body["error"], "labelNotRecorded") + + def test_a_hashed_query_is_answered_with_the_name_the_registrar_recorded(self): + hashed = "[e29dae06ef4c3e336b7538b6d4f52ca1ecec009b1df6fb501320e11b223aeeaf]" + snrc.eth_call = self._chain(self.now + 3600) + status, body = snrc.registration(hashed + ".testing") + self.assertEqual(status, 200) + self.assertEqual(body["nameRecord"]["name"], "acme.testing") if __name__ == "__main__": unittest.main() diff --git a/src/Simplex/Messaging/Names/Record.hs b/src/Simplex/Messaging/Names/Record.hs index 499038365..4ca7e92f1 100644 --- a/src/Simplex/Messaging/Names/Record.hs +++ b/src/Simplex/Messaging/Names/Record.hs @@ -22,6 +22,7 @@ import qualified Data.Aeson.TH as JQ import Data.Int (Int64) import Data.Map.Strict (Map) import Data.Text (Text) +import qualified Data.Text as T import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, taggedObjectJSON) import Simplex.Messaging.SystemTime (SystemSeconds) @@ -110,13 +111,13 @@ instance TextEncoding NameReservedReason where NRRUnknown t -> t textDecode = Just . reservedReasonOf --- | An unknown reason is kept as text. +-- | An unknown reason is kept as text, capped: it reaches a client as a word. reservedReasonOf :: Text -> NameReservedReason reservedReasonOf = \case "internal" -> NRRInternal "trademark" -> NRRTrademark "community" -> NRRCommunity - t -> NRRUnknown t + t -> NRRUnknown $ T.take 32 $ T.takeWhile (\c -> c > ' ' && c < '\DEL') t instance ToJSON NameReservedReason where toJSON = textToJSON diff --git a/src/Simplex/Messaging/Server.hs b/src/Simplex/Messaging/Server.hs index fd461dfb3..239e52fb3 100644 --- a/src/Simplex/Messaging/Server.hs +++ b/src/Simplex/Messaging/Server.hs @@ -104,7 +104,6 @@ import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Protocol -import Simplex.Messaging.SimplexName (SimplexDomain) import Simplex.Messaging.Server.Control import Simplex.Messaging.Server.Env.STM as Env import Simplex.Messaging.Server.Expiration @@ -1495,14 +1494,18 @@ client -- Runs on a forked thread so RSLV does not block other commands; -- concurrency is limited by serverResolverConcurrency in forkCmd. resolveNameMsg :: NamesEnv -> NameQuery -> M s BrokerMsg - resolveNameMsg nenv d = do + resolveNameMsg nenv q = do st <- asks (rslvStats . serverStats) (selector, msg) <- - liftIO (resolveName nenv d) <&> \case - Right reg -> (rslvSucc, RNAME reg) - Left e@NOT_FOUND -> (rslvNotFound, ERR $ NAME e) + liftIO (resolveName nenv q) <&> \case + Right reg -> (if answered reg then rslvSucc else rslvNotFound, RNAME reg) Left e -> (rslvResolverErrs, ERR $ NAME e) incStat (selector st) $> msg + where + -- below v22 the encoder answers anything but a record as NAME NOT_FOUND + answered = \case + NRRegistered {} -> True + _ -> thVersion thParams' >= nameAvailSMPVersion transportErr :: TransportError -> ErrorType transportErr = PROXY . BROKER . TRANSPORT mkIncProxyStats :: MonadIO m => ProxyStats -> ProxyStats -> OwnServer -> (ProxyStats -> IORef Int) -> m () diff --git a/src/Simplex/Messaging/Server/Names.hs b/src/Simplex/Messaging/Server/Names.hs index 67c4b22bf..2525a9f1c 100644 --- a/src/Simplex/Messaging/Server/Names.hs +++ b/src/Simplex/Messaging/Server/Names.hs @@ -74,9 +74,6 @@ fetch NamesEnv {resolverEnv} q = mapResolverError :: ResolverError -> NameErrorType mapResolverError = \case - HttpStatusErr 404 -> NOT_FOUND - HttpStatusErr 410 -> NOT_FOUND - HttpStatusErr 400 -> NOT_FOUND HttpStatusErr code -> RESOLVER ("HTTP " <> T.pack (show code)) HttpFailure _ -> RESOLVER "transport failure" BodyTooLarge -> RESOLVER "response too large" diff --git a/src/Simplex/Messaging/Server/Prometheus.hs b/src/Simplex/Messaging/Server/Prometheus.hs index 201f88426..85d2624fd 100644 --- a/src/Simplex/Messaging/Server/Prometheus.hs +++ b/src/Simplex/Messaging/Server/Prometheus.hs @@ -473,7 +473,7 @@ prometheusMetrics sm rtm ts = \# TYPE simplex_smp_names_success counter\n\ \simplex_smp_names_success " <> mshow _rslvSucc <> "\n# rslvSucc\n\ \\n\ - \# HELP simplex_smp_names_not_found Name not registered (resolver returned 404 / 400).\n\ + \# HELP simplex_smp_names_not_found Answers a client below v22 reads as NOT_FOUND.\n\ \# TYPE simplex_smp_names_not_found counter\n\ \simplex_smp_names_not_found " <> mshow _rslvNotFound <> "\n# rslvNotFound\n\ \\n\ diff --git a/tests/RSLVTests.hs b/tests/RSLVTests.hs index 9ab6ec857..dbe188811 100644 --- a/tests/RSLVTests.hs +++ b/tests/RSLVTests.hs @@ -19,7 +19,7 @@ import Data.List.NonEmpty (NonEmpty (..)) import Data.Text (Text) import Data.Text.Encoding (encodeUtf8) import Data.Time.Clock (getCurrentTime) -import Network.HTTP.Types (Status, status200, status404, status410, status502) +import Network.HTTP.Types (Status, status200, status404, status502) import NamesResolverServer (memCfg, memCfg2, memProxyCfg, withNames) import qualified NamesResolverServer as NRS import SMPClient @@ -81,13 +81,12 @@ sendRslv h@THandle {params} corrId d = do rslvTests :: Spec rslvTests = do describe "RSLV direct (non-forwarded)" $ do - it "resolver replies 404 -> NAME NOT_FOUND (reached, not CMD PROHIBITED)" testRslvBackendNotFound - it "resolver replies 410 -> NAME NOT_FOUND (a lapsed name, not a resolver failure)" testRslvBackendGone + it "resolver without the v2 route (404) -> NAME RESOLVER, not NOT_FOUND" testRslvBackendNotFound it "resolver replies 502 -> NAME (RESOLVER ..)" testRslvBackendHttpErr it "no names config -> NAME NO_RESOLVER" testRslvDisabled it "refuses to send RSLV on a session below namesSMPVersion" testRslvVersion describe "RSLV forwarded (PFWD)" $ do - it "PFWD-wrapped RSLV reaches resolver via proxy (PCEProtocolError (NAME NOT_FOUND))" testRslvForwarded + it "PFWD-wrapped RSLV reaches resolver via proxy (PCEProtocolError (NAME RESOLVER))" testRslvForwarded it "PFWD-wrapped RSLV success returns RNAME (record JSON frames over the proxy)" testRslvForwardedSuccess describe "RSLV success path (RNAME response)" $ do it "returns RNAME with NameRecord" testRslvSuccess @@ -103,20 +102,15 @@ rslvTests = do it "a name with subnames is sent as text" testSubnameKeepsItsLabels it "a record naming a different name is rejected" testRslvWrongName +-- | /v2/resolve answers 200, 400 or 502, so a 404 is a resolver that predates +-- the route, not a name that does not exist. testRslvBackendNotFound :: IO () testRslvBackendNotFound = withResolverServer (status404, "{}") $ testSMPClient @TLS $ \h -> do (corrId, _entId, resp) <- sendRslv h "rs01" (domain "ghost.simplex") corrId `shouldBe` CorrId "rs01" - resp `shouldBe` Right (ERR (NAME NOT_FOUND)) - -testRslvBackendGone :: IO () -testRslvBackendGone = - withResolverServer (status410, "{}") $ - testSMPClient @TLS $ \h -> do - (_, _, resp) <- sendRslv h "rs08" (domain "lapsed.simplex") - resp `shouldBe` Right (ERR (NAME NOT_FOUND)) + resp `shouldBe` Right (ERR (NAME (RESOLVER "HTTP 404"))) testRslvBackendHttpErr :: IO () testRslvBackendHttpErr = @@ -162,8 +156,8 @@ testRslvForwarded :: IO () testRslvForwarded = withProxyAndResolver (status404, "{}") $ forwardedResolveAlice >>= \r -> case r of - Left (PCEProtocolError (SMP.NAME SMP.NOT_FOUND)) -> pure () - _ -> expectationFailure $ "expected Left (PCEProtocolError (NAME NOT_FOUND)), got: " <> show r + Left (PCEProtocolError (SMP.NAME (SMP.RESOLVER _))) -> pure () + _ -> expectationFailure $ "expected Left (PCEProtocolError (NAME (RESOLVER _))), got: " <> show r testRslvForwardedSuccess :: IO () testRslvForwardedSuccess = diff --git a/tests/SMPNamesTests.hs b/tests/SMPNamesTests.hs index 3f0be57b5..23799cd91 100644 --- a/tests/SMPNamesTests.hs +++ b/tests/SMPNamesTests.hs @@ -14,7 +14,7 @@ import Data.List (sort) import qualified Data.Map.Strict as M import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) -import Network.HTTP.Types (status200, status400, status404, status410, status500, status502) +import Network.HTTP.Types (status200, status400, status404, status500, status502) import NamesResolverServer (resolveResp, testNamesConfig, withResolverServer, withResolverServerDelayed) import Simplex.Messaging.Encoding (smpDecode, smpEncode) import Simplex.Messaging.Encoding.String (strDecode) @@ -248,20 +248,17 @@ resolverSpec = do env <- newNamesEnv (testNamesConfig port) resolveName env aliceDomain `shouldReturn` Right registeredAlice - it "returns NOT_FOUND on 404" $ + -- /v2/resolve answers 200, 400 or 502 and never says "no such name": an + -- unregistered name is NRAvailable. So no status maps to NOT_FOUND. + it "returns RESOLVER on 404 (a resolver without the v2 route)" $ withResolverServer (resolveResp status404 "{}") $ \port _ -> do env <- newNamesEnv (testNamesConfig port) - resolveName env aliceDomain `shouldReturn` Left NOT_FOUND + resolveName env aliceDomain `shouldReturn` Left (RESOLVER "HTTP 404") - it "returns NOT_FOUND on 400 (unknown TLD)" $ + it "returns RESOLVER on 400 (TLD not configured)" $ withResolverServer (resolveResp status400 "{}") $ \port _ -> do env <- newNamesEnv (testNamesConfig port) - resolveName env aliceDomain `shouldReturn` Left NOT_FOUND - - it "returns NOT_FOUND on 410 (registration lapsed)" $ - withResolverServer (resolveResp status410 "{}") $ \port _ -> do - env <- newNamesEnv (testNamesConfig port) - resolveName env aliceDomain `shouldReturn` Left NOT_FOUND + resolveName env aliceDomain `shouldReturn` Left (RESOLVER "HTTP 400") it "returns RESOLVER on 502 (upstream failure)" $ withResolverServer (resolveResp status502 "{}") $ \port _ -> do