samagotchi 0.109.0 → 0.110.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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -1
  3. data/bin/chi +5 -1
  4. data/docs/architecture.md +1 -1
  5. data/docs/cli.md +40 -1
  6. data/docs/configuration.md +97 -3
  7. data/docs/internals/engine.md +19 -4
  8. data/docs/internals/participants.md +6 -3
  9. data/docs/internals/prompt-caching.md +5 -0
  10. data/docs/plugins.md +1 -1
  11. data/docs/sessions.md +3 -1
  12. data/docs/sub-agent.md +1 -1
  13. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
  14. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  15. data/lib/samagotchi/context_note.rb +7 -3
  16. data/lib/samagotchi/engine.rb +20 -9
  17. data/lib/samagotchi/idle_recap.rb +1 -1
  18. data/lib/samagotchi/idle_reminders.rb +1 -1
  19. data/lib/samagotchi/kernel_loop.rb +12 -7
  20. data/lib/samagotchi/llm/chat_loop.rb +9 -2
  21. data/lib/samagotchi/message_time.rb +104 -0
  22. data/lib/samagotchi/reminder_queue.rb +20 -3
  23. data/lib/samagotchi/reply_wait/poll.rb +39 -1
  24. data/lib/samagotchi/run_options.rb +4 -1
  25. data/lib/samagotchi/session_access/contract.rb +1 -1
  26. data/lib/samagotchi/session_access/local.rb +11 -5
  27. data/lib/samagotchi/session_inbox.rb +7 -3
  28. data/lib/samagotchi/steer.rb +5 -3
  29. data/lib/samagotchi/terminal_ui/attached_loop.rb +70 -9
  30. data/lib/samagotchi/terminal_ui/between_turns.rb +8 -3
  31. data/lib/samagotchi/terminal_ui/formatting.rb +13 -5
  32. data/lib/samagotchi/terminal_ui/gap_clock.rb +78 -0
  33. data/lib/samagotchi/terminal_ui/reline_seam.rb +20 -2
  34. data/lib/samagotchi/terminal_ui.rb +40 -5
  35. data/lib/samagotchi/tool_activity.rb +11 -0
  36. data/lib/samagotchi/turn_checkpoint.rb +6 -1
  37. data/lib/samagotchi/version.rb +1 -1
  38. data/lib/samagotchi/web/app.rb +15 -6
  39. data/lib/samagotchi/web/participant_view.rb +12 -5
  40. data/lib/samagotchi/web/public/app.js +11 -3
  41. data/lib/samagotchi/web/public/bubbles.js +29 -7
  42. data/lib/samagotchi/web/public/format.js +72 -6
  43. data/lib/samagotchi/web/public/index.html +2 -0
  44. data/lib/samagotchi/web/public/participant.html +2 -0
  45. data/lib/samagotchi/web/public/participant.js +30 -7
  46. data/lib/samagotchi/web/public/participant_model.js +25 -2
  47. data/lib/samagotchi/web/public/stream_handlers.js +32 -4
  48. data/lib/samagotchi/web/public/time_gaps.js +98 -0
  49. data/lib/samagotchi/web/public/tokens.css +2 -2
  50. data/lib/samagotchi/web/public/turn_events.js +3 -1
  51. data/lib/samagotchi/web/public/turn_html.js +15 -8
  52. data/lib/samagotchi/web/public/turn_view.js +8 -3
  53. data/lib/samagotchi/web/running.rb +39 -4
  54. data/lib/samagotchi/web/server.rb +87 -31
  55. data/lib/samagotchi/web/tunnel/tailscale.rb +325 -0
  56. data/lib/samagotchi/web/tunnel.rb +133 -0
  57. metadata +6 -1
@@ -16,6 +16,7 @@ require_relative "qr"
16
16
  require_relative "running"
17
17
  require_relative "session_hub"
18
18
  require_relative "token"
19
+ require_relative "tunnel"
19
20
  require_relative "../config"
20
21
  require_relative "../project_scope"
21
22
  require_relative "../log"
@@ -71,19 +72,24 @@ module Samagotchi
71
72
  # @param scope ["project", "all"] "all": the plain page, every session
72
73
  # @param new_token [Boolean] replace the LAN access token first
73
74
  # (--new-token): a running server takes the new one at once
75
+ # @param tunnel [String, nil] --tunnel's provider (Tunnel.for): this
76
+ # run's public_url is the tunnel's, never config's, and the tunnel is
77
+ # on while the server runs
74
78
  # @return [Integer] the exit status
75
79
  def self.launch(port: nil, host: nil, scope: "project", dir: Dir.pwd, open_browser: false, markdown: false,
76
80
  view: Config::BY_KEY["web.view"].default, annotate_presets: Config::BY_KEY["web.annotate_presets"].default,
77
81
  editor: Config::BY_KEY["web.editor"].default, new_token: false,
78
- working_set: Config::BY_KEY["web.working_set"].default)
82
+ working_set: Config::BY_KEY["web.working_set"].default, tunnel: nil)
79
83
  rotate_token if new_token
80
- public_url = public_url_setting
84
+ public_url = public_url_setting unless tunnel
81
85
  setting = host_setting(host)
82
86
  lan = Lan.wanted?(setting) ? Lan.choose(setting) : nil
83
87
  host = resolve_host(setting)
84
88
  port = resolve_port(port)
85
89
  url = scope_url(host, port, dir: dir, scope: scope)
86
90
  found = probe(host, port)
91
+ raise Tunnel::AlreadyRunning, port if tunnel && found.is_a?(Hash)
92
+
87
93
  case found
88
94
  when Hash
89
95
  if lan && !found["lan"]
@@ -93,7 +99,8 @@ module Samagotchi
93
99
  end
94
100
  puts "chi web already runs on port #{port} (pid #{found["pid"]}): #{url}"
95
101
  puts running_lan_lines(found["lan"], port) if found["lan"]
96
- puts running_public_lines(public_url) if public_url
102
+ running_public = public_url || Running.live_public_url(probe: ->(at) { probe(host, at) })
103
+ puts running_public_lines(running_public) if running_public
97
104
  puts lan_off_line(setting) if new_token && !found["lan"]
98
105
  open_url(url) if open_browser
99
106
  0
@@ -101,21 +108,30 @@ module Samagotchi
101
108
  warn in_use_message(port)
102
109
  1
103
110
  else
111
+ if tunnel
112
+ provider = Tunnel.for(tunnel)
113
+ plan = provider.prepare(port: port, config_owner: Samagotchi::Config.get("web.proxy.owner"),
114
+ config_public_url: Samagotchi::Config.get("web.public_url"),
115
+ config_identity_header: Samagotchi::Config.get("web.proxy.identity_header"))
116
+ public_url = plan.public_url
117
+ end
104
118
  if new_token && !lan && !public_url
105
119
  puts lan_off_line(setting)
106
120
  return 0
107
121
  end
108
122
  # The participants' grants: never served from a store others can read.
109
123
  Grants.check_permissions!(Grants.path)
110
- if start(port: port, host: host, url: url, open_browser: open_browser, markdown: markdown, view: view,
111
- annotate_presets: annotate_presets, editor: editor, lan: lan, working_set: working_set,
112
- public_url: public_url)
113
- 0
114
- else
115
- 1
116
- end
124
+ options = { port: port, host: host, url: url, open_browser: open_browser, markdown: markdown, view: view,
125
+ annotate_presets: annotate_presets, editor: editor, lan: lan, working_set: working_set,
126
+ public_url: public_url }
127
+ started = if plan
128
+ with_tunnel(provider, plan) { start(**options, public_notes: provider.start_lines(plan)) }
129
+ else
130
+ start(**options)
131
+ end
132
+ started ? 0 : 1
117
133
  end
118
- rescue Lan::Error, Grants::Insecure, PublicUrl::Invalid => e
134
+ rescue Lan::Error, Grants::Insecure, PublicUrl::Invalid, Tunnel::Refused => e
119
135
  warn "Error: #{e.message}"
120
136
  1
121
137
  rescue Interrupt
@@ -125,18 +141,52 @@ module Samagotchi
125
141
  CLI::Exit::INTERRUPTED
126
142
  end
127
143
 
144
+ # The tunnel on while the block runs (the server), off after it
145
+ # whatever ends it (Ctrl-C, SIGTERM, SIGHUP, a port taken), and only
146
+ # once #up succeeded: a refusal before it, or a failed #up, turns off
147
+ # nothing (a live tunnel of another chi web stays).
148
+ def self.with_tunnel(provider, plan)
149
+ provider.up(plan)
150
+ upped = true
151
+ yield
152
+ ensure
153
+ tunnel_down(provider, plan) if upped
154
+ end
155
+
156
+ # The tunnel's teardown, before any line (a hung-up terminal can't
157
+ # write): a failure is one warning line, never a crash or another exit
158
+ # status, and a second Ctrl-C during it only cuts it short.
159
+ def self.tunnel_down(provider, plan)
160
+ line = begin
161
+ provider.down_line(plan, provider.down(plan))
162
+ rescue Tunnel::Refused, SignalException => e
163
+ provider.down_warning(plan, e)
164
+ end
165
+ say(line) if line
166
+ end
167
+
168
+ # A line that may meet a terminal gone (SIGHUP): never an error.
169
+ def self.say(line)
170
+ puts line
171
+ $stdout.flush
172
+ rescue IOError, SystemCallError
173
+ nil
174
+ end
175
+
128
176
  # @param hub [SessionHub, nil] the session projection the page streams
129
177
  # from; built over the app's state dir unless given
130
178
  # @param lan [Lan::Choice, nil] also listen on this LAN address, where
131
179
  # every request but this machine's needs the access token
132
180
  # @param token_path [String] the LAN access token's file
133
181
  # @param public_url [PublicUrl, nil] behind an auth proxy
134
- # (web.public_url): every request needs the access token
182
+ # (web.public_url, or --tunnel's): every request needs the access token
183
+ # @param public_notes [Array<String>] lines after the proxied link's
184
+ # (the tunnel's)
135
185
  # @return [Boolean] false when the port was taken (said so on stderr)
136
186
  def self.start(port: nil, host: nil, url: nil, open_browser: false, state_dir: nil, manager: nil, markdown: false,
137
187
  view: Config::BY_KEY["web.view"].default, annotate_presets: Config::BY_KEY["web.annotate_presets"].default, hub: nil, lan: nil,
138
188
  editor: Config::BY_KEY["web.editor"].default, token_path: Token.path,
139
- working_set: Config::BY_KEY["web.working_set"].default, public_url: nil)
189
+ working_set: Config::BY_KEY["web.working_set"].default, public_url: nil, public_notes: [])
140
190
  config_restart_unsubscribe = ConfigFile.watch_restart_settings!(process: :web) do |message|
141
191
  Samagotchi::Log.warn(:config, "restart_required", echo: "Warning: config: #{message}")
142
192
  end
@@ -164,10 +214,12 @@ module Samagotchi
164
214
  # the in-use line instead.
165
215
  running_path = Running.path(state_dir: state_dir)
166
216
  started = lambda do
167
- record_running(running_path, port)
217
+ record_running(running_path, port, public_url)
168
218
  puts "Chi Web on #{url} (public: #{File.expand_path("public", __dir__)})"
169
219
  puts lan_lines(ip: lan.ip, port: port, token: Token.read(token_path), others: lan.others, public: lan.public) if lan
170
- puts public_lines(public_url, token: Token.read(token_path), codes: owner_codes(state_dir)) if public_url
220
+ if public_url
221
+ puts public_lines(public_url, token: Token.read(token_path), codes: owner_codes(state_dir), notes: public_notes)
222
+ end
171
223
  puts "Press Ctrl-C to stop."
172
224
  $stdout.flush # a log file isn't line-buffered
173
225
  if open_browser
@@ -203,11 +255,11 @@ module Samagotchi
203
255
 
204
256
  class LanListenError < StandardError; end
205
257
 
206
- # Where this chi web listens, for chi share's link (Web::Running); a
207
- # write that fails leaves the server running and the readers on
208
- # web.port.
209
- def self.record_running(path, port)
210
- Running.record(port: port, path: path)
258
+ # Where this chi web listens, and its public_url (config's or the
259
+ # tunnel's), for chi share's link (Web::Running); a write that fails
260
+ # leaves the server running and the readers on web.port and config.
261
+ def self.record_running(path, port, public_url = nil)
262
+ Running.record(port: port, path: path, public_url: public_url&.origin)
211
263
  rescue SystemCallError => e
212
264
  Samagotchi::Log.warn(:web, "running_record_failed", error: e.class.name)
213
265
  end
@@ -251,32 +303,36 @@ module Samagotchi
251
303
  owner: Samagotchi::Config.get("web.proxy.owner"))
252
304
  end
253
305
 
254
- # What web.public_url adds to the start lines: the proxied link (the
255
- # token in its fragment) and who the owner is. On this machine the
306
+ # What web.public_url adds to the start lines: the proxied link (a
307
+ # one-time code in its fragment), who the owner is, +notes+ (the
308
+ # tunnel's) and, at a terminal, the link's QR code. On this machine the
256
309
  # page opens through the proxy too; a direct request takes the token
257
310
  # as Authorization: Bearer only (no cookie for 127.0.0.1, which every
258
311
  # local port would get).
259
- def self.public_lines(public_url, token:, codes: owner_codes)
312
+ def self.public_lines(public_url, token:, codes: owner_codes, notes: [], qr: $stdout.tty?)
260
313
  unless token
