wsv 0.9.0 → 0.10.1
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 +61 -0
- data/README.md +79 -19
- data/lib/wsv/app.rb +39 -4
- data/lib/wsv/cli.rb +49 -3
- data/lib/wsv/cors.rb +30 -0
- data/lib/wsv/path_resolver.rb +6 -0
- data/lib/wsv/request/parser.rb +2 -2
- data/lib/wsv/request/too_large.rb +18 -0
- data/lib/wsv/request.rb +1 -9
- data/lib/wsv/response/file_builder.rb +6 -10
- data/lib/wsv/response.rb +9 -0
- data/lib/wsv/server/banner.rb +53 -0
- data/lib/wsv/server/browser_launcher.rb +57 -0
- data/lib/wsv/server/connection.rb +96 -0
- data/lib/wsv/server/connection_throttle.rb +50 -0
- data/lib/wsv/server/deadline_reader.rb +23 -0
- data/lib/wsv/server/url_host.rb +16 -0
- data/lib/wsv/server.rb +56 -122
- data/lib/wsv/status.rb +1 -0
- data/lib/wsv/tls_context/resolver.rb +71 -0
- data/lib/wsv/tls_context/self_signed_cert.rb +38 -0
- data/lib/wsv/tls_context.rb +29 -0
- data/lib/wsv/version.rb +1 -1
- data/lib/wsv.rb +2 -0
- data/test/app_test.rb +194 -0
- data/test/browser_launcher_test.rb +57 -0
- data/test/cli_test.rb +64 -0
- data/test/response_test.rb +8 -0
- data/test/server_test.rb +57 -2
- data/test/tls_context_test.rb +169 -0
- data/wsv.gemspec +7 -6
- metadata +23 -9
- /data/{bin → exe}/wsv +0 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
require_relative "url_host"
|
|
5
|
+
|
|
6
|
+
module Wsv
|
|
7
|
+
class Server
|
|
8
|
+
# Launches the OS default browser at the served URL when `--open` is set.
|
|
9
|
+
# Best-effort: unsupported platforms or spawn failures are logged but
|
|
10
|
+
# never abort the server.
|
|
11
|
+
class BrowserLauncher
|
|
12
|
+
def initialize(host:, port:, tls:, err:)
|
|
13
|
+
@host = host
|
|
14
|
+
@port = port
|
|
15
|
+
@tls = tls
|
|
16
|
+
@err = err
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def launch
|
|
20
|
+
command = platform_command
|
|
21
|
+
unless command
|
|
22
|
+
@err.puts "wsv: --open is not supported on this platform; skipping."
|
|
23
|
+
return
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
pid = Process.spawn(*command, url, in: :close, out: File::NULL, err: File::NULL)
|
|
27
|
+
Process.detach(pid)
|
|
28
|
+
rescue StandardError => e
|
|
29
|
+
@err.puts "wsv: failed to open browser: #{e.message}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def url
|
|
35
|
+
scheme = @tls ? "https" : "http"
|
|
36
|
+
"#{scheme}://#{UrlHost.format(display_host)}:#{@port}/"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def display_host
|
|
40
|
+
# Wildcard binds aren't reachable; redirect to the matching loopback.
|
|
41
|
+
case @host
|
|
42
|
+
when "0.0.0.0" then "127.0.0.1"
|
|
43
|
+
when "::" then "::1"
|
|
44
|
+
else @host
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def platform_command
|
|
49
|
+
case RbConfig::CONFIG["host_os"]
|
|
50
|
+
when /darwin/ then ["open"]
|
|
51
|
+
when /linux|bsd/ then ["xdg-open"]
|
|
52
|
+
when /mswin|mingw|cygwin/ then ["cmd.exe", "/c", "start", ""]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "deadline_reader"
|
|
4
|
+
require_relative "../request"
|
|
5
|
+
require_relative "../response"
|
|
6
|
+
|
|
7
|
+
module Wsv
|
|
8
|
+
class Server
|
|
9
|
+
# Owns a single accepted client socket. `serve` runs the request lifecycle
|
|
10
|
+
# (parse → app → write → drain → close); `reject` writes 503 (when allowed)
|
|
11
|
+
# and closes. Both share the safe-write / drain / close primitives so a
|
|
12
|
+
# broken peer cannot leak a connection or mask errors.
|
|
13
|
+
class Connection
|
|
14
|
+
DRAIN_TIMEOUT = 5
|
|
15
|
+
|
|
16
|
+
def initialize(client, err:)
|
|
17
|
+
@client = client
|
|
18
|
+
@err = err
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def serve(app, read_timeout:)
|
|
22
|
+
reader = DeadlineReader.new(@client, Time.now + read_timeout)
|
|
23
|
+
request = Request.parse(reader)
|
|
24
|
+
case request
|
|
25
|
+
when :empty
|
|
26
|
+
nil
|
|
27
|
+
when :malformed
|
|
28
|
+
write(Response.text(400))
|
|
29
|
+
else
|
|
30
|
+
write(app.call(request))
|
|
31
|
+
end
|
|
32
|
+
rescue Request::TooLarge => e
|
|
33
|
+
write(Response.text(e.status_code))
|
|
34
|
+
rescue IO::TimeoutError
|
|
35
|
+
write(Response.text(408))
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
# Treat unmapped failures as connection-scoped and close with 400 rather
|
|
38
|
+
# than letting one bad request path bring down the server.
|
|
39
|
+
@err.puts "wsv: #{e.class}: #{e.message}"
|
|
40
|
+
write(Response.text(400))
|
|
41
|
+
ensure
|
|
42
|
+
graceful_close
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def reject(reply:)
|
|
46
|
+
write(Response.text(503)) if reply
|
|
47
|
+
ensure
|
|
48
|
+
graceful_close
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def write(response)
|
|
54
|
+
return if @client.closed?
|
|
55
|
+
|
|
56
|
+
response.write_to(@client)
|
|
57
|
+
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def graceful_close
|
|
62
|
+
return if @client.closed?
|
|
63
|
+
|
|
64
|
+
drain_recv
|
|
65
|
+
rescue StandardError
|
|
66
|
+
nil
|
|
67
|
+
ensure
|
|
68
|
+
begin
|
|
69
|
+
@client.close unless @client.closed?
|
|
70
|
+
rescue StandardError
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def drain_recv
|
|
76
|
+
deadline = Time.now + DRAIN_TIMEOUT
|
|
77
|
+
loop do
|
|
78
|
+
return if Time.now >= deadline
|
|
79
|
+
|
|
80
|
+
chunk = @client.read_nonblock(8192, exception: false)
|
|
81
|
+
case chunk
|
|
82
|
+
when nil, :wait_writable
|
|
83
|
+
# nil = EOF. :wait_writable can come back from SSLSocket during a
|
|
84
|
+
# renegotiation (read needs an underlying write). Either way,
|
|
85
|
+
# there's nothing more we can usefully drain right now.
|
|
86
|
+
return
|
|
87
|
+
when :wait_readable
|
|
88
|
+
remaining = deadline - Time.now
|
|
89
|
+
return if remaining <= 0
|
|
90
|
+
return unless @client.wait_readable([remaining, 0.2].min)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wsv
|
|
4
|
+
class Server
|
|
5
|
+
# Caps in-flight connections at `max`. `try_spawn` runs the block in a new
|
|
6
|
+
# thread when capacity is available and returns true; otherwise returns
|
|
7
|
+
# false so the caller can reject the client.
|
|
8
|
+
class ConnectionThrottle
|
|
9
|
+
def initialize(max:, err:)
|
|
10
|
+
@max = max
|
|
11
|
+
@err = err
|
|
12
|
+
@mutex = Mutex.new
|
|
13
|
+
@active = 0
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def try_spawn(&block)
|
|
17
|
+
return false unless reserve_slot
|
|
18
|
+
|
|
19
|
+
begin
|
|
20
|
+
Thread.new do
|
|
21
|
+
Thread.current.report_on_exception = false
|
|
22
|
+
block.call
|
|
23
|
+
ensure
|
|
24
|
+
release_slot
|
|
25
|
+
end
|
|
26
|
+
true
|
|
27
|
+
rescue ThreadError => e
|
|
28
|
+
@err.puts "wsv: thread error: #{e.message}"
|
|
29
|
+
release_slot
|
|
30
|
+
false
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def reserve_slot
|
|
37
|
+
@mutex.synchronize do
|
|
38
|
+
next false if @active >= @max
|
|
39
|
+
|
|
40
|
+
@active += 1
|
|
41
|
+
true
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def release_slot
|
|
46
|
+
@mutex.synchronize { @active -= 1 }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wsv
|
|
4
|
+
class Server
|
|
5
|
+
# Wraps an IO with a shared deadline so each subsequent read is bounded by
|
|
6
|
+
# the time remaining until the deadline. Used to enforce a single budget
|
|
7
|
+
# across the request line and all header lines.
|
|
8
|
+
class DeadlineReader
|
|
9
|
+
def initialize(io, deadline)
|
|
10
|
+
@io = io
|
|
11
|
+
@deadline = deadline
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def gets(eol, limit)
|
|
15
|
+
remaining = @deadline - Time.now
|
|
16
|
+
raise IO::TimeoutError if remaining <= 0
|
|
17
|
+
|
|
18
|
+
@io.to_io.timeout = remaining
|
|
19
|
+
@io.gets(eol, limit)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wsv
|
|
4
|
+
class Server
|
|
5
|
+
# Formats a host for inclusion in a URL. IPv6 literals are bracketed
|
|
6
|
+
# (RFC 3986); zone identifiers (`%eth0` etc.) are percent-encoded
|
|
7
|
+
# (RFC 6874).
|
|
8
|
+
module UrlHost
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def format(host)
|
|
12
|
+
host.include?(":") ? "[#{host.gsub('%', '%25')}]" : host
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
data/lib/wsv/server.rb
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "openssl"
|
|
3
4
|
require "socket"
|
|
4
5
|
require_relative "app"
|
|
5
|
-
require_relative "
|
|
6
|
-
require_relative "
|
|
6
|
+
require_relative "server/banner"
|
|
7
|
+
require_relative "server/browser_launcher"
|
|
8
|
+
require_relative "server/connection"
|
|
9
|
+
require_relative "server/connection_throttle"
|
|
7
10
|
|
|
8
11
|
module Wsv
|
|
9
12
|
class Server
|
|
10
13
|
DEFAULT_READ_TIMEOUT = 10
|
|
11
14
|
DEFAULT_MAX_CONNECTIONS = 8
|
|
12
|
-
DRAIN_TIMEOUT = 5
|
|
13
15
|
|
|
14
16
|
attr_reader :host, :port, :root
|
|
15
17
|
|
|
@@ -20,7 +22,11 @@ module Wsv
|
|
|
20
22
|
out: $stdout,
|
|
21
23
|
err: $stderr,
|
|
22
24
|
read_timeout: DEFAULT_READ_TIMEOUT,
|
|
23
|
-
max_connections: DEFAULT_MAX_CONNECTIONS
|
|
25
|
+
max_connections: DEFAULT_MAX_CONNECTIONS,
|
|
26
|
+
tls: nil,
|
|
27
|
+
spa: false,
|
|
28
|
+
open: false,
|
|
29
|
+
cors: false
|
|
24
30
|
)
|
|
25
31
|
@host = host
|
|
26
32
|
@port = port
|
|
@@ -28,11 +34,12 @@ module Wsv
|
|
|
28
34
|
@out = out
|
|
29
35
|
@err = err
|
|
30
36
|
@read_timeout = read_timeout
|
|
31
|
-
@
|
|
32
|
-
@
|
|
37
|
+
@tls = tls
|
|
38
|
+
@ssl_context = tls&.to_ssl_context
|
|
39
|
+
@open = open
|
|
40
|
+
@app = App.new(@root, spa: spa, cors: cors)
|
|
41
|
+
@throttle = ConnectionThrottle.new(max: max_connections, err: err)
|
|
33
42
|
@running = false
|
|
34
|
-
@mutex = Mutex.new
|
|
35
|
-
@active = 0
|
|
36
43
|
end
|
|
37
44
|
|
|
38
45
|
def start
|
|
@@ -40,6 +47,7 @@ module Wsv
|
|
|
40
47
|
@running = true
|
|
41
48
|
log_startup
|
|
42
49
|
trap_signals
|
|
50
|
+
open_in_browser if @open
|
|
43
51
|
accept_loop
|
|
44
52
|
ensure
|
|
45
53
|
close
|
|
@@ -50,84 +58,8 @@ module Wsv
|
|
|
50
58
|
close
|
|
51
59
|
end
|
|
52
60
|
|
|
53
|
-
def handle(client)
|
|
54
|
-
reader = DeadlineReader.new(client, Time.now + @read_timeout)
|
|
55
|
-
request = Request.parse(reader)
|
|
56
|
-
case request
|
|
57
|
-
when :empty
|
|
58
|
-
nil
|
|
59
|
-
when :malformed
|
|
60
|
-
write_response(client, Response.text(400))
|
|
61
|
-
else
|
|
62
|
-
write_response(client, @app.call(request))
|
|
63
|
-
end
|
|
64
|
-
rescue Request::TooLarge => e
|
|
65
|
-
write_response(client, Response.text(e.status_code))
|
|
66
|
-
rescue IO::TimeoutError
|
|
67
|
-
write_response(client, Response.text(408))
|
|
68
|
-
rescue StandardError => e
|
|
69
|
-
@err.puts "wsv: #{e.class}: #{e.message}"
|
|
70
|
-
write_response(client, Response.text(400))
|
|
71
|
-
ensure
|
|
72
|
-
graceful_close(client)
|
|
73
|
-
end
|
|
74
|
-
|
|
75
61
|
private
|
|
76
62
|
|
|
77
|
-
def write_response(client, response)
|
|
78
|
-
return if client.closed?
|
|
79
|
-
|
|
80
|
-
response.write_to(client)
|
|
81
|
-
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
82
|
-
nil
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def graceful_close(client)
|
|
86
|
-
return if client.closed?
|
|
87
|
-
|
|
88
|
-
drain_recv(client)
|
|
89
|
-
rescue StandardError
|
|
90
|
-
nil
|
|
91
|
-
ensure
|
|
92
|
-
begin
|
|
93
|
-
client.close unless client.closed?
|
|
94
|
-
rescue StandardError
|
|
95
|
-
nil
|
|
96
|
-
end
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def drain_recv(client)
|
|
100
|
-
deadline = Time.now + DRAIN_TIMEOUT
|
|
101
|
-
loop do
|
|
102
|
-
return if Time.now >= deadline
|
|
103
|
-
|
|
104
|
-
chunk = client.read_nonblock(8192, exception: false)
|
|
105
|
-
case chunk
|
|
106
|
-
when nil, :wait_writable
|
|
107
|
-
return
|
|
108
|
-
when :wait_readable
|
|
109
|
-
remaining = deadline - Time.now
|
|
110
|
-
return if remaining <= 0
|
|
111
|
-
return unless client.wait_readable([remaining, 0.2].min)
|
|
112
|
-
end
|
|
113
|
-
end
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
class DeadlineReader
|
|
117
|
-
def initialize(io, deadline)
|
|
118
|
-
@io = io
|
|
119
|
-
@deadline = deadline
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def gets(limit)
|
|
123
|
-
remaining = @deadline - Time.now
|
|
124
|
-
raise IO::TimeoutError if remaining <= 0
|
|
125
|
-
|
|
126
|
-
@io.timeout = remaining
|
|
127
|
-
@io.gets(limit)
|
|
128
|
-
end
|
|
129
|
-
end
|
|
130
|
-
|
|
131
63
|
def accept_loop
|
|
132
64
|
while @running
|
|
133
65
|
client = nil
|
|
@@ -155,33 +87,45 @@ module Wsv
|
|
|
155
87
|
end
|
|
156
88
|
|
|
157
89
|
def spawn_handler(client)
|
|
158
|
-
accepted = @
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
90
|
+
accepted = @throttle.try_spawn do
|
|
91
|
+
Connection.new(maybe_wrap_tls(client), err: @err).serve(@app, read_timeout: @read_timeout)
|
|
92
|
+
end
|
|
93
|
+
spawn_rejection(client) unless accepted
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Reject in a separate thread so a slow client cannot block accept_loop
|
|
97
|
+
# via Connection#graceful_close (up to Connection::DRAIN_TIMEOUT seconds).
|
|
98
|
+
# In TLS mode `client` is the raw TCPSocket before any handshake; writing
|
|
99
|
+
# a plaintext 503 would corrupt the TLS handshake the client is about to
|
|
100
|
+
# start, so suppress the reply in that case.
|
|
101
|
+
def spawn_rejection(client)
|
|
102
|
+
reply = !@ssl_context
|
|
103
|
+
Thread.new do
|
|
104
|
+
Thread.current.report_on_exception = false
|
|
105
|
+
Connection.new(client, err: @err).reject(reply: reply)
|
|
163
106
|
end
|
|
107
|
+
rescue ThreadError
|
|
108
|
+
Connection.new(client, err: @err).reject(reply: reply)
|
|
109
|
+
end
|
|
164
110
|
|
|
165
|
-
|
|
111
|
+
def maybe_wrap_tls(client)
|
|
112
|
+
return client unless @ssl_context
|
|
166
113
|
|
|
114
|
+
client.timeout = @read_timeout
|
|
115
|
+
ssl = OpenSSL::SSL::SSLSocket.new(client, @ssl_context)
|
|
116
|
+
ssl.sync_close = true
|
|
117
|
+
ssl.accept
|
|
118
|
+
ssl
|
|
119
|
+
rescue StandardError
|
|
120
|
+
# If wrapping or the handshake failed, `serve` is never called and
|
|
121
|
+
# its ensure does not get a chance to close the underlying socket.
|
|
122
|
+
# Close it here so we do not leak a TCPSocket per failed handshake.
|
|
167
123
|
begin
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
ensure
|
|
172
|
-
@mutex.synchronize { @active -= 1 }
|
|
173
|
-
end
|
|
174
|
-
rescue ThreadError => e
|
|
175
|
-
@err.puts "wsv: thread error: #{e.message}"
|
|
176
|
-
@mutex.synchronize { @active -= 1 }
|
|
177
|
-
reject(client)
|
|
124
|
+
client.close
|
|
125
|
+
rescue StandardError
|
|
126
|
+
nil
|
|
178
127
|
end
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
def reject(client)
|
|
182
|
-
write_response(client, Response.text(503))
|
|
183
|
-
ensure
|
|
184
|
-
graceful_close(client)
|
|
128
|
+
raise
|
|
185
129
|
end
|
|
186
130
|
|
|
187
131
|
def close
|
|
@@ -196,28 +140,18 @@ module Wsv
|
|
|
196
140
|
end
|
|
197
141
|
end
|
|
198
142
|
rescue ArgumentError
|
|
143
|
+
# Signal.trap raises ArgumentError when called from a context that
|
|
144
|
+
# cannot install signal handlers (e.g. embedded in a non-main thread,
|
|
145
|
+
# which is how tests start the server). Skip silently in that case.
|
|
199
146
|
nil
|
|
200
147
|
end
|
|
201
148
|
|
|
202
149
|
def log_startup
|
|
203
|
-
@out
|
|
204
|
-
@out.puts "Bind: #{url_for(host)}"
|
|
205
|
-
@out.puts "Local: #{url_for('127.0.0.1')}" unless localhost?(host)
|
|
206
|
-
@out.puts "Stop: Ctrl-C"
|
|
207
|
-
warn_public_bind unless localhost?(host)
|
|
208
|
-
end
|
|
209
|
-
|
|
210
|
-
def warn_public_bind
|
|
211
|
-
@err.puts "WARNING: binding to #{host} exposes #{root} on your network."
|
|
212
|
-
@err.puts " Pass --host 127.0.0.1 (or omit --host) for local-only access."
|
|
213
|
-
end
|
|
214
|
-
|
|
215
|
-
def url_for(display_host)
|
|
216
|
-
"http://#{display_host}:#{port}/"
|
|
150
|
+
Banner.new(host: host, port: port, root: root, out: @out, err: @err, tls: @tls).emit
|
|
217
151
|
end
|
|
218
152
|
|
|
219
|
-
def
|
|
220
|
-
|
|
153
|
+
def open_in_browser
|
|
154
|
+
BrowserLauncher.new(host: host, port: port, tls: @tls, err: @err).launch
|
|
221
155
|
end
|
|
222
156
|
end
|
|
223
157
|
end
|
data/lib/wsv/status.rb
CHANGED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module Wsv
|
|
6
|
+
class TlsContext
|
|
7
|
+
class Resolver
|
|
8
|
+
XDG_DIR = "wsv"
|
|
9
|
+
CERT_FILE = "cert.pem"
|
|
10
|
+
KEY_FILE = "key.pem"
|
|
11
|
+
|
|
12
|
+
def self.resolve(cert_path: nil, key_path: nil)
|
|
13
|
+
new(cert_path: cert_path, key_path: key_path).resolve
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(cert_path: nil, key_path: nil)
|
|
17
|
+
@cert_path = cert_path
|
|
18
|
+
@key_path = key_path
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def resolve
|
|
22
|
+
return from_files(@cert_path, @key_path) if @cert_path && @key_path
|
|
23
|
+
|
|
24
|
+
raise ArgumentError, "--cert and --key must be provided together" if @cert_path || @key_path
|
|
25
|
+
|
|
26
|
+
xdg = xdg_pair
|
|
27
|
+
return from_files(*xdg) if xdg
|
|
28
|
+
|
|
29
|
+
ephemeral
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def from_files(cert_path, key_path)
|
|
35
|
+
cert = OpenSSL::X509::Certificate.new(File.read(cert_path))
|
|
36
|
+
key = OpenSSL::PKey.read(File.read(key_path))
|
|
37
|
+
raise ArgumentError, "key file at #{key_path} does not contain a private key" unless key.private?
|
|
38
|
+
unless cert.check_private_key(key)
|
|
39
|
+
raise ArgumentError,
|
|
40
|
+
"TLS certificate at #{cert_path} does not match the key at #{key_path}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
TlsContext.new(cert: cert, key: key)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def ephemeral
|
|
47
|
+
key = OpenSSL::PKey::RSA.new(2048)
|
|
48
|
+
cert = SelfSignedCert.build(key)
|
|
49
|
+
TlsContext.new(cert: cert, key: key, ephemeral: true)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def xdg_pair
|
|
53
|
+
cert = File.join(xdg_base, XDG_DIR, CERT_FILE)
|
|
54
|
+
key = File.join(xdg_base, XDG_DIR, KEY_FILE)
|
|
55
|
+
cert_exists = File.exist?(cert)
|
|
56
|
+
key_exists = File.exist?(key)
|
|
57
|
+
return [cert, key] if cert_exists && key_exists
|
|
58
|
+
|
|
59
|
+
if cert_exists ^ key_exists
|
|
60
|
+
raise ArgumentError, "found only one of #{cert} / #{key} -- both must exist or neither"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def xdg_base
|
|
67
|
+
ENV["XDG_CONFIG_HOME"] || File.join(Dir.home, ".config")
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module Wsv
|
|
7
|
+
class TlsContext
|
|
8
|
+
class SelfSignedCert
|
|
9
|
+
SUBJECT = "/CN=localhost"
|
|
10
|
+
SAN = "DNS:localhost,IP:127.0.0.1,IP:::1"
|
|
11
|
+
VALIDITY_SECONDS = 365 * 24 * 60 * 60
|
|
12
|
+
|
|
13
|
+
def self.build(key)
|
|
14
|
+
new(key).build
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(key)
|
|
18
|
+
@key = key
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def build
|
|
22
|
+
cert = OpenSSL::X509::Certificate.new
|
|
23
|
+
cert.version = 2
|
|
24
|
+
cert.serial = SecureRandom.random_number((2**63) - 1) + 1
|
|
25
|
+
cert.subject = OpenSSL::X509::Name.parse(SUBJECT)
|
|
26
|
+
cert.issuer = cert.subject
|
|
27
|
+
cert.public_key = @key.public_key
|
|
28
|
+
cert.not_before = Time.now - 60
|
|
29
|
+
cert.not_after = Time.now + VALIDITY_SECONDS
|
|
30
|
+
ef = OpenSSL::X509::ExtensionFactory.new(cert, cert)
|
|
31
|
+
cert.add_extension(ef.create_extension("subjectAltName", SAN))
|
|
32
|
+
cert.add_extension(ef.create_extension("basicConstraints", "CA:FALSE", true))
|
|
33
|
+
cert.sign(@key, OpenSSL::Digest.new("SHA256"))
|
|
34
|
+
cert
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require_relative "tls_context/self_signed_cert"
|
|
5
|
+
require_relative "tls_context/resolver"
|
|
6
|
+
|
|
7
|
+
module Wsv
|
|
8
|
+
class TlsContext
|
|
9
|
+
attr_reader :cert, :key
|
|
10
|
+
|
|
11
|
+
def initialize(cert:, key:, ephemeral: false)
|
|
12
|
+
@cert = cert
|
|
13
|
+
@key = key
|
|
14
|
+
@ephemeral = ephemeral
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def ephemeral?
|
|
18
|
+
@ephemeral
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def to_ssl_context
|
|
22
|
+
ctx = OpenSSL::SSL::SSLContext.new
|
|
23
|
+
ctx.cert = @cert
|
|
24
|
+
ctx.key = @key
|
|
25
|
+
ctx.min_version = OpenSSL::SSL::TLS1_2_VERSION
|
|
26
|
+
ctx
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
data/lib/wsv/version.rb
CHANGED
data/lib/wsv.rb
CHANGED
|
@@ -6,7 +6,9 @@ require_relative "wsv/mime_types"
|
|
|
6
6
|
require_relative "wsv/path_resolver"
|
|
7
7
|
require_relative "wsv/request"
|
|
8
8
|
require_relative "wsv/response"
|
|
9
|
+
require_relative "wsv/cors"
|
|
9
10
|
require_relative "wsv/app"
|
|
11
|
+
require_relative "wsv/tls_context"
|
|
10
12
|
require_relative "wsv/server"
|
|
11
13
|
require_relative "wsv/cli"
|
|
12
14
|
|