tipi 0.41 → 0.42

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.
data/lib/tipi.rb CHANGED
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'polyphony'
4
+
4
5
  require_relative './tipi/http1_adapter'
5
- # require_relative './tipi/http1_adapter_new'
6
6
  require_relative './tipi/http2_adapter'
7
7
  require_relative './tipi/configuration'
8
8
  require_relative './tipi/response_extensions'
9
+ require_relative './tipi/acme'
10
+
9
11
  require 'qeweney/request'
10
12
 
11
13
  class Qeweney::Request
@@ -49,16 +51,95 @@ module Tipi
49
51
  ensure
50
52
  client.close rescue nil
51
53
  end
52
-
54
+
53
55
  def protocol_adapter(socket, opts)
54
56
  use_http2 = socket.respond_to?(:alpn_protocol) &&
55
57
  socket.alpn_protocol == H2_PROTOCOL
56
- klass = use_http2 ? HTTP2Adapter : HTTP1Adapter#New
58
+ klass = use_http2 ? HTTP2Adapter : HTTP1Adapter
57
59
  klass.new(socket, opts)
58
60
  end
59
61
 
60
62
  def route(&block)
61
63
  proc { |req| req.route(&block) }
62
64
  end
65
+
66
+ CERTIFICATE_STORE_DEFAULT_DIR = File.expand_path('~/.tipi')
67
+ CERTIFICATE_STORE_DEFAULT_DB_PATH = File.join(
68
+ CERTIFICATE_STORE_DEFAULT_DIR, 'certificates.db'
69
+ )
70
+
71
+ def default_certificate_store
72
+ FileUtils.mkdir(CERTIFICATE_STORE_DEFAULT_DIR) rescue nil
73
+ Tipi::ACME::SQLiteCertificateStore.new(CERTIFICATE_STORE_DEFAULT_DB_PATH)
74
+ end
75
+
76
+ def full_service(
77
+ http_port: 10080,
78
+ https_port: 10443,
79
+ certificate_store: default_certificate_store,
80
+ app: nil, &block
81
+ )
82
+ app ||= block
83
+ raise "No app given" unless app
84
+
85
+ http_handler = ->(r) { r.redirect("https://#{r.host}#{r.path}") }
86
+
87
+ ctx = OpenSSL::SSL::SSLContext.new
88
+ # ctx.ciphers = 'ECDH+aRSA'
89
+ Polyphony::Net.setup_alpn(ctx, Tipi::ALPN_PROTOCOLS)
90
+
91
+ challenge_handler = Tipi::ACME::HTTPChallengeHandler.new
92
+ certificate_manager = Tipi::ACME::CertificateManager.new(
93
+ master_ctx: ctx,
94
+ store: certificate_store,
95
+ challenge_handler: challenge_handler
96
+ )
97
+
98
+ http_listener = spin do
99
+ opts = {
100
+ reuse_addr: true,
101
+ reuse_port: true,
102
+ dont_linger: true,
103
+ }
104
+ puts "Listening for HTTP on localhost:#{http_port}"
105
+ server = Polyphony::Net.tcp_listen('0.0.0.0', http_port, opts)
106
+ wrapped_handler = certificate_manager.challenge_routing_app(http_handler)
107
+ server.accept_loop do |client|
108
+ spin do
109
+ Tipi.client_loop(client, opts, &wrapped_handler)
110
+ rescue => e
111
+ puts "Uncaught error in HTTP listener: #{e.inspect}"
112
+ end
113
+ end
114
+ ensure
115
+ server.close
116
+ end
117
+
118
+ https_listener = spin do
119
+ opts = {
120
+ reuse_addr: true,
121
+ reuse_port: true,
122
+ dont_linger: true,
123
+ secure_context: ctx,
124
+ }
125
+
126
+ puts "Listening for HTTPS on localhost:#{https_port}"
127
+ server = Polyphony::Net.tcp_listen('0.0.0.0', https_port, opts)
128
+ loop do
129
+ client = server.accept
130
+ spin do
131
+ Tipi.client_loop(client, opts, &app)
132
+ rescue => e
133
+ puts "Uncaught error in HTTPS listener: #{e.inspect}"
134
+ end
135
+ rescue OpenSSL::SSL::SSLError, SystemCallError, TypeError
136
+ # ignore
137
+ end
138
+ ensure
139
+ server.close
140
+ end
141
+
142
+ Fiber.await(http_listener, https_listener)
143
+ end
63
144
  end
64
145
  end
