From 8d6f47b4c4f3bdbecba34d9f65c8c5629ca0a5b3 Mon Sep 17 00:00:00 2001 From: klzgrad Date: Fri, 2 Oct 2020 11:14:08 +0800 Subject: [PATCH] Support v2 Caddyfile (#79) * Fix probe_resistance config parsing * Support Caddyfile * Support HTTP/3 * Revert "Support HTTP/3" This reverts commit f01c163ad5145cdb0a0756345a259310b2cd50aa. * Fix review comments * Update README.md to new directive names * Use Caddy 2 logger --- README.md | 39 +++++----- forwardproxy.go | 190 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 206 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index acc844b..9e61f63 100644 --- a/README.md +++ b/README.md @@ -14,31 +14,36 @@ For a complete list of features and their usage, see Caddyfile syntax: ## Caddyfile Syntax (Server Configuration) -The simplest way to enable the forward proxy without authentication just include the `forwardproxy` directive in your Caddyfile. However, this allows anyone to use your server as a proxy, which might not be desirable. +The simplest way to enable the forward proxy without authentication just include the `forward_proxy` directive in your Caddyfile. However, this allows anyone to use your server as a proxy, which might not be desirable. -Open a block for more control; here's an example of all properties in use (note that the syntax is subject to change): +The `forward_proxy` directive has no default order and must be used within a `route` directive to explicitly specify its order of evaluation. In the Caddyfile the addresses must start with `:443` for the `forward_proxy` to work for proxy requests of all origins. + +Here's an example of all properties in use (note that the syntax is subject to change): ``` -forwardproxy { - basicauth user1 0NtCL2JPJBgPPMmlPcJ - basicauth user2 密码 +:443, example.com +route { + forward_proxy { + basic_auth user1 0NtCL2JPJBgPPMmlPcJ + basic_auth user2 密码 ports 80 443 hide_ip hide_via probe_resistance secret-link-kWWL9Q.com # alternatively you can use a real domain, such as caddyserver.com serve_pac /secret-proxy.pac - response_timeout 30 dial_timeout 30 upstream https://user:password@extra-upstream-hop.com acl { allow *.caddyserver.com deny 192.168.1.1/32 192.168.0.0/16 *.prohibitedsite.com *.localhost allow ::1/128 8.8.8.8 github.com *.github.io - allowfile /path/to/whitelist.txt - denyfile /path/to/blacklist.txt + allow_file /path/to/whitelist.txt + deny_file /path/to/blacklist.txt allow all deny all # unreachable rule, remaining requests are matched by `allow all` above } + } + file_server } ``` @@ -46,16 +51,16 @@ forwardproxy { ##### Security -- **basicauth [user] [password]** -Sets basic HTTP auth credentials. This property may be repeated multiple times. Note that this is different from Caddy's built-in `basicauth` directive. BE SURE TO CHECK THE NAME OF THE SITE THAT IS REQUESTING CREDENTIALS BEFORE YOU ENTER THEM. +- **basic_auth [user] [password]** +Sets basic HTTP auth credentials. This property may be repeated multiple times. Note that this is different from Caddy's built-in `basic_auth` directive. BE SURE TO CHECK THE NAME OF THE SITE THAT IS REQUESTING CREDENTIALS BEFORE YOU ENTER THEM. _Default: no authentication required._ - **probe_resistance [secretlink.tld]** Attempts to hide the fact that the site is a forward proxy. Proxy will no longer respond with "407 Proxy Authentication Required" if credentials are incorrect or absent, and will attempt to mimic a generic Caddy web server as if the forward proxy is not enabled. -Probing resistance works (and makes sense) only if basicauth is set up. -To use your proxy with probe resistance, supply your basicauth credentials to your client configuration. +Probing resistance works (and makes sense) only if `basic_auth` is set up. +To use your proxy with probe resistance, supply your `basic_auth` credentials to your client configuration. If your proxy client(browser, operating system, browser extension, etc) allows you to preconfigure credentials, and sends credentials preemptively, you do not need secret link. If your proxy client does not preemptively send credentials, you will have to visit your secret link in your browser to trigger the authentication. @@ -91,9 +96,9 @@ The hostname in each forwardproxy request will be resolved to an IP address, and caddy will check the IP address and hostname against the directives in order until a directive matches the request. acl_directive may be: - **allow [ip or subnet or hostname] [ip or subnet or hostname]...** - - **allowfile /path/to/whitelist.txt** + - **allow_file /path/to/whitelist.txt** - **deny [ip or subnet or hostname] [ip or subnet or hostname]...** - - **denyfile /path/to/blacklist.txt** + - **deny_file /path/to/blacklist.txt** If you don't want unmatched requests to be subject to the default policy, you could finish your acl rules with one of the following to specify action on unmatched requests: @@ -105,7 +110,7 @@ acl_directive may be: Note that hostname rules, matched early in the chain, will override later IP rules, so it is advised to put IP rules first, unless domains are highly trusted and should override the IP rules. Also note that domain-based blacklists are easily circumventable by directly specifying the IP. - For `allowfile`/`denyfile` directives, syntax is the same, and each entry must be separated by newline. + For `allow_file`/`deny_file` directives, syntax is the same, and each entry must be separated by newline. This policy applies to all requests except requests to the proxy's own domain and port. Whitelisting/blacklisting of ports on per-host/IP basis is not supported. _Default policy:_ @@ -117,10 +122,6 @@ _Default deny rules intend to prohibit access to localhost and local networks an ##### Timeouts -- **response_timeout [integer]** -Sets timeout (in seconds) to get full response for HTTP requests made by proxy on behalf of users (does not affect `CONNECT`-method requests). -_Default: no timeout._ - - **dial_timeout [integer]** Sets timeout (in seconds) for establishing TCP connection to target website. Affects all requests. _Default: 20 seconds._ diff --git a/forwardproxy.go b/forwardproxy.go index 5fb26fb..25b0567 100644 --- a/forwardproxy.go +++ b/forwardproxy.go @@ -11,23 +11,27 @@ import ( "fmt" "io" "io/ioutil" - "log" "net" "net/http" "net/url" + "os" "strconv" "strings" "sync" "time" "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" + "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile" "github.com/caddyserver/caddy/v2/modules/caddyhttp" "github.com/caddyserver/forwardproxy/httpclient" "golang.org/x/net/proxy" + "go.uber.org/zap" ) func init() { caddy.RegisterModule(Handler{}) + httpcaddyfile.RegisterHandlerDirective("forward_proxy", parseCaddyfile) } type ProbeResistance struct { @@ -35,6 +39,8 @@ type ProbeResistance struct { } type Handler struct { + logger *zap.Logger + PACPath string `json:"pac_path,omitempty"` HideIP bool `json:"hide_ip,omitempty"` @@ -46,7 +52,7 @@ type Handler struct { // port string // port on which chain with forwardproxy is listening on Hosts caddyhttp.MatchHost `json:"hosts,omitempty"` - ProbeResistance *ProbeResistance + ProbeResistance *ProbeResistance `json:"probe_resistance,omitempty"` DialTimeout caddy.Duration `json:"dial_timeout,omitempty"` // for initial tcp connection @@ -80,6 +86,8 @@ func (Handler) CaddyModule() caddy.ModuleInfo { // Provision ensures that h is set up properly before use. func (h *Handler) Provision(ctx caddy.Context) error { + h.logger = ctx.Logger(h) + if h.DialTimeout <= 0 { h.DialTimeout = caddy.Duration(30 * time.Second) } @@ -130,7 +138,7 @@ func (h *Handler) Provision(ctx caddy.Context) error { return fmt.Errorf("probe resistance requires authentication") } if len(h.ProbeResistance.Domain) > 0 { - log.Printf("Secret domain used to connect to proxy: %s\n", h.ProbeResistance.Domain) + h.logger.Info("Secret domain used to connect to proxy: " + h.ProbeResistance.Domain) } } @@ -167,7 +175,7 @@ func (h *Handler) Provision(ctx caddy.Context) error { if isLocalhost(h.upstream.Hostname()) && h.upstream.Scheme == "https" { // disabling verification helps with testing the package and setups // either way, it's impossible to have a legit TLS certificate for "127.0.0.1" - TODO: not true anymore - log.Println("Localhost upstream detected, disabling verification of TLS certificate") + h.logger.Info("Localhost upstream detected, disabling verification of TLS certificate") d.DialTLS = func(network string, address string) (net.Conn, string, error) { conn, err := tls.Dial(network, address, &tls.Config{InsecureSkipVerify: true}) if err != nil { @@ -695,8 +703,182 @@ type dialContexter interface { DialContext(ctx context.Context, network, address string) (net.Conn, error) } +func (h *Handler) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { + if !d.Next() { + return d.ArgErr() + } + args := d.RemainingArgs() + if len(args) > 0 { + return d.ArgErr() + } + for nesting := d.Nesting(); d.NextBlock(nesting); { + subdirective := d.Val() + args := d.RemainingArgs() + switch subdirective { + case "basic_auth": + if len(args) != 2 { + return d.ArgErr() + } + if len(args[0]) == 0 { + return d.Err("empty usernames are not allowed") + } + // TODO: Evaluate policy of allowing empty passwords. + if strings.Contains(args[0], ":") { + return d.Err("character ':' in usernames is not allowed") + } + // TODO: Support multiple basicauths. + if h.BasicauthUser != "" || h.BasicauthPass != "" { + return d.Err("Multi-user basicauth is not supported") + } + h.BasicauthUser = args[0] + h.BasicauthPass = args[1] + case "ports": + if len(args) == 0 { + return d.ArgErr() + } + if len(h.WhitelistedPorts) != 0 { + return d.Err("ports subdirective specified twice") + } + h.WhitelistedPorts = make([]int, len(args)) + for i, p := range args { + intPort, err := strconv.Atoi(p) + if intPort <= 0 || intPort > 65535 || err != nil { + return d.Err("ports are expected to be space-separated" + + " and in 0-65535 range. Got: " + p) + } + h.WhitelistedPorts[i] = intPort + } + case "hide_ip": + if len(args) != 0 { + return d.ArgErr() + } + h.HideIP = true + case "hide_via": + if len(args) != 0 { + return d.ArgErr() + } + h.HideVia = true + case "probe_resistance": + if len(args) > 1 { + return d.ArgErr() + } + if len(args) == 1 { + lowercaseArg := strings.ToLower(args[0]) + if lowercaseArg != args[0] { + h.logger.Warn("Secret domain appears to have uppercase letters in it, which are not visitable") + } + h.ProbeResistance = &ProbeResistance{Domain: args[0]} + } else { + h.ProbeResistance = &ProbeResistance{} + } + case "serve_pac": + if len(args) > 1 { + return d.ArgErr() + } + if len(h.PACPath) != 0 { + return d.Err("serve_pac subdirective specified twice") + } + if len(args) == 1 { + h.PACPath = args[0] + if !strings.HasPrefix(h.PACPath, "/") { + h.PACPath = "/" + h.PACPath + } + } else { + h.PACPath = "/proxy.pac" + } + h.logger.Info("Proxy Auto-Config will be served at " + h.PACPath) + case "dial_timeout": + if len(args) != 1 { + return d.ArgErr() + } + timeout, err := caddy.ParseDuration(args[0]) + if err != nil { + return d.ArgErr() + } + if timeout < 0 { + return d.Err("dial_timeout cannot be negative.") + } + h.DialTimeout = caddy.Duration(timeout) + case "upstream": + if len(args) != 1 { + return d.ArgErr() + } + if h.Upstream != "" { + return d.Err("upstream directive specified more than once") + } + h.Upstream = args[0] + case "acl": + for nesting := d.Nesting(); d.NextBlock(nesting); { + aclDirective := d.Val() + args := d.RemainingArgs() + if len(args) == 0 { + return d.ArgErr() + } + var ruleSubjects []string + var err error + aclAllow := false + switch aclDirective { + case "allow": + ruleSubjects = args[:] + aclAllow = true + case "allow_file": + if len(args) != 1 { + return d.Err("allowfile accepts a single filename argument") + } + ruleSubjects, err = readLinesFromFile(args[0]) + if err != nil { + return err + } + aclAllow = true + case "deny": + ruleSubjects = args[:] + case "deny_file": + if len(args) != 1 { + return d.Err("denyfile accepts a single filename argument") + } + ruleSubjects, err = readLinesFromFile(args[0]) + if err != nil { + return err + } + default: + return d.Err("expected acl directive: allow/allowfile/deny/denyfile." + + "got: " + aclDirective) + } + ar := ACLRule{Subjects: ruleSubjects, Allow: aclAllow} + h.ACL = append(h.ACL, ar) + } + default: + return d.ArgErr() + } + } + return nil +} + +func readLinesFromFile(filename string) ([]string, error) { + file, err := os.Open(filename) + if err != nil { + return nil, err + } + defer file.Close() + + var hostnames []string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + hostnames = append(hostnames, scanner.Text()) + } + + return hostnames, scanner.Err() +} + +func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) { + var fp Handler + err := fp.UnmarshalCaddyfile(h.Dispenser) + return &fp, err +} + // Interface guards var ( _ caddy.Provisioner = (*Handler)(nil) _ caddyhttp.MiddlewareHandler = (*Handler)(nil) + _ caddyfile.Unmarshaler = (*Handler)(nil) )