Skip to content

Commit c6a0996

Browse files
committed
Support configurable routing of ACME tls-sni-01 challenges.
By design, the tls-sni-01 challenge does not reveal information about the domain being verified, so the proxy cannot "naively" route such requests. Instead, it probes the Targets of all SNI routes, looking for one that responds plausibly to the challenge hostname, and routes the client connection to that. ACME support can be turned off by inserting AddStopAcmeSearch in the route chain. Subsequently registered SNI routes will not be probed by ACME challenges.
1 parent 815c942 commit c6a0996

File tree

3 files changed

+302
-15
lines changed

3 files changed

+302
-15
lines changed

sni.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,43 @@ package tcpproxy
1717
import (
1818
"bufio"
1919
"bytes"
20+
"context"
2021
"crypto/tls"
2122
"io"
2223
"net"
24+
"strings"
2325
)
2426

2527
// AddSNIRoute appends a route to the ipPort listener that says if the
2628
// incoming TLS SNI server name is sni, the connection is given to
2729
// dest. If it doesn't match, rule processing continues for any
2830
// additional routes on ipPort.
2931
//
32+
// By default, the proxy will route all ACME tls-sni-01 challenges
33+
// received on ipPort to all SNI dests. You can disable ACME routing
34+
// with AddStopACMESearch.
35+
//
3036
// The ipPort is any valid net.Listen TCP address.
3137
func (p *Proxy) AddSNIRoute(ipPort, sni string, dest Target) {
38+
cfg := p.configFor(ipPort)
39+
if !cfg.stopACME {
40+
if len(cfg.acmeTargets) == 0 {
41+
p.addRoute(ipPort, &acmeMatch{cfg})
42+
}
43+
cfg.acmeTargets = append(cfg.acmeTargets, dest)
44+
}
45+
3246
p.addRoute(ipPort, sniMatch{sni, dest})
3347
}
3448

