mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
* feat: Add dependency cooldown for npm packages Strip recently-published package versions from npm registry metadata responses so npm's resolver naturally falls back to older versions. Overrides the Accept header to force full packument responses (which include the "time" field needed for publish-date checks). Reports cooldown blocks only when all versions are stripped (remaining == 0), matching npm's --min-release-age behavior for silent fallback. * fix: Report oldest version in cooldown block (shortest wait) When all versions are blocked by cooldown, report the oldest version since it exits the cooldown window first — giving the user the shortest wait time instead of the longest. * fix: Handle resp.Body.Close error return for errcheck linter * test: Add dependency cooldown assertions to template config tests * fix: config template for dependency cooldown * fix: Prevent npm from caching cooldown-stripped metadata responses * fix: Restore body on ReadAll failure and log Close errors in response modifier * fix: Close response body before replacing to prevent connection leak * fix: Correct daysLeft ceiling math and update ContentLength on error recovery * fix: Clear Status on status code change and update ContentLength in modifier error path * refactor: address review comments on dependency cooldown PR - Make NpmCooldownHandler and constructor package-private - Pass cooldown days as parameter instead of reading config internally - Convert standalone functions to methods on npmCooldownHandler - Set Accept-Encoding: identity to prevent gzip responses breaking JSON parsing - Return 503 with descriptive message when upstream body read fails * fix: log errors in stripCooldownVersions instead of swallowing them * fix: Config preserve fallback defaults * fix: Code review fixes * fix: correct cooldown tip to show wait time instead of incorrect trusted_packages advice * fix: prevent integer overflow in cooldown duration calculation with large days values * refactor: deduplicate CooldownBlock into internal/models, fix misleading variable names - Move CooldownBlock struct to internal/models to eliminate duplication between proxy/interceptors and internal/ui packages - Simplify proxy_flow.go by using direct assignment instead of field copy - Rename latestStripped/latestDate to oldestVer/oldestDate for clarity * fix: Dependency Cooldown Check Encapsulation (#207) * fix: Encapsulate cooldown check * feat: Add --skip-dependency-cooldown override * fix: Code review fixes --------- Co-authored-by: Abhisek Datta <abhisek.datta@gmail.com>
554 lines
16 KiB
Go
554 lines
16 KiB
Go
package proxy
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/elazarl/goproxy"
|
|
"github.com/safedep/dry/log"
|
|
"github.com/safedep/pmg/proxy/certmanager"
|
|
)
|
|
|
|
// defaultServerReadWriteTimeout is the default timeout for the http.Server's
|
|
// ReadTimeout and WriteTimeout. These deadlines persist on hijacked CONNECT
|
|
// tunnel connections, so this must be large enough for a full bulk install.
|
|
const defaultServerReadWriteTimeout = 30 * time.Minute
|
|
|
|
// ProxyServer manages the proxy lifecycle
|
|
type ProxyServer interface {
|
|
// Start begins listening on the configured address
|
|
Start() error
|
|
|
|
// Stop gracefully shuts down the proxy
|
|
Stop(ctx context.Context) error
|
|
|
|
// Address returns the listening address (useful when using port 0)
|
|
Address() string
|
|
|
|
// AddInterceptor registers an interceptor
|
|
AddInterceptor(interceptor Interceptor) error
|
|
|
|
// RemoveInterceptor removes an interceptor by name
|
|
RemoveInterceptor(name string)
|
|
}
|
|
|
|
// ProxyConfig holds configuration for the proxy server
|
|
type ProxyConfig struct {
|
|
// Network configuration
|
|
ListenAddr string
|
|
|
|
// TLS configuration
|
|
CertManager certmanager.CertificateManager
|
|
|
|
// Interceptors
|
|
Interceptors []Interceptor
|
|
|
|
// Other configuration
|
|
EnableMITM bool
|
|
RequestTimeout time.Duration
|
|
ConnectTimeout time.Duration
|
|
|
|
// ServerReadWriteTimeout is the timeout applied to the http.Server's
|
|
// ReadTimeout and WriteTimeout. These deadlines are set on the raw TCP
|
|
// connection and persist after Hijack(), which means they become the
|
|
// hard wall-clock limit for CONNECT tunnels (used for non-MITM traffic
|
|
// like private registries). A bulk "npm install" can easily run for
|
|
// 15-30 minutes, so this must be significantly larger than
|
|
// RequestTimeout (which governs individual upstream round-trips for
|
|
// MITM'd connections).
|
|
//
|
|
// If zero, defaults to 30 minutes.
|
|
ServerReadWriteTimeout time.Duration
|
|
}
|
|
|
|
// DefaultProxyConfig returns a configuration with sensible defaults
|
|
func DefaultProxyConfig() *ProxyConfig {
|
|
return &ProxyConfig{
|
|
ListenAddr: "127.0.0.1:0",
|
|
EnableMITM: true,
|
|
ConnectTimeout: 30 * time.Second,
|
|
RequestTimeout: 5 * time.Minute,
|
|
ServerReadWriteTimeout: defaultServerReadWriteTimeout,
|
|
Interceptors: []Interceptor{},
|
|
}
|
|
}
|
|
|
|
type proxyServer struct {
|
|
config *ProxyConfig
|
|
proxy *goproxy.ProxyHttpServer
|
|
server *http.Server
|
|
|
|
listener net.Listener
|
|
interceptors map[string]Interceptor
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
var _ ProxyServer = &proxyServer{}
|
|
|
|
// goproxyLoggerWrapper implements the goproxy.Logger interface and bridges to the dry/log package
|
|
type goproxyLoggerWrapper struct{}
|
|
|
|
func (l *goproxyLoggerWrapper) Printf(format string, v ...interface{}) {
|
|
log.Debugf("[GOPROXY] "+format, v...)
|
|
}
|
|
|
|
// NewProxyServer creates a new proxy server with the given configuration
|
|
// using the goproxy library as the underlying proxy implementation
|
|
func NewProxyServer(config *ProxyConfig) (ProxyServer, error) {
|
|
if config == nil {
|
|
config = DefaultProxyConfig()
|
|
}
|
|
|
|
if config.EnableMITM && config.CertManager == nil {
|
|
return nil, fmt.Errorf("cert manager is required when MITM is enabled")
|
|
}
|
|
|
|
if config.ListenAddr == "" {
|
|
config.ListenAddr = "127.0.0.1:0"
|
|
}
|
|
|
|
proxy := goproxy.NewProxyHttpServer()
|
|
proxy.Logger = &goproxyLoggerWrapper{}
|
|
proxy.Tr = newUpstreamTransport(config)
|
|
|
|
// Set verbose to true for verbose logging.
|
|
// Logging is handled by our own logger which has log level controls.
|
|
proxy.Verbose = true
|
|
|
|
// Configure connection timeout for upstream connections during CONNECT requests
|
|
proxy.ConnectDial = func(network, addr string) (net.Conn, error) {
|
|
dialer := &net.Dialer{
|
|
Timeout: config.ConnectTimeout,
|
|
}
|
|
|
|
return dialer.Dial(network, addr)
|
|
}
|
|
|
|
ps := &proxyServer{
|
|
config: config,
|
|
proxy: proxy,
|
|
interceptors: make(map[string]Interceptor),
|
|
}
|
|
|
|
for _, interceptor := range config.Interceptors {
|
|
if err := ps.AddInterceptor(interceptor); err != nil {
|
|
return nil, fmt.Errorf("failed to add interceptor %s: %w", interceptor.Name(), err)
|
|
}
|
|
}
|
|
|
|
if config.EnableMITM {
|
|
ps.configureMITM()
|
|
}
|
|
|
|
ps.registerHandlers()
|
|
|
|
return ps, nil
|
|
}
|
|
|
|
func proxyWithLoopbackBypass(req *http.Request) (*url.URL, error) {
|
|
host := req.URL.Hostname()
|
|
if host == "localhost" || host == "127.0.0.1" || host == "::1" {
|
|
return nil, nil
|
|
}
|
|
|
|
return http.ProxyFromEnvironment(req)
|
|
}
|
|
|
|
func newUpstreamTransport(config *ProxyConfig) *http.Transport {
|
|
dialer := &net.Dialer{
|
|
Timeout: config.ConnectTimeout,
|
|
}
|
|
|
|
// Proxy honours the environment (HTTP_PROXY, HTTPS_PROXY, NO_PROXY) so
|
|
// that PMG works in enterprise environments that require a corporate
|
|
// upstream proxy to reach the internet. Loopback addresses are always
|
|
// bypassed to avoid routing localhost traffic through an external proxy,
|
|
// which would fail because the proxy can't reach the user's localhost.
|
|
//
|
|
// ForceAttemptHTTP2 is required because Go's http.Transport silently
|
|
// disables HTTP/2 when a custom TLSClientConfig or DialContext is set.
|
|
// Without it, every proxied request opens a separate HTTP/1.1 TCP+TLS
|
|
// connection to the upstream registry. During npm install of large
|
|
// projects (1000+ packages), this creates a burst of concurrent
|
|
// connections that triggers rate-limiting (RST) from CDNs like
|
|
// Cloudflare (which fronts registry.npmjs.org). HTTP/2 multiplexing
|
|
// allows hundreds of requests to share a few TCP connections.
|
|
//
|
|
// MaxConnsPerHost caps concurrent connections per upstream host to
|
|
// prevent overwhelming registries even if HTTP/2 is not negotiated.
|
|
// MaxIdleConnsPerHost is raised from the default of 2 to improve
|
|
// connection reuse.
|
|
return &http.Transport{
|
|
Proxy: proxyWithLoopbackBypass,
|
|
DialContext: dialer.DialContext,
|
|
ForceAttemptHTTP2: true,
|
|
MaxConnsPerHost: 100,
|
|
MaxIdleConns: 200,
|
|
MaxIdleConnsPerHost: 50,
|
|
IdleConnTimeout: 120 * time.Second,
|
|
TLSHandshakeTimeout: config.ConnectTimeout,
|
|
ResponseHeaderTimeout: config.RequestTimeout,
|
|
TLSClientConfig: &tls.Config{
|
|
MinVersion: tls.VersionTLS12,
|
|
InsecureSkipVerify: false,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (ps *proxyServer) Start() error {
|
|
listener, err := net.Listen("tcp", ps.config.ListenAddr)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to start listener: %w", err)
|
|
}
|
|
|
|
ps.listener = listener
|
|
|
|
serverTimeout := ps.config.ServerReadWriteTimeout
|
|
if serverTimeout == 0 {
|
|
serverTimeout = defaultServerReadWriteTimeout
|
|
}
|
|
|
|
ps.server = &http.Server{
|
|
Handler: ps.proxy,
|
|
ReadTimeout: serverTimeout,
|
|
WriteTimeout: serverTimeout,
|
|
}
|
|
|
|
log.Debugf("Proxy server listening on %s", ps.Address())
|
|
|
|
go func() {
|
|
if err := ps.server.Serve(ps.listener); err != nil && err != http.ErrServerClosed {
|
|
log.Errorf("Proxy server error: %v", err)
|
|
}
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
func (ps *proxyServer) Stop(ctx context.Context) error {
|
|
if ps.server == nil {
|
|
return nil
|
|
}
|
|
|
|
log.Debugf("Shutting down proxy server...")
|
|
|
|
if err := ps.server.Shutdown(ctx); err != nil {
|
|
return fmt.Errorf("failed to shutdown proxy server: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (ps *proxyServer) Address() string {
|
|
if ps.listener == nil {
|
|
return ""
|
|
}
|
|
|
|
return ps.listener.Addr().String()
|
|
}
|
|
|
|
func (ps *proxyServer) AddInterceptor(interceptor Interceptor) error {
|
|
ps.mu.Lock()
|
|
defer ps.mu.Unlock()
|
|
|
|
if _, ok := ps.interceptors[interceptor.Name()]; ok {
|
|
return fmt.Errorf("interceptor %s already registered", interceptor.Name())
|
|
}
|
|
|
|
ps.interceptors[interceptor.Name()] = interceptor
|
|
log.Debugf("Registered interceptor: %s", interceptor.Name())
|
|
|
|
return nil
|
|
}
|
|
|
|
func (ps *proxyServer) RemoveInterceptor(name string) {
|
|
ps.mu.Lock()
|
|
defer ps.mu.Unlock()
|
|
|
|
delete(ps.interceptors, name)
|
|
log.Debugf("Removed interceptor: %s", name)
|
|
}
|
|
|
|
// normalizeRequestURL fixes malformed URLs produced by goproxy's MITM URL reconstruction.
|
|
//
|
|
// When a client (e.g., npm) sends absolute-form Request-URIs inside a CONNECT tunnel
|
|
// (e.g., "POST http://registry.npmjs.org:443/-/npm/v1/security/advisories/bulk"),
|
|
// goproxy's MITM code checks if req.URL starts with "scheme://" and, if not, naively
|
|
// prepends "scheme://connectHost" to the full URI. Since the client's URI starts with
|
|
// "http://" but the MITM scheme is "https", the check fails and goproxy produces:
|
|
//
|
|
// https://registry.npmjs.org:443http://registry.npmjs.org:443/-/npm/v1/security/advisories/bulk
|
|
//
|
|
// This function detects the embedded absolute URI and extracts it, preserving the
|
|
// correct scheme from the MITM connection.
|
|
func normalizeRequestURL(req *http.Request) {
|
|
if req == nil || req.URL == nil {
|
|
return
|
|
}
|
|
|
|
host := req.URL.Host
|
|
if host == "" {
|
|
return
|
|
}
|
|
|
|
// The goproxy bug produces a Host field like "registry.npmjs.org:443http:"
|
|
// where the embedded scheme leaks into the authority. A valid host:port
|
|
// never contains "http:" or "https:", so this check is precise and avoids
|
|
// false positives from query parameters or path segments.
|
|
var embeddedScheme string
|
|
var schemeIdx int
|
|
if idx := strings.Index(host, "http:"); idx > 0 {
|
|
embeddedScheme = "http"
|
|
schemeIdx = idx
|
|
} else if idx := strings.Index(host, "https:"); idx > 0 {
|
|
embeddedScheme = "https"
|
|
schemeIdx = idx
|
|
}
|
|
|
|
if embeddedScheme == "" {
|
|
return
|
|
}
|
|
|
|
// Extract the real host (everything before the embedded scheme)
|
|
realHost := host[:schemeIdx]
|
|
|
|
// Reconstruct the embedded URL from the scheme found in the host
|
|
// plus the path portion that Go's URL parser placed after the authority.
|
|
// The full original URL looks like: scheme://realHost + embeddedScheme://embeddedHost/path
|
|
// Go parsed the authority as "realHost + embeddedScheme:" and the path as
|
|
// "//embeddedHost/path", so we combine them back.
|
|
embeddedURL := embeddedScheme + ":" + req.URL.Path
|
|
if req.URL.RawQuery != "" {
|
|
embeddedURL += "?" + req.URL.RawQuery
|
|
}
|
|
if req.URL.Fragment != "" {
|
|
embeddedURL += "#" + req.URL.Fragment
|
|
}
|
|
|
|
parsed, err := url.Parse(embeddedURL)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// If the embedded URL parsed to a valid host, use it. Otherwise fall back
|
|
// to the real host we extracted from the authority.
|
|
if parsed.Host == "" {
|
|
parsed.Host = realHost
|
|
}
|
|
|
|
// Preserve the MITM scheme (e.g. https) rather than the client's scheme
|
|
parsed.Scheme = req.URL.Scheme
|
|
req.URL = parsed
|
|
}
|
|
|
|
func (ps *proxyServer) configureMITM() {
|
|
// Configure selective MITM based on interceptors
|
|
ps.proxy.OnRequest().HandleConnect(goproxy.FuncHttpsHandler(func(host string, ctx *goproxy.ProxyCtx) (*goproxy.ConnectAction, string) {
|
|
reqCtx, err := newRequestContextFromURL(host, "CONNECT")
|
|
if err != nil {
|
|
log.Errorf("Failed to parse CONNECT request for %s: %v", host, err)
|
|
return goproxy.OkConnect, host
|
|
}
|
|
|
|
ps.mu.RLock()
|
|
shouldMITM := false
|
|
for _, interceptor := range ps.interceptors {
|
|
if !interceptor.ShouldIntercept(reqCtx) {
|
|
continue
|
|
}
|
|
|
|
mitm := true
|
|
if decider, ok := interceptor.(MITMDecider); ok {
|
|
mitm = decider.ShouldMITM(reqCtx)
|
|
}
|
|
|
|
if !mitm {
|
|
// Allow non-MITM interceptors (e.g., telemetry) to observe CONNECT traffic.
|
|
if _, err := interceptor.HandleRequest(reqCtx); err != nil {
|
|
log.Errorf("[%s] Interceptor %s error on CONNECT: %v", reqCtx.RequestID, interceptor.Name(), err)
|
|
}
|
|
continue
|
|
}
|
|
|
|
shouldMITM = true
|
|
log.Debugf("[%s] Interceptor %s will handle %s", reqCtx.RequestID, interceptor.Name(), host)
|
|
}
|
|
ps.mu.RUnlock()
|
|
|
|
if shouldMITM {
|
|
mitmAction := &goproxy.ConnectAction{
|
|
Action: goproxy.ConnectMitm,
|
|
TLSConfig: func(host string, ctx *goproxy.ProxyCtx) (*tls.Config, error) {
|
|
hostname, _, err := net.SplitHostPort(host)
|
|
if err != nil {
|
|
hostname = host
|
|
}
|
|
|
|
return ps.config.CertManager.GetTLSConfig(hostname)
|
|
},
|
|
}
|
|
|
|
return mitmAction, host
|
|
}
|
|
|
|
// Tunnel without interception
|
|
log.Debugf("[%s] Tunneling %s (no interceptor)", reqCtx.RequestID, host)
|
|
return goproxy.OkConnect, host
|
|
}))
|
|
}
|
|
|
|
func (ps *proxyServer) registerHandlers() {
|
|
ps.proxy.OnRequest().DoFunc(func(req *http.Request, ctx *goproxy.ProxyCtx) (*http.Request, *http.Response) {
|
|
// Fix malformed URLs produced by goproxy's MITM URL reconstruction.
|
|
// When a client sends absolute-form Request-URIs (e.g., http://host:port/path)
|
|
// inside a CONNECT tunnel, goproxy naively prepends scheme://connectHost to the
|
|
// full URI, producing malformed URLs like https://host:443http://host:443/path.
|
|
// We detect and fix this before processing.
|
|
normalizeRequestURL(req)
|
|
|
|
reqCtx, err := newRequestContext(req)
|
|
if err != nil {
|
|
log.Errorf("Failed to create request context: %v", err)
|
|
return req, nil
|
|
}
|
|
|
|
log.Debugf("[%s] %s %s", reqCtx.RequestID, req.Method, req.URL.String())
|
|
|
|
ps.mu.RLock()
|
|
defer ps.mu.RUnlock()
|
|
|
|
for _, interceptor := range ps.interceptors {
|
|
if !interceptor.ShouldIntercept(reqCtx) {
|
|
continue
|
|
}
|
|
|
|
resp, err := interceptor.HandleRequest(reqCtx)
|
|
if err != nil {
|
|
log.Errorf("[%s] Interceptor %s error: %v", reqCtx.RequestID, interceptor.Name(), err)
|
|
continue
|
|
}
|
|
|
|
if resp == nil {
|
|
continue
|
|
}
|
|
|
|
switch resp.Action {
|
|
case ActionBlock:
|
|
statusCode := resp.BlockCode
|
|
if statusCode == 0 {
|
|
statusCode = http.StatusForbidden
|
|
}
|
|
|
|
message := resp.BlockMessage
|
|
if message == "" {
|
|
message = "Blocked by proxy interceptor"
|
|
}
|
|
|
|
log.Debugf("[%s] Blocked by %s: %s", reqCtx.RequestID, interceptor.Name(), req.URL.String())
|
|
r := goproxy.NewResponse(req, goproxy.ContentTypeText, statusCode, message)
|
|
|
|
// goproxy v1.8.x writes the response via (*http.Response).Write for MITM traffic.
|
|
// Ensure the protocol version is valid (defaults to HTTP/0.0 otherwise).
|
|
// Ref: https://github.com/elazarl/goproxy/issues/745
|
|
if req.ProtoMajor > 0 {
|
|
r.Proto = req.Proto
|
|
r.ProtoMajor = req.ProtoMajor
|
|
r.ProtoMinor = req.ProtoMinor
|
|
} else {
|
|
r.Proto = "HTTP/1.1"
|
|
r.ProtoMajor = 1
|
|
r.ProtoMinor = 1
|
|
}
|
|
r.Close = true
|
|
r.Header.Set("Connection", "close")
|
|
r.Header.Set("Proxy-Connection", "close")
|
|
|
|
return req, r
|
|
|
|
case ActionModifyRequest:
|
|
if resp.ModifiedHeaders != nil {
|
|
req.Header = resp.ModifiedHeaders
|
|
}
|
|
|
|
log.Debugf("[%s] Request modified by %s", reqCtx.RequestID, interceptor.Name())
|
|
|
|
case ActionModifyResponse:
|
|
ctx.UserData = resp.ResponseModifier
|
|
log.Debugf("[%s] Response modifier registered by %s", reqCtx.RequestID, interceptor.Name())
|
|
}
|
|
}
|
|
|
|
return req, nil
|
|
})
|
|
|
|
ps.proxy.OnResponse().DoFunc(func(resp *http.Response, ctx *goproxy.ProxyCtx) *http.Response {
|
|
if resp == nil {
|
|
return resp
|
|
}
|
|
|
|
// When the upstream transport negotiates HTTP/2, responses arrive with
|
|
// Proto "HTTP/2.0" and ProtoMajor 2. goproxy writes MITM responses via
|
|
// resp.Write(), which serialises the status line verbatim. An HTTP/1.1
|
|
// client (pip, npm, etc.) rejects the "HTTP/2.0 200 OK" status line and
|
|
// resets the connection. Normalise to HTTP/1.1 so the response is valid
|
|
// for the downstream MITM connection.
|
|
if resp.ProtoMajor != 1 {
|
|
resp.Proto = "HTTP/1.1"
|
|
resp.ProtoMajor = 1
|
|
resp.ProtoMinor = 1
|
|
}
|
|
|
|
reqCtx, err := newRequestContext(ctx.Req)
|
|
if err != nil {
|
|
log.Errorf("Failed to create request context: %v", err)
|
|
return resp
|
|
}
|
|
|
|
log.Debugf("[%s] Response received for %s", reqCtx.RequestID, ctx.Req.URL.String())
|
|
|
|
modifier, ok := ctx.UserData.(ResponseModifierFunc)
|
|
if !ok || modifier == nil {
|
|
return resp
|
|
}
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if closeErr := resp.Body.Close(); closeErr != nil {
|
|
log.Warnf("[%s] Failed to close response body: %v", reqCtx.RequestID, closeErr)
|
|
}
|
|
if err != nil {
|
|
log.Errorf("[%s] Failed to read response body for modifier: %v", reqCtx.RequestID, err)
|
|
errMsg := []byte("PMG: failed to read response from upstream registry")
|
|
resp.StatusCode = http.StatusServiceUnavailable
|
|
resp.Status = ""
|
|
resp.Body = io.NopCloser(bytes.NewReader(errMsg))
|
|
resp.ContentLength = int64(len(errMsg))
|
|
return resp
|
|
}
|
|
|
|
newStatusCode, newHeaders, newBody, err := modifier(resp.StatusCode, resp.Header, body)
|
|
if err != nil {
|
|
log.Errorf("[%s] Response modifier error: %v", reqCtx.RequestID, err)
|
|
resp.Body = io.NopCloser(bytes.NewReader(body))
|
|
resp.ContentLength = int64(len(body))
|
|
return resp
|
|
}
|
|
|
|
resp.StatusCode = newStatusCode
|
|
resp.Status = ""
|
|
resp.Header = newHeaders
|
|
resp.Body = io.NopCloser(bytes.NewReader(newBody))
|
|
resp.ContentLength = int64(len(newBody))
|
|
|
|
return resp
|
|
})
|
|
}
|