Files
authentik/internal/outpost/ak/api.go
gp-somni-labs 79473341d6 internal/outpost: serialize websocket writes to prevent panic (#21728)
The outpost API controller shares a single *websocket.Conn across
multiple goroutines: the event-handler loop, the 10s health ticker
(SendEventHello), the shutdown path (WriteMessage close), initEvent
writing the hello frame on (re)connect, and RAC session handlers that
also invoke SendEventHello. gorilla/websocket explicitly documents that
concurrent WriteMessage/WriteJSON calls are unsafe and will panic with
"concurrent write to websocket connection", which takes the outpost
(and embedded-outpost authentik-server) pod down.

Fix by adding a sync.Mutex on APIController guarding every write path
on eventConn (initEvent hello, Shutdown close message, SendEventHello).
Reads (ReadJSON in startEventHandler) are left unsynchronized as
gorilla permits a single concurrent reader alongside a writer.

Minimal, localized change: no API changes, no behavior changes, writes
are already infrequent so lock contention is negligible.

Refs #11090

Co-authored-by: curiosity <curiosity@somni.dev>
2026-04-23 02:33:10 +02:00

278 lines
7.1 KiB
Go

package ak
import (
"context"
"crypto/fips140"
"fmt"
"math/rand"
"net"
"net/http"
"net/url"
"os"
"os/signal"
"runtime"
"sync"
"syscall"
"time"
"github.com/avast/retry-go/v4"
"github.com/getsentry/sentry-go"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"github.com/prometheus/client_golang/prometheus"
log "github.com/sirupsen/logrus"
"goauthentik.io/internal/constants"
cryptobackend "goauthentik.io/internal/crypto/backend"
"goauthentik.io/internal/utils/web"
api "goauthentik.io/packages/client-go"
)
const ConfigLogLevel = "log_level"
// APIController main controller which connects to the authentik api via http and ws
type APIController struct {
akURL url.URL
Client *api.APIClient
Outpost api.Outpost
GlobalConfig *api.Config
Server Outpost
token string
logger *log.Entry
reloadOffset time.Duration
eventConn *websocket.Conn
eventConnMu sync.Mutex
lastWsReconnect time.Time
wsIsReconnecting bool
eventHandlers []EventHandler
refreshHandlers []func()
instanceUUID uuid.UUID
}
// NewAPIController initialise new API Controller instance from URL and API token
func NewAPIController(akURL url.URL, token string) *APIController {
rsp := sentry.StartSpan(context.Background(), "authentik.outposts.init")
log := log.WithField("logger", "authentik.outpost.ak-api-controller")
originalAkURL := akURL
var client http.Client
if akURL.Scheme == "unix" {
log.WithField("host", akURL.Host).WithField("path", akURL.Path).Debug("using unix socket")
socketPath := akURL.Host
client = http.Client{
Transport: web.NewUserAgentTransport(
constants.UserAgentOutpost(),
web.NewTracingTransport(
rsp.Context(),
&http.Transport{
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
return net.Dial("unix", socketPath)
},
},
),
),
}
akURL.Scheme = "http"
akURL.Host = "localhost"
} else {
client = http.Client{
Transport: web.NewUserAgentTransport(
constants.UserAgentOutpost(),
web.NewTracingTransport(
rsp.Context(),
GetTLSTransport(),
),
),
}
}
apiConfig := api.NewConfiguration()
apiConfig.Host = akURL.Host
apiConfig.Scheme = akURL.Scheme
apiConfig.HTTPClient = &client
apiConfig.Servers = api.ServerConfigurations{
{
URL: fmt.Sprintf("%sapi/v3", akURL.Path),
},
}
apiConfig.AddDefaultHeader("Authorization", fmt.Sprintf("Bearer %s", token))
// create the API client, with the transport
apiClient := api.NewAPIClient(apiConfig)
// Because we don't know the outpost UUID, we simply do a list and pick the first
// The service account this token belongs to should only have access to a single outpost
outposts, _ := retry.DoWithData[*api.PaginatedOutpostList](
func() (*api.PaginatedOutpostList, error) {
outposts, _, err := apiClient.OutpostsAPI.OutpostsInstancesList(context.Background()).Execute()
return outposts, err
},
retry.Attempts(0),
retry.Delay(time.Second*3),
retry.OnRetry(func(attempt uint, err error) {
log.WithError(err).Error("Failed to fetch outpost configuration, retrying in 3 seconds")
}),
)
if len(outposts.Results) < 1 {
log.Panic("No outposts found with given token, ensure the given token corresponds to an authentik Outpost")
}
outpost := outposts.Results[0]
log.WithField("name", outpost.Name).Debug("Fetched outpost configuration")
akConfig, _, err := apiClient.RootAPI.RootConfigRetrieve(context.Background()).Execute()
if err != nil {
log.WithError(err).Error("Failed to fetch global configuration")
return nil
}
log.Debug("Fetched global configuration")
// doGlobalSetup is called by the OnRefresh handler, which ticks on start
// doGlobalSetup(outpost, akConfig)
ac := &APIController{
akURL: originalAkURL,
Client: apiClient,
GlobalConfig: akConfig,
token: token,
logger: log,
reloadOffset: time.Duration(rand.Intn(10)) * time.Second,
instanceUUID: uuid.New(),
Outpost: outpost,
eventHandlers: []EventHandler{},
refreshHandlers: make([]func(), 0),
}
ac.logger.WithField("embedded", ac.IsEmbedded()).Info("Outpost mode")
ac.logger.WithField("offset", ac.reloadOffset.String()).Debug("HA Reload offset")
err = ac.initEvent(outpost.Pk, 0)
if err != nil {
go ac.recentEvents()
}
ac.configureRefreshSignal()
return ac
}
func (a *APIController) Log() *log.Entry {
return a.logger
}
func (a *APIController) IsEmbedded() bool {
if m := a.Outpost.Managed.Get(); m != nil {
return *m == "goauthentik.io/outposts/embedded"
}
return false
}
// Start Starts all handlers, non-blocking
func (a *APIController) Start() error {
err := a.Server.Refresh()
if err != nil {
return err
}
err = a.StartBackgroundTasks()
if err != nil {
return err
}
go func() {
err := a.Server.Start()
if err != nil {
panic(err)
}
}()
return nil
}
func (a *APIController) configureRefreshSignal() {
s := make(chan os.Signal, 1)
go func() {
for {
<-s
err := a.OnRefresh()
if err != nil {
a.logger.WithError(err).Warning("failed to refresh")
}
}
}()
signal.Notify(s, syscall.SIGUSR1)
a.logger.Debug("Enabled USR1 hook to reload")
}
func (a *APIController) AddRefreshHandler(handler func()) {
a.refreshHandlers = append(a.refreshHandlers, handler)
}
func (a *APIController) Token() string {
return a.token
}
func (a *APIController) OnRefresh() error {
// Because we don't know the outpost UUID, we simply do a list and pick the first
// The service account this token belongs to should only have access to a single outpost
outposts, _, err := a.Client.OutpostsAPI.OutpostsInstancesList(context.Background()).Execute()
if err != nil {
log.WithError(err).Error("Failed to fetch outpost configuration")
return err
}
a.Outpost = outposts.Results[0]
a.logger.WithField("name", a.Outpost.Name).Debug("Fetched outpost configuration")
doGlobalSetup(a.Outpost, a.GlobalConfig)
err = a.Server.Refresh()
for _, handler := range a.refreshHandlers {
handler()
}
return err
}
func (a *APIController) getEventPingArgs() map[string]any {
args := map[string]any{
"version": constants.VERSION(),
"buildHash": constants.BUILD(""),
"uuid": a.instanceUUID.String(),
"golangVersion": runtime.Version(),
"opensslEnabled": cryptobackend.OpensslEnabled,
"opensslVersion": cryptobackend.OpensslVersion(),
"fipsEnabled": fips140.Enabled(),
}
hostname, err := os.Hostname()
if err == nil {
args["hostname"] = hostname
}
return args
}
func (a *APIController) StartBackgroundTasks() error {
OutpostInfo.With(prometheus.Labels{
"outpost_name": a.Outpost.Name,
"outpost_type": a.Server.Type(),
"uuid": a.instanceUUID.String(),
"version": constants.VERSION(),
"build": constants.BUILD(""),
}).Set(1)
go func() {
a.logger.Debug("Starting Event Handler...")
a.startEventHandler()
}()
go func() {
a.logger.Debug("Starting Event health notifier...")
a.startEventHealth()
}()
go func() {
a.logger.Debug("Starting Interval updater...")
a.startIntervalUpdater()
}()
go func() {
a.logger.Debug("Starting periodical timer...")
a.startPeriodicalTasks()
}()
return nil
}