From 8e4e52ef3351106f63a3bcfed0f8ad47308514f7 Mon Sep 17 00:00:00 2001 From: atomanticagent Date: Sun, 6 Sep 2026 17:14:44 +0000 Subject: [PATCH 1/3] feat: add federated peers via tailcat (no Tailscale account) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operators can paste a tc… address in Instances to start a local tailcat forward (15555→5555 by default) and register a loopback peer. Includes install/forward lifecycle, boot restore, docs, and tests. Closes #6400 --- client/src/lib/ports.js | 7 +- client/src/pages/Instances.jsx | 86 +++++- client/src/pages/Instances.test.jsx | 40 ++- client/src/services/apiSystem.js | 1 + docs/PORTS.md | 1 + docs/STORAGE.md | 1 + docs/features/tailcat-peers.md | 96 ++++++ ecosystem.config.cjs | 3 + server/lib/ports.js | 4 + server/lib/ports.test.js | 14 +- server/routes/instances.js | 19 ++ server/services/bootstrap.js | 8 +- server/services/instances.js | 16 +- server/services/tailcatPeer.js | 437 ++++++++++++++++++++++++++++ server/services/tailcatPeer.test.js | 160 ++++++++++ 15 files changed, 883 insertions(+), 10 deletions(-) create mode 100644 docs/features/tailcat-peers.md create mode 100644 server/services/tailcatPeer.js create mode 100644 server/services/tailcatPeer.test.js diff --git a/client/src/lib/ports.js b/client/src/lib/ports.js index f6d95aa4f9..1f6f685198 100644 --- a/client/src/lib/ports.js +++ b/client/src/lib/ports.js @@ -8,4 +8,9 @@ * top of that. Use these instead of re-hardcoding a port literal in a form * default, a copy-paste help string, or a cross-machine URL. */ -export { PORTS, DEFAULT_PEER_PORT } from '../../../server/lib/ports.js'; +export { + PORTS, + DEFAULT_PEER_PORT, + DEFAULT_TAILCAT_LOCAL_PORT, + DEFAULT_TAILCAT_REMOTE_PORT, +} from '../../../server/lib/ports.js'; diff --git a/client/src/pages/Instances.jsx b/client/src/pages/Instances.jsx index 0ba6751a9f..6a966b343c 100644 --- a/client/src/pages/Instances.jsx +++ b/client/src/pages/Instances.jsx @@ -14,7 +14,7 @@ import Pill from '../components/ui/Pill'; import EmptyState from '../components/EmptyState'; import socket from '../services/socket'; import { - getInstances, updateSelfInstance, addPeer, updatePeer, + getInstances, updateSelfInstance, addPeer, addTailcatPeer, updatePeer, removePeer, connectPeer, reciprocatePeer, probePeer, syncPeer, getTailnetInfo, getNetworkExposure, listPeerSubscriptions, @@ -24,7 +24,7 @@ import { import PeerAppsList from '../components/instances/PeerAppsList'; import PeerAgentsSection from '../components/instances/PeerAgentsSection'; import { SchemaGapBadge } from '../components/instances/SchemaGapBadge'; -import { DEFAULT_PEER_PORT } from '../lib/ports.js'; +import { DEFAULT_PEER_PORT, DEFAULT_TAILCAT_LOCAL_PORT } from '../lib/ports.js'; import PeerMediaProviderPanel from '../components/instances/PeerMediaProviderPanel'; import UnattendedRenderRouting from '../components/instances/UnattendedRenderRouting'; import BrainParityPanel from '../components/instances/BrainParityPanel'; @@ -207,7 +207,9 @@ function SelfCard({ self, onUpdate, syncStatus, tailnetInfo }) { // Exported for focused tests (the port input's placeholder must advertise the // same default the form actually submits — see Instances.test.jsx). export function AddPeerForm({ onAdd, addressRef }) { + const [mode, setMode] = useState('classic'); // 'classic' | 'tailcat' const [address, setAddress] = useState(''); + const [tcAddress, setTcAddress] = useState(''); const [port, setPort] = useState(String(DEFAULT_PEER_PORT)); const [name, setName] = useState(''); const [showAuth, setShowAuth] = useState(false); @@ -217,6 +219,24 @@ export function AddPeerForm({ onAdd, addressRef }) { const handleSubmit = async (e) => { e.preventDefault(); + if (mode === 'tailcat') { + if (!tcAddress.trim()) return; + setAdding(true); + const data = { tcAddress: tcAddress.trim() }; + if (name.trim()) data.name = name.trim(); + if (password) data.auth = { username: username.trim(), password }; + const result = await addTailcatPeer(data).catch(() => null); + setAdding(false); + if (!result) return; + setTcAddress(''); + setName(''); + setUsername(''); + setPassword(''); + setShowAuth(false); + onAdd(); + toast.success(`Peer added via tailcat (local :${result.port || DEFAULT_TAILCAT_LOCAL_PORT})`); + return; + } if (!address.trim()) return; setAdding(true); const data = { address: address.trim(), port: parseInt(port, 10) || DEFAULT_PEER_PORT }; @@ -237,11 +257,58 @@ export function AddPeerForm({ onAdd, addressRef }) { toast.success('Peer added'); }; + const canSubmit = mode === 'tailcat' ? !!tcAddress.trim() : !!address.trim(); + return (

Add Peer

+
+ + +
+ {mode === 'tailcat' ? ( +
+ setTcAddress(e.target.value)} + placeholder="tcEXAMPLE…" + required + className="bg-port-bg border border-port-border rounded px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-hidden focus:border-port-accent flex-1 min-w-[200px] font-mono" + /> + setName(e.target.value)} + placeholder="Name (optional)" + className="bg-port-bg border border-port-border rounded px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-hidden focus:border-port-accent flex-1 min-w-[120px]" + /> + +
+ ) : (
+ )} + {mode === 'tailcat' && ( +

+ Forwards 127.0.0.1:{DEFAULT_TAILCAT_LOCAL_PORT} + {' '}→ remote :5555 via tailcat + (next free port if {DEFAULT_TAILCAT_LOCAL_PORT} is busy). No Tailscale account required. +

+ )}