1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
require "digest"
require "fileutils"
require "pathname"
module ProTacts
# Keeps a full copy of the requests the app could not answer, so a client
# asking for something unimplemented leaves behind enough to implement it.
#
# Each capture is a directory holding "request" and "response", the same
# layout and format as test/fixtures/macos-exchange, so promoting one to a
# fixture is a copy. Strip the identifying headers when you do — captures
# keep every header, fixtures do not.
#
# This is the local counterpart to the Sentry reporting in config.ru, which
# no longer sends request bodies.
class UnhandledRequests
# @rbs @app: Rack::_App
# @rbs @directory: Pathname
# 404 is the missing-functionality signal: a client asked for something
# this server does not route. 5xx is kept because Sentry now reports
# those without a body, and a crash is hard to read without one. 403 is
# the routed-but-unimplemented case, today an unsupported REPORT type —
# safe to capture because TailscaleAuth sits above this middleware and
# refuses unauthenticated requests before they reach it, so every 403
# arriving here came from the app.
#: (Integer status) -> bool
def self.capture?(status)
status == 403 || status == 404 || status >= 500
end
#: (Rack::_App app, directory: Pathname | String) -> void
def initialize(app, directory:)
@app = app
@directory = Pathname.new(directory)
end
#: (Rack::env env) -> Rack::response
def call(env)
status, headers, body = @app.call(env)
return [status, headers, body] unless self.class.capture?(status)
parts = [] #: Array[String]
body.each { parts << it }
# See DebugLogger#log_response: respond_to? cannot narrow an
# interface, so the cast stands for the check beside it.
(_ = body).close if body.respond_to?(:close)
capture(env, status, headers, parts.join)
[status, headers, parts]
end
private
#: (Rack::env env, Integer status, Rack::headers headers, String body) -> void
def capture(env, status, headers, body)
target = @directory / name_for(env)
# The directory name carries a digest of the request, so an already
# captured request is one a client is repeating — recording it again
# would just grow the directory without adding anything.
return if target.exist?
FileUtils.mkdir_p(target)
(target / "request").write(render_request(env))
(target / "response").write(render_response(status, headers, body))
rescue SystemCallError => e
# A failed capture must not turn a 404 into a 500.
warn "pro-tacts: could not record unhandled request: #{e.message}"
end
#: (Rack::env env) -> String
def name_for(env)
slug = env["PATH_INFO"].to_s.gsub(%r{[^\w]+}, "-").delete_prefix("-").delete_suffix("-")
slug = "root" if slug.empty?
"#{env['REQUEST_METHOD'].to_s.downcase}-#{slug}-#{digest(env)}"
end
#: (Rack::env env) -> String
def digest(env)
# Slicing a hexdigest cannot come up short, which String#[] has
# no way to promise.
Digest::SHA256.hexdigest([env["REQUEST_METHOD"], env["PATH_INFO"], request_body(env)].join("\n"))[0, 8] #: String
end
#: (Rack::env env) -> String
def render_request(env)
lines = ["#{env['REQUEST_METHOD']} #{full_path(env)} #{env.fetch('SERVER_PROTOCOL', 'HTTP/1.1')}"]
lines += each_header(env).map { |name, value| "#{name}: #{value}" }
message(lines, request_body(env))
end
#: (Integer status, Rack::headers headers, String body) -> String
def render_response(status, headers, body)
message([status.to_s] + headers.map { |name, value| "#{name}: #{value}" }, body)
end
#: (Array[String] lines, String body) -> String
def message(lines, body)
head = lines.join("\n") + "\n\n"
body = body.to_s.chomp
body.empty? ? head : "#{head}#{body}\n"
end
#: (Rack::env env) -> String
def full_path(env)
path = env["PATH_INFO"].to_s
query = env["QUERY_STRING"].to_s
query.empty? ? path : "#{path}?#{query}"
end
#: (Rack::env env) -> Array[[String, untyped]]
def each_header(env)
env.filter_map { |key, value|
case key
when /\AHTTP_(.+)\z/ then [header_name(key.delete_prefix("HTTP_")), value]
when "CONTENT_TYPE" then ["Content-Type", value]
when "CONTENT_LENGTH" then ["Content-Length", value]
end
# The two-element arrays are pairs, which filter_map has no way
# to say.
}.sort #: Array[[String, untyped]]
end
#: (String name) -> String
def header_name(name)
name.split("_").map(&:capitalize).join("-")
end
#: (Rack::env env) -> String
def request_body(env)
input = env["rack.input"]
return "" if input.nil?
body = input.read.to_s
input.rewind
body
end
end
end