Tunnel¶
The otto.tunnel package builds and tears down host-resident
bidirectional tunnels — one end-to-end forwarding path per add_tunnel
call, realized as mirrored chains of tagged processes riding the topology
edges modelled by otto.link. What each process actually executes is a
pluggable carrier (otto.tunnel.carrier’s TunnelCarrier contract +
CARRIERS registry); socat remains the first-party default.
otto.tunnel.manage and otto.tunnel.discovery are the callable
library API behind otto tunnel add / list / remove;
otto.tunnel.socat is the socat carrier plus the pure command-builder
layer it wraps, and otto.tunnel.sentinel is the argv-tag codec that
makes every running process self-describing. For CLI usage, multi-hop
chains, docker endpoints, and host requirements, see the
user guide.
otto.tunnel — host-resident bidirectional tunnels (#2b spec).
Library-first: the CLI is a thin consumer of this package’s callable API. Grown task-by-task; final re-export surface lands with the manage layer.
Runtime Tunnel model — one end-to-end forwarding path (#2b spec §4).
A tunnel is what one otto tunnel add builds: service port + protocol +
ordered host path (+ optional far-end delivery override). Its per-hop
segments conceptually ride links (otto.link edges), but the
packages are fully decoupled: NEITHER imports the other; shared daemon
machinery lives in otto.host.daemon.
- class otto.tunnel.model.Direction(value)¶
Bases:
EnumWhich mirrored chain a process belongs to (spec §6.1).
-
FWD =
'fwd'¶ First-listed host toward last-listed host.
-
REV =
'rev'¶ Last-listed host toward first-listed host.
-
FWD =
- class otto.tunnel.model.Role(value)¶
Bases:
EnumWhat a tagged process does within its direction’s chain.
-
INGRESS =
'ingress'¶ Binds the service port on its endpoint’s data-plane ip.
-
RELAY =
'relay'¶ TCP carrier pass-through on an intermediate hop.
-
EGRESS =
'egress'¶ Delivers carrier traffic to the local service (or
--dest).
-
INGRESS =
- otto.tunnel.model.ProcKey¶
One expected/observed tunnel process:
(host_id, direction, role).
-
class otto.tunnel.model.TunnelHop(host: str, interface: str | None =
None)¶ Bases:
objectOne position in a tunnel’s ordered path.
- otto.tunnel.model.make_tunnel_id(path: tuple[TunnelHop, ...], protocol: str, service_port: int) str¶
Deterministic id:
tun-<12 hex>-<service_port>(spec §4).The hash covers the ordered chain + protocol; the service port is a readable suffix, not hashed (same scheme as the retired
lnk-<hex>-<port>).a,c,b!=b,c,a— a reversed duplicate is rejected by the endpoint-bind conflict rule instead (spec §7).
-
class otto.tunnel.model.Tunnel(protocol: str, service_port: int, path: tuple[TunnelHop, ...], dest: str | None =
None, id: str ='')¶ Bases:
objectOne end-to-end forwarding path (one
add= one tunnel, spec D4).- dest : str | None¶
Far-end delivery override host id (
--dest), orNonefor loopback delivery on the last-listed host (spec §6.3).
- id : str¶
Auto-computed via
make_tunnel_id()when empty. A sentinel-parsed tunnel passes its wire id through verbatim (never recomputed).
- expected_processes() set[tuple[str, Direction, Role]]¶
Return the
2 * len(path)processes a healthy tunnel runs (spec §6.1).Every host carries exactly two: its FWD-chain process and its REV-chain process, with the role determined by position (ingress at the direction’s origin, egress at its far end, relay between).
Sentinel v2 wire format: the argv marker every tunnel process carries (spec §5).
The running processes on the hosts ARE the tunnel record — zero persisted state. Each process is launched with argv[0]:
otto-tunnel:v1:<id>:<proto>:<svc-port>:<carrier-port>:<direction>:<role>:<hop-index>:<dest>:<path>
Prefix + version + 9 payload segments (framing: otto.host.daemon), each
percent-encoded; empty segment = None. The path segment carries the
full fwd-ordered chain (entries host@iface
or bare host, each percent-encoded, joined with ,, the joined string
percent-encoded once more), so any single surviving process reconstructs the
entire intended tunnel — the record survives every other host being down.
Owner-agnostic (no username). Unknown versions/prefixes parse to None,
never an error. STABILITY CONTRACT restarts at otto-tunnel:v1: from the
first release with users, evolve only by adding versions and keeping old
ones parseable. (The otto-link:v1 era predates users and is deleted.)
- class otto.tunnel.sentinel.ParsedSentinel(tunnel: Tunnel, direction: Direction, role: Role, hop_index: int, carrier_port: int)¶
Bases:
objectOne decoded tunnel process: the whole tunnel + this process’s slot.
- otto.tunnel.sentinel.encode_sentinel(tunnel: Tunnel, *, direction: Direction, role: Role, hop_index: int, carrier_port: int) str¶
Return the wire token for one process of tunnel.
- otto.tunnel.sentinel.parse_sentinel(token: str) ParsedSentinel | None¶
Parse one wire token;
Nonefor non-otto / other-version / malformed.
Pluggable tunnel carriers: the TunnelCarrier contract + CARRIERS registry.
Mirrors the impairer registry (otto.link.impairer): custom carriers
register from init modules under a name; otto tunnel add --carrier selects
one per tunnel (chain-wide). Socat is the only first-party registrant
(otto.tunnel.socat).
A carrier decides what each tagged process EXECUTES — nothing more. The 2n process topology (ingress/relay/egress x fwd/rev), the sentinel v1 wire format, free-port allocation, discovery, verify, and remove are all carrier-agnostic (spec 2026-07-11). The carrier name is deliberately not on the wire: a tunnel’s identity is its path+protocol+port, and remove reaps by pid, so it tears down any carrier’s processes.
-
otto.tunnel.carrier.DEFAULT_CARRIER =
'socat'¶ Name of the first-party carrier — the
--carrierdefault at the CLI and theadd_tunneldefault in the library; one source of truth for both.
- class otto.tunnel.carrier.TunnelCarrier¶
Bases:
objectBuilds the argv each tunnel process executes.
Stateless: implementations build argv lists; the orchestration layer (
otto.tunnel.manage) launches them on hosts viaotto.host.daemon.launch_command.-
supported_protocols : ClassVar[frozenset[str]] =
frozenset({})¶ Service protocols this carrier can forward (e.g.
frozenset({"tcp"})).
-
requirements_command : ClassVar[str] =
''¶ Complete shell probe run on every chain host; prints the bare word
okon a line of its own iff satisfied (the check is a whole-line match, so failure text may safely contain the substringok).
-
tools_description : ClassVar[str] =
''¶ Human summary of the required tools, for the missing-tools error.
- ingress_args(protocol: str, service_port: int, bind_ip: str, next_ip: str, carrier_port: int) list[str]¶
Argv accepting client traffic on the service port, shipping to the carrier.
- relay_args(carrier_port: int, next_ip: str) list[str]¶
Argv for an intermediate-hop pass-through (same carrier port both sides).
- egress_args(protocol: str, service_port: int, deliver_ip: str, carrier_port: int) list[str]¶
Argv accepting the carrier and delivering to the local service.
-
supported_protocols : ClassVar[frozenset[str]] =
-
otto.tunnel.carrier.register_carrier(name: str, cls: type[TunnelCarrier], *, overwrite: bool =
False) None¶ Make a custom carrier selectable via
--carrier <name>.Call from an init module listed in
.otto/settings.toml. The carrier must declare a non-emptyTunnelCarrier.supported_protocols; otherwise it could never validate any tunnel and is rejected here.
- otto.tunnel.carrier.build_carrier(name: str) type[TunnelCarrier]¶
Return the carrier class registered under name (rich unknown-name error).
The socat carrier: pure command/argv builders + the SocatCarrier registrant — no I/O.
Bidirectional ingress/relay/egress builders (#2b); every value is a string or
list of strings destined for host.exec; running nothing keeps the whole
module unit-testable (assert exact argv).
-
otto.tunnel.socat.FREE_PORT_PROBE_COMMAND : str =
'ss -Htln 2>/dev/null || netstat -tln 2>/dev/null || true'¶ Free-port probe run on each chain host —
sspreferred,netstatfallback (both exist on CentOS 6). Parsed byparse_listening_ports().
- otto.tunnel.socat.ingress_socat_args(protocol: str, service_port: int, bind_ip: str, next_ip: str, carrier_port: int) list[str]¶
Accept client traffic on the service port, ship over the TCP carrier.
Binds the endpoint’s data-plane ip specifically (never wildcard) so the reverse chain’s loopback delivery on this same host cannot U-turn into this listener (spec §6.3 loop hazard).
- otto.tunnel.socat.relay_socat_args(carrier_port: int, next_ip: str) list[str]¶
Intermediate-hop pass-through: same carrier port on both sides (§6.2).
- otto.tunnel.socat.egress_socat_args(protocol: str, service_port: int, deliver_ip: str, carrier_port: int) list[str]¶
Accept the TCP carrier, deliver to the service (loopback or
--dest).
- otto.tunnel.socat.parse_listening_ports(output: str) set[int]¶
Extract every port appearing as
:<port>in ss/netstat output.Safe superset of used ports — we only need to avoid them.
-
otto.tunnel.socat.pick_free_port(used: set[int], lo: int =
49152, hi: int =65535) int¶ First port in
[lo, hi]not inused. Raises when exhausted.
- class otto.tunnel.socat.SocatCarrier¶
Bases:
TunnelCarriersocat over a TCP4 carrier — the first-party tunnel transport (#2b).
-
supported_protocols : ClassVar[frozenset[str]] =
frozenset({'tcp', 'udp'})¶ Service protocols this carrier can forward (e.g.
frozenset({"tcp"})).
-
requirements_command : ClassVar[str] =
'command -v socat >/dev/null 2>&1 && command -v bash >/dev/null 2>&1 && echo ok || echo no'¶ Complete shell probe run on every chain host; prints the bare word
okon a line of its own iff satisfied (the check is a whole-line match, so failure text may safely contain the substringok).
-
tools_description : ClassVar[str] =
'socat and/or bash'¶ Human summary of the required tools, for the missing-tools error.
- ingress_args(protocol: str, service_port: int, bind_ip: str, next_ip: str, carrier_port: int) list[str]¶
Delegate to
ingress_socat_args()(the proven builder).
- relay_args(carrier_port: int, next_ip: str) list[str]¶
Delegate to
relay_socat_args().
- egress_args(protocol: str, service_port: int, deliver_ip: str, carrier_port: int) list[str]¶
Delegate to
egress_socat_args().
-
supported_protocols : ClassVar[frozenset[str]] =
Live tunnel discovery — the processes on the hosts ARE the record (spec §9).
_scan_hosts gathers DISCOVERY_PS_COMMAND across hosts (best-effort,
bounded, transparent about unreachables); parse_process_discovery decodes
each tagged process; and discover_tunnels groups observations by tunnel
id, comparing what was observed against what the sentinel-encoded path says
must exist.
-
otto.tunnel.discovery.DISCOVERY_PS_COMMAND : str =
"ps -eo pid= -eo etime= -eo args= 2>/dev/null | \\grep -a ' otto-tunnel:' || true"¶ The lab-wide daemon scan for tunnel processes. Built by
otto.host.daemon.ps_scan_command()— see it for the procps portability story. The bytes are pinned by TestPsScanCommand in tests/unit/host/test_daemon.py (STABILITY CONTRACT).
- class otto.tunnel.discovery.Observation(pid: int, age_seconds: int, parsed: ParsedSentinel)¶
Bases:
objectOne tagged tunnel process seen on one host.
- parsed : ParsedSentinel¶
- otto.tunnel.discovery.parse_process_discovery(ps_output: str) list[Observation]¶
Reconstruct observations from
DISCOVERY_PS_COMMANDoutput.
- async otto.tunnel.discovery.discover_observations(lab: Lab) tuple[list[tuple[str, Observation]], list[str]]¶
Every tagged tunnel process across the lab’s
has_bashhosts.
- class otto.tunnel.discovery.DiscoveredTunnel(tunnel: Tunnel, present: set[tuple[str, Direction, Role]], missing: set[tuple[str, Direction, Role]], age_seconds: int, uncertain: bool)¶
Bases:
objectOne live tunnel: intended shape + what was actually observed.
- class otto.tunnel.discovery.TunnelDiscovery(tunnels: list[DiscoveredTunnel], unreachable: list[str])¶
Bases:
objectA full scan: the tunnels seen plus the hosts that couldn’t be scanned.
- tunnels : list[DiscoveredTunnel]¶
- otto.tunnel.discovery.group_observations(observations: list[tuple[str, Observation]], unreachable: list[str]) list[DiscoveredTunnel]¶
Group per-host observations by tunnel id and compute per-tunnel status.
- async otto.tunnel.discovery.discover_tunnels(lab: Lab) TunnelDiscovery¶
Discover live otto tunnels across the lab (the monitor-facing surface).
Async orchestration for tunnels — the callable library API (spec §6-§8, §10-§12).
The CLI is a thin consumer of add_tunnel / remove_tunnel /
remove_all_tunnels; each is usable standalone from any Python code.
- class otto.tunnel.manage.ResolvedHop(hop: TunnelHop, ip: str, host: Any)¶
Bases:
objectOne chain position resolved against the live lab.
- class otto.tunnel.manage.AddedTunnel(tunnel: Tunnel, carrier_fwd: int, carrier_rev: int)¶
Bases:
objectA successfully added + verified tunnel.
-
async otto.tunnel.manage.add_tunnel(lab: Lab, hosts: list[tuple[str, str | None]], *, port: int, protocol: str =
'tcp', dest: tuple[str, str | None] | None =None, carrier: str ='socat') AddedTunnel¶ Build a bidirectional host-resident tunnel and verify it came up (spec §6).
Launch order is downstream-first per direction; any launch failure or a failed post-add verify reaps everything already started — no half-tunnels survive a failed add. “Started” is tracked from the moment a launch is attempted, not from a confirmed ack: a launch
execthat times out only bounds how long we waited for the reply, not whether the command reached the host, so even a first-launch timeout triggers rollback. The carrier names a registeredTunnelCarrier(chain-wide; default"socat").
- class otto.tunnel.manage.RemovedReport(removed_ids: list[str], killed: dict[str, list[int]], unreachable: list[str], survivors: list[tuple[str, int]])¶
Bases:
objectWhat a reap pass tore down — and what refused to die (spec §10).
- async otto.tunnel.manage.remove_tunnel(lab: Lab, tunnel_id: str) RemovedReport¶
Reap one tunnel by id, then verify its processes are actually gone.
- async otto.tunnel.manage.remove_all_tunnels(lab: Lab) RemovedReport¶
Reap every otto tunnel (owner-agnostic), with the same verify pass.