diff --git a/Cargo.lock b/Cargo.lock index 4b589cc..13ebecb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,7 +97,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -108,7 +108,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -382,6 +382,15 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "codepage" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f68d061bc2828ae826206326e61251aca94c1e4a5305cf52d9138639c918b4" +dependencies = [ + "encoding_rs", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -720,6 +729,47 @@ dependencies = [ "crypto-common 0.2.2", ] +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys 0.3.7", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + [[package]] name = "dispatch2" version = "0.3.1" @@ -770,19 +820,27 @@ dependencies = [ name = "dumbpipe" version = "0.39.0" dependencies = [ + "arc-swap", "clap", "data-encoding", + "dirs 6.0.0", "duct", "hex", "iroh", "iroh-tickets", + "iroh-util", "n0-error", "nix", "noq", + "notify", + "postcard", "rand", + "serde", + "service-manager", "tempfile", "tokio", "tokio-util", + "toml", "tracing", "tracing-subscriber", ] @@ -832,6 +890,26 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" +[[package]] +name = "encoding-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87b881ab2524b96a5ce932056c7482ba6152e2226fed3936b3e592adeb95ca6d" +dependencies = [ + "codepage", + "encoding_rs", + "windows-sys 0.52.0", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "enum-assoc" version = "1.3.0" @@ -856,7 +934,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -916,6 +994,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures" version = "0.3.32" @@ -1257,6 +1344,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "http" version = "1.4.1" @@ -1527,6 +1623,26 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inotify" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1" +dependencies = [ + "bitflags", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "inout" version = "0.1.4" @@ -1738,6 +1854,20 @@ dependencies = [ "serde", ] +[[package]] +name = "iroh-util" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20e41eb982f15230c55f0a70a74a514360e1f565b07861924fd0e8db172b3d00" +dependencies = [ + "derive_more", + "iroh", + "n0-error", + "n0-future", + "tokio", + "tracing", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1836,6 +1966,26 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags", + "libc", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1860,6 +2010,21 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1943,6 +2108,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -2223,13 +2389,40 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2396,6 +2589,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "os_pipe" version = "1.2.3" @@ -2403,7 +2602,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2684,6 +2883,28 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -2773,6 +2994,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -2782,8 +3016,8 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", - "windows-sys 0.60.2", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", ] [[package]] @@ -2841,7 +3075,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2933,7 +3167,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3001,6 +3235,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serdect" version = "0.4.3" @@ -3011,6 +3254,22 @@ dependencies = [ "serde", ] +[[package]] +name = "service-manager" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ff6975a4ea07dda326cf122fcc1b5cf6266daad42d1442a666a99fe50f0de9" +dependencies = [ + "cfg-if", + "dirs 4.0.0", + "encoding-utils", + "encoding_rs", + "log", + "plist", + "which", + "xml-rs", +] + [[package]] name = "sha1_smol" version = "1.0.1" @@ -3147,7 +3406,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3298,8 +3557,8 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", - "windows-sys 0.60.2", + "rustix 1.1.4", + "windows-sys 0.61.2", ] [[package]] @@ -3497,6 +3756,30 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -3513,9 +3796,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.3", ] [[package]] @@ -3524,9 +3807,15 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.3", ] +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "tower" version = "0.5.3" @@ -3948,6 +4237,18 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + [[package]] name = "widestring" version = "1.2.1" @@ -3976,7 +4277,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4328,6 +4629,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + [[package]] name = "winnow" version = "1.0.3" @@ -4471,6 +4778,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + [[package]] name = "yoke" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index b53e129..103bf41 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ clap = { version = "4.4.10", features = ["derive"] } hex = "0.4.3" iroh = { version = "1.0.0", default-features = false, features = ["tls-ring"] } iroh-tickets = "1.0.0" +iroh-util = "0.6.0" noq = "1.0.0" tokio = { version = "1.34.0", features = ["full"] } tokio-util = "0.7.10" @@ -25,11 +26,18 @@ tracing = "0.1.40" tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } data-encoding = "2.9.0" n0-error = "1.0.0" +dirs = "6.0.0" +toml = "0.9.5" +serde = { version = "1.0.228", features = ["derive"] } +postcard = { version = "1.1.1", features = ["use-std"] } +notify = "8.0.0" +arc-swap = "1.7.1" +rand = "0.10" +service-manager = "0.11.0" [dev-dependencies] duct = "1.1.1" nix = { version = "0.31", features = ["signal", "process"] } -rand = "0.10" tempfile = "3.8" [profile.release] diff --git a/README.md b/README.md index 56b7d35..8f2f52e 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,33 @@ ZELLIJ_SOCKET_DIR=/tmp/zj-remote zellij attach remote-task-1234 # Advanced features +## Daemon: many named tunnels from one config + +The `dumbpipe daemon` subcommand runs any number of incoming and outgoing +tunnels over a single endpoint, driven by a TOML config file. Each `connect` +entry exposes a local TCP port that forwards to a remote endpoint under a name, +and each `accept` entry forwards incoming named streams to a local TCP backend. +Tunnels can be protected with a shared token. + +Build the config with the `accept` and `connect` subcommands (or edit the TOML +by hand): + +``` +# On the server: expose a backend, protected by a generated token. +dumbpipe daemon accept web localhost:3000 --secure +dumbpipe daemon run # run it; prints the endpoint id and a ticket + +# On the client: forward a local port to the server's "web" tunnel. +dumbpipe daemon connect :web 127.0.0.1:8080 --token +dumbpipe daemon run +``` + +`daemon run` runs in the foreground; `daemon install` / `start` / `stop` / +`uninstall` manage it as a user-level service instead. By default the daemon +watches its config file and applies changes (added or removed tunnels) while +running. See [docs/daemon.md](docs/daemon.md) for the config format, tokens, +reloading, the service commands, key handling, and more. + ## Combining Listeners You can mix and match listeners. For example, forward from a remote Unix socket to a local TCP port: diff --git a/docs/daemon.md b/docs/daemon.md new file mode 100644 index 0000000..daa8551 --- /dev/null +++ b/docs/daemon.md @@ -0,0 +1,267 @@ +# Dumbpipe daemon + +The daemon runs many named tunnels over a single iroh endpoint, driven by a +config file. It is the multi-tunnel counterpart to the single-purpose +`listen-tcp` and `connect-tcp` subcommands: instead of one process per tunnel, +one daemon serves any number of incoming and outgoing tunnels from one endpoint +(one identity). + +``` +dumbpipe daemon [-c ] run +dumbpipe daemon [-c ] install | uninstall | start | stop | status +dumbpipe daemon [-c ] accept [--token ] [--secure] +dumbpipe daemon [-c ] connect : [--token ] +dumbpipe daemon [-c ] show +``` + +A subcommand is required; `dumbpipe daemon` with none prints help. + +- `run` runs the daemon in the foreground. +- `install` / `uninstall` / `start` / `stop` / `status` manage the daemon as a + user-level service (see [Running as a service](#running-as-a-service)). +- `accept` / `connect` edit the config file and do not run anything. +- `show` prints the configured tunnels. + +## Config file + +The config path is taken from `-c/--config` if given, and otherwise defaults to +`/dumbpipe/daemon/daemon.toml`, where `` is the platform +data directory (`~/.local/share` on Linux, `~/Library/Application Support` on +macOS; see the [`dirs`](https://crates.io/crates/dirs) crate). `run` creates an +empty config there if none exists rather than failing. + +```toml +# Watch this file and apply changes while running (see Reloading). +reload = true + +# Expose a local TCP port that forwards to a remote endpoint under a name. +[[connect]] +remote = "" +name = "boo" +addr = "localhost:13414" +token = "optional-token" # presented to the remote accept tunnel + +# Forward incoming streams with a given name to a local TCP backend. +[[accept]] +name = "foo" +addr = "localhost:31231" +token = "optional-token" # required from the connecting side +``` + +A `connect` entry binds `addr` as a local TCP listener. Every accepted socket is +forwarded to the endpoint `remote`, tagging the stream with `name`. An `accept` +entry registers a backend: an incoming stream tagged with `name` is forwarded to +that entry's `addr`. The two sides are wired together by matching names, so the +`connect` side and the `accept` side of one tunnel must agree on the name. + +`addr` values are resolved at bind and connect time, so host names such as +`localhost:13414` work as well as literal socket addresses. + +### The `remote` field + +`remote` accepts either form: + +- A bare endpoint id (hex or base32). The remote is then resolved through + discovery, exactly like a dumbpipe short ticket. +- A full endpoint ticket that also carries relay and direct-address hints. The + hints are seeded into the endpoint's address book (see + [Connection handling](#connection-handling)), so the remote can be reached + without waiting on discovery. + +### Tokens + +A tunnel can be protected with a shared token. When an `accept` entry has a +`token`, an incoming stream is forwarded only if the connecting side presents +the same token; otherwise the stream is dropped. An `accept` entry without a +token accepts any matching stream. The token travels inside the per-stream +[header](#stream-header), which is carried over the encrypted iroh connection. + +## Editing the config from the command line + +The `accept` and `connect` subcommands append an entry to the config file +(creating it if needed), so the config can be built up without editing TOML by +hand. With [reloading](#reloading) on, a running daemon picks the change up +immediately. + +``` +# Add an accept backend named "web", protected by a generated token. +dumbpipe daemon accept web localhost:3000 --secure +# added accept web -> localhost:3000 +# token: MZUW4Z3FOJSWG5DBNVSXG43F + +# Or set the token explicitly. +dumbpipe daemon accept ssh localhost:22 --token hunter2 + +# Add a connect tunnel. The first argument is remote:name. +dumbpipe daemon connect :web 127.0.0.1:8080 --token MZUW4Z3FOJSWG5DBNVSXG43F +``` + +`--secure` generates a random token of 16 base32-encoded bytes and prints it. +`--secure` and `--token` are mutually exclusive. + +## Running as a service + +The daemon can install itself as a user-level service via +[`service-manager`](https://crates.io/crates/service-manager), which uses the +platform's native service manager (systemd user units on Linux, launchd on +macOS, and so on): + +``` +dumbpipe daemon -c config.toml install # install a service that runs `daemon run -c ` +dumbpipe daemon start # start it +dumbpipe daemon status # not installed | stopped | running +dumbpipe daemon stop # stop it +dumbpipe daemon uninstall # remove it +``` + +`install` records the absolute config path so the service finds it regardless of +its working directory, and enables start at login. The service runs `daemon run` +in the foreground under the service manager. No elevated permissions are +required: the service is installed at the user level (a systemd user unit, a +launchd user agent, and so on). + +On macOS the commands also work over SSH. The default `launchctl load` path +targets the GUI login session, which an SSH connection does not have, so the +commands fall back to managing the agent in the per-user (Background) launchd +domain (`user/`) that an SSH session does have. This still needs no elevated +permissions. One caveat: a per-user agent runs while that user has a session and +is not guaranteed to restart after a reboot with nobody logged in; for +unconditional restart on a headless machine, a system-level daemon is required, +which does need elevated permissions to install. + +User-level services are not supported on every platform; failures print the +underlying service-manager error. + +## Reloading + +When `reload` is `true` (the default, and what the subcommands write), the +daemon watches the config file with [`notify`](https://crates.io/crates/notify) +and applies changes while running: + +- accept routes are swapped in atomically; +- connect tunnels are reconciled by local address: tunnels that disappeared or + changed are stopped, and newly added ones are bound and started; +- relay and address hints for new remotes are registered. + +A bad edit (unparseable file, a remote that fails to parse, an address that +fails to bind) is logged and skipped; the daemon keeps running with the entries +that are still valid. Set `reload = false` to load the config once at startup +and ignore later changes. + +## Secret key + +The daemon uses a stable identity so that its endpoint id does not change across +restarts. The secret key is resolved in this order: + +1. The `IROH_SECRET` environment variable (hex or base32), if set. +2. `/dumbpipe/daemon/secret.key`, a 32-byte lowercase hex key. +3. Otherwise a fresh key is generated and written to `secret.key` for next time. + +## Output + +On startup, after the endpoint comes online, the daemon prints to stdout: + +- `short addr:` followed by its endpoint id, as hex; +- `long addr:` followed by its ticket, in the same format the `remote` field + accepts, so it can be pasted into a connecting daemon's config; +- one line per configured tunnel, the same listing `daemon show` prints. + +``` +short addr: f2e16a92c17a40ceb7bbb6e6f216ad98f59f7708f32ff24bf8ed6335c908bb1d + long addr: endpointadzoc2usyf5ebtvxxo3on4qwvwmplh3xbdzs74sl7dwwgnojbc5r2ay... +connect 127.0.0.1:8080 -> :web [token] +accept web -> localhost:3000 [token] +accept ssh -> localhost:22 +``` + +A `connect` line reads `connect -> :`, an `accept` +line reads `accept -> `, and `[token]` marks a +token-protected tunnel. The id and the ticket are both valid `remote` values; +the ticket additionally carries the relay and address hints. + +`daemon show` prints the same tunnel listing without starting the daemon. + +## Logging + +The daemon logs lifecycle events (endpoint binding, tunnel listeners, reloads) +at `info`, and per-forward connect/disconnect events inside tracing spans: + +``` +incoming{remote=f198fe07e6}:tcp{name=foo target=localhost:31231}: connected +incoming{remote=f198fe07e6}:tcp{name=foo target=localhost:31231}: disconnected +outgoing{remote=69696f31c6}:tcp{name=foo target=127.0.0.1:58084}: connected +outgoing{remote=69696f31c6}:tcp{name=foo target=127.0.0.1:58084}: disconnected +``` + +An accepted iroh connection runs in an `incoming{remote}` span and each +forwarded stream in a child `tcp{name, target}` span, where `target` is the +backend address; the connect side mirrors this with `outgoing{remote}` and +`tcp{name, target}`, where `target` is the local client. `remote` is the short +endpoint id. + +When `RUST_LOG` is unset the daemon defaults to `dumbpipe=info,iroh=info`; set +`RUST_LOG` to override (for example `RUST_LOG=dumbpipe=debug`). + +## Stream header + +The daemon speaks its own ALPN, distinct from the single-tunnel dumbpipe +protocol. A single endpoint multiplexes several tunnels, so each stream begins +with a header that says which backend it is for and carries the optional token. +On a fresh bidi stream the connecting side writes, before any data: + +``` +header length (u32, big-endian) || postcard-encoded { name, token } +``` + +The accepting side reads the length (capped to bound allocation) and decodes the +header, routes the stream to the matching `accept` backend, and checks the token. +A stream whose name has no matching backend, or whose token does not match, is +dropped with a warning. + +## Connection handling + +The connect side reuses one iroh connection per remote across TCP streams, +rather than dialing afresh for every connection. Connections are managed by a +shared pool keyed by endpoint id; a connection is kept warm for a short idle +period and reused by later streams, then closed once unused. If a connect +attempt fails, it is retried once, which covers a transient discovery or relay +hiccup. + +Because the pool connects by endpoint id, relay and direct-address hints from a +ticket-form `remote` are registered with the endpoint as a static address +lookup. This runs alongside the default discovery, so a ticket's hints let the +daemon connect immediately while bare ids fall back to discovery. + +## Shutdown + +The daemon runs until interrupted with ctrl-c, at which point it stops accepting +new connections, cancels its listeners, and closes the endpoint. + +## Example + +Forward a web server and an SSH server from one machine to local ports on +another, all from one daemon on each side. + +On the server machine: + +``` +dumbpipe daemon -c server.toml accept web localhost:3000 +dumbpipe daemon -c server.toml accept ssh localhost:22 --secure +# token: MZUW4Z3FOJSWG5DBNVSXG43F +dumbpipe daemon -c server.toml run +# short addr: +# long addr: +``` + +On the client machine, using the server's id or ticket: + +``` +dumbpipe daemon -c client.toml connect :web 127.0.0.1:8080 +dumbpipe daemon -c client.toml connect :ssh 127.0.0.1:2222 --token MZUW4Z3FOJSWG5DBNVSXG43F +dumbpipe daemon -c client.toml run +``` + +The client can now reach the server's web server at `127.0.0.1:8080` and its SSH +server at `127.0.0.1:2222`, both over a single reused iroh connection. Because +the configs were built with the subcommands, `reload` is on, so the tunnels +added on each side take effect without restarting the daemons. diff --git a/src/daemon.rs b/src/daemon.rs new file mode 100644 index 0000000..2628c41 --- /dev/null +++ b/src/daemon.rs @@ -0,0 +1,1236 @@ +//! The dumbpipe daemon. +//! +//! The daemon runs many named tunnels over a single iroh endpoint, driven by a +//! TOML config file. It is the multi-tunnel counterpart to the single-purpose +//! `listen-tcp` / `connect-tcp` subcommands. +//! +//! A `[[connect]]` entry binds a local TCP port and forwards every accepted +//! socket to a remote endpoint under a name. A `[[accept]]` entry forwards +//! incoming named streams to a local TCP backend selected by that name. Because +//! one endpoint serves several tunnels, each stream is prefixed with a [`Header`] +//! carrying the name and an optional token. +//! +//! The daemon speaks its own ALPN ([`DAEMON_ALPN`]), distinct from the +//! single-tunnel dumbpipe protocol. + +use std::{ + collections::HashMap, + ffi::OsString, + path::{Path, PathBuf}, + str::FromStr, + sync::Arc, + time::Duration, +}; + +use arc_swap::ArcSwap; +use clap::{Parser, Subcommand}; +use dumbpipe::EndpointTicket; +use iroh::{address_lookup::MemoryLookup, EndpointAddr, EndpointId, SecretKey}; +use iroh_util::connection_pool::{ConnectionPool, ConnectionRef, Options}; +use n0_error::{bail_any, ensure_any, Result, StdResultExt}; +use notify::Watcher; +use serde::{Deserialize, Serialize}; +use service_manager::{ + RestartPolicy, ServiceInstallCtx, ServiceLabel, ServiceLevel, ServiceManager, ServiceStartCtx, + ServiceStatus, ServiceStatusCtx, ServiceStopCtx, ServiceUninstallCtx, +}; +use tokio::{ + io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}, + net::{TcpListener, TcpStream}, + select, + sync::mpsc, + time::timeout, +}; +use tokio_util::{sync::CancellationToken, task::AbortOnDropHandle}; +use tracing::{debug, info, info_span, warn, Instrument}; + +use crate::CommonArgs; + +/// The ALPN spoken by the daemon protocol. +/// +/// Distinct from [`dumbpipe::ALPN`] so a daemon and a plain `dumbpipe connect` +/// never accidentally talk to each other; the daemon also frames every stream +/// with a [`Header`] rather than the fixed handshake. +const DAEMON_ALPN: &[u8] = b"DUMBPIPEDAEMON0"; + +/// The maximum size of an encoded [`Header`], in bytes. +/// +/// The accepting side reads the header length from the wire before allocating, +/// so this bounds the allocation a peer can request. +const MAX_HEADER_LEN: usize = 4096; + +/// The number of random bytes in a `--secure` token, before base32 encoding. +const SECURE_TOKEN_BYTES: usize = 16; + +/// How long the connection pool keeps an idle connection before closing it. +/// +/// Connect tunnels reuse one iroh connection per remote across many TCP +/// streams; this keeps the connection warm between bursts of streams. +const POOL_IDLE_TIMEOUT: Duration = Duration::from_secs(30); + +/// How long the connection pool waits for a connect to complete. +/// +/// Connecting by endpoint id alone goes through discovery and a relay, so this +/// is generous compared to the pool's one-second default. +const POOL_CONNECT_TIMEOUT: Duration = Duration::from_secs(15); + +/// How long to wait after a config change before reloading, to coalesce the +/// burst of filesystem events a single save produces. +const RELOAD_DEBOUNCE: Duration = Duration::from_millis(200); + +/// The label identifying the daemon service to the platform service manager. +/// +/// A single-component label keeps the systemd unit named `dumbpipe.service` +/// rather than the `{organization}-{application}` form a qualified label yields. +const SERVICE_LABEL: &str = "dumbpipe"; + +/// Arguments for the `daemon` subcommand. +#[derive(Parser, Debug)] +#[command(arg_required_else_help = true)] +pub struct DaemonArgs { + #[clap(subcommand)] + pub command: DaemonCommand, + + /// Path to the daemon config file. + /// + /// Defaults to `/dumbpipe/daemon/daemon.toml`, where `data_dir` + /// is the platform data directory (see the `dirs` crate). + #[clap(short = 'c', long, global = true)] + pub config: Option, + + #[clap(flatten)] + pub common: CommonArgs, +} + +/// The daemon subcommands. +#[derive(Subcommand, Debug)] +pub enum DaemonCommand { + /// Install the daemon as a user-level service. + Install, + + /// Stop and remove the daemon service. + Uninstall, + + /// Start the installed daemon service. + Start, + + /// Stop the running daemon service. + Stop, + + /// Show the daemon service status. + Status, + + /// Run the daemon in the foreground. + Run, + + /// Add an accept tunnel to the config file. + Accept(AcceptCmd), + + /// Add a connect tunnel to the config file. + Connect(ConnectCmd), + + /// Print the configured connect and accept tunnels. + Show, +} + +/// Arguments for `daemon accept`. +#[derive(Parser, Debug)] +pub struct AcceptCmd { + /// The name that selects this backend. + pub name: String, + + /// The local TCP backend address to forward matching streams to. + pub addr: String, + + /// Require this token from the connecting side. + #[clap(long)] + pub token: Option, + + /// Generate a random token (16 base32 bytes) instead of passing one. + #[clap(long)] + pub secure: bool, +} + +/// Arguments for `daemon connect`. +#[derive(Parser, Debug)] +pub struct ConnectCmd { + /// The remote and name, written as `remote:name`. + /// + /// `remote` is an endpoint id or ticket; `name` selects the accept tunnel + /// on the remote daemon. + pub remote_name: String, + + /// The local TCP address to listen on. + pub addr: String, + + /// Token required by the remote accept tunnel, if any. + #[clap(long)] + pub token: Option, +} + +/// The daemon config, parsed from and written to TOML. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Config { + /// Whether to watch the config file and apply changes while running. + #[serde(default = "default_true")] + reload: bool, + /// Outgoing tunnels: local TCP port -> remote endpoint, under a name. + #[serde(default)] + connect: Vec, + /// Incoming tunnels: named stream -> local TCP backend. + #[serde(default)] + accept: Vec, +} + +impl Default for Config { + fn default() -> Self { + Self { + reload: true, + connect: Vec::new(), + accept: Vec::new(), + } + } +} + +fn default_true() -> bool { + true +} + +/// A single `[[connect]]` entry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct ConnectConfig { + /// The remote endpoint id or ticket to forward to. + remote: String, + /// The name sent in the header, used by the remote to route the stream. + name: String, + /// The local TCP address to listen on. + addr: String, + /// Token to present to the remote accept tunnel, if it requires one. + #[serde(default, skip_serializing_if = "Option::is_none")] + token: Option, +} + +/// A single `[[accept]]` entry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct AcceptConfig { + /// The name that selects this backend. + name: String, + /// The local TCP backend to forward matching streams to. + addr: String, + /// Token the connecting side must present, if set. + #[serde(default, skip_serializing_if = "Option::is_none")] + token: Option, +} + +/// The per-stream header, postcard-encoded ahead of any forwarded data. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct Header { + /// The tunnel name, used by the accepting daemon to route the stream. + name: String, + /// The token to authorize the stream, if the connect side has one. + #[serde(default)] + token: Option, +} + +/// A resolved accept route: where to forward and the token it requires. +#[derive(Debug, Clone)] +struct AcceptRoute { + addr: String, + token: Option, +} + +/// Accept routes, keyed by name. Swapped atomically on reload. +type Routes = HashMap; + +/// A running connect tunnel. Dropping it aborts the listener task. +struct ConnectTunnel { + config: ConnectConfig, + _handle: AbortOnDropHandle<()>, +} + +/// Dispatches the `daemon` subcommand. +pub(crate) async fn run(args: DaemonArgs) -> Result<()> { + let config_path = match args.config { + Some(path) => path, + None => daemon_dir()?.join("daemon.toml"), + }; + match args.command { + DaemonCommand::Install => cmd_install(&config_path), + DaemonCommand::Uninstall => cmd_uninstall(), + DaemonCommand::Start => cmd_service_start(), + DaemonCommand::Stop => cmd_service_stop(), + DaemonCommand::Status => cmd_service_status(), + DaemonCommand::Run => run_foreground(config_path, args.common).await, + DaemonCommand::Accept(cmd) => cmd_accept(&config_path, cmd), + DaemonCommand::Connect(cmd) => cmd_connect(&config_path, cmd), + DaemonCommand::Show => cmd_show(&config_path), + } +} + +/// Builds the platform service manager, set to manage user-level services. +fn service_manager() -> Result> { + let mut manager = + ::native().std_context("no supported service manager found")?; + manager + .set_level(ServiceLevel::User) + .std_context("user-level services are not supported on this platform")?; + Ok(manager) +} + +/// The service label for the daemon. +fn service_label() -> ServiceLabel { + SERVICE_LABEL.parse().expect("service label is valid") +} + +/// Installs the daemon as a user-level service that runs `daemon run`. +fn cmd_install(config_path: &Path) -> Result<()> { + let manager = service_manager()?; + let label = service_label(); + let program = + std::env::current_exe().std_context("could not determine the dumbpipe binary path")?; + // Pass an absolute config path so the service finds it regardless of the + // working directory it is launched in. + let config = std::path::absolute(config_path) + .with_std_context(|_| format!("could not resolve config path {}", config_path.display()))?; + let installed = manager.install(ServiceInstallCtx { + label: label.clone(), + program, + args: vec![ + OsString::from("daemon"), + OsString::from("run"), + OsString::from("-c"), + config.clone().into_os_string(), + ], + contents: None, + username: None, + working_directory: None, + environment: None, + autostart: true, + restart_policy: RestartPolicy::default(), + }); + // service-manager loads the agent via `launchctl load`, which aborts over + // SSH because that targets the GUI domain. The plist is written before that + // step, so fall back to bootstrapping it into the per-user domain. + #[cfg(target_os = "macos")] + let installed = installed.or_else(|_| macos::install(&label)); + installed.std_context("failed to install service")?; + println!("installed dumbpipe daemon service {label}"); + println!("config: {}", config.display()); + println!("start it with: dumbpipe daemon start"); + Ok(()) +} + +/// Stops and removes the daemon service. +fn cmd_uninstall() -> Result<()> { + let manager = service_manager()?; + let label = service_label(); + let removed = manager.uninstall(ServiceUninstallCtx { + label: label.clone(), + }); + #[cfg(target_os = "macos")] + let removed = removed.or_else(|_| macos::uninstall(&label)); + removed.std_context("failed to uninstall service")?; + println!("uninstalled dumbpipe daemon service {label}"); + Ok(()) +} + +/// Starts the installed daemon service. +fn cmd_service_start() -> Result<()> { + let manager = service_manager()?; + let label = service_label(); + let started = manager.start(ServiceStartCtx { + label: label.clone(), + }); + #[cfg(target_os = "macos")] + let started = started.or_else(|_| macos::start(&label)); + started.std_context("failed to start service")?; + println!("started dumbpipe daemon service {label}"); + Ok(()) +} + +/// Stops the running daemon service. +fn cmd_service_stop() -> Result<()> { + let manager = service_manager()?; + let label = service_label(); + let stopped = manager.stop(ServiceStopCtx { + label: label.clone(), + }); + #[cfg(target_os = "macos")] + let stopped = stopped.or_else(|_| macos::stop(&label)); + stopped.std_context("failed to stop service")?; + println!("stopped dumbpipe daemon service {label}"); + Ok(()) +} + +/// Prints the daemon service status. +fn cmd_service_status() -> Result<()> { + let manager = service_manager()?; + let label = service_label(); + let status = manager.status(ServiceStatusCtx { + label: label.clone(), + }); + // service-manager queries the GUI domain and reports a user-domain service as + // not installed, so on macOS also check the per-user domain. + #[cfg(target_os = "macos")] + let status = match status { + Ok(ServiceStatus::NotInstalled) | Err(_) => macos::status(&label), + installed => installed, + }; + let status = status.std_context("failed to query service status")?; + match status { + ServiceStatus::NotInstalled => println!("not installed"), + ServiceStatus::Running => println!("running"), + ServiceStatus::Stopped(Some(reason)) => println!("stopped: {reason}"), + ServiceStatus::Stopped(None) => println!("stopped"), + } + Ok(()) +} + +/// macOS fallbacks that manage the launchd agent in the per-user (Background) +/// domain. +/// +/// An SSH session has the `user/` domain but not the `gui/` domain +/// that `launchctl load` (used by service-manager) targets, so the standard +/// path aborts over SSH. These run the equivalent modern, domain-targeted +/// `launchctl` commands against `user/`. No elevated permissions are +/// required. +#[cfg(target_os = "macos")] +mod macos { + use std::{io, path::PathBuf, process::Command}; + + use service_manager::{ServiceLabel, ServiceStatus}; + + /// Runs `launchctl` with `args`, turning a non-zero exit into an error that + /// carries the command's stderr. + fn launchctl(args: &[&str]) -> io::Result<()> { + let output = Command::new("launchctl").args(args).output()?; + if output.status.success() { + Ok(()) + } else { + Err(io::Error::other(format!( + "launchctl {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr).trim() + ))) + } + } + + /// Returns the current user id via `id -u`. + fn uid() -> io::Result { + let output = Command::new("id").arg("-u").output()?; + if !output.status.success() { + return Err(io::Error::other("failed to run `id -u`")); + } + String::from_utf8_lossy(&output.stdout) + .trim() + .parse() + .map_err(|_| io::Error::other("could not parse the user id")) + } + + /// The path of the launchd agent plist that service-manager writes. + fn plist_path(label: &ServiceLabel) -> io::Result { + let home = dirs::home_dir() + .ok_or_else(|| io::Error::other("could not find the home directory"))?; + Ok(home + .join("Library") + .join("LaunchAgents") + .join(format!("{}.plist", label.to_qualified_name()))) + } + + /// The `user//