Skip to content

Commit bf00487

Browse files
kylecarbscoadler
andauthored
feat: Add TLS support (#556)
* feat: Add TLS support This adds numerous flags with inspiration taken from Vault for configuring TLS inside Coder. This enables secure deployments without a proxy, like Cloudflare. * Update cli/start.go Co-authored-by: Colin Adler <colin@coder.com> * Fix flag help in coder.env Co-authored-by: Colin Adler <colin@coder.com>
1 parent 565b940 commit bf00487

File tree

8 files changed

+341
-34
lines changed

8 files changed

+341
-34
lines changed

.github/workflows/coder.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,7 @@ jobs:
337337
gcloud config set compute/zone us-central1-a
338338
gcloud compute scp ./dist/coder_*_linux_amd64.deb coder:/tmp/coder.deb
339339
gcloud compute ssh coder -- sudo dpkg -i /tmp/coder.deb
340+
gcloud compute ssh coder -- sudo systemctl daemon-reload
340341
341342
- name: Start
342343
run: gcloud compute ssh coder -- sudo service coder restart

cli/start.go

Lines changed: 175 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,18 @@ package cli
22

33
import (
44
"context"
5+
"crypto/tls"
6+
"crypto/x509"
57
"database/sql"
8+
"encoding/pem"
69
"fmt"
710
"io/ioutil"
811
"net"
912
"net/http"
1013
"net/url"
1114
"os"
1215
"os/signal"
16+
"strconv"
1317
"time"
1418

1519
"github.com/briandowns/spinner"
@@ -36,23 +40,23 @@ import (
3640

3741
func start() *cobra.Command {
3842
var (
43+
accessURL string
3944
address string
45+
dev bool
4046
postgresURL string
4147
provisionerDaemonCount uint8
42-
dev bool
48+
tlsCertFile string
49+
tlsClientCAFile string
50+
tlsClientAuth string
51+
tlsEnable bool
52+
tlsKeyFile string
53+
tlsMinVersion string
4354
useTunnel bool
4455
)
4556
root := &cobra.Command{
4657
Use: "start",
4758
RunE: func(cmd *cobra.Command, args []string) error {
48-
_, _ = fmt.Fprintf(cmd.OutOrStdout(), ` ▄█▀ ▀█▄
49-
▄▄ ▀▀▀ █▌ ██▀▀█▄ ▐█
50-
▄▄██▀▀█▄▄▄ ██ ██ █▀▀█ ▐█▀▀██ ▄█▀▀█ █▀▀
51-
█▌ ▄▌ ▐█ █▌ ▀█▄▄▄█▌ █ █ ▐█ ██ ██▀▀ █
52-
██████▀▄█ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀ ▀
53-
54-
`)
55-
59+
printLogo(cmd)
5660
if postgresURL == "" {
5761
// Default to the environment variable!
5862
postgresURL = os.Getenv("CODER_PG_CONNECTION_URL")
@@ -63,6 +67,17 @@ func start() *cobra.Command {
6367
return xerrors.Errorf("listen %q: %w", address, err)
6468
}
6569
defer listener.Close()
70+
71+
tlsConfig := &tls.Config{
72+
MinVersion: tls.VersionTLS12,
73+
}
74+
if tlsEnable {
75+
listener, err = configureTLS(tlsConfig, listener, tlsMinVersion, tlsClientAuth, tlsCertFile, tlsKeyFile, tlsClientCAFile)
76+
if err != nil {
77+
return xerrors.Errorf("configure tls: %w", err)
78+
}
79+
}
80+
6681
tcpAddr, valid := listener.Addr().(*net.TCPAddr)
6782
if !valid {
6883
return xerrors.New("must be listening on tcp")
@@ -76,7 +91,12 @@ func start() *cobra.Command {
7691
Scheme: "http",
7792
Host: tcpAddr.String(),
7893
}
79-
accessURL := localURL
94+
if tlsEnable {
95+
localURL.Scheme = "https"
96+
}
97+
if accessURL == "" {
98+
accessURL = localURL.String()
99+
}
80100
var tunnelErr <-chan error
81101
// If we're attempting to tunnel in dev-mode, the access URL
82102
// needs to be changed to use the tunnel.
@@ -88,27 +108,25 @@ func start() *cobra.Command {
88108
IsConfirm: true,
89109
})
90110
if err == nil {
91-
var accessURLRaw string
92-
accessURLRaw, tunnelErr, err = tunnel.New(cmd.Context(), localURL.String())
111+
accessURL, tunnelErr, err = tunnel.New(cmd.Context(), localURL.String())
93112
if err != nil {
94113
return xerrors.Errorf("create tunnel: %w", err)
95114
}
96-
accessURL, err = url.Parse(accessURLRaw)
97-
if err != nil {
98-
return xerrors.Errorf("parse: %w", err)
99-
}
100-
101-
_, _ = fmt.Fprintf(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render(cliui.Styles.Wrap.Render(cliui.Styles.Prompt.String()+`Tunnel started. Your deployment is accessible at:`))+"\n "+cliui.Styles.Field.Render(accessURL.String()))
115+
_, _ = fmt.Fprintf(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render(cliui.Styles.Wrap.Render(cliui.Styles.Prompt.String()+`Tunnel started. Your deployment is accessible at:`))+"\n "+cliui.Styles.Field.Render(accessURL))
102116
}
103117
}
104118
validator, err := idtoken.NewValidator(cmd.Context(), option.WithoutAuthentication())
105119
if err != nil {
106120
return err
107121
}
108122

123+
accessURLParsed, err := url.Parse(accessURL)
124+
if err != nil {
125+
return xerrors.Errorf("parse access url %q: %w", accessURL, err)
126+
}
109127
logger := slog.Make(sloghuman.Sink(os.Stderr))
110128
options := &coderd.Options{
111-
AccessURL: accessURL,
129+
AccessURL: accessURLParsed,
112130
Logger: logger.Named("coderd"),
113131
Database: databasefake.New(),
114132
Pubsub: database.NewPubsubInMemory(),
@@ -137,6 +155,13 @@ func start() *cobra.Command {
137155

138156
handler, closeCoderd := coderd.New(options)
139157
client := codersdk.New(localURL)
158+
if tlsEnable {
159+
// Use the TLS config here. This client is used for creating the
160+
// default user, among other things.
161+
client.HTTPClient.Transport = &http.Transport{
162+
TLSClientConfig: tlsConfig,
163+
}
164+
}
140165

141166
provisionerDaemons := make([]*provisionerd.Server, 0)
142167
for i := uint8(0); i < provisionerDaemonCount; i++ {
@@ -152,10 +177,18 @@ func start() *cobra.Command {
152177
}
153178
}()
154179

155-
errCh := make(chan error)
180+
errCh := make(chan error, 1)
181+
shutdownConnsCtx, shutdownConns := context.WithCancel(cmd.Context())
182+
defer shutdownConns()
156183
go func() {
157184
defer close(errCh)
158-
errCh <- http.Serve(listener, handler)
185+
server := http.Server{
186+
Handler: handler,
187+
BaseContext: func(_ net.Listener) context.Context {
188+
return shutdownConnsCtx
189+
},
190+
}
191+
errCh <- server.Serve(listener)
159192
}()
160193

161194
config := createConfig(cmd)
@@ -271,6 +304,7 @@ func start() *cobra.Command {
271304
}
272305

273306
_, _ = fmt.Fprintf(cmd.OutOrStdout(), cliui.Styles.Prompt.String()+"Waiting for WebSocket connections to close...\n")
307+
shutdownConns()
274308
closeCoderd()
275309
return nil
276310
},
@@ -279,11 +313,42 @@ func start() *cobra.Command {
279313
if defaultAddress == "" {
280314
defaultAddress = "127.0.0.1:3000"
281315
}
282-
root.Flags().StringVarP(&address, "address", "a", defaultAddress, "The address to serve the API and dashboard.")
283-
root.Flags().BoolVarP(&dev, "dev", "", false, "Serve Coder in dev mode for tinkering.")
284-
root.Flags().StringVarP(&postgresURL, "postgres-url", "", "", "URL of a PostgreSQL database to connect to (defaults to $CODER_PG_CONNECTION_URL).")
316+
root.Flags().StringVarP(&accessURL, "access-url", "", os.Getenv("CODER_ACCESS_URL"), "Specifies the external URL to access Coder (uses $CODER_ACCESS_URL).")
317+
root.Flags().StringVarP(&address, "address", "a", defaultAddress, "The address to serve the API and dashboard (uses $CODER_ADDRESS).")
318+
defaultDev, _ := strconv.ParseBool(os.Getenv("CODER_DEV_MODE"))
319+
root.Flags().BoolVarP(&dev, "dev", "", defaultDev, "Serve Coder in dev mode for tinkering (uses $CODER_DEV_MODE).")
320+
root.Flags().StringVarP(&postgresURL, "postgres-url", "", "",
321+
"URL of a PostgreSQL database to connect to (defaults to $CODER_PG_CONNECTION_URL).")
285322
root.Flags().Uint8VarP(&provisionerDaemonCount, "provisioner-daemons", "", 1, "The amount of provisioner daemons to create on start.")
286-
root.Flags().BoolVarP(&useTunnel, "tunnel", "", true, "Serve dev mode through a Cloudflare Tunnel for easy setup.")
323+
defaultTLSEnable, _ := strconv.ParseBool(os.Getenv("CODER_TLS_ENABLE"))
324+
root.Flags().BoolVarP(&tlsEnable, "tls-enable", "", defaultTLSEnable, "Specifies if TLS will be enabled (uses $CODER_TLS_ENABLE).")
325+
root.Flags().StringVarP(&tlsCertFile, "tls-cert-file", "", os.Getenv("CODER_TLS_CERT_FILE"),
326+
"Specifies the path to the certificate for TLS. It requires a PEM-encoded file. "+
327+
"To configure the listener to use a CA certificate, concatenate the primary certificate "+
328+
"and the CA certificate together. The primary certificate should appear first in the combined file (uses $CODER_TLS_CERT_FILE).")
329+
root.Flags().StringVarP(&tlsClientCAFile, "tls-client-ca-file", "", os.Getenv("CODER_TLS_CLIENT_CA_FILE"),
330+
"PEM-encoded Certificate Authority file used for checking the authenticity of client (uses $CODER_TLS_CLIENT_CA_FILE).")
331+
defaultTLSClientAuth := os.Getenv("CODER_TLS_CLIENT_AUTH")
332+
if defaultTLSClientAuth == "" {
333+
defaultTLSClientAuth = "request"
334+
}
335+
root.Flags().StringVarP(&tlsClientAuth, "tls-client-auth", "", defaultTLSClientAuth,
336+
`Specifies the policy the server will follow for TLS Client Authentication. `+
337+
`Accepted values are "none", "request", "require-any", "verify-if-given", or "require-and-verify" (uses $CODER_TLS_CLIENT_AUTH).`)
338+
root.Flags().StringVarP(&tlsKeyFile, "tls-key-file", "", os.Getenv("CODER_TLS_KEY_FILE"),
339+
"Specifies the path to the private key for the certificate. It requires a PEM-encoded file (uses $CODER_TLS_KEY_FILE).")
340+
defaultTLSMinVersion := os.Getenv("CODER_TLS_MIN_VERSION")
341+
if defaultTLSMinVersion == "" {
342+
defaultTLSMinVersion = "tls12"
343+
}
344+
root.Flags().StringVarP(&tlsMinVersion, "tls-min-version", "", defaultTLSMinVersion,
345+
`Specifies the minimum supported version of TLS. Accepted values are "tls10", "tls11", "tls12" or "tls13" (uses $CODER_TLS_MIN_VERSION).`)
346+
defaultTunnelRaw := os.Getenv("CODER_DEV_TUNNEL")
347+
if defaultTunnelRaw == "" {
348+
defaultTunnelRaw = "true"
349+
}
350+
defaultTunnel, _ := strconv.ParseBool(defaultTunnelRaw)
351+
root.Flags().BoolVarP(&useTunnel, "tunnel", "", defaultTunnel, "Serve dev mode through a Cloudflare Tunnel for easy setup (uses $CODER_DEV_TUNNEL).")
287352
_ = root.Flags().MarkHidden("tunnel")
288353

289354
return root
@@ -346,3 +411,88 @@ func newProvisionerDaemon(ctx context.Context, client *codersdk.Client, logger s
346411
WorkDirectory: tempDir,
347412
}), nil
348413
}
414+
415+
func printLogo(cmd *cobra.Command) {
416+
_, _ = fmt.Fprintf(cmd.OutOrStdout(), ` ▄█▀ ▀█▄
417+
▄▄ ▀▀▀ █▌ ██▀▀█▄ ▐█
418+
▄▄██▀▀█▄▄▄ ██ ██ █▀▀█ ▐█▀▀██ ▄█▀▀█ █▀▀
419+
█▌ ▄▌ ▐█ █▌ ▀█▄▄▄█▌ █ █ ▐█ ██ ██▀▀ █
420+
██████▀▄█ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀ ▀
421+
422+
`)
423+
}
424+
425+
func configureTLS(tlsConfig *tls.Config, listener net.Listener, tlsMinVersion, tlsClientAuth, tlsCertFile, tlsKeyFile, tlsClientCAFile string) (net.Listener, error) {
426+
switch tlsMinVersion {
427+
case "tls10":
428+
tlsConfig.MinVersion = tls.VersionTLS10
429+
case "tls11":
430+
tlsConfig.MinVersion = tls.VersionTLS11
431+
case "tls12":
432+
tlsConfig.MinVersion = tls.VersionTLS12
433+
case "tls13":
434+
tlsConfig.MinVersion = tls.VersionTLS13
435+
default:
436+
return nil, xerrors.Errorf("unrecognized tls version: %q", tlsMinVersion)
437+
}
438+
439+
switch tlsClientAuth {
440+
case "none":
441+
tlsConfig.ClientAuth = tls.NoClientCert
442+
case "request":
443+
tlsConfig.ClientAuth = tls.RequestClientCert
444+
case "require-any":
445+
tlsConfig.ClientAuth = tls.RequireAnyClientCert
446+
case "verify-if-given":
447+
tlsConfig.ClientAuth = tls.VerifyClientCertIfGiven
448+
case "require-and-verify":
449+
tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert
450+
default:
451+
return nil, xerrors.Errorf("unrecognized tls client auth: %q", tlsClientAuth)
452+
}
453+
454+
if tlsCertFile == "" {
455+
return nil, xerrors.New("tls-cert-file is required when tls is enabled")
456+
}
457+
if tlsKeyFile == "" {
458+
return nil, xerrors.New("tls-key-file is required when tls is enabled")
459+
}
460+
461+
certPEMBlock, err := os.ReadFile(tlsCertFile)
462+
if err != nil {
463+
return nil, xerrors.Errorf("read file %q: %w", tlsCertFile, err)
464+
}
465+
keyPEMBlock, err := os.ReadFile(tlsKeyFile)
466+
if err != nil {
467+
return nil, xerrors.Errorf("read file %q: %w", tlsKeyFile, err)
468+
}
469+
keyBlock, _ := pem.Decode(keyPEMBlock)
470+
if keyBlock == nil {
471+
return nil, xerrors.New("decoded pem is blank")
472+
}
473+
cert, err := tls.X509KeyPair(certPEMBlock, keyPEMBlock)
474+
if err != nil {
475+
return nil, xerrors.Errorf("create key pair: %w", err)
476+
}
477+
tlsConfig.GetCertificate = func(chi *tls.ClientHelloInfo) (*tls.Certificate, error) {
478+
return &cert, nil
479+
}
480+
481+
certPool := x509.NewCertPool()
482+
certPool.AppendCertsFromPEM(certPEMBlock)
483+
tlsConfig.RootCAs = certPool
484+
485+
if tlsClientCAFile != "" {
486+
caPool := x509.NewCertPool()
487+
data, err := ioutil.ReadFile(tlsClientCAFile)
488+
if err != nil {
489+
return nil, xerrors.Errorf("read %q: %w", tlsClientCAFile, err)
490+
}
491+
if !caPool.AppendCertsFromPEM(data) {
492+
return nil, xerrors.Errorf("failed to parse CA certificate in tls-client-ca-file")
493+
}
494+
tlsConfig.ClientCAs = caPool
495+
}
496+
497+
return tls.NewListener(listener, tlsConfig), nil
498+
}

0 commit comments

Comments
 (0)