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.
@@ -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 "request"
6
- require_relative "response"
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
- @max_connections = max_connections
32
- @app = App.new(@root)
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 = @mutex.synchronize do
159
- next false if @active >= @max_connections
160
-
161
- @active += 1
162
- true
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
- return reject(client) unless accepted
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
- Thread.new do
169
- Thread.current.report_on_exception = false
170
- handle(client)
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
- end
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.puts "Serving: #{root}"
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 localhost?(display_host)
220
- ["127.0.0.1", "localhost", "::1"].include?(display_host)
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
@@ -4,6 +4,7 @@ module Wsv
4
4
  module Status
5
5
  REASONS = {
6
6
  200 => "OK",
7
+ 204 => "No Content",
7
8
  206 => "Partial Content",
8
9
  301 => "Moved Permanently",
9
10
  304 => "Not Modified",
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wsv
4
- VERSION = "0.9.0"
4
+ VERSION = "0.10.1"
5
5
  end
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