Skip to content

Commit

Permalink
sfe: Implement self-service frontend for account pausing/unpausing (#…
Browse files Browse the repository at this point in the history
…7500)

Adds a new boulder component named `sfe` aka the Self-service FrontEnd
which is dedicated to non-ACME related Subscriber functions. This change
implements one such function which is a web interface and handlers for
account unpausing.

When paused, an ACME client receives a log line URL with a JWT parameter
from the WFE. For the observant Subscriber, manually clicking the link
opens their web browser and displays a page with a pre-filled HTML form.
Upon clicking the form button, the SFE sends an HTTP POST back to itself
and either validates the JWT and issues an RA gRPC request to unpause
the account, or returns an HTML error page.

The SFE and WFE should share a 32 byte seed value e.g. the output of
`openssl rand -hex 16` which will be used as a go-jose symmetric signer
using the HS256 algorithm. The SFE will check various [RFC
7519](https://datatracker.ietf.org/doc/html/rfc7519) claims on the JWT
such as the `iss`, `aud`, `nbf`, `exp`, `iat`, and a custom `apiVersion`
claim.

The SFE should not yet be relied upon or deployed to staging/production
environments. It is very much a work in progress, but this change is big
enough as-is.

Related to #7406
Part of #7499
  • Loading branch information
pgporada authored Jul 10, 2024
1 parent 63452d5 commit 30c6e59
Show file tree
Hide file tree
Showing 30 changed files with 2,140 additions and 39 deletions.
40 changes: 4 additions & 36 deletions cmd/boulder-wfe2/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"encoding/pem"
"flag"
"fmt"
"log"
"net/http"
"os"
"time"
Expand All @@ -19,12 +18,12 @@ import (
bgrpc "github.com/letsencrypt/boulder/grpc"
"github.com/letsencrypt/boulder/grpc/noncebalancer"
"github.com/letsencrypt/boulder/issuance"
blog "github.com/letsencrypt/boulder/log"
"github.com/letsencrypt/boulder/nonce"
rapb "github.com/letsencrypt/boulder/ra/proto"
"github.com/letsencrypt/boulder/ratelimits"
bredis "github.com/letsencrypt/boulder/redis"
sapb "github.com/letsencrypt/boulder/sa/proto"
"github.com/letsencrypt/boulder/web"
"github.com/letsencrypt/boulder/wfe2"
)

Expand All @@ -42,7 +41,7 @@ type Config struct {
TLSListenAddress string `validate:"omitempty,hostname_port"`

// Timeout is the per-request overall timeout. This should be slightly
// lower than the upstream's timeout when making request to the WFE.
// lower than the upstream's timeout when making requests to the WFE.
Timeout config.Duration `validate:"-"`

ServerCertificatePath string `validate:"required_with=TLSListenAddress"`
Expand Down Expand Up @@ -196,22 +195,6 @@ func loadChain(certFiles []string) (*issuance.Certificate, []byte, error) {
return certs[0], buf.Bytes(), nil
}

type errorWriter struct {
blog.Logger
}

func (ew errorWriter) Write(p []byte) (n int, err error) {
// log.Logger will append a newline to all messages before calling
// Write. Our log checksum checker doesn't like newlines, because
// syslog will strip them out so the calculated checksums will
// differ. So that we don't hit this corner case for every line
// logged from inside net/http.Server we strip the newline before
// we get to the checksum generator.
p = bytes.TrimRight(p, "\n")
ew.Logger.Err(fmt.Sprintf("net/http.Server: %s", string(p)))
return
}

func main() {
listenAddr := flag.String("addr", "", "HTTP listen address override")
tlsAddr := flag.String("tls-addr", "", "HTTPS listen address override")
Expand Down Expand Up @@ -391,30 +374,15 @@ func main() {
logger.Infof("Server running, listening on %s....", c.WFE.ListenAddress)
handler := wfe.Handler(stats, c.OpenTelemetryHTTPConfig.Options()...)

srv := http.Server{
ReadTimeout: 30 * time.Second,
WriteTimeout: 120 * time.Second,
IdleTimeout: 120 * time.Second,
Addr: c.WFE.ListenAddress,
ErrorLog: log.New(errorWriter{logger}, "", 0),
Handler: handler,
}

srv := web.NewServer(c.WFE.ListenAddress, handler, logger)
go func() {
err := srv.ListenAndServe()
if err != nil && err != http.ErrServerClosed {
cmd.FailOnError(err, "Running HTTP server")
}
}()

tlsSrv := http.Server{
ReadTimeout: 30 * time.Second,
WriteTimeout: 120 * time.Second,
IdleTimeout: 120 * time.Second,
Addr: c.WFE.TLSListenAddress,
ErrorLog: log.New(errorWriter{logger}, "", 0),
Handler: handler,
}
tlsSrv := web.NewServer(c.WFE.TLSListenAddress, handler, logger)
if tlsSrv.Addr != "" {
go func() {
logger.Infof("TLS server listening on %s", tlsSrv.Addr)
Expand Down
1 change: 1 addition & 0 deletions cmd/boulder/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
_ "github.com/letsencrypt/boulder/cmd/remoteva"
_ "github.com/letsencrypt/boulder/cmd/reversed-hostname-checker"
_ "github.com/letsencrypt/boulder/cmd/rocsp-tool"
_ "github.com/letsencrypt/boulder/cmd/sfe"
"github.com/letsencrypt/boulder/core"

"github.com/letsencrypt/boulder/cmd"
Expand Down
2 changes: 2 additions & 0 deletions cmd/boulder/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ func TestConfigValidation(t *testing.T) {
}
case "boulder-wfe2":
fileNames = []string{"wfe2.json"}
case "sfe":
fileNames = []string{"sfe.json"}
case "nonce-service":
fileNames = []string{
"nonce-a.json",
Expand Down
9 changes: 9 additions & 0 deletions cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -553,3 +553,12 @@ type DNSProvider struct {
// 1 1 8153 0a4d4d4d.addr.dc1.consul.
SRVLookup ServiceDomain `validate:"required"`
}

type UnpauseConfig struct {
// HMACKey is a shared symmetric secret used to sign/validate unpause JWTs.
// It should be 32 alphanumeric characters, e.g. the output of `openssl rand
// -hex 16` to satisfy the go-jose HS256 algorithm implementation. In a
// multi-DC deployment this value should be the same across all boulder-wfe
// and sfe instances.
HMACKey PasswordConfig `validate:"-"`
}
143 changes: 143 additions & 0 deletions cmd/sfe/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package notmain

import (
"context"
"flag"
"net/http"
"os"

"github.com/letsencrypt/boulder/cmd"
"github.com/letsencrypt/boulder/config"
"github.com/letsencrypt/boulder/features"
bgrpc "github.com/letsencrypt/boulder/grpc"
rapb "github.com/letsencrypt/boulder/ra/proto"
sapb "github.com/letsencrypt/boulder/sa/proto"
"github.com/letsencrypt/boulder/sfe"
"github.com/letsencrypt/boulder/web"
)

type Config struct {
SFE struct {
DebugAddr string `validate:"omitempty,hostname_port"`

// ListenAddress is the address:port on which to listen for incoming
// HTTP requests. Defaults to ":80".
ListenAddress string `validate:"omitempty,hostname_port"`

// Timeout is the per-request overall timeout. This should be slightly
// lower than the upstream's timeout when making requests to the SFE.
Timeout config.Duration `validate:"-"`

// ShutdownStopTimeout is the duration that the SFE will wait before
// shutting down any listening servers.
ShutdownStopTimeout config.Duration

TLS cmd.TLSConfig

RAService *cmd.GRPCClientConfig
SAService *cmd.GRPCClientConfig

Unpause cmd.UnpauseConfig

Features features.Config
}

Syslog cmd.SyslogConfig
OpenTelemetry cmd.OpenTelemetryConfig

// OpenTelemetryHTTPConfig configures tracing on incoming HTTP requests
OpenTelemetryHTTPConfig cmd.OpenTelemetryHTTPConfig
}

func main() {
listenAddr := flag.String("addr", "", "HTTP listen address override")
debugAddr := flag.String("debug-addr", "", "Debug server address override")
configFile := flag.String("config", "", "File path to the configuration file for this service")
flag.Parse()
if *configFile == "" {
flag.Usage()
os.Exit(1)
}

var c Config
err := cmd.ReadConfigFile(*configFile, &c)
cmd.FailOnError(err, "Reading JSON config file into config structure")

features.Set(c.SFE.Features)

if *listenAddr != "" {
c.SFE.ListenAddress = *listenAddr
}
if c.SFE.ListenAddress == "" {
cmd.Fail("HTTP listen address is not configured")
}
if *debugAddr != "" {
c.SFE.DebugAddr = *debugAddr
}

stats, logger, oTelShutdown := cmd.StatsAndLogging(c.Syslog, c.OpenTelemetry, c.SFE.DebugAddr)
logger.Info(cmd.VersionString())

clk := cmd.Clock()

unpauseHMACKey, err := c.SFE.Unpause.HMACKey.Pass()
cmd.FailOnError(err, "Failed to load unpauseHMACKey")

if len(unpauseHMACKey) != 32 {
cmd.Fail("Invalid unpauseHMACKey length, should be 32 alphanumeric characters")
}

// The jose.SigningKey key interface where this is used can be satisfied by
// a byte slice, not a string.
unpauseHMACKeyBytes := []byte(unpauseHMACKey)

tlsConfig, err := c.SFE.TLS.Load(stats)
cmd.FailOnError(err, "TLS config")

raConn, err := bgrpc.ClientSetup(c.SFE.RAService, tlsConfig, stats, clk)
cmd.FailOnError(err, "Failed to load credentials and create gRPC connection to RA")
rac := rapb.NewRegistrationAuthorityClient(raConn)

saConn, err := bgrpc.ClientSetup(c.SFE.SAService, tlsConfig, stats, clk)
cmd.FailOnError(err, "Failed to load credentials and create gRPC connection to SA")
sac := sapb.NewStorageAuthorityReadOnlyClient(saConn)

sfei, err := sfe.NewSelfServiceFrontEndImpl(
stats,
clk,
logger,
c.SFE.Timeout.Duration,
rac,
sac,
unpauseHMACKeyBytes,
)
cmd.FailOnError(err, "Unable to create SFE")

logger.Infof("Server running, listening on %s....", c.SFE.ListenAddress)
handler := sfei.Handler(stats, c.OpenTelemetryHTTPConfig.Options()...)

srv := web.NewServer(c.SFE.ListenAddress, handler, logger)
go func() {
err := srv.ListenAndServe()
if err != nil && err != http.ErrServerClosed {
cmd.FailOnError(err, "Running HTTP server")
}
}()

// When main is ready to exit (because it has received a shutdown signal),
// gracefully shutdown the servers. Calling these shutdown functions causes
// ListenAndServe() and ListenAndServeTLS() to immediately return, then waits
// for any lingering connection-handling goroutines to finish their work.
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), c.SFE.ShutdownStopTimeout.Duration)
defer cancel()
_ = srv.Shutdown(ctx)
oTelShutdown(ctx)
}()

cmd.WaitForSignal()
}

func init() {
cmd.RegisterCommand("sfe", main, &cmd.ConfigValidator{Config: &Config{}})
}
22 changes: 22 additions & 0 deletions sfe/pages/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<!doctype html>
<html dir="ltr" lang="en-US">
<header>
<title>Self-Service Frontend</title>
{{ template "meta" }}
</header>
<body>
<h1>No Action Required</h1>
<div>
<p>
There is no action for you to take. This page is intended for
Subscribers whose accounts have been temporarily restricted from
requesting new certificates for certain hostnames, following a
significant number of failed validation attempts without any recent
successes. If your account was paused, your <a
href="https://letsencrypt.org/docs/client-options/">ACME client</a>
would provide you with a URL to visit to unpause your account.
</p>
</div>
</body>

{{template "footer"}}
64 changes: 64 additions & 0 deletions sfe/pages/unpause-form.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<!doctype html>
<html dir="ltr" lang="en-US">
<header>
<title>Unpause - Self-Service Frontend</title>
{{ template "meta" }}
</header>
<body>
<div>
<h1>Action Required to Unpause Your ACME Account</h1>

<p>
You have been directed to this page because your Account ID {{ .AccountID }}
is temporarily restricted from requesting new certificates for certain
hostnames including, but potentially not limited to, the following:
<ul>
{{ range $domain := .PausedDomains }}<li>{{ $domain }}</li>{{ end }}
</ul>
</p>

<h2>Why Did This Happen?</h2>
<p>
This often happens when domain names expire, point to new hosts, or if
there are issues with the DNS configuration or web server settings.
These problems prevent your ACME client from successfully <a
href="https://letsencrypt.org/how-it-works/">validating control over the
domain</a>, which is necessary for issuing TLS certificates.
</p>

<h2>What Can You Do?</h2>
<p>
Please check the DNS configuration and web server settings for the
affected hostnames. Ensure they are properly set up to respond to ACME
challenges. This might involve updating DNS records, renewing domain
registrations, or adjusting web server configurations. If you use a
hosting provider or third-party service for domain management, you may
need to coordinate with them. If you believe you've fixed the underlying
issue, consider attempting issuance against our <a
href="https://letsencrypt.org/docs/staging-environment/">staging
environment</a> to verify your fix.
</p>

<h2>Ready to Unpause?</h2>
<p>
Once you have addressed these issues, click the button below to remove
the pause on your account. This action will allow you to resume
requesting certificates for all affected hostnames associated with your
account.
</p>
<p>
<strong>Note:</strong> If you face difficulties unpausing your account or
need more guidance, our <a
href="https://community.letsencrypt.org">community support forum</a> is
a great resource for troubleshooting and advice.
</p>
<div>
<form action="{{ .UnpauseFormRedirectionPath }}?jwt={{ .JWT }}" method="POST">
<button class="primary" id="submit">Please Unpause My Account</button>
</form>
</div>

</div>
</body>

{{template "footer"}}
20 changes: 20 additions & 0 deletions sfe/pages/unpause-invalid-request.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<!doctype html>
<html dir="ltr" lang="en-US">
<header>
<title>Unpause - Self-Service Frontend</title>
{{ template "meta" }}
</header>
<body>
<div>
<h1>Invalid Request To Unpause Account</h1>
<p>
Your unpause request was invalid meaning that we could not find all of
the data required in the URL. Please verify you copied the log line from
your client correctly. You may visit our <a
href="https://community.letsencrypt.org">community forum</a> and request
assistance if the problem persists.
</p>
</div>
</body>

{{template "footer"}}
Loading

0 comments on commit 30c6e59

Please sign in to comment.