data/lib/tipi/acme.rb ADDED
@@ -0,0 +1,308 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'acme-client'
5
+ require 'localhost/authority'
6
+
7
+ module Tipi
8
+ module ACME
9
+ class Error < StandardError
10
+ end
11
+
12
+ class CertificateManager
13
+ def initialize(master_ctx:, store:, challenge_handler:)
14
+ @master_ctx = master_ctx
15
+ @store = store
16
+ @challenge_handler = challenge_handler
17
+ @contexts = {}
18
+ @requests = Polyphony::Queue.new
19
+ @worker = spin { run }
20
+ setup_sni_callback
21
+ end
22
+
23
+ ACME_CHALLENGE_PATH_REGEXP = /\/\.well\-known\/acme\-challenge/.freeze
24
+
25
+ def challenge_routing_app(app)
26
+ ->(req) do
27
+ (req.path =~ ACME_CHALLENGE_PATH_REGEXP ? @challenge_handler : app)
28
+ .(req)
29
+ rescue => e
30
+ puts "Error while handling request: #{e.inspect} (headers: #{req.headers})"
31
+ req.respond(nil, ':status' => Qeweney::Status::BAD_REQUEST)
32
+ end
33
+ end
34
+
35
+ IP_REGEXP = /^\d+\.\d+\.\d+\.\d+$/
36
+
37
+ def setup_sni_callback
38
+ @master_ctx.servername_cb = proc do |_socket, name|
39
+ p servername_cb: name
40
+ state = { ctx: nil }
41
+
42
+ if name =~ IP_REGEXP
43
+ @master_ctx
44
+ else
45
+ @requests << [name, state]
46
+ wait_for_ctx(state)
47
+ p name: name, error: state if state[:error]
48
+ # Eventually we might want to return an error returned in
49
+ # state[:error]. For the time being we handle errors by returning the
50
+ # master context
51
+ state[:ctx] || @master_ctx
52
+ end
53
+ end
54
+ end
55
+
56
+ def wait_for_ctx(state)
57
+ period = 0.00001
58
+ while !state[:ctx] && !state[:error]
59
+ orig_sleep period
60
+ period *= 2 if period < 0.1
61
+ end
62
+ end
63
+
64
+ def run
65
+ loop do
66
+ name, state = @requests.shift
67
+ state[:ctx] = get_context(name)
68
+ rescue => e
69
+ state[:error] = e if state
70
+ end
71
+ end
72
+
73
+ LOCALHOST_REGEXP = /\.?localhost$/.freeze
74
+
75
+ def get_context(name)
76
+ @contexts[name] = setup_context(name)
77
+ end
78
+
79
+ def setup_context(name)
80
+ ctx = provision_context(name)
81
+ transfer_ctx_settings(ctx)
82
+ ctx
83
+ end
84
+
85
+ def provision_context(name)
86
+ return localhost_context if name =~ LOCALHOST_REGEXP
87
+
88
+ info = get_certificate(name)
89
+ ctx = OpenSSL::SSL::SSLContext.new
90
+ chain = parse_certificate(info[:certificate])
91
+ cert = chain.shift
92
+ ctx.add_certificate(cert, info[:private_key], chain)
93
+ ctx
94
+ end
95
+
96
+ def transfer_ctx_settings(ctx)
97
+ ctx.alpn_protocols = @master_ctx.alpn_protocols
98
+ ctx.alpn_select_cb = @master_ctx.alpn_select_cb
99
+ ctx.ciphers = @master_ctx.ciphers
100
+ end
101
+
102
+ CERTIFICATE_REGEXP = /(-----BEGIN CERTIFICATE-----\n[^-]+-----END CERTIFICATE-----\n)/.freeze
103
+
104
+ def parse_certificate(certificate)
105
+ certificate
106
+ .scan(CERTIFICATE_REGEXP)
107
+ .map { |p| OpenSSL::X509::Certificate.new(p.first) }
108
+ end
109
+
110
+ def get_expired_stamp(certificate)
111
+ chain = parse_certificate(certificate)
112
+ cert = chain.shift
113
+ cert.not_after
114
+ end
115
+
116
+ def get_certificate(name)
117
+ entry = @store.get(name)
118
+ return entry if entry
119
+
120
+ provision_certificate(name).tap do |entry|
121
+ @store.set(name, **entry)
122
+ end
123
+ end
124
+
125
+ def localhost_context
126
+ @localhost_authority ||= Localhost::Authority.fetch
127
+ @localhost_authority.server_context
128
+ end
129
+
130
+ def private_key
131
+ @private_key ||= OpenSSL::PKey::RSA.new(4096)
132
+ end
133
+
134
+ ACME_DIRECTORY = 'https://acme-v02.api.letsencrypt.org/directory'
135
+
136
+ def acme_client
137
+ @acme_client ||= setup_acme_client
138
+ end
139
+
140
+ def setup_acme_client
141
+ client = Acme::Client.new(
142
+ private_key: private_key,
143
+ directory: ACME_DIRECTORY
144
+ )
145
+ account = client.new_account(
146
+ contact: 'mailto:info@noteflakes.com',
147
+ terms_of_service_agreed: true
148
+ )
149
+ client
150
+ end
151
+
152
+ def provision_certificate(name)
153
+ p provision_certificate: name
154
+ order = acme_client.new_order(identifiers: [name])
155
+ authorization = order.authorizations.first
156
+ challenge = authorization.http
157
+
158
+ @challenge_handler.add(challenge)
159
+ challenge.request_validation
160
+ while challenge.status == 'pending'
161
+ sleep(0.25)
162
+ challenge.reload
163
+ end
164
+ raise ACME::Error, "Invalid CSR" if challenge.status == 'invalid'
165
+
166
+ p challenge_status: challenge.status
167
+ private_key = OpenSSL::PKey::RSA.new(4096)
168
+ csr = Acme::Client::CertificateRequest.new(
169
+ private_key: private_key,
170
+ subject: { common_name: name }
171
+ )
172
+ order.finalize(csr: csr)
173
+ while order.status == 'processing'
174
+ sleep(0.25)
175
+ order.reload
176
+ end
177
+ certificate = begin
178
+ order.certificate(force_chain: 'DST Root CA X3')
179
+ rescue Acme::Client::Error::ForcedChainNotFound
180
+ order.certificate
181
+ end
182
+ expired_stamp = get_expired_stamp(certificate)
183
+ puts "Certificate for #{name} expires: #{expired_stamp.inspect}"
184
+
185
+ {
186
+ private_key: private_key,
187
+ certificate: certificate,
188
+ expired_stamp: expired_stamp
189
+ }
190
+ end
191
+ end
192
+
193
+ class HTTPChallengeHandler
194
+ def initialize
195
+ @challenges = {}
196
+ end
197
+
198
+ def add(challenge)
199
+ path = "/.well-known/acme-challenge/#{challenge.token}"
200
+ @challenges[path] = challenge
201
+ end
202
+
203
+ def remove(challenge)
204
+ path = "/.well-known/acme-challenge/#{challenge.token}"
205
+ @challenges.delete(path)
206
+ end
207
+
208
+ def call(req)
209
+ challenge = @challenges[req.path]
210
+
211
+ # handle incoming request
212
+ challenge = @challenges[req.path]
213
+ return req.respond(nil, ':status' => 400) unless challenge
214
+
215
+ req.respond(challenge.file_content, 'content-type' => challenge.content_type)
216
+ end
217
+ end
218
+
219
+ class CertificateStore
220
+ def set(name, private_key:, certificate:, expired_stamp:)
221
+ raise NotImplementedError
222
+ end
223
+
224
+ def get(name)
225
+ raise NotImplementedError
226
+ end
227
+ end
228
+
229
+ class InMemoryCertificateStore
230
+ def initialize
231
+ @store = {}
232
+ end
233
+
234
+ def set(name, private_key:, certificate:, expired_stamp:)
235
+ @store[name] = {
236
+ private_key: private_key,
237
+ certificate: certificate,
238
+ expired_stamp: expired_stamp
239
+ }
240
+ end
241
+
242
+ def get(name)
243
+ entry = @store[name]
244
+ return nil unless entry
245
+ if Time.now >= entry[:expired_stamp]
246
+ @store.delete(name)
247
+ return nil
248
+ end
249
+
250
+ entry
251
+ end
252
+ end
253
+
254
+ class SQLiteCertificateStore
255
+ attr_reader :db
256
+
257
+ def initialize(path)
258
+ require 'extralite'
259
+
260
+ @db = Extralite::Database.new(path)
261
+ @db.query("
262
+ create table if not exists certificates (
263
+ name primary key not null,
264
+ private_key not null,
265
+ certificate not null,
266
+ expired_stamp not null
267
+ );"
268
+ )
269
+ end
270
+
271
+ def set(name, private_key:, certificate:, expired_stamp:)
272
+ @db.query("
273
+ insert into certificates values (?, ?, ?, ?)
274
+ ", name, private_key.to_s, certificate, expired_stamp.to_i)
275
+ rescue Extralite::Error => e
276
+ p error_in_set: e
277
+ raise e
278
+ end
279
+
280
+ def get(name)
281
+ remove_expired_certificates
282
+
283
+ entry = @db.query_single_row("
284
+ select name, private_key, certificate, expired_stamp
285
+ from certificates
286
+ where name = ?
287
+ ", name)
288
+ return nil unless entry
289
+ entry[:expired_stamp] = Time.at(entry[:expired_stamp])
290
+ entry[:private_key] = OpenSSL::PKey::RSA.new(entry[:private_key])
291
+ entry
292
+ rescue Extralite::Error => e
293
+ p error_in_get: e
294
+ raise e
295
+ end
296
+
297
+ def remove_expired_certificates
298
+ @db.query("
299
+ delete from certificates
300
+ where expired_stamp < ?
301
+ ", Time.now.to_i)
302
+ rescue Extralite::Error => e
303
+ p error_in_remove_expired_certificates: e
304
+ raise e
305
+ end
306
+ end
307
+ end
308
+ end
data/lib/tipi/cli.rb ADDED
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tipi'
4
+ require 'fileutils'
5
+
6
+ module Tipi
7
+ module CLI
8
+ BANNER = "
9
+ ooo
10
+ oo
11
+ o
12
+ \\|/ Tipi - a better web server for a better world
13
+ / \\
14
+ / \\ https://github.com/digital-fabric/tipi
15
+ ⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺
16
+ "
17
+
18
+ def self.start
19
+ display_banner
20
+ require File.expand_path(ARGV[0] || 'app.rb', FileUtils.pwd)
21
+ end
22
+
23
+ def self.display_banner
24
+ puts BANNER
25
+ puts
26
+ end
27
+ end
28
+ end
29
+
30
+ __END__