From bfaa123c713548811a109ca563da493e82e0bd5d Mon Sep 17 00:00:00 2001 From: "Graciliano M. P." Date: Sun, 12 Jul 2026 18:55:39 -0300 Subject: [PATCH 1/2] Raise omnydrive constraint to ^1.12.2 (1.55.3) omnydrive 1.12.2 is itself just the omnyhub ^1.3.0 bump. The previous ^1.12.1 constraint already resolved to it, so this only makes the constraint name the version OmnyShell is built and tested against. No behaviour change and no API change: the full suite passes unmodified (808 pass, 9 skipped). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 9 +++++++++ lib/src/version.dart | 2 +- pubspec.yaml | 4 ++-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41a1ed3..ca72555 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## 1.55.3 + +### Changed + +- Raise the [omnydrive](https://pub.dev/packages/omnydrive) constraint to `^1.12.2`, + which is itself just the same omnyhub `^1.3.0` bump. `^1.12.1` already resolved to + it, so this only makes the constraint name the version OmnyShell is built and + tested against. No behaviour change and no API change. + ## 1.55.2 ### Changed diff --git a/lib/src/version.dart b/lib/src/version.dart index d18758b..dfb16a3 100644 --- a/lib/src/version.dart +++ b/lib/src/version.dart @@ -3,4 +3,4 @@ /// This is the single source of truth for "what build is this": it is rendered /// in the CLI banner and is the default a node reports as its /// [NodeConfig.agentVersion] / [PlatformInfo.agentVersion]. -const String omnyShellVersion = '1.55.2'; +const String omnyShellVersion = '1.55.3'; diff --git a/pubspec.yaml b/pubspec.yaml index 11a0f74..fe3a58a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ description: >- connect to a Hub by node identity (not host:port); the Hub authenticates, authorizes and brokers encrypted sessions to Nodes over WebSocket-on-TLS. Ships Hub, Node, Client and CLI implementations behind first-class Dart APIs. -version: 1.55.2 +version: 1.55.3 repository: https://github.com/OmnyGrid/omnyshell environment: @@ -22,7 +22,7 @@ dependencies: ffi: ^2.1.0 http: ^1.2.0 meta: ^1.18.3 - omnydrive: ^1.12.1 + omnydrive: ^1.12.2 omnyhub: ^1.3.0 path: ^1.9.0 pointycastle: ^4.0.0 From 7972533175da448e90b7b0ebdd26b001ee7ec9f0 Mon Sep 17 00:00:00 2001 From: "Graciliano M. P." Date: Sun, 12 Jul 2026 19:37:45 -0300 Subject: [PATCH 2/2] Host the Hub broker on a foreign listener; drop the shelf endpoint (v1.56.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Hub broker can now be hosted on a listener OmnyShell does not own, so another Hub — an OmnyServer Hub, say — can serve OmnyShell nodes on its own port, at its own path, beside its own surfaces. One Hub, one certificate, one port, two kinds of node. Nothing changes for a standalone OmnyShell Hub: OmnyShellHub's API and wire behaviour are unchanged, and its 808 existing tests pass untouched. Added: - OmnyShellHubService — the broker as an omnyhub Service. HubBroker was already transport-agnostic (driven by accept()ing connections), and FrameConnection already wraps an omnyhub Connection, so this is only an adapter: the whole integration is one method. Nodes need no change at all — they dial hubUri verbatim, so `omnyshell node start --hub wss://hub:8443/shell` already works. - NodeConfig.home overrides where a node persists its UID. The UID file is keyed by the machine, so two node runtimes in one process (an embedded shell node beside another agent) otherwise contend on ~/.omnyshell/node.uid and warn about the UID changing under them. Mirrors the existing gitCredentialsHome hatch. Changed: - OmnyShellHub is rebuilt on omnyhub's OmnyHub, hosting an OmnyShellHubService at '/' — which matches every path, exactly as the old listener did (it upgraded a WebSocket regardless of path), so existing clients are unaffected. - TLS is now an omnyhub TlsProvider: StaticTls for a securityContext, ReloadableFileTls for a tlsDirectory. Renewal still rebinds the listener gap-free, but omnyhub performs it and reports "TLS certificate renewed". - The hub's lifecycle/TLS/error messages are bridged into HubConfig.logger, which would otherwise have gone to omnyhub's default no-op logger and vanished. - Removed WsServerEndpoint, the last user of shelf: shelf and shelf_web_socket are dropped from the dependencies. - PemTlsSource is retained — tunnels are separate raw TCP listeners and still use it. 813 tests (up from 808); format/analyze/dependency_validator/doc green. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 55 +++++ lib/omnyshell_hub.dart | 2 +- lib/src/application/hub/omnyshell_hub.dart | 128 +++++------ .../hub/omnyshell_hub_service.dart | 89 ++++++++ lib/src/application/node/node_runtime.dart | 14 +- .../transport/ws_server_endpoint.dart | 72 ------- lib/src/version.dart | 2 +- pubspec.yaml | 4 +- .../frame_connection_wire_test.dart | 40 ++-- test/integration/hub_service_mount_test.dart | 201 ++++++++++++++++++ test/integration/hub_tls_dir_test.dart | 8 +- 11 files changed, 461 insertions(+), 154 deletions(-) create mode 100644 lib/src/application/hub/omnyshell_hub_service.dart delete mode 100644 lib/src/infrastructure/transport/ws_server_endpoint.dart create mode 100644 test/integration/hub_service_mount_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index ca72555..d2f2f0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,58 @@ +## 1.56.0 + +The Hub broker can now be hosted on a listener OmnyShell does not own, so another +Hub — an [OmnyServer](https://pub.dev/packages/omnyserver) Hub, say — can serve +OmnyShell nodes on its own port, at its own path, beside its own surfaces. One Hub, +one certificate, one port, two kinds of node. + +Nothing changes for a standalone OmnyShell Hub: `OmnyShellHub`'s API and wire +behaviour are unchanged, and its 808 existing tests pass untouched. + +### Added + +- **`OmnyShellHubService`** — the broker as an omnyhub `Service`. Mount it on any + `OmnyHub` and OmnyShell traffic is routed to it: + + ```dart + await hub.registerService(OmnyShellHubService(broker, mount: '/shell')); + // nodes then dial wss://host:8443/shell + ``` + + `HubBroker` was already transport-agnostic — driven by `accept`ing connections — + so this is only an adapter; the whole integration is one method. A node needs no + change at all: it dials its `hubUri` verbatim, so + `omnyshell node start --hub wss://hub:8443/shell` already works. + + The broker authenticates **in band** (it speaks first, with a challenge), so this + route must not be given a `ConnectionAuthenticator` — one would consume the very + frames the broker is waiting for. Note omnyhub treats a route's `null` connection + authenticator as *inherit the hub-wide one*, not *none*. + +- **`NodeConfig.home`** overrides the directory a node persists its UID under + (`/.omnyshell/node.uid`). The UID file is keyed by the machine, so two node + runtimes in one process — an OmnyShell node embedded beside another agent, or + several nodes in a test — otherwise contend on the same file and warn about the + UID changing under them. Mirrors the existing `gitCredentialsHome` escape hatch. + +### Changed + +- **`OmnyShellHub` is rebuilt on omnyhub's `OmnyHub`.** It hosts an + `OmnyShellHubService` at `/` (which matches every path — a standalone Hub upgrades + a WebSocket regardless of what the peer asked for, so existing clients dialling + `wss://host:8443` are unaffected). + - TLS is now an omnyhub `TlsProvider`: `StaticTls` for a `securityContext`, + `ReloadableFileTls` for a `tlsDirectory`. Certificate renewal still rebinds the + listener gap-free — established connections drain on the old one while new ones + land on the fresh certificate — but omnyhub performs it, and reports it as + `TLS certificate renewed` rather than the old `…rebound…`. + - The hub's lifecycle, TLS and unhandled-error messages are bridged into + `HubConfig.logger`, which would otherwise have gone to omnyhub's default + no-op logger and vanished. + - Removed `WsServerEndpoint`, the last user of `shelf` — **`shelf` and + `shelf_web_socket` are dropped from the dependencies.** + - `PemTlsSource` is retained: tunnels are separate raw TCP listeners and still + use it. + ## 1.55.3 ### Changed diff --git a/lib/omnyshell_hub.dart b/lib/omnyshell_hub.dart index 7cbefa8..8c7cb5c 100644 --- a/lib/omnyshell_hub.dart +++ b/lib/omnyshell_hub.dart @@ -28,10 +28,10 @@ export 'src/application/hub/http_proxy_service.dart'; export 'src/application/hub/hub_broker.dart'; export 'src/application/hub/node_registry.dart'; export 'src/application/hub/omnyshell_hub.dart'; +export 'src/application/hub/omnyshell_hub_service.dart'; export 'src/application/hub/session_router.dart'; export 'src/application/hub/tunnel_registry.dart'; export 'src/infrastructure/auth/authorized_keys_store.dart'; export 'src/infrastructure/auth/composite_authenticator.dart'; export 'src/infrastructure/auth/public_key_authenticator.dart'; export 'src/infrastructure/auth/token_authenticator.dart'; -export 'src/infrastructure/transport/ws_server_endpoint.dart'; diff --git a/lib/src/application/hub/omnyshell_hub.dart b/lib/src/application/hub/omnyshell_hub.dart index b41d4d0..24f74be 100644 --- a/lib/src/application/hub/omnyshell_hub.dart +++ b/lib/src/application/hub/omnyshell_hub.dart @@ -1,7 +1,7 @@ -import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; +import 'package:omnyhub/omnyhub.dart' as omnyhub; import 'package:tcp_tunnel/tcp_tunnel.dart' show PortRange; import '../../domain/auth/authenticator.dart'; @@ -13,13 +13,13 @@ import '../../infrastructure/identity/spki.dart'; import '../../infrastructure/identity/uid_computer.dart'; import '../../infrastructure/identity/uid_store.dart'; import '../../infrastructure/tls/pem_tls_source.dart'; -import '../../infrastructure/transport/ws_server_endpoint.dart'; import '../../shared/utils/clock.dart'; import '../ai/ai_config.dart'; import 'audit_log.dart'; import 'http_proxy_service.dart'; import 'hub_broker.dart'; import 'node_registry.dart'; +import 'omnyshell_hub_service.dart'; import 'session_router.dart'; /// Configuration for an [OmnyShellHub]. @@ -143,6 +143,10 @@ class HubConfig { /// final hub = OmnyShellHub(config); /// await hub.start(); /// ``` +/// +/// This is the *standalone* Hub — it owns its listener. To host the broker on a +/// listener someone else owns (an OmnyServer Hub, say, serving OmnyShell nodes +/// alongside its own on one port), mount an [OmnyShellHubService] instead. class OmnyShellHub { /// The hub configuration. final HubConfig config; @@ -150,9 +154,8 @@ class OmnyShellHub { /// The broker that authenticates, authorizes and relays. final HubBroker broker; - WsServerEndpoint? _endpoint; + omnyhub.OmnyHub? _server; OmnyUid? _uid; - PemTlsSource? _mainTls; PemTlsSource? _tunnelTls; /// Creates a hub from [config]. @@ -184,10 +187,10 @@ class OmnyShellHub { OmnyUid? get uid => _uid; /// The port the hub is listening on (valid after [start]). - int get port => _endpoint?.port ?? config.port; + int get port => _server?.port ?? config.port; /// Whether the hub is running. - bool get isRunning => _endpoint != null; + bool get isRunning => _server != null; /// A point-in-time snapshot of hub metrics. Map metrics() => { @@ -199,61 +202,44 @@ class OmnyShellHub { /// Binds the TLS endpoint and starts the liveness watchdog. Future start() async { - if (_endpoint != null) return; + if (_server != null) return; await _resolveUid(); _startTunnelTls(); - broker.start(); - final dir = config.tlsDirectory; - if (dir != null && dir.isNotEmpty) { - final source = PemTlsSource( - dir, - label: 'hub TLS', - checkInterval: config.tlsReloadInterval, - logger: config.logger, - onReloaded: _rebindMain, - ); - source.load(); - _mainTls = source; - _endpoint = await WsServerEndpoint.bind( - host: config.host, - port: config.port, - securityContext: source.context!, - onConnection: broker.accept, - shared: true, - ); - source.start(); - } else { - _endpoint = await WsServerEndpoint.bind( - host: config.host, - port: config.port, - securityContext: config.securityContext!, - onConnection: broker.accept, - ); - } + + // Mounted at '/', which matches every path — a standalone Hub upgrades a + // WebSocket regardless of what the peer asked for, and clients dial the bare + // authority (`wss://host:8443`). + final server = omnyhub.OmnyHub( + transports: [ + omnyhub.HttpTransport.https( + address: config.host, + port: config.port, + tls: _tls(), + ), + ], + // Without this the hub's own lifecycle, TLS-renewal and unhandled-error + // messages would go to omnyhub's default NoopLogger and vanish. + logger: _BridgeLogger(config.logger), + // Drives the certificate re-check: on renewal omnyhub rebinds the listener + // gap-free, so established connections drain on the old one while new ones + // land on the fresh certificate. + tlsRenewalInterval: config.tlsReloadInterval, + ); + // The broker owns its own in-band handshake (it speaks first, with a + // challenge), so the route gets no ConnectionAuthenticator. + await server.registerService(OmnyShellHubService(broker)); + await server.start(); + _server = server; } - /// Re-binds the main listener with a freshly-loaded [ctx] after a certificate - /// renewal. Binds the new (shared) listener on the currently-bound port, swaps - /// it in, then closes the old one without forcing — established connections - /// drain on the old listener while new ones land on the renewed certificate. - Future _rebindMain(SecurityContext ctx) async { - final old = _endpoint; - if (old == null) return; - try { - _endpoint = await WsServerEndpoint.bind( - host: config.host, - port: old.port, - securityContext: ctx, - onConnection: broker.accept, - shared: true, - ); - unawaited(old.close(force: false)); - config.logger?.call('hub TLS listener rebound on renewed certificate'); - } on Object catch (e) { - // Keep serving on the existing listener if the rebind fails. - _endpoint = old; - config.logger?.call('hub TLS listener rebind failed: $e'); + /// The TLS provider for the main listener: a static context, or a hot-reloading + /// `fullchain.pem`/`privkey.pem` directory. + omnyhub.TlsProvider _tls() { + final dir = config.tlsDirectory; + if (dir != null && dir.isNotEmpty) { + return omnyhub.ReloadableFileTls.directory(dir); } + return omnyhub.StaticTls.context(config.securityContext!); } /// Loads the tunnel TLS certificate from [HubConfig.tunnelTlsDirectory] and @@ -305,12 +291,32 @@ class OmnyShellHub { /// Stops the hub and releases the endpoint. Future stop({bool force = true}) async { - broker.stop(); - _mainTls?.stop(); - _mainTls = null; _tunnelTls?.stop(); _tunnelTls = null; - await _endpoint?.close(force: force); - _endpoint = null; + // Stops the transport and then the service, which stops the broker. + await _server?.stop(force: force); + _server = null; } } + +/// Feeds omnyhub's structured [omnyhub.Logger] into [HubConfig.logger], the +/// single line-oriented sink OmnyShell configures. +/// +/// The hosting hub reports its own lifecycle through omnyhub — the listener +/// binding, a TLS certificate being renewed, an unhandled request error — and +/// none of it would be visible otherwise. +class _BridgeLogger with omnyhub.LoggerBase { + final void Function(String message)? sink; + + const _BridgeLogger(this.sink); + + @override + void log( + omnyhub.LogLevel level, + String message, { + Map context = const {}, + }) => sink?.call(context.isEmpty ? message : '$message $context'); + + @override + omnyhub.Logger child(Map context) => this; +} diff --git a/lib/src/application/hub/omnyshell_hub_service.dart b/lib/src/application/hub/omnyshell_hub_service.dart new file mode 100644 index 0000000..2a110bd --- /dev/null +++ b/lib/src/application/hub/omnyshell_hub_service.dart @@ -0,0 +1,89 @@ +import 'dart:async'; + +import 'package:omnyhub/omnyhub.dart' as omnyhub; + +import '../../infrastructure/transport/frame_connection.dart'; +import '../../protocol/frame_codec.dart'; +import 'hub_broker.dart'; + +/// The OmnyShell Hub as an omnyhub [omnyhub.Service], so its broker can be +/// hosted on any [omnyhub.OmnyHub] listener instead of owning one. +/// +/// This is what lets a *different* Hub — an OmnyServer Hub, say — also serve +/// OmnyShell nodes: mount this at a path and OmnyShell traffic is routed to it +/// while the host keeps its own surfaces on the same port and certificate. +/// +/// ```dart +/// // On an OmnyServer (or any omnyhub) Hub: +/// await hub.registerService(OmnyShellHubService(broker, mount: '/shell')); +/// // Nodes then dial wss://host:8443/shell +/// ``` +/// +/// [HubBroker] is transport-agnostic — it is driven by `accept`ing connections — +/// so this class is only an adapter: omnyhub hands over a raw +/// [omnyhub.Connection], and [FrameConnection] wraps it in OmnyShell's frame +/// codec. +/// +/// **The broker must own the socket un-intercepted.** OmnyShell authenticates +/// *in band*, after the upgrade: the Hub speaks first with a challenge `hello`, +/// and the peer answers with an `auth.request`. So this route must not be given +/// an [omnyhub.ConnectionAuthenticator] — one would consume the very frames the +/// broker is waiting for. Note that on omnyhub a route's `null` authenticator +/// means *inherit the hub-wide one*, not *none*: a host hub that sets a hub-wide +/// connection authenticator must ensure it does not reach this mount. +class OmnyShellHubService extends omnyhub.ServiceBase { + /// The broker that authenticates, authorizes and relays. + final HubBroker broker; + + /// Builds the frame codec for each accepted connection. + final FrameCodec Function()? codecFactory; + + /// Whether this service owns [broker]'s lifecycle. + /// + /// `true` (the default) starts and stops the broker with the hosting hub. Set + /// it `false` when the embedder already drives the broker — as `OmnyShellHub` + /// does — so it is not started twice. + final bool ownsBroker; + + /// Mounts [broker] as a service. + /// + /// The default mount is `/`, which matches every path — the behaviour of a + /// standalone OmnyShell Hub, whose listener upgrades a WebSocket regardless of + /// path. Give it a specific mount (e.g. `/shell`) when sharing a listener. + OmnyShellHubService( + this.broker, { + super.name = 'omnyshell', + super.mount = '/', + this.codecFactory, + this.ownsBroker = true, + }); + + @override + void handleConnection( + omnyhub.Connection connection, + omnyhub.HubRequest request, + ) => broker.accept( + FrameConnection.wrap(connection, codec: codecFactory?.call()), + ); + + /// A plain HTTP request on the mount: this is a WebSocket endpoint, so answer + /// with a small status document rather than an upgrade. + @override + Future handle(omnyhub.HubRequest request) async => + omnyhub.HubResponse.json({ + 'service': 'omnyshell', + 'protocol': 'websocket', + 'hubUid': broker.hubUid, + 'nodes': broker.registry.all.length, + }); + + @override + Future start() async { + if (ownsBroker) broker.start(); + } + + @override + Future stop() async { + if (ownsBroker) broker.stop(); + } +} diff --git a/lib/src/application/node/node_runtime.dart b/lib/src/application/node/node_runtime.dart index 15e1a04..5188f9e 100644 --- a/lib/src/application/node/node_runtime.dart +++ b/lib/src/application/node/node_runtime.dart @@ -128,6 +128,16 @@ class NodeConfig { /// ([omnyshellHome]). Mainly for tests and multi-node isolation. final String? gitCredentialsHome; + /// Overrides the home directory this node persists its UID under + /// (`/.omnyshell/node.uid`). `null` uses the process default + /// ([omnyshellHome]). + /// + /// The UID file is keyed by the *machine*, so two node runtimes in one process + /// — an OmnyShell node embedded beside another agent, or several nodes in a + /// test — otherwise contend on the same file and warn about the UID changing + /// under them. Give each its own home to keep their identities separate. + final String? home; + /// Whether a client connection that drops (network loss, crash, terminal /// closed) automatically detaches its session — keeping the PTY, shell and /// child processes alive for a later resume — instead of terminating it. @@ -166,6 +176,7 @@ class NodeConfig { this.tunnelEnabled = true, this.driveRoots = const [], this.gitCredentialsHome, + this.home, this.autoDetachOnDisconnect = true, this.autoDetachTimeout, this.cleanupInterval = const Duration(minutes: 1), @@ -250,8 +261,9 @@ class NodeRuntime { arch: platform.arch, hostname: platform.hostname, ); - final resolution = await const UidStore( + final resolution = await UidStore( fileName: 'node.uid', + home: config.home, ).resolve(computed, logger: _log); _uid = resolution.uid; } on Object catch (e) { diff --git a/lib/src/infrastructure/transport/ws_server_endpoint.dart b/lib/src/infrastructure/transport/ws_server_endpoint.dart deleted file mode 100644 index 5b8b61f..0000000 --- a/lib/src/infrastructure/transport/ws_server_endpoint.dart +++ /dev/null @@ -1,72 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:shelf/shelf.dart'; -import 'package:shelf/shelf_io.dart' as shelf_io; -import 'package:shelf_web_socket/shelf_web_socket.dart'; - -import '../../protocol/frame_codec.dart'; -import '../../protocol/omnyshell_connection.dart'; -import 'frame_connection.dart'; - -/// Called for every accepted WebSocket connection. -typedef OnConnection = void Function(OmnyShellConnection connection); - -/// A Hub-side TLS WebSocket listener. -/// -/// Binds an HTTPS server with the provided [SecurityContext] and upgrades -/// incoming WebSocket requests, handing each accepted [OmnyShellConnection] -/// (an omnyhub-backed [FrameConnection]) to the supplied [OnConnection] -/// callback. There is no plaintext mode: a [SecurityContext] is mandatory. -class WsServerEndpoint { - final HttpServer _server; - - WsServerEndpoint._(this._server); - - /// The address the server is bound to. - InternetAddress get address => _server.address; - - /// The port the server is listening on. - int get port => _server.port; - - /// Binds and starts a TLS WebSocket endpoint on [host]:[port]. - /// - /// [securityContext] must provide the server certificate chain and private - /// key. [onConnection] receives every accepted connection. Pass `port: 0` to - /// bind an ephemeral port (useful in tests); read [port] afterwards. - /// - /// With [shared] true, multiple servers may bind the same address/port and - /// incoming connections are distributed among them. The Hub uses this when - /// the listener certificate is hot-reloadable: on renewal it binds a fresh - /// listener on the same port before draining the old one, so the swap happens - /// without a gap or a port-in-use race. - static Future bind({ - required Object host, - required int port, - required SecurityContext securityContext, - required OnConnection onConnection, - FrameCodec Function()? codecFactory, - bool shared = false, - }) async { - final handler = webSocketHandler((channel, _) { - onConnection( - FrameConnection.fromChannel( - channel, - codec: codecFactory?.call() ?? FrameCodec.standard(), - ), - ); - }); - - final server = await shelf_io.serve( - const Pipeline().addHandler(handler), - host, - port, - securityContext: securityContext, - shared: shared, - ); - return WsServerEndpoint._(server); - } - - /// Stops the server. With [force], open connections are dropped immediately. - Future close({bool force = false}) => _server.close(force: force); -} diff --git a/lib/src/version.dart b/lib/src/version.dart index dfb16a3..16a3561 100644 --- a/lib/src/version.dart +++ b/lib/src/version.dart @@ -3,4 +3,4 @@ /// This is the single source of truth for "what build is this": it is rendered /// in the CLI banner and is the default a node reports as its /// [NodeConfig.agentVersion] / [PlatformInfo.agentVersion]. -const String omnyShellVersion = '1.55.3'; +const String omnyShellVersion = '1.56.0'; diff --git a/pubspec.yaml b/pubspec.yaml index fe3a58a..4861254 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ description: >- connect to a Hub by node identity (not host:port); the Hub authenticates, authorizes and brokers encrypted sessions to Nodes over WebSocket-on-TLS. Ships Hub, Node, Client and CLI implementations behind first-class Dart APIs. -version: 1.55.3 +version: 1.56.0 repository: https://github.com/OmnyGrid/omnyshell environment: @@ -27,8 +27,6 @@ dependencies: path: ^1.9.0 pointycastle: ^4.0.0 #portable_pty: ^0.0.5 # waiting crash fix. - shelf: ^1.4.2 - shelf_web_socket: ^3.0.0 tcp_tunnel: ^2.1.0 uuid: ^4.5.3 web_socket_channel: ^3.0.3 diff --git a/test/integration/frame_connection_wire_test.dart b/test/integration/frame_connection_wire_test.dart index 73ba274..7b878c2 100644 --- a/test/integration/frame_connection_wire_test.dart +++ b/test/integration/frame_connection_wire_test.dart @@ -1,9 +1,9 @@ import 'dart:async'; import 'dart:typed_data'; +import 'package:omnyhub/omnyhub.dart' as omnyhub; import 'package:omnyshell/omnyshell.dart'; import 'package:omnyshell/src/infrastructure/transport/frame_connection.dart'; -import 'package:omnyshell/src/infrastructure/transport/ws_server_endpoint.dart'; import 'package:test/test.dart'; import '../support/harness.dart'; @@ -14,24 +14,38 @@ import '../support/harness.dart'; /// The unit test drives a fake connection, so it never proves that control /// frames actually cross the wire as WebSocket **text** frames and data frames /// as **binary** frames — the one thing that would silently corrupt every -/// session if omnyhub's transport got it wrong. This binds a real TLS -/// [WsServerEndpoint], dials it with [FrameConnection.connect], and asserts the -/// frames survive the round trip byte-for-byte in both directions. +/// session if omnyhub's transport got it wrong. This binds a real TLS listener, +/// dials it with [FrameConnection.connect], and asserts the frames survive the +/// round trip byte-for-byte in both directions. void main() { - late WsServerEndpoint server; + late omnyhub.OmnyHub server; late FrameConnection client; late OmnyShellConnection accepted; setUp(() async { final acceptedC = Completer(); - server = await WsServerEndpoint.bind( - host: '127.0.0.1', - port: 0, - securityContext: hubSecurityContext(), - onConnection: (c) { - if (!acceptedC.isCompleted) acceptedC.complete(c); - }, + server = omnyhub.OmnyHub( + transports: [ + omnyhub.HttpTransport.https( + address: '127.0.0.1', + port: 0, + tls: omnyhub.StaticTls.context(hubSecurityContext()), + ), + ], + ); + await server.registerService( + omnyhub.HandlerService( + name: 'wire', + handler: (_) async => omnyhub.HubResponse.notFound(), + onConnection: (connection, _) { + if (!acceptedC.isCompleted) { + acceptedC.complete(FrameConnection.wrap(connection)); + } + }, + ), ); + await server.start(); + client = await FrameConnection.connect( Uri.parse('wss://127.0.0.1:${server.port}'), securityContext: trustContext(), @@ -42,7 +56,7 @@ void main() { tearDown(() async { await client.close(); - await server.close(force: true); + await server.stop(); }); test('a control frame crosses the wire as a text frame', () async { diff --git a/test/integration/hub_service_mount_test.dart b/test/integration/hub_service_mount_test.dart new file mode 100644 index 0000000..bfaeafd --- /dev/null +++ b/test/integration/hub_service_mount_test.dart @@ -0,0 +1,201 @@ +@TestOn('vm') +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:omnyhub/omnyhub.dart' as omnyhub; +import 'package:omnyshell/omnyshell_client.dart'; +import 'package:omnyshell/omnyshell_hub.dart'; +import 'package:omnyshell/omnyshell_node.dart'; +import 'package:test/test.dart'; + +import '../support/fake_shell_backend.dart'; +import '../support/harness.dart'; + +/// The OmnyShell broker hosted on a listener OmnyShell does **not** own. +/// +/// This is what lets another Hub — an OmnyServer Hub, say — serve OmnyShell +/// nodes on its own port, at its own path, beside its own surfaces. The +/// standalone [OmnyShellHub] is just one embedder of the same broker; here we +/// stand up a bare omnyhub hub, mount the broker at `/shell`, and drive a real +/// node and client through it. +void main() { + late omnyhub.OmnyHub host; + late HubBroker broker; + final nodes = []; + final clients = []; + late Directory home; + + final grants = { + 'node-token': TokenGrant( + principal: PrincipalId('node-account'), + roles: {'node'}, + ), + 'admin-token': TokenGrant( + principal: PrincipalId('alice'), + displayName: 'Alice', + roles: {'admin'}, + ), + }; + + setUp(() async { + home = Directory.systemTemp.createTempSync('omnyshell-mount-'); + broker = HubBroker( + authenticator: TokenAuthenticator(grants), + authorizer: const RoleBasedAuthorizer(), + ); + + host = omnyhub.OmnyHub( + transports: [ + omnyhub.HttpTransport.https( + address: '127.0.0.1', + port: 0, + tls: omnyhub.StaticTls.context(hubSecurityContext()), + ), + ], + ); + // A path mount, not the root — the host hub keeps the rest of the port. + await host.registerService(OmnyShellHubService(broker, mount: '/shell')); + // Another surface on the same listener, to prove they coexist. + await host.registerService( + omnyhub.HandlerService( + name: 'other', + mount: '/other', + handler: (_) async => omnyhub.HubResponse.json({'ok': true}), + ), + ); + await host.start(); + }); + + tearDown(() async { + for (final c in clients) { + await c.close(); + } + for (final n in nodes) { + await n.shutdown(); + } + nodes.clear(); + clients.clear(); + await host.stop(); + home.deleteSync(recursive: true); + }); + + Uri shellUri() => Uri.parse('wss://127.0.0.1:${host.port}/shell'); + + Future startNode(String id, ShellBackend backend) async { + final node = NodeRuntime( + NodeConfig( + hubUri: shellUri(), + nodeId: NodeId(id), + credentials: const TokenCredentialProvider( + principal: 'node-account', + token: 'node-token', + ), + backend: backend, + labels: const {'allow-roles': 'admin'}, + securityContext: trustContext(), + onBadCertificate: (_, _, _) => true, + home: home.path, + ), + ); + nodes.add(node); + await node.connect(); + return node; + } + + Future connectClient() async { + final client = ClientRuntime( + ClientConfig( + hubUri: shellUri(), + credentials: const TokenCredentialProvider( + principal: 'alice', + token: 'admin-token', + ), + connectionFactory: ioConnectionFactory( + securityContext: trustContext(), + onBadCertificate: (_, _, _) => true, + ), + ), + ); + clients.add(client); + await client.connect(); + return client; + } + + test('a node registers with a broker mounted on a foreign hub', () async { + await startNode('web-01', FakeShellBackend()); + + expect(broker.registry.all, hasLength(1)); + expect(broker.registry.byId(NodeId('web-01'))?.descriptor.online, isTrue); + }); + + test('a client opens a session and runs a command end to end', () async { + final backend = FakeShellBackend(); + await startNode('web-01', backend); + final client = await connectClient(); + + final session = await client.openSession( + nodeId: 'web-01', + mode: SessionMode.exec, + command: 'run', + ); + final out = StringBuffer(); + session.stdout.listen((d) => out.write(utf8.decode(d))); + + final fake = backend.sessions.last; + fake.emitStdout('hello-from-a-foreign-hub'); + await fake.complete(0); + + expect(await session.exitCode, 0); + await Future.delayed(const Duration(milliseconds: 50)); + expect(out.toString(), contains('hello-from-a-foreign-hub')); + }); + + test('the host hub keeps its own surfaces on the same port', () async { + await startNode('web-01', FakeShellBackend()); + + final client = HttpClient(context: trustContext()) + ..badCertificateCallback = (_, _, _) => true; + final req = await client.getUrl( + Uri.parse('https://127.0.0.1:${host.port}/other'), + ); + final res = await req.close(); + final body = jsonDecode(await res.transform(utf8.decoder).join()); + client.close(); + + // Shell traffic on /shell, the host's own service on /other, one listener. + expect(res.statusCode, 200); + expect((body as Map)['ok'], isTrue); + expect(broker.registry.all, hasLength(1)); + }); + + test( + 'a plain GET on the shell mount reports status, not an upgrade', + () async { + final client = HttpClient(context: trustContext()) + ..badCertificateCallback = (_, _, _) => true; + final req = await client.getUrl( + Uri.parse('https://127.0.0.1:${host.port}/shell'), + ); + final res = await req.close(); + final body = jsonDecode(await res.transform(utf8.decoder).join()); + client.close(); + + expect(res.statusCode, 200); + expect((body as Map)['service'], 'omnyshell'); + }, + ); + + test('NodeConfig.home isolates the persisted node UID', () async { + // Two runtimes in one process would otherwise contend on the single + // ~/.omnyshell/node.uid — the case an embedded shell node creates. + await startNode('web-01', FakeShellBackend()); + + expect( + File('${home.path}/.omnyshell/node.uid').existsSync(), + isTrue, + reason: 'the UID must be persisted under the configured home', + ); + }); +} diff --git a/test/integration/hub_tls_dir_test.dart b/test/integration/hub_tls_dir_test.dart index 0470c03..66e3f13 100644 --- a/test/integration/hub_tls_dir_test.dart +++ b/test/integration/hub_tls_dir_test.dart @@ -93,11 +93,15 @@ void main() { writeCerts(chainPrefix: '# renewed\n'.codeUnits); // The periodic reloader should pick up the change and rebind the listener. - for (var i = 0; i < 100 && !logs.any((l) => l.contains('rebound')); i++) { + // The rebind is omnyhub's (gap-free: the old listener drains while new + // connections land on the fresh certificate), so the message is its + // "TLS certificate renewed" — reaching this logger through the Hub's bridge. + bool renewed() => logs.any((l) => l.contains('renewed')); + for (var i = 0; i < 100 && !renewed(); i++) { await pump(); } expect( - logs.any((l) => l.contains('rebound')), + renewed(), isTrue, reason: 'listener should rebind after renewal; logs: $logs', );