asterism 0.7.1 → 0.9.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.
@@ -0,0 +1,197 @@
1
+ # The credentials of one node or router as one file: the .tar an Asterism
2
+ # console hands out when it issues a certificate ("Save all (.tar)" on the
3
+ # certificate page; the archive is made in the browser that made the key).
4
+ # It holds, at its top or in one directory:
5
+ #
6
+ # <name>.key the private key (PKCS#8 PEM)
7
+ # <name>.pem the certificate (its common name is <name>)
8
+ # ca.pem the CA that signed it (and the router's certificate)
9
+ # zenoh.json5 an example configuration (for zenohd or another client)
10
+ # asterism.json {"name": ..., "router": "tls/host:port", "not_after": ..., ...}
11
+ #
12
+ # Asterism.connect(credentials: "/home/asterism/alice.tar", app: "game")
13
+ #
14
+ # The certificates go to zenoh as base64 (transport/link/tls/*_base64) with
15
+ # enable_mtls, and the router's locator and the node ID come from
16
+ # asterism.json unless given. This is the portable part, in plain Ruby for
17
+ # the boards (mruby / PicoRuby: no Regexp, no pack, no File.binread): the
18
+ # file is read whole and the tar taken apart with byteslice / getbyte.
19
+ # CRuby replaces the reading and adds checks (lib/asterism/cruby/credentials.rb:
20
+ # the standard library's tar reader, file modes, the key pair).
21
+ module Asterism
22
+ class Credentials
23
+ # What a credentials file is missing or holds wrong.
24
+ class Error < ::Asterism::Error; end
25
+
26
+ TLS = "transport/link/tls"
27
+ # The largest file read (the real ones are a few KB).
28
+ MAX_SIZE = 1024 * 1024
29
+ BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
30
+
31
+ attr_reader :path, :name, :kind, :router, :not_after, :key_pem, :cert_pem, :ca_pem, :info
32
+
33
+ # Reads path (a String of a .tar). warn: false skips the check of who
34
+ # may read the file (CRuby; the boards have no file modes).
35
+ def self.load(path, warn: true)
36
+ new(path, warn: warn)
37
+ end
38
+
39
+ # A credentials file (a path or a Credentials) and the arguments of a
40
+ # connection -> [locator, config Hash, credentials]. The locator given
41
+ # wins over asterism.json's router; config given (a Hash) is laid over
42
+ # the TLS keys. @api private
43
+ def self.merge(credentials, locator, config)
44
+ creds = credentials.is_a?(Credentials) ? credentials : load(credentials)
45
+ if config.is_a?(String)
46
+ raise ArgumentError, "credentials: and a config String cannot be combined; pass config: as a Hash"
47
+ end
48
+ merged = creds.zenoh_config
49
+ (config || {}).each { |k, v| merged[k] = v }
50
+ [locator || creds.router, merged, creds]
51
+ end
52
+
53
+ # {basename => content} of the regular files of a ustar archive (data,
54
+ # a String of its bytes), at its top or in one directory. @api private
55
+ def self.untar(data, path = "the file")
56
+ files = {}
57
+ pos = 0
58
+ total = data.bytesize
59
+ while pos + 512 <= total
60
+ break if data.getbyte(pos) == 0 # the end: blocks of zeros
61
+ name = field(data, pos, 100)
62
+ prefix = field(data, pos + 345, 155)
63
+ size_text = field(data, pos + 124, 12).strip
64
+ type = data.getbyte(pos + 156)
65
+ size = size_text.empty? ? 0 : size_text.to_i(8)
66
+ pos += 512
67
+ raise Error, "#{path}: not a tar file (a member runs past the end)" if pos + size > total
68
+ if type == 48 || type == 0 # '0' or NUL: a regular file
69
+ full = prefix.empty? ? name : "#{prefix}/#{name}"
70
+ base = full.split("/").last.to_s
71
+ raise Error, "#{path}: #{base} is in it twice" if files.key?(base)
72
+ files[base] = data.byteslice(pos, size) unless base.empty?
73
+ end
74
+ pos += (size + 511) / 512 * 512
75
+ end
76
+ raise Error, "#{path}: not a tar file with credentials in it" if files.empty?
77
+ files
78
+ end
79
+
80
+ # The text of a NUL-terminated header field. @api private
81
+ def self.field(data, pos, len)
82
+ n = 0
83
+ n += 1 while n < len && data.getbyte(pos + n) != 0
84
+ data.byteslice(pos, n).to_s
85
+ end
86
+
87
+ # base64 of s without line breaks (CRuby's [s].pack("m0")). @api private
88
+ def self.base64(s)
89
+ out = ""
90
+ n = s.bytesize
91
+ i = 0
92
+ while i < n
93
+ b0 = s.getbyte(i)
94
+ b1 = i + 1 < n ? s.getbyte(i + 1) : 0
95
+ b2 = i + 2 < n ? s.getbyte(i + 2) : 0
96
+ v = (b0 << 16) | (b1 << 8) | b2
97
+ out << BASE64[(v >> 18) & 63] << BASE64[(v >> 12) & 63]
98
+ out << (i + 1 < n ? BASE64[(v >> 6) & 63] : "=")
99
+ out << (i + 2 < n ? BASE64[v & 63] : "=")
100
+ i += 3
101
+ end
102
+ out
103
+ end
104
+
105
+ # The string fields of asterism.json without a JSON library (only for
106
+ # a build that has none). @api private
107
+ def self.json_strings(text)
108
+ h = {}
109
+ %w[format name kind router serial not_after fingerprint console issued_at].each do |k|
110
+ key = "\"#{k}\""
111
+ i = text.index(key)
112
+ next unless i
113
+ rest = text[i + key.size, text.size - i - key.size].to_s
114
+ colon = rest.index(":")
115
+ next unless colon
116
+ q1 = colon + 1
117
+ q1 += 1 while rest[q1] == " "
118
+ next unless rest[q1] == "\""
119
+ q2 = rest.index("\"", q1 + 1)
120
+ next unless q2
121
+ h[k] = rest[q1 + 1, q2 - q1 - 1]
122
+ end
123
+ h
124
+ end
125
+
126
+ def initialize(path, warn: true)
127
+ @path = path.to_s
128
+ check_mode if warn
129
+ files = read_tar
130
+ @info = parse_info(files["asterism.json"])
131
+ keys = files.keys.select { |f| f.end_with?(".key") }
132
+ raise Error, "#{@path}: no private key (<name>.key) in it" if keys.empty?
133
+ raise Error, "#{@path}: more than one private key in it (#{keys.sort.join(', ')})" if keys.size > 1
134
+ @name = @info["name"].to_s
135
+ @name = keys.first[0, keys.first.size - 4] if @name.empty?
136
+ @key_pem = files["#{@name}.key"] or raise Error, "#{@path}: the key is not named after #{@name} (#{keys.first})"
137
+ @cert_pem = files["#{@name}.pem"] or raise Error, "#{@path}: no certificate #{@name}.pem in it"
138
+ @ca_pem = files["ca.pem"] or raise Error, "#{@path}: no ca.pem in it"
139
+ @kind = @info["kind"]
140
+ @router = @info["router"].to_s.empty? ? nil : @info["router"].to_s
141
+ @not_after = @info["not_after"]
142
+ check_pair
143
+ end
144
+
145
+ # The zenoh configuration keys (a Hash for Session.open's config:):
146
+ # the CA, the certificate and the key as base64, and mutual TLS.
147
+ def zenoh_config
148
+ {
149
+ "#{TLS}/root_ca_certificate_base64" => b64(@ca_pem),
150
+ "#{TLS}/connect_certificate_base64" => b64(@cert_pem),
151
+ "#{TLS}/connect_private_key_base64" => b64(@key_pem),
152
+ "#{TLS}/enable_mtls" => true
153
+ }
154
+ end
155
+
156
+ def inspect
157
+ "#<Asterism::Credentials #{@name} #{@router || '(no router)'}#{@not_after ? " until #{@not_after}" : ''}>"
158
+ end
159
+ alias to_s inspect
160
+
161
+ private
162
+
163
+ def b64(pem)
164
+ Credentials.base64(pem)
165
+ end
166
+
167
+ # The boards have no file modes (CRuby warns like ssh).
168
+ def check_mode; end
169
+
170
+ # The key pair is checked by CRuby (openssl); on the boards the router
171
+ # refuses a wrong one.
172
+ def check_pair; end
173
+
174
+ def read_tar
175
+ data = begin
176
+ ::File.open(@path, "r") { |f| f.read }
177
+ rescue StandardError => e
178
+ raise Error, "#{@path}: #{e.message}"
179
+ end
180
+ raise Error, "#{@path}: cannot be read" if data.nil?
181
+ raise Error, "#{@path}: too large for a credentials file" if data.bytesize > MAX_SIZE
182
+ Credentials.untar(data, @path)
183
+ end
184
+
185
+ def parse_info(text)
186
+ return {} if text.nil?
187
+ return Credentials.json_strings(text) unless Object.const_defined?(:JSON)
188
+ h = ::JSON.parse(text)
189
+ raise Error, "#{@path}: asterism.json is not an object" unless h.is_a?(Hash)
190
+ h
191
+ rescue Error
192
+ raise
193
+ rescue StandardError => e
194
+ raise Error, "#{@path}: asterism.json: #{e.message}"
195
+ end
196
+ end
197
+ end
data/mrblib/status.rb ADDED
@@ -0,0 +1,198 @@
1
+ # An application's status: a small Hash of its own that changes while it
2
+ # runs (how many tables are open, who is seated, a battery level), told by
3
+ # the application itself.
4
+ #
5
+ # Asterism.status(tables: 3, humans: 2) # sets those fields, keeps the others
6
+ # Asterism.status({ "mahjong1" => {...} }) # the whole status (a Hash)
7
+ # Asterism.status # => {"tables" => 3, ...} or nil
8
+ # Asterism.status_of("games", "games") # another application's, or nil
9
+ #
10
+ # Key: asterism/<node>/<app>/@status (docs/protocol.md 8). The application
11
+ # answers queries there with its status, and puts it on the same key when
12
+ # it changes (only then: the same status again puts nothing), at connect
13
+ # and after a lost connection comes back. An application that never set a
14
+ # status declares nothing there and answers nothing.
15
+ #
16
+ # The value is a MessagePack map with str keys; the values are any values
17
+ # of the protocol nested at most MAX_DEPTH levels below the map, and the
18
+ # whole map packs into MAX_BYTES at most. It is not the profile
19
+ # (profile.rb): a profile says once what a node is; a status changes.
20
+ module Asterism
21
+ module Status
22
+ KEY = "@status"
23
+ MAX_BYTES = 1024 # the packed map
24
+ MAX_DEPTH = 4 # levels of arrays and maps below the map
25
+
26
+ # A received status, cleaned: the entries with a non-empty str key; nil
27
+ # when it is not a map or packs into more than MAX_BYTES.
28
+ def self.check(h)
29
+ return nil unless h.is_a?(Hash)
30
+ out = {}
31
+ h.each { |k, v| out[k] = v if k.is_a?(String) && !k.empty? }
32
+ begin
33
+ return nil if ::MessagePack.pack(out).bytesize > MAX_BYTES
34
+ rescue
35
+ return nil
36
+ end
37
+ out
38
+ end
39
+
40
+ # [node, app, status] of a notice put on asterism/<node>/<app>/@status
41
+ # (key and payload as a subscriber gets them), or nil when it is not one.
42
+ def self.parse(key, payload)
43
+ parts = key.to_s.split("/")
44
+ return nil unless parts.size == 4 && parts[0] == ROOT && parts[3] == KEY
45
+ st = check(Codec.unpack(payload))
46
+ st ? [parts[1], parts[2], st] : nil
47
+ end
48
+
49
+ # The checks of Asterism.status: a copy with String keys, or
50
+ # ArgumentError with the reason.
51
+ # @api private
52
+ def self.check!(h)
53
+ raise ArgumentError, "status: expected a Hash, got #{h.class}" unless h.is_a?(Hash)
54
+ out = {}
55
+ h.each do |k, v|
56
+ key = k.is_a?(Symbol) ? k.to_s : k
57
+ unless key.is_a?(String) && !key.empty?
58
+ raise ArgumentError, "status: the keys are Strings or Symbols (not empty), got #{k.inspect}"
59
+ end
60
+ raise ArgumentError, "status #{key}: nested deeper than #{MAX_DEPTH} levels" if too_deep?(v, 1)
61
+ begin
62
+ out[key] = Codec.check(v)
63
+ rescue EncodeError => e
64
+ raise ArgumentError, "status #{key}: #{e.message}"
65
+ end
66
+ end
67
+ n = Codec.pack(out).bytesize
68
+ raise ArgumentError, "status: #{n} bytes packed (#{MAX_BYTES} at most)" if n > MAX_BYTES
69
+ out
70
+ end
71
+
72
+ # @api private
73
+ def self.too_deep?(v, level)
74
+ if v.is_a?(Array) || v.is_a?(Hash)
75
+ return true if level > MAX_DEPTH
76
+ if v.is_a?(Array)
77
+ v.each { |e| return true if too_deep?(e, level + 1) }
78
+ else
79
+ v.each { |k, e| return true if too_deep?(k, level + 1) || too_deep?(e, level + 1) }
80
+ end
81
+ end
82
+ false
83
+ end
84
+ end
85
+
86
+ # This application's status. With a Hash, it is the whole status; with
87
+ # keywords, those fields change (nil removes one) and the others stay;
88
+ # both: the keywords on top of the Hash. Returns the status. Without
89
+ # arguments, returns it (nil when none was set). Raises ArgumentError for
90
+ # a status it does not accept (see Asterism::Status).
91
+ #
92
+ # Kept across connect and close (it belongs to the process, or the VM on
93
+ # a board). While connected, a change is put on asterism/<node>/<app>/@status;
94
+ # setting the same status again puts nothing.
95
+ def self.status(h = nil, **fields)
96
+ if h.nil? && fields.empty?
97
+ return @status ? @status.dup : nil
98
+ end
99
+ base = h.nil? ? (@status ? @status.dup : {}) : Status.check!(h)
100
+ fields.each do |k, v|
101
+ if v.nil?
102
+ base.delete(k.to_s)
103
+ else
104
+ base[k.to_s] = v
105
+ end
106
+ end
107
+ @status = Status.check!(base)
108
+ tell_status(false)
109
+ @status.dup
110
+ end
111
+
112
+ # Another application's status (Asterism::Status.check has cleaned it),
113
+ # or nil when it does not answer in time (no status set, an older
114
+ # version of Asterism, an application that is gone). timeout: in seconds
115
+ # (or timeout_ms:), 2.0 by default. Waits like a call (polling, answering
116
+ # calls meanwhile); raises Disconnected when not connected. This
117
+ # application's own status comes without the network.
118
+ def self.status_of(node, app, timeout: nil, timeout_ms: nil)
119
+ f = status_async(node, app, timeout, timeout_ms)
120
+ begin
121
+ raw = f.raw_value
122
+ rescue Disconnected
123
+ raise
124
+ rescue Error
125
+ return nil # no answer in time, or not MessagePack
126
+ end
127
+ Status.check(raw)
128
+ end
129
+
130
+ # @api private
131
+ def self.status_async(node, app, timeout, timeout_ms)
132
+ node = check_name("node", node)
133
+ app = check_name("app", app)
134
+ ms = time_ms("Asterism.status_of", timeout, timeout_ms, nil, DEFAULT_TIMEOUT_MS)
135
+ path = "#{node}/#{app}/#{Status::KEY}"
136
+ if @session && node == @node && app == @app
137
+ return Future.answered(path, "status", Codec.pack(@status)) if @status
138
+ return Future.answered(path, "status", Codec.pack(nil))
139
+ end
140
+ begin
141
+ g = session!.get("#{ROOT}/#{path}", timeout_ms: ms)
142
+ rescue Asterism::Zenoh::Error => e
143
+ lost_if_closed(e.message)
144
+ raise Disconnected, e.message
145
+ end
146
+ Future.new(path, "status", g, now_ms + ms, ms)
147
+ end
148
+
149
+ # The applications alive now ("<node>/<app>", from their liveliness
150
+ # tokens; this one included) whose path matches the pattern (* is one
151
+ # chunk, ** any number).
152
+ def self.apps(pattern = "*/*")
153
+ list = []
154
+ return list unless @session
155
+ own = "#{@node}/#{@app}"
156
+ list << own if glob?(pattern, own)
157
+ @apps.each_key { |path| list << path if path != own && glob?(pattern, path) }
158
+ list
159
+ end
160
+
161
+ # Puts the status on its key while connected: when it changed since the
162
+ # last put of this connection, or always with force (a connection that
163
+ # came back). Declares the status queryable with the first put.
164
+ # @api private
165
+ def self.tell_status(force)
166
+ s = @session
167
+ return unless s && @status
168
+ bytes = Codec.pack(@status)
169
+ return if !force && !@status_put.nil? && @status_put == bytes
170
+ key = "#{ROOT}/#{@node}/#{@app}/#{Status::KEY}"
171
+ @status_queryable ||= s.queryable(key, depth: 4)
172
+ s.put(key, bytes)
173
+ @status_put = bytes
174
+ rescue Asterism::Zenoh::Error
175
+ nil # the next poll notices a lost connection
176
+ end
177
+
178
+ # @api private
179
+ def self.answer_status
180
+ qa = @status_queryable
181
+ return unless qa && qa.pending > 0
182
+ queries = qa.each_pending
183
+ i = 0
184
+ while i < queries.size
185
+ q = queries[i]
186
+ i += 1
187
+ begin
188
+ q.reply("#{ROOT}/#{@node}/#{@app}/#{Status::KEY}", Codec.pack(@status || {}))
189
+ ensure
190
+ begin
191
+ q.finish
192
+ rescue Asterism::Zenoh::Error
193
+ # the session went away meanwhile
194
+ end
195
+ end
196
+ end
197
+ end
198
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: asterism
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.1
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Katsuhiko Kageyama
8
8
  autorequire:
9
- bindir: bin
9
+ bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-13 00:00:00.000000000 Z
11
+ date: 2026-10-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: asterism-zenoh
@@ -48,7 +48,8 @@ description: Proxies for objects exposed by other Ruby processes and boards, a R
48
48
  2 node speaking rmw_zenoh's wire format, CDR and generated message types. Pure Ruby;
49
49
  the same files are the mrbgem for mruby / PicoRuby.
50
50
  email:
51
- executables: []
51
+ executables:
52
+ - asterism
52
53
  extensions: []
53
54
  extra_rdoc_files: []
54
55
  files:
@@ -144,7 +145,17 @@ files:
144
145
  - data/msgs/visualization_msgs/msg/MarkerArray.rb
145
146
  - data/msgs/visualization_msgs/msg/MeshFile.rb
146
147
  - data/msgs/visualization_msgs/msg/UVCoordinate.rb
148
+ - exe/asterism
147
149
  - lib/asterism.rb
150
+ - lib/asterism/cli.rb
151
+ - lib/asterism/cli/allow.rb
152
+ - lib/asterism/cli/complete.rb
153
+ - lib/asterism/cli/hz.rb
154
+ - lib/asterism/cli/literal.rb
155
+ - lib/asterism/cli/mcp.rb
156
+ - lib/asterism/cli/nouns.rb
157
+ - lib/asterism/cli/survey.rb
158
+ - lib/asterism/cli/text.rb
148
159
  - lib/asterism/cruby.rb
149
160
  - lib/asterism/cruby/credentials.rb
150
161
  - lib/asterism/cruby/objects.rb
@@ -154,10 +165,12 @@ files:
154
165
  - lib/asterism/version.rb
155
166
  - mrblib/asterism.rb
156
167
  - mrblib/cdr.rb
168
+ - mrblib/credentials.rb
157
169
  - mrblib/future.rb
158
170
  - mrblib/profile.rb
159
171
  - mrblib/proxy.rb
160
172
  - mrblib/ros.rb
173
+ - mrblib/status.rb
161
174
  - tools/asterism_msggen.rb
162
175
  homepage: https://github.com/ruby-asterism/asterism
163
176
  licenses: