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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -1
- data/bin/chi +5 -1
- data/docs/architecture.md +1 -1
- data/docs/cli.md +40 -1
- data/docs/configuration.md +97 -3
- data/docs/internals/engine.md +19 -4
- data/docs/internals/participants.md +6 -3
- data/docs/internals/prompt-caching.md +5 -0
- data/docs/plugins.md +1 -1
- data/docs/sessions.md +3 -1
- data/docs/sub-agent.md +1 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/context_note.rb +7 -3
- data/lib/samagotchi/engine.rb +20 -9
- data/lib/samagotchi/idle_recap.rb +1 -1
- data/lib/samagotchi/idle_reminders.rb +1 -1
- data/lib/samagotchi/kernel_loop.rb +12 -7
- data/lib/samagotchi/llm/chat_loop.rb +9 -2
- data/lib/samagotchi/message_time.rb +104 -0
- data/lib/samagotchi/reminder_queue.rb +20 -3
- data/lib/samagotchi/reply_wait/poll.rb +39 -1
- data/lib/samagotchi/run_options.rb +4 -1
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/local.rb +11 -5
- data/lib/samagotchi/session_inbox.rb +7 -3
- data/lib/samagotchi/steer.rb +5 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +70 -9
- data/lib/samagotchi/terminal_ui/between_turns.rb +8 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +13 -5
- data/lib/samagotchi/terminal_ui/gap_clock.rb +78 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +20 -2
- data/lib/samagotchi/terminal_ui.rb +40 -5
- data/lib/samagotchi/tool_activity.rb +11 -0
- data/lib/samagotchi/turn_checkpoint.rb +6 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +15 -6
- data/lib/samagotchi/web/participant_view.rb +12 -5
- data/lib/samagotchi/web/public/app.js +11 -3
- data/lib/samagotchi/web/public/bubbles.js +29 -7
- data/lib/samagotchi/web/public/format.js +72 -6
- data/lib/samagotchi/web/public/index.html +2 -0
- data/lib/samagotchi/web/public/participant.html +2 -0
- data/lib/samagotchi/web/public/participant.js +30 -7
- data/lib/samagotchi/web/public/participant_model.js +25 -2
- data/lib/samagotchi/web/public/stream_handlers.js +32 -4
- data/lib/samagotchi/web/public/time_gaps.js +98 -0
- data/lib/samagotchi/web/public/tokens.css +2 -2
- data/lib/samagotchi/web/public/turn_events.js +3 -1
- data/lib/samagotchi/web/public/turn_html.js +15 -8
- data/lib/samagotchi/web/public/turn_view.js +8 -3
- data/lib/samagotchi/web/running.rb +39 -4
- data/lib/samagotchi/web/server.rb +87 -31
- data/lib/samagotchi/web/tunnel/tailscale.rb +325 -0
- data/lib/samagotchi/web/tunnel.rb +133 -0
- 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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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,
|
|
207
|
-
#
|
|
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 (
|
|
255
|
-
#
|
|
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
|
-
|
|
269
|
-
["Proxied: #{
|
|
270
|
-
|
|
271
|
-
|
|
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
|
|
278
|
-
#
|
|
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
|