Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ configuration file. Below is a list of supported options.
* `bind_ip="ip"` -- specify which IP will be used for outgoing connections.
* `add_headers=[["header1", "value1"], ["header2", "value2"]...]` -- adds the specified headers to outgoing HTTP requests, this option will not work for HTTPS connections.
* `read_timeout`/`write_timeout` -- how long a single read or write on a proxied connection may take, e.g. `"15m"`. A negative value disables the deadline. Default: 15 minutes.
* `tls_cert_file`/`tls_key_file` -- the proxy's own certificate and key. Setting them makes the proxy listener accept TLS connections instead of plain ones. Both are needed, or neither.
* `enable_health_check="on"` -- track whether the proxy is serving requests successfully and expose the result at `/health`. Needs `http_listen`.
* `http_listen="ip:port"` -- ip address and port the `/health` endpoint is served on. This is not a proxy listener; nothing is proxied there.
* `health_failure_limit=N` -- how many consecutive failures make the proxy unhealthy. Default: 5.
Expand Down Expand Up @@ -191,6 +192,49 @@ computing the digest it would have produced. When no credentials are configured,
SOCKS clients are accepted without authentication and the network ACLs are the
only thing limiting them.

### Encrypting the connection to the proxy

By default a client talks to the proxy in the clear, which puts the
`Proxy-Authorization` credentials and the host names of every `CONNECT` request
on the local network. Setting `tls_cert_file` and `tls_key_file` makes the proxy
listener accept TLS, so the client speaks the proxy protocol inside a TLS
connection — what a browser calls an HTTPS proxy.

```toml
listen="0.0.0.0:3128"
tls_cert_file="/etc/microproxy/proxy.crt"
tls_key_file="/etc/microproxy/proxy.key"
```

The certificate is the proxy's own, for the name its clients reach it by. It is
re-read on `USR2` along with the configuration, so a renewal is picked up
without a restart, and a pair that can't be read leaves the one in force in
place. Connections already established keep the certificate they started with.

This does not decrypt anything: what a client sends through `CONNECT` stays
opaque to the proxy. It protects the hop between the client and the proxy, not
the traffic inside it.

The SOCKS frontend is unaffected — SOCKS5 has no TLS convention — and so is the
`/health` endpoint, which stays plain HTTP on `http_listen`.

Note that not every client can be configured to use an HTTPS proxy: browsers
generally can, through a PAC file or a command-line flag, while system-wide
proxy settings on some platforms only accept a plain one. Check before turning
it on for an existing deployment.

In Go, the same thing is `WithListenerTLS`:

```go
srv, err := microproxy.New(cfg, microproxy.WithListenerTLS(&tls.Config{
GetCertificate: myCertSource.GetCertificate,
}))
```

`Serve` and `ListenAndServe` then wrap the listener themselves, so do not pass
an already wrapped one as well. TLS 1.2 is applied as a floor when the
configuration does not set one.

### Health

`enable_health_check="on"` makes the proxy count how its work goes: a run of
Expand Down
63 changes: 63 additions & 0 deletions cmd/microproxy/certs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package main

import (
"crypto/tls"
"fmt"
"sync"
)

// certificateReloader holds the proxy's own certificate and can replace it
// without restarting, which is what a 90-day certificate needs.
//
// tls.Config asks for the certificate on every handshake, so a reload takes
// effect on the next connection and the ones already established are left
// alone.
type certificateReloader struct {
certFile string
keyFile string

mu sync.RWMutex
certificate *tls.Certificate
}

// newCertificateReloader loads the pair once, so that a bad path or an
// unreadable key is reported at startup rather than on the first client.
func newCertificateReloader(certFile, keyFile string) (*certificateReloader, error) {
reloader := &certificateReloader{certFile: certFile, keyFile: keyFile}

if err := reloader.reload(); err != nil {
return nil, err
}

return reloader, nil
}

// reload re-reads the pair from disk. A pair that can't be used leaves the one
// in force untouched, so a half-written file during renewal does not take the
// listener down.
func (r *certificateReloader) reload() error {
certificate, err := tls.LoadX509KeyPair(r.certFile, r.keyFile)
if err != nil {
return fmt.Errorf("couldn't load the certificate %v and key %v: %w", r.certFile, r.keyFile, err)
}

r.mu.Lock()
defer r.mu.Unlock()

r.certificate = &certificate

return nil
}

// tlsConfig serves the certificate in force at the time of each handshake.
func (r *certificateReloader) tlsConfig() *tls.Config {
return &tls.Config{
MinVersion: tls.VersionTLS12,
GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
r.mu.RLock()
defer r.mu.RUnlock()

return r.certificate, nil
},
}
}
156 changes: 156 additions & 0 deletions cmd/microproxy/certs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package main

import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"testing"
"time"
)

// writeKeyPair puts a self-signed certificate and its key at the given paths,
// with serial as the way to tell one from another.
func writeKeyPair(t *testing.T, certPath, keyPath string, serial int64) {
t.Helper()

key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}

template := x509.Certificate{
SerialNumber: big.NewInt(serial),
Subject: pkix.Name{CommonName: "microproxy"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
}

der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}

certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
if err := os.WriteFile(certPath, certPEM, 0o600); err != nil {
t.Fatal(err)
}

keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
t.Fatal(err)
}

keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
t.Fatal(err)
}
}

func serialOf(t *testing.T, reloader *certificateReloader) int64 {
t.Helper()

certificate, err := reloader.tlsConfig().GetCertificate(nil)
if err != nil {
t.Fatal(err)
}

leaf, err := x509.ParseCertificate(certificate.Certificate[0])
if err != nil {
t.Fatal(err)
}

return leaf.SerialNumber.Int64()
}

