diff --git a/.github/workflows/typos.toml b/.github/workflows/typos.toml index 4197628dfd7d0..4de415b57de9d 100644 --- a/.github/workflows/typos.toml +++ b/.github/workflows/typos.toml @@ -20,6 +20,8 @@ hel = "hel" pn = "pn" # typos doesn't like the EDE in TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA EDE = "EDE" +# HELO is an SMTP command +HELO = "HELO" [files] extend-exclude = [ diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index d3bd1b587260a..3e826374eb556 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -327,6 +327,8 @@ can safely ignore these settings. "tls11", "tls12" or "tls13". NOTIFICATIONS OPTIONS: +Configure how notifications are processed and delivered. + --notifications-dispatch-timeout duration, $CODER_NOTIFICATIONS_DISPATCH_TIMEOUT (default: 1m0s) How long to wait while a notification is being sent before giving up. @@ -337,6 +339,11 @@ NOTIFICATIONS OPTIONS: Which delivery method to use (available options: 'smtp', 'webhook'). NOTIFICATIONS / EMAIL OPTIONS: +Configure how email notifications are sent. + + --notifications-email-force-tls bool, $CODER_NOTIFICATIONS_EMAIL_FORCE_TLS (default: false) + Force a TLS connection to the configured SMTP smarthost. + --notifications-email-from string, $CODER_NOTIFICATIONS_EMAIL_FROM The sender's address to use. @@ -346,6 +353,43 @@ NOTIFICATIONS / EMAIL OPTIONS: --notifications-email-smarthost host:port, $CODER_NOTIFICATIONS_EMAIL_SMARTHOST (default: localhost:587) The intermediary SMTP host through which emails are sent. +NOTIFICATIONS / EMAIL / EMAIL AUTHENTICATION OPTIONS: +Configure SMTP authentication options. + + --notifications-email-auth-identity string, $CODER_NOTIFICATIONS_EMAIL_AUTH_IDENTITY + Identity to use with PLAIN authentication. + + --notifications-email-auth-password string, $CODER_NOTIFICATIONS_EMAIL_AUTH_PASSWORD + Password to use with PLAIN/LOGIN authentication. + + --notifications-email-auth-password-file string, $CODER_NOTIFICATIONS_EMAIL_AUTH_PASSWORD_FILE + File from which to load password for use with PLAIN/LOGIN + authentication. + + --notifications-email-auth-username string, $CODER_NOTIFICATIONS_EMAIL_AUTH_USERNAME + Username to use with PLAIN/LOGIN authentication. + +NOTIFICATIONS / EMAIL / EMAIL TLS OPTIONS: +Configure TLS for your SMTP server target. + + --notifications-email-tls-ca-cert-file string, $CODER_NOTIFICATIONS_EMAIL_TLS_CACERTFILE + CA certificate file to use. + + --notifications-email-tls-cert-file string, $CODER_NOTIFICATIONS_EMAIL_TLS_CERTFILE + Certificate file to use. + + --notifications-email-tls-cert-key-file string, $CODER_NOTIFICATIONS_EMAIL_TLS_CERTKEYFILE + Certificate key file to use. + + --notifications-email-tls-server-name string, $CODER_NOTIFICATIONS_EMAIL_TLS_SERVERNAME + Server name to verify against the target certificate. + + --notifications-email-tls-skip-verify bool, $CODER_NOTIFICATIONS_EMAIL_TLS_SKIPVERIFY + Skip verification of the target server's certificate (insecure). + + --notifications-email-tls-starttls bool, $CODER_NOTIFICATIONS_EMAIL_TLS_STARTTLS + Enable STARTTLS to upgrade insecure SMTP connections using TLS. + NOTIFICATIONS / WEBHOOK OPTIONS: --notifications-webhook-endpoint url, $CODER_NOTIFICATIONS_WEBHOOK_ENDPOINT The endpoint to which to send webhooks. diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index fa6ddab54d0b6..42cb3b2aeb497 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -493,13 +493,15 @@ userQuietHoursSchedule: # compatibility reasons, this will be removed in a future release. # (default: false, type: bool) allowWorkspaceRenames: false +# Configure how notifications are processed and delivered. notifications: # Which delivery method to use (available options: 'smtp', 'webhook'). # (default: smtp, type: string) method: smtp # How long to wait while a notification is being sent before giving up. # (default: 1m0s, type: duration) - dispatch-timeout: 1m0s + dispatchTimeout: 1m0s + # Configure how email notifications are sent. email: # The sender's address to use. # (default: , type: string) @@ -510,30 +512,67 @@ notifications: # The hostname identifying the SMTP server. # (default: localhost, type: string) hello: localhost + # Force a TLS connection to the configured SMTP smarthost. + # (default: false, type: bool) + forceTLS: false + # Configure SMTP authentication options. + emailAuth: + # Identity to use with PLAIN authentication. + # (default: , type: string) + identity: "" + # Username to use with PLAIN/LOGIN authentication. + # (default: , type: string) + username: "" + # Password to use with PLAIN/LOGIN authentication. + # (default: , type: string) + password: "" + # File from which to load password for use with PLAIN/LOGIN authentication. + # (default: , type: string) + passwordFile: "" + # Configure TLS for your SMTP server target. + emailTLS: + # Enable STARTTLS to upgrade insecure SMTP connections using TLS. + # (default: , type: bool) + startTLS: false + # Server name to verify against the target certificate. + # (default: , type: string) + serverName: "" + # Skip verification of the target server's certificate (insecure). + # (default: , type: bool) + insecureSkipVerify: false + # CA certificate file to use. + # (default: , type: string) + caCertFile: "" + # Certificate file to use. + # (default: , type: string) + certFile: "" + # Certificate key file to use. + # (default: , type: string) + certKeyFile: "" webhook: # The endpoint to which to send webhooks. # (default: , type: url) hello: # The upper limit of attempts to send a notification. # (default: 5, type: int) - max-send-attempts: 5 + maxSendAttempts: 5 # The minimum time between retries. # (default: 5m0s, type: duration) - retry-interval: 5m0s + retryInterval: 5m0s # The notifications system buffers message updates in memory to ease pressure on # the database. This option controls how often it synchronizes its state with the # database. The shorter this value the lower the change of state inconsistency in # a non-graceful shutdown - but it also increases load on the database. It is # recommended to keep this option at its default value. # (default: 2s, type: duration) - store-sync-interval: 2s + storeSyncInterval: 2s # The notifications system buffers message updates in memory to ease pressure on # the database. This option controls how many updates are kept in memory. The # lower this value the lower the change of state inconsistency in a non-graceful # shutdown - but it also increases load on the database. It is recommended to keep # this option at its default value. # (default: 50, type: int) - store-sync-buffer-size: 50 + storeSyncBufferSize: 50 # How long a notifier should lease a message. This is effectively how long a # notification is 'owned' by a notifier, and once this period expires it will be # available for lease by another notifier. Leasing is important in order for @@ -541,10 +580,10 @@ notifications: # concurrently. This lease period will only expire if a notifier shuts down # ungracefully; a dispatch of the notification releases the lease. # (default: 2m0s, type: duration) - lease-period: 2m0s + leasePeriod: 2m0s # How many notifications a notifier should lease per fetch interval. # (default: 20, type: int) - lease-count: 20 + leaseCount: 20 # How often to query the database for queued notifications. # (default: 15s, type: duration) - fetch-interval: 15s + fetchInterval: 15s diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 0aecfdea8ff5e..3fe0ff6dc35d3 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -10047,9 +10047,42 @@ const docTemplate = `{ } } }, + "codersdk.NotificationsEmailAuthConfig": { + "type": "object", + "properties": { + "identity": { + "description": "Identity for PLAIN auth.", + "type": "string" + }, + "password": { + "description": "Password for LOGIN/PLAIN auth.", + "type": "string" + }, + "password_file": { + "description": "File from which to load the password for LOGIN/PLAIN auth.", + "type": "string" + }, + "username": { + "description": "Username for LOGIN/PLAIN auth.", + "type": "string" + } + } + }, "codersdk.NotificationsEmailConfig": { "type": "object", "properties": { + "auth": { + "description": "Authentication details.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.NotificationsEmailAuthConfig" + } + ] + }, + "force_tls": { + "description": "ForceTLS causes a TLS connection to be attempted.", + "type": "boolean" + }, "from": { "description": "The sender's address.", "type": "string" @@ -10065,6 +10098,43 @@ const docTemplate = `{ "$ref": "#/definitions/serpent.HostPort" } ] + }, + "tls": { + "description": "TLS details.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.NotificationsEmailTLSConfig" + } + ] + } + } + }, + "codersdk.NotificationsEmailTLSConfig": { + "type": "object", + "properties": { + "ca_file": { + "description": "CAFile specifies the location of the CA certificate to use.", + "type": "string" + }, + "cert_file": { + "description": "CertFile specifies the location of the certificate to use.", + "type": "string" + }, + "insecure_skip_verify": { + "description": "InsecureSkipVerify skips target certificate validation.", + "type": "boolean" + }, + "key_file": { + "description": "KeyFile specifies the location of the key to use.", + "type": "string" + }, + "server_name": { + "description": "ServerName to verify the hostname for the targets.", + "type": "string" + }, + "start_tls": { + "description": "StartTLS attempts to upgrade plain connections to TLS.", + "type": "boolean" } } }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 6d5ac99848dbf..0d8e7531becd2 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -9008,9 +9008,42 @@ } } }, + "codersdk.NotificationsEmailAuthConfig": { + "type": "object", + "properties": { + "identity": { + "description": "Identity for PLAIN auth.", + "type": "string" + }, + "password": { + "description": "Password for LOGIN/PLAIN auth.", + "type": "string" + }, + "password_file": { + "description": "File from which to load the password for LOGIN/PLAIN auth.", + "type": "string" + }, + "username": { + "description": "Username for LOGIN/PLAIN auth.", + "type": "string" + } + } + }, "codersdk.NotificationsEmailConfig": { "type": "object", "properties": { + "auth": { + "description": "Authentication details.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.NotificationsEmailAuthConfig" + } + ] + }, + "force_tls": { + "description": "ForceTLS causes a TLS connection to be attempted.", + "type": "boolean" + }, "from": { "description": "The sender's address.", "type": "string" @@ -9026,6 +9059,43 @@ "$ref": "#/definitions/serpent.HostPort" } ] + }, + "tls": { + "description": "TLS details.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.NotificationsEmailTLSConfig" + } + ] + } + } + }, + "codersdk.NotificationsEmailTLSConfig": { + "type": "object", + "properties": { + "ca_file": { + "description": "CAFile specifies the location of the CA certificate to use.", + "type": "string" + }, + "cert_file": { + "description": "CertFile specifies the location of the certificate to use.", + "type": "string" + }, + "insecure_skip_verify": { + "description": "InsecureSkipVerify skips target certificate validation.", + "type": "boolean" + }, + "key_file": { + "description": "KeyFile specifies the location of the key to use.", + "type": "string" + }, + "server_name": { + "description": "ServerName to verify the hostname for the targets.", + "type": "string" + }, + "start_tls": { + "description": "StartTLS attempts to upgrade plain connections to TLS.", + "type": "boolean" } } }, diff --git a/coderd/notifications/dispatch/fixtures/ca.conf b/coderd/notifications/dispatch/fixtures/ca.conf new file mode 100644 index 0000000000000..b7646c9e5e601 --- /dev/null +++ b/coderd/notifications/dispatch/fixtures/ca.conf @@ -0,0 +1,18 @@ +[ req ] +distinguished_name = req_distinguished_name +x509_extensions = v3_ca +prompt = no + +[ req_distinguished_name ] +C = ZA +ST = WC +L = Cape Town +O = Coder +OU = Team Coconut +CN = Coder CA + +[ v3_ca ] +basicConstraints = critical,CA:TRUE +keyUsage = critical,keyCertSign,cRLSign +subjectKeyIdentifier = hash +authorityKeyIdentifier = keyid:always,issuer:always diff --git a/coderd/notifications/dispatch/fixtures/ca.crt b/coderd/notifications/dispatch/fixtures/ca.crt new file mode 100644 index 0000000000000..212caf5a0d5a2 --- /dev/null +++ b/coderd/notifications/dispatch/fixtures/ca.crt @@ -0,0 +1,25 @@ +-----BEGIN CERTIFICATE----- +MIIESjCCAzKgAwIBAgIUceUne8C8ezg1leBzhm5M5QLjBc4wDQYJKoZIhvcNAQEL +BQAwaDELMAkGA1UEBhMCWkExCzAJBgNVBAgMAldDMRIwEAYDVQQHDAlDYXBlIFRv +d24xDjAMBgNVBAoMBUNvZGVyMRUwEwYDVQQLDAxUZWFtIENvY29udXQxETAPBgNV +BAMMCENvZGVyIENBMB4XDTI0MDcxNTEzMzYwOFoXDTM0MDcxMzEzMzYwOFowaDEL +MAkGA1UEBhMCWkExCzAJBgNVBAgMAldDMRIwEAYDVQQHDAlDYXBlIFRvd24xDjAM +BgNVBAoMBUNvZGVyMRUwEwYDVQQLDAxUZWFtIENvY29udXQxETAPBgNVBAMMCENv +ZGVyIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAijVhQfmImkQF +kDiBqCdSAaG7dO7slAjJH0jYizYCwVzCKP72Z7DJ2b/ohcGBw1YWZ8dOm88uCpsS +oWM5FvxIeaNeGpcFar+wEoR/o5p91DgwvpmkbNyu3uQaNRvIKoqGdTAu5GUNd+Ej +MxvwfofgRetziA56sa6ovQV11hPbKxp0YbSJXMRN64sGCqx+VNqpk2A57JCdCjcB +T1fc7LIqKc9uoqCaC0Hr2OaBCc8IxLwpwwOz5qCaOGmylXY3YE4lKNJkA1s/HXO/ +GAZ6aO0GqkO00fxIQwW13BexuaiDJfcAhUmJ8CjFt9qgKfnkP26jU8gfMxOkRkn2 +qG8sWy3z8wIDAQABo4HrMIHoMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQD +AgEGMB0GA1UdDgQWBBSk2BGdRQZDMvzOfLQkUmkwzjrOFzCBpQYDVR0jBIGdMIGa +gBSk2BGdRQZDMvzOfLQkUmkwzjrOF6FspGowaDELMAkGA1UEBhMCWkExCzAJBgNV +BAgMAldDMRIwEAYDVQQHDAlDYXBlIFRvd24xDjAMBgNVBAoMBUNvZGVyMRUwEwYD +VQQLDAxUZWFtIENvY29udXQxETAPBgNVBAMMCENvZGVyIENBghRx5Sd7wLx7ODWV +4HOGbkzlAuMFzjANBgkqhkiG9w0BAQsFAAOCAQEAFJtks88lruyIIbFpzQ8M932a +hNmkm3ZFM8qrjFWCEINmzeeQHV+rviu4Spd4Cltx+lf6+51V68jE730IGEzAu14o +U2dmhRxn+w17H6/Qmnxlbz4Da2HvVgL9C4IoEbCTTGEa+hDg3cH6Mah1rfC0zAXH +zxe/M2ahM+SOMDxmoUUf6M4tDVqu98FpELfsFe4MqTUbzQ32PyoP4ZOBpma1dl8Y +fMm0rJE9/g/9Tkj8WfA4AwedCWUA4e7MLZikmntcein310uSy1sEpA+HVji+Gt68 +2+TJgIGOX1EHj44SqK5hVExQNzqqi1IIhR05imFaJ426DX82LtOA1bIg7HNCWA== +-----END CERTIFICATE----- diff --git a/coderd/notifications/dispatch/fixtures/ca.key b/coderd/notifications/dispatch/fixtures/ca.key new file mode 100644 index 0000000000000..002bff6e689fd --- /dev/null +++ b/coderd/notifications/dispatch/fixtures/ca.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCKNWFB+YiaRAWQ +OIGoJ1IBobt07uyUCMkfSNiLNgLBXMIo/vZnsMnZv+iFwYHDVhZnx06bzy4KmxKh +YzkW/Eh5o14alwVqv7AShH+jmn3UODC+maRs3K7e5Bo1G8gqioZ1MC7kZQ134SMz +G/B+h+BF63OIDnqxrqi9BXXWE9srGnRhtIlcxE3riwYKrH5U2qmTYDnskJ0KNwFP +V9zssiopz26ioJoLQevY5oEJzwjEvCnDA7PmoJo4abKVdjdgTiUo0mQDWz8dc78Y +Bnpo7QaqQ7TR/EhDBbXcF7G5qIMl9wCFSYnwKMW32qAp+eQ/bqNTyB8zE6RGSfao +byxbLfPzAgMBAAECggEAMPlfYFiDDl8iNYvAbgyY45ki6vmq/X3rftl6WkImUcyD +xLEsMWwU6sM1Kwh56fT8dYPLmCyfHQT8YhHd7gYxzGCWfQec1MneI4GuFRQumF/c +7f1VpXnBwZvEqaMRl/mEUcxkIWypjBxMM9UnsD6Hu18GjmTLF2FTy78+lUBt/mSZ +CptLNIQJ0vncdAlxg9PYxfXhrtWj8I2T7PCAmBM+wbcGzfWTKyo/JMKylnEe4NNg +j4elBHhISSUACpZd2pU+iA2nTaaD1Rzlqang/FypIzwLye/Sz2a6spM9yL8H9UN5 +zdz+QIwNoSC4fhEAlDo7FMBr8ZdR97qadP78XH+3SQKBgQDC5mwvIEoLQSD7H9PT +t+J59uq90Dcg7qRxM+jbrtmPmvSuAql2Mx7KO5kf45CO7mLA1oE7YG2ceXQb4hFO +HCrIGYtK6iEyizvIOCmbwoPbYXBf2o6iSl1t7f4wQ4N35KjQptviW5CO3ThFI2H4 +Oco2zR1Bjtig/lPKPv4TlAA4ZwKBgQC1iTZzynr2UP6f2MIByNEzN86BAiHJBya0 +BCWrl93A66GRSjV/tNikSZ/Me/SU3h44WuiFVRMuDrYrCcrUgmXpVMSnAy6AiwXx +ItMsQNJW3JryN7uki/swI0zLWj8B+FMf8nXa2FS545etjOj1w6scoKT4txmVT0C+ +61l4KNXglQKBgQCQRD3qOE12vTPrjyiePCwxOZuS+1ADWYJxpQoFqwyx5vKc562G +p9pvuePjnfAATObedSldyUf5nlFa3mEO33yvd3EK9/mwzy1mTGRIPpiZyCuFWGNi +MAeueo9ALIlhMune4NQ8XqjHh2rCiqlXM3fCTtwMDe++Y+Oj/jLWTSRImwKBgDTb +UNmCGS9jAeB08ngmipMJKr1xa3jm9iPwGS/PNigX86EkJFOcyn97WGXnqZ0210G9 +Znp7/OuqKOx7G22o0heQMPoX+RBAamh9pVL7RMM51Hu2MpKEl4y6mn+TNUlTjpB8 +vkgMOQ8u71j+8E2uvUHGnII2feJ1gvqT+Cb+bNfJAoGAJNK6ufPA0lHJwuDlGlNu +eKU0bP3tkz7nM20PS8R2djoNGN+D+pFFR71TB2gTN6YmqBcwP7TjPwNLKSg9xJvY +ST1F2QnOyds/OgdFlabcNdmbNivT0rHX6qZs7vYXNVjt7rmIRY2TW3ifRLeCK0Ls +5Anq4SkaoH/ctBnP3TYRnQI= +-----END PRIVATE KEY----- diff --git a/coderd/notifications/dispatch/fixtures/ca.srl b/coderd/notifications/dispatch/fixtures/ca.srl new file mode 100644 index 0000000000000..c4d374941a4cf --- /dev/null +++ b/coderd/notifications/dispatch/fixtures/ca.srl @@ -0,0 +1 @@ +0330C6D190E3FE649DAFCDA2F4D765E2D29328DE diff --git a/coderd/notifications/dispatch/fixtures/generate.sh b/coderd/notifications/dispatch/fixtures/generate.sh new file mode 100755 index 0000000000000..afb0b7ecccd87 --- /dev/null +++ b/coderd/notifications/dispatch/fixtures/generate.sh @@ -0,0 +1,90 @@ +#!/bin/bash + +# Set filenames +CA_KEY="ca.key" +CA_CERT="ca.crt" +SERVER_KEY="server.key" +SERVER_CSR="server.csr" +SERVER_CERT="server.crt" +CA_CONF="ca.conf" +SERVER_CONF="server.conf" +V3_EXT_CONF="v3_ext.conf" + +# Generate the CA key +openssl genpkey -algorithm RSA -out $CA_KEY -pkeyopt rsa_keygen_bits:2048 + +# Create the CA configuration file +cat >$CA_CONF <$SERVER_CONF <$V3_EXT_CONF < 0 { + content, err := os.ReadFile(file) + if err != nil { + return "", xerrors.Errorf("could not read %s: %w", file, err) + } + return string(content), nil + } + return s.cfg.Auth.Password.String(), nil +} diff --git a/coderd/notifications/dispatch/smtp/html.gotmpl b/coderd/notifications/dispatch/smtp/html.gotmpl index fc34a701ecc61..00005179316bf 100644 --- a/coderd/notifications/dispatch/smtp/html.gotmpl +++ b/coderd/notifications/dispatch/smtp/html.gotmpl @@ -8,23 +8,7 @@
- - - - - - - - - - - - - - - - - +

