hop-endpoint 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 28842786d70c3471cfb49cc90b3ed1c611de8b109816235d94328f6b470c0f83
4
+ data.tar.gz: '08a7c7f9782a88233bdb6064213bb47e44ca73360aa05d736e32fddfbf4bed74'
5
+ SHA512:
6
+ metadata.gz: 3005f4bfb8aac1a6d5d796a7f39d5d978cd4af34373e3bf9917f1f3733403b74802ff9d21f103da5560eb5d4fa7080334ec14a9285b408720cdd27c4a42164e8
7
+ data.tar.gz: 82a538d1ea0675abf7e85001c5417abbf556499bf1ff996f3b3731cccb7e45a86a2550ce88d27dae51832e1378f089080b6c7a66a4e64dbb42a01b28cc49b9d3
data/README.md ADDED
@@ -0,0 +1,146 @@
1
+ <p align="center">
2
+ <img alt="Hop" src="https://hopme.sh/hop-mark.svg" width="200">
3
+ </p>
4
+
5
+ <h1 align="center">hop-endpoint</h1>
6
+
7
+ <p align="center">
8
+ <b>Receive Hop messages in your Ruby service.</b><br>
9
+ A Sinatra/Rails-shaped endpoint on the <a href="https://hopme.sh">Hop</a> mesh, over the <code>libhop</code> C ABI.
10
+ </p>
11
+
12
+ <p align="center">
13
+ <a href="https://rubygems.org/gems/hop-endpoint"><img src="https://img.shields.io/gem/v/hop-endpoint?color=cc342d&label=gem" alt="gem"></a>
14
+ <img src="https://img.shields.io/badge/license-Apache--2.0-3ddc84" alt="license">
15
+ <img src="https://img.shields.io/badge/ruby-%E2%89%A53.0-6ea8fe" alt="ruby >=3.0">
16
+ </p>
17
+
18
+ ---
19
+
20
+ Hop is a **delay-tolerant mesh**: end-to-end encrypted datagrams that hop device to device, over BLE,
21
+ Wi-Fi, and the internet, until they reach the person or service you meant. Held, never dropped.
22
+
23
+ `hop-endpoint` is the **server side**: your Ruby service becomes a first-class address on the mesh, so
24
+ senders hand messages straight to it. Self-host is an import, not an ops project. No inbound port to open
25
+ to the world, no bearer tokens to rotate, no message queue to run: the sender identity is authenticated
26
+ by the ratchet, and delivery is durable and store-and-forward. **Zero gems**, `Fiddle` is Ruby's stdlib FFI.
27
+
28
+ ## Install
29
+
30
+ ```sh
31
+ gem install hop-endpoint
32
+ ```
33
+
34
+ You also need `libhop`, the Rust protocol core, as a prebuilt binary or a local build, pointed to with
35
+ `HOP_LIBDIR`. See [libhop](https://github.com/hopmesh/libhop). Ruby 3.0+ (`Fiddle`, `OpenSSL`, `Socket`,
36
+ `Net::HTTP`, `JSON` are all stdlib).
37
+
38
+ ## Quick start
39
+
40
+ ```ruby
41
+ require "hop"
42
+ require "json"
43
+
44
+ hop = Hop::Endpoint.new
45
+
46
+ hop.on("acme/orders") do |req, reply|
47
+ # req.from is a VERIFIED identity (base58), not a spoofable header
48
+ order = JSON.parse(req.text)
49
+ reply.call(201, JSON.generate({ ok: true, order: order })) # uint16 status + body
50
+ end
51
+
52
+ Hop::TcpBearer.listen(hop, 9944) # reachable by any device
53
+ puts hop.address # publish this (or its name); senders reach you by it
54
+ ```
55
+
56
+ **The DX looks like HTTP; the semantics are better.** Inbound is a durable, store-and-forward consume; a
57
+ reply is a new addressed message that may arrive later, even after a restart. It works when the peer is
58
+ offline, and there is no auth layer to bolt on, the identity is cryptographic. core is poll-model, so the
59
+ endpoint runs a background pump thread (the node is thread-safe).
60
+
61
+ ## Reachable by name
62
+
63
+ Make an endpoint reachable at `myaddress.com` with no new port, on a pure-stdlib WebSocket bearer (zero
64
+ gems). `attach` wires the WSS bearer (`/_hop`) and the discovery route (`/.well-known/hop`) in one call:
65
+
66
+ ```ruby
67
+ require "openssl"
68
+ ctx = OpenSSL::SSL::SSLContext.new
69
+ ctx.cert = OpenSSL::X509::Certificate.new(File.read("cert.pem"))
70
+ ctx.key = OpenSSL::PKey::RSA.new(File.read("key.pem"))
71
+ hop.attach(443, ctx, "wss://myaddress.com/_hop")
72
+ ```
73
+
74
+ A client reaches it by name, verified end to end:
75
+
76
+ ```ruby
77
+ address = client.dial_by_name("https://myaddress.com")
78
+ status, body = client.request(address, "acme/orders", "create", order)
79
+ ```
80
+
81
+ TLS proves the domain, a signed **reach record** proves the address, and the Noise handshake confirms it.
82
+ Spoof the `A` record or MITM the lookup and the attacker still can't forge the cert or complete the
83
+ handshake as the address, and a request sealed to that address is unreadable to anyone else.
84
+
85
+ ## Rails / Rack
86
+
87
+ The endpoint is just an object with a pump thread, so it drops into a long-running process. In a Rails
88
+ app, build one `Hop::Endpoint` in an initializer, keep it in a constant or a singleton, register your
89
+ `on(...)` handlers there, and call `attach` to serve WSS on the same host. The handler block runs off the
90
+ request cycle (it is the mesh inbox, not a controller action): enqueue a job, write a row, then
91
+ `reply.call`.
92
+
93
+ ## How it maps to the core
94
+
95
+ The endpoint is a `hop-core` node in host-a-mailbox mode, over the same C ABI every Hop SDK binds (via
96
+ `Fiddle`), with zero core changes:
97
+
98
+ | Endpoint | libhop C ABI |
99
+ | -------------------------- | ---------------------------------------------------------- |
100
+ | `hop.on(svc) { }` | `hop_subscribe` + `hop_poll_service_requests` |
101
+ | `reply.call(status, body)` | `hop_send_service_response` (status is a `uint16`) |
102
+ | `hop.request(...)` | `hop_send_service_request` + `hop_poll_service_responses` |
103
+ | the Internet bearer | `hop_link_up` / `hop_bytes_received` / `hop_drain_outgoing`|
104
+
105
+ ## Examples
106
+
107
+ Point `HOP_LIBDIR` at a built `libhop`, then:
108
+
109
+ ```sh
110
+ ruby -Ilib test/test_hop.rb # in-process + reach record + WSS discovery, all pass
111
+ ruby examples/raw_roundtrip.rb # raw C ABI round trip (proves the Fiddle bindings)
112
+ ruby examples/echo.rb # the hop.on / reply DX in-process
113
+ ruby examples/tcp.rb # the same round trip over a real TCP bearer
114
+ ruby examples/discovery.rb # the full reachable-by-name chain (HTTPS + WSS)
115
+ ```
116
+
117
+ Two-process shape (a standalone server plus a client that dials it):
118
+
119
+ ```sh
120
+ ruby examples/server.rb # prints its address, listens on tcp://0.0.0.0:9944
121
+ ruby examples/client.rb <address> localhost 9944
122
+ ```
123
+
124
+ ## Status
125
+
126
+ Prototype. Built and working: the `on` block handler and `reply`, the client `request`, the in-process /
127
+ TCP / WSS bearers, base58 addressing, reach-record `attach` / `dial_by_name` discovery, sibling-replica
128
+ clustering, the ABI-version assert, and a use-after-free-safe `close` (bearer threads that fire after
129
+ teardown short-circuit instead of touching a freed node). HNS name publish/resolve and multi-tenant
130
+ hosting are on the roadmap (each an SDK-level follow-up, not a core change).
131
+
132
+ ## The Hop family
133
+
134
+ `hop-endpoint` is one of several SDKs over the same C ABI. Same surface, your language:
135
+ [node](https://github.com/hopmesh/hop-sdk-node) ·
136
+ [python](https://github.com/hopmesh/hop-sdk-python) ·
137
+ [go](https://github.com/hopmesh/hop-sdk-go) ·
138
+ [ruby](https://github.com/hopmesh/hop-sdk-ruby) ·
139
+ [crystal](https://github.com/hopmesh/hop-sdk-crystal) ·
140
+ [elixir](https://github.com/hopmesh/hop-sdk-elixir).
141
+ The protocol core is [libhop](https://github.com/hopmesh/libhop) / [hop-core](https://github.com/hopmesh/hop-core).
142
+
143
+ ## License
144
+
145
+ [Apache-2.0](./LICENSE.md), embed it freely. Only the protocol core (`hop-core`) is FSL-1.1-ALv2,
146
+ source-available and converting to Apache-2.0 after two years.
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Hop
6
+ # DEV/TEST ONLY: an in-process self-signed cert for the discovery example + test (no `openssl` CLI,
7
+ # no gems; OpenSSL ships with Ruby). Never use a self-signed cert in production; there a real WebPKI
8
+ # cert proves the domain.
9
+ module DevTls
10
+ # An SSLContext backed by a fresh in-process self-signed cert (RSA-2048, CN=<cn>, 1h).
11
+ def self.server_context(cn = "localhost")
12
+ key = OpenSSL::PKey::RSA.new(2048)
13
+ cert = OpenSSL::X509::Certificate.new
14
+ cert.version = 2
15
+ cert.serial = 1
16
+ cert.subject = OpenSSL::X509::Name.parse("/CN=#{cn}")
17
+ cert.issuer = cert.subject
18
+ cert.public_key = key.public_key
19
+ cert.not_before = Time.now - 60
20
+ cert.not_after = Time.now + 3600
21
+ cert.sign(key, OpenSSL::Digest.new("SHA256"))
22
+ ctx = OpenSSL::SSL::SSLContext.new
23
+ ctx.cert = cert
24
+ ctx.key = key
25
+ ctx
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Discovery: bind a name to a Hop address using the domain's TLS cert (WebPKI) plus a
4
+ # self-certifying reachability record served at /.well-known/hop. See docs/endpoint-sdk.md.
5
+ require "json"
6
+ require "base64"
7
+ require "net/http"
8
+ require "uri"
9
+ require "openssl"
10
+ require "hop/ffi"
11
+
12
+ module Hop
13
+ module Discovery
14
+ WELL_KNOWN_PATH = "/.well-known/hop"
15
+
16
+ def self.well_known_body(endpoint, public_url, ttl_secs = 3600)
17
+ record = endpoint.sign_reach(public_url, ttl_secs)
18
+ JSON.generate({ "address" => endpoint.address, "endpoint" => public_url, "reach" => Base64.strict_encode64(record) })
19
+ end
20
+
21
+ # Fetch + verify base_url's well-known. Returns {address:, address_bytes:, wss_url:}. Raises on a
22
+ # missing/malformed/unverified record.
23
+ def self.resolve(base_url, insecure_tls: false)
24
+ uri = URI.parse(base_url)
25
+ http = Net::HTTP.new(uri.host, uri.port || 443)
26
+ http.use_ssl = true
27
+ http.verify_mode = insecure_tls ? OpenSSL::SSL::VERIFY_NONE : OpenSSL::SSL::VERIFY_PEER
28
+ res = http.get(WELL_KNOWN_PATH)
29
+ raise "well-known fetch failed: HTTP #{res.code}" unless res.code.to_i == 200
30
+
31
+ body = JSON.parse(res.body)
32
+ info = Hop::FFI.verify_reach(Base64.strict_decode64(body["reach"]), Time.now.to_i)
33
+ raise "reach record failed verification (bad signature or expired)" unless info
34
+
35
+ { address: Hop::FFI.to_b58(info[:address]), address_bytes: info[:address], wss_url: info[:endpoint] }
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+ require "timeout"
5
+ require "hop/ffi"
6
+
7
+ module Hop
8
+ # An inbound service request. `from` is the cryptographically verified sender identity (base58).
9
+ Request = Struct.new(:from, :from_bytes, :service, :method, :args) do
10
+ def text = args
11
+ end
12
+
13
+ # Receive Hop messages in Ruby with a Sinatra/Rails-shaped surface, over the libhop C ABI.
14
+ #
15
+ # hop = Hop::Endpoint.new
16
+ # hop.on("acme/orders") { |req, reply| reply.call(201, "ok") } # req.from is VERIFIED
17
+ #
18
+ # Semantics: inbound is a durable store-and-forward consume; a reply is a new addressed message that
19
+ # may arrive later. The DX is HTTP-shaped; delivery is delay-tolerant. core is poll-model, so the
20
+ # endpoint runs a background pump thread (the node is thread-safe).
21
+ class Endpoint
22
+ # Sentinel pushed to a pending request queue by #close, so a blocked caller fails fast instead of
23
+ # waiting out its full timeout. A unique object, never equal to a real [status, body] response.
24
+ CLOSED = Object.new
25
+
26
+ def initialize(key: nil, tick_ms: 50, cluster: nil, quorum: nil)
27
+ Hop::FFI.assert_abi!
28
+ @node = key ? Hop::FFI.node_with_secret(key) : Hop::FFI.node_new
29
+ Hop::FFI.tick(@node, now_ms)
30
+ Hop::FFI.publish_prekey(@node)
31
+ @handlers = {}
32
+ @links = {}
33
+ @pending = {}
34
+ @closers = []
35
+ @mutex = Mutex.new # guards @pending
36
+ @node_lock = Monitor.new # serializes every libhop call on @node vs. #close; reentrant so a
37
+ @closed = false # reply issued from inside #pump re-enters without deadlocking
38
+ cluster(cluster) if cluster # dedup across sibling replicas (same identity, no shared store)
39
+ cluster_quorum(quorum) if quorum # TTL-based visibility threshold; not consensus
40
+ @thread = Thread.new { pump_loop(tick_ms / 1000.0) }
41
+ end
42
+
43
+ def address = Hop::FFI.to_b58(address_bytes)
44
+ def address_bytes = with_node { |n| Hop::FFI.address(n) }
45
+
46
+ # Join the endpoint cluster so sibling replicas (same identity, no shared datastore) each handle a
47
+ # given request once. Pass a String passphrase (interops with the service's HOP_CLUSTER_SECRET) or
48
+ # a 32-byte binary String secret. Dedup then applies transparently. Returns self.
49
+ def cluster(secret_or_passphrase)
50
+ if secret_or_passphrase.is_a?(String) && secret_or_passphrase.encoding != Encoding::BINARY
51
+ with_node { |n| Hop::FFI.cluster_join_passphrase(n, secret_or_passphrase) }
52
+ else
53
+ b = secret_or_passphrase.to_str
54
+ raise ArgumentError, "cluster secret must be 32 bytes or a passphrase string" unless b.bytesize == 32
55
+ with_node { |n| Hop::FFI.cluster_join(n, b) }
56
+ end
57
+ self
58
+ end
59
+
60
+ # Live replica count (self + peers within the membership TTL); 1 if not clustered.
61
+ def cluster_members = with_node { |n| Hop::FFI.cluster_members(n) }
62
+
63
+ # Require at least +min+ live cluster members visible before this replica will process a request
64
+ # using a TTL-based visibility threshold. This is a conservative failover heuristic, not consensus
65
+ # or an at-most-once guarantee. 0 or 1 disables it. Returns self.
66
+ def cluster_quorum(min)
67
+ with_node { |n| Hop::FFI.cluster_set_quorum(n, min) }
68
+ self
69
+ end
70
+
71
+ # Register a receiver for a hops:// service. The block gets (req, reply); reply is a callable
72
+ # reply.call(status, body).
73
+ def on(service, &block)
74
+ with_node { |n| Hop::FFI.subscribe(n, service) }
75
+ @handlers[service] = block
76
+ self
77
+ end
78
+
79
+ # Call a service on a remote endpoint. Blocks until the response returns (delay-tolerant).
80
+ def request(dst, service, method, args = "", timeout: 15.0)
81
+ dst_bytes = dst.is_a?(String) && dst.bytesize == 32 ? dst : Hop::FFI.from_b58(dst)
82
+ q = Queue.new
83
+ # Send and register the waiter atomically under @node_lock so #pump (which also holds it) cannot
84
+ # deliver the response before @pending knows to route it.
85
+ req_id = with_node do |n|
86
+ id = Hop::FFI.send_service_request(n, dst_bytes, service, method, to_bytes(args))
87
+ @mutex.synchronize { @pending[id] = q }
88
+ id
89
+ end
90
+ raise "endpoint is closed" unless req_id
91
+
92
+ begin
93
+ res = Timeout.timeout(timeout) { q.pop } # [status, body], or CLOSED if #close woke us
94
+ raise "endpoint is closed" if res == CLOSED
95
+ res
96
+ rescue Timeout::Error
97
+ @mutex.synchronize { @pending.delete(req_id) }
98
+ raise "hops://#{service}/#{method} timed out after #{timeout}s"
99
+ end
100
+ end
101
+
102
+ # Sign a self-certifying reachability record for this endpoint's address bound to `endpoint`.
103
+ def sign_reach(endpoint, ttl_secs = 3600) = with_node { |n| Hop::FFI.sign_reach(n, endpoint, ttl_secs) }
104
+
105
+ # Start an HTTPS server (WSS bearer at /_hop + /.well-known/hop) IN ONE CALL. `public_url` is where
106
+ # senders reach it, e.g. "wss://myaddress.com/_hop". Returns the server (call #shutdown to stop).
107
+ def attach(port, ssl_context, public_url, ttl_secs: 3600)
108
+ require "hop/wss_bearer"
109
+ Hop::WssBearer.serve(self, port, ssl_context, public_url, ttl_secs)
110
+ end
111
+
112
+ # Resolve a base HTTPS URL to a verified endpoint, dial its WSS, and return the reachable address
113
+ # (then use #request). Set insecure_tls: true only for a dev/self-signed cert.
114
+ def dial_by_name(base_url, insecure_tls: false)
115
+ require "hop/discovery"
116
+ require "hop/wss_bearer"
117
+ info = Hop::Discovery.resolve(base_url, insecure_tls: insecure_tls)
118
+ Hop::WssBearer.dial(self, info[:wss_url], insecure_tls: insecure_tls)
119
+ info[:address]
120
+ end
121
+
122
+ # Register a teardown hook (e.g. a bearer's listening socket). #close runs these before freeing the
123
+ # node so bearer threads unblock and exit. If already closed, the hook fires immediately.
124
+ def register_closer(&block)
125
+ run_now = @node_lock.synchronize { @closed ? true : (@closers << block; false) }
126
+ block.call if run_now
127
+ end
128
+
129
+ # ---- bearer seam (called from bearer threads) ----
130
+ def register_link(link, role, send_fn)
131
+ with_node do |n|
132
+ @links[link] = send_fn
133
+ Hop::FFI.connected(n, link, role == :dialer)
134
+ end
135
+ end
136
+
137
+ def deliver(link, data) = with_node { |n| Hop::FFI.received(n, link, data) }
138
+
139
+ def link_down(link)
140
+ with_node do |n|
141
+ @links.delete(link)
142
+ Hop::FFI.disconnected(n, link)
143
+ end
144
+ end
145
+
146
+ def close
147
+ @node_lock.synchronize do
148
+ return if @closed
149
+
150
+ @closed = true
151
+ end
152
+ @closers.each { |c| c.call rescue nil } # unblock bearer accept/read threads so they exit
153
+ # Wake any in-flight request waiters so they fail fast instead of blocking their full timeout.
154
+ @mutex.synchronize do
155
+ @pending.each_value { |q| q.push(CLOSED) }
156
+ @pending.clear
157
+ end
158
+ @thread.join(1) unless Thread.current == @thread
159
+ # Free only after @closed is set and the pump has stopped: a late bearer-thread call (a WSS
160
+ # run_link firing #link_down as its socket EOFs) now short-circuits in #with_node instead of
161
+ # dereferencing a freed node.
162
+ @node_lock.synchronize do
163
+ next unless @node
164
+
165
+ Hop::FFI.node_free(@node)
166
+ @node = nil
167
+ end
168
+ end
169
+
170
+ private
171
+
172
+ def now_ms = (Time.now.to_f * 1000).to_i
173
+ def to_bytes(v) = v.is_a?(String) ? v.b : v.to_s.b
174
+
175
+ # Run a libhop call on the node under the reentrant lock, unless the endpoint has been closed (in
176
+ # which case @node may already be freed, so we must not touch it). Returns nil when closed.
177
+ def with_node
178
+ @node_lock.synchronize do
179
+ return nil if @closed
180
+
181
+ yield @node
182
+ end
183
+ end
184
+
185
+ def pump_loop(dt)
186
+ until @closed
187
+ begin
188
+ pump
189
+ rescue StandardError => e
190
+ warn "hop pump error: #{e}"
191
+ end
192
+ sleep(dt)
193
+ end
194
+ end
195
+
196
+ def pump
197
+ snapshot = with_node do |n|
198
+ Hop::FFI.tick(n, now_ms)
199
+ outgoing = Hop::FFI.drain_outgoing(n).map { |link, data| [@links[link], data] }
200
+ [outgoing, Hop::FFI.take_service_requests(n), Hop::FFI.take_service_responses(n)]
201
+ end
202
+ return unless snapshot
203
+
204
+ outgoing, requests, responses = snapshot
205
+ outgoing.each { |fn, data| fn&.call(data) }
206
+ requests.each do |frm, rid, service, method, args|
207
+ handler = @handlers[service]
208
+ next unless handler
209
+
210
+ req = Request.new(Hop::FFI.to_b58(frm), frm, service, method, args)
211
+ reply = ->(status, body = "") { with_node { |n| Hop::FFI.send_service_response(n, frm, rid, status, to_bytes(body)) } }
212
+ handler.call(req, reply)
213
+ end
214
+ responses.each do |_frm, for_id, status, body|
215
+ q = @mutex.synchronize { @pending.delete(for_id) }
216
+ q&.push([status, body])
217
+ end
218
+ end
219
+ end
220
+ end
data/lib/hop/ffi.rb ADDED
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Raw Fiddle bindings to libhop (the C ABI, sdk/hop.h). Fiddle is Ruby's stdlib FFI (like ctypes), so
4
+ # this SDK has no native-gem build. Thin and one-to-one; ergonomics live in endpoint.rb.
5
+ require "fiddle"
6
+
7
+ module Hop
8
+ module FFI
9
+ P = Fiddle::TYPE_VOIDP
10
+ I = Fiddle::TYPE_INT
11
+ LL = Fiddle::TYPE_LONG_LONG
12
+ SZ = Fiddle::TYPE_SIZE_T
13
+ CH = Fiddle::TYPE_CHAR
14
+ V = Fiddle::TYPE_VOID
15
+
16
+ ABI_EXPECTED = 3
17
+
18
+ def self.lib_path
19
+ ext = case RbConfig::CONFIG["host_os"]
20
+ when /darwin/ then "dylib"
21
+ when /mswin|mingw/ then "dll"
22
+ else "so"
23
+ end
24
+ repo = File.expand_path("../../../..", __dir__) # sdk/ruby/lib/hop -> repo root
25
+ candidates = []
26
+ candidates << File.join(ENV["HOP_LIBDIR"], "libhop.#{ext}") if ENV["HOP_LIBDIR"]
27
+ candidates << File.join(repo, "target", "debug", "libhop.#{ext}")
28
+ candidates << File.join(repo, "target", "release", "libhop.#{ext}")
29
+ found = candidates.find { |c| File.exist?(c) }
30
+ raise "libhop.#{ext} not found. Build it with `cargo build -p hop` or set HOP_LIBDIR.\n" \
31
+ "Looked in:\n #{candidates.join("\n ")}" unless found
32
+
33
+ found
34
+ end
35
+
36
+ LIB = Fiddle.dlopen(lib_path)
37
+ private_class_method def self.fn(name, args, ret) = Fiddle::Function.new(LIB[name], args, ret)
38
+
39
+ ABI_VERSION = fn("hop_abi_version", [], I)
40
+ NODE_NEW = fn("hop_node_new", [], P)
41
+ NODE_WITH_SECRET = fn("hop_node_with_secret", [P, SZ], P)
42
+ NODE_FREE = fn("hop_node_free", [P], V)
43
+ NODE_ADDRESS = fn("hop_node_address", [P, P], CH)
44
+ NODE_TICK = fn("hop_node_tick", [P, LL], V)
45
+ LINK_UP = fn("hop_link_up", [P, LL, I], V)
46
+ BYTES_RECEIVED = fn("hop_bytes_received", [P, LL, P, SZ], V)
47
+ LINK_DOWN = fn("hop_link_down", [P, LL], V)
48
+ DRAIN_OUTGOING = fn("hop_drain_outgoing", [P, P, P], V)
49
+ SUBSCRIBE = fn("hop_subscribe", [P, P], V)
50
+ PUBLISH_PREKEY = fn("hop_publish_prekey", [P], CH)
51
+ SEND_SERVICE_REQUEST = fn("hop_send_service_request", [P, P, P, P, P, SZ, P], CH)
52
+ SEND_SERVICE_RESPONSE = fn("hop_send_service_response", [P, P, P, I, P, SZ], CH)
53
+ POLL_SERVICE_REQUESTS = fn("hop_poll_service_requests", [P, P, P], V)
54
+ POLL_SERVICE_RESPONSES = fn("hop_poll_service_responses", [P, P, P], V)
55
+ ADDRESS_TO_BASE58 = fn("hop_address_to_base58", [P, P, SZ], SZ)
56
+ ADDRESS_FROM_BASE58 = fn("hop_address_from_base58", [P, P], CH)
57
+ SIGN_REACH_RECORD = fn("hop_sign_reach_record", [P, P, I, P, P], V)
58
+ VERIFY_REACH_RECORD = fn("hop_verify_reach_record", [P, SZ, LL, P, P], CH)
59
+ # Endpoint clustering (DESIGN.md §40).
60
+ CLUSTER_JOIN = fn("hop_cluster_join", [P, P], V)
61
+ CLUSTER_JOIN_PASSPHRASE = fn("hop_cluster_join_passphrase", [P, P, SZ], V)
62
+ CLUSTER_MEMBERS = fn("hop_cluster_members", [P], I)
63
+ CLUSTER_SET_QUORUM = fn("hop_cluster_set_quorum", [P, I], V)
64
+
65
+ Closure = Fiddle::Closure::BlockCaller
66
+
67
+ def self.assert_abi!
68
+ got = ABI_VERSION.call
69
+ raise "libhop ABI mismatch: wrapper expects #{ABI_EXPECTED}, library reports #{got}" if got != ABI_EXPECTED
70
+ end
71
+
72
+ # ---- helpers: read C memory that is valid only during a call ----
73
+ def self.read_bytes(ptr, len) = len.zero? ? "".b : Fiddle::Pointer.new(ptr)[0, len].b
74
+ def self.read_cstr(ptr) = Fiddle::Pointer.new(ptr).to_s
75
+
76
+ # ---- thin wrappers ----
77
+ def self.node_new = NODE_NEW.call
78
+ def self.node_with_secret(secret) = NODE_WITH_SECRET.call(secret, secret.bytesize)
79
+ def self.node_free(node) = NODE_FREE.call(node)
80
+ def self.tick(node, now_ms) = NODE_TICK.call(node, now_ms)
81
+ def self.connected(node, link, initiator) = LINK_UP.call(node, link, initiator ? 0 : 1)
82
+ def self.disconnected(node, link) = LINK_DOWN.call(node, link)
83
+ def self.received(node, link, data) = BYTES_RECEIVED.call(node, link, data, data.bytesize)
84
+ def self.subscribe(node, topic) = SUBSCRIBE.call(node, topic)
85
+ def self.cluster_join(node, secret) = CLUSTER_JOIN.call(node, secret)
86
+ def self.cluster_join_passphrase(node, pass) = CLUSTER_JOIN_PASSPHRASE.call(node, pass, pass.bytesize)
87
+ def self.cluster_members(node) = CLUSTER_MEMBERS.call(node)
88
+ def self.cluster_set_quorum(node, min) = CLUSTER_SET_QUORUM.call(node, min)
89
+ def self.publish_prekey(node) = PUBLISH_PREKEY.call(node) != 0
90
+
91
+ def self.address(node)
92
+ out = Fiddle::Pointer.malloc(32, Fiddle::RUBY_FREE)
93
+ NODE_ADDRESS.call(node, out)
94
+ out[0, 32].b
95
+ end
96
+
97
+ def self.drain_outgoing(node)
98
+ out = []
99
+ sink = Closure.new(V, [P, LL, P, SZ]) { |_ctx, link, ptr, len| out << [link, read_bytes(ptr, len)] }
100
+ DRAIN_OUTGOING.call(node, sink, nil)
101
+ out
102
+ end
103
+
104
+ def self.send_service_request(node, dst, service, method, args)
105
+ out = Fiddle::Pointer.malloc(32, Fiddle::RUBY_FREE)
106
+ ok = SEND_SERVICE_REQUEST.call(node, dst, service, method, args, args.bytesize, out) != 0
107
+ raise "hop_send_service_request failed" unless ok
108
+
109
+ out[0, 32].b
110
+ end
111
+
112
+ def self.send_service_response(node, to, for_request_id, status, body)
113
+ SEND_SERVICE_RESPONSE.call(node, to, for_request_id, status, body, body.bytesize) != 0
114
+ end
115
+
116
+ def self.take_service_requests(node)
117
+ out = []
118
+ sink = Closure.new(V, [P, P, P, P, P, P, SZ]) do |_ctx, frm, rid, service, method, args, arglen|
119
+ out << [read_bytes(frm, 32), read_bytes(rid, 32), read_cstr(service), read_cstr(method), read_bytes(args, arglen)]
120
+ end
121
+ POLL_SERVICE_REQUESTS.call(node, sink, nil)
122
+ out
123
+ end
124
+
125
+ def self.take_service_responses(node)
126
+ out = []
127
+ sink = Closure.new(V, [P, P, P, I, P, SZ]) do |_ctx, frm, for_id, status, body, body_len|
128
+ out << [read_bytes(frm, 32), read_bytes(for_id, 32), status & 0xFFFF, read_bytes(body, body_len)]
129
+ end
130
+ POLL_SERVICE_RESPONSES.call(node, sink, nil)
131
+ out
132
+ end
133
+
134
+ def self.to_b58(addr32)
135
+ out = Fiddle::Pointer.malloc(64, Fiddle::RUBY_FREE)
136
+ n = ADDRESS_TO_BASE58.call(addr32, out, 64)
137
+ out[0, n]
138
+ end
139
+
140
+ def self.from_b58(text)
141
+ out = Fiddle::Pointer.malloc(32, Fiddle::RUBY_FREE)
142
+ raise "not a valid Hop address: #{text}" if ADDRESS_FROM_BASE58.call(text, out).zero?
143
+
144
+ out[0, 32].b
145
+ end
146
+
147
+ def self.sign_reach(node, endpoint, ttl_secs)
148
+ result = nil
149
+ sink = Closure.new(V, [P, P, SZ]) { |_ctx, ptr, len| result = read_bytes(ptr, len) }
150
+ SIGN_REACH_RECORD.call(node, endpoint, ttl_secs, sink, nil)
151
+ result
152
+ end
153
+
154
+ def self.verify_reach(record, now_secs)
155
+ info = nil
156
+ sink = Closure.new(V, [P, P, P, LL, I]) do |_ctx, addr, endpoint, issued_at, ttl_secs|
157
+ info = { address: read_bytes(addr, 32), endpoint: read_cstr(endpoint), issued_at: issued_at, ttl_secs: ttl_secs }
158
+ end
159
+ ok = VERIFY_REACH_RECORD.call(record, record.bytesize, now_secs, sink, nil) != 0
160
+ ok ? info : nil
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module Hop
6
+ # The raw-TCP Internet bearer: opaque Hop frames over TCP, core does the Noise. TCP is a stream, so
7
+ # each drained packet is length-prefixed (4-byte big-endian) and reassembled on the far side.
8
+ module TcpBearer
9
+ MAX_FRAME_BYTES = 1 << 20
10
+ @seq = 40_000
11
+ @seq_mutex = Mutex.new
12
+ def self.next_link = @seq_mutex.synchronize { @seq += 1 }
13
+
14
+ def self.send_framed(sock, buf)
15
+ sock.write([buf.bytesize].pack("N") + buf)
16
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET, Errno::EBADF
17
+ nil
18
+ end
19
+
20
+ def self.read_exact(sock, n)
21
+ data = +"".b
22
+ while data.bytesize < n
23
+ chunk = sock.read(n - data.bytesize)
24
+ return nil unless chunk
25
+
26
+ data << chunk
27
+ end
28
+ data
29
+ end
30
+
31
+ def self.recv_loop(endpoint, sock, link)
32
+ loop do
33
+ hdr = read_exact(sock, 4)
34
+ break unless hdr
35
+
36
+ n = hdr.unpack1("N")
37
+ break if n > MAX_FRAME_BYTES
38
+ frame = n.zero? ? "".b : read_exact(sock, n)
39
+ break unless frame
40
+
41
+ endpoint.deliver(link, frame)
42
+ end
43
+ rescue IOError, Errno::ECONNRESET, Errno::EBADF
44
+ nil
45
+ ensure
46
+ endpoint.link_down(link)
47
+ begin
48
+ sock.close
49
+ rescue StandardError
50
+ nil
51
+ end
52
+ end
53
+
54
+ def self.listen(endpoint, port, host: "0.0.0.0")
55
+ server = TCPServer.new(host, port)
56
+ sockets = {}
57
+ sockets_mutex = Mutex.new
58
+ closing = false
59
+ endpoint.register_closer do
60
+ sockets_mutex.synchronize do
61
+ closing = true
62
+ server.close rescue nil
63
+ sockets.each_key { |socket| socket.close rescue nil }
64
+ end
65
+ end
66
+ Thread.new do
67
+ loop do
68
+ sock = begin
69
+ server.accept
70
+ rescue StandardError
71
+ break
72
+ end
73
+ reject = sockets_mutex.synchronize do
74
+ if closing
75
+ true
76
+ else
77
+ sockets[sock] = true
78
+ false
79
+ end
80
+ end
81
+ if reject
82
+ sock.close rescue nil
83
+ next
84
+ end
85
+ link = next_link
86
+ endpoint.register_link(link, :acceptor, ->(buf) { send_framed(sock, buf) })
87
+ Thread.new do
88
+ recv_loop(endpoint, sock, link)
89
+ sockets_mutex.synchronize { sockets.delete(sock) }
90
+ end
91
+ end
92
+ end
93
+ server
94
+ end
95
+
96
+ def self.dial(endpoint, host, port)
97
+ sock = TCPSocket.new(host, port)
98
+ link = next_link
99
+ endpoint.register_link(link, :dialer, ->(buf) { send_framed(sock, buf) })
100
+ endpoint.register_closer { sock.close rescue nil }
101
+ Thread.new { recv_loop(endpoint, sock, link) }
102
+ sock
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The WSS Internet bearer for a Ruby endpoint, in pure stdlib (no gems): a minimal RFC 6455 WebSocket
4
+ # (Upgrade handshake + binary framing) over the stdlib socket + OpenSSL. The server also answers GET
5
+ # /.well-known/hop on the same port, so attach wires both. core does the Noise + crypto over the frame
6
+ # payloads; one drained packet is one binary WS message. IO buffering (gets then read) cleanly
7
+ # separates the HTTP handshake from the frame stream, so a header read never over-consumes frame bytes.
8
+ require "socket"
9
+ require "openssl"
10
+ require "digest/sha1"
11
+ require "base64"
12
+ require "uri"
13
+ require "hop/discovery"
14
+
15
+ module Hop
16
+ module WssBearer
17
+ GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
18
+ MAX_FRAME_BYTES = 1 << 20
19
+
20
+ @seq = 60_000
21
+ @seq_mutex = Mutex.new
22
+ def self.next_link = @seq_mutex.synchronize { @seq += 1 }
23
+
24
+ def self.accept_key(key) = Base64.strict_encode64(Digest::SHA1.digest(key + GUID))
25
+
26
+ def self.encode_frame(payload, mask)
27
+ n = payload.bytesize
28
+ header = (+"\x82").b # FIN + binary opcode
29
+ mb = mask ? 0x80 : 0
30
+ if n < 126
31
+ header << (mb | n).chr
32
+ elsif n < 65_536
33
+ header << (mb | 126).chr << [n].pack("n")
34
+ else
35
+ header << (mb | 127).chr << [n].pack("Q>")
36
+ end
37
+ if mask
38
+ mk = Random.bytes(4)
39
+ header << mk << apply_mask(payload, mk)
40
+ else
41
+ header << payload
42
+ end
43
+ header
44
+ end
45
+
46
+ def self.apply_mask(data, mask)
47
+ out = data.dup.b
48
+ out.bytesize.times { |i| out.setbyte(i, out.getbyte(i) ^ mask.getbyte(i % 4)) }
49
+ out
50
+ end
51
+
52
+ def self.read_exact(sock, n)
53
+ return "".b if n.zero?
54
+
55
+ data = sock.read(n)
56
+ raise EOFError, "closed" unless data && data.bytesize == n
57
+
58
+ data
59
+ end
60
+
61
+ def self.read_frame(sock)
62
+ b0, b1 = read_exact(sock, 2).bytes
63
+ opcode = b0 & 0x0F
64
+ masked = (b1 & 0x80) != 0
65
+ len = b1 & 0x7F
66
+ len = read_exact(sock, 2).unpack1("n") if len == 126
67
+ len = read_exact(sock, 8).unpack1("Q>") if len == 127
68
+ raise IOError, "WebSocket frame exceeds 1 MiB" if len > MAX_FRAME_BYTES
69
+ mask = masked ? read_exact(sock, 4) : nil
70
+ payload = read_exact(sock, len)
71
+ payload = apply_mask(payload, mask) if mask
72
+ [opcode, payload]
73
+ end
74
+
75
+ def self.run_link(endpoint, sock, role, mask)
76
+ link = next_link
77
+ send_fn = lambda do |buf|
78
+ sock.write(encode_frame(buf, mask))
79
+ rescue StandardError
80
+ nil
81
+ end
82
+ endpoint.register_link(link, role, send_fn)
83
+ loop do
84
+ opcode, payload = read_frame(sock)
85
+ break if opcode == 0x8
86
+
87
+ endpoint.deliver(link, payload) if [0x2, 0x0].include?(opcode)
88
+ end
89
+ rescue EOFError, IOError, OpenSSL::SSL::SSLError, Errno::ECONNRESET
90
+ nil
91
+ ensure
92
+ endpoint.link_down(link)
93
+ begin
94
+ sock.close
95
+ rescue StandardError
96
+ nil
97
+ end
98
+ end
99
+
100
+ def self.serve(endpoint, port, ssl_context, public_url, ttl_secs = 3600)
101
+ tcp = TCPServer.new(port)
102
+ ssl_server = OpenSSL::SSL::SSLServer.new(tcp, ssl_context)
103
+ # Let endpoint#close stop the listener so this accept loop exits instead of spinning on a closed
104
+ # socket (accept on a closed server raises immediately, which without the break is a busy loop).
105
+ endpoint.register_closer { ssl_server.close rescue nil }
106
+ Thread.new do
107
+ loop do
108
+ sock = begin
109
+ ssl_server.accept
110
+ rescue StandardError
111
+ break if tcp.closed?
112
+
113
+ next
114
+ end
115
+ Thread.new { handle_conn(endpoint, sock, public_url, ttl_secs) }
116
+ end
117
+ end
118
+ ssl_server
119
+ end
120
+
121
+ def self.handle_conn(endpoint, sock, public_url, ttl_secs)
122
+ request_line = sock.gets
123
+ return unless request_line
124
+
125
+ _method, path, = request_line.split
126
+ headers = {}
127
+ while (line = sock.gets) && line != "\r\n"
128
+ k, v = line.split(":", 2)
129
+ headers[k.strip.downcase] = v.strip if v
130
+ end
131
+
132
+ if path == "/.well-known/hop"
133
+ body = Hop::Discovery.well_known_body(endpoint, public_url, ttl_secs)
134
+ sock.write("HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: #{body.bytesize}\r\nconnection: close\r\n\r\n#{body}")
135
+ sock.close
136
+ elsif path == "/_hop" && headers["upgrade"]&.downcase == "websocket"
137
+ sock.write("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: #{accept_key(headers["sec-websocket-key"])}\r\n\r\n")
138
+ run_link(endpoint, sock, :acceptor, false)
139
+ else
140
+ sock.write("HTTP/1.1 404 Not Found\r\nconnection: close\r\n\r\n")
141
+ sock.close
142
+ end
143
+ rescue StandardError
144
+ begin
145
+ sock.close
146
+ rescue StandardError
147
+ nil
148
+ end
149
+ end
150
+
151
+ def self.dial(endpoint, wss_url, insecure_tls: false)
152
+ uri = URI.parse(wss_url)
153
+ ctx = OpenSSL::SSL::SSLContext.new
154
+ ctx.verify_mode = insecure_tls ? OpenSSL::SSL::VERIFY_NONE : OpenSSL::SSL::VERIFY_PEER
155
+ sock = OpenSSL::SSL::SSLSocket.new(TCPSocket.new(uri.host, uri.port || 443), ctx)
156
+ sock.hostname = uri.host
157
+ sock.connect
158
+ key = Base64.strict_encode64(Random.bytes(16))
159
+ path = uri.path.to_s.empty? ? "/_hop" : uri.path
160
+ sock.write("GET #{path} HTTP/1.1\r\nHost: #{uri.host}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: #{key}\r\nSec-WebSocket-Version: 13\r\n\r\n")
161
+ status = sock.gets
162
+ raise "WS upgrade failed: #{status}" unless status&.include?("101")
163
+
164
+ nil while (line = sock.gets) && line != "\r\n" # drain response headers
165
+ endpoint.register_closer { sock.close rescue nil } # so endpoint#close ends run_link's read loop
166
+ Thread.new { run_link(endpoint, sock, :dialer, true) }
167
+ sock
168
+ end
169
+ end
170
+ end
data/lib/hop.rb ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Receive Hop messages in Ruby: an embeddable endpoint over the libhop C ABI (via Fiddle, zero gems).
4
+ require "hop/ffi"
5
+ require "hop/endpoint"
6
+ require "hop/tcp_bearer"
7
+
8
+ module Hop
9
+ # Wire two endpoints directly (in-process bearer), no sockets. Proves the ergonomics end to end.
10
+ def self.connect_in_process(a, b, la: 11, lb: 22)
11
+ a.register_link(la, :dialer, ->(buf) { b.deliver(lb, buf) })
12
+ b.register_link(lb, :acceptor, ->(buf) { a.deliver(la, buf) })
13
+ end
14
+ end
metadata ADDED
@@ -0,0 +1,54 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: hop-endpoint
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Jason Waldrip
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-07-16 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Receive Hop messages in Ruby with a hop.on / reply surface, over libhop
14
+ via Fiddle. Your service becomes directly reachable on the mesh, no relay. Zero
15
+ gems (stdlib only).
16
+ email:
17
+ executables: []
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - README.md
22
+ - lib/hop.rb
23
+ - lib/hop/dev_tls.rb
24
+ - lib/hop/discovery.rb
25
+ - lib/hop/endpoint.rb
26
+ - lib/hop/ffi.rb
27
+ - lib/hop/tcp_bearer.rb
28
+ - lib/hop/wss_bearer.rb
29
+ homepage: https://hopme.sh
30
+ licenses:
31
+ - Apache-2.0
32
+ metadata:
33
+ rubygems_mfa_required: 'true'
34
+ post_install_message:
35
+ rdoc_options: []
36
+ require_paths:
37
+ - lib
38
+ required_ruby_version: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '3.0'
43
+ required_rubygems_version: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ requirements: []
49
+ rubygems_version: 3.0.3.1
50
+ signing_key:
51
+ specification_version: 4
52
+ summary: Embeddable Hop mesh endpoint for Ruby (Sinatra/Rails-shaped) over the libhop
53
+ C ABI
54
+ test_files: []