SOCKS proxy chains
Upcoming. This page describes
master. The proxy is not part of a release yet, and details may change.
Part of SOCKS proxy. A chain is the shared, append-only log in memory through which a client node and its exits talk. The members of a shard group host the chains of their group, flood new entries to each other and reconcile what they missed; a client node keeps a replica of each chain it writes on. How groups, chains and exits are chosen and how sessions use the entries is on SOCKS proxy implementation.
Chains and writers
A chain merges append-only feeds, one per writer. A writer is an
Ed25519 key made fresh for every client attachment and for every session
of an exit, and its public key is the writer id. WriterId.verify refuses a key that is not a point of prime order.
An entry names its chain, its writer, its sequence number and the hash of the writer’s previous entry, and it is
signed over its body and its hash.
A writer orders only its own entries, so a chain cannot fork. Chain orders all entries by timestamp, then writer,
then sequence number. FeedWriter signs a writer’s entries in order and stamps them in whole seconds, the clock
rounded down, so within a second the stamps tell nothing of how far apart two writers are. When the clock steps
back it keeps the last stamp, as a feed refuses time that runs backwards. writeLast signs the last entry of a
feed, after which the writer signs nothing more.
Entries
An entry is Entry.OVERHEAD, 181 bytes, plus its payload of n bytes, big-endian throughout. The header is
Entry.HEADER_SIZE, 109 bytes.
| Offset | Size | Field |
|---|---|---|
| 0 | 32 | Chain id |
| 32 | 32 | Writer id, an Ed25519 public key |
| 64 | 8 | Sequence number, never negative |
| 72 | 8 | Hash of the writer’s previous entry |
| 80 | 8 | Timestamp, milliseconds since 1970, a whole number of seconds |
| 88 | 16 | Tag |
| 104 | 1 | Marks: 0x01 is the end mark of the last entry of a feed; any other bit makes the entry invalid |
| 105 | 4 | Payload length n |
| 109 | n | Payload |
| 109 + n | 8 | Hash: FNVJ64 of bytes 0 to 108 + n |
| 117 + n | 64 | Ed25519 signature over bytes 0 to 116 + n |
Nothing in the clear says what an entry carries. The tag names its session for the two ends alone, and the payload is an opening, an answer or one sealed message, see SOCKS proxy implementation. The hash and the signature cover the end mark.
Size classes. An entry, its overhead included, is 1024 bytes, 4096 bytes or maxEntryBytes long, each class
only where it does not exceed maxEntryBytes (SizeClasses). An opening, an answer and a short message fill the
smallest class, so the size of an entry does not tell them apart.
FNVJ64
FnvJ64 is the FNVJ64 hash that greenrobot essentials dropped. It starts from the FNV-1a offset basis
0xcbf29ce484222325 and uses the FNV prime 0x100000001b3, but each round multiplies the state by the prime first
and then XORs in eight bytes at once, read as a big-endian word, the last word padded with zeros. The value is the
final state times the prime, XOR the length in bytes, times the prime once more. The hash is fast and not collision
resistant, so the signature covers the body itself and not only the hash.
Admission
Chain.offer first trims the chain and then refuses, in this order, an entry for another chain (WRONG_CHAIN),
one larger than maxEntryBytes (TOO_LARGE), one whose size is no size class or whose timestamp is no whole
second (MALFORMED), and one whose timestamp is more than the retention window ahead of the local clock (FUTURE)
or behind it (EXPIRED). The writer’s feed then judges the link:
| Link | When |
|---|---|
NEXT |
The next sequence number, with the head’s hash as previous hash and a timestamp no older than the head’s |
AHEAD |
Past the next sequence number |
DUPLICATE |
The head itself, seen again |
REPLAYED |
Below the head |
BROKEN |
At the next sequence number or the head but not linked to it, time running back, past the last entry of a feed that ended, or the sequence number 2⁶³ − 1, which nothing can follow |
FOREIGN |
Signed by another writer |
DUPLICATE and REPLAYED are answered as such, BROKEN and FOREIGN as BROKEN, without checking the
signature, since entries a chain already has arrive over and over. The signature is checked next, and a failure is
NOT_AUTHENTIC. Then an AHEAD entry waits for the missing ones, and a NEXT entry is ACCEPTED and appends with
it whatever waited behind it and now links, unless either finds no room.
An entry that would take the chain past chainCapacityBytes is refused FULL. An entry of the smallest class may
also take a reserve of max(smallest class, capacity / 16) beyond the capacity, so a chain full of larger entries
still lets sessions open, answer and close. Entries past a gap wait in a buffer of max(maxEntryBytes,
capacity / 4) bytes, and one that does not fit it is refused FULL too. Entries released from the buffer are not
checked again: past the room, the oldest larger entries go until the larger ones fit the capacity, then the oldest
of all until everything fits the capacity and the reserve. Every feed keeps its head. A feed may also start after a
head that a member named, through Chain.anchor; the first entry after such a head is not held to the head’s
timestamp, which the feed never saw, and a head with the end mark starts the feed ended.
Gaps, trimming and ended feeds
Once a second Chain.trim drops the entries older than the retention window, and expireGaps gives up on a gap
whose first waiting entry has aged past it, since every member has trimmed the missing entries by then. The feed
skips past the entries that waited, or ends at the first of them that carries the end mark, and the Gap it reports
names the writer, the skipped sequence numbers and the tags of the entries that waited. A client session whose exit
is that writer closes BROKEN, as the exit writes each session as a writer of its own; at the exit, a session whose
client is that writer closes so only when it looks out for one of those tags.
In the same second the host forgets every feed whose last entry carries the end mark and is older than the
retention window: its head and what the chain kept of it go, and its writer’s place comes free. Whatever the writer
signed is refused EXPIRED from then on. A feed that did not end is never forgotten, however long it was quiet.
Chain packets
Chain traffic has a QUIC stream of its own, so it never holds up the main stream. Only between peers that agreed on
hedgehog-proxy/0.0.2 or hedgehog-ledger/0.0.2, which only nodes
run with --preview offer, does the client of a connection open it after the main stream, with a PING, which
makes its id Network.CHAIN_STREAM_ID, 4.
StreamRouter gives that stream its own pipeline (ChainPipeline.streamHandlers): FrameDecoder.forChainStream(),
the ping stages, the chain codecs and handlers, and UnclaimedFrameHandler. That decoder has a size table of its
own: it takes frames of at most 8 MiB, header included, and only the ping and the chain packets, each within the
size its fields allow. The decoder of the main stream has no chain packets in its table and refuses them.
The packets use the frame header of the Peer-to-peer network protocol. A packet holds at most 64 entries of at most 1 MiB each, or 4096 heads or ranges. A head is 57 bytes: the writer (32), the sequence number (u64), the hash (u64), the timestamp of the entry (u64) and its marks (u8). A writer named twice among the heads of a packet fails it.
| Packet | Id | Payload |
|---|---|---|
JOIN_CHAIN |
5000 | Request id (u64), writer (32), has chain (u8), chain id (32) when it has one |
JOIN_REPLY |
5010 | Request id (u64), status (u8); when JOINED, chain id (32), n (u16), n heads |
CHAIN_ENTRY |
5020 | n (u16), then n times entry length (u32) and entry; each entry names its chain |
CHAIN_HEADS |
5030 | Chain id (32), n (u16), n heads |
FETCH_ENTRIES |
5040 | Chain id (32), n (u16), n times writer (32), from (u64) and to (u64) |
ENTRY_REFUSED |
5050 | Chain id (32), writer (32), sequence number (u64), admission (u8) |
LEAVE_CHAIN |
5060 | Chain id (32), writer (32) |
CHAIN_STEM |
5070 | As CHAIN_ENTRY: entries passed on before any member floods them, see The stem |
Status and admission bytes are enum ordinals. A status is JOINED 0, FULL 1, DISABLED 2, UNKNOWN_CHAIN 3
or ERROR 4; no node sends ERROR, and an unknown byte reads as it. An admission is the ordinal of Admission, in
which FULL is 10 and MALFORMED 11.
Joins and intake
Joins. ChainHost.join answers DISABLED while the proxy is off and UNKNOWN_CHAIN when the request names a
chain that is not a current chain of the member. It answers FULL for a stream that is over its join budget or
already holds 4 writers (MAX_WRITERS_PER_STREAM), and for a chain that has clientsPerChain clients or holds 4096
writers (HostedChain.MAX_WRITERS). A request without a chain id is placed on the chain the writer is attached to,
else on the one with the fewest clients, the lowest index among equals. The reply carries the chain’s heads, and a
chain with more heads than one packet holds takes no client. A stream that takes no writes is sent no reply, and the
request times out.
A writer keeps the live stream it attached through: another stream that names it is refused while that stream
lives, and the unsigned LEAVE_CHAIN is believed only from it. When the stream closes, its writers are detached.
Intake. A member stream may relay any writer’s entries, a client stream only its own writer’s; other entries
are dropped before anything is verified. receive offers a packet’s entries under the chain’s delivery lock and
queues what was appended, at most 4096 deliveries. Beyond that a delivery is dropped, its entries staying in the
chain but not flooded, and the log is warned when the queue fills. One thread at a time drains the queue with no
lock held: it floods the entries to every member stream and attached client except the one they came from,
skipping streams that are closed or take no writes, and then tells ClientSessions and ChainExit. An entry
refused FULL is answered with ENTRY_REFUSED while the stream takes writes; the other refusals are silent. A
client node’s own entries go in through post, are drained on a thread of their own and are not told to its
listeners; an exit’s go out on the stem through postStem.
Following
A client node keeps a replica of each chain it joined through ChainHost.follow, 8 chains at most, twice its 4
attachments so that the chains of an ended epoch can drain, and none it hosts itself. The replica is anchored at the
heads of the join replies, less the node’s own writer. It takes entries from the member streams it follows the
chain through, floods none of them on, and posts the node’s own entries on every one of those streams. It is
forgotten when the last of them closes.
Refusals
A FULL refusal from a member of the chain’s group, or from a stream the chain is followed through, pauses the
writer in WriterPauses at the lowest refused sequence number; the latest 1024 pauses are kept. ChainOutbox posts
a writer’s entries in order, and one that the local replica has no room for waits with everything after it, the
writer paused at its sequence number.
Once a second the outbox lifts the pause and places what waited. A client’s outbox first sends the retained entries
from the refused sequence number on to every stream again, and members that have them drop them as duplicates. An
exit’s outbox sends nothing again, as the stem moved the refused entries on when the refusal came. Any answer but
ACCEPTED, AHEAD, DUPLICATE, REPLAYED or FULL ends the outbox: a client then joins again with a new
writer, and an exit closes the session of that writer BROKEN.
Reconcile
Once a second each current chain of a member that has any feed sends its heads to one random member stream that
is open and takes writes. The receiving member answers with a FETCH_ENTRIES for what HeadsDiff.missing finds:
per writer it lags on and can take, the next 64 entries after its own head, at most 8 ranges, starting at a random
place in the list when there are more. The peer serves them oldest first, up to one 8 MiB frame, as CHAIN_ENTRY
packets. Only members are answered, and only while their stream takes writes.
Nothing is adopted from a peer’s heads: a feed the chain has not seen starts at its first entry, and if that is gone the entries after it wait until the gap expires. A client never reconciles, so a flood it missed stays lost until the gap expires.
Stream budgets
StreamBudgets limits what one stream may ask of a host in a window of one second. A join over the budget is
answered FULL; the other requests are dropped. The window opens with the first request after the last one closed,
so a stream can use up to twice its budget across the turn from one window to the next.
| Budget | Per second |
|---|---|
| Joins | 8 |
Entries to verify, counted per CHAIN_ENTRY or CHAIN_STEM packet before any is verified |
4096 |
CHAIN_HEADS packets |
16 |
| Ranges served | 64 |
| Bytes of fetched entries | 32 MiB |
In the source
proxy/src/main/java/org/unigrid/hedgehog/proxy/chain/: chains, feeds, entries, size classes, admission, reconcile and gapsapplication/src/main/java/org/unigrid/hedgehog/service/chain/: hosting, joins, intake, flooding, the stem, following, reconcile, posting, pauses and budgetsapplication/src/main/java/org/unigrid/hedgehog/model/network/: the chain packets and their codecsapplication/src/main/java/org/unigrid/hedgehog/transport/: inhandlerthe handlers of the chain packets, and ininitializerthe pipeline of stream 4 (ChainPipeline,ChainStreamInitializer)