{{ .Labels._subject }}

diff --git a/coderd/notifications/dispatch/smtp_test.go b/coderd/notifications/dispatch/smtp_test.go new file mode 100644 index 0000000000000..2605157f2b210 --- /dev/null +++ b/coderd/notifications/dispatch/smtp_test.go @@ -0,0 +1,509 @@ +package dispatch_test + +import ( + "bytes" + "crypto/tls" + _ "embed" + "fmt" + "log" + "net" + "sync" + "testing" + + "github.com/emersion/go-sasl" + "github.com/emersion/go-smtp" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/goleak" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/serpent" + + "github.com/coder/coder/v2/coderd/notifications/dispatch" + "github.com/coder/coder/v2/coderd/notifications/types" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} + +func TestSMTP(t *testing.T) { + t.Parallel() + + const ( + username = "bob" + password = "🤫" + + hello = "localhost" + + identity = "robert" + from = "system@coder.com" + to = "bob@bob.com" + + subject = "This is the subject" + body = "This is the body" + + caFile = "fixtures/ca.crt" + certFile = "fixtures/server.crt" + keyFile = "fixtures/server.key" + ) + + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true, IgnoredErrorIs: []error{}}).Leveled(slog.LevelDebug) + tests := []struct { + name string + cfg codersdk.NotificationsEmailConfig + toAddrs []string + authMechs []string + expectedAuthMeth string + expectedErr string + retryable bool + useTLS bool + }{ + /** + * LOGIN auth mechanism + */ + { + name: "LOGIN auth", + authMechs: []string{sasl.Login}, + cfg: codersdk.NotificationsEmailConfig{ + Hello: hello, + From: from, + + Auth: codersdk.NotificationsEmailAuthConfig{ + Username: username, + Password: password, + }, + }, + toAddrs: []string{to}, + expectedAuthMeth: sasl.Login, + }, + { + name: "invalid LOGIN auth user", + authMechs: []string{sasl.Login}, + cfg: codersdk.NotificationsEmailConfig{ + Hello: hello, + From: from, + + Auth: codersdk.NotificationsEmailAuthConfig{ + Username: username + "-wrong", + Password: password, + }, + }, + toAddrs: []string{to}, + expectedAuthMeth: sasl.Login, + expectedErr: "unknown user", + retryable: true, + }, + { + name: "invalid LOGIN auth credentials", + authMechs: []string{sasl.Login}, + cfg: codersdk.NotificationsEmailConfig{ + Hello: hello, + From: from, + + Auth: codersdk.NotificationsEmailAuthConfig{ + Username: username, + Password: password + "-wrong", + }, + }, + toAddrs: []string{to}, + expectedAuthMeth: sasl.Login, + expectedErr: "incorrect password", + retryable: true, + }, + { + name: "password from file", + authMechs: []string{sasl.Login}, + cfg: codersdk.NotificationsEmailConfig{ + Hello: hello, + From: from, + + Auth: codersdk.NotificationsEmailAuthConfig{ + Username: username, + PasswordFile: "fixtures/password.txt", + }, + }, + toAddrs: []string{to}, + expectedAuthMeth: sasl.Login, + }, + /** + * PLAIN auth mechanism + */ + { + name: "PLAIN auth", + authMechs: []string{sasl.Plain}, + cfg: codersdk.NotificationsEmailConfig{ + Hello: hello, + From: from, + + Auth: codersdk.NotificationsEmailAuthConfig{ + Identity: identity, + Username: username, + Password: password, + }, + }, + toAddrs: []string{to}, + expectedAuthMeth: sasl.Plain, + }, + { + name: "PLAIN auth without identity", + authMechs: []string{sasl.Plain}, + cfg: codersdk.NotificationsEmailConfig{ + Hello: hello, + From: from, + + Auth: codersdk.NotificationsEmailAuthConfig{ + Identity: "", + Username: username, + Password: password, + }, + }, + toAddrs: []string{to}, + expectedAuthMeth: sasl.Plain, + }, + { + name: "PLAIN+LOGIN, choose PLAIN", + authMechs: []string{sasl.Login, sasl.Plain}, + cfg: codersdk.NotificationsEmailConfig{ + Hello: hello, + From: from, + + Auth: codersdk.NotificationsEmailAuthConfig{ + Identity: identity, + Username: username, + Password: password, + }, + }, + toAddrs: []string{to}, + expectedAuthMeth: sasl.Plain, + }, + /** + * No auth mechanism + */ + { + name: "No auth mechanisms supported", + authMechs: []string{}, + cfg: codersdk.NotificationsEmailConfig{ + Hello: hello, + From: from, + + Auth: codersdk.NotificationsEmailAuthConfig{ + Username: username, + Password: password, + }, + }, + toAddrs: []string{to}, + expectedAuthMeth: "", + expectedErr: "no authentication mechanisms supported by server", + retryable: false, + }, + { + // No auth, no problem! + name: "No auth mechanisms supported, none configured", + authMechs: []string{}, + cfg: codersdk.NotificationsEmailConfig{ + Hello: hello, + From: from, + }, + toAddrs: []string{to}, + expectedAuthMeth: "", + }, + /** + * TLS connections + */ + { + // TLS is forced but certificate used by mock server is untrusted. + name: "TLS: x509 untrusted", + useTLS: true, + expectedErr: "tls: failed to verify certificate", + retryable: true, + }, + { + // TLS is forced and self-signed certificate used by mock server is not verified. + name: "TLS: x509 untrusted ignored", + useTLS: true, + cfg: codersdk.NotificationsEmailConfig{ + Hello: hello, + From: from, + ForceTLS: true, + TLS: codersdk.NotificationsEmailTLSConfig{ + InsecureSkipVerify: true, + }, + }, + toAddrs: []string{to}, + }, + { + // TLS is forced and STARTTLS is configured, but STARTTLS cannot be used by TLS connections. + // STARTTLS should be disabled and connection should succeed. + name: "TLS: STARTTLS is ignored", + useTLS: true, + cfg: codersdk.NotificationsEmailConfig{ + Hello: hello, + From: from, + TLS: codersdk.NotificationsEmailTLSConfig{ + InsecureSkipVerify: true, + StartTLS: true, + }, + }, + toAddrs: []string{to}, + }, + { + // Plain connection is established and upgraded via STARTTLS, but certificate is untrusted. + name: "TLS: STARTTLS untrusted", + useTLS: false, + cfg: codersdk.NotificationsEmailConfig{ + TLS: codersdk.NotificationsEmailTLSConfig{ + InsecureSkipVerify: false, + StartTLS: true, + }, + ForceTLS: false, + }, + expectedErr: "tls: failed to verify certificate", + retryable: true, + }, + { + // Plain connection is established and upgraded via STARTTLS, certificate is not verified. + name: "TLS: STARTTLS", + useTLS: false, + cfg: codersdk.NotificationsEmailConfig{ + Hello: hello, + From: from, + TLS: codersdk.NotificationsEmailTLSConfig{ + InsecureSkipVerify: true, + StartTLS: true, + }, + ForceTLS: false, + }, + toAddrs: []string{to}, + }, + { + // TLS connection using self-signed certificate. + name: "TLS: self-signed", + useTLS: true, + cfg: codersdk.NotificationsEmailConfig{ + Hello: hello, + From: from, + TLS: codersdk.NotificationsEmailTLSConfig{ + CAFile: caFile, + CertFile: certFile, + KeyFile: keyFile, + }, + }, + toAddrs: []string{to}, + }, + { + // TLS connection using self-signed certificate & specifying the DNS name configured in the certificate. + name: "TLS: self-signed + SNI", + useTLS: true, + cfg: codersdk.NotificationsEmailConfig{ + Hello: hello, + From: from, + TLS: codersdk.NotificationsEmailTLSConfig{ + ServerName: "myserver.local", + CAFile: caFile, + CertFile: certFile, + KeyFile: keyFile, + }, + }, + toAddrs: []string{to}, + }, + { + name: "TLS: load CA", + useTLS: true, + cfg: codersdk.NotificationsEmailConfig{ + TLS: codersdk.NotificationsEmailTLSConfig{ + CAFile: "nope.crt", + }, + }, + // not using full error message here since it differs on *nix and Windows: + // *nix: no such file or directory + // Windows: The system cannot find the file specified. + expectedErr: "open nope.crt:", + retryable: true, + }, + { + name: "TLS: load cert", + useTLS: true, + cfg: codersdk.NotificationsEmailConfig{ + TLS: codersdk.NotificationsEmailTLSConfig{ + CAFile: caFile, + CertFile: "fixtures/nope.cert", + KeyFile: keyFile, + }, + }, + // not using full error message here since it differs on *nix and Windows: + // *nix: no such file or directory + // Windows: The system cannot find the file specified. + expectedErr: "open fixtures/nope.cert:", + retryable: true, + }, + { + name: "TLS: load cert key", + useTLS: true, + cfg: codersdk.NotificationsEmailConfig{ + TLS: codersdk.NotificationsEmailTLSConfig{ + CAFile: caFile, + CertFile: certFile, + KeyFile: "fixtures/nope.key", + }, + }, + // not using full error message here since it differs on *nix and Windows: + // *nix: no such file or directory + // Windows: The system cannot find the file specified. + expectedErr: "open fixtures/nope.key:", + retryable: true, + }, + /** + * Kitchen sink + */ + { + name: "PLAIN auth and TLS", + useTLS: true, + authMechs: []string{sasl.Plain}, + cfg: codersdk.NotificationsEmailConfig{ + Hello: hello, + From: from, + Auth: codersdk.NotificationsEmailAuthConfig{ + Identity: identity, + Username: username, + Password: password, + }, + TLS: codersdk.NotificationsEmailTLSConfig{ + CAFile: caFile, + CertFile: certFile, + KeyFile: keyFile, + }, + }, + toAddrs: []string{to}, + expectedAuthMeth: sasl.Plain, + }, + } + + // nolint:paralleltest // Reinitialization is not required as of Go v1.22. + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + + tc.cfg.ForceTLS = serpent.Bool(tc.useTLS) + + backend := NewBackend(Config{ + AuthMechanisms: tc.authMechs, + + AcceptedIdentity: tc.cfg.Auth.Identity.String(), + AcceptedUsername: username, + AcceptedPassword: password, + }) + + // Create a mock SMTP server which conditionally listens for plain or TLS connections. + srv, listen, err := createMockSMTPServer(backend, tc.useTLS) + require.NoError(t, err) + t.Cleanup(func() { + // We expect that the server has already been closed in the test + assert.ErrorIs(t, srv.Shutdown(ctx), smtp.ErrServerClosed) + }) + + errs := bytes.NewBuffer(nil) + srv.ErrorLog = log.New(errs, "oops", 0) + // Enable this to debug mock SMTP server. + // srv.Debug = os.Stderr + + var hp serpent.HostPort + require.NoError(t, hp.Set(listen.Addr().String())) + tc.cfg.Smarthost = hp + + handler := dispatch.NewSMTPHandler(tc.cfg, logger.Named("smtp")) + + // Start mock SMTP server in the background. + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + assert.NoError(t, srv.Serve(listen)) + }() + + // Wait for the server to become pingable. + require.Eventually(t, func() bool { + cl, err := pingClient(listen, tc.useTLS, tc.cfg.TLS.StartTLS.Value()) + if err != nil { + t.Logf("smtp not yet dialable: %s", err) + return false + } + + if err = cl.Noop(); err != nil { + t.Logf("smtp not yet noopable: %s", err) + return false + } + + if err = cl.Close(); err != nil { + t.Logf("smtp didn't close properly: %s", err) + return false + } + + return true + }, testutil.WaitShort, testutil.IntervalFast) + + // Build a fake payload. + payload := types.MessagePayload{ + Version: "1.0", + UserEmail: to, + Labels: make(map[string]string), + } + + dispatchFn, err := handler.Dispatcher(payload, subject, body) + require.NoError(t, err) + + msgID := uuid.New() + retryable, err := dispatchFn(ctx, msgID) + + if tc.expectedErr == "" { + require.Nil(t, err) + require.Empty(t, errs.Bytes()) + + msg := backend.LastMessage() + require.NotNil(t, msg) + backend.Reset() + + require.Equal(t, tc.expectedAuthMeth, msg.AuthMech) + require.Equal(t, from, msg.From) + require.Equal(t, tc.toAddrs, msg.To) + if !tc.cfg.Auth.Empty() { + require.Equal(t, tc.cfg.Auth.Identity.String(), msg.Identity) + require.Equal(t, username, msg.Username) + require.Equal(t, password, msg.Password) + } + require.Contains(t, msg.Contents, subject) + require.Contains(t, msg.Contents, body) + require.Contains(t, msg.Contents, fmt.Sprintf("Message-Id: %s", msgID)) + } else { + require.ErrorContains(t, err, tc.expectedErr) + } + + require.Equal(t, tc.retryable, retryable) + + require.NoError(t, srv.Shutdown(ctx)) + wg.Wait() + }) + } +} + +func pingClient(listen net.Listener, useTLS bool, startTLS bool) (*smtp.Client, error) { + tlsCfg := &tls.Config{ + // nolint:gosec // It's a test. + InsecureSkipVerify: true, + } + + switch { + case useTLS: + return smtp.DialTLS(listen.Addr().String(), tlsCfg) + case startTLS: + return smtp.DialStartTLS(listen.Addr().String(), tlsCfg) + default: + return smtp.Dial(listen.Addr().String()) + } +} diff --git a/coderd/notifications/dispatch/smtp_util_test.go b/coderd/notifications/dispatch/smtp_util_test.go new file mode 100644 index 0000000000000..659a17bec4a08 --- /dev/null +++ b/coderd/notifications/dispatch/smtp_util_test.go @@ -0,0 +1,200 @@ +package dispatch_test + +import ( + "crypto/tls" + _ "embed" + "io" + "net" + "sync" + "time" + + "github.com/emersion/go-sasl" + "github.com/emersion/go-smtp" + "golang.org/x/xerrors" +) + +// TLS cert files. +var ( + //go:embed fixtures/server.crt + certFile []byte + //go:embed fixtures/server.key + keyFile []byte +) + +type Config struct { + AuthMechanisms []string + AcceptedIdentity, AcceptedUsername, AcceptedPassword string +} + +type Message struct { + AuthMech string + Identity, Username, Password string // Auth + From string + To []string // Address + Subject, Contents string // Content +} + +type Backend struct { + cfg Config + + mu sync.Mutex + lastMsg *Message +} + +func NewBackend(cfg Config) *Backend { + return &Backend{ + cfg: cfg, + } +} + +// NewSession is called after client greeting (EHLO, HELO). +func (b *Backend) NewSession(c *smtp.Conn) (smtp.Session, error) { + return &Session{conn: c, backend: b}, nil +} + +func (b *Backend) LastMessage() *Message { + return b.lastMsg +} + +func (b *Backend) Reset() { + b.lastMsg = nil +} + +type Session struct { + conn *smtp.Conn + backend *Backend +} + +// AuthMechanisms returns a slice of available auth mechanisms; only PLAIN is +// supported in this example. +func (s *Session) AuthMechanisms() []string { + return s.backend.cfg.AuthMechanisms +} + +// Auth is the handler for supported authenticators. +func (s *Session) Auth(mech string) (sasl.Server, error) { + s.backend.mu.Lock() + defer s.backend.mu.Unlock() + + if s.backend.lastMsg == nil { + s.backend.lastMsg = &Message{AuthMech: mech} + } + + switch mech { + case sasl.Plain: + return sasl.NewPlainServer(func(identity, username, password string) error { + s.backend.lastMsg.Identity = identity + s.backend.lastMsg.Username = username + s.backend.lastMsg.Password = password + + if s.backend.cfg.AcceptedIdentity != "" && identity != s.backend.cfg.AcceptedIdentity { + return xerrors.Errorf("unknown identity: %q", identity) + } + if username != s.backend.cfg.AcceptedUsername { + return xerrors.Errorf("unknown user: %q", username) + } + if password != s.backend.cfg.AcceptedPassword { + return xerrors.Errorf("incorrect password for username: %q", username) + } + + return nil + }), nil + case sasl.Login: + return sasl.NewLoginServer(func(username, password string) error { + s.backend.lastMsg.Username = username + s.backend.lastMsg.Password = password + + if username != s.backend.cfg.AcceptedUsername { + return xerrors.Errorf("unknown user: %q", username) + } + if password != s.backend.cfg.AcceptedPassword { + return xerrors.Errorf("incorrect password for username: %q", username) + } + + return nil + }), nil + default: + return nil, xerrors.Errorf("unexpected auth mechanism: %q", mech) + } +} + +func (s *Session) Mail(from string, _ *smtp.MailOptions) error { + s.backend.mu.Lock() + defer s.backend.mu.Unlock() + + if s.backend.lastMsg == nil { + s.backend.lastMsg = &Message{} + } + + s.backend.lastMsg.From = from + return nil +} + +func (s *Session) Rcpt(to string, _ *smtp.RcptOptions) error { + s.backend.mu.Lock() + defer s.backend.mu.Unlock() + + s.backend.lastMsg.To = append(s.backend.lastMsg.To, to) + return nil +} + +func (s *Session) Data(r io.Reader) error { + s.backend.mu.Lock() + defer s.backend.mu.Unlock() + + b, err := io.ReadAll(r) + if err != nil { + return err + } + + s.backend.lastMsg.Contents = string(b) + + return nil +} + +func (*Session) Reset() {} + +func (*Session) Logout() error { return nil } + +// nolint:revive // Yes, useTLS is a control flag. +func createMockSMTPServer(be *Backend, useTLS bool) (*smtp.Server, net.Listener, error) { + // nolint:gosec + tlsCfg := &tls.Config{ + GetCertificate: readCert, + } + + l, err := net.Listen("tcp", "localhost:0") + if err != nil { + return nil, nil, xerrors.Errorf("connect: tls? %v: %w", useTLS, err) + } + + if useTLS { + l = tls.NewListener(l, tlsCfg) + } + + addr, ok := l.Addr().(*net.TCPAddr) + if !ok { + return nil, nil, xerrors.Errorf("unexpected address type: %T", l.Addr()) + } + + s := smtp.NewServer(be) + + s.Addr = addr.String() + s.WriteTimeout = 10 * time.Second + s.ReadTimeout = 10 * time.Second + s.MaxMessageBytes = 1024 * 1024 + s.MaxRecipients = 50 + s.AllowInsecureAuth = !useTLS + s.TLSConfig = tlsCfg + + return s, l, nil +} + +func readCert(_ *tls.ClientHelloInfo) (*tls.Certificate, error) { + crt, err := tls.X509KeyPair(certFile, keyFile) + if err != nil { + return nil, xerrors.Errorf("load x509 cert: %w", err) + } + + return &crt, nil +} diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 8cf6681ad5954..bd7e26e356fe5 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "path/filepath" + "reflect" "strconv" "strings" "time" @@ -503,23 +504,46 @@ type NotificationsEmailConfig struct { // The hostname identifying the SMTP server. Hello serpent.String `json:"hello" typescript:",notnull"` - // TODO: Auth and Headers - //// Authentication details. - // Auth struct { - // // Username for CRAM-MD5/LOGIN/PLAIN auth; authentication is disabled if this is left blank. - // Username serpent.String `json:"username" typescript:",notnull"` - // // Password to use for LOGIN/PLAIN auth. - // Password serpent.String `json:"password" typescript:",notnull"` - // // File from which to load the password to use for LOGIN/PLAIN auth. - // PasswordFile serpent.String `json:"password_file" typescript:",notnull"` - // // Secret to use for CRAM-MD5 auth. - // Secret serpent.String `json:"secret" typescript:",notnull"` - // // Identity used for PLAIN auth. - // Identity serpent.String `json:"identity" typescript:",notnull"` - // } `json:"auth" typescript:",notnull"` - // // Additional headers to use in the SMTP request. - // Headers map[string]string `json:"headers" typescript:",notnull"` - // TODO: TLS + // Authentication details. + Auth NotificationsEmailAuthConfig `json:"auth" typescript:",notnull"` + // TLS details. + TLS NotificationsEmailTLSConfig `json:"tls" typescript:",notnull"` + // ForceTLS causes a TLS connection to be attempted. + ForceTLS serpent.Bool `json:"force_tls" typescript:",notnull"` +} + +type NotificationsEmailAuthConfig struct { + // Identity for PLAIN auth. + Identity serpent.String `json:"identity" typescript:",notnull"` + // Username for LOGIN/PLAIN auth. + Username serpent.String `json:"username" typescript:",notnull"` + // Password for LOGIN/PLAIN auth. + Password serpent.String `json:"password" typescript:",notnull"` + // File from which to load the password for LOGIN/PLAIN auth. + PasswordFile serpent.String `json:"password_file" typescript:",notnull"` +} + +func (c *NotificationsEmailAuthConfig) Empty() bool { + return reflect.ValueOf(*c).IsZero() +} + +type NotificationsEmailTLSConfig struct { + // StartTLS attempts to upgrade plain connections to TLS. + StartTLS serpent.Bool `json:"start_tls" typescript:",notnull"` + // ServerName to verify the hostname for the targets. + ServerName serpent.String `json:"server_name" typescript:",notnull"` + // InsecureSkipVerify skips target certificate validation. + InsecureSkipVerify serpent.Bool `json:"insecure_skip_verify" typescript:",notnull"` + // CAFile specifies the location of the CA certificate to use. + CAFile serpent.String `json:"ca_file" typescript:",notnull"` + // CertFile specifies the location of the certificate to use. + CertFile serpent.String `json:"cert_file" typescript:",notnull"` + // KeyFile specifies the location of the key to use. + KeyFile serpent.String `json:"key_file" typescript:",notnull"` +} + +func (c *NotificationsEmailTLSConfig) Empty() bool { + return reflect.ValueOf(*c).IsZero() } type NotificationsWebhookConfig struct { @@ -673,13 +697,27 @@ when required by your organization's security policy.`, Description: `Use a YAML configuration file when your server launch become unwieldy.`, } deploymentGroupNotifications = serpent.Group{ - Name: "Notifications", - YAML: "notifications", + Name: "Notifications", + YAML: "notifications", + Description: "Configure how notifications are processed and delivered.", } deploymentGroupNotificationsEmail = serpent.Group{ - Name: "Email", - Parent: &deploymentGroupNotifications, - YAML: "email", + Name: "Email", + Parent: &deploymentGroupNotifications, + Description: "Configure how email notifications are sent.", + YAML: "email", + } + deploymentGroupNotificationsEmailAuth = serpent.Group{ + Name: "Email Authentication", + Parent: &deploymentGroupNotificationsEmail, + Description: "Configure SMTP authentication options.", + YAML: "emailAuth", + } + deploymentGroupNotificationsEmailTLS = serpent.Group{ + Name: "Email TLS", + Parent: &deploymentGroupNotificationsEmail, + Description: "Configure TLS for your SMTP server target.", + YAML: "emailTLS", } deploymentGroupNotificationsWebhook = serpent.Group{ Name: "Webhook", @@ -2121,7 +2159,7 @@ Write out the current server config as YAML to stdout.`, Value: &c.Notifications.DispatchTimeout, Default: time.Minute.String(), Group: &deploymentGroupNotifications, - YAML: "dispatch-timeout", + YAML: "dispatchTimeout", Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"), }, { @@ -2153,6 +2191,106 @@ Write out the current server config as YAML to stdout.`, Group: &deploymentGroupNotificationsEmail, YAML: "hello", }, + { + Name: "Notifications: Email: Force TLS", + Description: "Force a TLS connection to the configured SMTP smarthost.", + Flag: "notifications-email-force-tls", + Env: "CODER_NOTIFICATIONS_EMAIL_FORCE_TLS", + Default: "false", + Value: &c.Notifications.SMTP.ForceTLS, + Group: &deploymentGroupNotificationsEmail, + YAML: "forceTLS", + }, + { + Name: "Notifications: Email Auth: Identity", + Description: "Identity to use with PLAIN authentication.", + Flag: "notifications-email-auth-identity", + Env: "CODER_NOTIFICATIONS_EMAIL_AUTH_IDENTITY", + Value: &c.Notifications.SMTP.Auth.Identity, + Group: &deploymentGroupNotificationsEmailAuth, + YAML: "identity", + }, + { + Name: "Notifications: Email Auth: Username", + Description: "Username to use with PLAIN/LOGIN authentication.", + Flag: "notifications-email-auth-username", + Env: "CODER_NOTIFICATIONS_EMAIL_AUTH_USERNAME", + Value: &c.Notifications.SMTP.Auth.Username, + Group: &deploymentGroupNotificationsEmailAuth, + YAML: "username", + }, + { + Name: "Notifications: Email Auth: Password", + Description: "Password to use with PLAIN/LOGIN authentication.", + Flag: "notifications-email-auth-password", + Env: "CODER_NOTIFICATIONS_EMAIL_AUTH_PASSWORD", + Value: &c.Notifications.SMTP.Auth.Password, + Group: &deploymentGroupNotificationsEmailAuth, + YAML: "password", + }, + { + Name: "Notifications: Email Auth: Password File", + Description: "File from which to load password for use with PLAIN/LOGIN authentication.", + Flag: "notifications-email-auth-password-file", + Env: "CODER_NOTIFICATIONS_EMAIL_AUTH_PASSWORD_FILE", + Value: &c.Notifications.SMTP.Auth.PasswordFile, + Group: &deploymentGroupNotificationsEmailAuth, + YAML: "passwordFile", + }, + { + Name: "Notifications: Email TLS: StartTLS", + Description: "Enable STARTTLS to upgrade insecure SMTP connections using TLS.", + Flag: "notifications-email-tls-starttls", + Env: "CODER_NOTIFICATIONS_EMAIL_TLS_STARTTLS", + Value: &c.Notifications.SMTP.TLS.StartTLS, + Group: &deploymentGroupNotificationsEmailTLS, + YAML: "startTLS", + }, + { + Name: "Notifications: Email TLS: Server Name", + Description: "Server name to verify against the target certificate.", + Flag: "notifications-email-tls-server-name", + Env: "CODER_NOTIFICATIONS_EMAIL_TLS_SERVERNAME", + Value: &c.Notifications.SMTP.TLS.ServerName, + Group: &deploymentGroupNotificationsEmailTLS, + YAML: "serverName", + }, + { + Name: "Notifications: Email TLS: Skip Certificate Verification (Insecure)", + Description: "Skip verification of the target server's certificate (insecure).", + Flag: "notifications-email-tls-skip-verify", + Env: "CODER_NOTIFICATIONS_EMAIL_TLS_SKIPVERIFY", + Value: &c.Notifications.SMTP.TLS.InsecureSkipVerify, + Group: &deploymentGroupNotificationsEmailTLS, + YAML: "insecureSkipVerify", + }, + { + Name: "Notifications: Email TLS: Certificate Authority File", + Description: "CA certificate file to use.", + Flag: "notifications-email-tls-ca-cert-file", + Env: "CODER_NOTIFICATIONS_EMAIL_TLS_CACERTFILE", + Value: &c.Notifications.SMTP.TLS.CAFile, + Group: &deploymentGroupNotificationsEmailTLS, + YAML: "caCertFile", + }, + { + Name: "Notifications: Email TLS: Certificate File", + Description: "Certificate file to use.", + Flag: "notifications-email-tls-cert-file", + Env: "CODER_NOTIFICATIONS_EMAIL_TLS_CERTFILE", + Value: &c.Notifications.SMTP.TLS.CertFile, + Group: &deploymentGroupNotificationsEmailTLS, + YAML: "certFile", + }, + { + Name: "Notifications: Email TLS: Certificate Key File", + Description: "Certificate key file to use.", + Flag: "notifications-email-tls-cert-key-file", + Env: "CODER_NOTIFICATIONS_EMAIL_TLS_CERTKEYFILE", + Value: &c.Notifications.SMTP.TLS.KeyFile, + Group: &deploymentGroupNotificationsEmailTLS, + YAML: "certKeyFile", + }, { Name: "Notifications: Webhook: Endpoint", Description: "The endpoint to which to send webhooks.", @@ -2170,7 +2308,7 @@ Write out the current server config as YAML to stdout.`, Value: &c.Notifications.MaxSendAttempts, Default: "5", Group: &deploymentGroupNotifications, - YAML: "max-send-attempts", + YAML: "maxSendAttempts", }, { Name: "Notifications: Retry Interval", @@ -2180,7 +2318,7 @@ Write out the current server config as YAML to stdout.`, Value: &c.Notifications.RetryInterval, Default: (time.Minute * 5).String(), Group: &deploymentGroupNotifications, - YAML: "retry-interval", + YAML: "retryInterval", Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"), Hidden: true, // Hidden because most operators should not need to modify this. }, @@ -2195,7 +2333,7 @@ Write out the current server config as YAML to stdout.`, Value: &c.Notifications.StoreSyncInterval, Default: (time.Second * 2).String(), Group: &deploymentGroupNotifications, - YAML: "store-sync-interval", + YAML: "storeSyncInterval", Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"), Hidden: true, // Hidden because most operators should not need to modify this. }, @@ -2210,7 +2348,7 @@ Write out the current server config as YAML to stdout.`, Value: &c.Notifications.StoreSyncBufferSize, Default: "50", Group: &deploymentGroupNotifications, - YAML: "store-sync-buffer-size", + YAML: "storeSyncBufferSize", Hidden: true, // Hidden because most operators should not need to modify this. }, { @@ -2225,7 +2363,7 @@ Write out the current server config as YAML to stdout.`, Value: &c.Notifications.LeasePeriod, Default: (time.Minute * 2).String(), Group: &deploymentGroupNotifications, - YAML: "lease-period", + YAML: "leasePeriod", Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"), Hidden: true, // Hidden because most operators should not need to modify this. }, @@ -2237,7 +2375,7 @@ Write out the current server config as YAML to stdout.`, Value: &c.Notifications.LeaseCount, Default: "20", Group: &deploymentGroupNotifications, - YAML: "lease-count", + YAML: "leaseCount", Hidden: true, // Hidden because most operators should not need to modify this. }, { @@ -2248,7 +2386,7 @@ Write out the current server config as YAML to stdout.`, Value: &c.Notifications.FetchInterval, Default: (time.Second * 15).String(), Group: &deploymentGroupNotifications, - YAML: "fetch-interval", + YAML: "fetchInterval", Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"), Hidden: true, // Hidden because most operators should not need to modify this. }, diff --git a/docs/api/general.md b/docs/api/general.md index c628604b92123..e4ea5557f0ac2 100644 --- a/docs/api/general.md +++ b/docs/api/general.md @@ -256,11 +256,26 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "notifications": { "dispatch_timeout": 0, "email": { + "auth": { + "identity": "string", + "password": "string", + "password_file": "string", + "username": "string" + }, + "force_tls": true, "from": "string", "hello": "string", "smarthost": { "host": "string", "port": "string" + }, + "tls": { + "ca_file": "string", + "cert_file": "string", + "insecure_skip_verify": true, + "key_file": "string", + "server_name": "string", + "start_tls": true } }, "fetch_interval": 0, diff --git a/docs/api/schemas.md b/docs/api/schemas.md index a3e745b46fa17..ff7dec97c5b58 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -1682,11 +1682,26 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "notifications": { "dispatch_timeout": 0, "email": { + "auth": { + "identity": "string", + "password": "string", + "password_file": "string", + "username": "string" + }, + "force_tls": true, "from": "string", "hello": "string", "smarthost": { "host": "string", "port": "string" + }, + "tls": { + "ca_file": "string", + "cert_file": "string", + "insecure_skip_verify": true, + "key_file": "string", + "server_name": "string", + "start_tls": true } }, "fetch_interval": 0, @@ -2089,11 +2104,26 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "notifications": { "dispatch_timeout": 0, "email": { + "auth": { + "identity": "string", + "password": "string", + "password_file": "string", + "username": "string" + }, + "force_tls": true, "from": "string", "hello": "string", "smarthost": { "host": "string", "port": "string" + }, + "tls": { + "ca_file": "string", + "cert_file": "string", + "insecure_skip_verify": true, + "key_file": "string", + "server_name": "string", + "start_tls": true } }, "fetch_interval": 0, @@ -3052,11 +3082,26 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o { "dispatch_timeout": 0, "email": { + "auth": { + "identity": "string", + "password": "string", + "password_file": "string", + "username": "string" + }, + "force_tls": true, "from": "string", "hello": "string", "smarthost": { "host": "string", "port": "string" + }, + "tls": { + "ca_file": "string", + "cert_file": "string", + "insecure_skip_verify": true, + "key_file": "string", + "server_name": "string", + "start_tls": true } }, "fetch_interval": 0, @@ -3101,26 +3146,88 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `sync_interval` | integer | false | | The notifications system buffers message updates in memory to ease pressure on the database. This option controls how often it synchronizes its state with the database. The shorter this value the lower the change of state inconsistency in a non-graceful shutdown - but it also increases load on the database. It is recommended to keep this option at its default value. | | `webhook` | [codersdk.NotificationsWebhookConfig](#codersdknotificationswebhookconfig) | false | | Webhook settings. | +## codersdk.NotificationsEmailAuthConfig + +```json +{ + "identity": "string", + "password": "string", + "password_file": "string", + "username": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| --------------- | ------ | -------- | ------------ | ---------------------------------------------------------- | +| `identity` | string | false | | Identity for PLAIN auth. | +| `password` | string | false | | Password for LOGIN/PLAIN auth. | +| `password_file` | string | false | | File from which to load the password for LOGIN/PLAIN auth. | +| `username` | string | false | | Username for LOGIN/PLAIN auth. | + ## codersdk.NotificationsEmailConfig ```json { + "auth": { + "identity": "string", + "password": "string", + "password_file": "string", + "username": "string" + }, + "force_tls": true, "from": "string", "hello": "string", "smarthost": { "host": "string", "port": "string" + }, + "tls": { + "ca_file": "string", + "cert_file": "string", + "insecure_skip_verify": true, + "key_file": "string", + "server_name": "string", + "start_tls": true } } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -| ----------- | ------------------------------------ | -------- | ------------ | --------------------------------------------------------------------- | -| `from` | string | false | | The sender's address. | -| `hello` | string | false | | The hostname identifying the SMTP server. | -| `smarthost` | [serpent.HostPort](#serpenthostport) | false | | The intermediary SMTP host through which emails are sent (host:port). | +| Name | Type | Required | Restrictions | Description | +| ----------- | ------------------------------------------------------------------------------ | -------- | ------------ | --------------------------------------------------------------------- | +| `auth` | [codersdk.NotificationsEmailAuthConfig](#codersdknotificationsemailauthconfig) | false | | Authentication details. | +| `force_tls` | boolean | false | | Force tls causes a TLS connection to be attempted. | +| `from` | string | false | | The sender's address. | +| `hello` | string | false | | The hostname identifying the SMTP server. | +| `smarthost` | [serpent.HostPort](#serpenthostport) | false | | The intermediary SMTP host through which emails are sent (host:port). | +| `tls` | [codersdk.NotificationsEmailTLSConfig](#codersdknotificationsemailtlsconfig) | false | | Tls details. | + +## codersdk.NotificationsEmailTLSConfig + +```json +{ + "ca_file": "string", + "cert_file": "string", + "insecure_skip_verify": true, + "key_file": "string", + "server_name": "string", + "start_tls": true +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ---------------------- | ------- | -------- | ------------ | ------------------------------------------------------------ | +| `ca_file` | string | false | | Ca file specifies the location of the CA certificate to use. | +| `cert_file` | string | false | | Cert file specifies the location of the certificate to use. | +| `insecure_skip_verify` | boolean | false | | Insecure skip verify skips target certificate validation. | +| `key_file` | string | false | | Key file specifies the location of the key to use. | +| `server_name` | string | false | | Server name to verify the hostname for the targets. | +| `start_tls` | boolean | false | | Start tls attempts to upgrade plain connections to TLS. | ## codersdk.NotificationsSettings diff --git a/docs/cli/server.md b/docs/cli/server.md index b3e8da3213b3d..c01f9c3b8c88c 100644 --- a/docs/cli/server.md +++ b/docs/cli/server.md @@ -1212,7 +1212,7 @@ Which delivery method to use (available options: 'smtp', 'webhook'). | ----------- | -------------------------------------------------- | | Type | duration | | Environment | $CODER_NOTIFICATIONS_DISPATCH_TIMEOUT | -| YAML | notifications.dispatch-timeout | +| YAML | notifications.dispatchTimeout | | Default | 1m0s | How long to wait while a notification is being sent before giving up. @@ -1249,6 +1249,117 @@ The intermediary SMTP host through which emails are sent. The hostname identifying the SMTP server. +### --notifications-email-force-tls + +| | | +| ----------- | ------------------------------------------------- | +| Type | bool | +| Environment | $CODER_NOTIFICATIONS_EMAIL_FORCE_TLS | +| YAML | notifications.email.forceTLS | +| Default | false | + +Force a TLS connection to the configured SMTP smarthost. + +### --notifications-email-auth-identity + +| | | +| ----------- | ----------------------------------------------------- | +| Type | string | +| Environment | $CODER_NOTIFICATIONS_EMAIL_AUTH_IDENTITY | +| YAML | notifications.email.emailAuth.identity | + +Identity to use with PLAIN authentication. + +### --notifications-email-auth-username + +| | | +| ----------- | ----------------------------------------------------- | +| Type | string | +| Environment | $CODER_NOTIFICATIONS_EMAIL_AUTH_USERNAME | +| YAML | notifications.email.emailAuth.username | + +Username to use with PLAIN/LOGIN authentication. + +### --notifications-email-auth-password + +| | | +| ----------- | ----------------------------------------------------- | +| Type | string | +| Environment | $CODER_NOTIFICATIONS_EMAIL_AUTH_PASSWORD | +| YAML | notifications.email.emailAuth.password | + +Password to use with PLAIN/LOGIN authentication. + +### --notifications-email-auth-password-file + +| | | +| ----------- | ---------------------------------------------------------- | +| Type | string | +| Environment | $CODER_NOTIFICATIONS_EMAIL_AUTH_PASSWORD_FILE | +| YAML | notifications.email.emailAuth.passwordFile | + +File from which to load password for use with PLAIN/LOGIN authentication. + +### --notifications-email-tls-starttls + +| | | +| ----------- | ---------------------------------------------------- | +| Type | bool | +| Environment | $CODER_NOTIFICATIONS_EMAIL_TLS_STARTTLS | +| YAML | notifications.email.emailTLS.startTLS | + +Enable STARTTLS to upgrade insecure SMTP connections using TLS. + +### --notifications-email-tls-server-name + +| | | +| ----------- | ------------------------------------------------------ | +| Type | string | +| Environment | $CODER_NOTIFICATIONS_EMAIL_TLS_SERVERNAME | +| YAML | notifications.email.emailTLS.serverName | + +Server name to verify against the target certificate. + +### --notifications-email-tls-skip-verify + +| | | +| ----------- | ------------------------------------------------------------ | +| Type | bool | +| Environment | $CODER_NOTIFICATIONS_EMAIL_TLS_SKIPVERIFY | +| YAML | notifications.email.emailTLS.insecureSkipVerify | + +Skip verification of the target server's certificate (insecure). + +### --notifications-email-tls-ca-cert-file + +| | | +| ----------- | ------------------------------------------------------ | +| Type | string | +| Environment | $CODER_NOTIFICATIONS_EMAIL_TLS_CACERTFILE | +| YAML | notifications.email.emailTLS.caCertFile | + +CA certificate file to use. + +### --notifications-email-tls-cert-file + +| | | +| ----------- | ---------------------------------------------------- | +| Type | string | +| Environment | $CODER_NOTIFICATIONS_EMAIL_TLS_CERTFILE | +| YAML | notifications.email.emailTLS.certFile | + +Certificate file to use. + +### --notifications-email-tls-cert-key-file + +| | | +| ----------- | ------------------------------------------------------- | +| Type | string | +| Environment | $CODER_NOTIFICATIONS_EMAIL_TLS_CERTKEYFILE | +| YAML | notifications.email.emailTLS.certKeyFile | + +Certificate key file to use. + ### --notifications-webhook-endpoint | | | @@ -1265,7 +1376,7 @@ The endpoint to which to send webhooks. | ----------- | --------------------------------------------------- | | Type | int | | Environment | $CODER_NOTIFICATIONS_MAX_SEND_ATTEMPTS | -| YAML | notifications.max-send-attempts | +| YAML | notifications.maxSendAttempts | | Default | 5 | The upper limit of attempts to send a notification. diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index 8bde8a9d3fc94..979abafc72118 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -328,6 +328,8 @@ can safely ignore these settings. "tls11", "tls12" or "tls13". NOTIFICATIONS OPTIONS: +Configure how notifications are processed and delivered. + --notifications-dispatch-timeout duration, $CODER_NOTIFICATIONS_DISPATCH_TIMEOUT (default: 1m0s) How long to wait while a notification is being sent before giving up. @@ -338,6 +340,11 @@ NOTIFICATIONS OPTIONS: Which delivery method to use (available options: 'smtp', 'webhook'). NOTIFICATIONS / EMAIL OPTIONS: +Configure how email notifications are sent. + + --notifications-email-force-tls bool, $CODER_NOTIFICATIONS_EMAIL_FORCE_TLS (default: false) + Force a TLS connection to the configured SMTP smarthost. + --notifications-email-from string, $CODER_NOTIFICATIONS_EMAIL_FROM The sender's address to use. @@ -347,6 +354,43 @@ NOTIFICATIONS / EMAIL OPTIONS: --notifications-email-smarthost host:port, $CODER_NOTIFICATIONS_EMAIL_SMARTHOST (default: localhost:587) The intermediary SMTP host through which emails are sent. +NOTIFICATIONS / EMAIL / EMAIL AUTHENTICATION OPTIONS: +Configure SMTP authentication options. + + --notifications-email-auth-identity string, $CODER_NOTIFICATIONS_EMAIL_AUTH_IDENTITY + Identity to use with PLAIN authentication. + + --notifications-email-auth-password string, $CODER_NOTIFICATIONS_EMAIL_AUTH_PASSWORD + Password to use with PLAIN/LOGIN authentication. + + --notifications-email-auth-password-file string, $CODER_NOTIFICATIONS_EMAIL_AUTH_PASSWORD_FILE + File from which to load password for use with PLAIN/LOGIN + authentication. + + --notifications-email-auth-username string, $CODER_NOTIFICATIONS_EMAIL_AUTH_USERNAME + Username to use with PLAIN/LOGIN authentication. + +NOTIFICATIONS / EMAIL / EMAIL TLS OPTIONS: +Configure TLS for your SMTP server target. + + --notifications-email-tls-ca-cert-file string, $CODER_NOTIFICATIONS_EMAIL_TLS_CACERTFILE + CA certificate file to use. + + --notifications-email-tls-cert-file string, $CODER_NOTIFICATIONS_EMAIL_TLS_CERTFILE + Certificate file to use. + + --notifications-email-tls-cert-key-file string, $CODER_NOTIFICATIONS_EMAIL_TLS_CERTKEYFILE + Certificate key file to use. + + --notifications-email-tls-server-name string, $CODER_NOTIFICATIONS_EMAIL_TLS_SERVERNAME + Server name to verify against the target certificate. + + --notifications-email-tls-skip-verify bool, $CODER_NOTIFICATIONS_EMAIL_TLS_SKIPVERIFY + Skip verification of the target server's certificate (insecure). + + --notifications-email-tls-starttls bool, $CODER_NOTIFICATIONS_EMAIL_TLS_STARTTLS + Enable STARTTLS to upgrade insecure SMTP connections using TLS. + NOTIFICATIONS / WEBHOOK OPTIONS: --notifications-webhook-endpoint url, $CODER_NOTIFICATIONS_WEBHOOK_ENDPOINT The endpoint to which to send webhooks. diff --git a/flake.nix b/flake.nix index 07ab3ee091150..5d05d5c45b570 100644 --- a/flake.nix +++ b/flake.nix @@ -117,7 +117,7 @@ name = "coder-${osArch}"; # Updated with ./scripts/update-flake.sh`. # This should be updated whenever go.mod changes! - vendorHash = "sha256-HXDei93ALEImIMgX3Ez829jmJJsf46GwaqPDlleQFmk="; + vendorHash = "sha256-Sjv5MjOFRKe2BaOdEh48Hdlgn46CIWUVrKqtZ21Z/d8="; proxyVendor = true; src = ./.; nativeBuildInputs = with pkgs; [ getopt openssl zstd ]; diff --git a/go.mod b/go.mod index 3267771487631..6d9c1528a6e66 100644 --- a/go.mod +++ b/go.mod @@ -197,6 +197,8 @@ require go.uber.org/mock v0.4.0 require ( github.com/coder/serpent v0.7.0 + github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 + github.com/emersion/go-smtp v0.21.2 github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47 github.com/google/go-github/v61 v61.0.0 github.com/mocktools/go-smtp-mock/v2 v2.3.0 diff --git a/go.sum b/go.sum index e50c8619a34b3..cba3c1c7c4fad 100644 --- a/go.sum +++ b/go.sum @@ -277,6 +277,10 @@ github.com/elastic/go-sysinfo v1.14.0 h1:dQRtiqLycoOOla7IflZg3aN213vqJmP0lpVpKQ9 github.com/elastic/go-sysinfo v1.14.0/go.mod h1:FKUXnZWhnYI0ueO7jhsGV3uQJ5hiz8OqM5b3oGyaRr8= github.com/elastic/go-windows v1.0.0 h1:qLURgZFkkrYyTTkvYpsZIgf83AUsdIHfvlJaqaZ7aSY= github.com/elastic/go-windows v1.0.0/go.mod h1:TsU0Nrp7/y3+VwE82FoZF8gC/XFg/Elz6CcloAxnPgU= +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ= +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-smtp v0.21.2 h1:OLDgvZKuofk4em9fT5tFG5j4jE1/hXnX75UMvcrL4AA= +github.com/emersion/go-smtp v0.21.2/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index d4bbc32bba10c..bbe4fba5803be 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -702,11 +702,32 @@ export interface NotificationsConfig { readonly webhook: NotificationsWebhookConfig; } +// From codersdk/deployment.go +export interface NotificationsEmailAuthConfig { + readonly identity: string; + readonly username: string; + readonly password: string; + readonly password_file: string; +} + // From codersdk/deployment.go export interface NotificationsEmailConfig { readonly from: string; readonly smarthost: string; readonly hello: string; + readonly auth: NotificationsEmailAuthConfig; + readonly tls: NotificationsEmailTLSConfig; + readonly force_tls: boolean; +} + +// From codersdk/deployment.go +export interface NotificationsEmailTLSConfig { + readonly start_tls: boolean; + readonly server_name: string; + readonly insecure_skip_verify: boolean; + readonly ca_file: string; + readonly cert_file: string; + readonly key_file: string; } // From codersdk/notifications.go