capsium 0.1.2 → 0.3.0
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/.gitignore +1 -0
- data/.rubocop.yml +46 -7
- data/CHANGELOG.md +183 -0
- data/Gemfile +6 -0
- data/LICENSE +21 -0
- data/README.adoc +496 -127
- data/Rakefile +8 -1
- data/bin/console +1 -1
- data/capsium.gemspec +12 -19
- data/exe/capsium +1 -1
- data/lib/capsium/cli/convert.rb +18 -0
- data/lib/capsium/cli/formatting.rb +27 -0
- data/lib/capsium/cli/package.rb +148 -0
- data/lib/capsium/cli/reactor.rb +33 -0
- data/lib/capsium/cli.rb +4 -96
- data/lib/capsium/converters/jekyll.rb +5 -7
- data/lib/capsium/converters.rb +7 -0
- data/lib/capsium/package/authentication.rb +38 -0
- data/lib/capsium/package/authentication_config.rb +78 -0
- data/lib/capsium/package/cipher.rb +197 -0
- data/lib/capsium/package/composition.rb +77 -0
- data/lib/capsium/package/dataset.rb +67 -34
- data/lib/capsium/package/dependency_resolver.rb +61 -0
- data/lib/capsium/package/encryption_config.rb +38 -0
- data/lib/capsium/package/manifest.rb +33 -22
- data/lib/capsium/package/manifest_config.rb +52 -7
- data/lib/capsium/package/merged_view.rb +168 -0
- data/lib/capsium/package/metadata.rb +4 -8
- data/lib/capsium/package/metadata_config.rb +97 -9
- data/lib/capsium/package/preparation.rb +81 -0
- data/lib/capsium/package/routes.rb +39 -41
- data/lib/capsium/package/routes_config.rb +174 -44
- data/lib/capsium/package/security.rb +140 -0
- data/lib/capsium/package/security_config.rb +60 -0
- data/lib/capsium/package/signer.rb +201 -0
- data/lib/capsium/package/storage.rb +20 -45
- data/lib/capsium/package/storage_config.rb +128 -3
- data/lib/capsium/package/store.rb +109 -0
- data/lib/capsium/package/testing/config_test.rb +70 -0
- data/lib/capsium/package/testing/context.rb +11 -0
- data/lib/capsium/package/testing/data_validation_test.rb +63 -0
- data/lib/capsium/package/testing/file_test.rb +45 -0
- data/lib/capsium/package/testing/report.rb +33 -0
- data/lib/capsium/package/testing/route_test.rb +64 -0
- data/lib/capsium/package/testing/test_case.rb +60 -0
- data/lib/capsium/package/testing/test_suite.rb +86 -0
- data/lib/capsium/package/testing.rb +20 -0
- data/lib/capsium/package/validator.rb +160 -0
- data/lib/capsium/package/verification.rb +32 -0
- data/lib/capsium/package/version.rb +204 -0
- data/lib/capsium/package.rb +92 -106
- data/lib/capsium/packager.rb +75 -6
- data/lib/capsium/reactor/authenticator.rb +180 -0
- data/lib/capsium/reactor/deploy.rb +71 -0
- data/lib/capsium/reactor/htpasswd.rb +154 -0
- data/lib/capsium/reactor/introspection.rb +91 -0
- data/lib/capsium/reactor/oauth2.rb +106 -0
- data/lib/capsium/reactor/serving.rb +75 -0
- data/lib/capsium/reactor/session.rb +82 -0
- data/lib/capsium/reactor.rb +93 -43
- data/lib/capsium/thor_ext.rb +1 -1
- data/lib/capsium/version.rb +1 -1
- data/lib/capsium.rb +7 -9
- data/sig/capsium/package/authentication.rbs +23 -0
- data/sig/capsium/package/authentication_config.rbs +71 -0
- data/sig/capsium/package/cipher.rbs +51 -0
- data/sig/capsium/package/composition.rbs +20 -0
- data/sig/capsium/package/dataset.rbs +28 -0
- data/sig/capsium/package/dependency_resolver.rbs +49 -0
- data/sig/capsium/package/encryption_config.rbs +35 -0
- data/sig/capsium/package/manifest.rbs +34 -0
- data/sig/capsium/package/manifest_config.rbs +38 -0
- data/sig/capsium/package/merged_view.rbs +46 -0
- data/sig/capsium/package/metadata.rbs +26 -0
- data/sig/capsium/package/metadata_config.rbs +49 -0
- data/sig/capsium/package/preparation.rbs +23 -0
- data/sig/capsium/package/routes.rbs +37 -0
- data/sig/capsium/package/routes_config.rbs +107 -0
- data/sig/capsium/package/security.rbs +72 -0
- data/sig/capsium/package/security_config.rbs +57 -0
- data/sig/capsium/package/signer.rbs +59 -0
- data/sig/capsium/package/storage.rbs +28 -0
- data/sig/capsium/package/storage_config.rbs +85 -0
- data/sig/capsium/package/store.rbs +37 -0
- data/sig/capsium/package/testing/config_test.rbs +21 -0
- data/sig/capsium/package/testing/context.rbs +14 -0
- data/sig/capsium/package/testing/data_validation_test.rbs +22 -0
- data/sig/capsium/package/testing/file_test.rbs +17 -0
- data/sig/capsium/package/testing/report.rbs +20 -0
- data/sig/capsium/package/testing/route_test.rbs +22 -0
- data/sig/capsium/package/testing/test_case.rbs +41 -0
- data/sig/capsium/package/testing/test_suite.rbs +20 -0
- data/sig/capsium/package/testing.rbs +7 -0
- data/sig/capsium/package/validator.rbs +29 -0
- data/sig/capsium/package/verification.rbs +24 -0
- data/sig/capsium/package/version.rbs +39 -0
- data/sig/capsium/package.rbs +61 -0
- data/sig/capsium/packager.rbs +32 -0
- data/sig/capsium/reactor/authenticator.rbs +40 -0
- data/sig/capsium/reactor/deploy.rbs +34 -0
- data/sig/capsium/reactor/htpasswd.rbs +36 -0
- data/sig/capsium/reactor/introspection.rbs +31 -0
- data/sig/capsium/reactor/oauth2.rbs +31 -0
- data/sig/capsium/reactor/serving.rbs +22 -0
- data/sig/capsium/reactor/session.rbs +36 -0
- data/sig/capsium/reactor.rbs +35 -0
- data/sig/capsium.rbs +3 -1
- metadata +108 -170
- data/lib/capsium/package/dataset_config.rb +0 -28
- data/lib/capsium/protector.rb +0 -103
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "bcrypt"
|
|
5
|
+
require "digest"
|
|
6
|
+
require "openssl"
|
|
7
|
+
|
|
8
|
+
module Capsium
|
|
9
|
+
class Reactor
|
|
10
|
+
# Apache htpasswd verification (05x-authentication). Supported hash
|
|
11
|
+
# formats:
|
|
12
|
+
# - bcrypt ($2a$/$2b$/$2y$, `htpasswd -B`) via the bcrypt gem
|
|
13
|
+
# - Apache apr1 MD5 ($apr1$, `htpasswd -m`) and md5-crypt ($1$),
|
|
14
|
+
# pure-Ruby md5-crypt
|
|
15
|
+
# - unsalted SHA-1 ({SHA}, `htpasswd -s`)
|
|
16
|
+
# - anything else (DES, $5$/$6$) via the platform's crypt(3) —
|
|
17
|
+
# support depends on the OS
|
|
18
|
+
class Htpasswd
|
|
19
|
+
# md5-crypt as deployed by Apache htpasswd, OpenSSL and glibc
|
|
20
|
+
# ($1$ and $apr1$ magics; identical except for the magic string).
|
|
21
|
+
# NOTE: Poul-Henning Kamp's original FreeBSD implementation mixed
|
|
22
|
+
# the magic in at the % 7 step where all deployed implementations
|
|
23
|
+
# mix in the password (see apr_md5.c, glibc crypt-md5.c).
|
|
24
|
+
module Md5Crypt
|
|
25
|
+
ITOA64 = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
|
26
|
+
DIGEST_GROUPS = [[0, 6, 12], [1, 7, 13], [2, 8, 14], [3, 9, 15], [4, 10, 5]].freeze
|
|
27
|
+
|
|
28
|
+
def self.verify(hash, password)
|
|
29
|
+
_prefix, magic, salt, = hash.split("$", 4)
|
|
30
|
+
return false if salt.nil? || salt.empty?
|
|
31
|
+
|
|
32
|
+
Reactor.secure_compare(digest(password, salt, "$#{magic}$"), hash)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.digest(password, salt, magic)
|
|
36
|
+
final = initial_digest(password, salt, magic)
|
|
37
|
+
1000.times do |i|
|
|
38
|
+
final = stretch_digest(i, password, salt, final)
|
|
39
|
+
end
|
|
40
|
+
"#{magic}#{salt}$#{to64(final)}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.initial_digest(password, salt, magic)
|
|
44
|
+
inner = Digest::MD5.digest(password + salt + password)
|
|
45
|
+
context = Digest::MD5.new
|
|
46
|
+
context << password << magic << salt
|
|
47
|
+
length = password.length
|
|
48
|
+
while length.positive?
|
|
49
|
+
context << inner[0, [16, length].min]
|
|
50
|
+
length -= 16
|
|
51
|
+
end
|
|
52
|
+
length = password.length
|
|
53
|
+
while length.positive?
|
|
54
|
+
context << (length.odd? ? "\0" : password[0])
|
|
55
|
+
length >>= 1
|
|
56
|
+
end
|
|
57
|
+
context.digest
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.stretch_digest(iteration, password, salt, final)
|
|
61
|
+
context = Digest::MD5.new
|
|
62
|
+
context << (iteration.odd? ? password : final)
|
|
63
|
+
context << salt unless (iteration % 3).zero?
|
|
64
|
+
context << password unless (iteration % 7).zero?
|
|
65
|
+
context << (iteration.odd? ? final : password)
|
|
66
|
+
context.digest
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The custom base-64 of md5-crypt: digest triples permuted, first
|
|
70
|
+
# index to the high bits, 22 characters total.
|
|
71
|
+
def self.to64(final)
|
|
72
|
+
encoded = +""
|
|
73
|
+
DIGEST_GROUPS.each do |a, b, c|
|
|
74
|
+
value = final[c].ord | (final[b].ord << 8) | (final[a].ord << 16)
|
|
75
|
+
4.times do
|
|
76
|
+
encoded << ITOA64[value & 0x3f]
|
|
77
|
+
value >>= 6
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
value = final[11].ord
|
|
81
|
+
2.times do
|
|
82
|
+
encoded << ITOA64[value & 0x3f]
|
|
83
|
+
value >>= 6
|
|
84
|
+
end
|
|
85
|
+
encoded
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
attr_reader :path
|
|
90
|
+
|
|
91
|
+
def initialize(path)
|
|
92
|
+
raise Error, "htpasswd file not found: #{path}" unless File.file?(path)
|
|
93
|
+
|
|
94
|
+
@path = path
|
|
95
|
+
@entries = parse(path)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def usernames
|
|
99
|
+
@entries.keys
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def verify?(username, password)
|
|
103
|
+
hash = @entries[username]
|
|
104
|
+
return false unless hash
|
|
105
|
+
|
|
106
|
+
verify_hash(hash, password)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def parse(path)
|
|
112
|
+
File.readlines(path, chomp: true).each_with_object({}) do |line, entries|
|
|
113
|
+
next if line.strip.empty? || line.start_with?("#")
|
|
114
|
+
|
|
115
|
+
username, hash = line.split(":", 2)
|
|
116
|
+
entries[username] = hash
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def verify_hash(hash, password)
|
|
121
|
+
case hash
|
|
122
|
+
when /\A\$2[aby]\$/ then verify_bcrypt(hash, password)
|
|
123
|
+
when /\A\$(?:apr1|1)\$/ then Md5Crypt.verify(hash, password)
|
|
124
|
+
when /\A\{SHA\}/ then verify_sha1(hash, password)
|
|
125
|
+
else verify_crypt(hash, password)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The bcrypt gem predates htpasswd's $2y$ tag; the variants are
|
|
130
|
+
# algorithmically identical, normalized here.
|
|
131
|
+
def verify_bcrypt(hash, password)
|
|
132
|
+
BCrypt::Password.new(hash.sub(/\A\$2y\$/, "$2a$")) == password
|
|
133
|
+
rescue BCrypt::Errors::InvalidHash
|
|
134
|
+
false
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def verify_sha1(hash, password)
|
|
138
|
+
digest = "{SHA}#{Base64.strict_encode64(Digest::SHA1.digest(password))}"
|
|
139
|
+
Reactor.secure_compare(digest, hash)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def verify_crypt(hash, password)
|
|
143
|
+
computed = password.crypt(hash)
|
|
144
|
+
computed && Reactor.secure_compare(computed, hash)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Constant-time string comparison (length-check first).
|
|
149
|
+
def self.secure_compare(own, theirs)
|
|
150
|
+
own.bytesize == theirs.bytesize &&
|
|
151
|
+
OpenSSL.fixed_length_secure_compare(own, theirs)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module Capsium
|
|
8
|
+
class Reactor
|
|
9
|
+
# Monitoring HTTP API reports for the package this reactor serves
|
|
10
|
+
# (ARCHITECTURE.md section 7). Each report wraps its single entry in
|
|
11
|
+
# the list shape all reactors converge on.
|
|
12
|
+
class Introspection
|
|
13
|
+
METADATA_PATH = "/api/v1/introspect/metadata"
|
|
14
|
+
ROUTES_PATH = "/api/v1/introspect/routes"
|
|
15
|
+
CONTENT_HASHES_PATH = "/api/v1/introspect/content-hashes"
|
|
16
|
+
CONTENT_VALIDITY_PATH = "/api/v1/introspect/content-validity"
|
|
17
|
+
PATHS = [METADATA_PATH, ROUTES_PATH, CONTENT_HASHES_PATH,
|
|
18
|
+
CONTENT_VALIDITY_PATH].freeze
|
|
19
|
+
|
|
20
|
+
attr_reader :package
|
|
21
|
+
|
|
22
|
+
def initialize(package)
|
|
23
|
+
@package = package
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def endpoint?(path)
|
|
27
|
+
PATHS.include?(path)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The report body for an introspection endpoint, or nil when the
|
|
31
|
+
# path is not an introspection endpoint.
|
|
32
|
+
def report_for(path)
|
|
33
|
+
case path
|
|
34
|
+
when METADATA_PATH then metadata_report
|
|
35
|
+
when ROUTES_PATH then routes_report
|
|
36
|
+
when CONTENT_HASHES_PATH then content_hashes_report
|
|
37
|
+
when CONTENT_VALIDITY_PATH then content_validity_report
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def metadata_report
|
|
42
|
+
metadata = package.metadata
|
|
43
|
+
{ packages: [{
|
|
44
|
+
name: metadata.name,
|
|
45
|
+
version: metadata.version,
|
|
46
|
+
author: metadata.author,
|
|
47
|
+
description: metadata.description
|
|
48
|
+
}] }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def routes_report
|
|
52
|
+
entries = package.routes.config.routes.map do |route|
|
|
53
|
+
{ method: route.http_method || "GET", path: route.path }
|
|
54
|
+
end
|
|
55
|
+
{ routes: [{ package: package.name, routes: entries }] }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def content_hashes_report
|
|
59
|
+
{ contentHashes: [{ package: package.name, hash: content_hash }] }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def content_validity_report
|
|
63
|
+
errors = package.verify_integrity
|
|
64
|
+
entry = {
|
|
65
|
+
package: package.name,
|
|
66
|
+
valid: errors.empty?,
|
|
67
|
+
lastChecked: Time.now.utc.iso8601,
|
|
68
|
+
signed: package.signed?,
|
|
69
|
+
encrypted: package.encrypted?
|
|
70
|
+
}
|
|
71
|
+
entry[:signatureValid] = package.verify_signature if package.signed?
|
|
72
|
+
entry[:reason] = errors.map(&:message).join("; ") unless errors.empty?
|
|
73
|
+
{ contentValidity: [entry] }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# SHA-256 of the .cap blob when the package was loaded from one.
|
|
79
|
+
# For directory sources there is no blob, so the hash covers a
|
|
80
|
+
# canonical (sorted-key) JSON serialization of the package content
|
|
81
|
+
# checksums — the same data security.json integrityChecks carry.
|
|
82
|
+
def content_hash
|
|
83
|
+
cap_file = package.cap_file_path
|
|
84
|
+
return Digest::SHA256.file(cap_file).hexdigest if cap_file
|
|
85
|
+
|
|
86
|
+
checksums = Package::Security.checksums_for(package.path)
|
|
87
|
+
Digest::SHA256.hexdigest(JSON.generate(checksums))
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module Capsium
|
|
9
|
+
class Reactor
|
|
10
|
+
# OAuth2 authorization-code flow client (05x-authentication), used by
|
|
11
|
+
# the Authenticator. Talks to the provider over net/http; the client
|
|
12
|
+
# secret never comes from the package. State is a random nonce signed
|
|
13
|
+
# with the session HMAC key (CSRF protection, self-contained).
|
|
14
|
+
class OAuth2
|
|
15
|
+
# The token exchange or userinfo fetch failed at the provider.
|
|
16
|
+
class FlowError < Capsium::Error; end
|
|
17
|
+
|
|
18
|
+
GRANT_TYPE = "authorization_code"
|
|
19
|
+
|
|
20
|
+
attr_reader :config
|
|
21
|
+
|
|
22
|
+
def initialize(config, client_secret:, session:, base_url: nil)
|
|
23
|
+
@config = config
|
|
24
|
+
@client_secret = client_secret
|
|
25
|
+
@session = session
|
|
26
|
+
@base_url = base_url
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The provider URL the login endpoint redirects the browser to.
|
|
30
|
+
def authorization_url(request)
|
|
31
|
+
params = {
|
|
32
|
+
"response_type" => "code",
|
|
33
|
+
"client_id" => @config.client_id,
|
|
34
|
+
"redirect_uri" => callback_uri(request),
|
|
35
|
+
"state" => generate_state
|
|
36
|
+
}
|
|
37
|
+
params["scope"] = @config.scopes.join(" ") unless @config.scopes.empty?
|
|
38
|
+
"#{@config.authorization_url}?#{URI.encode_www_form(params)}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def valid_state?(state)
|
|
42
|
+
nonce, signature = state.to_s.split(".", 2)
|
|
43
|
+
return false if nonce.nil? || nonce.empty? || signature.nil?
|
|
44
|
+
|
|
45
|
+
Reactor.secure_compare(@session.sign(nonce), signature)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Exchanges the authorization code and fetches the userinfo claims.
|
|
49
|
+
# Returns the raw userinfo hash; raises FlowError on provider
|
|
50
|
+
# errors.
|
|
51
|
+
def complete(code, request)
|
|
52
|
+
userinfo(exchange_code(code, callback_uri(request)))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def callback_uri(request)
|
|
56
|
+
"#{base_url_for(request)}#{@config.redirect_path}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def generate_state
|
|
62
|
+
nonce = SecureRandom.hex(16)
|
|
63
|
+
"#{nonce}.#{@session.sign(nonce)}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def base_url_for(request)
|
|
67
|
+
@base_url || "http://#{request['Host']}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def exchange_code(code, redirect_uri)
|
|
71
|
+
response = Net::HTTP.post_form(
|
|
72
|
+
URI(@config.token_url),
|
|
73
|
+
"grant_type" => GRANT_TYPE, "code" => code,
|
|
74
|
+
"redirect_uri" => redirect_uri, "client_id" => @config.client_id,
|
|
75
|
+
"client_secret" => @client_secret
|
|
76
|
+
)
|
|
77
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
78
|
+
raise FlowError, "token exchange failed (HTTP #{response.code})"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
JSON.parse(response.body).fetch("access_token") do
|
|
82
|
+
raise FlowError, "token response without access_token"
|
|
83
|
+
end
|
|
84
|
+
rescue JSON::ParserError
|
|
85
|
+
raise FlowError, "token response was not JSON"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def userinfo(access_token)
|
|
89
|
+
uri = URI(@config.userinfo_url)
|
|
90
|
+
response = Net::HTTP.start(uri.host, uri.port,
|
|
91
|
+
use_ssl: uri.scheme == "https") do |http|
|
|
92
|
+
http.get(uri.request_uri,
|
|
93
|
+
"authorization" => "Bearer #{access_token}",
|
|
94
|
+
"accept" => "application/json")
|
|
95
|
+
end
|
|
96
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
97
|
+
raise FlowError, "userinfo request failed (HTTP #{response.code})"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
JSON.parse(response.body)
|
|
101
|
+
rescue JSON::ParserError
|
|
102
|
+
raise FlowError, "userinfo response was not JSON"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "marcel"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module Capsium
|
|
7
|
+
class Reactor
|
|
8
|
+
# Serving of resolved routes (static files through the merged view,
|
|
9
|
+
# datasets, handler-route 501s), mixed into Reactor. Includes route
|
|
10
|
+
# inheritance processing per 05x-routing.
|
|
11
|
+
module Serving
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def serve_route(route, response)
|
|
15
|
+
case route.kind
|
|
16
|
+
when :dataset then serve_dataset(route.dataset, response)
|
|
17
|
+
when :resource then serve_file(route, response)
|
|
18
|
+
else respond_not_implemented(response)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def serve_file(route, response)
|
|
23
|
+
content_path = @merged_view.resolve(route.resource)
|
|
24
|
+
return respond_not_found(response) unless content_path
|
|
25
|
+
|
|
26
|
+
body, headers = inherited_processing(route, File.read(content_path),
|
|
27
|
+
headers_for(route))
|
|
28
|
+
response.status = 200
|
|
29
|
+
response["Content-Type"] = content_type_for(route, content_path)
|
|
30
|
+
headers.each { |name, value| response[name] = value }
|
|
31
|
+
response.body = body
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Route inheritance processing (05x-routing section "Route
|
|
35
|
+
# Inheritance"): responseRewrite replaces the body and/or overrides
|
|
36
|
+
# headers; responseHeaders are merged over the served headers.
|
|
37
|
+
# requestHeaders are parsed and exposed on the route for forwarding
|
|
38
|
+
# reactors; this reactor serves statically without an upstream, so
|
|
39
|
+
# they do not alter its responses.
|
|
40
|
+
def inherited_processing(route, body, headers)
|
|
41
|
+
rewrite = route.response_rewrite
|
|
42
|
+
if rewrite
|
|
43
|
+
body = rewrite.body unless rewrite.body.nil?
|
|
44
|
+
headers = headers.merge(rewrite.headers) if rewrite.headers
|
|
45
|
+
end
|
|
46
|
+
headers = headers.merge(route.response_headers) if route.response_headers
|
|
47
|
+
[body, headers]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The route's declared MIME type, detected from the resolved file
|
|
51
|
+
# for resources the manifest does not list (dependency content
|
|
52
|
+
# reached through inheritance, layer-only files).
|
|
53
|
+
def content_type_for(route, content_path)
|
|
54
|
+
route.mime(@package.manifest) ||
|
|
55
|
+
Marcel::MimeType.for(Pathname.new(content_path),
|
|
56
|
+
name: File.basename(content_path))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def headers_for(route)
|
|
60
|
+
route.headers || (@cache_control ? { "Cache-Control" => @cache_control } : {})
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def serve_dataset(dataset_name, response)
|
|
64
|
+
dataset = @package.storage.dataset(dataset_name)
|
|
65
|
+
if dataset
|
|
66
|
+
response.status = 200
|
|
67
|
+
response["Content-Type"] = "application/json"
|
|
68
|
+
response.body = JSON.generate(dataset.data)
|
|
69
|
+
else
|
|
70
|
+
respond_not_found(response)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "json"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
|
|
8
|
+
module Capsium
|
|
9
|
+
class Reactor
|
|
10
|
+
# HMAC-SHA256 signed session cookies (05x-authentication: "Session
|
|
11
|
+
# via signed cookie"). The cookie value is
|
|
12
|
+
# "base64url(JSON payload).hmac hex" — signed, not encrypted, so it
|
|
13
|
+
# carries only the identity claims. The signing secret comes from
|
|
14
|
+
# deploy.json (sessionSecret) or is generated and persisted
|
|
15
|
+
# reactor-side (never in the package).
|
|
16
|
+
class Session
|
|
17
|
+
COOKIE_NAME = "capsium_session"
|
|
18
|
+
SECRET_BYTES = 32
|
|
19
|
+
|
|
20
|
+
def self.hmac(secret, data)
|
|
21
|
+
OpenSSL::HMAC.hexdigest("SHA256", secret, data)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
attr_reader :secret
|
|
25
|
+
|
|
26
|
+
def initialize(secret: nil, state_file: nil)
|
|
27
|
+
@secret = secret || self.class.load_or_generate_secret(state_file)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# HMAC-SHA256 of data with the session secret (hex).
|
|
31
|
+
def sign(data)
|
|
32
|
+
self.class.hmac(@secret, data)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The persisted reactor-side secret: loaded from state_file when
|
|
36
|
+
# present, generated and written (mode 0600) otherwise.
|
|
37
|
+
def self.load_or_generate_secret(state_file)
|
|
38
|
+
raise Error, "session secret or state_file is required" unless state_file
|
|
39
|
+
|
|
40
|
+
return File.read(state_file).strip if File.file?(state_file)
|
|
41
|
+
|
|
42
|
+
secret = SecureRandom.hex(SECRET_BYTES)
|
|
43
|
+
File.write(state_file, secret, perm: 0o600)
|
|
44
|
+
secret
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def encode(payload)
|
|
48
|
+
data = Base64.urlsafe_encode64(JSON.generate(payload), padding: false)
|
|
49
|
+
"#{data}.#{self.class.hmac(@secret, data)}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The verified payload, or nil when the cookie is missing or the
|
|
53
|
+
# signature does not match.
|
|
54
|
+
def decode(cookie_value)
|
|
55
|
+
data, signature = cookie_value.to_s.split(".", 2)
|
|
56
|
+
return if data.nil? || signature.nil?
|
|
57
|
+
|
|
58
|
+
expected = self.class.hmac(@secret, data)
|
|
59
|
+
return unless Reactor.secure_compare(expected, signature)
|
|
60
|
+
|
|
61
|
+
JSON.parse(Base64.urlsafe_decode64(data))
|
|
62
|
+
rescue JSON::ParserError, ArgumentError
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The identity for a request's Cookie header, or nil.
|
|
67
|
+
def identity_from(request)
|
|
68
|
+
cookie = request["Cookie"].to_s.split(/;\s*/).find do |part|
|
|
69
|
+
part.start_with?("#{COOKIE_NAME}=")
|
|
70
|
+
end
|
|
71
|
+
return unless cookie
|
|
72
|
+
|
|
73
|
+
decode(cookie.delete_prefix("#{COOKIE_NAME}="))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# A Set-Cookie value establishing the identity session.
|
|
77
|
+
def cookie_for(identity)
|
|
78
|
+
"#{COOKIE_NAME}=#{encode(identity)}; Path=/; HttpOnly; SameSite=Lax"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
data/lib/capsium/reactor.rb
CHANGED
|
@@ -1,52 +1,86 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "webrick"
|
|
4
3
|
require "json"
|
|
5
4
|
require "listen"
|
|
6
|
-
require "
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
require "webrick"
|
|
7
7
|
|
|
8
8
|
module Capsium
|
|
9
9
|
class Reactor
|
|
10
|
+
autoload :Authenticator, "capsium/reactor/authenticator"
|
|
11
|
+
autoload :Deploy, "capsium/reactor/deploy"
|
|
12
|
+
autoload :Htpasswd, "capsium/reactor/htpasswd"
|
|
13
|
+
autoload :Introspection, "capsium/reactor/introspection"
|
|
14
|
+
autoload :OAuth2, "capsium/reactor/oauth2"
|
|
15
|
+
autoload :Serving, "capsium/reactor/serving"
|
|
16
|
+
autoload :Session, "capsium/reactor/session"
|
|
17
|
+
|
|
18
|
+
include Serving
|
|
19
|
+
|
|
10
20
|
DEFAULT_PORT = 8864
|
|
21
|
+
DEFAULT_CACHE_CONTROL = "public, max-age=31536000"
|
|
11
22
|
|
|
12
|
-
|
|
13
|
-
|
|
23
|
+
attr_reader :package, :package_path, :routes, :port, :cache_control,
|
|
24
|
+
:server, :server_thread, :introspection, :authenticator
|
|
14
25
|
|
|
15
|
-
def initialize(package:, port: DEFAULT_PORT,
|
|
16
|
-
|
|
26
|
+
def initialize(package:, port: DEFAULT_PORT,
|
|
27
|
+
cache_control: DEFAULT_CACHE_CONTROL, do_not_listen: false,
|
|
28
|
+
store: nil, deploy: nil)
|
|
29
|
+
@package = package.is_a?(String) ? Package.new(package, store: store) : package
|
|
17
30
|
@package_path = @package.path
|
|
31
|
+
@store = store
|
|
18
32
|
@port = port
|
|
33
|
+
@cache_control = cache_control
|
|
34
|
+
@deploy_config = Deploy.load(deploy)
|
|
19
35
|
setup_server(do_not_listen)
|
|
20
|
-
|
|
36
|
+
load_state
|
|
21
37
|
mount_routes
|
|
22
38
|
end
|
|
23
39
|
|
|
24
40
|
def serve
|
|
41
|
+
trap("INT") { shutdown_server }
|
|
25
42
|
@server_thread = start_server
|
|
26
43
|
start_listener
|
|
27
44
|
end
|
|
28
45
|
|
|
29
46
|
def handle_request(request, response)
|
|
47
|
+
if @authenticator.endpoint?(request.path)
|
|
48
|
+
@authenticator.serve_endpoint(request, response)
|
|
49
|
+
return
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
identity = @authenticator.authenticate(request)
|
|
53
|
+
return @authenticator.challenge(response) if @authenticator.enabled? && identity.nil?
|
|
54
|
+
return serve_introspection(request, response) if @introspection.endpoint?(request.path)
|
|
55
|
+
|
|
30
56
|
route = @routes.resolve(request.path)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
response.body = "Not Found"
|
|
57
|
+
return respond_not_found(response) unless route
|
|
58
|
+
|
|
59
|
+
case @authenticator.authorize(identity, route.access_control)
|
|
60
|
+
when :unauthenticated then return @authenticator.challenge(response)
|
|
61
|
+
when :forbidden then return respond_forbidden(response)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
serve_route(route, response)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def mount_routes
|
|
68
|
+
paths = @routes.config.routes.map(&:serving_path) +
|
|
69
|
+
Introspection::PATHS + @authenticator.endpoints
|
|
70
|
+
paths.each do |path|
|
|
71
|
+
@server.mount_proc(path.to_s) { |req, res| handle_request(req, res) }
|
|
47
72
|
end
|
|
48
73
|
end
|
|
49
74
|
|
|
75
|
+
def restart_server
|
|
76
|
+
@server.shutdown
|
|
77
|
+
@server_thread&.join
|
|
78
|
+
load_package
|
|
79
|
+
setup_server(false)
|
|
80
|
+
mount_routes
|
|
81
|
+
@server_thread = start_server
|
|
82
|
+
end
|
|
83
|
+
|
|
50
84
|
private
|
|
51
85
|
|
|
52
86
|
def setup_server(do_not_listen)
|
|
@@ -55,18 +89,8 @@ module Capsium
|
|
|
55
89
|
@server = WEBrick::HTTPServer.new(server_options)
|
|
56
90
|
end
|
|
57
91
|
|
|
58
|
-
def mount_routes
|
|
59
|
-
@routes.config.routes.each do |route|
|
|
60
|
-
path = route.path
|
|
61
|
-
@server.mount_proc(path.to_s) do |req, res|
|
|
62
|
-
handle_request(req, res)
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
|
|
67
92
|
def start_server
|
|
68
93
|
Thread.new do
|
|
69
|
-
trap("INT") { shutdown_server }
|
|
70
94
|
puts "Starting server on http://localhost:#{@port}"
|
|
71
95
|
@server.start
|
|
72
96
|
end
|
|
@@ -78,15 +102,6 @@ module Capsium
|
|
|
78
102
|
exit
|
|
79
103
|
end
|
|
80
104
|
|
|
81
|
-
def restart_server
|
|
82
|
-
@server.shutdown
|
|
83
|
-
@server_thread&.join
|
|
84
|
-
load_package
|
|
85
|
-
setup_server(false)
|
|
86
|
-
mount_routes
|
|
87
|
-
@server_thread = start_server
|
|
88
|
-
end
|
|
89
|
-
|
|
90
105
|
def start_listener
|
|
91
106
|
listener = Listen.to(@package_path) do |_modified, _added, _removed|
|
|
92
107
|
puts "Changes detected, reloading..."
|
|
@@ -99,8 +114,43 @@ module Capsium
|
|
|
99
114
|
end
|
|
100
115
|
|
|
101
116
|
def load_package
|
|
102
|
-
@package = Package.new(@package_path)
|
|
117
|
+
@package = Package.new(@package_path, store: @store)
|
|
118
|
+
load_state
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def load_state
|
|
103
122
|
@routes = @package.routes
|
|
123
|
+
@merged_view = @package.merged_view
|
|
124
|
+
@introspection = Introspection.new(@package)
|
|
125
|
+
@authenticator = Authenticator.new(
|
|
126
|
+
@package.authentication,
|
|
127
|
+
deploy: @deploy_config,
|
|
128
|
+
package_path: @package.path,
|
|
129
|
+
base_url: @deploy_config.base_url,
|
|
130
|
+
state_file: File.join(Dir.tmpdir, "capsium-#{@package.name}-session-secret")
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def serve_introspection(request, response)
|
|
135
|
+
return respond_method_not_allowed(response) unless request.request_method == "GET"
|
|
136
|
+
|
|
137
|
+
response.status = 200
|
|
138
|
+
response["Content-Type"] = "application/json"
|
|
139
|
+
response.body = JSON.generate(@introspection.report_for(request.path))
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def respond_not_found(response) = respond_text(response, 404, "Not Found")
|
|
143
|
+
|
|
144
|
+
def respond_forbidden(response) = respond_text(response, 403, "Forbidden")
|
|
145
|
+
|
|
146
|
+
def respond_not_implemented(response) = respond_text(response, 501, "Not Implemented")
|
|
147
|
+
|
|
148
|
+
def respond_method_not_allowed(response) = respond_text(response, 405, "Method Not Allowed")
|
|
149
|
+
|
|
150
|
+
def respond_text(response, status, body)
|
|
151
|
+
response.status = status
|
|
152
|
+
response["Content-Type"] = "text/plain"
|
|
153
|
+
response.body = body
|
|
104
154
|
end
|
|
105
155
|
end
|
|
106
156
|
end
|