Visitar URL original
Forward ACME HTTP in the cluster by stgraber · Pull Request #4180 · lxc/incus · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 111 additions & 11 deletions cmd/incusd/api_acme.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,20 @@ package main

import (
"context"
"errors"
"io"
"net"
"net/http"
"strings"
"time"

"github.com/lxc/incus/v7/internal/server/acme"
"github.com/lxc/incus/v7/internal/server/auth"
"github.com/lxc/incus/v7/internal/server/cluster"
"github.com/lxc/incus/v7/internal/server/db/operationtype"
"github.com/lxc/incus/v7/internal/server/operations"
"github.com/lxc/incus/v7/internal/server/response"
"github.com/lxc/incus/v7/internal/server/state"
"github.com/lxc/incus/v7/internal/server/task"
"github.com/lxc/incus/v7/internal/util"
"github.com/lxc/incus/v7/shared/api"
Expand All @@ -29,6 +33,108 @@ var acmeChallengeCmd = APIEndpoint{
Get: APIEndpointAction{Handler: acmeProvideChallenge, AllowUntrusted: true},
}

var internalACMEChallengeCmd = APIEndpoint{
Path: "acme/challenge",

Post: APIEndpointAction{Handler: internalACMEChallengePost, AccessHandler: allowPermission(auth.ObjectTypeServer, auth.EntitlementCanEdit)},
Delete: APIEndpointAction{Handler: internalACMEChallengeDelete, AccessHandler: allowPermission(auth.ObjectTypeServer, auth.EntitlementCanEdit)},
}

// internalACMEChallengePost starts the HTTP-01 challenge listener on a cluster member.
func internalACMEChallengePost(d *Daemon, r *http.Request) response.Response {
err := acmeChallengeListenerStart(d)
if err != nil {
return response.SmartError(err)
}

return response.EmptySyncResponse
}

// internalACMEChallengeDelete stops the HTTP-01 challenge listener on a cluster member.
func internalACMEChallengeDelete(d *Daemon, r *http.Request) response.Response {
acmeChallengeListenerStop(d, nil)

return response.EmptySyncResponse
}

// acmeChallengeListenerStart listens on the ACME HTTP port and forwards challenges to the leader.
func acmeChallengeListenerStart(d *Daemon) error {
s := d.State()

d.acmeListenerMu.Lock()
defer d.acmeListenerMu.Unlock()

if d.acmeListener != nil {
return nil
}

listener, err := net.Listen("tcp", s.GlobalConfig.ACMEHTTP())
if err != nil {
return err
}

server := &http.Server{
ReadHeaderTimeout: 3 * time.Second,
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || !strings.HasPrefix(r.URL.Path, "/.well-known/acme-challenge/") {
http.NotFound(w, r)
return
}

err := acmeForwardChallenge(s, r).Render(w)
if err != nil {
_ = response.SmartError(err).Render(w)
}
}),
}

go func() {
_ = server.Serve(listener)
}()

// Stop on our own in case the leader never asks us to.
time.AfterFunc(15*time.Minute, func() {
acmeChallengeListenerStop(d, server)
})

d.acmeListener = server

return nil
}

// acmeChallengeListenerStop stops the HTTP-01 challenge listener (or only the given one).
func acmeChallengeListenerStop(d *Daemon, server *http.Server) {
d.acmeListenerMu.Lock()
defer d.acmeListenerMu.Unlock()

if d.acmeListener == nil || (server != nil && d.acmeListener != server) {
return
}

_ = d.acmeListener.Close()
d.acmeListener = nil
}

// acmeForwardChallenge forwards a challenge request to the cluster leader.
func acmeForwardChallenge(s *state.State, r *http.Request) response.Response {
leader, err := s.Cluster.LeaderAddress()
if err != nil {
return response.SmartError(err)
}

// The leader answers through lego itself, never through this listener.
if s.LocalConfig.ClusterAddress() == leader {
return response.NotFound(errors.New("Challenge isn't being forwarded"))
}

client, err := cluster.Connect(leader, s.Endpoints.NetworkCert(), s.ServerCert(), r, true)
if err != nil {
return response.SmartError(err)
}

return response.ForwardedResponse(client, r)
}

