From client to node
In the client post we made a product call: connect to one node the way phone apps like DVSwitch Mobile do, and skip the heavy machinery of being a node. The protocol core — IAX2 frames, information elements, MD5 auth, GSM/μ-law codecs — was identical either way, so we noted that "full node participation can be layered on later without touching the protocol core."
This is that layer. Hosting a node adds three genuinely new pieces on top of the existing IAX2 stack:
- An inbound server that answers
NEWcalls from other nodes instead of placing them — with the whole handshake running in reverse. - A registration client so the AllStarLink network publishes our address and other nodes can resolve our node number to our IP.
- A radio relay: a bridge that cross-connects the internet call to a physical radio's receiver and transmitter, with the half-duplex arbitration a real repeater controller does.
And because a hosted node is exposed to the whole internet, a fourth concern runs through all of it: authentication and access control.
The shape of it: a background handler, device 203
The outbound AllStarLink client lives on device 202 — a pseudo-radio you select and talk through. Hosting is a different animal: it isn't a radio you listen to, it's a service that runs in the background and takes over one of your real radios. So it gets its own home.
Node hosting is an AllStarNodeHandler — a background Data Broker handler in the
same family as the BBS and Torrent handlers — and it publishes its control and
status on a new pseudo-device, 203. It owns exactly one UDP socket and three
things hanging off it: the server, the registrar, and the audio bridge to a locked
radio.
flowchart TB
subgraph Net["AllStarLink network"]
Reg["Registration server\nregister.allstarlink.org"]
Peer1["Node 1998"]
Peer2["WT client\n(phone / browser)"]
end
subgraph App["HTCommander — AllStarNodeHandler (device 203)"]
Sock["One UDP socket\nbound to :4569"]
Server["Iax2Server\n(inbound sessions)"]
Registrar["Iax2Registrar\n(REGREQ keepalive)"]
Bridge["Audio bridge\n+ half-duplex arbiter"]
end
Radio["Benshi handheld\n(locked: usage 'AllStarLink')"]
Reg <-->|REGREQ/REGACK| Sock
Peer1 <-->|IAX2| Sock
Peer2 <-->|IAX2| Sock
Sock --> Registrar
Sock --> Server
Server <--> Bridge <--> Radio
When you start hosting, the handler locks the chosen radio to a new usage string,
AllStarLink, so the rest of the app leaves it alone (more on that lock later),
opens the socket, spins up the server and registrar, and starts a 20 ms mixer
tick. Stop hosting and it unwinds all of it — unlock the radio, hang up every
session, deregister, close the socket.
Everything below the handler is, again, pure Dart and transport-agnostic: the server and registrar emit datagrams through callbacks and receive them through plain methods, so both are unit-tested without a socket in sight.
One socket, two tenants: routing by call number
Here's the first wrinkle unique to hosting. A real node uses UDP 4569 for everything, and so must we — but now "everything" includes two very different conversations sharing one socket: registration traffic to AllStarLink's servers, and call traffic to and from every linked peer. When a datagram arrives, which one gets it?
IAX2 hands us the answer for free. Every full frame names the destination call number — the local call number the sender is talking to. So we simply partition the call-number space:
| Tenant | Local call numbers |
|---|---|
Inbound peer sessions (Iax2Server) |
1 … 0x3FFE |
Registration leg (Iax2Registrar) |
0x4000 … |
The handler peeks at the destination call number on each inbound datagram: if it's
≥ 0x4000 it belongs to the registrar, otherwise it's routed to the server, which
demultiplexes further by (peer address, call number) into the right session.
NEW frames — which carry destination call number 0 because the peer doesn't
know its call number yet — always go to the server to start a new session.
The handshake, in reverse
The client post walked the outbound handshake: we send NEW, the node challenges
us with AUTHREQ, we answer AUTHREP, it says ACCEPT then ANSWER. Hosting is
the same conversation from the other chair. Now we are the one issuing the
challenge and deciding who gets in.
sequenceDiagram participant Peer as Remote node / WT client participant Node as HTCommander (Iax2Server) Peer->>Node: NEW (username, called number, capability, format) Node-->>Peer: AUTHREQ (auth methods = MD5, random challenge) Peer->>Node: AUTHREP (MD5 result = md5(challenge + secret)) Note over Node: verify md5(challenge + expected-secret) Node-->>Peer: ACCEPT (chosen codec: μ-law or GSM) Node-->>Peer: ANSWER (control frame — we auto-answer) Note over Peer,Node: call is UP — voice bridges to the radio Peer->>Node: HANGUP (or we drop it on inactivity)
A few decisions that only exist on the server side:
- We allocate the call number. Each
NEWgets a fresh local call number from the1…0x3FFEpool, and the session is filed under both that number and the peer's(host, port, their call number)so mini frames — which only carry the source call number — still route correctly. - We generate the challenge. A random numeric string per session; the peer's
AUTHREPmust bemd5(challenge + secret), and we compare against our own computation of the same. Wrong secret →REJECT, and the session never exists. - We auto-answer. A repeater node doesn't "ring"; the moment auth passes we
send
ACCEPT(naming the codec we picked from the caller'sCAPABILITY, preferring μ-law then GSM) immediately followed byANSWER, and the call is up. - We keep the reliability machinery, but per-session: each session has its own sequence counters, its own map of unacknowledged frames, its own retransmit timer, and an inactivity watchdog that hangs up a peer that goes silent for 60 seconds. One misbehaving link can't wedge the others.
The server caps concurrent sessions and supports optional allow/deny lists of node numbers — the cheap, important guardrails for something that now listens to the open internet.
Getting found: IAX2 registration
An inbound server is useless if nobody can find it. AllStarLink resolves a node
number to an IP through DNS (<node>.nodes.allstarlink.org), and those DNS
records are populated from a registration database. To appear in it, a node
periodically tells AllStarLink "I'm node 2000, here's my password, here's where I
am" — and the network records the address it sees the packet coming from
(which neatly handles NAT).
ASL3 defaults to an HTTP registration endpoint whose wire format isn't publicly
documented, but the classic IAX2 registration — register => node:password@register.allstarlink.org — is right there in RFC 5456 and still
supported. It reuses the exact frame and IE code we already had, so that's what we
implemented.
sequenceDiagram participant Node as HTCommander (Iax2Registrar) participant Reg as register.allstarlink.org Node->>Reg: REGREQ (username = node number, refresh) Reg-->>Node: REGAUTH (auth methods = MD5, challenge) Node->>Reg: REGREQ (username, md5(challenge + password), refresh) Reg-->>Node: REGACK (refresh interval, APPARENTADDR = our public IP:port) Note over Node: schedule refresh a little before it expires
It's the same MD5 challenge-response as a call, just with REGREQ/REGAUTH/
REGACK instead of NEW/AUTHREQ/ACCEPT. The REGACK even hands back an
APPARENTADDR information element — a sockaddr_in telling us the public IP and
port the server saw — which we decode and surface so you can confirm your node is
reachable. A timer re-registers shortly before the lease expires; a REGREJ (bad
password) or a silent server flips the status to failed and backs off.
Because registration shares the node's socket, it registers from port 4569 — which is exactly the port inbound calls need to arrive on. The address AllStarLink publishes is therefore the one that works.
The whole point: bridging the internet to RF
Signaling and registration are plumbing. The reason any of this exists is to take
audio off an internet call and put it out over a radio — and take what the radio
hears and send it back. This is what app_rpt does on a real node, and it's the
part with the most physics in it.
The radio side already speaks a clean event language inside HTCommander. A locked
radio publishes its received audio as AudioData* events at 32 kHz, tagged with
the lock usage; transmitting is a matter of dispatching TransmitVoicePCM back to
it. So the bridge is two resamplers and a mixer wired between the server and those
events:
flowchart LR
subgraph RXtoNet["Radio RX → network"]
A["Radio audio\n32 kHz, usage 'AllStarLink'"] --> B["Resample 32k → 8k"]
B --> C["Slice into 20 ms\n160-sample frames"]
C --> D["broadcast to every\nlinked session"]
end
subgraph NetToRadio["Network → radio TX"]
E["Per-session 8 kHz audio"] --> F["Mix (sum + clip)\nevery 20 ms"]
F --> G["Resample 8k → 32k"]
G --> H["TransmitVoicePCM\n(keys the radio)"]
end
Received radio audio is resampled down to the node's 8 kHz, chopped into the 20 ms frames IAX2 wants, and broadcast to every connected peer. In the other direction, a 20 ms mixer tick pulls a frame from each talking session, sums them (with clipping), resamples up to the engine's 32 kHz, and streams it to the radio as a held PTT. Multiple people talking at once mix into one transmission — exactly what a repeater does.
Half-duplex is the hard part
A repeater is full-duplex — it can listen and transmit at the same time. A handheld cannot. It's half-duplex: while it's transmitting it's deaf, and you must never key it on top of a local user who's mid-sentence. Getting this wrong gives you the two classic failure modes — a feedback loop, or a local operator who gets stepped on by the internet.
We arbitrate with the radio's own carrier detect. The radio reports isInRx in
its status — true when it hears a signal on the air. That's the priority signal:
- Local RF wins, always. While
isInRxis true, the mixer tick refuses to key the radio and drops buffered network audio. A local operator is never interrupted by the link. - Network audio keys the radio only when the air is clear. When RF is idle and a session has audio, we key PTT and stream; when the talkers stop, a short tail timer (~300 ms) unkeys, so the transmitter doesn't chatter between syllables.
- Carrier state is signaled both ways. When the radio starts and stops
receiving, we send IAX2
KEY/UNKEYcontrol frames (theapp_rptPTT indications) to the linked peers, so the rest of the network sees our node key up just like any other.
That "local RF wins" rule, plus the fact that keying the radio makes it deaf (so it can't hear — and re-transmit — the very audio we're playing into it), is what keeps the bridge from howling.
The lock
To dedicate a radio to relaying, the handler claims it with HTCommander's radio
lock mechanism under a new usage, AllStarLink. The lock is how features like
BBS, Terminal, and Torrent take exclusive control of a radio; the Comms tab already
respects it and disables manual PTT on a locked radio. Reusing it means the moment
you start hosting, the radio is off-limits to everything else and fully owned by
the node — no new arbitration code required.
"Allow anyone": Web Transceiver, done safely
There are two ways to let people connect. The first is to hand out your node password — fine for a friend, awkward for the public. The second is the mechanism AllStarLink built for exactly this: the Web Transceiver (WT).
WT clients don't have your password. They authenticate with a public shared
secret (allstar-public / allstar) that every WT-enabled node accepts — and the
real access control is a token each client fetches from the AllStarLink portal
by logging in with their own account. The client presents that token as its IAX2
CallerID name; the node validates it, and only lets the call through if it's good.
We added an Allow Web Transceiver connections switch that turns this on. When a caller uses the public username, the server authenticates them against the public secret instead of your node password. But that public secret is, by design, known to everyone — so on its own it would let anyone in. The token is what makes it safe, and it turns out a real node validates it with a plain HTTP GET:
GET https://register.allstarlink.org/cgi-bin/authwebphone.pl?<token>
That's lifted straight from the [allstar-public] dialplan every ASL node ships:
it CURLs that URL with the caller's token and hangs up only if the reply begins
with ?. We do exactly the same.
sequenceDiagram
participant WT as WT client
participant Node as HTCommander
participant Portal as AllStarLink portal
WT->>Node: NEW (username = allstar-public, CallerID name = token)
Node-->>WT: AUTHREQ (challenge)
WT->>Node: AUTHREP (md5(challenge + "allstar"))
Note over Node: MD5 passes — but is the token real?
Node->>Portal: GET authwebphone.pl?<token>
Portal-->>Node: reply (starts with '?' = invalid)
alt token valid
Node-->>WT: ACCEPT + ANSWER
else token invalid
Node-->>WT: REJECT
end
Two implementation details made this behave well:
- Auth is now two-phase and async. MD5 is synchronous; the token check is a
network round-trip. So after MD5 passes for a WT session, the server sends a bare
ACKto stop the peer retransmitting itsAUTHREP, kicks off the async validation, and only then sendsACCEPTorREJECTwhen the portal answers. A guard flag stops a retransmittedAUTHREPfrom launching a second lookup, and the result is ignored if the session was torn down while we waited. - We match the node's fail-open stance. The dialplan rejects only on an
explicit
?; an empty or failed response lets the call through. We do the same — reject on?, but treat a portal outage as allow, so a hiccup at AllStarLink doesn't lock out every legitimate user. (Flipping this to fail-closed is a one-line change if a node owner prefers it.)
The upshot: flip one switch and anyone with a valid AllStarLink account can reach your radio through the official Web Transceiver — and nobody without one can.
Using it
Two places, mirroring how the feature splits:
Settings → AllStarLink → Host a Node holds the identity and network config:
| Field | What it is |
|---|---|
| Node Number | Your AllStarLink node number |
| Node Password | The password from your node's portal page |
| IAX Port | The UDP port to bind (defaults to 4569) |
| Registration | AllStarLink (IAX), or None for a private node |
| Allow Web Transceiver | Accept token-validated public WT clients |
The Comms tab is where you actually go on the air. Select a connected handheld, tap the new node button in the header, confirm the control-operator notice, and HTCommander locks that radio, registers, and starts relaying. The button glows while hosting and shows how many nodes are linked; tap it again to stop. Because a hosted node listens on the internet, you'll need to forward UDP 4569 to the machine for inbound links to reach you — the one bit of network setup the protocol can't do for you.
A word on responsibility: relaying internet audio onto RF means you are the control operator for everything that goes out. HTCommander shows a one-time notice to that effect before it keys your radio for the first time — the same reminder any repeater trustee lives by.
Testing a server with no internet in the room
The same discipline that made the client testable pays off double here. Because
Iax2Server and Iax2Registrar talk through callbacks, a test can be the
remote node: feed the server a crafted NEW and assert it challenges with an
AUTHREQ; feed a correct AUTHREP and assert ACCEPT + ANSWER and a live
session; feed a wrong secret and assert REJECT and zero sessions; feed a voice
frame and assert PCM comes out the audio callback; feed a HANGUP and assert a
clean teardown. The registrar test plays the registration server through the whole
REGREQ → REGAUTH → REGREQ → REGACK dance.
Web Transceiver validation drops in as a swappable async function, so two tests
pin the important behaviors without touching the network: a validator that returns
false must produce a REJECT after the async check resolves, and one that
returns true must accept. No live node, no portal, no repeater across town.
What's next
Hosting a node opens a long runway of app_rpt behavior we haven't built yet:
- DTMF node control — letting a connected user key
*3<node>to link and unlink other nodes through us, the way a real controller does. - Outbound linking from node mode — so your hosted node can reach out to other nodes and hubs, not only accept inbound links.
- HTTP registration and IAX2 call tokens, once their wire formats are pinned down, for full parity with ASL3 defaults.
- Node status on the radio panel — a live roster of who's linked, right on the LCD.
But the core is in place: HTCommander will now stand up as an AllStarLink node, register itself with the network, answer calls from anywhere in the world, prove who's allowed in — and put every one of them out over a handheld's PTT, all in pure Dart on top of the same audio pipeline that started this whole series.
See also: Dialing In: Adding AllStarLink Support to HTCommander · Radio Over the Internet: Adding EchoLink to HTCommander