claude-inbox 0.2.0 → 0.3.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/README.md +160 -152
- data/exe/claude-inbox +23 -4
- data/lib/claude_inbox/agents_client.rb +57 -55
- data/lib/claude_inbox/app.rb +42 -26
- data/lib/claude_inbox/config.rb +24 -0
- data/lib/claude_inbox/dialog.rb +3 -1
- data/lib/claude_inbox/images.rb +37 -6
- data/lib/claude_inbox/job_state.rb +23 -8
- data/lib/claude_inbox/keymap.rb +1 -1
- data/lib/claude_inbox/new_session_form.rb +29 -39
- data/lib/claude_inbox/painter.rb +32 -0
- data/lib/claude_inbox/palette.rb +4 -7
- data/lib/claude_inbox/peek.rb +2 -2
- data/lib/claude_inbox/poller.rb +4 -3
- data/lib/claude_inbox/pull_requests.rb +5 -5
- data/lib/claude_inbox/records.rb +7 -2
- data/lib/claude_inbox/remote/http.rb +137 -0
- data/lib/claude_inbox/remote/icon.png +0 -0
- data/lib/claude_inbox/remote/icon.svg +8 -0
- data/lib/claude_inbox/remote/listener.rb +436 -0
- data/lib/claude_inbox/remote/page.html +477 -0
- data/lib/claude_inbox/remote/pairing.rb +111 -0
- data/lib/claude_inbox/remote/pairing_dialog.rb +93 -0
- data/lib/claude_inbox/remote/start.rb +202 -0
- data/lib/claude_inbox/remote.rb +10 -0
- data/lib/claude_inbox/renderer.rb +57 -57
- data/lib/claude_inbox/session.rb +7 -21
- data/lib/claude_inbox/session_request.rb +151 -0
- data/lib/claude_inbox/sessions.rb +0 -1
- data/lib/claude_inbox/settings.rb +19 -7
- data/lib/claude_inbox/store.rb +0 -2
- data/lib/claude_inbox/subprocess.rb +9 -1
- data/lib/claude_inbox/terminal.rb +9 -3
- data/lib/claude_inbox/text.rb +7 -1
- data/lib/claude_inbox/trust.rb +23 -0
- data/lib/claude_inbox/vt_screen.rb +1 -1
- data/lib/claude_inbox.rb +1 -1
- metadata +14 -1
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeInbox
|
|
4
|
+
module Remote
|
|
5
|
+
# The little of HTTP/1.1 the listener speaks: one request per
|
|
6
|
+
# connection, a body sized by Content-Length, and a response that closes
|
|
7
|
+
# the connection. Nothing here knows a route or a token.
|
|
8
|
+
module Http
|
|
9
|
+
MAX_LINE = 8 * 1024
|
|
10
|
+
MAX_HEADERS = 64
|
|
11
|
+
CHUNK = 64 * 1024
|
|
12
|
+
TOKEN = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/
|
|
13
|
+
|
|
14
|
+
REASONS = {
|
|
15
|
+
100 => "Continue", 200 => "OK", 201 => "Created", 400 => "Bad Request", 401 => "Unauthorized",
|
|
16
|
+
403 => "Forbidden", 404 => "Not Found", 405 => "Method Not Allowed", 409 => "Conflict",
|
|
17
|
+
411 => "Length Required", 413 => "Content Too Large", 415 => "Unsupported Media Type",
|
|
18
|
+
421 => "Misdirected Request", 422 => "Unprocessable Content", 500 => "Internal Server Error",
|
|
19
|
+
503 => "Service Unavailable"
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
HEADERS = {
|
|
23
|
+
"Connection" => "close",
|
|
24
|
+
"Cache-Control" => "no-store",
|
|
25
|
+
"X-Content-Type-Options" => "nosniff",
|
|
26
|
+
"Referrer-Policy" => "no-referrer"
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
JSON_TYPE = {"Content-Type" => "application/json"}.freeze
|
|
30
|
+
|
|
31
|
+
# A request refused: the status, the message and any other members for
|
|
32
|
+
# the JSON body, and the headers that status calls for. `note`, when
|
|
33
|
+
# set, is what the refusal is logged as in place of its message.
|
|
34
|
+
class Error < StandardError
|
|
35
|
+
attr_reader :status, :headers, :details, :note
|
|
36
|
+
|
|
37
|
+
def initialize(status, message, headers: {}, note: nil, **details)
|
|
38
|
+
super(message)
|
|
39
|
+
@status = status
|
|
40
|
+
@headers = headers
|
|
41
|
+
@note = note
|
|
42
|
+
@details = details
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def body = {error: Http.utf8(message), **details.transform_values { |v| v.is_a?(Integer) ? v : Http.utf8(v) }}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# A request head. `headers` are keyed in lower case, and a repeated
|
|
49
|
+
# header is joined with ", ", so a second Content-Length or Host can't
|
|
50
|
+
# pass for the first. The bytes read past the head stay here for the
|
|
51
|
+
# body.
|
|
52
|
+
class Request
|
|
53
|
+
attr_reader :verb, :path, :query, :version, :headers
|
|
54
|
+
|
|
55
|
+
def initialize(verb, target, version, headers, rest)
|
|
56
|
+
@verb = verb
|
|
57
|
+
@path, @query = target.split("?", 2)
|
|
58
|
+
@version = version
|
|
59
|
+
@headers = headers
|
|
60
|
+
@rest = rest
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Read only once the request has earned it: a client that sent
|
|
64
|
+
# `Expect: 100-continue` holds the body back until it hears 100.
|
|
65
|
+
def read_body(io, max:, deadline:)
|
|
66
|
+
raise Error.new(400, "Transfer-Encoding isn't accepted; send Content-Length") if headers.key?("transfer-encoding")
|
|
67
|
+
length = headers["content-length"]
|
|
68
|
+
raise Error.new(411, "Content-Length is required") unless length
|
|
69
|
+
raise Error.new(400, "Content-Length isn't a number") unless length.match?(/\A\d{1,15}\z/)
|
|
70
|
+
size = length.to_i
|
|
71
|
+
raise Error.new(413, "the body is over #{max} bytes") if size > max
|
|
72
|
+
io.write("HTTP/1.1 100 Continue\r\n\r\n") if headers["expect"]&.casecmp?("100-continue") && @rest.bytesize < size
|
|
73
|
+
Http.fill(io, @rest, deadline) while @rest.bytesize < size
|
|
74
|
+
@rest.byteslice(0, size)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
79
|
+
|
|
80
|
+
# The JSON generator warns on stderr, which is the terminal, about a
|
|
81
|
+
# binary string and raises on a bad byte, and a message can quote CLI
|
|
82
|
+
# output or request bytes.
|
|
83
|
+
def self.utf8(text) = String.new(text.to_s, encoding: Encoding::UTF_8).scrub("\uFFFD")
|
|
84
|
+
|
|
85
|
+
# `deadline` (on the monotonic clock) bounds the whole head, not each
|
|
86
|
+
# read: a per-read timeout lets a byte every few seconds hold the
|
|
87
|
+
# connection for ever.
|
|
88
|
+
def self.read_head(io, deadline:)
|
|
89
|
+
rest = +"".b
|
|
90
|
+
line = read_line(io, rest, deadline)
|
|
91
|
+
request = line.match(%r{\A(\S+) (/\S*) HTTP/(1\.[01])\z})
|
|
92
|
+
raise Error.new(400, "not an HTTP/1.x request line") unless request && request[1].match?(TOKEN)
|
|
93
|
+
headers = {}
|
|
94
|
+
count = 0
|
|
95
|
+
loop do
|
|
96
|
+
line = read_line(io, rest, deadline)
|
|
97
|
+
return Request.new(request[1], request[2], request[3], headers, rest) if line.empty?
|
|
98
|
+
raise Error.new(400, "more than #{MAX_HEADERS} headers") if (count += 1) > MAX_HEADERS
|
|
99
|
+
name, value = line.split(":", 2)
|
|
100
|
+
raise Error.new(400, "malformed header") unless value && name.match?(TOKEN)
|
|
101
|
+
key = name.downcase
|
|
102
|
+
value = value.strip
|
|
103
|
+
headers[key] = headers.key?(key) ? "#{headers[key]}, #{value}" : value
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.write(io, status, headers = {}, body = "")
|
|
108
|
+
body = body.b
|
|
109
|
+
head = ["HTTP/1.1 #{status} #{REASONS.fetch(status)}"]
|
|
110
|
+
HEADERS.merge(headers, "Content-Length" => body.bytesize.to_s).each { |name, value| head << "#{name}: #{value}" }
|
|
111
|
+
io.write((head.join("\r\n") + "\r\n\r\n").b + body)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def self.read_line(io, rest, deadline)
|
|
115
|
+
loop do
|
|
116
|
+
i = rest.index("\n")
|
|
117
|
+
raise Error.new(400, "a line of the request head is over #{MAX_LINE} bytes") if (i || rest.bytesize) > MAX_LINE
|
|
118
|
+
return rest.slice!(0..i).chomp if i
|
|
119
|
+
fill(io, rest, deadline)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def self.fill(io, rest, deadline)
|
|
124
|
+
loop do
|
|
125
|
+
case (chunk = io.read_nonblock(CHUNK, exception: false))
|
|
126
|
+
when nil then raise Error.new(400, "the connection closed mid-request")
|
|
127
|
+
when :wait_readable
|
|
128
|
+
left = deadline - monotonic
|
|
129
|
+
raise Error.new(400, "the request took too long") unless left > 0 && IO.select([io], nil, nil, left)
|
|
130
|
+
else return rest << chunk
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
private_class_method :read_line
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
Binary file
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
|
2
|
+
<rect width="512" height="512" fill="#0a7385"/>
|
|
3
|
+
<g fill="none" stroke="#fff" stroke-width="28" stroke-linejoin="round" stroke-linecap="round">
|
|
4
|
+
<path d="M136 150h240l44 132v92a28 28 0 0 1-28 28H120a28 28 0 0 1-28-28v-92z"/>
|
|
5
|
+
<path d="M92 282h92l26 46h92l26-46h92"/>
|
|
6
|
+
<path d="M256 170v88M220 222l36 36 36-36"/>
|
|
7
|
+
</g>
|
|
8
|
+
</svg>
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "socket"
|
|
6
|
+
require_relative "../agents_client"
|
|
7
|
+
require_relative "http"
|
|
8
|
+
require_relative "pairing"
|
|
9
|
+
require_relative "pairing_dialog"
|
|
10
|
+
require_relative "start"
|
|
11
|
+
require_relative "../subprocess"
|
|
12
|
+
require_relative "../text"
|
|
13
|
+
|
|
14
|
+
module ClaudeInbox
|
|
15
|
+
module Remote
|
|
16
|
+
# The opt-in HTTP listener a phone starts sessions through: the port, the
|
|
17
|
+
# connection limits, the static files and the Pairing token, in front of
|
|
18
|
+
# Start. Like the Poller it reaches App only through the queue, and it
|
|
19
|
+
# never writes to the terminal: every failure becomes a response or a
|
|
20
|
+
# notice.
|
|
21
|
+
class Listener
|
|
22
|
+
DEFAULT_PORT = 7433
|
|
23
|
+
DEFAULT_MODES = %w[default auto plan].freeze
|
|
24
|
+
LOCK_PATH = File.join(Dir.home, ".config", "claude-inbox", "listen.lock")
|
|
25
|
+
MAX_CONNECTIONS = 4
|
|
26
|
+
# Per host, so one host's idle sockets can't shut the others out. Two,
|
|
27
|
+
# so a phone loading the page doesn't turn its own requests away.
|
|
28
|
+
MAX_UNAUTHENTICATED = 2
|
|
29
|
+
MAX_TURNED_AWAY = 8
|
|
30
|
+
RETRY_EVERY = 5
|
|
31
|
+
WAITING = %i[in_use held].freeze
|
|
32
|
+
HEAD_TIMEOUT = 10
|
|
33
|
+
LINGER = 2
|
|
34
|
+
RECENT = 5
|
|
35
|
+
# The phone's form. Everything it needs is inline, and it talks to
|
|
36
|
+
# nothing but this listener.
|
|
37
|
+
PAGE = File.read(File.join(__dir__, "page.html"), encoding: Encoding::UTF_8).freeze
|
|
38
|
+
PAGE_TYPE = {
|
|
39
|
+
"Content-Type" => "text/html; charset=utf-8",
|
|
40
|
+
"Content-Security-Policy" => "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " \
|
|
41
|
+
"img-src blob: data:; connect-src 'self'; manifest-src 'self'; form-action 'none'; frame-ancestors 'none'"
|
|
42
|
+
}.freeze
|
|
43
|
+
# What Add to Home Screen reads to open the page as an app of its own.
|
|
44
|
+
# The icon is drawn by icon.svg: rsvg-convert -w 512 icon.svg -o icon.png
|
|
45
|
+
MANIFEST = JSON.generate(name: "Claude Inbox", start_url: "/", scope: "/", display: "standalone",
|
|
46
|
+
icons: [{src: "/icon.png", sizes: "512x512", type: "image/png"}]).freeze
|
|
47
|
+
ICON = File.binread(File.join(__dir__, "icon.png")).freeze
|
|
48
|
+
# Served to anyone, like the page itself: none of it is a secret.
|
|
49
|
+
FILES = {
|
|
50
|
+
"/" => [PAGE_TYPE, PAGE],
|
|
51
|
+
"/manifest.webmanifest" => [{"Content-Type" => "application/manifest+json"}, MANIFEST],
|
|
52
|
+
"/icon.png" => [{"Content-Type" => "image/png"}, ICON]
|
|
53
|
+
}.freeze
|
|
54
|
+
ROUTES = FILES.transform_values { "GET" }.merge(Start::VERBS).freeze
|
|
55
|
+
|
|
56
|
+
# What `N` and the header chip show, as of one moment. `state` is :off,
|
|
57
|
+
# :listening, :in_use (the port is taken), :held (another inbox has the
|
|
58
|
+
# lock, as `held_by`) or :failed (for good, as `error` says). `urls` and
|
|
59
|
+
# `firewall` are nil until `refresh`.
|
|
60
|
+
Snapshot = Data.define(:state, :port, :lan, :urls, :firewall, :allowed_modes, :recent, :held_by, :fixture, :error) do
|
|
61
|
+
def initialize(error: nil, **) = super
|
|
62
|
+
|
|
63
|
+
# Over a VPN only an address reaches the Mac: multicast DNS, which
|
|
64
|
+
# the .local name needs, stays on the LAN.
|
|
65
|
+
def pairing_url = (lan && urls&.[](1)) || urls&.first
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# `count` is how many times `via` got `result`, the last at `at`.
|
|
69
|
+
Outcome = Data.define(:at, :via, :result, :count) do
|
|
70
|
+
def initialize(at:, via:, result:, count: 1) = super
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# App's `listen:` from the flags, else the environment; nil when neither
|
|
74
|
+
# asks for it. --listen-lan wins over --listen, whichever came last.
|
|
75
|
+
def self.options(argv, env)
|
|
76
|
+
flags = argv.filter_map { |arg| arg.match(/\A--listen(-lan)?(?:=(.*))?\z/) }
|
|
77
|
+
lan, port =
|
|
78
|
+
if flags.any?
|
|
79
|
+
flag = flags.reverse.find { |m| m[1] } || flags.last
|
|
80
|
+
[!flag[1].nil?, flag[2]]
|
|
81
|
+
elsif !(value = env["CLAUDE_INBOX_LISTEN"].to_s.strip).empty?
|
|
82
|
+
m = value.match(/\A(?:(lan)(?::(.*))?|(\d+))\z/i)
|
|
83
|
+
raise ArgumentError, "CLAUDE_INBOX_LISTEN is a port, lan or lan:PORT, not #{value}" unless m
|
|
84
|
+
[!m[1].nil?, m[2] || m[3]]
|
|
85
|
+
else
|
|
86
|
+
return nil
|
|
87
|
+
end
|
|
88
|
+
modes = argv.filter_map { |arg| arg[/\A--listen-allow-modes=(.*)\z/, 1] }.last || env["CLAUDE_INBOX_LISTEN_ALLOW_MODES"]
|
|
89
|
+
{port: port_option(port), lan: lan, allowed_modes: modes_option(modes)}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.port_option(value)
|
|
93
|
+
return DEFAULT_PORT if value.nil?
|
|
94
|
+
raise ArgumentError, "the listen port is a number up to 65535, not #{value}" unless value.match?(/\A\d{1,5}\z/) && value.to_i <= 65_535
|
|
95
|
+
value.to_i
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.modes_option(value)
|
|
99
|
+
return DEFAULT_MODES if value.nil? || value.strip.empty?
|
|
100
|
+
modes = value.split(",").map(&:strip).reject(&:empty?)
|
|
101
|
+
unknown = modes - AgentsClient::PERMISSION_MODES
|
|
102
|
+
raise ArgumentError, "unknown permission mode #{unknown.first}: one of #{AgentsClient::PERMISSION_MODES.join(", ")}" if unknown.any?
|
|
103
|
+
modes.uniq.freeze
|
|
104
|
+
end
|
|
105
|
+
private_class_method :port_option, :modes_option
|
|
106
|
+
|
|
107
|
+
# For tests, --fixture without a listen flag, and anything embedding
|
|
108
|
+
# App: never binds, and answers :off.
|
|
109
|
+
def self.disabled = new(client: nil, store: nil, queue: nil, pairing: nil, port: nil)
|
|
110
|
+
|
|
111
|
+
# `start_options` are Start's own.
|
|
112
|
+
def initialize(client:, store:, queue:, port:, pairing: Pairing.new, lan: false, allowed_modes: DEFAULT_MODES,
|
|
113
|
+
lock_path: LOCK_PATH, fixture: false, retry_every: RETRY_EVERY, **start_options)
|
|
114
|
+
@queue = queue
|
|
115
|
+
@pairing = pairing
|
|
116
|
+
@port = port
|
|
117
|
+
@lan = lan
|
|
118
|
+
@allowed_modes = allowed_modes
|
|
119
|
+
@lock_path = lock_path
|
|
120
|
+
@fixture = fixture
|
|
121
|
+
@retry_every = retry_every
|
|
122
|
+
@start = Start.new(client: client, store: store, queue: queue, allowed_modes: allowed_modes,
|
|
123
|
+
fixture: fixture, **start_options)
|
|
124
|
+
@mutex = Mutex.new
|
|
125
|
+
@state = :off
|
|
126
|
+
@recent = []
|
|
127
|
+
@threads = Set.new
|
|
128
|
+
@alive = 0
|
|
129
|
+
@unauthenticated = Hash.new(0)
|
|
130
|
+
@turning_away = 0
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Tries again every few seconds while the port is taken or another
|
|
134
|
+
# inbox has the listener, so this one takes over once it is free.
|
|
135
|
+
def start
|
|
136
|
+
return if @port.nil? || @accept || @retry || !WAITING.include?(claim_port)
|
|
137
|
+
@retry = Thread.new { retry_claim }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Runs after the terminal is restored and can't fail, so a bad socket
|
|
141
|
+
# never leaves the terminal raw.
|
|
142
|
+
def stop
|
|
143
|
+
quietly { @retry&.kill }
|
|
144
|
+
quietly { @accept&.kill }
|
|
145
|
+
quietly { @mutex.synchronize { @threads.to_a }.each(&:kill) }
|
|
146
|
+
quietly { @server&.close }
|
|
147
|
+
release_lock
|
|
148
|
+
@mutex.synchronize { @state = :off }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def port = @bound_port || @port
|
|
152
|
+
|
|
153
|
+
def snapshot
|
|
154
|
+
@mutex.synchronize do
|
|
155
|
+
Snapshot.new(state: @state, port: port, lan: @lan, urls: @urls, firewall: @firewall, allowed_modes: @allowed_modes,
|
|
156
|
+
recent: @recent.dup.freeze, held_by: @held_by, fixture: @fixture, error: @error)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Looks up the pairing URLs and the firewall for the snapshot. It
|
|
161
|
+
# forks, so it runs off the main thread, when `N` opens; asked for
|
|
162
|
+
# before the port is ours, it runs once it is.
|
|
163
|
+
def refresh
|
|
164
|
+
@refresh_wanted = true
|
|
165
|
+
return unless snapshot.state == :listening
|
|
166
|
+
urls = @pairing.urls(port: port, lan: @lan).freeze
|
|
167
|
+
firewall = @pairing.firewall if @lan
|
|
168
|
+
@mutex.synchronize do
|
|
169
|
+
@urls = urls
|
|
170
|
+
@firewall = firewall
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# The addresses fork scutil, so they fill in once looked up.
|
|
175
|
+
def pairing_dialog
|
|
176
|
+
in_background { refresh }
|
|
177
|
+
PairingDialog.new(-> { snapshot })
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def copy_pairing_url
|
|
181
|
+
url = snapshot.pairing_url
|
|
182
|
+
return @queue << [:notice, "still looking up this Mac's addresses"] unless url
|
|
183
|
+
in_background do
|
|
184
|
+
r = Subprocess.capture("osascript", "-e", "on run argv", "-e", "set the clipboard to item 1 of argv", "-e", "end run", url)
|
|
185
|
+
@queue << [:notice, r.success? ? "pairing URL copied" : "couldn't copy: #{r.err.strip}"]
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def rotate
|
|
190
|
+
in_background do
|
|
191
|
+
@pairing.rotate!
|
|
192
|
+
refresh
|
|
193
|
+
@queue << [:notice, "new token: phones pair again with N"]
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# One request read from `io` and answered on it. The connection's
|
|
198
|
+
# `slot` stops counting as unauthenticated once the token checks out.
|
|
199
|
+
def handle(io, via:, slot: nil)
|
|
200
|
+
authorized = false
|
|
201
|
+
request = Http.read_head(io, deadline: Http.monotonic + HEAD_TIMEOUT)
|
|
202
|
+
file = route(request)
|
|
203
|
+
return Http.write(io, 200, *file) if file
|
|
204
|
+
authorize(request, via)
|
|
205
|
+
authorized = true
|
|
206
|
+
authenticated(slot) if slot
|
|
207
|
+
Http.write(io, *answer(request, io, via))
|
|
208
|
+
rescue Http::Error => e
|
|
209
|
+
Http.write(io, e.status, Http::JSON_TYPE.merge(e.headers), JSON.generate(e.body))
|
|
210
|
+
rescue => e
|
|
211
|
+
# A message can name a path under the home directory, which is
|
|
212
|
+
# nobody's business before the token checks out.
|
|
213
|
+
Http.write(io, 500, Http::JSON_TYPE, JSON.generate(error: authorized ? Http.utf8(e.message) : "internal error"))
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
private
|
|
217
|
+
|
|
218
|
+
# Without SO_REUSEADDR the bind fails while another program holds the
|
|
219
|
+
# port on any address, rather than sharing it (macOS gives loopback to
|
|
220
|
+
# the more specific bind); the cost is a bind refused during TIME_WAIT.
|
|
221
|
+
def claim_port
|
|
222
|
+
return :held unless take_lock
|
|
223
|
+
server = Socket.new(:INET, :STREAM)
|
|
224
|
+
server.bind(Addrinfo.tcp(@lan ? "0.0.0.0" : "127.0.0.1", @port))
|
|
225
|
+
server.listen(8)
|
|
226
|
+
@server = server
|
|
227
|
+
@bound_port = server.local_address.ip_port
|
|
228
|
+
@mutex.synchronize { @state = :listening }
|
|
229
|
+
@accept = Thread.new { accept_loop }
|
|
230
|
+
refresh_quietly if @refresh_wanted
|
|
231
|
+
:listening
|
|
232
|
+
rescue Errno::EADDRINUSE
|
|
233
|
+
give_up(server, :in_use)
|
|
234
|
+
rescue SystemCallError => e
|
|
235
|
+
give_up(server, :failed, e.message)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def retry_claim
|
|
239
|
+
loop do
|
|
240
|
+
sleep @retry_every
|
|
241
|
+
break unless WAITING.include?(claim_port)
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def give_up(server, state, error = nil)
|
|
246
|
+
quietly { server&.close }
|
|
247
|
+
release_lock
|
|
248
|
+
@mutex.synchronize do
|
|
249
|
+
@state = state
|
|
250
|
+
@error = error && Text.printable(error)
|
|
251
|
+
end
|
|
252
|
+
state
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# On the retry thread, where nothing would report a failure; the dialog
|
|
256
|
+
# just goes on saying it is looking the addresses up.
|
|
257
|
+
def refresh_quietly
|
|
258
|
+
refresh
|
|
259
|
+
rescue
|
|
260
|
+
nil
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def take_lock
|
|
264
|
+
FileUtils.mkdir_p(File.dirname(@lock_path))
|
|
265
|
+
file = File.open(@lock_path, File::RDWR | File::CREAT, 0o600)
|
|
266
|
+
if file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
267
|
+
file.truncate(0)
|
|
268
|
+
file.write(Process.pid.to_s)
|
|
269
|
+
file.flush
|
|
270
|
+
@mutex.synchronize { @held_by = nil }
|
|
271
|
+
@lock = file
|
|
272
|
+
else
|
|
273
|
+
holder = file.read.to_i
|
|
274
|
+
file.close
|
|
275
|
+
@mutex.synchronize do
|
|
276
|
+
@state = :held
|
|
277
|
+
@held_by = holder.positive? ? holder : nil
|
|
278
|
+
end
|
|
279
|
+
nil
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def release_lock
|
|
284
|
+
quietly { @lock&.close }
|
|
285
|
+
@lock = nil
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Only closing the socket ends it; anything else, a full file table
|
|
289
|
+
# say, is waited out.
|
|
290
|
+
def accept_loop
|
|
291
|
+
loop do
|
|
292
|
+
sock, addr = @server.accept
|
|
293
|
+
admit(sock, peer(addr))
|
|
294
|
+
rescue IOError
|
|
295
|
+
break
|
|
296
|
+
rescue
|
|
297
|
+
sleep 0.1
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def peer(addr) = addr.ip_address
|
|
302
|
+
|
|
303
|
+
def admit(sock, via)
|
|
304
|
+
slot = @mutex.synchronize { take_slot(via) }
|
|
305
|
+
return quietly { sock.close } unless slot
|
|
306
|
+
thread = Thread.new { serve(sock, slot) }
|
|
307
|
+
@mutex.synchronize { @threads << thread if thread.alive? }
|
|
308
|
+
rescue ThreadError
|
|
309
|
+
quietly { sock.close }
|
|
310
|
+
free(slot)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Over the cap, a 503 rather than a queue. It lingers like any answer,
|
|
314
|
+
# so it isn't lost to a reset; past a few of those, only a close.
|
|
315
|
+
def take_slot(via)
|
|
316
|
+
if @alive < MAX_CONNECTIONS && @unauthenticated[via] < MAX_UNAUTHENTICATED
|
|
317
|
+
@alive += 1
|
|
318
|
+
@unauthenticated[via] += 1
|
|
319
|
+
{via: via, authenticated: false}
|
|
320
|
+
elsif @turning_away < MAX_TURNED_AWAY
|
|
321
|
+
@turning_away += 1
|
|
322
|
+
{busy: true}
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def free(slot)
|
|
327
|
+
@mutex.synchronize do
|
|
328
|
+
if slot[:busy] then @turning_away -= 1
|
|
329
|
+
else
|
|
330
|
+
@alive -= 1
|
|
331
|
+
release_unauthenticated(slot[:via]) unless slot[:authenticated]
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Under the mutex. A host leaves the hash at zero, so it holds only
|
|
337
|
+
# hosts connected now.
|
|
338
|
+
def release_unauthenticated(via)
|
|
339
|
+
@unauthenticated[via] -= 1
|
|
340
|
+
@unauthenticated.delete(via) if @unauthenticated[via].zero?
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# `handle` answers every failure it can; what is left is the socket
|
|
344
|
+
# itself going away, and a thread must not report that on stderr.
|
|
345
|
+
def serve(sock, slot)
|
|
346
|
+
if slot[:busy]
|
|
347
|
+
Http.write(sock, 503, Http::JSON_TYPE.merge("Retry-After" => "2"), JSON.generate(error: "busy: try again in a moment"))
|
|
348
|
+
else
|
|
349
|
+
handle(sock, via: slot[:via], slot: slot)
|
|
350
|
+
end
|
|
351
|
+
linger(sock)
|
|
352
|
+
rescue
|
|
353
|
+
nil
|
|
354
|
+
ensure
|
|
355
|
+
quietly { sock.close }
|
|
356
|
+
free(slot)
|
|
357
|
+
@mutex.synchronize { @threads.delete(Thread.current) }
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def authenticated(slot)
|
|
361
|
+
@mutex.synchronize do
|
|
362
|
+
release_unauthenticated(slot[:via]) unless slot[:authenticated]
|
|
363
|
+
slot[:authenticated] = true
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# The client closes first: a close on unread bytes resets the answer,
|
|
368
|
+
# and a server-side close leaves the TIME_WAIT that refuses a rebind.
|
|
369
|
+
def linger(sock)
|
|
370
|
+
deadline = Http.monotonic + LINGER
|
|
371
|
+
loop do
|
|
372
|
+
left = deadline - Http.monotonic
|
|
373
|
+
break unless left > 0 && IO.select([sock], nil, nil, left)
|
|
374
|
+
break if sock.read_nonblock(Http::CHUNK, exception: false).nil?
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# The file a path serves to anyone, or nil for one Start answers.
|
|
379
|
+
def route(request)
|
|
380
|
+
raise Http::Error.new(405, "only GET and POST", headers: {"Allow" => "GET, POST"}) unless %w[GET POST].include?(request.verb)
|
|
381
|
+
raise Http::Error.new(421, "the Host header isn't a name this inbox answers to") unless @pairing.hosts(lan: @lan).include?(host(request))
|
|
382
|
+
verb = ROUTES[request.path]
|
|
383
|
+
raise Http::Error.new(404, "no such path") unless verb
|
|
384
|
+
raise Http::Error.new(405, "#{request.path} takes #{verb}", headers: {"Allow" => verb}) unless request.verb == verb
|
|
385
|
+
FILES[request.path]
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# The name without its port: a tunnel or a proxy can change the port,
|
|
389
|
+
# and only the name tells this Mac from a site pointed at it.
|
|
390
|
+
def host(request) = request.headers["host"].to_s.downcase.sub(/:\d+\z/, "")
|
|
391
|
+
|
|
392
|
+
def authorize(request, via)
|
|
393
|
+
return if @pairing.matches?(request.headers["authorization"].to_s[/\ABearer +(\S+)\z/i, 1])
|
|
394
|
+
remember(via, "token rejected")
|
|
395
|
+
raise Http::Error.new(401, "missing or wrong token: press N in the inbox and pair again", headers: {"WWW-Authenticate" => "Bearer"})
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# The same outcome from the same place again moves up with a count, so
|
|
399
|
+
# a host sending bad tokens can't push everything else out of the list.
|
|
400
|
+
# Every start, and every refusal once the token has checked out, is
|
|
401
|
+
# listed for `N`.
|
|
402
|
+
def answer(request, io, via)
|
|
403
|
+
status, headers, body, note = @start.call(request, io, via)
|
|
404
|
+
remember(via, note) if note
|
|
405
|
+
[status, headers, body]
|
|
406
|
+
rescue Http::Error => e
|
|
407
|
+
remember(via, e.note || "refused: #{e.message}")
|
|
408
|
+
raise
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def remember(via, result)
|
|
412
|
+
result = Text.printable(result)
|
|
413
|
+
@mutex.synchronize do
|
|
414
|
+
same = @recent.find { |o| o.via == via && o.result == result }
|
|
415
|
+
outcome = Outcome.new(at: Time.now, via: via, result: result, count: (same&.count || 0) + 1)
|
|
416
|
+
@recent = (@recent - [same] + [outcome]).last(RECENT)
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# A notice, not an :error: the next poll clears an :error.
|
|
421
|
+
def in_background
|
|
422
|
+
Thread.new do
|
|
423
|
+
yield
|
|
424
|
+
rescue => e
|
|
425
|
+
@queue << [:notice, Text.printable(e.message)]
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def quietly
|
|
430
|
+
yield
|
|
431
|
+
rescue IOError, SystemCallError
|
|
432
|
+
nil
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
end
|