{"record":{"id":"bcf5ff9508215490","repo":"schollz/croc","slug":"relay-returned-an-invalid-port-list-banner","errorCode":null,"errorMessage":"Relay returned an invalid port list: ${banner}","messagePattern":"Relay returned an invalid port list: (.+?)","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"web/src/protocol/client.ts","lineNumber":85,"sourceCode":"}\n\nfunction controlPort(relayAddress: string) {\n  try {\n    const parsed = new URL(\n      relayAddress.includes(\"://\") ? relayAddress : `tcp://${relayAddress}`,\n    );\n    return parsed.port || CONTROL_PORT;\n  } catch {\n    return CONTROL_PORT;\n  }\n}\n\nfunction dataPorts(banner: string) {\n  const ports = banner\n    .split(\",\")\n    .map((port) => port.trim())\n    .filter((port) => /^\\d{1,5}$/.test(port));\n  if (ports.length === 0) throw new Error(`Relay returned an invalid port list: ${banner}`);\n  return ports;\n}\n\nfunction machineID() {\n  const key = \"croc-web-machine-id\";\n  try {\n    const existing = localStorage.getItem(key);\n    if (existing) return existing;\n    const created = `web-${crypto.randomUUID()}`;\n    localStorage.setItem(key, created);\n    return created;\n  } catch {\n    return `web-${crypto.randomUUID()}`;\n  }\n}\n\nasync function connectRelay(\n  settings: TransferSettings,","sourceCodeStart":67,"sourceCodeEnd":103,"githubUrl":"https://github.com/schollz/croc/blob/e25f1bdc04f07f094d50b0a1bf67e2563944b57a/web/src/protocol/client.ts#L67-L103","documentation":"After authenticating to the relay, the client parses the banner (the part before '|||' in the relay's response) as a comma-separated list of data-port numbers. If not a single token matches /^\\d{1,5}$/, the banner is considered invalid and the error includes the raw banner text for diagnosis. The listed ports are where the parallel data connections will be opened.","triggerScenarios":"Connecting through settings.gatewayURL to a service that is not a croc relay (an HTTP proxy or arbitrary WebSocket server) so the 'banner|||ip' payload is HTML or JSON; a relay implementation that returns an empty or named-host banner; a MITM/gateway rewriting the plaintext after decryption.","commonSituations":"Typo in the relay address pointing at a web server; a custom relay fork that changed the banner format; an API gateway in front of the relay injecting its own response.","solutions":["Read the banner text embedded in the message: HTML/JSON means you reached the wrong server","Point settings.relayAddress at a genuine croc relay (default croc relay) and verify with the croc CLI first","If running a custom relay, make it send 'port1,port2,...|||external-ip' after the password handshake"],"exampleFix":"// before\nconst settings = { relayAddress: \"https://mywebsite.com\", ... };\n// banner = \"<html>404</html>\" -> invalid port list\n\n// after\nconst settings = { relayAddress: \"croc-relay.example.com:9009\", ... };\n// banner = \"9009,9010,9011,9012\" -> parsed ok","handlingStrategy":"validation","validationCode":"function isValidBanner(banner) {\n  return banner.split(\",\").some((p) => /^\\d{1,5}$/.test(p.trim()));\n}\n// Probe the relay with the croc CLI or a test handshake before starting a transfer\nif (!isValidBanner(lastBanner)) throw new Error(\"Relay did not advertise data ports\");","typeGuard":null,"tryCatchPattern":"try {\n  await sendFiles(opts);\n} catch (e) {\n  if (/invalid port list/.test(e.message)) {\n    showBanner(\"Relay address is not a croc relay. Check settings.relayAddress.\");\n    return;\n  }\n  throw e;\n}","preventionTips":["Default users to the standard croc relay unless a custom one is verified","Validate relay connectivity at settings-save time, not mid-transfer","Keep the relay and web client versions from the same release"],"tags":["relay","protocol","configuration"],"backgroundTag":null,"analyzedSha":"e25f1bdc04f07f094d50b0a1bf67e2563944b57a","analyzedAt":"2026-08-15T12:53:39.096Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}