Compare commits

...

9 Commits

Author SHA1 Message Date
Simon Ser 60cab19e46 Don't print nil connection errors 2023-02-20 14:40:44 +01:00
delthas d314adee59 Add support for backend PROXY protocol v1
This is enabled with backend /* ... */ { proxy_version 1 }
2023-02-09 15:28:44 +01:00
Simon Ser 84ae2e62d6 Show more errors without -debug
Some errors should be surfaced back even without -debug: for
instance, failure to connect to the backend.
2023-01-27 11:04:36 +01:00
Simon Ser 151e7cf586 Add support for certificate fingerprint pinning 2023-01-27 10:55:53 +01:00
Simon Ser ce4e23e5d8 man: only one URI can be supplied to the backend directive
Multiple URIs is something worth supporting, but we're not there
yet.
2023-01-27 10:39:52 +01:00
Simon Ser 86308c9780 Fix ACME DNS challenge for top-level domains in a zone
e.g. "*.emersion.fr" when the zone is "emersion.fr".

Fixes: 662136ea74 ("Add support for ACME DNS hooks")
2023-01-26 19:14:08 +01:00
Simon Ser 662136ea74 Add support for ACME DNS hooks
Closes: https://todo.sr.ht/~emersion/tlstunnel/2
2023-01-26 17:04:45 +01:00
Simon Ser 3fd3471799 Silence connection errors by default
Often times the connection-level errors clutter the logs, for
instance with failed TLS handshakes or unknown hostname.
2023-01-26 11:43:59 +01:00
Simon Ser bb3c49e3b5 readme: restrict CI badge to master branch 2023-01-12 19:29:33 +01:00
7 changed files with 211 additions and 11 deletions

View File

