portage-cli 0.6.4 → 0.7.3
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 +216 -0
- data/README.md +263 -7
- data/lib/portage/cli/buy.rb +529 -104
- data/lib/portage/cli/checkout_handoff.rb +6 -18
- data/lib/portage/cli/confidence_check.rb +127 -0
- data/lib/portage/cli/decisions.rb +72 -0
- data/lib/portage/cli/doctor.rb +104 -1
- data/lib/portage/cli/find.rb +10 -9
- data/lib/portage/cli/handoff_reconciler/wire_adapters.rb +50 -0
- data/lib/portage/cli/handoff_reconciler.rb +249 -0
- data/lib/portage/cli/handoff_spend_mode.rb +28 -0
- data/lib/portage/cli/handoff_wait_timeout.rb +49 -0
- data/lib/portage/cli/handoff_waiter.rb +103 -0
- data/lib/portage/cli/history.rb +23 -6
- data/lib/portage/cli/homepage_fetch.rb +41 -0
- data/lib/portage/cli/macos_notifier.rb +37 -0
- data/lib/portage/cli/notifier.rb +26 -27
- data/lib/portage/cli/payment_methods.rb +6 -4
- data/lib/portage/cli/permissive_authenticator.rb +16 -0
- data/lib/portage/cli/proxy_doctor.rb +149 -0
- data/lib/portage/cli/proxy_settings/password_ref.rb +48 -0
- data/lib/portage/cli/proxy_settings.rb +350 -0
- data/lib/portage/cli/reconcile_notifier.rb +65 -0
- data/lib/portage/cli/reconcile_notify.rb +38 -0
- data/lib/portage/cli/search_backends.rb +6 -4
- data/lib/portage/cli/setting.rb +44 -0
- data/lib/portage/cli/user_agent.rb +39 -0
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli/webmcp.rb +25 -0
- data/lib/portage/cli/webmcp_checkout_mode.rb +28 -0
- data/lib/portage/cli.rb +367 -27
- metadata +46 -5
|
@@ -5,6 +5,7 @@ require "uri"
|
|
|
5
5
|
require "net/http"
|
|
6
6
|
require "portage/ucp"
|
|
7
7
|
require "portage/ucp/client"
|
|
8
|
+
require "portage/ucp/support/connection"
|
|
8
9
|
|
|
9
10
|
# Buy::PermissiveAuthenticator (adapter-loopback auth) is used by
|
|
10
11
|
# #adapter_session below — not require_relative'd here to avoid a load
|
|
@@ -14,6 +15,7 @@ require "portage/ucp/client"
|
|
|
14
15
|
require_relative "payment_methods/keychain_backend"
|
|
15
16
|
require_relative "payment_methods/secret_service_backend"
|
|
16
17
|
require_relative "payment_methods/env_backend"
|
|
18
|
+
require_relative "user_agent"
|
|
17
19
|
|
|
18
20
|
module Portage
|
|
19
21
|
module Cli
|
|
@@ -199,7 +201,7 @@ module Portage
|
|
|
199
201
|
# entangled with cart/checkout-specific branching this only needs the
|
|
200
202
|
# session object from.
|
|
201
203
|
def discover_session(url)
|
|
202
|
-
native = Portage::Ucp::Client.discover(url)
|
|
204
|
+
native = Portage::Ucp::Client.discover(url, headers: UserAgent.headers)
|
|
203
205
|
native if native
|
|
204
206
|
rescue Portage::Ucp::Client::DiscoveryError
|
|
205
207
|
adapter_session(url)
|
|
@@ -221,9 +223,9 @@ module Portage
|
|
|
221
223
|
end
|
|
222
224
|
|
|
223
225
|
def fetch_homepage(uri)
|
|
224
|
-
response =
|
|
225
|
-
|
|
226
|
-
http.get(uri.request_uri,
|
|
226
|
+
response = Portage::Ucp::Support::Connection.start(uri, route: :payment, open_timeout: 5,
|
|
227
|
+
read_timeout: 5) do |http|
|
|
228
|
+
http.get(uri.request_uri, UserAgent.headers)
|
|
227
229
|
end
|
|
228
230
|
response.is_a?(Net::HTTPSuccess) ? [response.body, response.to_hash] : [nil, {}]
|
|
229
231
|
rescue StandardError
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
require "portage/ucp"
|
|
2
|
+
|
|
3
|
+
module Portage
|
|
4
|
+
module Cli
|
|
5
|
+
# Loopback buy against your own store needs *some* authenticator (§9
|
|
6
|
+
# rejects anonymous mutation by default) — since this process already
|
|
7
|
+
# has this platform's own credentials (that's the gate to even reach the
|
|
8
|
+
# adapter-fallback path, in both Buy and HandoffReconciler), authenticating
|
|
9
|
+
# this local CLI session is reasonable. Shared by both rather than
|
|
10
|
+
# defined once as a nested class of Buy, since HandoffReconciler's own
|
|
11
|
+
# reconnect needs it too and shouldn't reach into Buy's internals for it.
|
|
12
|
+
class PermissiveAuthenticator < Portage::Ucp::Authenticator
|
|
13
|
+
def call(_server_context) = :local_cli
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
require_relative "proxy_settings"
|
|
3
|
+
|
|
4
|
+
module Portage
|
|
5
|
+
module Cli
|
|
6
|
+
# Phase 2 of docs/plans/proxy-support.md's `doctor` checks for whatever
|
|
7
|
+
# `ProxySettings` resolved: each configured proxy's own reachability (a
|
|
8
|
+
# real CONNECT/GET through it, via Support::Connection — same code path
|
|
9
|
+
# a real request would use, not a re-implementation), whether a
|
|
10
|
+
# configured gateway answers at all, and the two warnings the plan calls
|
|
11
|
+
# out by name (plaintext credentials sitting in config.json, an
|
|
12
|
+
# intercepting proxy on the payment route). Every message redacts
|
|
13
|
+
# credentials the same way Support::Connection.redact does — never the
|
|
14
|
+
# real userinfo, in any finding this produces.
|
|
15
|
+
#
|
|
16
|
+
# Stays entirely separate from Doctor's own Phase 0 `proxy_finding`
|
|
17
|
+
# (the raw $http_proxy/$https_proxy env report) rather than replacing
|
|
18
|
+
# it — that one is still accurate and still worth showing for anyone who
|
|
19
|
+
# hasn't touched Phase 2 flags/config.json at all.
|
|
20
|
+
class ProxyDoctor
|
|
21
|
+
PROBE_URI = URI("https://example.com/").freeze
|
|
22
|
+
ROUTE_NAMES = %w[store search notify payment platform probe].freeze
|
|
23
|
+
|
|
24
|
+
def initialize(proxy_settings:, config: Config.load, timeout: 5, probe_uri: PROBE_URI)
|
|
25
|
+
@proxy_settings = proxy_settings
|
|
26
|
+
@config = config
|
|
27
|
+
@timeout = timeout
|
|
28
|
+
@probe_uri = probe_uri
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def findings
|
|
32
|
+
[*route_summary_findings, *reachability_findings, *plaintext_credential_findings,
|
|
33
|
+
*payment_intercept_findings].compact
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def routes = @proxy_settings.routes
|
|
39
|
+
|
|
40
|
+
# --- effective routing, credentials redacted -----------------------
|
|
41
|
+
|
|
42
|
+
def route_summary_findings
|
|
43
|
+
return [] unless anything_configured?
|
|
44
|
+
|
|
45
|
+
lines = ROUTE_NAMES.map { |name| "#{name}: #{effective_description(name)}" }
|
|
46
|
+
[Doctor::Finding.new(check: "proxy_routes", message: lines.join("; "))]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def direct_profile = Portage::Ucp::Support::ProxyConfig::Profile::DIRECT
|
|
50
|
+
|
|
51
|
+
def anything_configured?
|
|
52
|
+
routes.any? { |name, spec| name != "payment" || spec != direct_profile }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def effective_description(name)
|
|
56
|
+
return describe(routes[name]) if routes.key?(name)
|
|
57
|
+
return describe(routes["default"]) if routes.key?("default")
|
|
58
|
+
|
|
59
|
+
"unconfigured (falls back to HTTP_PROXY/HTTPS_PROXY env, or direct)"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def describe(spec)
|
|
63
|
+
spec.is_a?(Array) ? spec.map { |hop| describe_hop(hop) }.join(" -> ") : describe_hop(spec)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def describe_hop(hop)
|
|
67
|
+
return "direct" if hop.direct?
|
|
68
|
+
|
|
69
|
+
"#{hop.mode} #{redact(hop.url)}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def redact(url)
|
|
73
|
+
return "(none)" unless url
|
|
74
|
+
|
|
75
|
+
uri = URI(url)
|
|
76
|
+
userinfo = uri.userinfo ? "***@" : ""
|
|
77
|
+
"#{uri.scheme}://#{userinfo}#{uri.host}:#{uri.port}"
|
|
78
|
+
rescue URI::InvalidURIError
|
|
79
|
+
"(unparseable)"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# --- reachability ---------------------------------------------------
|
|
83
|
+
|
|
84
|
+
def reachability_findings
|
|
85
|
+
collect_profiles.filter_map { |profile| reachability_finding(profile) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def collect_profiles
|
|
89
|
+
hops = routes.values.flat_map { |spec| spec.is_a?(Array) ? spec : [spec] }
|
|
90
|
+
hops.reject(&:direct?).uniq(&:url)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def reachability_finding(profile)
|
|
94
|
+
probe(profile)
|
|
95
|
+
nil
|
|
96
|
+
rescue StandardError => e
|
|
97
|
+
Doctor::Finding.new(check: "proxy_reachability",
|
|
98
|
+
message: "#{profile.mode} proxy #{redact(profile.url)} is not reachable: " \
|
|
99
|
+
"#{e.class}: #{e.message}")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def probe(profile)
|
|
103
|
+
synthetic = Portage::Ucp::Support::ProxyConfig.new(routes: { "probe" => profile })
|
|
104
|
+
Portage::Ucp::Support::Connection.start(@probe_uri, route: "probe", proxy: synthetic,
|
|
105
|
+
open_timeout: @timeout, read_timeout: @timeout) do |http|
|
|
106
|
+
http.get(@probe_uri.request_uri, {})
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# --- warnings ---------------------------------------------------
|
|
111
|
+
|
|
112
|
+
def plaintext_credential_findings
|
|
113
|
+
offenders = credential_bearing_profiles.select { |profile| plaintext_password?(profile) }
|
|
114
|
+
return [] if offenders.empty?
|
|
115
|
+
|
|
116
|
+
[Doctor::Finding.new(check: "proxy_credentials",
|
|
117
|
+
message: "#{offenders.length} proxy profile(s) in config.json carry a plaintext " \
|
|
118
|
+
"password in their url — use password_ref (Keychain/Secret Service) or " \
|
|
119
|
+
"${ENV} instead.")]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def credential_bearing_profiles
|
|
123
|
+
raw = @config.get("proxy") || {}
|
|
124
|
+
[raw["default"], *raw.fetch("chains", {}).values.flatten, *raw.fetch("routes", {}).values].grep(Hash)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def plaintext_password?(profile)
|
|
128
|
+
uri = URI(profile["url"].to_s)
|
|
129
|
+
!uri.password.to_s.empty?
|
|
130
|
+
rescue URI::InvalidURIError
|
|
131
|
+
false
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def payment_intercept_findings
|
|
135
|
+
spec = routes["payment"]
|
|
136
|
+
return [] unless intercepting?(spec)
|
|
137
|
+
|
|
138
|
+
[Doctor::Finding.new(check: "proxy_payment_intercept",
|
|
139
|
+
message: "the payment route is routed through a proxy that can see plaintext payment " \
|
|
140
|
+
"data (ca_file set, or gateway mode): #{describe(spec)}.")]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def intercepting?(spec)
|
|
144
|
+
hops = spec.is_a?(Array) ? spec : [spec]
|
|
145
|
+
hops.any? { |hop| hop.gateway? || hop.ca_file }
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
require_relative "../payment_methods"
|
|
3
|
+
|
|
4
|
+
module Portage
|
|
5
|
+
module Cli
|
|
6
|
+
class ProxySettings
|
|
7
|
+
# Resolves `proxy.password_ref` (and any per-hop `password_ref`) the
|
|
8
|
+
# same three-tier way PaymentMethods.detect_backend picks a payment
|
|
9
|
+
# secret store: macOS Keychain, then Linux Secret Service, then
|
|
10
|
+
# nothing in headless mode. Deliberately its own service name rather
|
|
11
|
+
# than PaymentMethods::KeychainBackend/SecretServiceBackend directly —
|
|
12
|
+
# those are scoped to "portage-cli-payment", and their headless tier
|
|
13
|
+
# (EnvBackend#read(_id)) answers *every* id with PORTAGE_PAYMENT_TOKEN
|
|
14
|
+
# regardless of what was asked for, which would make a misconfigured
|
|
15
|
+
# proxy.password_ref silently leak the payment token as a proxy
|
|
16
|
+
# password. A ref that can't be resolved here just comes back nil —
|
|
17
|
+
# ProxySettings turns that into a clear config error rather than
|
|
18
|
+
# sending a proxy request with a literal "password_ref" placeholder.
|
|
19
|
+
module PasswordRef
|
|
20
|
+
SERVICE = "portage-cli-proxy".freeze
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
def resolve(ref)
|
|
25
|
+
return nil if ref.to_s.strip.empty?
|
|
26
|
+
return keychain(ref) if macos? && Portage::Cli::PaymentMethods.executable?("security")
|
|
27
|
+
return secret_service(ref) if linux_session? && Portage::Cli::PaymentMethods.executable?("secret-tool")
|
|
28
|
+
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def macos? = RUBY_PLATFORM.include?("darwin")
|
|
33
|
+
|
|
34
|
+
def linux_session? = ENV["DBUS_SESSION_BUS_ADDRESS"].to_s != ""
|
|
35
|
+
|
|
36
|
+
def keychain(ref)
|
|
37
|
+
out, status = Open3.capture2("security", "find-generic-password", "-a", ref, "-s", SERVICE, "-w")
|
|
38
|
+
status.success? ? out.chomp : nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def secret_service(ref)
|
|
42
|
+
out, status = Open3.capture2("secret-tool", "lookup", "service", SERVICE, "account", ref)
|
|
43
|
+
status.success? ? out.chomp : nil
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "uri"
|
|
3
|
+
require "portage/ucp"
|
|
4
|
+
require_relative "config"
|
|
5
|
+
require_relative "proxy_settings/password_ref"
|
|
6
|
+
|
|
7
|
+
module Portage
|
|
8
|
+
module Cli
|
|
9
|
+
# Phase 2 of docs/plans/proxy-support.md: resolves `--proxy*` flags,
|
|
10
|
+
# `PORTAGE_PROXY*` env vars, and `~/.portage/config.json`'s "proxy"
|
|
11
|
+
# section into the `Portage::Ucp::Support::ProxyConfig` Phase 1 already
|
|
12
|
+
# built — core stays exactly as ignorant of config.json as open decision
|
|
13
|
+
# 1 says it should (docs/plans/proxy-support.md). One instance per CLI
|
|
14
|
+
# invocation, same "resolved fresh, not frozen at load time" posture as
|
|
15
|
+
# UserAgent/Notifier's own Setting-backed resolution.
|
|
16
|
+
#
|
|
17
|
+
# Precedence is *per field*, not whole-object: a bare `--proxy URL`
|
|
18
|
+
# overrides only `default.url`, leaving config.json's `default.no_proxy`,
|
|
19
|
+
# `routes`, `chains`, etc. exactly as configured. See #build_default_spec
|
|
20
|
+
# and the `resolve3` calls throughout for where each field's own
|
|
21
|
+
# flag > env > config.json chain lives.
|
|
22
|
+
#
|
|
23
|
+
# The `payment` route is force-set to `:direct` unless the caller (flag,
|
|
24
|
+
# or config.json's `routes.payment`) names a proxy for it explicitly —
|
|
25
|
+
# the plan's "payment route defaults to direct" constraint isn't
|
|
26
|
+
# enforced by core's ProxyConfig#resolved_spec itself (an unlisted route
|
|
27
|
+
# falls back to "default" same as any other), so this class is where it
|
|
28
|
+
# actually holds. That also means portage-cli's own payment traffic no
|
|
29
|
+
# longer inherits a bare $http_proxy/$https_proxy the way every other
|
|
30
|
+
# unconfigured route still does (see #build_routes) — a deliberate
|
|
31
|
+
# narrowing to protect payment tokens from an egress proxy nobody meant
|
|
32
|
+
# to hand them to, not a bug.
|
|
33
|
+
class ProxySettings
|
|
34
|
+
# Raised instead of Portage::Ucp::Support::ProxyConfig::ConfigError —
|
|
35
|
+
# a CLI-level config problem (a bad flag, malformed config.json, an
|
|
36
|
+
# unresolvable password_ref) should read as portage-cli's own error,
|
|
37
|
+
# not leak a bare core exception class/message at the command line.
|
|
38
|
+
class ConfigError < StandardError; end
|
|
39
|
+
|
|
40
|
+
ENV_URL = "PORTAGE_PROXY".freeze
|
|
41
|
+
ENV_MODE = "PORTAGE_PROXY_MODE".freeze
|
|
42
|
+
ENV_NO_PROXY = "PORTAGE_NO_PROXY".freeze
|
|
43
|
+
ENV_CA = "PORTAGE_PROXY_CA".freeze
|
|
44
|
+
ENV_HEADERS = "PORTAGE_PROXY_HEADERS".freeze
|
|
45
|
+
|
|
46
|
+
ENV_INTERPOLATION = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/
|
|
47
|
+
|
|
48
|
+
ProxyConfig = Portage::Ucp::Support::ProxyConfig
|
|
49
|
+
|
|
50
|
+
# Adds every proxy flag to `parser`, accumulating into `target` under
|
|
51
|
+
# the keys #initialize's `flags:` expects — one call site shared by
|
|
52
|
+
# every network command's own option parser (buy/find/compare/doctor/
|
|
53
|
+
# payment enroll), per the plan's "sharing one helper".
|
|
54
|
+
def self.add_options(parser, target)
|
|
55
|
+
init_array_flags!(target)
|
|
56
|
+
add_scalar_options(parser, target)
|
|
57
|
+
add_repeatable_options(parser, target)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.init_array_flags!(target)
|
|
61
|
+
target[:proxy_headers] ||= []
|
|
62
|
+
target[:proxy_routes] ||= []
|
|
63
|
+
target[:proxy_passthrough] ||= []
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.add_scalar_options(parser, target)
|
|
67
|
+
parser.on("--proxy URL") { |v| target[:proxy] = v }
|
|
68
|
+
parser.on("--proxy-mode MODE") { |v| target[:proxy_mode] = v }
|
|
69
|
+
parser.on("--no-proxy HOSTS") { |v| target[:no_proxy] = v }
|
|
70
|
+
parser.on("--proxy-chain CHAIN") { |v| target[:proxy_chain] = v }
|
|
71
|
+
parser.on("--proxy-ca FILE") { |v| target[:proxy_ca] = v }
|
|
72
|
+
parser.on("--no-env-proxy") { target[:no_env_proxy] = true }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.add_repeatable_options(parser, target)
|
|
76
|
+
parser.on("--proxy-header HEADER") { |v| target[:proxy_headers] << v }
|
|
77
|
+
parser.on("--proxy-route ROUTE") { |v| target[:proxy_routes] << v }
|
|
78
|
+
parser.on("--proxy-passthrough HEADER") { |v| target[:proxy_passthrough] << v }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# @param value [String] a header value, or any other config string.
|
|
82
|
+
def self.expand_env(value)
|
|
83
|
+
return value unless value.is_a?(String)
|
|
84
|
+
|
|
85
|
+
value.gsub(ENV_INTERPOLATION) { ENV.fetch(::Regexp.last_match(1), "") }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.expand_env_hash(hash)
|
|
89
|
+
hash.to_h { |key, value| [key.to_s, expand_env(value)] }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.parse_header_flag(raw)
|
|
93
|
+
name, value = raw.to_s.split(":", 2)
|
|
94
|
+
raise ConfigError, "--proxy-header must be \"Name: value\" (got #{raw.inspect})" unless name && value
|
|
95
|
+
|
|
96
|
+
[name.strip, value.strip]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.resolve(flags: {}, config: Config.load) = new(flags: flags, config: config).resolve
|
|
100
|
+
|
|
101
|
+
def initialize(flags: {}, config: Config.load)
|
|
102
|
+
@flags = flags
|
|
103
|
+
@config = config
|
|
104
|
+
@proxy_json = config.get("proxy") || {}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# @return [Portage::Ucp::Support::ProxyConfig]
|
|
108
|
+
# @raise [ConfigError]
|
|
109
|
+
def resolve
|
|
110
|
+
@resolve ||= build!
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# @return [Hash] route name -> resolved spec (a ProxyConfig::Profile,
|
|
114
|
+
# or an Array chain of them — Profile::DIRECT for "direct") — the
|
|
115
|
+
# same shape #resolve handed to ProxyConfig.new, kept around for
|
|
116
|
+
# `doctor`'s effective-routing display.
|
|
117
|
+
def routes
|
|
118
|
+
resolve
|
|
119
|
+
@routes
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @return [Hash] `{"add" => {...}, "strip" => [...]}` from
|
|
123
|
+
# config.json's `proxy.default.forward_headers`, ${ENV}-expanded and
|
|
124
|
+
# protected-header-checked — parsed and stored, matching how the
|
|
125
|
+
# plan's `passthrough`/`inbound` sections are handled below, since
|
|
126
|
+
# core's Support::Connection has nowhere to apply an add/strip
|
|
127
|
+
# rewrite to the real target yet (that's request-building work at
|
|
128
|
+
# every call site, out of Phase 2's "CLI config + flags + doctor"
|
|
129
|
+
# scope — see the handoff report).
|
|
130
|
+
def forward_headers
|
|
131
|
+
resolve
|
|
132
|
+
@forward_headers
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# @return [Hash] config.json's `proxy.passthrough`, verbatim (Phase 3
|
|
136
|
+
# reads this; Phase 2 only parses and stores it).
|
|
137
|
+
def passthrough
|
|
138
|
+
resolve
|
|
139
|
+
@passthrough
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# @return [Hash] config.json's `proxy.inbound`, verbatim (Phase 3
|
|
143
|
+
# reads this; Phase 2 only parses and stores it).
|
|
144
|
+
def inbound
|
|
145
|
+
resolve
|
|
146
|
+
@inbound
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def no_env_proxy? = !!@flags[:no_env_proxy]
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
def build!
|
|
154
|
+
@default_spec = build_default_spec
|
|
155
|
+
@default_spec = parse_chain_flag(@flags[:proxy_chain]) if present?(@flags[:proxy_chain])
|
|
156
|
+
@routes = build_routes
|
|
157
|
+
@forward_headers = forward_headers_meta
|
|
158
|
+
@passthrough = stringify(@proxy_json["passthrough"] || {})
|
|
159
|
+
@inbound = stringify(@proxy_json["inbound"] || {})
|
|
160
|
+
ProxyConfig.new(routes: @routes, no_proxy: no_proxy_list)
|
|
161
|
+
rescue ProxyConfig::ConfigError => e
|
|
162
|
+
raise ConfigError, "invalid proxy configuration: #{e.message}"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# --- default profile (the flag/env/config.json "default.*" fields) --
|
|
166
|
+
|
|
167
|
+
def build_default_spec
|
|
168
|
+
url = resolve3(@flags[:proxy], ENV_URL, @proxy_json.dig("default", "url"))
|
|
169
|
+
return nil unless present?(url)
|
|
170
|
+
|
|
171
|
+
raw = @proxy_json["default"] || {}
|
|
172
|
+
url = apply_password_ref(url, raw["password_ref"])
|
|
173
|
+
mode = resolve3(@flags[:proxy_mode], ENV_MODE, raw["mode"])
|
|
174
|
+
ca_file = resolve3(@flags[:proxy_ca], ENV_CA, raw["ca_file"])
|
|
175
|
+
build_profile(raw, url: url, mode: mode, proxy_headers: default_proxy_headers, ca_file: ca_file)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def default_proxy_headers
|
|
179
|
+
headers = self.class.expand_env_hash(stringify(@proxy_json.dig("default", "proxy_headers") || {}))
|
|
180
|
+
headers.merge!(env_headers)
|
|
181
|
+
headers.merge!(flag_headers)
|
|
182
|
+
headers
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def env_headers
|
|
186
|
+
raw = ENV.fetch(ENV_HEADERS, nil)
|
|
187
|
+
return {} unless present?(raw)
|
|
188
|
+
|
|
189
|
+
parsed = JSON.parse(raw)
|
|
190
|
+
parsed.is_a?(Hash) ? stringify(parsed) : {}
|
|
191
|
+
rescue JSON::ParserError => e
|
|
192
|
+
raise ConfigError, "#{ENV_HEADERS} is not valid JSON: #{e.message}"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def flag_headers
|
|
196
|
+
Array(@flags[:proxy_headers]).to_h { |raw| self.class.parse_header_flag(raw) }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# --- forward_headers (parsed + protected-header-checked, not yet
|
|
200
|
+
# applied to any outgoing request — see #forward_headers above) ------
|
|
201
|
+
|
|
202
|
+
def forward_headers_meta
|
|
203
|
+
raw = @proxy_json.dig("default", "forward_headers") || {}
|
|
204
|
+
add = self.class.expand_env_hash(stringify(raw["add"] || {}))
|
|
205
|
+
strip = Array(raw["strip"]).map(&:to_s)
|
|
206
|
+
ProxyConfig.validate_headers!(add)
|
|
207
|
+
refuse_protected_strip!(strip)
|
|
208
|
+
{ "add" => add, "strip" => strip }
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def refuse_protected_strip!(strip)
|
|
212
|
+
offender = strip.find { |name| ProxyConfig.protected_header?(name) }
|
|
213
|
+
return unless offender
|
|
214
|
+
|
|
215
|
+
raise ProxyConfig::ConfigError,
|
|
216
|
+
"#{offender} is a protected header and cannot be stripped via forward_headers"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# --- no_proxy ---------------------------------------------------
|
|
220
|
+
|
|
221
|
+
def no_proxy_list
|
|
222
|
+
flag = @flags[:no_proxy]
|
|
223
|
+
return split_list(flag) if present?(flag)
|
|
224
|
+
|
|
225
|
+
env = ENV.fetch(ENV_NO_PROXY, nil)
|
|
226
|
+
return split_list(env) if present?(env)
|
|
227
|
+
|
|
228
|
+
Array(@proxy_json.dig("default", "no_proxy"))
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def split_list(value) = value.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
232
|
+
|
|
233
|
+
# --- routes -------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
def build_routes
|
|
236
|
+
routes = {}
|
|
237
|
+
@proxy_json.fetch("routes", {}).each { |route, spec| routes[route.to_s] = resolve_route_spec(spec) }
|
|
238
|
+
routes["default"] = @default_spec if @default_spec
|
|
239
|
+
Array(@flags[:proxy_routes]).each { |raw| apply_route_flag!(routes, raw) }
|
|
240
|
+
routes["payment"] = ProxyConfig::Profile::DIRECT unless routes.key?("payment")
|
|
241
|
+
routes["default"] = ProxyConfig::Profile::DIRECT if no_env_proxy? && !routes.key?("default")
|
|
242
|
+
routes
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def resolve_route_spec(spec)
|
|
246
|
+
case spec
|
|
247
|
+
when Hash then resolve_route_hash(spec)
|
|
248
|
+
when Array then spec.map { |hop| resolve_route_spec(hop) }
|
|
249
|
+
when String then resolve_route_string(spec)
|
|
250
|
+
else raise ConfigError, "invalid proxy route value #{spec.inspect}"
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def resolve_route_hash(spec)
|
|
255
|
+
spec["chain"] ? resolve_chain_name(spec["chain"]) : resolve_inline_profile(spec)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def resolve_route_string(spec)
|
|
259
|
+
return ProxyConfig::Profile::DIRECT if spec == "direct"
|
|
260
|
+
return @default_spec || ProxyConfig::Profile::DIRECT if spec == "default"
|
|
261
|
+
return resolve_inline_profile("url" => spec) if spec.include?("://")
|
|
262
|
+
|
|
263
|
+
resolve_chain_name(spec)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def resolve_chain_name(name)
|
|
267
|
+
chain = @proxy_json.dig("chains", name)
|
|
268
|
+
raise ConfigError, "unknown proxy profile or chain #{name.inspect}" unless chain
|
|
269
|
+
|
|
270
|
+
Array(chain).map { |hop| resolve_inline_profile(hop) }
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def resolve_inline_profile(raw)
|
|
274
|
+
url = apply_password_ref(raw["url"], raw["password_ref"])
|
|
275
|
+
headers = self.class.expand_env_hash(stringify(raw["proxy_headers"] || {}))
|
|
276
|
+
build_profile(raw, url: url, mode: raw["mode"], proxy_headers: headers)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def apply_route_flag!(routes, raw)
|
|
280
|
+
route, value = raw.to_s.split("=", 2)
|
|
281
|
+
raise ConfigError, "--proxy-route must be ROUTE=VALUE (got #{raw.inspect})" unless route && value
|
|
282
|
+
|
|
283
|
+
routes[route.strip] = route_value_from_flag(value.strip)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def route_value_from_flag(value)
|
|
287
|
+
return ProxyConfig::Profile::DIRECT if value == "direct"
|
|
288
|
+
return @default_spec || ProxyConfig::Profile::DIRECT if value == "default"
|
|
289
|
+
return ProxyConfig::Profile.new(url: value) if value.include?("://")
|
|
290
|
+
|
|
291
|
+
raise ConfigError, "--proxy-route: expected a URL or \"direct\" (got #{value.inspect})"
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def parse_chain_flag(raw)
|
|
295
|
+
raw.to_s.split(",").map { |hop| chain_hop_from_flag(hop.strip) }
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def chain_hop_from_flag(hop)
|
|
299
|
+
return ProxyConfig::Profile.new(mode: :gateway, url: hop.sub(/\Agateway\+/, "")) if hop.start_with?("gateway+")
|
|
300
|
+
|
|
301
|
+
ProxyConfig::Profile.new(mode: :forward, url: hop)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# --- shared profile builder ------------------------------------------
|
|
305
|
+
|
|
306
|
+
# Builds a real ProxyConfig::Profile (not a plain Hash core would only
|
|
307
|
+
# coerce lazily, on first #chain_for) so a protected header or a bad
|
|
308
|
+
# mode/url raises right here, at config-load time, from any of the
|
|
309
|
+
# three sources (flag/env/config.json) that fed `proxy_headers` —
|
|
310
|
+
# exactly what the plan's "Protected headers" constraint and this
|
|
311
|
+
# phase's own step 8 require, rather than surfacing on the first real
|
|
312
|
+
# request Support::Connection makes.
|
|
313
|
+
def build_profile(raw, url:, mode:, proxy_headers:, ca_file: raw["ca_file"])
|
|
314
|
+
kwargs = { url: url, proxy_headers: proxy_headers, ca_file: ca_file,
|
|
315
|
+
target_header: raw["target_header"], target_param: raw["target_param"] }.compact
|
|
316
|
+
kwargs[:mode] = mode.to_sym if present?(mode)
|
|
317
|
+
ProxyConfig::Profile.new(**kwargs)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def apply_password_ref(url, password_ref)
|
|
321
|
+
return url unless present?(url) && present?(password_ref)
|
|
322
|
+
|
|
323
|
+
secret = PasswordRef.resolve(password_ref)
|
|
324
|
+
unless secret
|
|
325
|
+
raise ConfigError, "proxy password_ref #{password_ref.inspect} could not be resolved from any local " \
|
|
326
|
+
"secret backend (macOS Keychain, Linux Secret Service)"
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
uri = URI(url)
|
|
330
|
+
uri.password = URI.encode_www_form_component(secret)
|
|
331
|
+
uri.to_s
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# --- misc -----------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
def resolve3(flag, env_name, config_value)
|
|
337
|
+
return flag if present?(flag)
|
|
338
|
+
|
|
339
|
+
from_env = env_name && ENV.fetch(env_name, nil)
|
|
340
|
+
return from_env if present?(from_env)
|
|
341
|
+
|
|
342
|
+
config_value if present?(config_value)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def present?(value) = !(value.nil? || (value.is_a?(String) && value.strip.empty?))
|
|
346
|
+
|
|
347
|
+
def stringify(hash) = hash.to_h { |key, value| [key.to_s, value] }
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
require_relative "notifier"
|
|
2
|
+
require_relative "macos_notifier"
|
|
3
|
+
require_relative "reconcile_notify"
|
|
4
|
+
|
|
5
|
+
module Portage
|
|
6
|
+
module Cli
|
|
7
|
+
# docs/plans/handoff-reconcile.md Phase 3 — fans a settle event
|
|
8
|
+
# (`checkout_reconciled`, from HandoffReconciler#notify) out to whichever
|
|
9
|
+
# channels `ReconcileNotify` names. The default `HandoffReconciler`
|
|
10
|
+
# notifier for both `portage buy --wait` and `portage orders reconcile`,
|
|
11
|
+
# replacing the plain webhook-only `Notifier` those used through Phase 1.
|
|
12
|
+
#
|
|
13
|
+
# Every channel is best-effort, same posture as `Notifier#call` itself —
|
|
14
|
+
# one channel failing (a bad webhook URL, no `osascript` on this host)
|
|
15
|
+
# never stops another from firing, and this never raises out of
|
|
16
|
+
# `HandoffReconciler#notify`, which already wraps the whole call in its
|
|
17
|
+
# own rescue.
|
|
18
|
+
class ReconcileNotifier
|
|
19
|
+
def initialize(channels: ReconcileNotify.resolve, webhook: Notifier.new, macos: MacosNotifier.new)
|
|
20
|
+
@channels = channels
|
|
21
|
+
@webhook = webhook
|
|
22
|
+
@macos = macos
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def enabled?(channel) = @channels.include?(channel)
|
|
26
|
+
|
|
27
|
+
# @return [String, nil] the webhook channel's own failure message, same
|
|
28
|
+
# shape `Notifier#call` always returned — the other channels have no
|
|
29
|
+
# return-value contract to preserve, since nothing read theirs before
|
|
30
|
+
# this class existed.
|
|
31
|
+
def call(payload)
|
|
32
|
+
error = @webhook.call(payload) if enabled?("webhook")
|
|
33
|
+
notify_macos(payload) if enabled?("macos")
|
|
34
|
+
puts terminal_line(payload) if enabled?("terminal")
|
|
35
|
+
error
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def notify_macos(payload)
|
|
41
|
+
@macos.call(title: "Portage checkout #{payload[:result]}", message: macos_message(payload))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def macos_message(payload)
|
|
45
|
+
parts = [payload[:shop], format_amount(payload[:amount], payload[:currency])].compact
|
|
46
|
+
parts.empty? ? "checkout #{payload[:checkout_id]}" : parts.join(" — ")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def terminal_line(payload)
|
|
50
|
+
parts = ["[reconcile] #{payload[:checkout_id]}: #{payload[:result]}"]
|
|
51
|
+
parts << "resolution: #{payload[:resolution]}" if payload[:resolution]
|
|
52
|
+
parts << "order: #{payload[:order_id]}" if payload[:order_id]
|
|
53
|
+
amount = format_amount(payload[:amount], payload[:currency])
|
|
54
|
+
parts << amount if amount
|
|
55
|
+
parts.join(" — ")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def format_amount(amount, currency)
|
|
59
|
+
return nil unless amount
|
|
60
|
+
|
|
61
|
+
"#{format('%.2f', amount / 100.0)}#{" #{currency}" if currency}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|