func acmeProvideChallenge(d *Daemon, r *http.Request) response.Response {
s := d.State()

Expand All @@ -39,17 +145,8 @@ func acmeProvideChallenge(d *Daemon, r *http.Request) response.Response {
return response.SmartError(err)
}

// This gives me the correct value
clusterAddress := s.LocalConfig.ClusterAddress()

if clusterAddress != "" && clusterAddress != leader {
// Forward the request to the leader
client, err := cluster.Connect(leader, s.Endpoints.NetworkCert(), s.ServerCert(), r, true)
if err != nil {
return response.SmartError(err)
}

return response.ForwardedResponse(client, r)
if s.LocalConfig.ClusterAddress() != leader {
return acmeForwardChallenge(s, r)
}
}

Expand Down Expand Up @@ -132,6 +229,9 @@ func autoRenewCertificate(ctx context.Context, d *Daemon, force bool) error {
}

opRun := func(op *operations.Operation) error {
// Free the HTTP port for lego in case we were forwarding challenges before becoming leader.
acmeChallengeListenerStop(d, nil)

newCert, err := acme.UpdateCertificate(s, challengeType, s.ServerClustered, domain, email, caURL, force)
if err != nil {
return err
Expand Down
1 change: 1 addition & 0 deletions cmd/incusd/api_internal.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import (
)

var apiInternal = []APIEndpoint{
internalACMEChallengeCmd,
internalBGPStateCmd,
internalDebugPprofCmd,
internalClusterAcceptCmd,
Expand Down
4 changes: 4 additions & 0 deletions cmd/incusd/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,10 @@ type Daemon struct {
// Linstor client.
linstor *linstor.Client
linstorMu sync.Mutex

// ACME challenge listener used by non-leader cluster members.
acmeListener *http.Server
acmeListenerMu sync.Mutex
}

// DaemonConfig holds configuration values for Daemon.
Expand Down
4 changes: 3 additions & 1 deletion doc/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,9 @@ For `DNS-01`, the relevant {config:option}`server-acme:acme.provider` and {confi
values can be found directly in the [documentation of `lego`](https://go-acme.github.io/lego/dns/index.html),
the ACME client that Incus uses behind the scenes.

For `HTTP-01`, Incus will cause `lego` to temporarily listen on port `80` so the the HTTP challenge can go through.
For `HTTP-01`, Incus will cause `lego` to temporarily listen on port `80` so the HTTP challenge can go through.
In a cluster, the challenge runs on the leader while the other members temporarily listen on that same port and forward the challenge requests to the leader.
This allows for a certificate covering the names of all cluster members.
If your Incus server sits behind a reverse proxy, you'll need that reverse proxy to redirect HTTP traffic to HTTPS.

For ACME services that require External Account Binding (EAB), set both
Expand Down
28 changes: 28 additions & 0 deletions internal/server/acme/acme.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"fmt"
"os"

incus "github.com/lxc/incus/v7/client"
"github.com/lxc/incus/v7/internal/server/cluster"
"github.com/lxc/incus/v7/internal/server/state"
internalUtil "github.com/lxc/incus/v7/internal/util"
"github.com/lxc/incus/v7/shared/logger"
Expand Down Expand Up @@ -102,6 +104,32 @@ func UpdateCertificate(s *state.State, challengeType string, clustered bool, dom
}
}()

// Have the other cluster members forward HTTP-01 challenges for their own names.
if clustered && challengeType == "HTTP-01" {
notifier, err := cluster.NewNotifier(s, s.Endpoints.NetworkCert(), s.ServerCert(), cluster.NotifyAlive)
if err != nil {
return nil, err
}

notify := func(method string) {
err := notifier(func(client incus.InstanceServer) error {
_, _, err := client.RawQuery(method, "/internal/acme/challenge", nil, "")
if err != nil {
info, _ := client.GetConnectionInfo()
l.Warn("Failed to update the ACME challenge listener on a cluster member", logger.Ctx{"method": method, "member": info.URL, "err": err})
}

return nil
})
if err != nil {
l.Warn("Failed to notify the cluster members of the ACME challenge", logger.Ctx{"method": method, "err": err})
}
}

notify("POST")
defer notify("DELETE")
}

certBytes, keyBytes, err := incustls.RunACMEChallenge(context.TODO(), tmpDir, caURL, domain, email, challengeType, provider, port, proxy, eabKid, eabHmac, resolvers, environment)
if err != nil {
return nil, err
Expand Down