├── go.mod ├── go.sum ├── autocertdelegate_test.go ├── LICENSE ├── README.md └── autocertdelegate.go /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/bradfitz/autocertdelegate 2 | 3 | go 1.13 4 | 5 | require golang.org/x/crypto v0.0.0-20191219195013-becbf705a915 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 2 | golang.org/x/crypto v0.0.0-20191219195013-becbf705a915 h1:aJ0ex187qoXrJHPo8ZasVTASQB7llQP6YeNzgDALPRk= 3 | golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 4 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 h1:0GoQqolDA55aaLxZyTzK/Y2ePZzZTUrRacwib7cNsYQ= 5 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 6 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 7 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 8 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 9 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 10 | -------------------------------------------------------------------------------- /autocertdelegate_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package autocertdelegate 6 | 7 | import "testing" 8 | 9 | func TestValidChallengeAddr(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | want bool 13 | }{ 14 | {"10.0.0.1", true}, 15 | {"192.168.5.2", true}, 16 | {"8.8.8.8", false}, 17 | {"", false}, 18 | {"::1", false}, // yet 19 | } 20 | for _, tt := range tests { 21 | got := validChallengeAddr(tt.name) 22 | if got != tt.want { 23 | t.Errorf("validChallengeAddr(%q) = %v; want %v", tt.name, got, tt.want) 24 | } 25 | } 26 | } 27 | 28 | func TestValidDelegateServerName(t *testing.T) { 29 | tests := []struct { 30 | name string 31 | want bool 32 | }{ 33 | {"", false}, 34 | {"foo", false}, 35 | {"::1", false}, 36 | {"foo.com:123", false}, 37 | {"8.8.8.8", false}, 38 | {"cams.int.example.net", true}, 39 | {"cams.int.example.net/foo", false}, 40 | } 41 | for _, tt := range tests { 42 | got := validDelegateServerName(tt.name) 43 | if got != tt.want { 44 | t.Errorf("validDelegateServerName(%q) = %v; want %v", tt.name, got, tt.want) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 The Go Authors (https://golang.org/AUTHORS). 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are 6 | met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above 11 | copyright notice, this list of conditions and the following disclaimer 12 | in the documentation and/or other materials provided with the 13 | distribution. 14 | * Neither the name of Google Inc. nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 19 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 21 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 22 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 23 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 24 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 25 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 26 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # autocertdelegate 2 | 3 | ## What 4 | 5 | [I wanted](https://twitter.com/bradfitz/status/1206058552357355520) 6 | internal HTTPS servers to have valid TLS certs with minimal fuss. 7 | 8 | In particular: 9 | 10 | * I didn't want to deal with being my own CA or configuring all my 11 | devices to trust a new root. 12 | * I didn't want to use LetsEncrypt DNS challenges because there are 13 | tons of DNS providers and I don't want API clients for tons of DNS 14 | providers and I don't want to configure secrets (or anything) 15 | anywhere. 16 | * I don't want to expose my internal services to the internet or deal 17 | with updating firewall rules to only allow LetsEncrypt. 18 | 19 | ## How 20 | 21 | See https://godoc.org/github.com/bradfitz/autocertdelegate 22 | 23 | It provides a client that plugs in to an http.Server to get certs & a 24 | server handler for a public-facing server that does the LetsEncrypt 25 | ALPN challenges. You then do split-horizon DNS to give out internal 26 | IPs to internal clients and a public IP (of the delegate server) to 27 | everybody else (namely LetsEncrypt doing the ALPN challenges). 28 | 29 | Then internal clients just ask the delegate server for the certs, and 30 | the delegate server does a little challenge itself to test the 31 | internal clients. 32 | 33 | ## Is it secure? 34 | 35 | I built this for my own use on my home network. 36 | Maybe you'll find it useful, but maybe you'll find it insecure. 37 | Beauty is in the eye of the downloader. 38 | 39 | ## Contributing 40 | 41 | I'm releasing as a Go project under the Go AUTHORs/LICENSEs, as it's 42 | related to golang.org/x/crypto/acme/autocert. As such, I'm not 43 | accepting any PRs unless you've contributed to Go or otherwise done 44 | the Google CLA. 45 | -------------------------------------------------------------------------------- /autocertdelegate.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | // Package autocertdelegate provides a mechanism to provision LetsEncrypt certs 6 | // for internal LAN TLS servers (that aren't reachable publicly) via a delegated 7 | // server that is. 8 | // 9 | // See also https://github.com/bradfitz/autocertdelegate. 10 | package autocertdelegate 11 | 12 | import ( 13 | "bytes" 14 | "context" 15 | "crypto/hmac" 16 | "crypto/rand" 17 | "crypto/sha256" 18 | "crypto/tls" 19 | "errors" 20 | "fmt" 21 | "io" 22 | "io/ioutil" 23 | "log" 24 | "net" 25 | "net/http" 26 | "net/url" 27 | "strconv" 28 | "strings" 29 | "time" 30 | 31 | "golang.org/x/crypto/acme" 32 | "golang.org/x/crypto/acme/autocert" 33 | ) 34 | 35 | // Server is an http.Handler that runs on the Internet-facing daemon 36 | // and gets the TLS certs from LetsEncrypt (using ALPN challenges) and 37 | // gives them out to internal clients. 38 | // 39 | // It will only give them out to internal clients whose DNS names 40 | // resolve to internal IP addresses and who can provide that they are 41 | // running code on that IP address. (This assumes that such hostnames 42 | // aren't multi-user systems with untrusted users.) 43 | type Server struct { 44 | am *autocert.Manager 45 | key []byte 46 | } 47 | 48 | // NewServer returns a new server given an autocert.Manager 49 | // configuration. 50 | func NewServer(am *autocert.Manager) *Server { 51 | key := make([]byte, 64) 52 | if _, err := rand.Read(key); err != nil { 53 | panic(err) 54 | } 55 | return &Server{ 56 | am: am, 57 | key: key, 58 | } 59 | } 60 | 61 | // validDelegateServerName reports whether n is a valid name that we 62 | // can be a delegate cert fetcher for. It must be a bare DNS name (no 63 | // port, not an IP address). 64 | func validDelegateServerName(n string) bool { 65 | if n == "" { 66 | return false 67 | } 68 | if !strings.Contains(n, ".") { 69 | return false 70 | } 71 | if strings.Contains(n, ":") { 72 | // Contains port or is IPv6 literal. 73 | return false 74 | } 75 | if net.ParseIP(n) != nil { 76 | // No IPs. 77 | return false 78 | } 79 | if "x://"+n != (&url.URL{Scheme: "x", Host: n}).String() { 80 | // name must have contained invalid characters and caused escaping. 81 | return false 82 | } 83 | return true 84 | } 85 | 86 | // validChallengeAddr reports whether a is a valid IP address to serve 87 | // a delegated cert to. 88 | func validChallengeAddr(a string) bool { 89 | // TODO: flesh this out. parse a, make configurable, support 90 | // IPv6. Good enough for now. 91 | return strings.HasPrefix(a, "10.") || strings.HasPrefix(a, "192.168.") 92 | } 93 | 94 | // badServerName says that something's wrong with the servername 95 | // parameter, without saying what, as this might be hit by the outside world. 96 | func badServerName(w http.ResponseWriter) { 97 | http.Error(w, "missing or invalid servername", 403) // intentionally vague 98 | } 99 | 100 | func challengeAnswer(masterKey []byte, serverName string, t time.Time) string { 101 | hm := hmac.New(sha256.New, masterKey) 102 | fmt.Fprintf(hm, "%s-%d", serverName, t.Unix()) 103 | return fmt.Sprintf("%x", hm.Sum(nil)) 104 | } 105 | 106 | // ServeHTTP is the HTTP handler to get challenges & certs for the Client. 107 | // The Handler only responds to GET requests over TLS. It can be installed 108 | // at any path, but the client only makes requests to the root. It's assumed 109 | // that any existing HTTP mux is routing based on the hostname. 110 | func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { 111 | if r.TLS == nil { 112 | http.Error(w, "TLS required", 403) 113 | return 114 | } 115 | if r.Method != "GET" { 116 | http.Error(w, "wrong method; want GET", 400) 117 | return 118 | } 119 | serverName := r.FormValue("servername") 120 | if !validDelegateServerName(serverName) { 121 | log.Printf("autocertdelegate: invalid server name %q", serverName) 122 | badServerName(w) 123 | return 124 | } 125 | if err := s.am.HostPolicy(r.Context(), serverName); err != nil { 126 | log.Printf("autocertdelegate: %q denied by configured HostPolicy: %v", serverName, err) 127 | badServerName(w) 128 | return 129 | } 130 | 131 | switch r.FormValue("mode") { 132 | default: 133 | http.Error(w, "unknown or missing mode argument", 400) 134 | return 135 | case "getchallenge": 136 | t := time.Now() 137 | fmt.Fprintf(w, "%s/%d/%s\n", serverName, t.Unix(), challengeAnswer(s.key, serverName, t)) 138 | return 139 | case "getcert": 140 | } 141 | 142 | // Verify serverName resolves to a local IP. 143 | lookupCtx, cancel := context.WithTimeout(r.Context(), 5*time.Second) 144 | defer cancel() 145 | var resolver net.Resolver 146 | resolver.PreferGo = true 147 | addrs, err := resolver.LookupHost(lookupCtx, serverName) 148 | if err != nil { 149 | log.Printf("autocertdelegate: lookup %q error: %v", serverName, err) 150 | badServerName(w) 151 | return 152 | } 153 | if len(addrs) != 1 { 154 | log.Printf("autocertDelegate: invalid server name %q; wrong number of resolved addrs. Want 1; got: %q", serverName, addrs) 155 | badServerName(w) 156 | return 157 | } 158 | challengeIP := addrs[0] 159 | if !validChallengeAddr(challengeIP) { 160 | log.Printf("autocertDelegate: server name %q resolved to invalid challenge IP %q", serverName, challengeIP) 161 | badServerName(w) 162 | return 163 | } 164 | 165 | challengePort, err := strconv.Atoi(r.FormValue("challengeport")) 166 | if err != nil || challengePort < 0 || challengePort > 64<<10 { 167 | http.Error(w, "invalid challengeport param", 400) 168 | return 169 | } 170 | challengeScheme := r.FormValue("challengescheme") 171 | switch challengeScheme { 172 | case "http", "https": 173 | case "": 174 | challengeScheme = "http" 175 | default: 176 | http.Error(w, "invalid challengescheme param", 400) 177 | return 178 | } 179 | challengeURL := fmt.Sprintf("%s://%s:%d/.well-known/autocertdelegate-challenge", 180 | challengeScheme, challengeIP, challengePort) 181 | 182 | if err := s.verifyChallengeURL(r.Context(), challengeURL, serverName); err != nil { 183 | log.Printf("autocertdelegate: failed challenge for %q: %v", serverName, err) 184 | badServerName(w) 185 | return 186 | } 187 | 188 | wantRSA, _ := strconv.ParseBool(r.FormValue("rsa")) 189 | 190 | var cipherSuites []uint16 191 | if !wantRSA { 192 | cipherSuites = append(cipherSuites, tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256) 193 | } 194 | // Prime the cache: 195 | if _, err := s.am.GetCertificate(&tls.ClientHelloInfo{ 196 | ServerName: r.FormValue("servername"), 197 | CipherSuites: cipherSuites, 198 | }); err != nil { 199 | http.Error(w, err.Error(), 500) 200 | return 201 | } 202 | key := serverName 203 | if wantRSA { 204 | key += "+rsa" 205 | } 206 | // But what we really want is the on-disk PEM representation: 207 | pems, err := s.am.Cache.Get(r.Context(), key) 208 | if err != nil { 209 | http.Error(w, err.Error(), 500) 210 | return 211 | } 212 | 213 | w.Header().Set("Content-Type", "text/plain; charset=utf-8") 214 | w.Write(pems) 215 | } 216 | 217 | func (s *Server) verifyChallengeURL(ctx context.Context, challengeURL, serverName string) error { 218 | ctx, cancel := context.WithTimeout(ctx, 5*time.Second) 219 | defer cancel() 220 | req, err := http.NewRequestWithContext(ctx, "GET", challengeURL, nil) 221 | if err != nil { 222 | log.Printf("autocertdelegate: verifyChallengeURL: new request: %v", err) 223 | return err 224 | } 225 | res, err := http.DefaultClient.Do(req) 226 | if err != nil { 227 | log.Printf("autocertdelegate: fetch %v: %v", challengeURL, err) 228 | return err 229 | } 230 | defer res.Body.Close() 231 | slurp, err := ioutil.ReadAll(io.LimitReader(res.Body, 4<<10)) 232 | if err != nil { 233 | return err 234 | } 235 | f := strings.SplitN(strings.TrimSpace(string(slurp)), "/", 3) 236 | if len(f) != 3 { 237 | return errors.New("wrong number of parts") 238 | } 239 | gotServerName, unixTimeStr, gotAnswer := f[0], f[1], f[2] 240 | if serverName != gotServerName { 241 | return errors.New("wrong server name") 242 | } 243 | unixTimeN, err := strconv.ParseInt(unixTimeStr, 10, 64) 244 | if err != nil { 245 | return err 246 | } 247 | ut := time.Unix(unixTimeN, 0) 248 | if ut.Before(time.Now().Add(-10 * time.Second)) { 249 | return errors.New("too old") 250 | } 251 | wantAnswer := challengeAnswer(s.key, serverName, ut) 252 | if wantAnswer != gotAnswer { 253 | return errors.New("wrong challenge answer") 254 | } 255 | return nil 256 | } 257 | 258 | // Client fetches certs from the Server. 259 | // Its GetCertificate method is suitable for use by an HTTP server's 260 | // TLSConfig.GetCertificate. 261 | type Client struct { 262 | server string 263 | am *autocert.Manager 264 | } 265 | 266 | // NewClient returns a new client fetching from the provided server hostname. 267 | // The server must be a hostname only (without a scheme or path). 268 | func NewClient(server string) *Client { 269 | c := &Client{ 270 | server: server, 271 | } 272 | c.am = &autocert.Manager{ 273 | Cache: &delegateCache{c}, 274 | Prompt: autocert.AcceptTOS, 275 | HostPolicy: func(ctx context.Context, host string) error { return nil }, 276 | Client: &acme.Client{ 277 | HTTPClient: &http.Client{ 278 | Transport: failTransport{}, 279 | }, 280 | }, 281 | } 282 | return c 283 | } 284 | 285 | // GetCertificate fetches a certificate suitable for responding to the 286 | // provided hello. The signature of GetCertificate is suitable for 287 | // use by an HTTP server's TLSConfig.GetCertificate. 288 | func (c *Client) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { 289 | return c.am.GetCertificate(hello) 290 | } 291 | 292 | // TODO: configuration knobs as needed. 293 | func (c *Client) httpClient() *http.Client { return http.DefaultClient } 294 | func (c *Client) getCertTimeout() time.Duration { return 10 * time.Second } 295 | 296 | type delegateCache struct{ c *Client } 297 | 298 | func (dc *delegateCache) Get(ctx context.Context, key string) ([]byte, error) { 299 | rsa := strings.HasSuffix(key, "+rsa") 300 | host := strings.TrimSuffix(key, "+rsa") 301 | 302 | ctx, cancel := context.WithTimeout(ctx, dc.c.getCertTimeout()) 303 | defer cancel() 304 | req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("https://%s/?servername=%s&mode=getchallenge", 305 | dc.c.server, url.QueryEscape(host)), nil) 306 | if err != nil { 307 | return nil, err 308 | } 309 | res, err := dc.c.httpClient().Do(req) 310 | if err != nil { 311 | return nil, fmt.Errorf("failed to get challenge for %s: %v", host, err) 312 | } 313 | if res.StatusCode != 200 { 314 | res.Body.Close() 315 | return nil, fmt.Errorf("failed to get challenge for %s: %v", host, res.Status) 316 | } 317 | const maxChalLen = 1 << 10 318 | challenge, err := ioutil.ReadAll(io.LimitReader(res.Body, maxChalLen+1)) 319 | res.Body.Close() 320 | if err != nil { 321 | return nil, fmt.Errorf("failed to read challenge for %s: %v", host, err) 322 | } 323 | if len(challenge) > maxChalLen || bytes.Count(challenge, []byte("\n")) > 1 { 324 | return nil, fmt.Errorf("challenge for %s doesn't look like a challenge", host) 325 | } 326 | 327 | ln, err := net.Listen("tcp", ":0") 328 | if err != nil { 329 | return nil, err 330 | } 331 | defer ln.Close() 332 | port := ln.Addr().(*net.TCPAddr).Port 333 | 334 | srv := &http.Server{ 335 | Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 336 | w.Write(challenge) 337 | }), 338 | } 339 | go srv.Serve(ln) 340 | 341 | req, err = http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("https://%s/?servername=%s&mode=getcert&rsa=%v&challengeport=%d&challengescheme=http", 342 | dc.c.server, url.QueryEscape(host), rsa, port), 343 | nil) 344 | if err != nil { 345 | return nil, err 346 | } 347 | res, err = dc.c.httpClient().Do(req) 348 | if err != nil { 349 | return nil, fmt.Errorf("failed to get cert for %s: %v", host, err) 350 | } 351 | if res.StatusCode != 200 { 352 | res.Body.Close() 353 | return nil, fmt.Errorf("failed to get cert for %s: %v", host, res.Status) 354 | } 355 | defer res.Body.Close() 356 | slurp, err := ioutil.ReadAll(io.LimitReader(res.Body, 1<<20)) 357 | return slurp, err 358 | } 359 | 360 | func (c *delegateCache) Put(ctx context.Context, key string, data []byte) error { return nil } 361 | 362 | func (c *delegateCache) Delete(ctx context.Context, key string) error { return nil } 363 | 364 | type failTransport struct{} 365 | 366 | func (failTransport) RoundTrip(r *http.Request) (*http.Response, error) { 367 | log.Printf("Not doing ACME request: %s", r.URL.String()) 368 | return nil, errors.New("network request denied") 369 | } 370 | --------------------------------------------------------------------------------