261
- return ["Proxied: #{public_url.origin}/ (no access token: its file is gone; chi web --new-token makes one)"]
314
+ return ["Proxied: #{public_url.origin}/ (no access token: its file is gone; chi web --new-token makes one)",
315
+ *notes]
262
316
  end
263
317
 
264
318
  who = if public_url.identity_header.nil? then "token only, no identity header"
265
319
  elsif public_url.owner then "owner #{public_url.owner} by #{public_url.identity_header}, and the token"
266
320
  else "nobody is the owner by identity: web.proxy.owner is unset"
267
321
  end
268
- code = codes.issue(kind: :owner, ttl: LinkCodes::OWNER_TTL)
269
- ["Proxied: #{public_url.link(code)} ← opens chi web as you once, within #{LinkCodes::OWNER_TTL / 60} minutes " \
270
- "(chi web again prints a new one)",
271
- " (#{who}; on this machine the page opens through the proxy too)"]
322
+ link = public_url.link(codes.issue(kind: :owner, ttl: LinkCodes::OWNER_TTL))
323
+ lines = ["Proxied: #{link} ← opens chi web as you once, within #{LinkCodes::OWNER_TTL / 60} minutes " \
324
+ "(chi web again prints a new one)",
325
+ " (#{who}; on this machine the page opens through the proxy too)", *notes]
326
+ lines.concat(QR.lines(link)) if qr
327
+ lines
272
328
  end
273
329
 
274
330
  # The owner's one-time link codes, next to the grants.
275
331
  def self.owner_codes(state_dir = nil) = LinkCodes.for_grants(Grants.path(state_dir: state_dir))
276
332
 
277
- # A second chi web, when this machine's config has web.public_url: the
278
- # link from this config and the token file, never from what answered
279
- # on the port.
333
+ # A second chi web, when this machine's config has web.public_url (or
334
+ # the running chi web recorded one: Running.live_public_url): the link
335
+ # from there and the token file, never from what answered on the port.
280
336
  def self.running_public_lines(public_url, token_path: Token.path, codes: owner_codes)
281
337
  return ["Proxied: #{public_url.origin}/"] unless Token.read(token_path)
282
338
 
