tipi 0.41 → 0.42

Sign up to get free protection for your applications and to get access to all the features.
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__