@ -1,6 +1,6 @@
# [tlstunnel]
[![builds.sr.ht status](https://builds.sr.ht/~emersion/tlstunnel/commits.svg)](https://builds.sr.ht/~emersion/tlstunnel/commits?)
[![builds.sr.ht status](https://builds.sr.ht/~emersion/tlstunnel/commits/master.svg)](https://builds.sr.ht/~emersion/tlstunnel/commits/master?)
A TLS reverse proxy.

View File

@ -17,6 +17,8 @@ import (
var (
configPath = "config"
certDataPath = ""
debug = false
)
func newServer() (*tlstunnel.Server, error) {
@ -26,6 +28,7 @@ func newServer() (*tlstunnel.Server, error) {
}
srv := tlstunnel.NewServer()
srv.Debug = debug
loggerCfg := zap.Config{
Level: zap.NewAtomicLevelAt(zap.InfoLevel),
@ -68,6 +71,7 @@ func bumpOpenedFileLimit() error {
func main() {
flag.StringVar(&configPath, "config", configPath, "path to configuration file")
flag.BoolVar(&debug, "debug", false, "enable debug logging")
flag.Parse()
if err := bumpOpenedFileLimit(); err != nil {

View File

@ -1,12 +1,17 @@
package tlstunnel
import (
"crypto/sha256"
"crypto/subtle"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"fmt"
"net"
"net/url"
"os"
"os/exec"
"strconv"
"strings"
"git.sr.ht/~emersion/go-scfg"
@ -130,6 +135,64 @@ func parseBackend(backend *Backend, d *scfg.Directive) error {
return fmt.Errorf("failed to setup backend %q: unsupported URI scheme", backendURI)
}
for _, child := range d.Children {
switch child.Name {
case "tls_certfp":
if backend.TLSConfig == nil {
return fmt.Errorf("tls_certfp requires a tls:// backend address")
}
var algo, wantCertFP string
if err := child.ParseParams(&algo, &wantCertFP); err != nil {
return err
}
if algo != "sha-256" {
return fmt.Errorf("directive tls_certfp: only sha-256 is supported")
}
wantCertFP = strings.ReplaceAll(wantCertFP, ":", "")
wantSum, err := hex.DecodeString(wantCertFP)
if err != nil {
return fmt.Errorf("directive tls_certfp: invalid fingerprint: %v", err)
} else if len(wantSum) != sha256.Size {
return fmt.Errorf("directive tls_certfp: invalid fingerprint length")
}
backend.TLSConfig.InsecureSkipVerify = true
backend.TLSConfig.VerifyPeerCertificate = func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
if len(rawCerts) == 0 {
return fmt.Errorf("the server didn't present any TLS certificate")
}
for _, rawCert := range rawCerts {
sum := sha256.Sum256(rawCert)
if subtle.ConstantTimeCompare(sum[:], wantSum) == 1 {
return nil // fingerprints match
}
}
sum := sha256.Sum256(rawCerts[0])
remoteCertFP := hex.EncodeToString(sum[:])
return fmt.Errorf("configured TLS certificate fingerprint doesn't match the server's - %s", remoteCertFP)
}
case "proxy_version":
var version string
if err := child.ParseParams(&version); err != nil {
return err
}
v, err := strconv.Atoi(version)
if err != nil {
return fmt.Errorf("directive proxy_version: invalid version: %v", err)
}
switch v {
case 1, 2:
backend.ProxyVersion = v
default:
return fmt.Errorf("directive proxy_version: unknown version: %v", v)
}
}
}
return nil
}
@ -175,6 +238,19 @@ func parseTLS(srv *Server, d *scfg.Directive) error {
if err := parseTLSOnDemand(srv, child); err != nil {
return err
}
case "acme_dns_command":
var cmdName string
if err := child.ParseParams(&cmdName); err != nil {
return err
}
cmdParams := child.Params[1:]
srv.ACMEIssuer.DNS01Solver = &certmagic.DNS01Solver{
DNSProvider: &commandDNSProvider{
Name: cmdName,
Params: cmdParams,
},
}
default:
return fmt.Errorf("unknown %q directive", child.Name)
}

81
dns.go Normal file
View File

@ -0,0 +1,81 @@
package tlstunnel
import (
"context"
"fmt"
"os/exec"
"strings"
"github.com/caddyserver/certmagic"
"github.com/libdns/libdns"
)
type commandDNSProvider struct {
Name string
Params []string
}
var _ certmagic.ACMEDNSProvider = (*commandDNSProvider)(nil)
func (provider *commandDNSProvider) exec(ctx context.Context, subcmd string, subargs ...string) error {
var params []string
params = append(params, provider.Params...)
params = append(params, subcmd)
params = append(params, subargs...)
cmd := exec.CommandContext(ctx, provider.Name, params...)
if out, err := cmd.CombinedOutput(); err != nil {
details := ""
if len(out) > 0 {
details = ": " + string(out)
}
return fmt.Errorf("failed to run DNS hook %v (%w)%v", subcmd, err, details)
}
return nil
}
func (provider *commandDNSProvider) processRecords(ctx context.Context, zone string, recs []libdns.Record, subcmd string) ([]libdns.Record, error) {
var (
done []libdns.Record
err error
)
for _, rec := range recs {
var domain string
if domain, err = domainFromACMEChallengeRecord(zone, &rec); err != nil {
break
}
if err = provider.exec(ctx, subcmd, domain, "-", rec.Value); err != nil {
break
}
done = append(done, rec)
}
return done, err
}
func (provider *commandDNSProvider) AppendRecords(ctx context.Context, zone string, recs []libdns.Record) ([]libdns.Record, error) {
return provider.processRecords(ctx, zone, recs, "deploy_challenge")
}
func (provider *commandDNSProvider) DeleteRecords(ctx context.Context, zone string, recs []libdns.Record) ([]libdns.Record, error) {
return provider.processRecords(ctx, zone, recs, "clean_challenge")
}
func domainFromACMEChallengeRecord(zone string, rec *libdns.Record) (string, error) {
relZone := strings.TrimSuffix(zone, ".")
var domain string
if rec.Name == "_acme-challenge" {
// Root domain
domain = relZone
} else if strings.HasPrefix(rec.Name, "_acme-challenge.") {
// Subdomain
relName := strings.TrimPrefix(rec.Name, "_acme-challenge.")
domain = relName + "." + relZone
}
if rec.Type != "TXT" || domain == "" {
return "", fmt.Errorf("DNS record doesn't look like an ACME challenge: %v %v", rec.Type, rec.Name)
}
return domain, nil
}

1
go.mod
View File

@ -6,6 +6,7 @@ require (
git.sr.ht/~emersion/go-scfg v0.0.0-20211215104734-c2c7a15d6c99
github.com/caddyserver/certmagic v0.17.2
github.com/klauspost/cpuid/v2 v2.2.1 // indirect
github.com/libdns/libdns v0.2.1
github.com/pires/go-proxyproto v0.6.2
github.com/pkg/errors v0.9.1 // indirect
go.uber.org/atomic v1.10.0 // indirect

View File

@ -38,6 +38,7 @@ func newACMECache() *acmeCache {
type Server struct {
Listeners map[string]*Listener // indexed by listening address
Frontends []*Frontend
Debug bool
ManagedNames []string
UnmanagedCerts []tls.Certificate
@ -186,6 +187,10 @@ func (srv *Server) Replace(old *Server) error {
return nil
}
type clientError struct {
error
}
type listenerHandles struct {
Server *Server
Frontends map[string]*Frontend // indexed by server name
@ -256,8 +261,14 @@ func (ln *Listener) serve() error {
}
go func() {
if err := ln.handle(conn); err != nil {
log.Printf("listener %q: %v", ln.Address, err)
err := ln.handle(conn)
if err == nil {
return
}
srv := ln.atomic.Load().(*listenerHandles).Server
var clientErr clientError
if !errors.As(err, &clientErr) || srv.Debug {
log.Printf("listener %q: connection %q: %v", ln.Address, conn.RemoteAddr(), err)
}
}()
}
@ -298,7 +309,7 @@ func (ln *Listener) handle(conn net.Conn) error {
if err := tlsConn.Handshake(); err == io.EOF {
return nil
} else if err != nil {
return fmt.Errorf("TLS handshake failed: %v", err)
return clientError{fmt.Errorf("TLS handshake failed: %v", err)}
}
if err := tlsConn.SetDeadline(time.Time{}); err != nil {
return fmt.Errorf("failed to reset TLS handshake timeout: %v", err)
@ -358,7 +369,7 @@ func (fe *Frontend) handle(downstream net.Conn, tlsState *tls.ConnectionState) e
defer upstream.Close()
if be.Proxy {
h := proxyproto.HeaderProxyFromAddrs(2, downstream.RemoteAddr(), downstream.LocalAddr())
h := proxyproto.HeaderProxyFromAddrs(byte(be.ProxyVersion), downstream.RemoteAddr(), downstream.LocalAddr())
var tlvs []proxyproto.TLV
if tlsState.ServerName != "" {
@ -382,16 +393,17 @@ func (fe *Frontend) handle(downstream net.Conn, tlsState *tls.ConnectionState) e
}
if err := duplexCopy(upstream, downstream); err != nil {
return fmt.Errorf("failed to copy bytes: %v", err)
return clientError{fmt.Errorf("failed to copy bytes: %v", err)}
}
return nil
}
type Backend struct {
Network string
Address string
Proxy bool
TLSConfig *tls.Config // nil if no TLS
Network string
Address string
Proxy bool
ProxyVersion int
TLSConfig *tls.Config // nil if no TLS
}
func duplexCopy(a, b io.ReadWriter) error {

View File

@ -52,7 +52,7 @@ The following directives are supported:
*listen* <address>...
Additional addresses to listen on.
*backend* <uri>...
*backend* <uri> { ... }
Backend to forward incoming connections to.
The following URIs are supported:
@ -64,6 +64,25 @@ The following directives are supported:
The _+proxy_ suffix can be added to the URI scheme to forward
connection metadata via the PROXY protocol.
The backend directive supports the following sub-directives:
*tls_certfp* sha-256 <fingerprint>
Instead of using CAs to check the TLS certificate provided by the
backend, check that the certificate matches the provided
fingerprint. This can be used to connect to servers with a
self-signed certificate, for instance.
The fingerprint of a certificate can be obtained via *openssl*(1):
```
openssl x509 -fingerprint -sha256 -noout <certificate>
```
*proxy_version* <version>
PROXY protocol version to use, if _+proxy_ is specified.
The supported versions are 1 and 2.
If not specified, the PROXY version used defaults to version 2.
*tls* { ... }
Customise frontend-specific TLS configuration.
@ -123,6 +142,13 @@ The following directives are supported:
The environment will contain a *TLSTUNNEL_NAME* variable with the
domain name to be validated.
*acme_dns_command* command [arguments...]
Configure the ACME DNS challenge using the specified command.
The command must implement _deploy_challenge_ and _clean_challenge_
as specified by dehydrated's hooks:
https://github.com/dehydrated-io/dehydrated
# FILES
_/etc/tlstunnel/config_