@@ -0,0 +1,325 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../tunnel"
5
+ require_relative "../public_url"
6
+ require_relative "../../session"
7
+
8
+ module Samagotchi
9
+ module Web
10
+ module Tunnel
11
+ # `chi web --tunnel tailscale`: Tailscale Serve on this machine's
12
+ # tailnet name, port 443 → chi web's 127.0.0.1 port, never Funnel.
13
+ # The identity is the Tailscale login Serve sends (Tailscale-User-Login);
14
+ # the owner is web.proxy.owner, else this machine's own login.
15
+ #
16
+ # Serve's other ports are never touched, and on 443 only exactly what
17
+ # this provider sets (one handler / → http://127.0.0.1:<port>, no
18
+ # Funnel) is adopted or turned off: anything else there is refused at
19
+ # start and left alone at exit.
20
+ class Tailscale
21
+ # The CLI to run, before PATH: every spec that runs it sets this to a
22
+ # fake.
23
+ ENV_CLI = "SAMAGOTCHI_TAILSCALE_CLI"
24
+ # The macOS app's own CLI, when there is no tailscale on PATH (never
25
+ # in a test run).
26
+ APP_CLI = "/Applications/Tailscale.app/Contents/MacOS/Tailscale"
27
+ IDENTITY_HEADER = "Tailscale-User-Login"
28
+ STATUS_TIMEOUT = 5
29
+ SERVE_TIMEOUT = 15
30
+ LOCK_DOCS = "https://tailscale.com/kb/1226/tailnet-lock"
31
+
32
+ # No tailscale to run.
33
+ class CliMissing < Refused
34
+ def initialize
35
+ super("tailscale isn't installed: no tailscale on PATH or at #{APP_CLI} (install it from tailscale.com, " \
36
+ "or set #{ENV_CLI} to its path)")
37
+ end
38
+ end
39
+
40
+ # SAMAGOTCHI_TAILSCALE_CLI names something that can't run.
41
+ class CliOverrideMissing < Refused
42
+ def initialize(path)
43
+ super("#{ENV_CLI} is #{path}, which isn't an executable file")
44
+ end
45
+ end
46
+
47
+ # Tailscale is installed but not connected (BackendState).
48
+ class NotRunning < Refused
49
+ def initialize(state)
50
+ super("Tailscale isn't connected (#{state}): open the Tailscale app and sign in, then run this again")
51
+ end
52
+ end
53
+
54
+ # No MagicDNS name for this machine.
55
+ class NoDnsName < Refused
56
+ def initialize
57
+ super("this machine has no tailnet name: turn on MagicDNS in the Tailscale admin console (DNS)")
58
+ end
59
+ end
60
+
61
+ # HTTPS certificates are off (no CertDomains for the name).
62
+ class NoHttps < Refused
63
+ def initialize(name)
64
+ super("HTTPS certificates are off for this tailnet (none for #{name}): turn them on in the Tailscale admin " \
65
+ "console (DNS); rename this machine first, certificate names go into a public log")
66
+ end
67
+ end
68
+
69
+ # A tagged machine's UserID isn't a person: no owner by default.
70
+ class TaggedOwner < Refused
71
+ def initialize(tags)
72
+ super("this machine is tagged (#{tags.join(", ")}), so its Tailscale login isn't a person's: set " \
73
+ "web.proxy.owner to your Tailscale login")
74
+ end
75
+ end
76
+
77
+ # Something other than this provider's one handler serves 443.
78
+ class ServeInUse < Refused
79
+ def initialize(what)
80
+ super("Tailscale Serve already serves port 443 here (#{what}), and chi web won't change it: turn it off " \
81
+ "with tailscale serve --https=443 off, or use it as it is without --tunnel")
82
+ end
83
+ end
84
+
85
+ # Funnel (the public internet) is on for 443.
86
+ class Funnel < Refused
87
+ def initialize(name)
88
+ super("Tailscale Funnel is on for #{name} (the public internet), and chi web never runs behind Funnel: " \
89
+ "turn it off first (tailscale funnel status shows it)")
90
+ end
91
+ end
92
+
93
+ # @param env [Hash] ENV: SAMAGOTCHI_TAILSCALE_CLI, PATH, the test-run flags
94
+ # @param app_cli [String] the app bundle's CLI (a spec's own path)
95
+ # @param runner [#call] (argv, timeout:) → Runner::Result
96
+ def initialize(env: ENV, app_cli: APP_CLI, runner: Runner.new)
97
+ @env = env
98
+ @app_cli = app_cli
99
+ @runner = runner
100
+ end
101
+
102
+ # The tailscale command: SAMAGOTCHI_TAILSCALE_CLI, else tailscale on
103
+ # PATH, else the macOS app's CLI (not in a test run).
104
+ # @raise [CliMissing, CliOverrideMissing]
105
+ def cli
106
+ @cli ||= lookup
107
+ end
108
+
109
+ # Everything checked before anything changes: connected, the name
110
+ # and its certificate, the owner, config conflicts, what Serve has
111
+ # on 443, Tailnet Lock.
112
+ # @param config_owner [String, nil] web.proxy.owner
113
+ # @param config_public_url [String, nil] web.public_url
114
+ # @param config_identity_header [String, nil] web.proxy.identity_header
115
+ # @return [Plan]
116
+ # @raise [Refused]
117
+ def prepare(port:, config_owner: nil, config_public_url: nil, config_identity_header: nil)
118
+ status = json(%w[status --json])
119
+ dns_name = connected_name(status)
120
+ origin = "https://#{dns_name}"
121
+ check_config(origin, config_public_url, config_identity_header)
122
+ owner, owner_source = owner_for(status, config_owner)
123
+ adopted = classify(json(%w[serve status --json]), dns_name, port) == :ours
124
+ public_url = PublicUrl.from_config(url: origin, identity_header: IDENTITY_HEADER, owner: owner)
125
+ Plan.new(public_url: public_url, owner_source: owner_source, adopted: adopted, lock_off: lock_off?,
126
+ port: port, dns_name: dns_name)
127
+ end
128
+
129
+ # Serve 443 → the plan's port, in the background (tailscaled keeps
130
+ # it until #down, also past a chi web killed hard: the next
131
+ # --tunnel adopts it). The explicit target: chi web binds 127.0.0.1
132
+ # only.
133
+ # @raise [CliFailed, CliTimeout] its output in the message (a consent URL)
134
+ def up(plan)
135
+ args = ["serve", "--bg", "--https=443", plan.target]
136
+ result = run(args, timeout: SERVE_TIMEOUT)
137
+ raise CliFailed.new(command(args), result.output) unless result.success
138
+
139
+ nil
140
+ end
141
+
142
+ # 443 off again, only while Serve has exactly what #up set (the same
143
+ # test as the adopt): a handler someone added meanwhile, another
144
+ # target, Funnel, is left as it is (`serve --https=443 off` would drop
145
+ # all of 443).
146
+ # @return [Symbol] :off, :changed (left alone), :gone (443 was off already)
147
+ # @raise [Refused] a command that failed (the caller warns)
148
+ def down(plan)
149
+ state = begin
150
+ classify(json(%w[serve status --json]), plan.dns_name, plan.port)
151
+ rescue ServeInUse, Funnel
152
+ :changed
153
+ end
154
+ return state == :none ? :gone : state unless state == :ours
155
+
156
+ args = %w[serve --https=443 off]
157
+ result = run(args, timeout: SERVE_TIMEOUT)
158
+ raise CliFailed.new(command(args), result.output) unless result.success
159
+
160
+ :off
161
+ end
162
+
163
+ # What the start lines add: how it is reached and whose login is the
164
+ # owner, and the Tailnet Lock warning.
165
+ def start_lines(plan)
166
+ source = plan.owner_source == :config ? "web.proxy.owner" : "this machine's Tailscale login"
167
+ via = " via Tailscale Serve (tailnet only), owner #{plan.public_url.owner} (#{source})"
168
+ via += "; Serve already forwarded port 443 here, chi web turns it off on exit" if plan.adopted
169
+ lines = [via]
170
+ if plan.lock_off
171
+ lines << "Tailnet Lock is off: a device Tailscale's servers add to your tailnet could reach this page; " \
172
+ "tailscale lock init turns it on (#{LOCK_DOCS})"
173
+ end
174
+ lines
175
+ end
176
+
177
+ # The line #down's outcome is said in, nil for none.
178
+ def down_line(plan, outcome)
179
+ case outcome
180
+ when :off then "Tailscale Serve: port 443 is off again."
181
+ when :changed
182
+ "Tailscale Serve's port 443 changed while chi web ran (#{plan.dns_name}): left as it is " \
183
+ "(tailscale serve status shows it)"
184
+ end
185
+ end
186
+
187
+ # A #down that failed: Serve may still forward the name.
188
+ def down_warning(plan, error)
189
+ "Warning: Tailscale Serve may still forward #{plan.dns_name} to 127.0.0.1:#{plan.port} (#{error.message}): " \
190
+ "turn it off with tailscale serve --https=443 off"
191
+ end
192
+
193
+ # What Serve has on 443: :none, :ours (exactly one handler / →
194
+ # http://127.0.0.1:<port>, no Funnel), or a ServeInUse / Funnel.
195
+ # Other ports are not looked at.
196
+ # @raise [ServeInUse, Funnel, UnexpectedOutput]
197
+ def classify(serve, dns_name, port)
198
+ raise UnexpectedOutput, command(%w[serve status --json]) unless serve.is_a?(Hash)
199
+
200
+ funnel = hash(serve["AllowFunnel"]).find { |key, on| on && https_key?(key) }
201
+ raise Funnel, funnel.first if funnel
202
+
203
+ tcp = hash(serve["TCP"])["443"]
204
+ web = hash(serve["Web"]).select { |key, _| https_key?(key) }
205
+ return :none if tcp.nil? && web.empty?
206
+ return :ours if ours?(tcp, web, dns_name, port)
207
+
208
+ raise ServeInUse, describe(tcp, web)
209
+ end
210
+
211
+ private
212
+
213
+ def lookup
214
+ override = @env[ENV_CLI].to_s.strip
215
+ unless override.empty?
216
+ return override if executable?(override)
217
+
218
+ raise CliOverrideMissing, override
219
+ end
220
+ on_path = @env["PATH"].to_s.split(File::PATH_SEPARATOR).reject(&:empty?)
221
+ .map { |dir| File.join(dir, "tailscale") }.find { |path| executable?(path) }
222
+ return on_path if on_path
223
+ return @app_cli if !Session.test_session_env?(env: @env) && executable?(@app_cli)
224
+
225
+ raise CliMissing
226
+ end
227
+
228
+ def executable?(path) = File.file?(path) && File.executable?(path)
229
+
230
+ def connected_name(status)
231
+ raise UnexpectedOutput, command(%w[status --json]) unless status.is_a?(Hash)
232
+
233
+ state = status["BackendState"].to_s
234
+ raise NotRunning, (state.empty? ? "no state" : state) unless state == "Running"
235
+
236
+ name = hash(status["Self"])["DNSName"].to_s.strip.downcase.delete_suffix(".")
237
+ raise NoDnsName if name.empty?
238
+ raise NoHttps, name unless Array(status["CertDomains"]).any? { |domain| domain.to_s.casecmp?(name) }
239
+
240
+ name
241
+ end
242
+
243
+ def check_config(origin, config_public_url, config_identity_header)
244
+ unless blank?(config_public_url) || configured_origin(config_public_url) == origin
245
+ raise ConfigConflict.new("web.public_url", config_public_url.to_s.strip, origin)
246
+ end
247
+ return if blank?(config_identity_header) || config_identity_header.to_s.strip.casecmp?(IDENTITY_HEADER)
248
+
249
+ raise ConfigConflict.new("web.proxy.identity_header", config_identity_header.to_s.strip, IDENTITY_HEADER)
250
+ end
251
+
252
+ def configured_origin(url)
253
+ PublicUrl.from_config(url: url)&.origin
254
+ rescue PublicUrl::Invalid
255
+ nil
256
+ end
257
+
258
+ # [login, :config | :login]
259
+ def owner_for(status, config_owner)
260
+ return [config_owner.to_s.strip, :config] unless blank?(config_owner)
261
+
262
+ me = hash(status["Self"])
263
+ tags = Array(me["Tags"])
264
+ raise TaggedOwner, tags.map(&:to_s) unless tags.empty?
265
+
266
+ login = hash(hash(status["User"])[me["UserID"].to_s])["LoginName"].to_s.strip
267
+ raise UnexpectedOutput, command(%w[status --json]) if login.empty?
268
+
269
+ [login, :login]
270
+ end
271
+
272
+ def ours?(tcp, web, dns_name, port)
273
+ handlers = { "Handlers" => { "/" => { "Proxy" => "http://127.0.0.1:#{port}" } } }
274
+ tcp == { "HTTPS" => true } && web.size == 1 && web.keys.first.casecmp?("#{dns_name}:443") &&
275
+ web.values.first == handlers
276
+ end
277
+
278
+ def describe(tcp, web)
279
+ parts = web.values.flat_map do |entry|
280
+ hash(hash(entry)["Handlers"]).map { |path, handler| "#{path} → #{handler_target(handler)}" }
281
+ end
282
+ forward = hash(tcp)["TCPForward"]
283
+ parts << "TCP → #{forward}" if forward
284
+ parts.empty? ? "a configuration chi web didn't make" : parts.join(", ")
285
+ end
286
+
287
+ def handler_target(handler)
288
+ handler = hash(handler)
289
+ handler["Proxy"] || (handler["Path"] && "files #{handler["Path"]}") || (handler.key?("Text") && "text") ||
290
+ handler.keys.join("/")
291
+ end
292
+
293
+ def https_key?(key) = key.to_s.end_with?(":443")
294
+
295
+ # Tailnet Lock is off (Enabled false); a failure says nothing.
296
+ def lock_off?
297
+ json(%w[lock status --json]).then { |lock| lock.is_a?(Hash) && lock["Enabled"] == false }
298
+ rescue Refused
299
+ false
300
+ end
301
+
302
+ def json(args)
303
+ result = run(args, timeout: STATUS_TIMEOUT)
304
+ raise CliFailed.new(command(args), result.output) unless result.success
305
+
306
+ JSON.parse(result.stdout)
307
+ rescue JSON::ParserError
308
+ raise UnexpectedOutput, command(args)
309
+ end
310
+
311
+ def run(args, timeout:)
312
+ @runner.call([cli, *args], timeout: timeout)
313
+ rescue Runner::Timeout => e
314
+ raise CliTimeout.new(command(args), timeout, e.output)
315
+ rescue SystemCallError => e
316
+ raise CliFailed.new(command(args), e.message)
317
+ end
318
+
319
+ def command(args) = ["tailscale", *args].join(" ")
320
+ def hash(value) = value.is_a?(Hash) ? value : {}
321
+ def blank?(value) = value.to_s.strip.empty?
322
+ end
323
+ end
324
+ end
325
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require_relative "../config"
5
+
6
+ module Samagotchi
7
+ module Web
8
+ # `chi web --tunnel PROVIDER`: chi web reached from outside this machine
9
+ # through a provider that gives it an https name and tells it who is
10
+ # asking (Tailscale Serve: docs/configuration.md "Tailscale Serve"). The
11
+ # provider checks what it can before anything changes (#prepare), turns
12
+ # the tunnel on (#up) and off again on exit (#down), never touching what
13
+ # it didn't set. The public_url it makes is this run's, never written to
14
+ # config.yml.
15
+ module Tunnel
16
+ PROVIDERS = %w[tailscale].freeze
17
+
18
+ # What a tunnel's #prepare found: the PublicUrl chi web runs with this
19
+ # time, whose login is the owner (:config for web.proxy.owner, :login
20
+ # for this machine's own), whether the serve was already there (a
21
+ # leftover of an earlier run: #down resets it all the same), whether
22
+ # Tailnet Lock is off (a warning line), and the port it forwards to.
23
+ Plan = Data.define(:public_url, :owner_source, :adopted, :lock_off, :port, :dns_name) do
24
+ def target = "http://127.0.0.1:#{port}"
25
+ end
26
+
27
+ # Why `chi web --tunnel` won't start: each condition its own class and
28
+ # its own text (callers add only context).
29
+ class Refused < StandardError; end
30
+
31
+ # @return [Tailscale] the provider +name+ names
32
+ # @raise [ArgumentError] an unknown name (the flag's list refuses it first)
33
+ def self.for(name, **)
34
+ case name.to_s
35
+ when "tailscale"
36
+ require_relative "tunnel/tailscale"
37
+ Tailscale.new(**)
38
+ else raise ArgumentError, "unknown tunnel #{name} (#{PROVIDERS.join(", ")})"
39
+ end
40
+ end
41
+
42
+ # A web.public_url or web.proxy.identity_header setting the tunnel would
43
+ # override for its run.
44
+ class ConfigConflict < Refused
45
+ def initialize(key, value, tunnel_value)
46
+ env = Config::BY_KEY.fetch(key).env_key
47
+ super("#{key} is #{value}, but --tunnel sets it to #{tunnel_value} for this run: remove it from config.yml " \
48
+ "or the environment (#{env}), or drop --tunnel")
49
+ end
50
+ end
51
+
52
+ # A chi web already answers on the port: --tunnel starts its own.
53
+ class AlreadyRunning < Refused
54
+ def initialize(port)
55
+ super("chi web already runs on port #{port}, and --tunnel starts its own: stop that one first (Ctrl-C in its " \
56
+ "terminal), or use --port")
57
+ end
58
+ end
59
+
60
+ # The provider's command exited with an error.
61
+ class CliFailed < Refused
62
+ def initialize(command, output)
63
+ text = output.to_s.strip
64
+ super("#{command} failed#{text.empty? ? " (no output)" : ": #{text}"}")
65
+ end
66
+ end
67
+
68
+ # The provider's command didn't finish in time (stopped): a tailnet
69
+ # where Serve isn't allowed yet prints a consent URL and waits.
70
+ class CliTimeout < Refused
71
+ def initialize(command, seconds, output)
72
+ text = output.to_s.strip
73
+ super("#{command} didn't finish in #{seconds} s and was stopped#{": #{text}" unless text.empty?}" \
74
+ "#{" (if it printed a URL, open it to allow this, then run chi web again)" unless text.empty?}")
75
+ end
76
+ end
77
+
78
+ # The provider's command printed something chi can't read: never a
79
+ # guess that could change the wrong thing.
80
+ class UnexpectedOutput < Refused
81
+ def initialize(command)
82
+ super("#{command} printed something chi can't read (a newer version?); nothing was changed")
83
+ end
84
+ end
85
+
86
+ # A command run with a deadline: its own process group, both pipes read
87
+ # on threads, the group KILLed past +timeout+ (what it printed so far
88
+ # goes into the Timeout).
89
+ class Runner
90
+ Result = Data.define(:stdout, :stderr, :success) do
91
+ def output = [stdout, stderr].map(&:to_s).reject(&:empty?).join("\n")
92
+ end
93
+
94
+ class Timeout < StandardError
95
+ attr_reader :output
96
+
97
+ def initialize(output)
98
+ @output = output
99
+ super("timed out")
100
+ end
101
+ end
102
+
103
+ # @return [Result]
104
+ # @raise [Timeout]
105
+ # @raise [SystemCallError] the command can't run (ENOENT, EACCES)
106
+ def call(argv, timeout:)
107
+ Open3.popen3(*argv, pgroup: true) do |stdin, out, err, wait|
108
+ stdin.close
109
+ readers = [out, err].map { |io| Thread.new { io.read }.tap { |t| t.report_on_exception = false } }
110
+ unless wait.join(timeout)
111
+ stop(wait.pid)
112
+ readers.each { |reader| reader.join(1) }
113
+ raise Timeout, readers.map { |reader| text(reader) }.reject(&:empty?).join("\n")
114
+ end
115
+ # A process it left behind may hold the pipes: what was read by then.
116
+ readers.each { |reader| reader.join(1) }
117
+ Result.new(stdout: text(readers[0]), stderr: text(readers[1]), success: wait.value.success?)
118
+ end
119
+ end
120
+
121
+ private
122
+
123
+ def text(reader) = reader.alive? ? "" : reader.value.to_s
124
+
125
+ def stop(pid)
126
+ Process.kill("KILL", -pid)
127
+ rescue SystemCallError
128
+ nil
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end