tcp

package module
v1.7.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 13, 2026 License: MIT Imports: 31 Imported by: 0

README

protocols/tcp

The raw socket protocol for hikyaku: the tcp and udp source and destination types. Use them for devices, syslog, statsd and other systems that send bytes over a socket with no HTTP in between. Standard library only, except DTLS, which uses pion/dtls (pure Go).

import "github.com/xavidop/hikyaku/protocols/tcp"

tcp.Register() // registers tcp and udp as sources and destinations

Framing

Framing is how a stream of bytes is cut into messages. frame and length_bytes set it. Sources and destinations use the same rules, so a destination writes exactly what a source with the same framing reads.

frame One message is Details
newline (default) one line A line ends at \n (in a udp datagram, also where the datagram ends). A trailing \r is stripped, and empty lines are skipped.
length the bytes after a big-endian unsigned length of length_bytes bytes length_bytes is 1, 2, 4 (default) or 8, and applies with frame: length only. A zero length carries no message.
raw tcp: a connection's whole stream, until the client closes (or half-closes) it. udp: one datagram An empty stream or datagram is no message. A stream the client resets instead of closing is no message either.

A udp datagram may hold several newline or length frames. Over DTLS, a record is read the way a datagram is.

A tcp stream that ends inside a frame loses that frame: a last line without its \n, or a length frame without all its bytes. The loss is logged at warn. A sender cut off in the middle of a write leaves such a partial frame, and it must not pass for a whole message. So a client must end its last line with \n (printf 'msg\n' | nc host 9000, not printf 'msg').

Sources

A source receives bytes from clients and turns each frame into a message. Sources only listen: clients connect (tcp) or send datagrams (udp) to listen.

Key Meaning
listen Address, such as :9000 (required).
frame, length_bytes Framing (see above).
max_body_bytes Longest frame (default 1MiB). A longer frame is logged at warn with the client's remote_addr. tcp then closes the connection, because the stream cannot be split into frames again; udp drops the rest of the datagram.
tls cert_file + key_file, and client_ca_file to require client certificates (mTLS, mutual TLS). TLS for tcp, DTLS for udp. Any tls key turns it on, so a block without a certificate (client_ca_file alone, say) is refused at startup rather than leaving the port in plain text. enabled: false turns it off. A client must finish the handshake within 10s.
auth.ip_allow IPs and CIDRs (IPv4 or IPv6) allowed to connect (tcp, checked before TLS) or to send (udp, checked before DTLS). An IPv4 client seen through an IPv6 socket (::ffff:a.b.c.d) matches IPv4 entries. Others are closed or dropped, and logged at debug.

auth.hmac, auth.bearer, auth.jwt and auth.api_key are refused at startup, because a tcp or udp message carries no headers or signature. mode is listen (the default).

Each frame becomes one envelope:

Field Value
Protocol tcp or udp
Subject, Headers empty
ContentType application/json when the frame parses as JSON, text/plain; charset=utf-8 when it is valid UTF-8, application/octet-stream otherwise
Attributes tcp: tcp.remote_addr (the client's ip:port) and tcp.conn_id (a ULID per connection). udp: udp.remote_addr (the sender's ip:port)
ordering_key attribute tcp: the connection id. udp: the sender's address. With ordering and no key, one client's messages keep their order
ID a new ULID
Acknowledgements, backpressure and loss

Neither protocol acknowledges messages: there is no way to tell the client that a message is done. So there is nothing to ack, nack or resume. ack does not apply, and ack: accepted is refused.

  • tcp holds clients back. A connection reads its next frame only once the relay has taken the one before. While the relay's queue is full, the connection is not read, and TCP flow control slows the client down. This is backpressure. Nothing is dropped.
  • udp drops what the relay cannot take. A sender cannot be held back, so a message that finds the relay's queue full is dropped. Each drop is counted (Source.Dropped()). A warning with dropped_total is logged at most once every 10s, so a flood does not flood the logs too.
  • Shutdown closes everything. When the relay stops, the listener and every connection close. A frame read but not yet taken by the relay is kept if the queue has room. Otherwise it is lost and logged at warn. Bytes still in the socket buffers are lost, because tcp gives the client no acknowledgement to wait for.
  • Idle connections close after 5 minutes without data. Time spent waiting for the relay does not count.
  • A partial frame is dropped and logged at warn: one that ends a tcp stream (see Framing), or a length frame cut off by the end of a udp datagram. A udp datagram with a frame over max_body_bytes or a partial frame loses the rest of the datagram too.

