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 +7 -0
- data/README.md +146 -0
- data/lib/hop/dev_tls.rb +28 -0
- data/lib/hop/discovery.rb +38 -0
- data/lib/hop/endpoint.rb +220 -0
- data/lib/hop/ffi.rb +163 -0
- data/lib/hop/tcp_bearer.rb +105 -0
- data/lib/hop/wss_bearer.rb +170 -0
- data/lib/hop.rb +14 -0
- metadata +54 -0
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.
|
data/lib/hop/dev_tls.rb
ADDED
|
@@ -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
|
data/lib/hop/endpoint.rb
ADDED
|
@@ -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: []
|