// A renewed certificate has to be picked up without restarting the proxy.
func TestCertificateReloader(t *testing.T) {
dir := t.TempDir()
certPath := filepath.Join(dir, "proxy.crt")
keyPath := filepath.Join(dir, "proxy.key")

writeKeyPair(t, certPath, keyPath, 1)

reloader, err := newCertificateReloader(certPath, keyPath)
if err != nil {
t.Fatal(err)
}

if serial := serialOf(t, reloader); serial != 1 {
t.Fatalf("expected the first certificate, got serial %v", serial)
}

// renewal replaces the files underneath the running proxy
writeKeyPair(t, certPath, keyPath, 2)

if serial := serialOf(t, reloader); serial != 1 {
t.Error("expected the certificate in force to be unchanged until a reload")
}

if err := reloader.reload(); err != nil {
t.Fatal(err)
}

if serial := serialOf(t, reloader); serial != 2 {
t.Errorf("expected the renewed certificate, got serial %v", serial)
}
}

// A half-written or broken pair must not take the listener down.
func TestCertificateReloaderKeepsTheCurrentPairOnError(t *testing.T) {
dir := t.TempDir()
certPath := filepath.Join(dir, "proxy.crt")
keyPath := filepath.Join(dir, "proxy.key")

writeKeyPair(t, certPath, keyPath, 1)

reloader, err := newCertificateReloader(certPath, keyPath)
if err != nil {
t.Fatal(err)
}

if err := os.WriteFile(certPath, []byte("half a certificate"), 0o600); err != nil {
t.Fatal(err)
}

if err := reloader.reload(); err == nil {
t.Error("expected a broken pair to be reported")
}

if serial := serialOf(t, reloader); serial != 1 {
t.Errorf("expected the working certificate to still be served, got serial %v", serial)
}
}

// A pair that cannot be read at all is a startup error, not a surprise on the
// first client.
func TestCertificateReloaderReportsAMissingPair(t *testing.T) {
dir := t.TempDir()

if _, err := newCertificateReloader(
filepath.Join(dir, "absent.crt"), filepath.Join(dir, "absent.key")); err == nil {
t.Error("expected a missing certificate to be reported at startup")
}
}

// The two settings only make sense together.
func TestConfigRejectsAHalfConfiguredCertificate(t *testing.T) {
for name, contents := range map[string]string{
"cert without key": "tls_cert_file = \"/tmp/proxy.crt\"\n",
"key without cert": "tls_key_file = \"/tmp/proxy.key\"\n",
} {
path := filepath.Join(t.TempDir(), "microproxy.toml")
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}

if _, err := loadConfig(path); err == nil {
t.Errorf("%v: expected a configuration error", name)
}
}
}
15 changes: 15 additions & 0 deletions cmd/microproxy/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@ type fileConfig struct {
// HTTPListen is where the /health endpoint is served. The proxy itself
// does not listen there.
HTTPListen string `toml:"http_listen"`

// TLSCertFile and TLSKeyFile turn the proxy listener into a TLS one, so
// that clients speak the proxy protocol inside a TLS connection. They are
// the proxy's own certificate, for the name its clients reach it by.
TLSCertFile string `toml:"tls_cert_file"`
TLSKeyFile string `toml:"tls_key_file"`
}

// tlsEnabled reports whether the proxy listener should speak TLS.
func (c *fileConfig) tlsEnabled() bool {
return c.TLSCertFile != "" || c.TLSKeyFile != ""
}

// loadConfig reads and validates a configuration file. It reports an error
Expand Down Expand Up @@ -66,6 +77,10 @@ func (c *fileConfig) validate() error {
return fmt.Errorf("'enable_health_check' needs 'http_listen' to serve the endpoint on")
}

if c.tlsEnabled() && (c.TLSCertFile == "" || c.TLSKeyFile == "") {
return fmt.Errorf("'tls_cert_file' and 'tls_key_file' have to be given together")
}

return c.Config.Validate()
}

Expand Down
33 changes: 32 additions & 1 deletion cmd/microproxy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ type proxy struct {
// healthServer is the separate listener the /health endpoint is served
// on, when the configuration asks for one.
healthServer *http.Server

// certificates holds the proxy's own certificate when the listener speaks
// TLS, so that a renewed one can be picked up without a restart.
certificates *certificateReloader
}

func run(conf *fileConfig, path string, verbose, insecure bool) error {
Expand Down Expand Up @@ -94,12 +98,28 @@ func run(conf *fileConfig, path string, verbose, insecure bool) error {
}
options = append(options, microproxy.WithCredentials(credentials))

var certificates *certificateReloader

if conf.tlsEnabled() {
if certificates, err = newCertificateReloader(conf.TLSCertFile, conf.TLSKeyFile); err != nil {
return err
}

options = append(options, microproxy.WithListenerTLS(certificates.tlsConfig()))
}

server, err := microproxy.New(conf.Config, options...)
if err != nil {
return err
}

command := &proxy{server: server, activity: activity, access: access, path: path}
command := &proxy{
server: server,
activity: activity,
access: access,
path: path,
certificates: certificates,
}
command.handleSignals()

activity.Printf("starting proxy\n")
Expand Down Expand Up @@ -240,5 +260,16 @@ func (p *proxy) reload() {
return
}

// The certificate is re-read from the paths the running proxy started
// with: the listener is already up, so a reload can't move it to a
// different file, and a renewal replaces the contents in place anyway.
if p.certificates != nil {
if err := p.certificates.reload(); err != nil {
p.activity.Printf("ERROR: couldn't reload the certificate, keeping the current one: %v\n", err)
} else {
p.activity.Printf("certificate reloaded\n")
}
}

p.activity.Printf("configuration reloaded\n")
}
Loading
Loading