Destinations

A destination writes each message to a server as one frame.

Key Meaning
url tcp://host:port, tls://host:port (TLS), udp://host:port or dtls://host:port (DTLS) (required). It cannot be a per-message template: the connection is dialed before any message. A path, query or credentials are refused.
frame, length_bytes Framing (see above).
body Per-message template of what is written (default: the message body).
tls Client TLS, DTLS for udp: ca_file, cert_file + key_file (mTLS), server_name (default: the URL's host), insecure_skip_verify. tls:// (dtls:// for udp) turns it on, and so does any of these keys on a tcp:// (udp://) URL. enabled: false turns it off, and contradicts tls:// or dtls://.

headers are refused: only the body goes on the wire.

Connections. A tcp destination with newline or length framing keeps one persistent connection. It dials on the first delivery and redials on the delivery after a failure. Deliveries take turns on the connection, so frames never interleave. A delivery waits for its turn within its own timeout. The destination reads and discards what the peer sends. So when the peer closes the connection between deliveries, the next delivery dials a new one instead of writing into the closed one. A half-close (the peer ends its side but keeps reading) looks the same. So a server that half-closes every connection gets one connection per message. With frame: raw, each message gets a connection of its own: dialed, written, then closed, because the end of the stream ends the message. When the write fails, the connection is reset instead of closed, so the peer does not take the part written as a whole message. A udp destination sends one datagram per message. Over DTLS, it sends one record per message, on one association (see DTLS).

What is written. newline appends \n unless the body already ends with one. The line ending (\n or \r\n) is not part of the message, so a body ending in one arrives without it. A body with a newline before its end would arrive as several messages. If it is JSON, it is compacted, because JSON newlines are only whitespace. Anything else fails (<kind>:newline_in_body): use frame: length or raw for multi-line text. length writes the prefix, then the body. An empty message fails (<kind>:empty_body), because no framing carries one: a source skips empty lines, frames and streams. With newline, a body that is only a line ending counts as empty.

When it counts as delivered. tcp has no acknowledgement, so a write the kernel accepted counts as delivered. A peer that dies right after it can still lose the message. A retry after a timeout can send it twice. udp cannot tell whether a datagram arrived, and neither can DTLS (see DTLS). On some systems, a datagram sent to a closed port makes the next send fail with udp:ECONNREFUSED.

mTLS over TLS 1.3. With mTLS, the server checks the client's certificate too. TLS 1.3 completes the client's handshake before the server checks that certificate, so a refusal arrives right after the handshake. When the server asked for a client certificate, the destination waits up to 200ms after the handshake for that verdict. Without the wait, a message written into the doomed connection would count as delivered. The wait happens once per connection, so once per message with frame: raw. DTLS 1.3 works the same way, and a DTLS destination waits the same way.

Each delivery is one attempt, because the relay owns retries. A failed attempt is an error with a class and tags. The class says whether hikyaku retries it by default. The tags are what retry_on and never_on match (<kind> is tcp or udp):

Failure Class Tags
The dial failed: refused, unreachable, DNS, the TLS or DTLS handshake, or a TLS 1.3 or DTLS 1.3 server that refused the client certificate transient connect, plus the socket error when there is one: <kind>:ECONNREFUSED, <kind>:EHOSTUNREACH, <kind>:ENETUNREACH, <kind>:ETIMEDOUT
A write failed on a broken connection (or a closed DTLS association), which is dropped transient connect, plus <kind>:ECONNRESET or <kind>:EPIPE when the system says so
The attempt's timeout ran out (dial, a handshake the server never answers, waiting for its turn, or a write to a peer that stopped reading) timeout timeout
A message longer than length_bytes can carry (255 bytes for 1, 65,535 for 2, ...), a udp datagram longer than the network carries, or a frame longer than one DTLS record carries (16,384 bytes) permanent <kind>:frame_too_large
With frame: newline, a body that is not JSON holds a newline permanent <kind>:newline_in_body
The message is empty permanent <kind>:empty_body
The body template failed permanent template
The destination is closed transient none

The default policy (retry_on: [connect, timeout, 5xx, 429]) retries dial and write failures and timeouts. A connection whose write failed or timed out may end with part of a frame. So it is always dropped, and the retry dials afresh. Frames written before it still arrive, and a hikyaku source drops the partial one at the end (see Framing).

Health. A tcp destination is healthy at once while its persistent connection is up. Otherwise it dials one within 2s and keeps it for the next delivery. Health never waits for a delivery: while one is dialing, and always with frame: raw, Health dials a connection of its own and closes it. A raw peer sees that as an empty stream, which is no message. A udp destination is healthy when its host resolves. A DTLS destination is checked like a tcp one, with its association.

Close waits, within its context, for a delivery in progress on the persistent connection to finish its write. Then it closes the connection: over TLS or DTLS with close_notify, unless the context ends first, which cuts the connection. Later deliveries fail.

DTLS

DTLS is TLS for datagrams: it encrypts udp the way TLS encrypts tcp. A udp source or destination with tls speaks DTLS 1.2 and 1.3, with the same keys as TLS over tcp. Version 1.3 is preferred.

Sources. The source is a DTLS server on its port. Each client's association (its DTLS session) is a peer, and a client must finish its handshake within 10s. Each record a client sends is read the way plain udp reads a datagram: the same framing, max_body_bytes, attributes (udp.remote_addr, ordering_key) and loss rules. A record the full queue cannot take is dropped and counted. Datagrams that are not DTLS are dropped. auth.ip_allow is checked before DTLS, so a sender it keeps out gets no handshake. An association that sends nothing for 5 minutes is closed, with a close_notify. So is every association when the relay stops. A client that comes back on the same address and port, without having closed its association, cannot handshake until that association idles out.

Destinations. The destination keeps one association. It dials and handshakes on the first delivery, and writes each message as one record. A frame longer than one record carries (16,384 bytes, the line ending or length prefix included) fails as udp:frame_too_large, before any dial. The destination redials:

  • after a failed write, except a record too long for the network, which never left;
  • when the server closes the association with a close_notify, or its port is closed (the system says so through ICMP);
  • before a delivery, when the association has gone unused for 30s. A server or a NAT on the way may have dropped it, and DTLS does not tell the client.

With ca_file unset, the server is verified against the system's CAs. The handshake is bounded by the attempt's timeout, or by 10s when the attempt has none.

When it counts as delivered. As for plain udp, a record the kernel accepted counts as delivered: DTLS acknowledges nothing after the handshake. A server that loses an association without closing it, such as one that crashes and restarts, drops what is written into it. The destination redials once a write meets the closed port, or once the association has gone unused for 30s.

Example

sources:
  - name: devices
    type: tcp
    listen: ":7000"
    frame: length
    length_bytes: 2
    max_body_bytes: 64KiB
    tls: { cert_file: /etc/hikyaku/tls.crt, key_file: /etc/hikyaku/tls.key, client_ca_file: /etc/hikyaku/devices-ca.crt }

  - name: syslog
    type: udp
    listen: ":5514"
    auth: { ip_allow: [10.0.0.0/8, "fd00::/8"] }

  - name: sensors
    type: udp
    listen: ":5684"
    frame: raw
    tls: { cert_file: /etc/hikyaku/tls.crt, key_file: /etc/hikyaku/tls.key, client_ca_file: /etc/hikyaku/sensors-ca.crt }

destinations:
  - name: legacy-ingest
    type: tcp
    url: tls://ingest.internal:6000
    tls: { ca_file: /etc/hikyaku/internal-ca.crt }
    frame: newline
    retry: { attempts: 5, retry_on: [connect, timeout] }

  - name: metrics
    type: udp
    url: udp://statsd.internal:8125
    frame: raw
    body: "orders.created:1|c"
    required: false

  - name: telemetry
    type: udp
    url: dtls://collector.internal:5684
    tls: { ca_file: /etc/hikyaku/internal-ca.crt, cert_file: /etc/hikyaku/relay.crt, key_file: /etc/hikyaku/relay.key }
    frame: raw

Documentation

Overview

Package tcp is hikyaku's raw socket protocol: the tcp and udp source and destination types.

A source listens. Clients connect (tcp, optionally over TLS or mTLS) or send datagrams (udp, optionally over DTLS or DTLS with client certificates), and every frame they send becomes one envelope. The frame setting says how bytes become messages: newline (one message per line, the default), length (each message after a big-endian length_bytes prefix) or raw (a tcp connection's whole stream, or one udp datagram). A udp datagram, or a DTLS record, may hold several newline or length frames. A frame that the end of a tcp stream cuts short is dropped, never passed on as a whole message.

Neither protocol acknowledges messages, so ack does not apply. A tcp connection reads its next frame only once the relay has taken the one before: while the relay's queue is full, TCP flow control holds the client back. A udp source cannot hold its senders back, so a message the full queue cannot take is dropped, counted and logged.

Every envelope has Protocol tcp or udp, a content type sniffed from its bytes (application/json, text/plain; charset=utf-8 or application/octet-stream) and the attributes tcp.remote_addr and tcp.conn_id (a ULID per connection), or udp.remote_addr. Its ordering_key is the connection (tcp) or the sender's address (udp), so one client's messages keep their order.

A destination writes each message as one frame of the same framing. A tcp destination keeps one persistent connection for newline and length framing and opens one connection per message for raw. A udp destination sends one datagram per message; over DTLS (dtls://), one record per message on one association.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Register

func Register()

Register makes the tcp and udp source and destination types available.

Types

type Sink

type Sink struct {
	// contains filtered or unexported fields
}

Sink is a tcp or udp destination.

func (*Sink) Close

func (s *Sink) Close(ctx context.Context) error

Close releases the connection, once a delivery in progress has written its frame. Over TLS it says close_notify. ctx bounds both: when it ends, the connection is cut. Deliveries after Close fail.

func (*Sink) Deliver

func (s *Sink) Deliver(ctx context.Context, env *envelope.Envelope) (*plugin.Result, error)

Deliver writes the body (or the rendered body template) as one frame. It makes one attempt: a failed write drops the connection, and the next delivery redials. A write that the kernel took counts as delivered: tcp, udp and DTLS have no acknowledgement.

func (*Sink) Health

func (s *Sink) Health(ctx context.Context) error

Health is nil when the destination can take a message now. A tcp or DTLS destination has its persistent connection (or association) up, or dials one and keeps it; while a delivery is dialing, or for tcp with raw framing, it dials one of its own and closes it. A plain udp destination resolves its host. Health never waits for a delivery.

func (*Sink) Name

func (s *Sink) Name() string

Name is the destination name.

type Source

type Source struct {
	// contains filtered or unexported fields
}

Source is a tcp or udp listen source.

func (*Source) Addr

func (s *Source) Addr() string

Addr is the address the source is bound to, "" before it listens.

func (*Source) Dropped

func (s *Source) Dropped() int64

Dropped counts the udp messages dropped because the relay's queue was full.

func (*Source) Health

func (s *Source) Health(context.Context) error

Health is nil while the source listens, else why it does not.

func (*Source) Name

func (s *Source) Name() string

Name is the source name.

func (*Source) Start

func (s *Source) Start(ctx context.Context, out chan<- *envelope.Envelope) error

Start listens until ctx ends, then closes the listener and every connection and returns nil. It fails only when it cannot listen.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL
⌂ 换网址