mirror of
https://github.com/h44z/wg-portal.git
synced 2026-09-03 21:36:44 +00:00
updateInterfaceLdapFilters saved the matched users with SaveInterface, which creates the interface when it is missing. On first start that panics. The sync runs immediately (main.go:85) and the importer later (main.go:116), so the sync creates a stub row for every interface_filter key, and the importer, which snapshotted the interface list before its device round-trips, then fails with "interface already exists". The window is GetInterfaces plus GetPeers, so a directory on localhost loses the race and a slower one hides it. The stub rows are wrong anyway. They have no backend, so a typo in an interface_filter key quietly created an interface attached to no controller. Look the interface up and skip with a warning when it is absent. The filter is applied on the next sync once the importer has created it. A lookup error that is not ErrNotFound also skips. Signed-off-by: clark-ja <37738506+clark-ja@users.noreply.github.com>
371 lines
12 KiB
Go
371 lines
12 KiB
Go
package users
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/go-ldap/ldap/v3"
|
|
|
|
"github.com/h44z/wg-portal/internal"
|
|
"github.com/h44z/wg-portal/internal/app"
|
|
"github.com/h44z/wg-portal/internal/config"
|
|
"github.com/h44z/wg-portal/internal/domain"
|
|
)
|
|
|
|
func (m Manager) runLdapSynchronizationService(ctx context.Context) {
|
|
ctx = domain.SetUserInfo(ctx, domain.LdapSyncContextUserInfo()) // switch to service context for LDAP sync
|
|
|
|
for _, ldapCfg := range m.cfg.Auth.Ldap { // LDAP Auth providers
|
|
go func(cfg config.LdapProvider) {
|
|
syncInterval := cfg.SyncInterval
|
|
if syncInterval == 0 {
|
|
slog.Debug("sync disabled for LDAP server", "provider", cfg.ProviderName)
|
|
return
|
|
}
|
|
|
|
// perform initial sync
|
|
err := m.synchronizeLdapUsers(ctx, &cfg)
|
|
if err != nil {
|
|
slog.Error("failed to synchronize LDAP users", "provider", cfg.ProviderName, "error", err)
|
|
} else {
|
|
slog.Debug("initial LDAP user sync completed", "provider", cfg.ProviderName)
|
|
}
|
|
|
|
// start periodic sync
|
|
running := true
|
|
for running {
|
|
select {
|
|
case <-ctx.Done():
|
|
running = false
|
|
continue
|
|
case <-time.After(syncInterval):
|
|
// select blocks until one of the cases evaluate to true
|
|
}
|
|
|
|
err := m.synchronizeLdapUsers(ctx, &cfg)
|
|
if err != nil {
|
|
slog.Error("failed to synchronize LDAP users", "provider", cfg.ProviderName, "error", err)
|
|
}
|
|
}
|
|
}(ldapCfg)
|
|
}
|
|
}
|
|
|
|
func (m Manager) synchronizeLdapUsers(ctx context.Context, provider *config.LdapProvider) error {
|
|
slog.Debug("starting to synchronize users", "provider", provider.ProviderName)
|
|
|
|
dn, err := ldap.ParseDN(provider.AdminGroupDN)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse admin group DN: %w", err)
|
|
}
|
|
provider.ParsedAdminGroupDN = dn
|
|
|
|
conn, err := internal.LdapConnect(provider)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to setup LDAP connection: %w", err)
|
|
}
|
|
defer internal.LdapDisconnect(conn)
|
|
|
|
rawUsers, err := internal.LdapFindAllUsers(conn, provider.BaseDN, provider.SyncFilter, &provider.FieldMap)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
slog.Debug("fetched raw ldap users", "count", len(rawUsers), "provider", provider.ProviderName)
|
|
|
|
// Update existing LDAP users
|
|
err = m.updateLdapUsers(ctx, provider, rawUsers, &provider.FieldMap, provider.ParsedAdminGroupDN)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Disable missing LDAP users
|
|
if provider.DisableMissing {
|
|
err = m.disableMissingLdapUsers(ctx, provider.ProviderName, rawUsers, &provider.FieldMap)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Update interface allowed users based on LDAP filters
|
|
err = m.updateInterfaceLdapFilters(ctx, conn, provider)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (m Manager) updateLdapUsers(
|
|
ctx context.Context,
|
|
provider *config.LdapProvider,
|
|
rawUsers []internal.RawLdapUser,
|
|
fields *config.LdapFields,
|
|
adminGroupDN *ldap.DN,
|
|
) error {
|
|
for _, rawUser := range rawUsers {
|
|
user, err := convertRawLdapUser(provider.ProviderName, rawUser, fields, adminGroupDN)
|
|
if err != nil && !errors.Is(err, domain.ErrNotFound) {
|
|
if errors.Is(err, domain.ErrInvalidData) {
|
|
slog.Warn("skipping LDAP user with invalid data after sanitization",
|
|
"raw-dn", rawUser["dn"], "error", err)
|
|
continue
|
|
}
|
|
return fmt.Errorf("failed to convert LDAP data for %v: %w", rawUser["dn"], err)
|
|
}
|
|
|
|
if provider.SyncLogUserInfo {
|
|
slog.Debug("ldap user data",
|
|
"raw-user", rawUser, "user", user.Identifier,
|
|
"is-admin", user.IsAdmin, "provider", provider.ProviderName)
|
|
}
|
|
|
|
existingUser, err := m.users.GetUser(ctx, user.Identifier)
|
|
if err != nil && !errors.Is(err, domain.ErrNotFound) {
|
|
return fmt.Errorf("find error for user id %s: %w", user.Identifier, err)
|
|
}
|
|
|
|
tctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
tctx = domain.SetUserInfo(tctx, domain.SystemAdminContextUserInfo())
|
|
|
|
if existingUser == nil {
|
|
// create new user
|
|
slog.Debug("creating new user from provider", "user", user.Identifier, "provider", provider.ProviderName)
|
|
|
|
_, err := m.create(tctx, user)
|
|
if err != nil {
|
|
cancel()
|
|
return fmt.Errorf("create error for user id %s: %w", user.Identifier, err)
|
|
}
|
|
} else {
|
|
// update existing user
|
|
if provider.AutoReEnable && existingUser.DisabledReason == domain.DisabledReasonLdapMissing {
|
|
user.Disabled = nil
|
|
user.DisabledReason = ""
|
|
} else {
|
|
user.Disabled = existingUser.Disabled
|
|
user.DisabledReason = existingUser.DisabledReason
|
|
}
|
|
|
|
if existingUser.PersistLocalChanges {
|
|
cancel()
|
|
continue // skip synchronization for this user
|
|
}
|
|
|
|
if userChangedInLdap(existingUser, user) {
|
|
syncedUser, err := m.users.GetUser(ctx, user.Identifier)
|
|
if err != nil && !errors.Is(err, domain.ErrNotFound) {
|
|
cancel()
|
|
return fmt.Errorf("find error for user id %s: %w", user.Identifier, err)
|
|
}
|
|
syncedUser.UpdatedAt = time.Now()
|
|
syncedUser.UpdatedBy = domain.CtxSystemLdapSyncer
|
|
syncedUser.MergeAuthSources(user.Authentications...)
|
|
syncedUser.Email = user.Email
|
|
syncedUser.Firstname = user.Firstname
|
|
syncedUser.Lastname = user.Lastname
|
|
syncedUser.Phone = user.Phone
|
|
syncedUser.Department = user.Department
|
|
syncedUser.IsAdmin = user.IsAdmin
|
|
syncedUser.Disabled = user.Disabled
|
|
syncedUser.DisabledReason = user.DisabledReason
|
|
|
|
_, err = m.update(tctx, existingUser, syncedUser, false)
|
|
if err != nil {
|
|
cancel()
|
|
return fmt.Errorf("update error for user id %s: %w", user.Identifier, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
cancel()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (m Manager) disableMissingLdapUsers(
|
|
ctx context.Context,
|
|
providerName string,
|
|
rawUsers []internal.RawLdapUser,
|
|
fields *config.LdapFields,
|
|
) error {
|
|
// Collect the identifiers the directory actually returned. A hard LDAP
|
|
// failure is already handled by the caller, but a search that *succeeds*
|
|
// and yields nothing usable is indistinguishable here from "every user was
|
|
// removed from the directory". In practice that means a wrong base DN, a
|
|
// sync_filter that no longer matches, a replica that is up but not yet
|
|
// populated, a field map pointing at an attribute the server does not
|
|
// return, or a bind account that has lost read access to the user subtree.
|
|
// LDAP offers nothing to tell those apart: a search that matches nobody and
|
|
// one the client is not allowed to answer both come back as success with
|
|
// zero entries, and a directory server may reply that way for a base DN
|
|
// that does not exist rather than disclose it to an unprivileged client.
|
|
//
|
|
// Acting on it disables every LDAP-sourced user at once, and through
|
|
// TopicUserDisabled that removes each of their peers from the WireGuard
|
|
// device rather than only flagging them. The search succeeded, so no error
|
|
// is logged, and every message on this path is Debug: at the default log
|
|
// level the whole thing is silent. It also repeats every sync interval.
|
|
// Refuse instead. The cost is that a directory intentionally emptied of all
|
|
// users disables nobody; leaving a single account in scope restores the
|
|
// normal behaviour for everyone else.
|
|
ldapUserIds := make(map[domain.UserIdentifier]struct{}, len(rawUsers))
|
|
for _, rawUser := range rawUsers {
|
|
if userId := ldapUserIdentifier(rawUser, fields.UserIdentifier); userId != "" {
|
|
ldapUserIds[userId] = struct{}{}
|
|
}
|
|
}
|
|
if len(ldapUserIds) == 0 {
|
|
slog.Error("refusing to disable missing LDAP users: directory returned no usable user identifiers",
|
|
"provider", providerName,
|
|
"raw-entries", len(rawUsers),
|
|
"identifier-field", fields.UserIdentifier,
|
|
"hint", "check base_dn, sync_filter and the bind account's read permissions; "+
|
|
"if the directory is intentionally empty, keep one account matching sync_filter")
|
|
return nil
|
|
}
|
|
|
|
allUsers, err := m.users.GetAllUsers(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, user := range allUsers {
|
|
userHasAuthSource := false
|
|
for _, auth := range user.Authentications {
|
|
if auth.Source == domain.UserSourceLdap && auth.ProviderName == providerName {
|
|
userHasAuthSource = true
|
|
break
|
|
}
|
|
}
|
|
if !userHasAuthSource {
|
|
continue // ignore non ldap users
|
|
}
|
|
if user.IsDisabled() {
|
|
continue // ignore deactivated
|
|
}
|
|
if user.PersistLocalChanges {
|
|
continue // skip sync for this user
|
|
}
|
|
|
|
if _, existsInLDAP := ldapUserIds[user.Identifier]; existsInLDAP {
|
|
continue
|
|
}
|
|
|
|
// Warn, not Debug: this removes the user's peers from the device, and at
|
|
// the default log level a Debug line would make a mass disable silent.
|
|
slog.Warn("user is missing in ldap provider, disabling",
|
|
"user", user.Identifier, "provider", providerName)
|
|
|
|
now := time.Now()
|
|
user.Disabled = &now
|
|
user.DisabledReason = domain.DisabledReasonLdapMissing
|
|
|
|
err := m.users.SaveUser(ctx, user.Identifier, func(u *domain.User) (*domain.User, error) {
|
|
u.Disabled = user.Disabled
|
|
u.DisabledReason = user.DisabledReason
|
|
return u, nil
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("disable error for user id %s: %w", user.Identifier, err)
|
|
}
|
|
|
|
m.bus.Publish(app.TopicUserDisabled, user)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (m Manager) updateInterfaceLdapFilters(
|
|
ctx context.Context,
|
|
conn *ldap.Conn,
|
|
provider *config.LdapProvider,
|
|
) error {
|
|
if len(provider.InterfaceFilter) == 0 {
|
|
return nil // nothing to do if no interfaces are configured for this provider
|
|
}
|
|
|
|
for ifaceName, groupFilter := range provider.InterfaceFilter {
|
|
ifaceId := domain.InterfaceIdentifier(ifaceName)
|
|
|
|
// Combined filter: user must match the provider's base SyncFilter AND the interface's LdapGroupFilter
|
|
combinedFilter := fmt.Sprintf("(&(%s)(%s))", provider.SyncFilter, groupFilter)
|
|
|
|
rawUsers, err := internal.LdapFindAllUsers(conn, provider.BaseDN, combinedFilter, &provider.FieldMap)
|
|
if err != nil {
|
|
slog.Error("failed to find users for interface filter",
|
|
"interface", ifaceId,
|
|
"provider", provider.ProviderName,
|
|
"error", err)
|
|
continue
|
|
}
|
|
|
|
matchedUserIds := make([]domain.UserIdentifier, 0, len(rawUsers))
|
|
for _, rawUser := range rawUsers {
|
|
userId := ldapUserIdentifier(rawUser, provider.FieldMap.UserIdentifier)
|
|
if userId != "" {
|
|
matchedUserIds = append(matchedUserIds, userId)
|
|
}
|
|
}
|
|
|
|
m.applyInterfaceLdapFilter(ctx, ifaceId, provider.ProviderName, matchedUserIds)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// applyInterfaceLdapFilter stores the users an LDAP filter matched onto an
|
|
// existing interface.
|
|
//
|
|
// It deliberately does not create the interface. SaveInterface would, which is
|
|
// wrong twice over: an interface_filter entry is a statement about who may use
|
|
// an interface, not a reason to bring one into existence, and the row it
|
|
// creates has no backend. It also races the startup importer, which snapshots
|
|
// the interface list before its device round-trips and then fails with
|
|
// "interface already exists" once it finds the row.
|
|
func (m Manager) applyInterfaceLdapFilter(
|
|
ctx context.Context,
|
|
ifaceId domain.InterfaceIdentifier,
|
|
providerName string,
|
|
matchedUserIds []domain.UserIdentifier,
|
|
) {
|
|
if _, err := m.interfaces.GetInterface(ctx, ifaceId); err != nil {
|
|
if errors.Is(err, domain.ErrNotFound) {
|
|
slog.Warn("skipping interface filter for unknown interface",
|
|
"interface", ifaceId, "provider", providerName)
|
|
} else {
|
|
slog.Error("failed to look up interface for ldap filter",
|
|
"interface", ifaceId, "provider", providerName, "error", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
err := m.interfaces.SaveInterface(ctx, ifaceId, func(i *domain.Interface) (*domain.Interface, error) {
|
|
if i.LdapAllowedUsers == nil {
|
|
i.LdapAllowedUsers = make(map[string][]domain.UserIdentifier)
|
|
}
|
|
i.LdapAllowedUsers[providerName] = matchedUserIds
|
|
return i, nil
|
|
})
|
|
if err != nil {
|
|
slog.Error("failed to save interface ldap allowed users",
|
|
"interface", ifaceId, "provider", providerName, "error", err)
|
|
return
|
|
}
|
|
|
|
slog.Debug("updated interface ldap allowed users",
|
|
"interface", ifaceId, "provider", providerName, "matched_count", len(matchedUserIds))
|
|
}
|
|
|
|
func ldapUserIdentifier(rawUser map[string]any, field string) domain.UserIdentifier {
|
|
identifier := internal.MapDefaultString(rawUser, field, "")
|
|
identifier = domain.SanitizeIdentifier(identifier, 256)
|
|
if identifier == "" {
|
|
return ""
|
|
}
|
|
return domain.UserIdentifier(identifier)
|
|
}
|