asterism 0.7.1 → 0.8.0
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 +4 -4
- data/CHANGELOG.md +56 -0
- data/README.md +166 -5
- data/exe/asterism +8 -0
- data/lib/asterism/cli/allow.rb +53 -0
- data/lib/asterism/cli/literal.rb +132 -0
- data/lib/asterism/cli/mcp.rb +272 -0
- data/lib/asterism/cli/survey.rb +204 -0
- data/lib/asterism/cli.rb +424 -0
- data/lib/asterism/cruby/credentials.rb +61 -116
- data/lib/asterism/cruby/objects.rb +60 -7
- data/lib/asterism/version.rb +1 -1
- data/lib/asterism.rb +1 -1
- data/mrblib/asterism.rb +206 -23
- data/mrblib/credentials.rb +197 -0
- data/mrblib/status.rb +198 -0
- metadata +13 -4
data/lib/asterism/cli.rb
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
# The command line of the gem (exe/asterism): look at an Asterism network
|
|
2
|
+
# from CRuby, and call an object when asked to.
|
|
3
|
+
#
|
|
4
|
+
# asterism nodes --router tcp/192.0.2.2:7447
|
|
5
|
+
# asterism tree --credentials admin.tar
|
|
6
|
+
# asterism objects 'games/games/*'
|
|
7
|
+
# asterism watch # joins, leaves, statuses as they come
|
|
8
|
+
# asterism ping games/games/othello1 # the time of a round trip (meta)
|
|
9
|
+
# asterism call games/games/othello1 view '"<token>"'
|
|
10
|
+
# asterism mcp --allow 'games/othello1.*' # an MCP server on stdio
|
|
11
|
+
#
|
|
12
|
+
# It is one more Asterism node: it connects with Asterism.connect (as the
|
|
13
|
+
# certificate's name with --credentials, as cli-<6 hex digits> otherwise)
|
|
14
|
+
# and reads what docs/protocol.md lets anyone read. It calls a method only
|
|
15
|
+
# with `call` (and the MCP bridge only the methods --allow names). What it
|
|
16
|
+
# can see is what the router lets it see: with a console's .tar, its role.
|
|
17
|
+
require "asterism"
|
|
18
|
+
require "optparse"
|
|
19
|
+
require "json"
|
|
20
|
+
require "securerandom"
|
|
21
|
+
require_relative "cli/literal"
|
|
22
|
+
require_relative "cli/survey"
|
|
23
|
+
require_relative "cli/allow"
|
|
24
|
+
|
|
25
|
+
module Asterism
|
|
26
|
+
class CLI
|
|
27
|
+
USAGE = <<~TEXT
|
|
28
|
+
Usage: asterism COMMAND [options]
|
|
29
|
+
|
|
30
|
+
Commands (they only read, except call):
|
|
31
|
+
nodes the nodes, their applications and profiles
|
|
32
|
+
objects [PATTERN] the objects and their methods (PATTERN: node/app/object, * and **)
|
|
33
|
+
tree nodes > applications (with their status) > objects (with their methods)
|
|
34
|
+
watch nodes, applications and objects coming and going, and status changes
|
|
35
|
+
ping PATH round trips: PATH node/app/object (its meta), or node (its profile);
|
|
36
|
+
--call METHOD [ARG...] times a call instead (echo is the same command)
|
|
37
|
+
call PATH METHOD [ARG...]
|
|
38
|
+
calls a method; each ARG is JSON, else a Ruby literal, else text
|
|
39
|
+
mcp an MCP server on stdio (needs the mcp gem): the listings as tools,
|
|
40
|
+
and the methods --allow names
|
|
41
|
+
version the gem's version
|
|
42
|
+
|
|
43
|
+
Where the network is (one of them):
|
|
44
|
+
--router LOCATOR a router without TLS, e.g. tcp/192.0.2.2:7447
|
|
45
|
+
--credentials TAR a console's .tar (mutual TLS; its router and name)
|
|
46
|
+
TEXT
|
|
47
|
+
|
|
48
|
+
def self.start(argv, out: $stdout, err: $stderr)
|
|
49
|
+
new(out: out, err: err).run(argv.dup)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def initialize(out: $stdout, err: $stderr)
|
|
53
|
+
@out = out
|
|
54
|
+
@err = err
|
|
55
|
+
@opts = { timeout: 2.0, settle: 1.5, json: false, count: 5, interval: 0.2, allow: [] }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def run(argv)
|
|
59
|
+
cmd = argv.shift
|
|
60
|
+
case cmd
|
|
61
|
+
when nil, "help", "-h", "--help"
|
|
62
|
+
@out.puts USAGE
|
|
63
|
+
return cmd.nil? ? 2 : 0
|
|
64
|
+
when "version", "--version", "-v"
|
|
65
|
+
@out.puts "asterism #{::Asterism::VERSION}"
|
|
66
|
+
return 0
|
|
67
|
+
end
|
|
68
|
+
unless %w[nodes objects tree watch ping echo call mcp].include?(cmd)
|
|
69
|
+
@err.puts "asterism: unknown command #{cmd.inspect}\n\n#{USAGE}"
|
|
70
|
+
return 2
|
|
71
|
+
end
|
|
72
|
+
rest = parser(cmd).parse(argv)
|
|
73
|
+
send("cmd_#{cmd == 'echo' ? 'ping' : cmd}", rest)
|
|
74
|
+
rescue OptionParser::ParseError, ArgumentError => e
|
|
75
|
+
@err.puts "asterism: #{e.message}"
|
|
76
|
+
2
|
|
77
|
+
rescue ::Asterism::Disconnected => e
|
|
78
|
+
@err.puts "asterism: cannot reach the network: #{e.message}"
|
|
79
|
+
1
|
|
80
|
+
rescue ::Asterism::Error => e
|
|
81
|
+
@err.puts "asterism: #{e.class}: #{e.message}"
|
|
82
|
+
1
|
|
83
|
+
rescue Interrupt
|
|
84
|
+
0
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def parser(cmd)
|
|
88
|
+
OptionParser.new do |o|
|
|
89
|
+
o.banner = "Usage: asterism #{cmd} [options]"
|
|
90
|
+
o.on("--router LOCATOR", "a router (tcp/HOST:7447)") { @opts[:router] = _1 }
|
|
91
|
+
o.on("--credentials TAR", "a console's .tar") { @opts[:credentials] = _1 }
|
|
92
|
+
o.on("--peer LOCATOR", "a direct peer link to a node listening there (no router)") { @opts[:peer] = _1 }
|
|
93
|
+
o.on("--node NAME", "this node's ID (default: the certificate's name, or cli-<6 hex>)") { @opts[:node] = _1 }
|
|
94
|
+
o.on("--app NAME", "this application's name (default: cli-<4 hex>)") { @opts[:app] = _1 }
|
|
95
|
+
o.on("--json", "JSON instead of text (watch: one JSON object a line)") { @opts[:json] = true }
|
|
96
|
+
o.on("--timeout SECONDS", Float, "time limit of each get or call (2.0)") { @opts[:timeout] = _1 }
|
|
97
|
+
o.on("--settle SECONDS", Float, "how long to wait for the tokens at connect (1.5)") { @opts[:settle] = _1 }
|
|
98
|
+
case cmd
|
|
99
|
+
when "watch"
|
|
100
|
+
o.on("--duration SECONDS", Float, "stop after this long (default: until Ctrl-C)") { @opts[:duration] = _1 }
|
|
101
|
+
when "ping", "echo"
|
|
102
|
+
o.on("--count N", Integer, "round trips (5)") { @opts[:count] = _1 }
|
|
103
|
+
o.on("--interval SECONDS", Float, "pause between them (0.2)") { @opts[:interval] = _1 }
|
|
104
|
+
o.on("--call METHOD", "time a call of METHOD (with the ARGs after PATH) instead of the meta") { @opts[:call] = _1 }
|
|
105
|
+
when "call"
|
|
106
|
+
o.on("--kwargs VALUE", "keyword arguments: a JSON object or a Ruby Hash literal") { @opts[:kwargs] = _1 }
|
|
107
|
+
when "mcp"
|
|
108
|
+
o.on("--allow PATTERN", "methods the MCP client may call: [node/][app/]object.method, * as a wildcard " \
|
|
109
|
+
"(repeatable)") { @opts[:allow] << _1 }
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# ------------------------------------------------------------ connecting
|
|
115
|
+
|
|
116
|
+
# Connects, starts the receiving thread, waits for the tokens and yields
|
|
117
|
+
# the Net and a Survey.
|
|
118
|
+
def connect(app_prefix: "cli")
|
|
119
|
+
router = @opts[:router]
|
|
120
|
+
creds = @opts[:credentials]
|
|
121
|
+
peer = @opts[:peer]
|
|
122
|
+
if router.nil? && creds.nil? && peer.nil?
|
|
123
|
+
raise ArgumentError, "where is the network? --router tcp/HOST:7447 or --credentials FILE.tar"
|
|
124
|
+
end
|
|
125
|
+
node = @opts[:node]
|
|
126
|
+
node ||= "#{app_prefix}-#{SecureRandom.hex(3)}" unless creds
|
|
127
|
+
app = @opts[:app] || "#{app_prefix}-#{SecureRandom.hex(2)}"
|
|
128
|
+
kw = { node: node, app: app }
|
|
129
|
+
kw[:credentials] = creds if creds
|
|
130
|
+
locator = router
|
|
131
|
+
if peer
|
|
132
|
+
locator = peer
|
|
133
|
+
kw[:mode] = :peer
|
|
134
|
+
end
|
|
135
|
+
::Asterism.profile(name: app_prefix == "mcp" ? "asterism MCP" : "asterism CLI")
|
|
136
|
+
::Asterism.connect(locator, **kw) do |net|
|
|
137
|
+
net.on_error { |e, where| @err.puts "asterism: #{where}: #{e.class}: #{e.message}" }
|
|
138
|
+
net.start
|
|
139
|
+
survey = Survey.new(timeout: @opts[:timeout])
|
|
140
|
+
survey.settle(@opts[:settle])
|
|
141
|
+
yield net, survey
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def emit_json(v)
|
|
146
|
+
@out.puts JSON.pretty_generate(v)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# ------------------------------------------------------------ nodes
|
|
150
|
+
|
|
151
|
+
def cmd_nodes(_rest)
|
|
152
|
+
connect do |_net, sv|
|
|
153
|
+
tree = sv.tree(meta: false)
|
|
154
|
+
if @opts[:json]
|
|
155
|
+
emit_json(tree.map { |n| n.merge("apps" => n["apps"].map { |a| a.except("objects") }) })
|
|
156
|
+
elsif tree.empty?
|
|
157
|
+
@out.puts "(no other node is visible)"
|
|
158
|
+
else
|
|
159
|
+
tree.each do |n|
|
|
160
|
+
@out.puts "#{n['node']}#{profile_text(n['profile'])}"
|
|
161
|
+
n["apps"].each do |a|
|
|
162
|
+
@out.puts " #{a['app']}#{" #{a['objects'].size} objects" unless a['objects'].empty?}" \
|
|
163
|
+
"#{" status #{compact(a['status'])}" if a.key?('status')}"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
0
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# ------------------------------------------------------------ objects
|
|
172
|
+
|
|
173
|
+
def cmd_objects(rest)
|
|
174
|
+
pattern = rest.shift || "**"
|
|
175
|
+
connect do |_net, sv|
|
|
176
|
+
paths = sv.objects(pattern)
|
|
177
|
+
metas = sv.metas(paths)
|
|
178
|
+
if @opts[:json]
|
|
179
|
+
emit_json(paths.map do |p|
|
|
180
|
+
h = { "path" => p, "methods" => metas[p] }
|
|
181
|
+
h["doc"] = sv.object_doc(p) if sv.object_doc(p)
|
|
182
|
+
h
|
|
183
|
+
end)
|
|
184
|
+
elsif paths.empty?
|
|
185
|
+
@out.puts "(no object matches #{pattern})"
|
|
186
|
+
else
|
|
187
|
+
paths.each do |p|
|
|
188
|
+
@out.puts "#{p} #{metas.key?(p) ? methods_text(metas[p]) : '(no meta: not answered in time, or not allowed)'}"
|
|
189
|
+
@out.puts " #{sv.object_doc(p)}" if sv.object_doc(p)
|
|
190
|
+
# The methods that tell what they do, one a line (doc's first line).
|
|
191
|
+
(metas[p] || []).each do |m|
|
|
192
|
+
@out.puts " #{signature(m)} #{m['doc'].lines.first.strip}" if m["doc"]
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
0
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# ------------------------------------------------------------ tree
|
|
201
|
+
|
|
202
|
+
def cmd_tree(_rest)
|
|
203
|
+
connect do |_net, sv|
|
|
204
|
+
tree = sv.tree
|
|
205
|
+
if @opts[:json]
|
|
206
|
+
emit_json(tree)
|
|
207
|
+
elsif tree.empty?
|
|
208
|
+
@out.puts "(no other node is visible)"
|
|
209
|
+
else
|
|
210
|
+
tree.each do |n|
|
|
211
|
+
@out.puts "#{n['node']}#{profile_text(n['profile'])}"
|
|
212
|
+
n["apps"].each do |a|
|
|
213
|
+
@out.puts " #{a['app']}#{" status #{compact(a['status'])}" if a.key?('status')}"
|
|
214
|
+
a["objects"].each do |o|
|
|
215
|
+
@out.puts " #{o['name']} #{o['methods'] ? methods_text(o['methods']) : '(no meta)'}"
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
0
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# ------------------------------------------------------------ watch
|
|
225
|
+
|
|
226
|
+
# Prints the changes until Ctrl-C (or --duration): nodes (Asterism's
|
|
227
|
+
# on_join / on_leave), applications and objects (their liveliness
|
|
228
|
+
# tokens), statuses (their puts) and profiles (their puts). The
|
|
229
|
+
# statuses there at the start come first.
|
|
230
|
+
def cmd_watch(_rest)
|
|
231
|
+
connect do |net, sv|
|
|
232
|
+
own = "#{net.node_id}/#{net.app}"
|
|
233
|
+
sv.statuses.sort.each { |path, st| event("status", path, status: st, now: true) }
|
|
234
|
+
net.on_join { |n| event("join", n, what: "node") }
|
|
235
|
+
net.on_leave { |n| event("leave", n, what: "node") }
|
|
236
|
+
net.session.liveliness_watch("asterism/**") do |key, alive|
|
|
237
|
+
# The tokens alive at the start come first, in one burst: not news.
|
|
238
|
+
next unless @watch_started
|
|
239
|
+
path = key.delete_prefix("asterism/")
|
|
240
|
+
next if path == own || path.start_with?("#{own}/")
|
|
241
|
+
what = { 2 => "app", 3 => "object" }[path.count("/") + 1]
|
|
242
|
+
event(alive ? "join" : "leave", path, what: what) if what
|
|
243
|
+
end
|
|
244
|
+
net.session.subscribe(Survey::STATUS_ALL) do |sample|
|
|
245
|
+
got = ::Asterism::Status.parse(sample.key, sample.payload)
|
|
246
|
+
event("status", "#{got[0]}/#{got[1]}", status: got[2]) if got && "#{got[0]}/#{got[1]}" != own
|
|
247
|
+
end
|
|
248
|
+
net.session.subscribe(Survey::PROFILES_ALL) do |sample|
|
|
249
|
+
node = sample.key.split("/")[1]
|
|
250
|
+
pr = ::Asterism::Profile.check(::Asterism::Codec.unpack(sample.payload))
|
|
251
|
+
event("profile", node, profile: pr) if pr && node != net.node_id
|
|
252
|
+
end
|
|
253
|
+
sleep 0.3
|
|
254
|
+
@watch_started = true
|
|
255
|
+
@err.puts "asterism: watching (Ctrl-C to stop)" unless @opts[:json]
|
|
256
|
+
deadline = @opts[:duration] && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + @opts[:duration])
|
|
257
|
+
loop do
|
|
258
|
+
break if deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
259
|
+
break unless net.connected?
|
|
260
|
+
sleep 0.05
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
0
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def event(kind, path, what: nil, status: nil, profile: nil, now: false)
|
|
267
|
+
t = Time.now
|
|
268
|
+
if @opts[:json]
|
|
269
|
+
h = { "t" => t.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ"), "event" => kind, "path" => path }
|
|
270
|
+
h["what"] = what if what
|
|
271
|
+
h["status"] = status if status
|
|
272
|
+
h["profile"] = profile if profile
|
|
273
|
+
h["now"] = true if now
|
|
274
|
+
@out.puts JSON.generate(h)
|
|
275
|
+
else
|
|
276
|
+
mark = { "join" => "+", "leave" => "-", "status" => "~", "profile" => "*" }[kind]
|
|
277
|
+
text = case kind
|
|
278
|
+
when "status" then "status #{path} #{compact(status)}#{' (now)' if now}"
|
|
279
|
+
when "profile" then "profile #{path}#{profile_text(profile)}"
|
|
280
|
+
else "#{what} #{path}"
|
|
281
|
+
end
|
|
282
|
+
@out.puts "#{t.strftime('%H:%M:%S.%L')} #{mark} #{text}"
|
|
283
|
+
end
|
|
284
|
+
@out.flush
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# ------------------------------------------------------------ ping
|
|
288
|
+
|
|
289
|
+
# Round trips to PATH: the meta of an object (no method runs), the
|
|
290
|
+
# profile of a node, or with --call a method (with the ARGs given).
|
|
291
|
+
def cmd_ping(rest)
|
|
292
|
+
path = rest.shift or raise ArgumentError, "ping: which node or object? (asterism ping node/app/object)"
|
|
293
|
+
args = rest.map { Literal.parse(_1) }
|
|
294
|
+
chunks = path.split("/")
|
|
295
|
+
raise ArgumentError, "ping: PATH is node or node/app/object" unless [1, 3].include?(chunks.size)
|
|
296
|
+
raise ArgumentError, "ping: --call needs node/app/object" if @opts[:call] && chunks.size != 3
|
|
297
|
+
what = @opts[:call] ? "call #{@opts[:call]}" : (chunks.size == 3 ? "meta" : "profile")
|
|
298
|
+
times = []
|
|
299
|
+
connect do |_net, _sv|
|
|
300
|
+
ms = (@opts[:timeout] * 1000).round
|
|
301
|
+
@opts[:count].times do |i|
|
|
302
|
+
sleep @opts[:interval] if i.positive?
|
|
303
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
304
|
+
ok = begin
|
|
305
|
+
ping_once(path, chunks, args, ms)
|
|
306
|
+
true
|
|
307
|
+
rescue ::Asterism::RemoteError => e
|
|
308
|
+
@err.puts "asterism: #{path}: #{e.remote_kind || 'error'}: #{e.message}" if i.zero?
|
|
309
|
+
true # it answered
|
|
310
|
+
rescue ::Asterism::TimeoutError => e
|
|
311
|
+
@err.puts "asterism: #{e.message}"
|
|
312
|
+
false
|
|
313
|
+
end
|
|
314
|
+
dt = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000
|
|
315
|
+
times << dt if ok
|
|
316
|
+
@out.puts "#{path} #{what}: #{ok ? format('%.1f ms', dt) : 'no answer'}" unless @opts[:json]
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
if @opts[:json]
|
|
320
|
+
emit_json({ "path" => path, "what" => what, "count" => @opts[:count], "answered" => times.size,
|
|
321
|
+
"ms" => times.map { _1.round(2) } })
|
|
322
|
+
elsif times.any?
|
|
323
|
+
@out.puts format("%d of %d answered: min %.1f / avg %.1f / max %.1f ms", times.size, @opts[:count],
|
|
324
|
+
times.min, times.sum / times.size, times.max)
|
|
325
|
+
end
|
|
326
|
+
times.empty? ? 1 : 0
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def ping_once(path, chunks, args, ms)
|
|
330
|
+
if @opts[:call]
|
|
331
|
+
::Asterism[path, timeout_ms: ms].asterism_call(@opts[:call], *args)
|
|
332
|
+
elsif chunks.size == 3
|
|
333
|
+
raw = ::Asterism.meta(path, ms)
|
|
334
|
+
raise ::Asterism::Error, "malformed meta from #{path}" unless raw.is_a?(Hash)
|
|
335
|
+
else
|
|
336
|
+
::Asterism.profile_async(path, false, nil, ms).raw_value
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# ------------------------------------------------------------ call
|
|
341
|
+
|
|
342
|
+
def cmd_call(rest)
|
|
343
|
+
path = rest.shift
|
|
344
|
+
method = rest.shift
|
|
345
|
+
raise ArgumentError, "call: asterism call node/app/object METHOD [ARG...]" unless path && method
|
|
346
|
+
::Asterism.split_path(path)
|
|
347
|
+
args = rest.map { Literal.parse(_1) }
|
|
348
|
+
kwargs = @opts[:kwargs] ? Literal.parse(@opts[:kwargs]) : {}
|
|
349
|
+
raise ArgumentError, "--kwargs: expected a JSON object or a Ruby Hash" unless kwargs.is_a?(Hash)
|
|
350
|
+
kwargs = kwargs.transform_keys(&:to_sym)
|
|
351
|
+
code = 0
|
|
352
|
+
connect do |_net, _sv|
|
|
353
|
+
px = ::Asterism[path, timeout: @opts[:timeout]]
|
|
354
|
+
begin
|
|
355
|
+
v = px.asterism_call(method, *args, **kwargs)
|
|
356
|
+
@opts[:json] ? @out.puts(JSON.generate({ "value" => json_safe(v) })) : @out.puts(v.inspect)
|
|
357
|
+
rescue ::Asterism::RemoteError => e
|
|
358
|
+
if @opts[:json]
|
|
359
|
+
@out.puts JSON.generate({ "error" => { "kind" => e.remote_kind, "class" => e.remote_class,
|
|
360
|
+
"message" => e.remote_message } })
|
|
361
|
+
else
|
|
362
|
+
@err.puts "asterism: #{path}.#{method}: #{e.remote_kind || 'error'}: #{e.message}"
|
|
363
|
+
end
|
|
364
|
+
code = 1
|
|
365
|
+
rescue ::Asterism::TimeoutError => e
|
|
366
|
+
@err.puts "asterism: #{e.message}" \
|
|
367
|
+
"#{"\n (with a console's .tar, the router refuses a call its role does not allow; " \
|
|
368
|
+
'it looks the same as no answer)' if @opts[:credentials]}"
|
|
369
|
+
code = 1
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
code
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# ------------------------------------------------------------ mcp
|
|
376
|
+
|
|
377
|
+
def cmd_mcp(_rest)
|
|
378
|
+
begin
|
|
379
|
+
require_relative "cli/mcp"
|
|
380
|
+
rescue LoadError => e
|
|
381
|
+
raise unless e.message.include?("mcp")
|
|
382
|
+
@err.puts "asterism mcp needs the mcp gem: gem install mcp (#{e.message})"
|
|
383
|
+
return 1
|
|
384
|
+
end
|
|
385
|
+
connect(app_prefix: "mcp") do |net, sv|
|
|
386
|
+
MCPBridge.new(net, sv, allow: @opts[:allow], log: @err).run
|
|
387
|
+
end
|
|
388
|
+
0
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# ------------------------------------------------------------ text
|
|
392
|
+
|
|
393
|
+
def profile_text(pr)
|
|
394
|
+
return "" unless pr
|
|
395
|
+
parts = [pr["kind"], pr["host"]].compact.join("/")
|
|
396
|
+
name = pr["name"] ? " \"#{pr['name']}\"" : ""
|
|
397
|
+
" (#{parts})#{name}"
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def methods_text(list)
|
|
401
|
+
list.map { |m| signature(m) }.join(" ")
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# name(arg, arg) with the names the meta tells, else name(2) / name(*).
|
|
405
|
+
def signature(m)
|
|
406
|
+
return "#{m['name']}(#{m['args'].join(', ')}#{', *' if m['arity'].negative?})" if m["args"]
|
|
407
|
+
m["arity"].negative? ? "#{m['name']}(*)" : "#{m['name']}(#{m['arity']})"
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def compact(v)
|
|
411
|
+
JSON.generate(json_safe(v))
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# A value as JSON can hold it (bytes as a note).
|
|
415
|
+
def json_safe(v)
|
|
416
|
+
case v
|
|
417
|
+
when Hash then v.to_h { |k, x| [k.to_s, json_safe(x)] }
|
|
418
|
+
when Array then v.map { json_safe(_1) }
|
|
419
|
+
when String then v.encoding == Encoding::BINARY && !v.ascii_only? ? "(#{v.bytesize} bytes)" : v
|
|
420
|
+
else v
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
end
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
# The credentials of one node or router as one file
|
|
1
|
+
# The credentials of one node or router as one file, CRuby's part (the
|
|
2
|
+
# class itself is mrblib/credentials.rb, shared with the boards): the .tar
|
|
2
3
|
# an Asterism console hands out when it issues a certificate ("Save all
|
|
3
4
|
# (.tar)" on the certificate page; the archive is made in the browser that
|
|
4
5
|
# made the key). It holds, at its top or in one directory:
|
|
@@ -23,130 +24,74 @@ require "json"
|
|
|
23
24
|
require "stringio"
|
|
24
25
|
|
|
25
26
|
module Asterism
|
|
27
|
+
# The portable class is mrblib/credentials.rb (the boards read the .tar
|
|
28
|
+
# with it); CRuby reads the file with the standard library and checks
|
|
29
|
+
# more: who may read the file, and the key pair (prepended, so the
|
|
30
|
+
# portable methods are not redefined).
|
|
26
31
|
class Credentials
|
|
27
|
-
|
|
28
|
-
|
|
32
|
+
module CRuby
|
|
33
|
+
private
|
|
29
34
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
MAX_SIZE = 1024 * 1024
|
|
33
|
-
|
|
34
|
-
attr_reader :path, :name, :kind, :router, :not_after, :key_pem, :cert_pem, :ca_pem, :info
|
|
35
|
-
|
|
36
|
-
# Reads path (a String or Pathname of a .tar). warn: false skips the
|
|
37
|
-
# check of who may read the file.
|
|
38
|
-
def self.load(path, warn: true)
|
|
39
|
-
new(path, warn: warn)
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
# A credentials file (a path or a Credentials) and the arguments of a
|
|
43
|
-
# connection -> [locator, config Hash, credentials]. The locator given
|
|
44
|
-
# wins over asterism.json's router; config given (a Hash) is laid over
|
|
45
|
-
# the TLS keys. @api private
|
|
46
|
-
def self.merge(credentials, locator, config)
|
|
47
|
-
creds = credentials.is_a?(Credentials) ? credentials : load(credentials)
|
|
48
|
-
if config.is_a?(String)
|
|
49
|
-
raise ArgumentError, "credentials: and a config String cannot be combined; pass config: as a Hash"
|
|
35
|
+
def b64(pem)
|
|
36
|
+
[pem].pack("m0")
|
|
50
37
|
end
|
|
51
|
-
merged = creds.zenoh_config.merge(config || {})
|
|
52
|
-
[locator || creds.router, merged, creds]
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def initialize(path, warn: true)
|
|
56
|
-
@path = path.to_s
|
|
57
|
-
check_mode if warn
|
|
58
|
-
files = read_tar
|
|
59
|
-
@info = parse_info(files["asterism.json"])
|
|
60
|
-
keys = files.keys.select { _1.end_with?(".key") }
|
|
61
|
-
raise Error, "#{@path}: no private key (<name>.key) in it" if keys.empty?
|
|
62
|
-
raise Error, "#{@path}: more than one private key in it (#{keys.sort.join(', ')})" if keys.size > 1
|
|
63
|
-
@name = @info["name"].to_s
|
|
64
|
-
@name = File.basename(keys.first, ".key") if @name.empty?
|
|
65
|
-
@key_pem = files["#{@name}.key"] or raise Error, "#{@path}: the key is not named after #{@name} (#{keys.first})"
|
|
66
|
-
@cert_pem = files["#{@name}.pem"] or raise Error, "#{@path}: no certificate #{@name}.pem in it"
|
|
67
|
-
@ca_pem = files["ca.pem"] or raise Error, "#{@path}: no ca.pem in it"
|
|
68
|
-
@kind = @info["kind"]
|
|
69
|
-
@router = @info["router"].to_s.empty? ? nil : @info["router"].to_s
|
|
70
|
-
@not_after = @info["not_after"]
|
|
71
|
-
check_pair
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
# The zenoh configuration keys (a Hash for Session.open's config:):
|
|
75
|
-
# the CA, the certificate and the key as base64, and mutual TLS.
|
|
76
|
-
def zenoh_config
|
|
77
|
-
{
|
|
78
|
-
"#{TLS}/root_ca_certificate_base64" => b64(@ca_pem),
|
|
79
|
-
"#{TLS}/connect_certificate_base64" => b64(@cert_pem),
|
|
80
|
-
"#{TLS}/connect_private_key_base64" => b64(@key_pem),
|
|
81
|
-
"#{TLS}/enable_mtls" => true
|
|
82
|
-
}
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def inspect
|
|
86
|
-
"#<Asterism::Credentials #{@name} #{@router || '(no router)'}#{@not_after ? " until #{@not_after}" : ''}>"
|
|
87
|
-
end
|
|
88
|
-
alias to_s inspect
|
|
89
|
-
|
|
90
|
-
private
|
|
91
38
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
"(mode #{format('%04o', st.mode & 0o7777)}); chmod 600 #{@path}")
|
|
103
|
-
rescue SystemCallError => e
|
|
104
|
-
raise Error, "#{@path}: #{e.message}"
|
|
105
|
-
end
|
|
39
|
+
# Like ssh with a private key: a file that the group or others may read
|
|
40
|
+
# is told about (it still works).
|
|
41
|
+
def check_mode
|
|
42
|
+
st = File.stat(@path)
|
|
43
|
+
return if Gem.win_platform? || (st.mode & 0o077).zero?
|
|
44
|
+
Kernel.warn("asterism: #{@path} holds a private key and can be read by others " \
|
|
45
|
+
"(mode #{format('%04o', st.mode & 0o7777)}); chmod 600 #{@path}")
|
|
46
|
+
rescue SystemCallError => e
|
|
47
|
+
raise Error, "#{@path}: #{e.message}"
|
|
48
|
+
end
|
|
106
49
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
50
|
+
# {basename => content} of the regular files, at the top or in one
|
|
51
|
+
# directory.
|
|
52
|
+
def read_tar
|
|
53
|
+
data = File.binread(@path)
|
|
54
|
+
raise Error, "#{@path}: too large for a credentials file" if data.bytesize > MAX_SIZE
|
|
55
|
+
files = {}
|
|
56
|
+
Gem::Package::TarReader.new(StringIO.new(data)) do |tar|
|
|
57
|
+
tar.each do |entry|
|
|
58
|
+
next unless entry.file?
|
|
59
|
+
base = File.basename(entry.full_name)
|
|
60
|
+
raise Error, "#{@path}: #{base} is in it twice" if files.key?(base)
|
|
61
|
+
files[base] = entry.read.to_s
|
|
62
|
+
end
|
|
119
63
|
end
|
|
64
|
+
raise Error, "#{@path}: not a tar file with credentials in it" if files.empty?
|
|
65
|
+
files
|
|
66
|
+
rescue SystemCallError => e
|
|
67
|
+
raise Error, "#{@path}: #{e.message}"
|
|
68
|
+
rescue Gem::Package::TarInvalidError, ArgumentError => e
|
|
69
|
+
raise Error, "#{@path}: not a tar file (#{e.message})"
|
|
120
70
|
end
|
|
121
|
-
raise Error, "#{@path}: not a tar file with credentials in it" if files.empty?
|
|
122
|
-
files
|
|
123
|
-
rescue SystemCallError => e
|
|
124
|
-
raise Error, "#{@path}: #{e.message}"
|
|
125
|
-
rescue Gem::Package::TarInvalidError, ArgumentError => e
|
|
126
|
-
raise Error, "#{@path}: not a tar file (#{e.message})"
|
|
127
|
-
end
|
|
128
71
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
72
|
+
def parse_info(text)
|
|
73
|
+
return {} if text.nil?
|
|
74
|
+
h = JSON.parse(text)
|
|
75
|
+
raise Error, "#{@path}: asterism.json is not an object" unless h.is_a?(Hash)
|
|
76
|
+
h
|
|
77
|
+
rescue JSON::ParserError => e
|
|
78
|
+
raise Error, "#{@path}: asterism.json: #{e.message}"
|
|
79
|
+
end
|
|
137
80
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
81
|
+
# The key belongs to the certificate, and the certificate has not
|
|
82
|
+
# expired (a warning: zenoh refuses it anyway). Skipped without openssl.
|
|
83
|
+
def check_pair
|
|
84
|
+
require "openssl"
|
|
85
|
+
cert = OpenSSL::X509::Certificate.new(@cert_pem)
|
|
86
|
+
key = OpenSSL::PKey.read(@key_pem)
|
|
87
|
+
raise Error, "#{@path}: #{@name}.key is not the key of #{@name}.pem" unless cert.check_private_key(key)
|
|
88
|
+
Kernel.warn("asterism: #{@path}: the certificate expired at #{cert.not_after.utc}") if cert.not_after < Time.now
|
|
89
|
+
rescue LoadError
|
|
90
|
+
nil
|
|
91
|
+
rescue OpenSSL::OpenSSLError => e
|
|
92
|
+
raise Error, "#{@path}: #{e.message}"
|
|
93
|
+
end
|
|
150
94
|
end
|
|
95
|
+
prepend CRuby
|
|
151
96
|
end
|
|
152
97
|
end
|