49+
// AddStopACMESearch prevents ACME probing of subsequent SNI routes.
50+
// Any ACME challenges on ipPort for SNI routes previously added
51+
// before this call will still be proxied to all possible SNI
52+
// backends.
53+
func (p *Proxy) AddStopACMESearch(ipPort string) {
54+
p.configFor(ipPort).stopACME = true
55+
}
56+
3557
type sniMatch struct {
3658
sni string
3759
target Target
@@ -44,6 +66,79 @@ func (m sniMatch) match(br *bufio.Reader) Target {
4466
return nil
4567
}
4668

69+
// acmeMatch matches "*.acme.invalid" ACME tls-sni-01 challenges and
70+
// searches for a Target in cfg.acmeTargets that has the challenge
71+
// response.
72+
type acmeMatch struct {
73+
cfg *config
74+
}
75+
76+
func (m *acmeMatch) match(br *bufio.Reader) Target {
77+
sni := clientHelloServerName(br)
78+
if !strings.HasSuffix(sni, ".acme.invalid") {
79+
return nil
80+
}
81+
82+
// TODO: cache. ACME issuers will hit multiple times in a short
83+
// burst for each issuance event. A short TTL cache + singleflight
84+
// should have an excellent hit rate.
85+
// TODO: maybe an acme-specific timeout as well?
86+
// TODO: plumb context upwards?
87+
ctx, cancel := context.WithCancel(context.Background())
88+
defer cancel()
89+
90+
ch := make(chan Target, len(m.cfg.acmeTargets))
91+
for _, target := range m.cfg.acmeTargets {
92+
go tryACME(ctx, ch, target, sni)
93+
}
94+
for range m.cfg.acmeTargets {
95+
if target := <-ch; target != nil {
96+
return target
97+
}
98+
}
99+
100+
// No target was happy with the provided challenge.
101+
return nil
102+
}
103+
104+
func tryACME(ctx context.Context, ch chan<- Target, dest Target, sni string) {
105+
var ret Target
106+
defer func() { ch <- ret }()
107+
108+
conn, targetConn := net.Pipe()
109+
defer conn.Close()
110+
go dest.HandleConn(targetConn)
111+
112+
deadline, ok := ctx.Deadline()
113+
if ok {
114+
conn.SetDeadline(deadline)
115+
}
116+
117+
client := tls.Client(conn, &tls.Config{
118+
ServerName: sni,
119+
InsecureSkipVerify: true,
120+
})
121+
if err := client.Handshake(); err != nil {
122+
// TODO: log?
123+
return
124+
}
125+
certs := client.ConnectionState().PeerCertificates
126+
if len(certs) == 0 {
127+
// TODO: log?
128+
return
129+
}
130+
// acme says the first cert offered by the server must match the
131+
// challenge hostname.
132+
if err := certs[0].VerifyHostname(sni); err != nil {
133+
// TODO: log?
134+
return
135+
}
136+
137+
// Target presented what looks like a valid challenge
138+
// response, send it back to the matcher.
139+
ret = dest
140+
}
141+
47142
// clientHelloServerName returns the SNI server name inside the TLS ClientHello,
48143
// without consuming any bytes from br.
49144
// On any error, the empty string is returned.

tcpproxy.go

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ import (
6969
// The order that routes are added in matters; each is matched in the order
7070
// registered.
7171
type Proxy struct {
72-
routes map[string][]route // ip:port => routes
72+
configs map[string]*config // ip:port => config
7373

7474
lns []net.Listener
7575
donec chan struct{} // closed before err
@@ -81,7 +81,22 @@ type Proxy struct {
8181
ListenFunc func(net, laddr string) (net.Listener, error)
8282
}
8383

84+
// config contains the proxying state for one listener.
85+
type config struct {
86+
routes []route
87+
acmeTargets []Target // accumulates targets that should be probed for acme.
88+
stopACME bool // if true, AddSNIRoute doesn't add targets to acmeTargets.
89+
}
90+
91+
// A route matches a connection to a target.
8492
type route interface {
93+
// match examines the initial bytes of a connection, looking for a
94+
// match. If a match is found, match returns a non-nil Target to
95+
// which the stream should be proxied. match returns nil if the
96+
// connection doesn't match.
97+
//
98+
// match must not consume bytes from the given bufio.Reader, it
99+
// can only Peek.
85100
match(*bufio.Reader) Target
86101
}
87102

@@ -92,11 +107,19 @@ func (p *Proxy) netListen() func(net, laddr string) (net.Listener, error) {
92107
return net.Listen
93108
}
94109

95-
func (p *Proxy) addRoute(ipPort string, r route) {
96-
if p.routes == nil {
97-
p.routes = make(map[string][]route)
110+
func (p *Proxy) configFor(ipPort string) *config {
111+
if p.configs == nil {
112+
p.configs = make(map[string]*config)
98113
}
99-
p.routes[ipPort] = append(p.routes[ipPort], r)
114+
if p.configs[ipPort] == nil {
115+
p.configs[ipPort] = &config{}
116+
}
117+
return p.configs[ipPort]
118+
}
119+
120+
func (p *Proxy) addRoute(ipPort string, r route) {
121+
cfg := p.configFor(ipPort)
122+
cfg.routes = append(cfg.routes, r)
100123
}
101124

102125
// AddRoute appends an always-matching route to the ipPort listener,
@@ -107,14 +130,14 @@ func (p *Proxy) addRoute(ipPort string, r route) {
107130
//
108131
// The ipPort is any valid net.Listen TCP address.
109132
func (p *Proxy) AddRoute(ipPort string, dest Target) {
110-
p.addRoute(ipPort, alwaysMatch{dest})
133+
p.addRoute(ipPort, fixedTarget{dest})
111134
}
112135

113-
type alwaysMatch struct {
136+
type fixedTarget struct {
114137
t Target
115138
}
116139

117-
func (m alwaysMatch) match(*bufio.Reader) Target { return m.t }
140+
func (m fixedTarget) match(*bufio.Reader) Target { return m.t }
118141

119142
// Run is calls Start, and then Wait.
120143
//
@@ -155,16 +178,16 @@ func (p *Proxy) Start() error {
155178
return errors.New("already started")
156179
}
157180
p.donec = make(chan struct{})
158-
errc := make(chan error, len(p.routes))
159-
p.lns = make([]net.Listener, 0, len(p.routes))
160-
for ipPort, routes := range p.routes {
181+
errc := make(chan error, len(p.configs))
182+
p.lns = make([]net.Listener, 0, len(p.configs))
183+
for ipPort, config := range p.configs {
161184
ln, err := p.netListen()("tcp", ipPort)
162185
if err != nil {
163186
p.Close()
164187
return err
165188
}
166189
p.lns = append(p.lns, ln)
167-
go p.serveListener(errc, ln, routes)
190+
go p.serveListener(errc, ln, config.routes)
168191
}
169192
go p.awaitFirstError(errc)
170193
return nil

0 commit comments

Comments
 (0)