Compare commits
34 Commits
test/proxy
...
combined-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a5efb8d19 | ||
|
|
86b148dcd8 | ||
|
|
e4e6294ea1 | ||
|
|
527bdcfa1d | ||
|
|
7237c9ac1b | ||
|
|
30db103331 | ||
|
|
287748932b | ||
|
|
a4945fb644 | ||
|
|
f60d86862c | ||
|
|
c4836c03e3 | ||
|
|
0d8ffd177c | ||
|
|
90d1a7bc0b | ||
|
|
b716049e24 | ||
|
|
0188034d43 | ||
|
|
dbe83c57c8 | ||
|
|
f80fe506d5 | ||
|
|
967c6f3cd3 | ||
|
|
1775067a15 | ||
|
|
7ff7690eb4 | ||
|
|
51ddd69a1b | ||
|
|
e50e124e70 | ||
|
|
c545689448 | ||
|
|
8f389fef19 | ||
|
|
d3d6a327e0 | ||
|
|
b5489d4986 | ||
|
|
7a23c57cf8 | ||
|
|
11f891220e | ||
|
|
5585adce18 | ||
|
|
f884299823 | ||
|
|
15aa6bae1b | ||
|
|
11eb725ac8 | ||
|
|
a1d8c275ca | ||
|
|
9769397347 | ||
|
|
7a10fa61c7 |
52
.github/workflows/release.yml
vendored
52
.github/workflows/release.yml
vendored
@@ -10,7 +10,7 @@ on:
|
||||
|
||||
env:
|
||||
SIGN_PIPE_VER: "v0.1.1"
|
||||
GORELEASER_VER: "v2.3.2"
|
||||
GORELEASER_VER: "v2.14.3"
|
||||
PRODUCT_NAME: "NetBird"
|
||||
COPYRIGHT: "NetBird GmbH"
|
||||
|
||||
@@ -169,6 +169,13 @@ jobs:
|
||||
- name: Install OS build dependencies
|
||||
run: sudo apt update && sudo apt install -y -q gcc-arm-linux-gnueabihf gcc-aarch64-linux-gnu
|
||||
|
||||
- name: Decode GPG signing key
|
||||
env:
|
||||
GPG_RPM_PRIVATE_KEY: ${{ secrets.GPG_RPM_PRIVATE_KEY }}
|
||||
run: |
|
||||
echo "$GPG_RPM_PRIVATE_KEY" | base64 -d > /tmp/gpg-rpm-signing-key.asc
|
||||
echo "GPG_RPM_KEY_FILE=/tmp/gpg-rpm-signing-key.asc" >> $GITHUB_ENV
|
||||
|
||||
- name: Install goversioninfo
|
||||
run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e
|
||||
- name: Generate windows syso amd64
|
||||
@@ -186,6 +193,24 @@ jobs:
|
||||
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
|
||||
UPLOAD_DEBIAN_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
|
||||
UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
|
||||
GPG_RPM_KEY_FILE: ${{ env.GPG_RPM_KEY_FILE }}
|
||||
NFPM_NETBIRD_RPM_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }}
|
||||
- name: Verify RPM signatures
|
||||
run: |
|
||||
docker run --rm -v $(pwd)/dist:/dist fedora:41 bash -c '
|
||||
dnf install -y -q rpm-sign curl >/dev/null 2>&1
|
||||
curl -sSL https://pkgs.netbird.io/yum/repodata/repomd.xml.key -o /tmp/rpm-pub.key
|
||||
rpm --import /tmp/rpm-pub.key
|
||||
echo "=== Verifying RPM signatures ==="
|
||||
for rpm_file in /dist/*amd64*.rpm; do
|
||||
[ -f "$rpm_file" ] || continue
|
||||
echo "--- $(basename $rpm_file) ---"
|
||||
rpm -K "$rpm_file"
|
||||
done
|
||||
'
|
||||
- name: Clean up GPG key
|
||||
if: always()
|
||||
run: rm -f /tmp/gpg-rpm-signing-key.asc
|
||||
- name: Tag and push PR images (amd64 only)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
run: |
|
||||
@@ -265,6 +290,13 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: sudo apt update && sudo apt install -y -q libappindicator3-dev gir1.2-appindicator3-0.1 libxxf86vm-dev gcc-mingw-w64-x86-64
|
||||
|
||||
- name: Decode GPG signing key
|
||||
env:
|
||||
GPG_RPM_PRIVATE_KEY: ${{ secrets.GPG_RPM_PRIVATE_KEY }}
|
||||
run: |
|
||||
echo "$GPG_RPM_PRIVATE_KEY" | base64 -d > /tmp/gpg-rpm-signing-key.asc
|
||||
echo "GPG_RPM_KEY_FILE=/tmp/gpg-rpm-signing-key.asc" >> $GITHUB_ENV
|
||||
|
||||
- name: Install LLVM-MinGW for ARM64 cross-compilation
|
||||
run: |
|
||||
cd /tmp
|
||||
@@ -289,6 +321,24 @@ jobs:
|
||||
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
|
||||
UPLOAD_DEBIAN_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
|
||||
UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
|
||||
GPG_RPM_KEY_FILE: ${{ env.GPG_RPM_KEY_FILE }}
|
||||
NFPM_NETBIRD_UI_RPM_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }}
|
||||
- name: Verify RPM signatures
|
||||
run: |
|
||||
docker run --rm -v $(pwd)/dist:/dist fedora:41 bash -c '
|
||||
dnf install -y -q rpm-sign curl >/dev/null 2>&1
|
||||
curl -sSL https://pkgs.netbird.io/yum/repodata/repomd.xml.key -o /tmp/rpm-pub.key
|
||||
rpm --import /tmp/rpm-pub.key
|
||||
echo "=== Verifying RPM signatures ==="
|
||||
for rpm_file in /dist/*.rpm; do
|
||||
[ -f "$rpm_file" ] || continue
|
||||
echo "--- $(basename $rpm_file) ---"
|
||||
rpm -K "$rpm_file"
|
||||
done
|
||||
'
|
||||
- name: Clean up GPG key
|
||||
if: always()
|
||||
run: rm -f /tmp/gpg-rpm-signing-key.asc
|
||||
- name: upload non tags for debug purposes
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
||||
@@ -154,6 +154,26 @@ builds:
|
||||
- -s -w -X main.Version={{.Version}} -X main.Commit={{.Commit}} -X main.BuildDate={{.CommitDate}}
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
|
||||
- id: netbird-idp-migrate
|
||||
dir: tools/idp-migrate
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- >-
|
||||
{{- if eq .Runtime.Goos "linux" }}
|
||||
{{- if eq .Arch "arm64"}}CC=aarch64-linux-gnu-gcc{{- end }}
|
||||
{{- if eq .Arch "arm"}}CC=arm-linux-gnueabihf-gcc{{- end }}
|
||||
{{- end }}
|
||||
binary: netbird-idp-migrate
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- arm
|
||||
ldflags:
|
||||
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
|
||||
universal_binaries:
|
||||
- id: netbird
|
||||
|
||||
@@ -166,18 +186,21 @@ archives:
|
||||
- netbird-wasm
|
||||
name_template: "{{ .ProjectName }}_{{ .Version }}"
|
||||
format: binary
|
||||
- id: netbird-idp-migrate
|
||||
builds:
|
||||
- netbird-idp-migrate
|
||||
name_template: "netbird-idp-migrate_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
|
||||
nfpms:
|
||||
- maintainer: Netbird <dev@netbird.io>
|
||||
description: Netbird client.
|
||||
homepage: https://netbird.io/
|
||||
id: netbird-deb
|
||||
id: netbird_deb
|
||||
bindir: /usr/bin
|
||||
builds:
|
||||
- netbird
|
||||
formats:
|
||||
- deb
|
||||
|
||||
scripts:
|
||||
postinstall: "release_files/post_install.sh"
|
||||
preremove: "release_files/pre_remove.sh"
|
||||
@@ -185,16 +208,18 @@ nfpms:
|
||||
- maintainer: Netbird <dev@netbird.io>
|
||||
description: Netbird client.
|
||||
homepage: https://netbird.io/
|
||||
id: netbird-rpm
|
||||
id: netbird_rpm
|
||||
bindir: /usr/bin
|
||||
builds:
|
||||
- netbird
|
||||
formats:
|
||||
- rpm
|
||||
|
||||
scripts:
|
||||
postinstall: "release_files/post_install.sh"
|
||||
preremove: "release_files/pre_remove.sh"
|
||||
rpm:
|
||||
signature:
|
||||
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
|
||||
dockers:
|
||||
- image_templates:
|
||||
- netbirdio/netbird:{{ .Version }}-amd64
|
||||
@@ -876,7 +901,7 @@ brews:
|
||||
uploads:
|
||||
- name: debian
|
||||
ids:
|
||||
- netbird-deb
|
||||
- netbird_deb
|
||||
mode: archive
|
||||
target: https://pkgs.wiretrustee.com/debian/pool/{{ .ArtifactName }};deb.distribution=stable;deb.component=main;deb.architecture={{ if .Arm }}armhf{{ else }}{{ .Arch }}{{ end }};deb.package=
|
||||
username: dev@wiretrustee.com
|
||||
@@ -884,7 +909,7 @@ uploads:
|
||||
|
||||
- name: yum
|
||||
ids:
|
||||
- netbird-rpm
|
||||
- netbird_rpm
|
||||
mode: archive
|
||||
target: https://pkgs.wiretrustee.com/yum/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }}
|
||||
username: dev@wiretrustee.com
|
||||
|
||||
@@ -61,7 +61,7 @@ nfpms:
|
||||
- maintainer: Netbird <dev@netbird.io>
|
||||
description: Netbird client UI.
|
||||
homepage: https://netbird.io/
|
||||
id: netbird-ui-deb
|
||||
id: netbird_ui_deb
|
||||
package_name: netbird-ui
|
||||
builds:
|
||||
- netbird-ui
|
||||
@@ -80,7 +80,7 @@ nfpms:
|
||||
- maintainer: Netbird <dev@netbird.io>
|
||||
description: Netbird client UI.
|
||||
homepage: https://netbird.io/
|
||||
id: netbird-ui-rpm
|
||||
id: netbird_ui_rpm
|
||||
package_name: netbird-ui
|
||||
builds:
|
||||
- netbird-ui
|
||||
@@ -95,11 +95,14 @@ nfpms:
|
||||
dst: /usr/share/pixmaps/netbird.png
|
||||
dependencies:
|
||||
- netbird
|
||||
rpm:
|
||||
signature:
|
||||
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
|
||||
|
||||
uploads:
|
||||
- name: debian
|
||||
ids:
|
||||
- netbird-ui-deb
|
||||
- netbird_ui_deb
|
||||
mode: archive
|
||||
target: https://pkgs.wiretrustee.com/debian/pool/{{ .ArtifactName }};deb.distribution=stable;deb.component=main;deb.architecture={{ if .Arm }}armhf{{ else }}{{ .Arch }}{{ end }};deb.package=
|
||||
username: dev@wiretrustee.com
|
||||
@@ -107,7 +110,7 @@ uploads:
|
||||
|
||||
- name: yum
|
||||
ids:
|
||||
- netbird-ui-rpm
|
||||
- netbird_ui_rpm
|
||||
mode: archive
|
||||
target: https://pkgs.wiretrustee.com/yum/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }}
|
||||
username: dev@wiretrustee.com
|
||||
|
||||
@@ -77,7 +77,7 @@ func (d *Resolver) ID() types.HandlerID {
|
||||
return "local-resolver"
|
||||
}
|
||||
|
||||
func (d *Resolver) ProbeAvailability() {}
|
||||
func (d *Resolver) ProbeAvailability(context.Context) {}
|
||||
|
||||
// ServeDNS handles a DNS request
|
||||
func (d *Resolver) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
|
||||
|
||||
@@ -104,12 +104,16 @@ type DefaultServer struct {
|
||||
|
||||
statusRecorder *peer.Status
|
||||
stateManager *statemanager.Manager
|
||||
|
||||
probeMu sync.Mutex
|
||||
probeCancel context.CancelFunc
|
||||
probeWg sync.WaitGroup
|
||||
}
|
||||
|
||||
type handlerWithStop interface {
|
||||
dns.Handler
|
||||
Stop()
|
||||
ProbeAvailability()
|
||||
ProbeAvailability(context.Context)
|
||||
ID() types.HandlerID
|
||||
}
|
||||
|
||||
@@ -362,7 +366,13 @@ func (s *DefaultServer) DnsIP() netip.Addr {
|
||||
|
||||
// Stop stops the server
|
||||
func (s *DefaultServer) Stop() {
|
||||
s.probeMu.Lock()
|
||||
if s.probeCancel != nil {
|
||||
s.probeCancel()
|
||||
}
|
||||
s.ctxCancel()
|
||||
s.probeMu.Unlock()
|
||||
s.probeWg.Wait()
|
||||
s.shutdownWg.Wait()
|
||||
|
||||
s.mux.Lock()
|
||||
@@ -479,7 +489,8 @@ func (s *DefaultServer) SearchDomains() []string {
|
||||
}
|
||||
|
||||
// ProbeAvailability tests each upstream group's servers for availability
|
||||
// and deactivates the group if no server responds
|
||||
// and deactivates the group if no server responds.
|
||||
// If a previous probe is still running, it will be cancelled before starting a new one.
|
||||
func (s *DefaultServer) ProbeAvailability() {
|
||||
if val := os.Getenv(envSkipDNSProbe); val != "" {
|
||||
skipProbe, err := strconv.ParseBool(val)
|
||||
@@ -492,15 +503,52 @@ func (s *DefaultServer) ProbeAvailability() {
|
||||
}
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for _, mux := range s.dnsMuxMap {
|
||||
wg.Add(1)
|
||||
go func(mux handlerWithStop) {
|
||||
defer wg.Done()
|
||||
mux.ProbeAvailability()
|
||||
}(mux.handler)
|
||||
s.probeMu.Lock()
|
||||
|
||||
// don't start probes on a stopped server
|
||||
if s.ctx.Err() != nil {
|
||||
s.probeMu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// cancel any running probe
|
||||
if s.probeCancel != nil {
|
||||
s.probeCancel()
|
||||
s.probeCancel = nil
|
||||
}
|
||||
|
||||
// wait for the previous probe goroutines to finish while holding
|
||||
// the mutex so no other caller can start a new probe concurrently
|
||||
s.probeWg.Wait()
|
||||
|
||||
// start a new probe
|
||||
probeCtx, probeCancel := context.WithCancel(s.ctx)
|
||||
s.probeCancel = probeCancel
|
||||
|
||||
s.probeWg.Add(1)
|
||||
defer s.probeWg.Done()
|
||||
|
||||
// Snapshot handlers under s.mux to avoid racing with updateMux/dnsMuxMap writers.
|
||||
s.mux.Lock()
|
||||
handlers := make([]handlerWithStop, 0, len(s.dnsMuxMap))
|
||||
for _, mux := range s.dnsMuxMap {
|
||||
handlers = append(handlers, mux.handler)
|
||||
}
|
||||
s.mux.Unlock()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for _, handler := range handlers {
|
||||
wg.Add(1)
|
||||
go func(h handlerWithStop) {
|
||||
defer wg.Done()
|
||||
h.ProbeAvailability(probeCtx)
|
||||
}(handler)
|
||||
}
|
||||
|
||||
s.probeMu.Unlock()
|
||||
|
||||
wg.Wait()
|
||||
probeCancel()
|
||||
}
|
||||
|
||||
func (s *DefaultServer) UpdateServerConfig(domains dnsconfig.ServerDomains) error {
|
||||
|
||||
@@ -1065,7 +1065,7 @@ type mockHandler struct {
|
||||
|
||||
func (m *mockHandler) ServeDNS(dns.ResponseWriter, *dns.Msg) {}
|
||||
func (m *mockHandler) Stop() {}
|
||||
func (m *mockHandler) ProbeAvailability() {}
|
||||
func (m *mockHandler) ProbeAvailability(context.Context) {}
|
||||
func (m *mockHandler) ID() types.HandlerID { return types.HandlerID(m.Id) }
|
||||
|
||||
type mockService struct{}
|
||||
|
||||
@@ -65,6 +65,7 @@ type upstreamResolverBase struct {
|
||||
mutex sync.Mutex
|
||||
reactivatePeriod time.Duration
|
||||
upstreamTimeout time.Duration
|
||||
wg sync.WaitGroup
|
||||
|
||||
deactivate func(error)
|
||||
reactivate func()
|
||||
@@ -115,6 +116,11 @@ func (u *upstreamResolverBase) MatchSubdomains() bool {
|
||||
func (u *upstreamResolverBase) Stop() {
|
||||
log.Debugf("stopping serving DNS for upstreams %s", u.upstreamServers)
|
||||
u.cancel()
|
||||
|
||||
u.mutex.Lock()
|
||||
u.wg.Wait()
|
||||
u.mutex.Unlock()
|
||||
|
||||
}
|
||||
|
||||
// ServeDNS handles a DNS request
|
||||
@@ -260,16 +266,10 @@ func formatFailures(failures []upstreamFailure) string {
|
||||
|
||||
// ProbeAvailability tests all upstream servers simultaneously and
|
||||
// disables the resolver if none work
|
||||
func (u *upstreamResolverBase) ProbeAvailability() {
|
||||
func (u *upstreamResolverBase) ProbeAvailability(ctx context.Context) {
|
||||
u.mutex.Lock()
|
||||
defer u.mutex.Unlock()
|
||||
|
||||
select {
|
||||
case <-u.ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
// avoid probe if upstreams could resolve at least one query
|
||||
if u.successCount.Load() > 0 {
|
||||
return
|
||||
@@ -279,31 +279,39 @@ func (u *upstreamResolverBase) ProbeAvailability() {
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
|
||||
var errors *multierror.Error
|
||||
var errs *multierror.Error
|
||||
for _, upstream := range u.upstreamServers {
|
||||
upstream := upstream
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
go func(upstream netip.AddrPort) {
|
||||
defer wg.Done()
|
||||
err := u.testNameserver(upstream, 500*time.Millisecond)
|
||||
err := u.testNameserver(u.ctx, ctx, upstream, 500*time.Millisecond)
|
||||
if err != nil {
|
||||
errors = multierror.Append(errors, err)
|
||||
mu.Lock()
|
||||
errs = multierror.Append(errs, err)
|
||||
mu.Unlock()
|
||||
log.Warnf("probing upstream nameserver %s: %s", upstream, err)
|
||||
return
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
success = true
|
||||
}()
|
||||
mu.Unlock()
|
||||
}(upstream)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-u.ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
// didn't find a working upstream server, let's disable and try later
|
||||
if !success {
|
||||
u.disable(errors.ErrorOrNil())
|
||||
u.disable(errs.ErrorOrNil())
|
||||
|
||||
if u.statusRecorder == nil {
|
||||
return
|
||||
@@ -339,7 +347,7 @@ func (u *upstreamResolverBase) waitUntilResponse() {
|
||||
}
|
||||
|
||||
for _, upstream := range u.upstreamServers {
|
||||
if err := u.testNameserver(upstream, probeTimeout); err != nil {
|
||||
if err := u.testNameserver(u.ctx, nil, upstream, probeTimeout); err != nil {
|
||||
log.Tracef("upstream check for %s: %s", upstream, err)
|
||||
} else {
|
||||
// at least one upstream server is available, stop probing
|
||||
@@ -364,7 +372,9 @@ func (u *upstreamResolverBase) waitUntilResponse() {
|
||||
log.Infof("upstreams %s are responsive again. Adding them back to system", u.upstreamServersString())
|
||||
u.successCount.Add(1)
|
||||
u.reactivate()
|
||||
u.mutex.Lock()
|
||||
u.disabled = false
|
||||
u.mutex.Unlock()
|
||||
}
|
||||
|
||||
// isTimeout returns true if the given error is a network timeout error.
|
||||
@@ -387,7 +397,11 @@ func (u *upstreamResolverBase) disable(err error) {
|
||||
u.successCount.Store(0)
|
||||
u.deactivate(err)
|
||||
u.disabled = true
|
||||
go u.waitUntilResponse()
|
||||
u.wg.Add(1)
|
||||
go func() {
|
||||
defer u.wg.Done()
|
||||
u.waitUntilResponse()
|
||||
}()
|
||||
}
|
||||
|
||||
func (u *upstreamResolverBase) upstreamServersString() string {
|
||||
@@ -398,13 +412,18 @@ func (u *upstreamResolverBase) upstreamServersString() string {
|
||||
return strings.Join(servers, ", ")
|
||||
}
|
||||
|
||||
func (u *upstreamResolverBase) testNameserver(server netip.AddrPort, timeout time.Duration) error {
|
||||
ctx, cancel := context.WithTimeout(u.ctx, timeout)
|
||||
func (u *upstreamResolverBase) testNameserver(baseCtx context.Context, externalCtx context.Context, server netip.AddrPort, timeout time.Duration) error {
|
||||
mergedCtx, cancel := context.WithTimeout(baseCtx, timeout)
|
||||
defer cancel()
|
||||
|
||||
if externalCtx != nil {
|
||||
stop2 := context.AfterFunc(externalCtx, cancel)
|
||||
defer stop2()
|
||||
}
|
||||
|
||||
r := new(dns.Msg).SetQuestion(testRecord, dns.TypeSOA)
|
||||
|
||||
_, _, err := u.upstreamClient.exchange(ctx, server.String(), r)
|
||||
_, _, err := u.upstreamClient.exchange(mergedCtx, server.String(), r)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -188,7 +188,7 @@ func TestUpstreamResolver_DeactivationReactivation(t *testing.T) {
|
||||
reactivated = true
|
||||
}
|
||||
|
||||
resolver.ProbeAvailability()
|
||||
resolver.ProbeAvailability(context.TODO())
|
||||
|
||||
if !failed {
|
||||
t.Errorf("expected that resolving was deactivated")
|
||||
|
||||
@@ -1315,8 +1315,7 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error {
|
||||
|
||||
// Test received (upstream) servers for availability right away instead of upon usage.
|
||||
// If no server of a server group responds this will disable the respective handler and retry later.
|
||||
e.dnsServer.ProbeAvailability()
|
||||
|
||||
go e.dnsServer.ProbeAvailability()
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1625,9 +1625,14 @@ func (s *Server) GetFeatures(ctx context.Context, msg *proto.GetFeaturesRequest)
|
||||
|
||||
func (s *Server) connect(ctx context.Context, config *profilemanager.Config, statusRecorder *peer.Status, doInitialAutoUpdate bool, runningChan chan struct{}) error {
|
||||
log.Tracef("running client connection")
|
||||
s.connectClient = internal.NewConnectClient(ctx, config, statusRecorder, doInitialAutoUpdate)
|
||||
s.connectClient.SetSyncResponsePersistence(s.persistSyncResponse)
|
||||
if err := s.connectClient.Run(runningChan, s.logFile); err != nil {
|
||||
client := internal.NewConnectClient(ctx, config, statusRecorder, doInitialAutoUpdate)
|
||||
client.SetSyncResponsePersistence(s.persistSyncResponse)
|
||||
|
||||
s.mutex.Lock()
|
||||
s.connectClient = client
|
||||
s.mutex.Unlock()
|
||||
|
||||
if err := client.Run(runningChan, s.logFile); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
||||
187
client/server/server_connect_test.go
Normal file
187
client/server/server_connect_test.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
func newTestServer() *Server {
|
||||
return &Server{
|
||||
rootCtx: context.Background(),
|
||||
statusRecorder: peer.NewRecorder(""),
|
||||
}
|
||||
}
|
||||
|
||||
func newDummyConnectClient(ctx context.Context) *internal.ConnectClient {
|
||||
return internal.NewConnectClient(ctx, nil, nil, false)
|
||||
}
|
||||
|
||||
// TestConnectSetsClientWithMutex validates that connect() sets s.connectClient
|
||||
// under mutex protection so concurrent readers see a consistent value.
|
||||
func TestConnectSetsClientWithMutex(t *testing.T) {
|
||||
s := newTestServer()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Manually simulate what connect() does (without calling Run which panics without full setup)
|
||||
client := newDummyConnectClient(ctx)
|
||||
|
||||
s.mutex.Lock()
|
||||
s.connectClient = client
|
||||
s.mutex.Unlock()
|
||||
|
||||
// Verify the assignment is visible under mutex
|
||||
s.mutex.Lock()
|
||||
assert.Equal(t, client, s.connectClient, "connectClient should be set")
|
||||
s.mutex.Unlock()
|
||||
}
|
||||
|
||||
// TestConcurrentConnectClientAccess validates that concurrent reads of
|
||||
// s.connectClient under mutex don't race with a write.
|
||||
func TestConcurrentConnectClientAccess(t *testing.T) {
|
||||
s := newTestServer()
|
||||
ctx := context.Background()
|
||||
client := newDummyConnectClient(ctx)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
nilCount := 0
|
||||
setCount := 0
|
||||
var mu sync.Mutex
|
||||
|
||||
// Start readers
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
s.mutex.Lock()
|
||||
c := s.connectClient
|
||||
s.mutex.Unlock()
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if c == nil {
|
||||
nilCount++
|
||||
} else {
|
||||
setCount++
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Simulate connect() writing under mutex
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
s.mutex.Lock()
|
||||
s.connectClient = client
|
||||
s.mutex.Unlock()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
assert.Equal(t, 50, nilCount+setCount, "all goroutines should complete without panic")
|
||||
}
|
||||
|
||||
// TestCleanupConnection_ClearsConnectClient validates that cleanupConnection
|
||||
// properly nils out connectClient.
|
||||
func TestCleanupConnection_ClearsConnectClient(t *testing.T) {
|
||||
s := newTestServer()
|
||||
_, cancel := context.WithCancel(context.Background())
|
||||
s.actCancel = cancel
|
||||
|
||||
s.connectClient = newDummyConnectClient(context.Background())
|
||||
s.clientRunning = true
|
||||
|
||||
err := s.cleanupConnection()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Nil(t, s.connectClient, "connectClient should be nil after cleanup")
|
||||
}
|
||||
|
||||
// TestCleanState_NilConnectClient validates that CleanState doesn't panic
|
||||
// when connectClient is nil.
|
||||
func TestCleanState_NilConnectClient(t *testing.T) {
|
||||
s := newTestServer()
|
||||
s.connectClient = nil
|
||||
s.profileManager = nil // will cause error if it tries to proceed past the nil check
|
||||
|
||||
// Should not panic — the nil check should prevent calling Status() on nil
|
||||
assert.NotPanics(t, func() {
|
||||
_, _ = s.CleanState(context.Background(), &proto.CleanStateRequest{All: true})
|
||||
})
|
||||
}
|
||||
|
||||
// TestDeleteState_NilConnectClient validates that DeleteState doesn't panic
|
||||
// when connectClient is nil.
|
||||
func TestDeleteState_NilConnectClient(t *testing.T) {
|
||||
s := newTestServer()
|
||||
s.connectClient = nil
|
||||
s.profileManager = nil
|
||||
|
||||
assert.NotPanics(t, func() {
|
||||
_, _ = s.DeleteState(context.Background(), &proto.DeleteStateRequest{All: true})
|
||||
})
|
||||
}
|
||||
|
||||
// TestDownThenUp_StaleRunningChan documents the known state issue where
|
||||
// clientRunningChan from a previous connection is already closed, causing
|
||||
// waitForUp() to return immediately on reconnect.
|
||||
func TestDownThenUp_StaleRunningChan(t *testing.T) {
|
||||
s := newTestServer()
|
||||
|
||||
// Simulate state after a successful connection
|
||||
s.clientRunning = true
|
||||
s.clientRunningChan = make(chan struct{})
|
||||
close(s.clientRunningChan) // closed when engine started
|
||||
s.clientGiveUpChan = make(chan struct{})
|
||||
s.connectClient = newDummyConnectClient(context.Background())
|
||||
|
||||
_, cancel := context.WithCancel(context.Background())
|
||||
s.actCancel = cancel
|
||||
|
||||
// Simulate Down(): cleanupConnection sets connectClient = nil
|
||||
s.mutex.Lock()
|
||||
err := s.cleanupConnection()
|
||||
s.mutex.Unlock()
|
||||
require.NoError(t, err)
|
||||
|
||||
// After cleanup: connectClient is nil, clientRunning still true
|
||||
// (goroutine hasn't exited yet)
|
||||
s.mutex.Lock()
|
||||
assert.Nil(t, s.connectClient, "connectClient should be nil after cleanup")
|
||||
assert.True(t, s.clientRunning, "clientRunning still true until goroutine exits")
|
||||
s.mutex.Unlock()
|
||||
|
||||
// waitForUp() returns immediately due to stale closed clientRunningChan
|
||||
ctx, ctxCancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer ctxCancel()
|
||||
|
||||
waitDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := s.waitForUp(ctx)
|
||||
waitDone <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-waitDone:
|
||||
assert.NoError(t, err, "waitForUp returns success on stale channel")
|
||||
// But connectClient is still nil — this is the stale state issue
|
||||
s.mutex.Lock()
|
||||
assert.Nil(t, s.connectClient, "connectClient is nil despite waitForUp success")
|
||||
s.mutex.Unlock()
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("waitForUp should have returned immediately due to stale closed channel")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnectClient_EngineNilOnFreshClient validates that a newly created
|
||||
// ConnectClient has nil Engine (before Run is called).
|
||||
func TestConnectClient_EngineNilOnFreshClient(t *testing.T) {
|
||||
client := newDummyConnectClient(context.Background())
|
||||
assert.Nil(t, client.Engine(), "engine should be nil on fresh ConnectClient")
|
||||
}
|
||||
@@ -39,7 +39,7 @@ func (s *Server) ListStates(_ context.Context, _ *proto.ListStatesRequest) (*pro
|
||||
|
||||
// CleanState handles cleaning of states (performing cleanup operations)
|
||||
func (s *Server) CleanState(ctx context.Context, req *proto.CleanStateRequest) (*proto.CleanStateResponse, error) {
|
||||
if s.connectClient.Status() == internal.StatusConnected || s.connectClient.Status() == internal.StatusConnecting {
|
||||
if s.connectClient != nil && (s.connectClient.Status() == internal.StatusConnected || s.connectClient.Status() == internal.StatusConnecting) {
|
||||
return nil, status.Errorf(codes.FailedPrecondition, "cannot clean state while connecting or connected, run 'netbird down' first.")
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ func (s *Server) CleanState(ctx context.Context, req *proto.CleanStateRequest) (
|
||||
|
||||
// DeleteState handles deletion of states without cleanup
|
||||
func (s *Server) DeleteState(ctx context.Context, req *proto.DeleteStateRequest) (*proto.DeleteStateResponse, error) {
|
||||
if s.connectClient.Status() == internal.StatusConnected || s.connectClient.Status() == internal.StatusConnecting {
|
||||
if s.connectClient != nil && (s.connectClient.Status() == internal.StatusConnected || s.connectClient.Status() == internal.StatusConnecting) {
|
||||
return nil, status.Errorf(codes.FailedPrecondition, "cannot clean state while connecting or connected, run 'netbird down' first.")
|
||||
}
|
||||
|
||||
|
||||
@@ -323,7 +323,7 @@ type serviceClient struct {
|
||||
|
||||
exitNodeMu sync.Mutex
|
||||
mExitNodeItems []menuHandler
|
||||
exitNodeStates []exitNodeState
|
||||
exitNodeRetryCancel context.CancelFunc
|
||||
mExitNodeDeselectAll *systray.MenuItem
|
||||
logFile string
|
||||
wLoginURL fyne.Window
|
||||
@@ -924,7 +924,7 @@ func (s *serviceClient) updateStatus() error {
|
||||
s.mDown.Enable()
|
||||
s.mNetworks.Enable()
|
||||
s.mExitNode.Enable()
|
||||
go s.updateExitNodes()
|
||||
s.startExitNodeRefresh()
|
||||
systrayIconState = true
|
||||
case status.Status == string(internal.StatusConnecting):
|
||||
s.setConnectingStatus()
|
||||
@@ -985,6 +985,7 @@ func (s *serviceClient) setDisconnectedStatus() {
|
||||
s.mUp.Enable()
|
||||
s.mNetworks.Disable()
|
||||
s.mExitNode.Disable()
|
||||
s.cancelExitNodeRetry()
|
||||
go s.updateExitNodes()
|
||||
}
|
||||
|
||||
|
||||
@@ -100,8 +100,7 @@ func (h *eventHandler) handleConnectClick() {
|
||||
|
||||
func (h *eventHandler) handleDisconnectClick() {
|
||||
h.client.mDown.Disable()
|
||||
|
||||
h.client.exitNodeStates = []exitNodeState{}
|
||||
h.client.cancelExitNodeRetry()
|
||||
|
||||
if h.client.connectCancel != nil {
|
||||
log.Debugf("cancelling ongoing connect operation")
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -34,11 +33,6 @@ const (
|
||||
|
||||
type filter string
|
||||
|
||||
type exitNodeState struct {
|
||||
id string
|
||||
selected bool
|
||||
}
|
||||
|
||||
func (s *serviceClient) showNetworksUI() {
|
||||
s.wNetworks = s.app.NewWindow("Networks")
|
||||
s.wNetworks.SetOnClosed(s.cancel)
|
||||
@@ -335,16 +329,75 @@ func (s *serviceClient) updateNetworksBasedOnDisplayTab(tabs *container.AppTabs,
|
||||
s.updateNetworks(grid, f)
|
||||
}
|
||||
|
||||
func (s *serviceClient) updateExitNodes() {
|
||||
// startExitNodeRefresh initiates exit node menu refresh after connecting.
|
||||
// On Windows, TrayOpenedCh is not supported by the systray library, so we use
|
||||
// a background poller to keep exit nodes in sync while connected.
|
||||
// On macOS/Linux, TrayOpenedCh handles refreshes on each tray open.
|
||||
func (s *serviceClient) startExitNodeRefresh() {
|
||||
s.cancelExitNodeRetry()
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
ctx, cancel := context.WithCancel(s.ctx)
|
||||
s.exitNodeMu.Lock()
|
||||
s.exitNodeRetryCancel = cancel
|
||||
s.exitNodeMu.Unlock()
|
||||
|
||||
go s.pollExitNodes(ctx)
|
||||
} else {
|
||||
go s.updateExitNodes()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *serviceClient) cancelExitNodeRetry() {
|
||||
s.exitNodeMu.Lock()
|
||||
if s.exitNodeRetryCancel != nil {
|
||||
s.exitNodeRetryCancel()
|
||||
s.exitNodeRetryCancel = nil
|
||||
}
|
||||
s.exitNodeMu.Unlock()
|
||||
}
|
||||
|
||||
// pollExitNodes periodically refreshes exit nodes while connected.
|
||||
// Uses a short initial interval to catch routes from the management sync,
|
||||
// then switches to a longer interval for ongoing updates.
|
||||
func (s *serviceClient) pollExitNodes(ctx context.Context) {
|
||||
// Initial fast polling to catch routes as they appear after connect.
|
||||
for i := 0; i < 5; i++ {
|
||||
if s.updateExitNodes() {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.updateExitNodes()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// updateExitNodes fetches exit nodes from the daemon and recreates the menu.
|
||||
// Returns true if exit nodes were found.
|
||||
func (s *serviceClient) updateExitNodes() bool {
|
||||
conn, err := s.getSrvClient(defaultFailTimeout)
|
||||
if err != nil {
|
||||
log.Errorf("get client: %v", err)
|
||||
return
|
||||
return false
|
||||
}
|
||||
exitNodes, err := s.getExitNodes(conn)
|
||||
if err != nil {
|
||||
log.Errorf("get exit nodes: %v", err)
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
s.exitNodeMu.Lock()
|
||||
@@ -354,28 +407,14 @@ func (s *serviceClient) updateExitNodes() {
|
||||
|
||||
if len(s.mExitNodeItems) > 0 {
|
||||
s.mExitNode.Enable()
|
||||
} else {
|
||||
s.mExitNode.Disable()
|
||||
return true
|
||||
}
|
||||
|
||||
s.mExitNode.Disable()
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *serviceClient) recreateExitNodeMenu(exitNodes []*proto.Network) {
|
||||
var exitNodeIDs []exitNodeState
|
||||
for _, node := range exitNodes {
|
||||
exitNodeIDs = append(exitNodeIDs, exitNodeState{
|
||||
id: node.ID,
|
||||
selected: node.Selected,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(exitNodeIDs, func(i, j int) bool {
|
||||
return exitNodeIDs[i].id < exitNodeIDs[j].id
|
||||
})
|
||||
if slices.Equal(s.exitNodeStates, exitNodeIDs) {
|
||||
log.Debug("Exit node menu already up to date")
|
||||
return
|
||||
}
|
||||
|
||||
for _, node := range s.mExitNodeItems {
|
||||
node.cancel()
|
||||
node.Hide()
|
||||
@@ -413,8 +452,6 @@ func (s *serviceClient) recreateExitNodeMenu(exitNodes []*proto.Network) {
|
||||
go s.handleChecked(ctx, node.ID, menuItem)
|
||||
}
|
||||
|
||||
s.exitNodeStates = exitNodeIDs
|
||||
|
||||
if showDeselectAll {
|
||||
s.mExitNode.AddSeparator()
|
||||
deselectAllItem := s.mExitNode.AddSubMenuItem("Deselect All", "Deselect All")
|
||||
|
||||
@@ -170,20 +170,66 @@ type Connector struct {
|
||||
}
|
||||
|
||||
// ToStorageConnector converts a Connector to storage.Connector type.
|
||||
// It maps custom connector types (e.g., "zitadel", "entra") to Dex-native types
|
||||
// and augments the config with OIDC defaults when needed.
|
||||
func (c *Connector) ToStorageConnector() (storage.Connector, error) {
|
||||
data, err := json.Marshal(c.Config)
|
||||
dexType, augmentedConfig := mapConnectorToDex(c.Type, c.Config)
|
||||
|
||||
data, err := json.Marshal(augmentedConfig)
|
||||
if err != nil {
|
||||
return storage.Connector{}, fmt.Errorf("failed to marshal connector config: %v", err)
|
||||
}
|
||||
|
||||
return storage.Connector{
|
||||
ID: c.ID,
|
||||
Type: c.Type,
|
||||
Type: dexType,
|
||||
Name: c.Name,
|
||||
Config: data,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// mapConnectorToDex maps custom connector types to Dex-native types and applies
|
||||
// OIDC defaults. This ensures static connectors from config files or env vars
|
||||
// are stored with types that Dex can open.
|
||||
func mapConnectorToDex(connType string, config map[string]interface{}) (string, map[string]interface{}) {
|
||||
switch connType {
|
||||
case "oidc", "zitadel", "entra", "okta", "pocketid", "authentik", "keycloak":
|
||||
return "oidc", applyOIDCDefaults(connType, config)
|
||||
default:
|
||||
return connType, config
|
||||
}
|
||||
}
|
||||
|
||||
// applyOIDCDefaults clones the config map, sets common OIDC defaults,
|
||||
// and applies provider-specific overrides.
|
||||
func applyOIDCDefaults(connType string, config map[string]interface{}) map[string]interface{} {
|
||||
augmented := make(map[string]interface{}, len(config)+4)
|
||||
for k, v := range config {
|
||||
augmented[k] = v
|
||||
}
|
||||
setDefault(augmented, "scopes", []string{"openid", "profile", "email"})
|
||||
setDefault(augmented, "insecureEnableGroups", true)
|
||||
setDefault(augmented, "insecureSkipEmailVerified", true)
|
||||
|
||||
switch connType {
|
||||
case "zitadel":
|
||||
setDefault(augmented, "getUserInfo", true)
|
||||
case "entra":
|
||||
setDefault(augmented, "claimMapping", map[string]string{"email": "preferred_username"})
|
||||
case "okta", "pocketid":
|
||||
augmented["scopes"] = []string{"openid", "profile", "email", "groups"}
|
||||
}
|
||||
|
||||
return augmented
|
||||
}
|
||||
|
||||
// setDefault sets a key in the map only if it doesn't already exist.
|
||||
func setDefault(m map[string]interface{}, key string, value interface{}) {
|
||||
if _, ok := m[key]; !ok {
|
||||
m[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
// StorageConfig is a configuration that can create a storage.
|
||||
type StorageConfig interface {
|
||||
Open(logger *slog.Logger) (storage.Storage, error)
|
||||
|
||||
@@ -2,11 +2,14 @@ package dex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/dexidp/dex/storage"
|
||||
sqllib "github.com/dexidp/dex/storage/sql"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -197,6 +200,295 @@ enablePasswordDB: true
|
||||
t.Logf("User lookup successful: rawID=%s, connectorID=%s", rawID, connID)
|
||||
}
|
||||
|
||||
// openTestStorage creates a SQLite storage in the given directory for testing.
|
||||
func openTestStorage(t *testing.T, tmpDir string) storage.Storage {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||
stor, err := (&sqllib.SQLite3{File: filepath.Join(tmpDir, "dex.db")}).Open(logger)
|
||||
require.NoError(t, err)
|
||||
return stor
|
||||
}
|
||||
|
||||
func TestStaticConnectors_CreatedFromYAML(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "dex-static-conn-*")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
yamlContent := `
|
||||
issuer: http://localhost:5556/dex
|
||||
storage:
|
||||
type: sqlite3
|
||||
config:
|
||||
file: ` + filepath.Join(tmpDir, "dex.db") + `
|
||||
web:
|
||||
http: 127.0.0.1:5556
|
||||
enablePasswordDB: true
|
||||
connectors:
|
||||
- type: oidc
|
||||
id: my-oidc
|
||||
name: My OIDC Provider
|
||||
config:
|
||||
issuer: https://accounts.example.com
|
||||
clientID: test-client-id
|
||||
clientSecret: test-client-secret
|
||||
redirectURI: http://localhost:5556/dex/callback
|
||||
`
|
||||
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||
err = os.WriteFile(configPath, []byte(yamlContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
yamlConfig, err := LoadConfig(configPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Open storage and run initializeStorage directly (avoids Dex server
|
||||
// trying to dial the OIDC issuer)
|
||||
stor := openTestStorage(t, tmpDir)
|
||||
defer stor.Close()
|
||||
|
||||
err = initializeStorage(ctx, stor, yamlConfig)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify connector was created in storage
|
||||
conn, err := stor.GetConnector(ctx, "my-oidc")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "my-oidc", conn.ID)
|
||||
assert.Equal(t, "My OIDC Provider", conn.Name)
|
||||
assert.Equal(t, "oidc", conn.Type)
|
||||
|
||||
// Verify config fields were serialized correctly
|
||||
var configMap map[string]interface{}
|
||||
err = json.Unmarshal(conn.Config, &configMap)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https://accounts.example.com", configMap["issuer"])
|
||||
assert.Equal(t, "test-client-id", configMap["clientID"])
|
||||
}
|
||||
|
||||
func TestStaticConnectors_UpdatedOnRestart(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "dex-static-conn-update-*")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dbFile := filepath.Join(tmpDir, "dex.db")
|
||||
|
||||
// First: load config with initial connector
|
||||
yamlContent1 := `
|
||||
issuer: http://localhost:5556/dex
|
||||
storage:
|
||||
type: sqlite3
|
||||
config:
|
||||
file: ` + dbFile + `
|
||||
web:
|
||||
http: 127.0.0.1:5556
|
||||
enablePasswordDB: true
|
||||
connectors:
|
||||
- type: oidc
|
||||
id: my-oidc
|
||||
name: Original Name
|
||||
config:
|
||||
issuer: https://accounts.example.com
|
||||
clientID: original-client-id
|
||||
clientSecret: original-secret
|
||||
`
|
||||
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||
err = os.WriteFile(configPath, []byte(yamlContent1), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
yamlConfig1, err := LoadConfig(configPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
stor := openTestStorage(t, tmpDir)
|
||||
err = initializeStorage(ctx, stor, yamlConfig1)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify initial state
|
||||
conn, err := stor.GetConnector(ctx, "my-oidc")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Original Name", conn.Name)
|
||||
|
||||
var configMap1 map[string]interface{}
|
||||
err = json.Unmarshal(conn.Config, &configMap1)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "original-client-id", configMap1["clientID"])
|
||||
|
||||
// Close storage to simulate restart
|
||||
stor.Close()
|
||||
|
||||
// Second: load updated config against the same DB
|
||||
yamlContent2 := `
|
||||
issuer: http://localhost:5556/dex
|
||||
storage:
|
||||
type: sqlite3
|
||||
config:
|
||||
file: ` + dbFile + `
|
||||
web:
|
||||
http: 127.0.0.1:5556
|
||||
enablePasswordDB: true
|
||||
connectors:
|
||||
- type: oidc
|
||||
id: my-oidc
|
||||
name: Updated Name
|
||||
config:
|
||||
issuer: https://accounts.example.com
|
||||
clientID: updated-client-id
|
||||
clientSecret: updated-secret
|
||||
`
|
||||
err = os.WriteFile(configPath, []byte(yamlContent2), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
yamlConfig2, err := LoadConfig(configPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
stor2 := openTestStorage(t, tmpDir)
|
||||
defer stor2.Close()
|
||||
|
||||
err = initializeStorage(ctx, stor2, yamlConfig2)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify connector was updated, not duplicated
|
||||
allConnectors, err := stor2.ListConnectors(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
nonLocalCount := 0
|
||||
for _, c := range allConnectors {
|
||||
if c.ID != "local" {
|
||||
nonLocalCount++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, nonLocalCount, "connector should be updated, not duplicated")
|
||||
|
||||
conn2, err := stor2.GetConnector(ctx, "my-oidc")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Updated Name", conn2.Name)
|
||||
|
||||
var configMap2 map[string]interface{}
|
||||
err = json.Unmarshal(conn2.Config, &configMap2)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "updated-client-id", configMap2["clientID"])
|
||||
}
|
||||
|
||||
func TestStaticConnectors_MultipleConnectors(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "dex-static-conn-multi-*")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
yamlContent := `
|
||||
issuer: http://localhost:5556/dex
|
||||
storage:
|
||||
type: sqlite3
|
||||
config:
|
||||
file: ` + filepath.Join(tmpDir, "dex.db") + `
|
||||
web:
|
||||
http: 127.0.0.1:5556
|
||||
enablePasswordDB: true
|
||||
connectors:
|
||||
- type: oidc
|
||||
id: my-oidc
|
||||
name: My OIDC Provider
|
||||
config:
|
||||
issuer: https://accounts.example.com
|
||||
clientID: oidc-client-id
|
||||
clientSecret: oidc-secret
|
||||
- type: google
|
||||
id: my-google
|
||||
name: Google Login
|
||||
config:
|
||||
clientID: google-client-id
|
||||
clientSecret: google-secret
|
||||
`
|
||||
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||
err = os.WriteFile(configPath, []byte(yamlContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
yamlConfig, err := LoadConfig(configPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
stor := openTestStorage(t, tmpDir)
|
||||
defer stor.Close()
|
||||
|
||||
err = initializeStorage(ctx, stor, yamlConfig)
|
||||
require.NoError(t, err)
|
||||
|
||||
allConnectors, err := stor.ListConnectors(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Build a map for easier assertion
|
||||
connByID := make(map[string]storage.Connector)
|
||||
for _, c := range allConnectors {
|
||||
connByID[c.ID] = c
|
||||
}
|
||||
|
||||
// Verify both static connectors exist
|
||||
oidcConn, ok := connByID["my-oidc"]
|
||||
require.True(t, ok, "oidc connector should exist")
|
||||
assert.Equal(t, "My OIDC Provider", oidcConn.Name)
|
||||
assert.Equal(t, "oidc", oidcConn.Type)
|
||||
|
||||
var oidcConfig map[string]interface{}
|
||||
err = json.Unmarshal(oidcConn.Config, &oidcConfig)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "oidc-client-id", oidcConfig["clientID"])
|
||||
|
||||
googleConn, ok := connByID["my-google"]
|
||||
require.True(t, ok, "google connector should exist")
|
||||
assert.Equal(t, "Google Login", googleConn.Name)
|
||||
assert.Equal(t, "google", googleConn.Type)
|
||||
|
||||
var googleConfig map[string]interface{}
|
||||
err = json.Unmarshal(googleConn.Config, &googleConfig)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "google-client-id", googleConfig["clientID"])
|
||||
|
||||
// Verify local connector still exists alongside them (enablePasswordDB: true)
|
||||
localConn, ok := connByID["local"]
|
||||
require.True(t, ok, "local connector should exist")
|
||||
assert.Equal(t, "local", localConn.Type)
|
||||
}
|
||||
|
||||
func TestStaticConnectors_EmptyList(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "dex-static-conn-empty-*")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
yamlContent := `
|
||||
issuer: http://localhost:5556/dex
|
||||
storage:
|
||||
type: sqlite3
|
||||
config:
|
||||
file: ` + filepath.Join(tmpDir, "dex.db") + `
|
||||
web:
|
||||
http: 127.0.0.1:5556
|
||||
enablePasswordDB: true
|
||||
`
|
||||
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||
err = os.WriteFile(configPath, []byte(yamlContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
yamlConfig, err := LoadConfig(configPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
provider, err := NewProviderFromYAML(ctx, yamlConfig)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = provider.Stop(ctx) }()
|
||||
|
||||
// No static connectors configured, so ListConnectors should return empty
|
||||
connectors, err := provider.ListConnectors(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, connectors)
|
||||
|
||||
// But local connector should still exist
|
||||
localConn, err := provider.Storage().GetConnector(ctx, "local")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "local", localConn.ID)
|
||||
}
|
||||
|
||||
func TestNewProvider_ContinueOnConnectorFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
|
||||
@@ -182,44 +182,6 @@ read_enable_proxy() {
|
||||
return 0
|
||||
}
|
||||
|
||||
read_proxy_domain() {
|
||||
local suggested_proxy="proxy.${BASE_DOMAIN}"
|
||||
|
||||
echo "" > /dev/stderr
|
||||
echo "NOTE: The proxy domain must be different from the management domain ($NETBIRD_DOMAIN)" > /dev/stderr
|
||||
echo "to avoid TLS certificate conflicts." > /dev/stderr
|
||||
echo "" > /dev/stderr
|
||||
echo "You also need to add a wildcard DNS record for the proxy domain," > /dev/stderr
|
||||
echo "e.g. *.${suggested_proxy} pointing to the same server domain as $NETBIRD_DOMAIN with a CNAME record." > /dev/stderr
|
||||
echo "" > /dev/stderr
|
||||
echo -n "Enter the domain for the NetBird Proxy (e.g. ${suggested_proxy}): " > /dev/stderr
|
||||
read -r READ_PROXY_DOMAIN < /dev/tty
|
||||
|
||||
if [[ -z "$READ_PROXY_DOMAIN" ]]; then
|
||||
echo "The proxy domain cannot be empty." > /dev/stderr
|
||||
read_proxy_domain
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ "$READ_PROXY_DOMAIN" == "$NETBIRD_DOMAIN" ]]; then
|
||||
echo "" > /dev/stderr
|
||||
echo "WARNING: The proxy domain cannot be the same as the management domain ($NETBIRD_DOMAIN)." > /dev/stderr
|
||||
read_proxy_domain
|
||||
return
|
||||
fi
|
||||
|
||||
echo ${READ_PROXY_DOMAIN} | grep ${NETBIRD_DOMAIN} > /dev/null
|
||||
if [[ $? -eq 0 ]]; then
|
||||
echo "" > /dev/stderr
|
||||
echo "WARNING: The proxy domain cannot be a subdomain of the management domain ($NETBIRD_DOMAIN)." > /dev/stderr
|
||||
read_proxy_domain
|
||||
return
|
||||
fi
|
||||
|
||||
echo "$READ_PROXY_DOMAIN"
|
||||
return 0
|
||||
}
|
||||
|
||||
read_traefik_acme_email() {
|
||||
echo "" > /dev/stderr
|
||||
echo "Enter your email for Let's Encrypt certificate notifications." > /dev/stderr
|
||||
@@ -334,7 +296,6 @@ initialize_default_values() {
|
||||
|
||||
# NetBird Proxy configuration
|
||||
ENABLE_PROXY="false"
|
||||
PROXY_DOMAIN=""
|
||||
PROXY_TOKEN=""
|
||||
return 0
|
||||
}
|
||||
@@ -364,9 +325,6 @@ configure_reverse_proxy() {
|
||||
if [[ "$REVERSE_PROXY_TYPE" == "0" ]]; then
|
||||
TRAEFIK_ACME_EMAIL=$(read_traefik_acme_email)
|
||||
ENABLE_PROXY=$(read_enable_proxy)
|
||||
if [[ "$ENABLE_PROXY" == "true" ]]; then
|
||||
PROXY_DOMAIN=$(read_proxy_domain)
|
||||
fi
|
||||
fi
|
||||
|
||||
# Handle external Traefik-specific prompts (option 1)
|
||||
@@ -813,7 +771,7 @@ NB_PROXY_MANAGEMENT_ADDRESS=http://netbird-server:80
|
||||
# Allow insecure gRPC connection to management (required for internal Docker network)
|
||||
NB_PROXY_ALLOW_INSECURE=true
|
||||
# Public URL where this proxy is reachable (used for cluster registration)
|
||||
NB_PROXY_DOMAIN=$PROXY_DOMAIN
|
||||
NB_PROXY_DOMAIN=$NETBIRD_DOMAIN
|
||||
NB_PROXY_ADDRESS=:8443
|
||||
NB_PROXY_TOKEN=$PROXY_TOKEN
|
||||
NB_PROXY_CERTIFICATE_DIRECTORY=/certs
|
||||
@@ -1203,8 +1161,7 @@ print_builtin_traefik_instructions() {
|
||||
echo " The proxy handles its own TLS certificates via ACME TLS-ALPN-01 challenge."
|
||||
echo " Point your proxy domain to this server's domain address like in the examples below:"
|
||||
echo ""
|
||||
echo " $PROXY_DOMAIN CNAME $NETBIRD_DOMAIN"
|
||||
echo " *.$PROXY_DOMAIN CNAME $NETBIRD_DOMAIN"
|
||||
echo " *.$NETBIRD_DOMAIN CNAME $NETBIRD_DOMAIN"
|
||||
echo ""
|
||||
fi
|
||||
return 0
|
||||
|
||||
@@ -87,9 +87,14 @@ func NewController(ctx context.Context, store store.Store, metrics telemetry.App
|
||||
newNetworkMapBuilder = false
|
||||
}
|
||||
|
||||
compactedNetworkMap, err := strconv.ParseBool(os.Getenv(types.EnvNewNetworkMapCompacted))
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Warnf("failed to parse %s, using default value false: %v", types.EnvNewNetworkMapCompacted, err)
|
||||
compactedNetworkMap := true
|
||||
compactedEnv := os.Getenv(types.EnvNewNetworkMapCompacted)
|
||||
parsedCompactedNmap, err := strconv.ParseBool(compactedEnv)
|
||||
if err != nil && len(compactedEnv) > 0 {
|
||||
log.WithContext(ctx).Warnf("failed to parse %s, using default value true: %v", types.EnvNewNetworkMapCompacted, err)
|
||||
}
|
||||
if err == nil && !parsedCompactedNmap {
|
||||
log.WithContext(ctx).Info("disabling compacted mode")
|
||||
compactedNetworkMap = false
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ type AccessLogEntry struct {
|
||||
Reason string
|
||||
UserId string `gorm:"index"`
|
||||
AuthMethodUsed string `gorm:"index"`
|
||||
BytesUpload int64 `gorm:"index"`
|
||||
BytesDownload int64 `gorm:"index"`
|
||||
}
|
||||
|
||||
// FromProto creates an AccessLogEntry from a proto.AccessLog
|
||||
@@ -39,6 +41,8 @@ func (a *AccessLogEntry) FromProto(serviceLog *proto.AccessLog) {
|
||||
a.UserId = serviceLog.GetUserId()
|
||||
a.AuthMethodUsed = serviceLog.GetAuthMechanism()
|
||||
a.AccountID = serviceLog.GetAccountId()
|
||||
a.BytesUpload = serviceLog.GetBytesUpload()
|
||||
a.BytesDownload = serviceLog.GetBytesDownload()
|
||||
|
||||
if sourceIP := serviceLog.GetSourceIp(); sourceIP != "" {
|
||||
if ip, err := netip.ParseAddr(sourceIP); err == nil {
|
||||
@@ -101,5 +105,7 @@ func (a *AccessLogEntry) ToAPIResponse() *api.ProxyAccessLog {
|
||||
AuthMethodUsed: authMethod,
|
||||
CountryCode: countryCode,
|
||||
CityName: cityName,
|
||||
BytesUpload: a.BytesUpload,
|
||||
BytesDownload: a.BytesDownload,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,3 +15,12 @@ type Domain struct {
|
||||
Type Type `gorm:"-"`
|
||||
Validated bool
|
||||
}
|
||||
|
||||
// EventMeta returns activity event metadata for a domain
|
||||
func (d *Domain) EventMeta() map[string]any {
|
||||
return map[string]any{
|
||||
"domain": d.Domain,
|
||||
"target_cluster": d.TargetCluster,
|
||||
"validated": d.Validated,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
|
||||
"github.com/netbirdio/netbird/management/server/account"
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
"github.com/netbirdio/netbird/management/server/permissions"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/modules"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/operations"
|
||||
@@ -36,16 +38,16 @@ type Manager struct {
|
||||
validator domain.Validator
|
||||
proxyManager proxyManager
|
||||
permissionsManager permissions.Manager
|
||||
accountManager account.Manager
|
||||
}
|
||||
|
||||
func NewManager(store store, proxyMgr proxyManager, permissionsManager permissions.Manager) Manager {
|
||||
func NewManager(store store, proxyMgr proxyManager, permissionsManager permissions.Manager, accountManager account.Manager) Manager {
|
||||
return Manager{
|
||||
store: store,
|
||||
proxyManager: proxyMgr,
|
||||
validator: domain.Validator{
|
||||
Resolver: net.DefaultResolver,
|
||||
},
|
||||
store: store,
|
||||
proxyManager: proxyMgr,
|
||||
validator: domain.Validator{Resolver: net.DefaultResolver},
|
||||
permissionsManager: permissionsManager,
|
||||
accountManager: accountManager,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +138,9 @@ func (m Manager) CreateDomain(ctx context.Context, accountID, userID, domainName
|
||||
if err != nil {
|
||||
return d, fmt.Errorf("create domain in store: %w", err)
|
||||
}
|
||||
|
||||
m.accountManager.StoreEvent(ctx, userID, d.ID, accountID, activity.DomainAdded, d.EventMeta())
|
||||
|
||||
return d, nil
|
||||
}
|
||||
|
||||
@@ -148,10 +153,18 @@ func (m Manager) DeleteDomain(ctx context.Context, accountID, userID, domainID s
|
||||
return status.NewPermissionDeniedError()
|
||||
}
|
||||
|
||||
d, err := m.store.GetCustomDomain(ctx, accountID, domainID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get domain from store: %w", err)
|
||||
}
|
||||
|
||||
if err := m.store.DeleteCustomDomain(ctx, accountID, domainID); err != nil {
|
||||
// TODO: check for "no records" type error. Because that is a success condition.
|
||||
return fmt.Errorf("delete domain from store: %w", err)
|
||||
}
|
||||
|
||||
m.accountManager.StoreEvent(ctx, userID, domainID, accountID, activity.DomainDeleted, d.EventMeta())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -218,6 +231,8 @@ func (m Manager) ValidateDomain(ctx context.Context, accountID, userID, domainID
|
||||
}).WithError(err).Error("update custom domain in store")
|
||||
return
|
||||
}
|
||||
|
||||
m.accountManager.StoreEvent(context.Background(), userID, domainID, accountID, activity.DomainValidated, d.EventMeta())
|
||||
} else {
|
||||
log.WithFields(log.Fields{
|
||||
"accountID": accountID,
|
||||
|
||||
@@ -392,7 +392,7 @@ func (m *Manager) sendServiceUpdateNotifications(ctx context.Context, accountID
|
||||
oidcCfg := m.proxyController.GetOIDCValidationConfig()
|
||||
|
||||
switch {
|
||||
case updateInfo.domainChanged && updateInfo.oldCluster != s.ProxyCluster:
|
||||
case updateInfo.domainChanged || updateInfo.oldCluster != s.ProxyCluster:
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Delete, "", oidcCfg), updateInfo.oldCluster)
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Create, "", oidcCfg), s.ProxyCluster)
|
||||
case !s.Enabled && updateInfo.serviceEnabledChanged:
|
||||
|
||||
@@ -116,9 +116,11 @@ func (s *BaseServer) IdpManager() idp.Manager {
|
||||
return Create(s, func() idp.Manager {
|
||||
var idpManager idp.Manager
|
||||
var err error
|
||||
|
||||
// Use embedded IdP service if embedded Dex is configured and enabled.
|
||||
// Legacy IdpManager won't be used anymore even if configured.
|
||||
if s.Config.EmbeddedIdP != nil && s.Config.EmbeddedIdP.Enabled {
|
||||
embeddedEnabled := s.Config.EmbeddedIdP != nil && s.Config.EmbeddedIdP.Enabled
|
||||
if embeddedEnabled {
|
||||
idpManager, err = idp.NewEmbeddedIdPManager(context.Background(), s.Config.EmbeddedIdP, s.Metrics())
|
||||
if err != nil {
|
||||
log.Fatalf("failed to create embedded IDP service: %v", err)
|
||||
@@ -210,7 +212,7 @@ func (s *BaseServer) ProxyManager() proxy.Manager {
|
||||
|
||||
func (s *BaseServer) ReverseProxyDomainManager() *manager.Manager {
|
||||
return Create(s, func() *manager.Manager {
|
||||
m := manager.NewManager(s.Store(), s.ProxyManager(), s.PermissionsManager())
|
||||
m := manager.NewManager(s.Store(), s.ProxyManager(), s.PermissionsManager(), s.AccountManager())
|
||||
return &m
|
||||
})
|
||||
}
|
||||
|
||||
@@ -330,13 +330,12 @@ func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_S
|
||||
|
||||
s.secretsManager.SetupRefresh(ctx, accountID, peer.ID)
|
||||
|
||||
if s.appMetrics != nil {
|
||||
s.appMetrics.GRPCMetrics().CountSyncRequestDuration(time.Since(reqStart), accountID)
|
||||
}
|
||||
|
||||
unlock()
|
||||
unlock = nil
|
||||
|
||||
if s.appMetrics != nil {
|
||||
s.appMetrics.GRPCMetrics().CountSyncRequestDuration(time.Since(reqStart), accountID)
|
||||
}
|
||||
log.WithContext(ctx).Debugf("Sync took %s", time.Since(reqStart))
|
||||
|
||||
s.syncSem.Add(-1)
|
||||
@@ -743,13 +742,6 @@ func (s *Server) Login(ctx context.Context, req *proto.EncryptedMessage) (*proto
|
||||
|
||||
log.WithContext(ctx).Debugf("Login request from peer [%s] [%s]", req.WgPubKey, sRealIP)
|
||||
|
||||
defer func() {
|
||||
if s.appMetrics != nil {
|
||||
s.appMetrics.GRPCMetrics().CountLoginRequestDuration(time.Since(reqStart), accountID)
|
||||
}
|
||||
log.WithContext(ctx).Debugf("Login took %s", time.Since(reqStart))
|
||||
}()
|
||||
|
||||
if loginReq.GetMeta() == nil {
|
||||
msg := status.Errorf(codes.FailedPrecondition,
|
||||
"peer system meta has to be provided to log in. Peer %s, remote addr %s", peerKey.String(), realIP)
|
||||
@@ -799,6 +791,11 @@ func (s *Server) Login(ctx context.Context, req *proto.EncryptedMessage) (*proto
|
||||
return nil, status.Errorf(codes.Internal, "failed logging in peer")
|
||||
}
|
||||
|
||||
if s.appMetrics != nil {
|
||||
s.appMetrics.GRPCMetrics().CountLoginRequestDuration(time.Since(reqStart), accountID)
|
||||
}
|
||||
log.WithContext(ctx).Debugf("Login took %s", time.Since(reqStart))
|
||||
|
||||
return &proto.EncryptedMessage{
|
||||
WgPubKey: key.PublicKey().String(),
|
||||
Body: encryptedResp,
|
||||
|
||||
@@ -63,11 +63,20 @@ func (ac *AccountRequestBuffer) GetAccountWithBackpressure(ctx context.Context,
|
||||
|
||||
log.WithContext(ctx).Tracef("requesting account %s with backpressure", accountID)
|
||||
startTime := time.Now()
|
||||
ac.getAccountRequestCh <- req
|
||||
|
||||
result := <-req.ResultChan
|
||||
log.WithContext(ctx).Tracef("got account with backpressure after %s", time.Since(startTime))
|
||||
return result.Account, result.Err
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case ac.getAccountRequestCh <- req:
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case result := <-req.ResultChan:
|
||||
log.WithContext(ctx).Tracef("got account with backpressure after %s", time.Since(startTime))
|
||||
return result.Account, result.Err
|
||||
}
|
||||
}
|
||||
|
||||
func (ac *AccountRequestBuffer) processGetAccountBatch(ctx context.Context, accountID string) {
|
||||
@@ -86,7 +95,14 @@ func (ac *AccountRequestBuffer) processGetAccountBatch(ctx context.Context, acco
|
||||
result := &AccountResult{Account: account, Err: err}
|
||||
|
||||
for _, req := range requests {
|
||||
req.ResultChan <- result
|
||||
if account != nil {
|
||||
// Shallow copy the account so each goroutine gets its own struct value.
|
||||
// This prevents data races when callers mutate fields like Policies.
|
||||
accountCopy := *account
|
||||
req.ResultChan <- &AccountResult{Account: &accountCopy, Err: err}
|
||||
} else {
|
||||
req.ResultChan <- result
|
||||
}
|
||||
close(req.ResultChan)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,6 +220,13 @@ const (
|
||||
// AccountPeerExposeDisabled indicates that a user disabled peer expose for the account
|
||||
AccountPeerExposeDisabled Activity = 115
|
||||
|
||||
// DomainAdded indicates that a user added a custom domain
|
||||
DomainAdded Activity = 118
|
||||
// DomainDeleted indicates that a user deleted a custom domain
|
||||
DomainDeleted Activity = 119
|
||||
// DomainValidated indicates that a custom domain was validated
|
||||
DomainValidated Activity = 120
|
||||
|
||||
AccountDeleted Activity = 99999
|
||||
)
|
||||
|
||||
@@ -364,6 +371,10 @@ var activityMap = map[Activity]Code{
|
||||
|
||||
AccountPeerExposeEnabled: {"Account peer expose enabled", "account.setting.peer.expose.enable"},
|
||||
AccountPeerExposeDisabled: {"Account peer expose disabled", "account.setting.peer.expose.disable"},
|
||||
|
||||
DomainAdded: {"Domain added", "domain.add"},
|
||||
DomainDeleted: {"Domain deleted", "domain.delete"},
|
||||
DomainValidated: {"Domain validated", "domain.validate"},
|
||||
}
|
||||
|
||||
// StringCode returns a string code of the activity
|
||||
|
||||
61
management/server/activity/store/sql_store_idp_migration.go
Normal file
61
management/server/activity/store/sql_store_idp_migration.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package store
|
||||
|
||||
// This file contains migration-only methods on Store.
|
||||
// They satisfy the migration.MigrationEventStore interface via duck typing.
|
||||
// Delete this file when migration tooling is no longer needed.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
"github.com/netbirdio/netbird/management/server/idp/migration"
|
||||
)
|
||||
|
||||
// CheckSchema verifies that all tables and columns required by the migration exist in the event database.
|
||||
func (store *Store) CheckSchema(checks []migration.SchemaCheck) []migration.SchemaError {
|
||||
migrator := store.db.Migrator()
|
||||
var errs []migration.SchemaError
|
||||
|
||||
for _, check := range checks {
|
||||
if !migrator.HasTable(check.Table) {
|
||||
errs = append(errs, migration.SchemaError{Table: check.Table})
|
||||
continue
|
||||
}
|
||||
for _, col := range check.Columns {
|
||||
if !migrator.HasColumn(check.Table, col) {
|
||||
errs = append(errs, migration.SchemaError{Table: check.Table, Column: col})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
// UpdateUserID updates all references to oldUserID in events and deleted_users tables.
|
||||
func (store *Store) UpdateUserID(ctx context.Context, oldUserID, newUserID string) error {
|
||||
return store.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&activity.Event{}).
|
||||
Where("initiator_id = ?", oldUserID).
|
||||
Update("initiator_id", newUserID).Error; err != nil {
|
||||
return fmt.Errorf("update events.initiator_id: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Model(&activity.Event{}).
|
||||
Where("target_id = ?", oldUserID).
|
||||
Update("target_id", newUserID).Error; err != nil {
|
||||
return fmt.Errorf("update events.target_id: %w", err)
|
||||
}
|
||||
|
||||
// Raw exec: GORM can't update a PK via Model().Update()
|
||||
if err := tx.Exec(
|
||||
"UPDATE deleted_users SET id = ? WHERE id = ?", newUserID, oldUserID,
|
||||
).Error; err != nil {
|
||||
return fmt.Errorf("update deleted_users.id: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
161
management/server/activity/store/sql_store_idp_migration_test.go
Normal file
161
management/server/activity/store/sql_store_idp_migration_test.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
"github.com/netbirdio/netbird/util/crypt"
|
||||
)
|
||||
|
||||
func TestUpdateUserID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
newStore := func(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
key, _ := crypt.GenerateKey()
|
||||
s, err := NewSqlStore(ctx, t.TempDir(), key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { s.Close(ctx) }) //nolint
|
||||
return s
|
||||
}
|
||||
|
||||
t.Run("updates initiator_id in events", func(t *testing.T) {
|
||||
store := newStore(t)
|
||||
accountID := "account_1"
|
||||
|
||||
_, err := store.Save(ctx, &activity.Event{
|
||||
Timestamp: time.Now().UTC(),
|
||||
Activity: activity.PeerAddedByUser,
|
||||
InitiatorID: "old-user",
|
||||
TargetID: "some-peer",
|
||||
AccountID: accountID,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = store.UpdateUserID(ctx, "old-user", "new-user")
|
||||
assert.NoError(t, err)
|
||||
|
||||
result, err := store.Get(ctx, accountID, 0, 10, false)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, result, 1)
|
||||
assert.Equal(t, "new-user", result[0].InitiatorID)
|
||||
})
|
||||
|
||||
t.Run("updates target_id in events", func(t *testing.T) {
|
||||
store := newStore(t)
|
||||
accountID := "account_1"
|
||||
|
||||
_, err := store.Save(ctx, &activity.Event{
|
||||
Timestamp: time.Now().UTC(),
|
||||
Activity: activity.PeerAddedByUser,
|
||||
InitiatorID: "some-admin",
|
||||
TargetID: "old-user",
|
||||
AccountID: accountID,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = store.UpdateUserID(ctx, "old-user", "new-user")
|
||||
assert.NoError(t, err)
|
||||
|
||||
result, err := store.Get(ctx, accountID, 0, 10, false)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, result, 1)
|
||||
assert.Equal(t, "new-user", result[0].TargetID)
|
||||
})
|
||||
|
||||
t.Run("updates deleted_users id", func(t *testing.T) {
|
||||
store := newStore(t)
|
||||
accountID := "account_1"
|
||||
|
||||
// Save an event with email/name meta to create a deleted_users row for "old-user"
|
||||
_, err := store.Save(ctx, &activity.Event{
|
||||
Timestamp: time.Now().UTC(),
|
||||
Activity: activity.PeerAddedByUser,
|
||||
InitiatorID: "admin",
|
||||
TargetID: "old-user",
|
||||
AccountID: accountID,
|
||||
Meta: map[string]any{
|
||||
"email": "user@example.com",
|
||||
"name": "Test User",
|
||||
},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = store.UpdateUserID(ctx, "old-user", "new-user")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Save another event referencing new-user with email/name meta.
|
||||
// This should upsert (not conflict) because the PK was already migrated.
|
||||
_, err = store.Save(ctx, &activity.Event{
|
||||
Timestamp: time.Now().UTC(),
|
||||
Activity: activity.PeerAddedByUser,
|
||||
InitiatorID: "admin",
|
||||
TargetID: "new-user",
|
||||
AccountID: accountID,
|
||||
Meta: map[string]any{
|
||||
"email": "user@example.com",
|
||||
"name": "Test User",
|
||||
},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// The deleted user info should be retrievable via Get (joined on target_id)
|
||||
result, err := store.Get(ctx, accountID, 0, 10, false)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, result, 2)
|
||||
for _, ev := range result {
|
||||
assert.Equal(t, "new-user", ev.TargetID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no-op when old user ID does not exist", func(t *testing.T) {
|
||||
store := newStore(t)
|
||||
|
||||
err := store.UpdateUserID(ctx, "nonexistent-user", "new-user")
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("only updates matching user leaves others unchanged", func(t *testing.T) {
|
||||
store := newStore(t)
|
||||
accountID := "account_1"
|
||||
|
||||
_, err := store.Save(ctx, &activity.Event{
|
||||
Timestamp: time.Now().UTC(),
|
||||
Activity: activity.PeerAddedByUser,
|
||||
InitiatorID: "user-a",
|
||||
TargetID: "peer-1",
|
||||
AccountID: accountID,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = store.Save(ctx, &activity.Event{
|
||||
Timestamp: time.Now().UTC(),
|
||||
Activity: activity.PeerAddedByUser,
|
||||
InitiatorID: "user-b",
|
||||
TargetID: "peer-2",
|
||||
AccountID: accountID,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = store.UpdateUserID(ctx, "user-a", "user-a-new")
|
||||
assert.NoError(t, err)
|
||||
|
||||
result, err := store.Get(ctx, accountID, 0, 10, false)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, result, 2)
|
||||
|
||||
for _, ev := range result {
|
||||
if ev.TargetID == "peer-1" {
|
||||
assert.Equal(t, "user-a-new", ev.InitiatorID)
|
||||
} else {
|
||||
assert.Equal(t, "user-b", ev.InitiatorID)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -51,19 +51,28 @@ func GetList() []string {
|
||||
// This can be used to bypass authz/authn middlewares for certain paths, such as webhooks that implement their own authentication.
|
||||
func ShouldBypass(requestPath string, h http.Handler, w http.ResponseWriter, r *http.Request) bool {
|
||||
byPassMutex.RLock()
|
||||
defer byPassMutex.RUnlock()
|
||||
|
||||
var matched bool
|
||||
for bypassPath := range bypassPaths {
|
||||
matched, err := path.Match(bypassPath, requestPath)
|
||||
m, err := path.Match(bypassPath, requestPath)
|
||||
if err != nil {
|
||||
log.WithContext(r.Context()).Errorf("Error matching path %s with %s from %s: %v", bypassPath, requestPath, GetList(), err)
|
||||
list := make([]string, 0, len(bypassPaths))
|
||||
for k := range bypassPaths {
|
||||
list = append(list, k)
|
||||
}
|
||||
log.WithContext(r.Context()).Errorf("Error matching path %s with %s from %v: %v", bypassPath, requestPath, list, err)
|
||||
continue
|
||||
}
|
||||
if matched {
|
||||
h.ServeHTTP(w, r)
|
||||
return true
|
||||
if m {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
byPassMutex.RUnlock()
|
||||
|
||||
if matched {
|
||||
h.ServeHTTP(w, r)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee
|
||||
t.Fatalf("Failed to create proxy manager: %v", err)
|
||||
}
|
||||
proxyServiceServer := nbgrpc.NewProxyServiceServer(accessLogsManager, proxyTokenStore, pkceverifierStore, nbgrpc.ProxyOIDCConfig{}, peersManager, userManager, proxyMgr)
|
||||
domainManager := manager.NewManager(store, proxyMgr, permissionsManager)
|
||||
domainManager := manager.NewManager(store, proxyMgr, permissionsManager, am)
|
||||
serviceProxyController, err := proxymanager.NewGRPCController(proxyServiceServer, noopMeter)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create proxy controller: %v", err)
|
||||
|
||||
@@ -48,6 +48,8 @@ type EmbeddedIdPConfig struct {
|
||||
// Existing local users are preserved and will be able to login again if re-enabled.
|
||||
// Cannot be enabled if no external identity provider connectors are configured.
|
||||
LocalAuthDisabled bool
|
||||
// StaticConnectors are additional connectors to seed during initialization
|
||||
StaticConnectors []dex.Connector
|
||||
}
|
||||
|
||||
// EmbeddedStorageConfig holds storage configuration for the embedded IdP.
|
||||
@@ -157,6 +159,7 @@ func (c *EmbeddedIdPConfig) ToYAMLConfig() (*dex.YAMLConfig, error) {
|
||||
RedirectURIs: cliRedirectURIs,
|
||||
},
|
||||
},
|
||||
StaticConnectors: c.StaticConnectors,
|
||||
}
|
||||
|
||||
// Add owner user if provided
|
||||
|
||||
235
management/server/idp/migration/migration.go
Normal file
235
management/server/idp/migration/migration.go
Normal file
@@ -0,0 +1,235 @@
|
||||
// Package migration provides utility functions for migrating from the external IdP solution in pre v0.62.0
|
||||
// to the new embedded IdP manager (Dex based), which is the default in v0.62.0 and later.
|
||||
// It includes functions to seed connectors and migrate existing users to use these connectors.
|
||||
package migration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/idp/dex"
|
||||
"github.com/netbirdio/netbird/management/server/idp"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
)
|
||||
|
||||
// Server is the dependency interface that migration functions use to access
|
||||
// the main data store and the activity event store.
|
||||
type Server interface {
|
||||
Store() Store
|
||||
EventStore() EventStore // may return nil
|
||||
}
|
||||
|
||||
const idpSeedInfoKey = "IDP_SEED_INFO"
|
||||
const dryRunEnvKey = "NB_IDP_MIGRATION_DRY_RUN"
|
||||
|
||||
func isDryRun() bool {
|
||||
return os.Getenv(dryRunEnvKey) == "true"
|
||||
}
|
||||
|
||||
var ErrNoSeedInfo = errors.New("no seed info found in environment")
|
||||
|
||||
// SeedConnectorFromEnv reads the IDP_SEED_INFO env var, base64-decodes it,
|
||||
// and JSON-unmarshals it into a dex.Connector. Returns nil if not set.
|
||||
func SeedConnectorFromEnv() (*dex.Connector, error) {
|
||||
val, ok := os.LookupEnv(idpSeedInfoKey)
|
||||
if !ok || val == "" {
|
||||
return nil, ErrNoSeedInfo
|
||||
}
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(val)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("base64 decode: %w", err)
|
||||
}
|
||||
|
||||
var conn dex.Connector
|
||||
if err := json.Unmarshal(decoded, &conn); err != nil {
|
||||
return nil, fmt.Errorf("json unmarshal: %w", err)
|
||||
}
|
||||
|
||||
return &conn, nil
|
||||
}
|
||||
|
||||
// MigrateUsersToStaticConnectors re-keys every user ID in the main store (and
|
||||
// the activity store, if present) so that it encodes the given connector ID,
|
||||
// skipping users that have already been migrated. Set NB_IDP_MIGRATION_DRY_RUN=true
|
||||
// to log what would happen without writing any changes.
|
||||
func MigrateUsersToStaticConnectors(s Server, conn *dex.Connector) error {
|
||||
ctx := context.Background()
|
||||
|
||||
if isDryRun() {
|
||||
log.Info("[DRY RUN] migration dry-run mode enabled, no changes will be written")
|
||||
}
|
||||
|
||||
users, err := s.Store().ListUsers(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list users: %w", err)
|
||||
}
|
||||
|
||||
// Reconciliation pass: fix activity store for users already migrated in main DB
|
||||
// but whose activity references may still use old IDs (from a previous partial failure).
|
||||
if s.EventStore() != nil && !isDryRun() {
|
||||
if err := reconcileActivityStore(ctx, s.EventStore(), users); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var migratedCount, skippedCount int
|
||||
|
||||
for _, user := range users {
|
||||
_, _, decErr := dex.DecodeDexUserID(user.Id)
|
||||
if decErr == nil {
|
||||
skippedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
newUserID := dex.EncodeDexUserID(user.Id, conn.ID)
|
||||
|
||||
if isDryRun() {
|
||||
log.Infof("[DRY RUN] would migrate user %s -> %s (account: %s)", user.Id, newUserID, user.AccountID)
|
||||
migratedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
if err := migrateUser(ctx, s, user.Id, user.AccountID, newUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
migratedCount++
|
||||
}
|
||||
|
||||
if isDryRun() {
|
||||
log.Infof("[DRY RUN] migration summary: %d users would be migrated, %d already migrated", migratedCount, skippedCount)
|
||||
} else {
|
||||
log.Infof("migration complete: %d users migrated, %d already migrated", migratedCount, skippedCount)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// reconcileActivityStore updates activity store references for users already migrated
|
||||
// in the main DB whose activity entries may still use old IDs from a previous partial failure.
|
||||
func reconcileActivityStore(ctx context.Context, eventStore EventStore, users []*types.User) error {
|
||||
for _, user := range users {
|
||||
originalID, _, err := dex.DecodeDexUserID(user.Id)
|
||||
if err != nil {
|
||||
// skip users that aren't migrated, they will be handled in the main migration loop
|
||||
continue
|
||||
}
|
||||
if err := eventStore.UpdateUserID(ctx, originalID, user.Id); err != nil {
|
||||
return fmt.Errorf("reconcile activity store for user %s: %w", user.Id, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateUser updates a single user's ID in both the main store and the activity store.
|
||||
func migrateUser(ctx context.Context, s Server, oldID, accountID, newID string) error {
|
||||
if err := s.Store().UpdateUserID(ctx, accountID, oldID, newID); err != nil {
|
||||
return fmt.Errorf("failed to update user ID for user %s: %w", oldID, err)
|
||||
}
|
||||
|
||||
if s.EventStore() == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := s.EventStore().UpdateUserID(ctx, oldID, newID); err != nil {
|
||||
return fmt.Errorf("failed to update activity store user ID for user %s: %w", oldID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PopulateUserInfo fetches user email and name from the external IDP and updates
|
||||
// the store for users that are missing this information.
|
||||
func PopulateUserInfo(s Server, idpManager idp.Manager, dryRun bool) error {
|
||||
ctx := context.Background()
|
||||
|
||||
users, err := s.Store().ListUsers(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list users: %w", err)
|
||||
}
|
||||
|
||||
// Build a map of IDP user ID -> UserData from the external IDP
|
||||
allAccounts, err := idpManager.GetAllAccounts(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch accounts from IDP: %w", err)
|
||||
}
|
||||
|
||||
idpUsers := make(map[string]*idp.UserData)
|
||||
for _, accountUsers := range allAccounts {
|
||||
for _, userData := range accountUsers {
|
||||
idpUsers[userData.ID] = userData
|
||||
}
|
||||
}
|
||||
|
||||
log.Infof("fetched %d users from IDP", len(idpUsers))
|
||||
|
||||
var updatedCount, skippedCount, notFoundCount int
|
||||
|
||||
for _, user := range users {
|
||||
if user.IsServiceUser {
|
||||
skippedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
if user.Email != "" && user.Name != "" {
|
||||
skippedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
// The user ID in the store may be the original IDP ID or a Dex-encoded ID.
|
||||
// Try to decode the Dex format first to get the original IDP ID.
|
||||
lookupID := user.Id
|
||||
if originalID, _, decErr := dex.DecodeDexUserID(user.Id); decErr == nil {
|
||||
lookupID = originalID
|
||||
}
|
||||
|
||||
idpUser, found := idpUsers[lookupID]
|
||||
if !found {
|
||||
notFoundCount++
|
||||
log.Debugf("user %s (lookup: %s) not found in IDP, skipping", user.Id, lookupID)
|
||||
continue
|
||||
}
|
||||
|
||||
email := user.Email
|
||||
name := user.Name
|
||||
if email == "" && idpUser.Email != "" {
|
||||
email = idpUser.Email
|
||||
}
|
||||
if name == "" && idpUser.Name != "" {
|
||||
name = idpUser.Name
|
||||
}
|
||||
|
||||
if email == user.Email && name == user.Name {
|
||||
skippedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
log.Infof("[DRY RUN] would update user %s: email=%q, name=%q", user.Id, email, name)
|
||||
updatedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
if err := s.Store().UpdateUserInfo(ctx, user.Id, email, name); err != nil {
|
||||
return fmt.Errorf("failed to update user info for %s: %w", user.Id, err)
|
||||
}
|
||||
|
||||
log.Infof("updated user %s: email=%q, name=%q", user.Id, email, name)
|
||||
updatedCount++
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
log.Infof("[DRY RUN] user info summary: %d would be updated, %d skipped, %d not found in IDP", updatedCount, skippedCount, notFoundCount)
|
||||
} else {
|
||||
log.Infof("user info population complete: %d updated, %d skipped, %d not found in IDP", updatedCount, skippedCount, notFoundCount)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
828
management/server/idp/migration/migration_test.go
Normal file
828
management/server/idp/migration/migration_test.go
Normal file
@@ -0,0 +1,828 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/idp/dex"
|
||||
"github.com/netbirdio/netbird/management/server/idp"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
)
|
||||
|
||||
// testStore is a hand-written mock for MigrationStore.
|
||||
type testStore struct {
|
||||
listUsersFunc func(ctx context.Context) ([]*types.User, error)
|
||||
updateUserIDFunc func(ctx context.Context, accountID, oldUserID, newUserID string) error
|
||||
updateUserInfoFunc func(ctx context.Context, userID, email, name string) error
|
||||
checkSchemaFunc func(checks []SchemaCheck) []SchemaError
|
||||
updateCalls []updateUserIDCall
|
||||
updateInfoCalls []updateUserInfoCall
|
||||
}
|
||||
|
||||
type updateUserIDCall struct {
|
||||
AccountID string
|
||||
OldUserID string
|
||||
NewUserID string
|
||||
}
|
||||
|
||||
type updateUserInfoCall struct {
|
||||
UserID string
|
||||
Email string
|
||||
Name string
|
||||
}
|
||||
|
||||
func (s *testStore) ListUsers(ctx context.Context) ([]*types.User, error) {
|
||||
return s.listUsersFunc(ctx)
|
||||
}
|
||||
|
||||
func (s *testStore) UpdateUserID(ctx context.Context, accountID, oldUserID, newUserID string) error {
|
||||
s.updateCalls = append(s.updateCalls, updateUserIDCall{accountID, oldUserID, newUserID})
|
||||
return s.updateUserIDFunc(ctx, accountID, oldUserID, newUserID)
|
||||
}
|
||||
|
||||
func (s *testStore) UpdateUserInfo(ctx context.Context, userID, email, name string) error {
|
||||
s.updateInfoCalls = append(s.updateInfoCalls, updateUserInfoCall{userID, email, name})
|
||||
if s.updateUserInfoFunc != nil {
|
||||
return s.updateUserInfoFunc(ctx, userID, email, name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *testStore) CheckSchema(checks []SchemaCheck) []SchemaError {
|
||||
if s.checkSchemaFunc != nil {
|
||||
return s.checkSchemaFunc(checks)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type testServer struct {
|
||||
store Store
|
||||
eventStore EventStore
|
||||
}
|
||||
|
||||
func (s *testServer) Store() Store { return s.store }
|
||||
func (s *testServer) EventStore() EventStore { return s.eventStore }
|
||||
|
||||
func TestSeedConnectorFromEnv(t *testing.T) {
|
||||
t.Run("returns ErrNoSeedInfo when env var is not set", func(t *testing.T) {
|
||||
os.Unsetenv(idpSeedInfoKey)
|
||||
|
||||
conn, err := SeedConnectorFromEnv()
|
||||
assert.ErrorIs(t, err, ErrNoSeedInfo)
|
||||
assert.Nil(t, conn)
|
||||
})
|
||||
|
||||
t.Run("returns ErrNoSeedInfo when env var is empty", func(t *testing.T) {
|
||||
t.Setenv(idpSeedInfoKey, "")
|
||||
|
||||
conn, err := SeedConnectorFromEnv()
|
||||
assert.ErrorIs(t, err, ErrNoSeedInfo)
|
||||
assert.Nil(t, conn)
|
||||
})
|
||||
|
||||
t.Run("returns error on invalid base64", func(t *testing.T) {
|
||||
t.Setenv(idpSeedInfoKey, "not-valid-base64!!!")
|
||||
|
||||
conn, err := SeedConnectorFromEnv()
|
||||
assert.NotErrorIs(t, err, ErrNoSeedInfo)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, conn)
|
||||
assert.Contains(t, err.Error(), "base64 decode")
|
||||
})
|
||||
|
||||
t.Run("returns error on invalid JSON", func(t *testing.T) {
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte("not json"))
|
||||
t.Setenv(idpSeedInfoKey, encoded)
|
||||
|
||||
conn, err := SeedConnectorFromEnv()
|
||||
assert.NotErrorIs(t, err, ErrNoSeedInfo)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, conn)
|
||||
assert.Contains(t, err.Error(), "json unmarshal")
|
||||
})
|
||||
|
||||
t.Run("successfully decodes valid connector", func(t *testing.T) {
|
||||
expected := dex.Connector{
|
||||
Type: "oidc",
|
||||
Name: "Test Provider",
|
||||
ID: "test-provider",
|
||||
Config: map[string]any{
|
||||
"issuer": "https://example.com",
|
||||
"clientID": "my-client-id",
|
||||
"clientSecret": "my-secret",
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(expected)
|
||||
require.NoError(t, err)
|
||||
|
||||
encoded := base64.StdEncoding.EncodeToString(data)
|
||||
t.Setenv(idpSeedInfoKey, encoded)
|
||||
|
||||
conn, err := SeedConnectorFromEnv()
|
||||
assert.NoError(t, err)
|
||||
require.NotNil(t, conn)
|
||||
assert.Equal(t, expected.Type, conn.Type)
|
||||
assert.Equal(t, expected.Name, conn.Name)
|
||||
assert.Equal(t, expected.ID, conn.ID)
|
||||
assert.Equal(t, expected.Config["issuer"], conn.Config["issuer"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestMigrateUsersToStaticConnectors(t *testing.T) {
|
||||
connector := &dex.Connector{
|
||||
Type: "oidc",
|
||||
Name: "Test Provider",
|
||||
ID: "test-connector",
|
||||
}
|
||||
|
||||
t.Run("succeeds with no users", func(t *testing.T) {
|
||||
ms := &testStore{
|
||||
listUsersFunc: func(ctx context.Context) ([]*types.User, error) { return nil, nil },
|
||||
updateUserIDFunc: func(ctx context.Context, accountID, oldUserID, newUserID string) error { return nil },
|
||||
}
|
||||
|
||||
srv := &testServer{store: ms}
|
||||
err := MigrateUsersToStaticConnectors(srv, connector)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("returns error when ListUsers fails", func(t *testing.T) {
|
||||
ms := &testStore{
|
||||
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
|
||||
return nil, fmt.Errorf("db error")
|
||||
},
|
||||
updateUserIDFunc: func(ctx context.Context, accountID, oldUserID, newUserID string) error { return nil },
|
||||
}
|
||||
|
||||
srv := &testServer{store: ms}
|
||||
err := MigrateUsersToStaticConnectors(srv, connector)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to list users")
|
||||
})
|
||||
|
||||
t.Run("migrates single user with correct encoded ID", func(t *testing.T) {
|
||||
user := &types.User{Id: "user-1", AccountID: "account-1"}
|
||||
expectedNewID := dex.EncodeDexUserID("user-1", "test-connector")
|
||||
|
||||
ms := &testStore{
|
||||
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
|
||||
return []*types.User{user}, nil
|
||||
},
|
||||
updateUserIDFunc: func(ctx context.Context, accountID, oldUserID, newUserID string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
srv := &testServer{store: ms}
|
||||
err := MigrateUsersToStaticConnectors(srv, connector)
|
||||
assert.NoError(t, err)
|
||||
require.Len(t, ms.updateCalls, 1)
|
||||
assert.Equal(t, "account-1", ms.updateCalls[0].AccountID)
|
||||
assert.Equal(t, "user-1", ms.updateCalls[0].OldUserID)
|
||||
assert.Equal(t, expectedNewID, ms.updateCalls[0].NewUserID)
|
||||
})
|
||||
|
||||
t.Run("migrates multiple users", func(t *testing.T) {
|
||||
users := []*types.User{
|
||||
{Id: "user-1", AccountID: "account-1"},
|
||||
{Id: "user-2", AccountID: "account-1"},
|
||||
{Id: "user-3", AccountID: "account-2"},
|
||||
}
|
||||
|
||||
ms := &testStore{
|
||||
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
|
||||
return users, nil
|
||||
},
|
||||
updateUserIDFunc: func(ctx context.Context, accountID, oldUserID, newUserID string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
srv := &testServer{store: ms}
|
||||
err := MigrateUsersToStaticConnectors(srv, connector)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, ms.updateCalls, 3)
|
||||
})
|
||||
|
||||
t.Run("returns error when UpdateUserID fails", func(t *testing.T) {
|
||||
users := []*types.User{
|
||||
{Id: "user-1", AccountID: "account-1"},
|
||||
{Id: "user-2", AccountID: "account-1"},
|
||||
}
|
||||
|
||||
callCount := 0
|
||||
ms := &testStore{
|
||||
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
|
||||
return users, nil
|
||||
},
|
||||
updateUserIDFunc: func(ctx context.Context, accountID, oldUserID, newUserID string) error {
|
||||
callCount++
|
||||
if callCount == 2 {
|
||||
return fmt.Errorf("update failed")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
srv := &testServer{store: ms}
|
||||
err := MigrateUsersToStaticConnectors(srv, connector)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to update user ID for user user-2")
|
||||
})
|
||||
|
||||
t.Run("stops on first UpdateUserID error", func(t *testing.T) {
|
||||
users := []*types.User{
|
||||
{Id: "user-1", AccountID: "account-1"},
|
||||
{Id: "user-2", AccountID: "account-1"},
|
||||
}
|
||||
|
||||
ms := &testStore{
|
||||
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
|
||||
return users, nil
|
||||
},
|
||||
updateUserIDFunc: func(ctx context.Context, accountID, oldUserID, newUserID string) error {
|
||||
return fmt.Errorf("update failed")
|
||||
},
|
||||
}
|
||||
|
||||
srv := &testServer{store: ms}
|
||||
err := MigrateUsersToStaticConnectors(srv, connector)
|
||||
assert.Error(t, err)
|
||||
assert.Len(t, ms.updateCalls, 1) // stopped after first error
|
||||
})
|
||||
|
||||
t.Run("skips already migrated users", func(t *testing.T) {
|
||||
alreadyMigratedID := dex.EncodeDexUserID("user-1", "test-connector")
|
||||
users := []*types.User{
|
||||
{Id: alreadyMigratedID, AccountID: "account-1"},
|
||||
}
|
||||
|
||||
ms := &testStore{
|
||||
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
|
||||
return users, nil
|
||||
},
|
||||
updateUserIDFunc: func(ctx context.Context, accountID, oldUserID, newUserID string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
srv := &testServer{store: ms}
|
||||
err := MigrateUsersToStaticConnectors(srv, connector)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, ms.updateCalls, 0)
|
||||
})
|
||||
|
||||
t.Run("migrates only non-migrated users in mixed state", func(t *testing.T) {
|
||||
alreadyMigratedID := dex.EncodeDexUserID("user-1", "test-connector")
|
||||
users := []*types.User{
|
||||
{Id: alreadyMigratedID, AccountID: "account-1"},
|
||||
{Id: "user-2", AccountID: "account-1"},
|
||||
{Id: "user-3", AccountID: "account-2"},
|
||||
}
|
||||
|
||||
ms := &testStore{
|
||||
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
|
||||
return users, nil
|
||||
},
|
||||
updateUserIDFunc: func(ctx context.Context, accountID, oldUserID, newUserID string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
srv := &testServer{store: ms}
|
||||
err := MigrateUsersToStaticConnectors(srv, connector)
|
||||
assert.NoError(t, err)
|
||||
// Only user-2 and user-3 should be migrated
|
||||
assert.Len(t, ms.updateCalls, 2)
|
||||
assert.Equal(t, "user-2", ms.updateCalls[0].OldUserID)
|
||||
assert.Equal(t, "user-3", ms.updateCalls[1].OldUserID)
|
||||
})
|
||||
|
||||
t.Run("dry run does not call UpdateUserID", func(t *testing.T) {
|
||||
t.Setenv(dryRunEnvKey, "true")
|
||||
|
||||
users := []*types.User{
|
||||
{Id: "user-1", AccountID: "account-1"},
|
||||
{Id: "user-2", AccountID: "account-1"},
|
||||
}
|
||||
|
||||
ms := &testStore{
|
||||
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
|
||||
return users, nil
|
||||
},
|
||||
updateUserIDFunc: func(ctx context.Context, accountID, oldUserID, newUserID string) error {
|
||||
t.Fatal("UpdateUserID should not be called in dry-run mode")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
srv := &testServer{store: ms}
|
||||
err := MigrateUsersToStaticConnectors(srv, connector)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, ms.updateCalls, 0)
|
||||
})
|
||||
|
||||
t.Run("dry run skips already migrated users", func(t *testing.T) {
|
||||
t.Setenv(dryRunEnvKey, "true")
|
||||
|
||||
alreadyMigratedID := dex.EncodeDexUserID("user-1", "test-connector")
|
||||
users := []*types.User{
|
||||
{Id: alreadyMigratedID, AccountID: "account-1"},
|
||||
{Id: "user-2", AccountID: "account-1"},
|
||||
}
|
||||
|
||||
ms := &testStore{
|
||||
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
|
||||
return users, nil
|
||||
},
|
||||
updateUserIDFunc: func(ctx context.Context, accountID, oldUserID, newUserID string) error {
|
||||
t.Fatal("UpdateUserID should not be called in dry-run mode")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
srv := &testServer{store: ms}
|
||||
err := MigrateUsersToStaticConnectors(srv, connector)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("dry run disabled by default", func(t *testing.T) {
|
||||
user := &types.User{Id: "user-1", AccountID: "account-1"}
|
||||
|
||||
ms := &testStore{
|
||||
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
|
||||
return []*types.User{user}, nil
|
||||
},
|
||||
updateUserIDFunc: func(ctx context.Context, accountID, oldUserID, newUserID string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
srv := &testServer{store: ms}
|
||||
err := MigrateUsersToStaticConnectors(srv, connector)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, ms.updateCalls, 1) // proves it's not in dry-run
|
||||
})
|
||||
}
|
||||
|
||||
func TestPopulateUserInfo(t *testing.T) {
|
||||
noopUpdateID := func(ctx context.Context, accountID, oldUserID, newUserID string) error { return nil }
|
||||
|
||||
t.Run("succeeds with no users", func(t *testing.T) {
|
||||
ms := &testStore{
|
||||
listUsersFunc: func(ctx context.Context) ([]*types.User, error) { return nil, nil },
|
||||
updateUserIDFunc: noopUpdateID,
|
||||
}
|
||||
mockIDP := &idp.MockIDP{
|
||||
GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) {
|
||||
return map[string][]*idp.UserData{}, nil
|
||||
},
|
||||
}
|
||||
|
||||
srv := &testServer{store: ms}
|
||||
err := PopulateUserInfo(srv, mockIDP, false)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, ms.updateInfoCalls)
|
||||
})
|
||||
|
||||
t.Run("returns error when ListUsers fails", func(t *testing.T) {
|
||||
ms := &testStore{
|
||||
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
|
||||
return nil, fmt.Errorf("db error")
|
||||
},
|
||||
updateUserIDFunc: noopUpdateID,
|
||||
}
|
||||
mockIDP := &idp.MockIDP{}
|
||||
|
||||
srv := &testServer{store: ms}
|
||||
err := PopulateUserInfo(srv, mockIDP, false)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to list users")
|
||||
})
|
||||
|
||||
t.Run("returns error when GetAllAccounts fails", func(t *testing.T) {
|
||||
ms := &testStore{
|
||||
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
|
||||
return []*types.User{{Id: "user-1", AccountID: "acc-1"}}, nil
|
||||
},
|
||||
updateUserIDFunc: noopUpdateID,
|
||||
}
|
||||
mockIDP := &idp.MockIDP{
|
||||
GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) {
|
||||
return nil, fmt.Errorf("idp error")
|
||||
},
|
||||
}
|
||||
|
||||
srv := &testServer{store: ms}
|
||||
err := PopulateUserInfo(srv, mockIDP, false)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to fetch accounts from IDP")
|
||||
})
|
||||
|
||||
t.Run("updates user with missing email and name", func(t *testing.T) {
|
||||
ms := &testStore{
|
||||
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
|
||||
return []*types.User{
|
||||
{Id: "user-1", AccountID: "acc-1", Email: "", Name: ""},
|
||||
}, nil
|
||||
},
|
||||
updateUserIDFunc: noopUpdateID,
|
||||
}
|
||||
mockIDP := &idp.MockIDP{
|
||||
GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) {
|
||||
return map[string][]*idp.UserData{
|
||||
"acc-1": {
|
||||
{ID: "user-1", Email: "user1@example.com", Name: "User One"},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
srv := &testServer{store: ms}
|
||||
err := PopulateUserInfo(srv, mockIDP, false)
|
||||
assert.NoError(t, err)
|
||||
require.Len(t, ms.updateInfoCalls, 1)
|
||||
assert.Equal(t, "user-1", ms.updateInfoCalls[0].UserID)
|
||||
assert.Equal(t, "user1@example.com", ms.updateInfoCalls[0].Email)
|
||||
assert.Equal(t, "User One", ms.updateInfoCalls[0].Name)
|
||||
})
|
||||
|
||||
t.Run("updates only missing email when name exists", func(t *testing.T) {
|
||||
ms := &testStore{
|
||||
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
|
||||
return []*types.User{
|
||||
{Id: "user-1", AccountID: "acc-1", Email: "", Name: "Existing Name"},
|
||||
}, nil
|
||||
},
|
||||
updateUserIDFunc: noopUpdateID,
|
||||
}
|
||||
mockIDP := &idp.MockIDP{
|
||||
GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) {
|
||||
return map[string][]*idp.UserData{
|
||||
"acc-1": {{ID: "user-1", Email: "user1@example.com", Name: "IDP Name"}},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
srv := &testServer{store: ms}
|
||||
err := PopulateUserInfo(srv, mockIDP, false)
|
||||
assert.NoError(t, err)
|
||||
require.Len(t, ms.updateInfoCalls, 1)
|
||||
assert.Equal(t, "user1@example.com", ms.updateInfoCalls[0].Email)
|
||||
assert.Equal(t, "Existing Name", ms.updateInfoCalls[0].Name)
|
||||
})
|
||||
|
||||
t.Run("updates only missing name when email exists", func(t *testing.T) {
|
||||
ms := &testStore{
|
||||
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
|
||||
return []*types.User{
|
||||
{Id: "user-1", AccountID: "acc-1", Email: "existing@example.com", Name: ""},
|
||||
}, nil
|
||||
},
|
||||
updateUserIDFunc: noopUpdateID,
|
||||
}
|
||||
mockIDP := &idp.MockIDP{
|
||||
GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) {
|
||||
return map[string][]*idp.UserData{
|
||||
"acc-1": {{ID: "user-1", Email: "idp@example.com", Name: "IDP Name"}},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
srv := &testServer{store: ms}
|
||||
err := PopulateUserInfo(srv, mockIDP, false)
|
||||
assert.NoError(t, err)
|
||||
require.Len(t, ms.updateInfoCalls, 1)
|
||||
assert.Equal(t, "existing@example.com", ms.updateInfoCalls[0].Email)
|
||||
assert.Equal(t, "IDP Name", ms.updateInfoCalls[0].Name)
|
||||
})
|
||||
|
||||
t.Run("skips users that already have both email and name", func(t *testing.T) {
|
||||
ms := &testStore{
|
||||
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
|
||||
return []*types.User{
|
||||
{Id: "user-1", AccountID: "acc-1", Email: "user1@example.com", Name: "User One"},
|
||||
}, nil
|
||||
},
|
||||
updateUserIDFunc: noopUpdateID,
|
||||
}
|
||||
mockIDP := &idp.MockIDP{
|
||||
GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) {
|
||||
return map[string][]*idp.UserData{
|
||||
"acc-1": {{ID: "user-1", Email: "different@example.com", Name: "Different Name"}},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
srv := &testServer{store: ms}
|
||||
err := PopulateUserInfo(srv, mockIDP, false)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, ms.updateInfoCalls)
|
||||
})
|
||||
|
||||
t.Run("skips service users", func(t *testing.T) {
|
||||
ms := &testStore{
|
||||
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
|
||||
return []*types.User{
|
||||
{Id: "svc-1", AccountID: "acc-1", Email: "", Name: "", IsServiceUser: true},
|
||||
}, nil
|
||||
},
|
||||
updateUserIDFunc: noopUpdateID,
|
||||
}
|
||||
mockIDP := &idp.MockIDP{
|
||||
GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) {
|
||||
return map[string][]*idp.UserData{
|
||||
"acc-1": {{ID: "svc-1", Email: "svc@example.com", Name: "Service"}},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
srv := &testServer{store: ms}
|
||||
err := PopulateUserInfo(srv, mockIDP, false)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, ms.updateInfoCalls)
|
||||
})
|
||||
|
||||
t.Run("skips users not found in IDP", func(t *testing.T) {
|
||||
ms := &testStore{
|
||||
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
|
||||
return []*types.User{
|
||||
{Id: "user-1", AccountID: "acc-1", Email: "", Name: ""},
|
||||
}, nil
|
||||
},
|
||||
updateUserIDFunc: noopUpdateID,
|
||||
}
|
||||
mockIDP := &idp.MockIDP{
|
||||
GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) {
|
||||
return map[string][]*idp.UserData{
|
||||
"acc-1": {{ID: "different-user", Email: "other@example.com", Name: "Other"}},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
srv := &testServer{store: ms}
|
||||
err := PopulateUserInfo(srv, mockIDP, false)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, ms.updateInfoCalls)
|
||||
})
|
||||
|
||||
t.Run("looks up dex-encoded user IDs by original ID", func(t *testing.T) {
|
||||
dexEncodedID := dex.EncodeDexUserID("original-idp-id", "my-connector")
|
||||
|
||||
ms := &testStore{
|
||||
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
|
||||
return []*types.User{
|
||||
{Id: dexEncodedID, AccountID: "acc-1", Email: "", Name: ""},
|
||||
}, nil
|
||||
},
|
||||
updateUserIDFunc: noopUpdateID,
|
||||
}
|
||||
mockIDP := &idp.MockIDP{
|
||||
GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) {
|
||||
return map[string][]*idp.UserData{
|
||||
"acc-1": {{ID: "original-idp-id", Email: "user@example.com", Name: "User"}},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
srv := &testServer{store: ms}
|
||||
err := PopulateUserInfo(srv, mockIDP, false)
|
||||
assert.NoError(t, err)
|
||||
require.Len(t, ms.updateInfoCalls, 1)
|
||||
assert.Equal(t, dexEncodedID, ms.updateInfoCalls[0].UserID)
|
||||
assert.Equal(t, "user@example.com", ms.updateInfoCalls[0].Email)
|
||||
assert.Equal(t, "User", ms.updateInfoCalls[0].Name)
|
||||
})
|
||||
|
||||
t.Run("handles multiple users across multiple accounts", func(t *testing.T) {
|
||||
ms := &testStore{
|
||||
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
|
||||
return []*types.User{
|
||||
{Id: "user-1", AccountID: "acc-1", Email: "", Name: ""},
|
||||
{Id: "user-2", AccountID: "acc-1", Email: "already@set.com", Name: "Already Set"},
|
||||
{Id: "user-3", AccountID: "acc-2", Email: "", Name: ""},
|
||||
}, nil
|
||||
},
|
||||
updateUserIDFunc: noopUpdateID,
|
||||
}
|
||||
mockIDP := &idp.MockIDP{
|
||||
GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) {
|
||||
return map[string][]*idp.UserData{
|
||||
"acc-1": {
|
||||
{ID: "user-1", Email: "u1@example.com", Name: "User 1"},
|
||||
{ID: "user-2", Email: "u2@example.com", Name: "User 2"},
|
||||
},
|
||||
"acc-2": {
|
||||
{ID: "user-3", Email: "u3@example.com", Name: "User 3"},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
srv := &testServer{store: ms}
|
||||
err := PopulateUserInfo(srv, mockIDP, false)
|
||||
assert.NoError(t, err)
|
||||
require.Len(t, ms.updateInfoCalls, 2)
|
||||
assert.Equal(t, "user-1", ms.updateInfoCalls[0].UserID)
|
||||
assert.Equal(t, "u1@example.com", ms.updateInfoCalls[0].Email)
|
||||
assert.Equal(t, "user-3", ms.updateInfoCalls[1].UserID)
|
||||
assert.Equal(t, "u3@example.com", ms.updateInfoCalls[1].Email)
|
||||
})
|
||||
|
||||
t.Run("returns error when UpdateUserInfo fails", func(t *testing.T) {
|
||||
ms := &testStore{
|
||||
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
|
||||
return []*types.User{
|
||||
{Id: "user-1", AccountID: "acc-1", Email: "", Name: ""},
|
||||
}, nil
|
||||
},
|
||||
updateUserIDFunc: noopUpdateID,
|
||||
updateUserInfoFunc: func(ctx context.Context, userID, email, name string) error {
|
||||
return fmt.Errorf("db write error")
|
||||
},
|
||||
}
|
||||
mockIDP := &idp.MockIDP{
|
||||
GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) {
|
||||
return map[string][]*idp.UserData{
|
||||
"acc-1": {{ID: "user-1", Email: "u1@example.com", Name: "User 1"}},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
srv := &testServer{store: ms}
|
||||
err := PopulateUserInfo(srv, mockIDP, false)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to update user info for user-1")
|
||||
})
|
||||
|
||||
t.Run("stops on first UpdateUserInfo error", func(t *testing.T) {
|
||||
callCount := 0
|
||||
ms := &testStore{
|
||||
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
|
||||
return []*types.User{
|
||||
{Id: "user-1", AccountID: "acc-1", Email: "", Name: ""},
|
||||
{Id: "user-2", AccountID: "acc-1", Email: "", Name: ""},
|
||||
}, nil
|
||||
},
|
||||
updateUserIDFunc: noopUpdateID,
|
||||
updateUserInfoFunc: func(ctx context.Context, userID, email, name string) error {
|
||||
callCount++
|
||||
return fmt.Errorf("db write error")
|
||||
},
|
||||
}
|
||||
mockIDP := &idp.MockIDP{
|
||||
GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) {
|
||||
return map[string][]*idp.UserData{
|
||||
"acc-1": {
|
||||
{ID: "user-1", Email: "u1@example.com", Name: "U1"},
|
||||
{ID: "user-2", Email: "u2@example.com", Name: "U2"},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
srv := &testServer{store: ms}
|
||||
err := PopulateUserInfo(srv, mockIDP, false)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, 1, callCount)
|
||||
})
|
||||
|
||||
t.Run("dry run does not call UpdateUserInfo", func(t *testing.T) {
|
||||
ms := &testStore{
|
||||
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
|
||||
return []*types.User{
|
||||
{Id: "user-1", AccountID: "acc-1", Email: "", Name: ""},
|
||||
{Id: "user-2", AccountID: "acc-1", Email: "", Name: ""},
|
||||
}, nil
|
||||
},
|
||||
updateUserIDFunc: noopUpdateID,
|
||||
updateUserInfoFunc: func(ctx context.Context, userID, email, name string) error {
|
||||
t.Fatal("UpdateUserInfo should not be called in dry-run mode")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
mockIDP := &idp.MockIDP{
|
||||
GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) {
|
||||
return map[string][]*idp.UserData{
|
||||
"acc-1": {
|
||||
{ID: "user-1", Email: "u1@example.com", Name: "U1"},
|
||||
{ID: "user-2", Email: "u2@example.com", Name: "U2"},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
srv := &testServer{store: ms}
|
||||
err := PopulateUserInfo(srv, mockIDP, true)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, ms.updateInfoCalls)
|
||||
})
|
||||
|
||||
t.Run("skips user when IDP has empty email and name too", func(t *testing.T) {
|
||||
ms := &testStore{
|
||||
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
|
||||
return []*types.User{
|
||||
{Id: "user-1", AccountID: "acc-1", Email: "", Name: ""},
|
||||
}, nil
|
||||
},
|
||||
updateUserIDFunc: noopUpdateID,
|
||||
}
|
||||
mockIDP := &idp.MockIDP{
|
||||
GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) {
|
||||
return map[string][]*idp.UserData{
|
||||
"acc-1": {{ID: "user-1", Email: "", Name: ""}},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
srv := &testServer{store: ms}
|
||||
err := PopulateUserInfo(srv, mockIDP, false)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, ms.updateInfoCalls)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSchemaError_String(t *testing.T) {
|
||||
t.Run("missing table", func(t *testing.T) {
|
||||
e := SchemaError{Table: "jobs"}
|
||||
assert.Equal(t, `table "jobs" is missing`, e.String())
|
||||
})
|
||||
|
||||
t.Run("missing column", func(t *testing.T) {
|
||||
e := SchemaError{Table: "users", Column: "email"}
|
||||
assert.Equal(t, `column "email" on table "users" is missing`, e.String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestRequiredSchema(t *testing.T) {
|
||||
// Verify RequiredSchema covers all the tables touched by UpdateUserID and UpdateUserInfo.
|
||||
expectedTables := []string{
|
||||
"users",
|
||||
"personal_access_tokens",
|
||||
"peers",
|
||||
"accounts",
|
||||
"user_invites",
|
||||
"proxy_access_tokens",
|
||||
"jobs",
|
||||
}
|
||||
|
||||
schemaTableNames := make([]string, len(RequiredSchema))
|
||||
for i, s := range RequiredSchema {
|
||||
schemaTableNames[i] = s.Table
|
||||
}
|
||||
|
||||
for _, expected := range expectedTables {
|
||||
assert.Contains(t, schemaTableNames, expected, "RequiredSchema should include table %q", expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSchema_MockStore(t *testing.T) {
|
||||
t.Run("returns nil when all schema exists", func(t *testing.T) {
|
||||
ms := &testStore{
|
||||
checkSchemaFunc: func(checks []SchemaCheck) []SchemaError {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
errs := ms.CheckSchema(RequiredSchema)
|
||||
assert.Empty(t, errs)
|
||||
})
|
||||
|
||||
t.Run("returns errors for missing tables", func(t *testing.T) {
|
||||
ms := &testStore{
|
||||
checkSchemaFunc: func(checks []SchemaCheck) []SchemaError {
|
||||
return []SchemaError{
|
||||
{Table: "jobs"},
|
||||
{Table: "proxy_access_tokens"},
|
||||
}
|
||||
},
|
||||
}
|
||||
errs := ms.CheckSchema(RequiredSchema)
|
||||
require.Len(t, errs, 2)
|
||||
assert.Equal(t, "jobs", errs[0].Table)
|
||||
assert.Equal(t, "", errs[0].Column)
|
||||
assert.Equal(t, "proxy_access_tokens", errs[1].Table)
|
||||
})
|
||||
|
||||
t.Run("returns errors for missing columns", func(t *testing.T) {
|
||||
ms := &testStore{
|
||||
checkSchemaFunc: func(checks []SchemaCheck) []SchemaError {
|
||||
return []SchemaError{
|
||||
{Table: "users", Column: "email"},
|
||||
{Table: "users", Column: "name"},
|
||||
}
|
||||
},
|
||||
}
|
||||
errs := ms.CheckSchema(RequiredSchema)
|
||||
require.Len(t, errs, 2)
|
||||
assert.Equal(t, "users", errs[0].Table)
|
||||
assert.Equal(t, "email", errs[0].Column)
|
||||
})
|
||||
}
|
||||
82
management/server/idp/migration/store.go
Normal file
82
management/server/idp/migration/store.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
)
|
||||
|
||||
// SchemaCheck represents a table and the columns required on it.
|
||||
type SchemaCheck struct {
|
||||
Table string
|
||||
Columns []string
|
||||
}
|
||||
|
||||
// RequiredSchema lists all tables and columns that the migration tool needs.
|
||||
// If any are missing, the user must upgrade their management server first so
|
||||
// that the automatic GORM migrations create them.
|
||||
var RequiredSchema = []SchemaCheck{
|
||||
{Table: "users", Columns: []string{"id", "email", "name", "account_id"}},
|
||||
{Table: "personal_access_tokens", Columns: []string{"user_id", "created_by"}},
|
||||
{Table: "peers", Columns: []string{"user_id"}},
|
||||
{Table: "accounts", Columns: []string{"created_by"}},
|
||||
{Table: "user_invites", Columns: []string{"created_by"}},
|
||||
{Table: "proxy_access_tokens", Columns: []string{"created_by"}},
|
||||
{Table: "jobs", Columns: []string{"triggered_by"}},
|
||||
}
|
||||
|
||||
// SchemaError describes a single missing table or column.
|
||||
type SchemaError struct {
|
||||
Table string
|
||||
Column string // empty when the whole table is missing
|
||||
}
|
||||
|
||||
func (e SchemaError) String() string {
|
||||
if e.Column == "" {
|
||||
return fmt.Sprintf("table %q is missing", e.Table)
|
||||
}
|
||||
return fmt.Sprintf("column %q on table %q is missing", e.Column, e.Table)
|
||||
}
|
||||
|
||||
// Store defines the data store operations required for IdP user migration.
|
||||
// This interface is separate from the main store.Store interface because these methods
|
||||
// are only used during one-time migration and should be removed once migration tooling
|
||||
// is no longer needed.
|
||||
//
|
||||
// The SQL store implementations (SqlStore) already have these methods on their concrete
|
||||
// types, so they satisfy this interface via Go's structural typing with zero code changes.
|
||||
type Store interface {
|
||||
// ListUsers returns all users across all accounts.
|
||||
ListUsers(ctx context.Context) ([]*types.User, error)
|
||||
|
||||
// UpdateUserID atomically updates a user's ID and all foreign key references
|
||||
// across the database (peers, groups, policies, PATs, etc.).
|
||||
UpdateUserID(ctx context.Context, accountID, oldUserID, newUserID string) error
|
||||
|
||||
// UpdateUserInfo updates a user's email and name in the store.
|
||||
UpdateUserInfo(ctx context.Context, userID, email, name string) error
|
||||
|
||||
// CheckSchema verifies that all tables and columns required by the migration
|
||||
// exist in the database. Returns a list of problems; an empty slice means OK.
|
||||
CheckSchema(checks []SchemaCheck) []SchemaError
|
||||
}
|
||||
|
||||
// RequiredEventSchema lists all tables and columns that the migration tool needs
|
||||
// in the activity/event store.
|
||||
var RequiredEventSchema = []SchemaCheck{
|
||||
{Table: "events", Columns: []string{"initiator_id", "target_id"}},
|
||||
{Table: "deleted_users", Columns: []string{"id"}},
|
||||
}
|
||||
|
||||
// EventStore defines the activity event store operations required for migration.
|
||||
// Like Store, this is a temporary interface for migration tooling only.
|
||||
type EventStore interface {
|
||||
// CheckSchema verifies that all tables and columns required by the migration
|
||||
// exist in the event database. Returns a list of problems; an empty slice means OK.
|
||||
CheckSchema(checks []SchemaCheck) []SchemaError
|
||||
|
||||
// UpdateUserID updates all event references (initiator_id, target_id) and
|
||||
// deleted_users records to use the new user ID format.
|
||||
UpdateUserID(ctx context.Context, oldUserID, newUserID string) error
|
||||
}
|
||||
@@ -28,7 +28,13 @@ func NewChannel() *Channel {
|
||||
return jc
|
||||
}
|
||||
|
||||
func (jc *Channel) AddEvent(ctx context.Context, responseWait time.Duration, event *Event) error {
|
||||
func (jc *Channel) AddEvent(ctx context.Context, responseWait time.Duration, event *Event) (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = ErrJobChannelClosed
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
|
||||
128
management/server/store/sql_store_idp_migration.go
Normal file
128
management/server/store/sql_store_idp_migration.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package store
|
||||
|
||||
// This file contains migration-only methods on SqlStore.
|
||||
// They satisfy the migration.Store interface via duck typing.
|
||||
// Delete this file when migration tooling is no longer needed.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/idp/migration"
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
func (s *SqlStore) CheckSchema(checks []migration.SchemaCheck) []migration.SchemaError {
|
||||
migrator := s.db.Migrator()
|
||||
var errs []migration.SchemaError
|
||||
|
||||
for _, check := range checks {
|
||||
if !migrator.HasTable(check.Table) {
|
||||
errs = append(errs, migration.SchemaError{Table: check.Table})
|
||||
continue
|
||||
}
|
||||
for _, col := range check.Columns {
|
||||
if !migrator.HasColumn(check.Table, col) {
|
||||
errs = append(errs, migration.SchemaError{Table: check.Table, Column: col})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
func (s *SqlStore) ListUsers(ctx context.Context) ([]*types.User, error) {
|
||||
tx := s.db
|
||||
var users []*types.User
|
||||
result := tx.Find(&users)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("error when listing users from the store: %s", result.Error)
|
||||
return nil, status.Errorf(status.Internal, "issue listing users from store")
|
||||
}
|
||||
|
||||
for _, user := range users {
|
||||
if err := user.DecryptSensitiveData(s.fieldEncrypt); err != nil {
|
||||
return nil, fmt.Errorf("decrypt user: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// txDeferFKConstraints defers foreign key constraint checks for the duration of the transaction.
|
||||
// MySQL is already handled by s.transaction (SET FOREIGN_KEY_CHECKS = 0).
|
||||
func (s *SqlStore) txDeferFKConstraints(tx *gorm.DB) error {
|
||||
switch s.storeEngine {
|
||||
case types.PostgresStoreEngine:
|
||||
return tx.Exec("SET CONSTRAINTS ALL DEFERRED").Error
|
||||
case types.SqliteStoreEngine:
|
||||
return tx.Exec("PRAGMA defer_foreign_keys = ON").Error
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SqlStore) UpdateUserInfo(ctx context.Context, userID, email, name string) error {
|
||||
user := &types.User{Email: email, Name: name}
|
||||
if err := user.EncryptSensitiveData(s.fieldEncrypt); err != nil {
|
||||
return fmt.Errorf("encrypt user info: %w", err)
|
||||
}
|
||||
|
||||
result := s.db.Model(&types.User{}).Where("id = ?", userID).Updates(map[string]interface{}{
|
||||
"email": user.Email,
|
||||
"name": user.Name,
|
||||
})
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("error updating user info for %s: %s", userID, result.Error)
|
||||
return status.Errorf(status.Internal, "failed to update user info")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) UpdateUserID(ctx context.Context, accountID, oldUserID, newUserID string) error {
|
||||
type fkUpdate struct {
|
||||
model any
|
||||
column string
|
||||
where string
|
||||
}
|
||||
|
||||
updates := []fkUpdate{
|
||||
{&types.PersonalAccessToken{}, "user_id", "user_id = ?"},
|
||||
{&types.PersonalAccessToken{}, "created_by", "created_by = ?"},
|
||||
{&nbpeer.Peer{}, "user_id", "user_id = ?"},
|
||||
{&types.UserInviteRecord{}, "created_by", "created_by = ?"},
|
||||
{&types.Account{}, "created_by", "created_by = ?"},
|
||||
{&types.ProxyAccessToken{}, "created_by", "created_by = ?"},
|
||||
{&types.Job{}, "triggered_by", "triggered_by = ?"},
|
||||
}
|
||||
|
||||
err := s.transaction(func(tx *gorm.DB) error {
|
||||
if err := s.txDeferFKConstraints(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, u := range updates {
|
||||
if err := tx.Model(u.model).Where(u.where, oldUserID).Update(u.column, newUserID).Error; err != nil {
|
||||
return fmt.Errorf("update %s: %w", u.column, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Model(&types.User{}).Where(accountAndIDQueryCondition, accountID, oldUserID).Update("id", newUserID).Error; err != nil {
|
||||
return fmt.Errorf("update users: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to update user ID in the store: %s", err)
|
||||
return status.Errorf(status.Internal, "failed to update user ID in store")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -368,7 +368,7 @@ func (a *Account) getPeersGroupsPoliciesRoutes(
|
||||
func (a *Account) getPeersFromGroups(ctx context.Context, groups []string, peerID string, sourcePostureChecksIDs []string,
|
||||
validatedPeersMap map[string]struct{}, postureFailedPeers *map[string]map[string]struct{}) ([]string, bool) {
|
||||
peerInGroups := false
|
||||
filteredPeerIDs := make([]string, 0, len(a.Peers))
|
||||
filteredPeerIDs := make([]string, 0, len(groups))
|
||||
seenPeerIds := make(map[string]struct{}, len(groups))
|
||||
|
||||
for _, gid := range groups {
|
||||
@@ -378,7 +378,7 @@ func (a *Account) getPeersFromGroups(ctx context.Context, groups []string, peerI
|
||||
}
|
||||
|
||||
if group.IsGroupAll() || len(groups) == 1 {
|
||||
filteredPeerIDs = filteredPeerIDs[:0]
|
||||
filteredPeerIDs = make([]string, 0, len(group.Peers))
|
||||
peerInGroups = false
|
||||
for _, pid := range group.Peers {
|
||||
peer, ok := a.Peers[pid]
|
||||
|
||||
@@ -152,6 +152,8 @@ func (n *Network) CurrentSerial() uint64 {
|
||||
}
|
||||
|
||||
func (n *Network) Copy() *Network {
|
||||
n.Mu.Lock()
|
||||
defer n.Mu.Unlock()
|
||||
return &Network{
|
||||
Identifier: n.Identifier,
|
||||
Net: n.Net,
|
||||
|
||||
@@ -134,7 +134,7 @@ func (c *NetworkMapComponents) Calculate(ctx context.Context) *NetworkMap {
|
||||
sourcePeers,
|
||||
)
|
||||
|
||||
dnsManagementStatus := c.getPeerDNSManagementStatus(targetPeerID)
|
||||
dnsManagementStatus := c.getPeerDNSManagementStatusFromGroups(peerGroups)
|
||||
dnsUpdate := nbdns.Config{
|
||||
ServiceEnable: dnsManagementStatus,
|
||||
}
|
||||
@@ -152,7 +152,7 @@ func (c *NetworkMapComponents) Calculate(ctx context.Context) *NetworkMap {
|
||||
customZones = append(customZones, c.AccountZones...)
|
||||
|
||||
dnsUpdate.CustomZones = customZones
|
||||
dnsUpdate.NameServerGroups = c.getPeerNSGroups(targetPeerID)
|
||||
dnsUpdate.NameServerGroups = c.getPeerNSGroupsFromGroups(targetPeerID, peerGroups)
|
||||
}
|
||||
|
||||
return &NetworkMap{
|
||||
@@ -278,6 +278,16 @@ func (c *NetworkMapComponents) connResourcesGenerator(targetPeer *nbpeer.Peer) (
|
||||
peers := make([]*nbpeer.Peer, 0)
|
||||
|
||||
return func(rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int) {
|
||||
protocol := rule.Protocol
|
||||
if protocol == PolicyRuleProtocolNetbirdSSH {
|
||||
protocol = PolicyRuleProtocolTCP
|
||||
}
|
||||
|
||||
protocolStr := string(protocol)
|
||||
actionStr := string(rule.Action)
|
||||
dirStr := strconv.Itoa(direction)
|
||||
portsJoined := strings.Join(rule.Ports, ",")
|
||||
|
||||
for _, peer := range groupPeers {
|
||||
if peer == nil {
|
||||
continue
|
||||
@@ -288,21 +298,18 @@ func (c *NetworkMapComponents) connResourcesGenerator(targetPeer *nbpeer.Peer) (
|
||||
peersExists[peer.ID] = struct{}{}
|
||||
}
|
||||
|
||||
protocol := rule.Protocol
|
||||
if protocol == PolicyRuleProtocolNetbirdSSH {
|
||||
protocol = PolicyRuleProtocolTCP
|
||||
}
|
||||
peerIP := net.IP(peer.IP).String()
|
||||
|
||||
fr := FirewallRule{
|
||||
PolicyID: rule.ID,
|
||||
PeerIP: net.IP(peer.IP).String(),
|
||||
PeerIP: peerIP,
|
||||
Direction: direction,
|
||||
Action: string(rule.Action),
|
||||
Protocol: string(protocol),
|
||||
Action: actionStr,
|
||||
Protocol: protocolStr,
|
||||
}
|
||||
|
||||
ruleID := rule.ID + fr.PeerIP + strconv.Itoa(direction) +
|
||||
fr.Protocol + fr.Action + strings.Join(rule.Ports, ",")
|
||||
ruleID := rule.ID + peerIP + dirStr +
|
||||
protocolStr + actionStr + portsJoined
|
||||
if _, ok := rulesExists[ruleID]; ok {
|
||||
continue
|
||||
}
|
||||
@@ -313,13 +320,7 @@ func (c *NetworkMapComponents) connResourcesGenerator(targetPeer *nbpeer.Peer) (
|
||||
continue
|
||||
}
|
||||
|
||||
rules = append(rules, expandPortsAndRanges(fr, &PolicyRule{
|
||||
ID: rule.ID,
|
||||
Ports: rule.Ports,
|
||||
PortRanges: rule.PortRanges,
|
||||
Protocol: rule.Protocol,
|
||||
Action: rule.Action,
|
||||
}, targetPeer)...)
|
||||
rules = append(rules, expandPortsAndRanges(fr, rule, targetPeer)...)
|
||||
}
|
||||
}, func() ([]*nbpeer.Peer, []*FirewallRule) {
|
||||
return peers, rules
|
||||
@@ -395,7 +396,7 @@ func (c *NetworkMapComponents) getPeerFromResource(resource Resource, peerID str
|
||||
}
|
||||
|
||||
func (c *NetworkMapComponents) filterPeersByLoginExpiration(aclPeers []*nbpeer.Peer) ([]*nbpeer.Peer, []*nbpeer.Peer) {
|
||||
var peersToConnect []*nbpeer.Peer
|
||||
peersToConnect := make([]*nbpeer.Peer, 0, len(aclPeers))
|
||||
var expiredPeers []*nbpeer.Peer
|
||||
|
||||
for _, p := range aclPeers {
|
||||
@@ -410,35 +411,35 @@ func (c *NetworkMapComponents) filterPeersByLoginExpiration(aclPeers []*nbpeer.P
|
||||
return peersToConnect, expiredPeers
|
||||
}
|
||||
|
||||
func (c *NetworkMapComponents) getPeerDNSManagementStatus(peerID string) bool {
|
||||
peerGroups := c.GetPeerGroups(peerID)
|
||||
enabled := true
|
||||
func (c *NetworkMapComponents) getPeerDNSManagementStatusFromGroups(peerGroups map[string]struct{}) bool {
|
||||
for _, groupID := range c.DNSSettings.DisabledManagementGroups {
|
||||
if _, found := peerGroups[groupID]; found {
|
||||
enabled = false
|
||||
break
|
||||
return false
|
||||
}
|
||||
}
|
||||
return enabled
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *NetworkMapComponents) getPeerNSGroups(peerID string) []*nbdns.NameServerGroup {
|
||||
groupList := c.GetPeerGroups(peerID)
|
||||
|
||||
func (c *NetworkMapComponents) getPeerNSGroupsFromGroups(peerID string, groupList map[string]struct{}) []*nbdns.NameServerGroup {
|
||||
var peerNSGroups []*nbdns.NameServerGroup
|
||||
|
||||
targetPeerInfo := c.GetPeerInfo(peerID)
|
||||
if targetPeerInfo == nil {
|
||||
return peerNSGroups
|
||||
}
|
||||
|
||||
peerIPStr := targetPeerInfo.IP.String()
|
||||
|
||||
for _, nsGroup := range c.NameServerGroups {
|
||||
if !nsGroup.Enabled {
|
||||
continue
|
||||
}
|
||||
for _, gID := range nsGroup.Groups {
|
||||
_, found := groupList[gID]
|
||||
if found {
|
||||
targetPeerInfo := c.GetPeerInfo(peerID)
|
||||
if targetPeerInfo != nil && !c.peerIsNameserver(targetPeerInfo, nsGroup) {
|
||||
if _, found := groupList[gID]; found {
|
||||
if !c.peerIsNameserver(peerIPStr, nsGroup) {
|
||||
peerNSGroups = append(peerNSGroups, nsGroup.Copy())
|
||||
break
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -446,9 +447,9 @@ func (c *NetworkMapComponents) getPeerNSGroups(peerID string) []*nbdns.NameServe
|
||||
return peerNSGroups
|
||||
}
|
||||
|
||||
func (c *NetworkMapComponents) peerIsNameserver(peerInfo *nbpeer.Peer, nsGroup *nbdns.NameServerGroup) bool {
|
||||
func (c *NetworkMapComponents) peerIsNameserver(peerIPStr string, nsGroup *nbdns.NameServerGroup) bool {
|
||||
for _, ns := range nsGroup.NameServers {
|
||||
if peerInfo.IP.String() == ns.IP.String() {
|
||||
if peerIPStr == ns.IP.String() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -489,14 +490,13 @@ func (c *NetworkMapComponents) getRoutingPeerRoutes(peerID string) (enabledRoute
|
||||
}
|
||||
seenRoute[r.ID] = struct{}{}
|
||||
|
||||
routeObj := c.copyRoute(r)
|
||||
routeObj.Peer = peerInfo.Key
|
||||
r.Peer = peerInfo.Key
|
||||
|
||||
if r.Enabled {
|
||||
enabledRoutes = append(enabledRoutes, routeObj)
|
||||
enabledRoutes = append(enabledRoutes, r)
|
||||
return
|
||||
}
|
||||
disabledRoutes = append(disabledRoutes, routeObj)
|
||||
disabledRoutes = append(disabledRoutes, r)
|
||||
}
|
||||
|
||||
for _, r := range c.Routes {
|
||||
@@ -510,7 +510,7 @@ func (c *NetworkMapComponents) getRoutingPeerRoutes(peerID string) (enabledRoute
|
||||
continue
|
||||
}
|
||||
|
||||
newPeerRoute := c.copyRoute(r)
|
||||
newPeerRoute := r.Copy()
|
||||
newPeerRoute.Peer = id
|
||||
newPeerRoute.PeerGroups = nil
|
||||
newPeerRoute.ID = route.ID(string(r.ID) + ":" + id)
|
||||
@@ -519,50 +519,13 @@ func (c *NetworkMapComponents) getRoutingPeerRoutes(peerID string) (enabledRoute
|
||||
}
|
||||
}
|
||||
if r.Peer == peerID {
|
||||
takeRoute(c.copyRoute(r))
|
||||
takeRoute(r.Copy())
|
||||
}
|
||||
}
|
||||
|
||||
return enabledRoutes, disabledRoutes
|
||||
}
|
||||
|
||||
func (c *NetworkMapComponents) copyRoute(r *route.Route) *route.Route {
|
||||
var groups, accessControlGroups, peerGroups []string
|
||||
var domains domain.List
|
||||
|
||||
if r.Groups != nil {
|
||||
groups = append([]string{}, r.Groups...)
|
||||
}
|
||||
if r.AccessControlGroups != nil {
|
||||
accessControlGroups = append([]string{}, r.AccessControlGroups...)
|
||||
}
|
||||
if r.PeerGroups != nil {
|
||||
peerGroups = append([]string{}, r.PeerGroups...)
|
||||
}
|
||||
if r.Domains != nil {
|
||||
domains = append(domain.List{}, r.Domains...)
|
||||
}
|
||||
|
||||
return &route.Route{
|
||||
ID: r.ID,
|
||||
AccountID: r.AccountID,
|
||||
Network: r.Network,
|
||||
NetworkType: r.NetworkType,
|
||||
Description: r.Description,
|
||||
Peer: r.Peer,
|
||||
PeerID: r.PeerID,
|
||||
Metric: r.Metric,
|
||||
Masquerade: r.Masquerade,
|
||||
NetID: r.NetID,
|
||||
Enabled: r.Enabled,
|
||||
Groups: groups,
|
||||
AccessControlGroups: accessControlGroups,
|
||||
PeerGroups: peerGroups,
|
||||
Domains: domains,
|
||||
KeepRoute: r.KeepRoute,
|
||||
SkipAutoApply: r.SkipAutoApply,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *NetworkMapComponents) filterRoutesByGroups(routes []*route.Route, groupListMap LookupMap) []*route.Route {
|
||||
var filteredRoutes []*route.Route
|
||||
|
||||
@@ -53,6 +53,7 @@ var (
|
||||
certFile string
|
||||
certKeyFile string
|
||||
certLockMethod string
|
||||
wildcardCertDir string
|
||||
wgPort int
|
||||
proxyProtocol bool
|
||||
preSharedKey string
|
||||
@@ -87,6 +88,7 @@ func init() {
|
||||
rootCmd.Flags().StringVar(&certFile, "cert-file", envStringOrDefault("NB_PROXY_CERTIFICATE_FILE", "tls.crt"), "TLS certificate filename within the certificate directory")
|
||||
rootCmd.Flags().StringVar(&certKeyFile, "cert-key-file", envStringOrDefault("NB_PROXY_CERTIFICATE_KEY_FILE", "tls.key"), "TLS certificate key filename within the certificate directory")
|
||||
rootCmd.Flags().StringVar(&certLockMethod, "cert-lock-method", envStringOrDefault("NB_PROXY_CERT_LOCK_METHOD", "auto"), "Certificate lock method for cross-replica coordination: auto, flock, or k8s-lease")
|
||||
rootCmd.Flags().StringVar(&wildcardCertDir, "wildcard-cert-dir", envStringOrDefault("NB_PROXY_WILDCARD_CERT_DIR", ""), "Directory containing wildcard certificate pairs (<name>.crt/<name>.key). Wildcard patterns are extracted from SANs automatically")
|
||||
rootCmd.Flags().IntVar(&wgPort, "wg-port", envIntOrDefault("NB_PROXY_WG_PORT", 0), "WireGuard listen port (0 = random). Fixed port only works with single-account deployments")
|
||||
rootCmd.Flags().BoolVar(&proxyProtocol, "proxy-protocol", envBoolOrDefault("NB_PROXY_PROXY_PROTOCOL", false), "Enable PROXY protocol on TCP listeners to preserve client IPs behind L4 proxies")
|
||||
rootCmd.Flags().StringVar(&preSharedKey, "preshared-key", envStringOrDefault("NB_PROXY_PRESHARED_KEY", ""), "Define a pre-shared key for the tunnel between proxy and peers")
|
||||
@@ -162,6 +164,7 @@ func runServer(cmd *cobra.Command, args []string) error {
|
||||
ForwardedProto: forwardedProto,
|
||||
TrustedProxies: parsedTrustedProxies,
|
||||
CertLockMethod: nbacme.CertLockMethod(certLockMethod),
|
||||
WildcardCertDir: wildcardCertDir,
|
||||
WireguardPort: wgPort,
|
||||
ProxyProtocol: proxyProtocol,
|
||||
PreSharedKey: preSharedKey,
|
||||
|
||||
@@ -3,6 +3,7 @@ package accesslog
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -13,6 +14,23 @@ import (
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
const (
|
||||
requestThreshold = 10000 // Log every 10k requests
|
||||
bytesThreshold = 1024 * 1024 * 1024 // Log every 1GB
|
||||
usageCleanupPeriod = 1 * time.Hour // Clean up stale counters every hour
|
||||
usageInactiveWindow = 24 * time.Hour // Consider domain inactive if no traffic for 24 hours
|
||||
)
|
||||
|
||||
type domainUsage struct {
|
||||
requestCount int64
|
||||
requestStartTime time.Time
|
||||
|
||||
bytesTransferred int64
|
||||
bytesStartTime time.Time
|
||||
|
||||
lastActivity time.Time // Track last activity for cleanup
|
||||
}
|
||||
|
||||
type gRPCClient interface {
|
||||
SendAccessLog(ctx context.Context, in *proto.SendAccessLogRequest, opts ...grpc.CallOption) (*proto.SendAccessLogResponse, error)
|
||||
}
|
||||
@@ -22,6 +40,11 @@ type Logger struct {
|
||||
client gRPCClient
|
||||
logger *log.Logger
|
||||
trustedProxies []netip.Prefix
|
||||
|
||||
usageMux sync.Mutex
|
||||
domainUsage map[string]*domainUsage
|
||||
|
||||
cleanupCancel context.CancelFunc
|
||||
}
|
||||
|
||||
// NewLogger creates a new access log Logger. The trustedProxies parameter
|
||||
@@ -31,10 +54,26 @@ func NewLogger(client gRPCClient, logger *log.Logger, trustedProxies []netip.Pre
|
||||
if logger == nil {
|
||||
logger = log.StandardLogger()
|
||||
}
|
||||
return &Logger{
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
l := &Logger{
|
||||
client: client,
|
||||
logger: logger,
|
||||
trustedProxies: trustedProxies,
|
||||
domainUsage: make(map[string]*domainUsage),
|
||||
cleanupCancel: cancel,
|
||||
}
|
||||
|
||||
// Start background cleanup routine
|
||||
go l.cleanupStaleUsage(ctx)
|
||||
|
||||
return l
|
||||
}
|
||||
|
||||
// Close stops the cleanup routine. Should be called during graceful shutdown.
|
||||
func (l *Logger) Close() {
|
||||
if l.cleanupCancel != nil {
|
||||
l.cleanupCancel()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +90,8 @@ type logEntry struct {
|
||||
AuthMechanism string
|
||||
UserId string
|
||||
AuthSuccess bool
|
||||
BytesUpload int64
|
||||
BytesDownload int64
|
||||
}
|
||||
|
||||
func (l *Logger) log(ctx context.Context, entry logEntry) {
|
||||
@@ -84,6 +125,8 @@ func (l *Logger) log(ctx context.Context, entry logEntry) {
|
||||
AuthMechanism: entry.AuthMechanism,
|
||||
UserId: entry.UserId,
|
||||
AuthSuccess: entry.AuthSuccess,
|
||||
BytesUpload: entry.BytesUpload,
|
||||
BytesDownload: entry.BytesDownload,
|
||||
},
|
||||
}); err != nil {
|
||||
// If it fails to send on the gRPC connection, then at least log it to the error log.
|
||||
@@ -103,3 +146,82 @@ func (l *Logger) log(ctx context.Context, entry logEntry) {
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// trackUsage records request and byte counts per domain, logging when thresholds are hit.
|
||||
func (l *Logger) trackUsage(domain string, bytesTransferred int64) {
|
||||
if domain == "" {
|
||||
return
|
||||
}
|
||||
|
||||
l.usageMux.Lock()
|
||||
defer l.usageMux.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
usage, exists := l.domainUsage[domain]
|
||||
if !exists {
|
||||
usage = &domainUsage{
|
||||
requestStartTime: now,
|
||||
bytesStartTime: now,
|
||||
lastActivity: now,
|
||||
}
|
||||
l.domainUsage[domain] = usage
|
||||
}
|
||||
|
||||
usage.lastActivity = now
|
||||
|
||||
usage.requestCount++
|
||||
if usage.requestCount >= requestThreshold {
|
||||
elapsed := time.Since(usage.requestStartTime)
|
||||
l.logger.WithFields(log.Fields{
|
||||
"domain": domain,
|
||||
"requests": usage.requestCount,
|
||||
"duration": elapsed.String(),
|
||||
}).Infof("domain %s had %d requests over %s", domain, usage.requestCount, elapsed)
|
||||
|
||||
usage.requestCount = 0
|
||||
usage.requestStartTime = now
|
||||
}
|
||||
|
||||
usage.bytesTransferred += bytesTransferred
|
||||
if usage.bytesTransferred >= bytesThreshold {
|
||||
elapsed := time.Since(usage.bytesStartTime)
|
||||
bytesInGB := float64(usage.bytesTransferred) / (1024 * 1024 * 1024)
|
||||
l.logger.WithFields(log.Fields{
|
||||
"domain": domain,
|
||||
"bytes": usage.bytesTransferred,
|
||||
"bytes_gb": bytesInGB,
|
||||
"duration": elapsed.String(),
|
||||
}).Infof("domain %s transferred %.2f GB over %s", domain, bytesInGB, elapsed)
|
||||
|
||||
usage.bytesTransferred = 0
|
||||
usage.bytesStartTime = now
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupStaleUsage removes usage entries for domains that have been inactive.
|
||||
func (l *Logger) cleanupStaleUsage(ctx context.Context) {
|
||||
ticker := time.NewTicker(usageCleanupPeriod)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
l.usageMux.Lock()
|
||||
now := time.Now()
|
||||
removed := 0
|
||||
for domain, usage := range l.domainUsage {
|
||||
if now.Sub(usage.lastActivity) > usageInactiveWindow {
|
||||
delete(l.domainUsage, domain)
|
||||
removed++
|
||||
}
|
||||
}
|
||||
l.usageMux.Unlock()
|
||||
|
||||
if removed > 0 {
|
||||
l.logger.Debugf("cleaned up %d stale domain usage entries", removed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,14 @@ func (l *Logger) Middleware(next http.Handler) http.Handler {
|
||||
status: http.StatusOK,
|
||||
}
|
||||
|
||||
var bytesRead int64
|
||||
if r.Body != nil {
|
||||
r.Body = &bodyCounter{
|
||||
ReadCloser: r.Body,
|
||||
bytesRead: &bytesRead,
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the source IP using trusted proxy configuration before passing
|
||||
// the request on, as the proxy will modify forwarding headers.
|
||||
sourceIp := extractSourceIP(r, l.trustedProxies)
|
||||
@@ -53,6 +61,9 @@ func (l *Logger) Middleware(next http.Handler) http.Handler {
|
||||
host = r.Host
|
||||
}
|
||||
|
||||
bytesUpload := bytesRead
|
||||
bytesDownload := sw.bytesWritten
|
||||
|
||||
entry := logEntry{
|
||||
ID: requestID,
|
||||
ServiceId: capturedData.GetServiceId(),
|
||||
@@ -66,10 +77,15 @@ func (l *Logger) Middleware(next http.Handler) http.Handler {
|
||||
AuthMechanism: capturedData.GetAuthMethod(),
|
||||
UserId: capturedData.GetUserID(),
|
||||
AuthSuccess: sw.status != http.StatusUnauthorized && sw.status != http.StatusForbidden,
|
||||
BytesUpload: bytesUpload,
|
||||
BytesDownload: bytesDownload,
|
||||
}
|
||||
l.logger.Debugf("response: request_id=%s method=%s host=%s path=%s status=%d duration=%dms source=%s origin=%s service=%s account=%s",
|
||||
requestID, r.Method, host, r.URL.Path, sw.status, duration.Milliseconds(), sourceIp, capturedData.GetOrigin(), capturedData.GetServiceId(), capturedData.GetAccountId())
|
||||
|
||||
l.log(r.Context(), entry)
|
||||
|
||||
// Track usage for cost monitoring (upload + download) by domain
|
||||
l.trackUsage(host, bytesUpload+bytesDownload)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,18 +1,39 @@
|
||||
package accesslog
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/netbirdio/netbird/proxy/internal/responsewriter"
|
||||
)
|
||||
|
||||
// statusWriter captures the HTTP status code from WriteHeader calls.
|
||||
// statusWriter captures the HTTP status code and bytes written from responses.
|
||||
// It embeds responsewriter.PassthroughWriter which handles all the optional
|
||||
// interfaces (Hijacker, Flusher, Pusher) automatically.
|
||||
type statusWriter struct {
|
||||
*responsewriter.PassthroughWriter
|
||||
status int
|
||||
status int
|
||||
bytesWritten int64
|
||||
}
|
||||
|
||||
func (w *statusWriter) WriteHeader(status int) {
|
||||
w.status = status
|
||||
w.PassthroughWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
func (w *statusWriter) Write(b []byte) (int, error) {
|
||||
n, err := w.PassthroughWriter.Write(b)
|
||||
w.bytesWritten += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// bodyCounter wraps an io.ReadCloser and counts bytes read from the request body.
|
||||
type bodyCounter struct {
|
||||
io.ReadCloser
|
||||
bytesRead *int64
|
||||
}
|
||||
|
||||
func (bc *bodyCounter) Read(p []byte) (int, error) {
|
||||
n, err := bc.ReadCloser.Read(p)
|
||||
*bc.bytesRead += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
@@ -7,9 +7,14 @@ import (
|
||||
"encoding/asn1"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -17,6 +22,7 @@ import (
|
||||
"golang.org/x/crypto/acme"
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
|
||||
"github.com/netbirdio/netbird/proxy/internal/certwatch"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
)
|
||||
|
||||
@@ -42,6 +48,38 @@ type domainInfo struct {
|
||||
err string
|
||||
}
|
||||
|
||||
type metricsRecorder interface {
|
||||
RecordCertificateIssuance(duration time.Duration)
|
||||
}
|
||||
|
||||
// wildcardEntry maps a domain suffix (e.g. ".example.com") to a certwatch
|
||||
// watcher that hot-reloads the corresponding wildcard certificate from disk.
|
||||
type wildcardEntry struct {
|
||||
suffix string // e.g. ".example.com"
|
||||
pattern string // e.g. "*.example.com"
|
||||
watcher *certwatch.Watcher
|
||||
}
|
||||
|
||||
// ManagerConfig holds the configuration values for the ACME certificate manager.
|
||||
type ManagerConfig struct {
|
||||
// CertDir is the directory used for caching ACME certificates.
|
||||
CertDir string
|
||||
// ACMEURL is the ACME directory URL (e.g. Let's Encrypt).
|
||||
ACMEURL string
|
||||
// EABKID and EABHMACKey are optional External Account Binding credentials
|
||||
// required by some CAs (e.g. ZeroSSL). EABHMACKey is the base64
|
||||
// URL-encoded string provided by the CA.
|
||||
EABKID string
|
||||
EABHMACKey string
|
||||
// LockMethod controls the cross-replica coordination strategy.
|
||||
LockMethod CertLockMethod
|
||||
// WildcardDir is an optional path to a directory containing wildcard
|
||||
// certificate pairs (<name>.crt / <name>.key). Wildcard patterns are
|
||||
// extracted from the certificates' SAN lists. Domains matching a
|
||||
// wildcard are served from disk; all others go through ACME.
|
||||
WildcardDir string
|
||||
}
|
||||
|
||||
// Manager wraps autocert.Manager with domain tracking and cross-replica
|
||||
// coordination via a pluggable locking strategy. The locker prevents
|
||||
// duplicate ACME requests when multiple replicas share a certificate cache.
|
||||
@@ -53,52 +91,182 @@ type Manager struct {
|
||||
mu sync.RWMutex
|
||||
domains map[domain.Domain]*domainInfo
|
||||
|
||||
// wildcards holds all loaded wildcard certificates, keyed by suffix.
|
||||
wildcards []wildcardEntry
|
||||
|
||||
certNotifier certificateNotifier
|
||||
logger *log.Logger
|
||||
metrics metricsRecorder
|
||||
}
|
||||
|
||||
// NewManager creates a new ACME certificate manager. The certDir is used
|
||||
// for caching certificates. The lockMethod controls cross-replica
|
||||
// coordination strategy (see CertLockMethod constants).
|
||||
// eabKID and eabHMACKey are optional External Account Binding credentials
|
||||
// required for some CAs like ZeroSSL. The eabHMACKey should be the base64
|
||||
// URL-encoded string provided by the CA.
|
||||
func NewManager(certDir, acmeURL, eabKID, eabHMACKey string, notifier certificateNotifier, logger *log.Logger, lockMethod CertLockMethod) *Manager {
|
||||
// NewManager creates a new ACME certificate manager.
|
||||
func NewManager(cfg ManagerConfig, notifier certificateNotifier, logger *log.Logger, metrics metricsRecorder) (*Manager, error) {
|
||||
if logger == nil {
|
||||
logger = log.StandardLogger()
|
||||
}
|
||||
mgr := &Manager{
|
||||
certDir: certDir,
|
||||
locker: newCertLocker(lockMethod, certDir, logger),
|
||||
certDir: cfg.CertDir,
|
||||
locker: newCertLocker(cfg.LockMethod, cfg.CertDir, logger),
|
||||
domains: make(map[domain.Domain]*domainInfo),
|
||||
certNotifier: notifier,
|
||||
logger: logger,
|
||||
metrics: metrics,
|
||||
}
|
||||
|
||||
if cfg.WildcardDir != "" {
|
||||
entries, err := loadWildcardDir(cfg.WildcardDir, logger)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load wildcard certificates from %q: %w", cfg.WildcardDir, err)
|
||||
}
|
||||
mgr.wildcards = entries
|
||||
}
|
||||
|
||||
var eab *acme.ExternalAccountBinding
|
||||
if eabKID != "" && eabHMACKey != "" {
|
||||
decodedKey, err := base64.RawURLEncoding.DecodeString(eabHMACKey)
|
||||
if cfg.EABKID != "" && cfg.EABHMACKey != "" {
|
||||
decodedKey, err := base64.RawURLEncoding.DecodeString(cfg.EABHMACKey)
|
||||
if err != nil {
|
||||
logger.Errorf("failed to decode EAB HMAC key: %v", err)
|
||||
} else {
|
||||
eab = &acme.ExternalAccountBinding{
|
||||
KID: eabKID,
|
||||
KID: cfg.EABKID,
|
||||
Key: decodedKey,
|
||||
}
|
||||
logger.Infof("configured External Account Binding with KID: %s", eabKID)
|
||||
logger.Infof("configured External Account Binding with KID: %s", cfg.EABKID)
|
||||
}
|
||||
}
|
||||
|
||||
mgr.Manager = &autocert.Manager{
|
||||
Prompt: autocert.AcceptTOS,
|
||||
HostPolicy: mgr.hostPolicy,
|
||||
Cache: autocert.DirCache(certDir),
|
||||
Cache: autocert.DirCache(cfg.CertDir),
|
||||
ExternalAccountBinding: eab,
|
||||
Client: &acme.Client{
|
||||
DirectoryURL: acmeURL,
|
||||
DirectoryURL: cfg.ACMEURL,
|
||||
},
|
||||
}
|
||||
return mgr
|
||||
return mgr, nil
|
||||
}
|
||||
|
||||
// WatchWildcards starts watching all wildcard certificate files for changes.
|
||||
// It blocks until ctx is cancelled. It is a no-op if no wildcards are loaded.
|
||||
func (mgr *Manager) WatchWildcards(ctx context.Context) {
|
||||
if len(mgr.wildcards) == 0 {
|
||||
return
|
||||
}
|
||||
seen := make(map[*certwatch.Watcher]struct{})
|
||||
var wg sync.WaitGroup
|
||||
for i := range mgr.wildcards {
|
||||
w := mgr.wildcards[i].watcher
|
||||
if _, ok := seen[w]; ok {
|
||||
continue
|
||||
}
|
||||
seen[w] = struct{}{}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
w.Watch(ctx)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// loadWildcardDir scans dir for .crt files, pairs each with a matching .key
|
||||
// file, loads them, and extracts wildcard SANs (*.example.com) to build
|
||||
// the suffix lookup entries.
|
||||
func loadWildcardDir(dir string, logger *log.Logger) ([]wildcardEntry, error) {
|
||||
crtFiles, err := filepath.Glob(filepath.Join(dir, "*.crt"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("glob certificate files: %w", err)
|
||||
}
|
||||
|
||||
if len(crtFiles) == 0 {
|
||||
return nil, fmt.Errorf("no .crt files found in %s", dir)
|
||||
}
|
||||
|
||||
var entries []wildcardEntry
|
||||
|
||||
for _, crtPath := range crtFiles {
|
||||
base := strings.TrimSuffix(filepath.Base(crtPath), ".crt")
|
||||
keyPath := filepath.Join(dir, base+".key")
|
||||
if _, err := os.Stat(keyPath); err != nil {
|
||||
logger.Warnf("skipping %s: no matching key file %s", crtPath, keyPath)
|
||||
continue
|
||||
}
|
||||
|
||||
watcher, err := certwatch.NewWatcher(crtPath, keyPath, logger)
|
||||
if err != nil {
|
||||
logger.Warnf("skipping %s: %v", crtPath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
leaf := watcher.Leaf()
|
||||
if leaf == nil {
|
||||
logger.Warnf("skipping %s: no parsed leaf certificate", crtPath)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, san := range leaf.DNSNames {
|
||||
suffix, ok := parseWildcard(san)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, wildcardEntry{
|
||||
suffix: suffix,
|
||||
pattern: san,
|
||||
watcher: watcher,
|
||||
})
|
||||
logger.Infof("wildcard certificate loaded: %s (from %s)", san, filepath.Base(crtPath))
|
||||
}
|
||||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
return nil, fmt.Errorf("no wildcard SANs (*.example.com) found in certificates in %s", dir)
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// parseWildcard validates a wildcard domain pattern like "*.example.com"
|
||||
// and returns the suffix ".example.com" for matching.
|
||||
func parseWildcard(pattern string) (suffix string, ok bool) {
|
||||
if !strings.HasPrefix(pattern, "*.") {
|
||||
return "", false
|
||||
}
|
||||
parent := pattern[1:] // ".example.com"
|
||||
if strings.Count(parent, ".") < 1 {
|
||||
return "", false
|
||||
}
|
||||
return strings.ToLower(parent), true
|
||||
}
|
||||
|
||||
// findWildcardEntry returns the wildcard entry that covers host, or nil.
|
||||
func (mgr *Manager) findWildcardEntry(host string) *wildcardEntry {
|
||||
if len(mgr.wildcards) == 0 {
|
||||
return nil
|
||||
}
|
||||
host = strings.ToLower(host)
|
||||
for i := range mgr.wildcards {
|
||||
e := &mgr.wildcards[i]
|
||||
if !strings.HasSuffix(host, e.suffix) {
|
||||
continue
|
||||
}
|
||||
// Single-level match: prefix before suffix must have no dots.
|
||||
prefix := strings.TrimSuffix(host, e.suffix)
|
||||
if len(prefix) > 0 && !strings.Contains(prefix, ".") {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WildcardPatterns returns the wildcard patterns that are currently loaded.
|
||||
func (mgr *Manager) WildcardPatterns() []string {
|
||||
patterns := make([]string, len(mgr.wildcards))
|
||||
for i, e := range mgr.wildcards {
|
||||
patterns[i] = e.pattern
|
||||
}
|
||||
slices.Sort(patterns)
|
||||
return patterns
|
||||
}
|
||||
|
||||
func (mgr *Manager) hostPolicy(_ context.Context, host string) error {
|
||||
@@ -114,8 +282,39 @@ func (mgr *Manager) hostPolicy(_ context.Context, host string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddDomain registers a domain for ACME certificate prefetching.
|
||||
func (mgr *Manager) AddDomain(d domain.Domain, accountID, serviceID string) {
|
||||
// GetCertificate returns the TLS certificate for the given ClientHello.
|
||||
// If the requested domain matches a loaded wildcard, the static wildcard
|
||||
// certificate is returned. Otherwise, the ACME autocert manager handles
|
||||
// the request.
|
||||
func (mgr *Manager) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
if e := mgr.findWildcardEntry(hello.ServerName); e != nil {
|
||||
return e.watcher.GetCertificate(hello)
|
||||
}
|
||||
return mgr.Manager.GetCertificate(hello)
|
||||
}
|
||||
|
||||
// AddDomain registers a domain for certificate management. Domains that
|
||||
// match a loaded wildcard are marked ready immediately (they use the
|
||||
// static wildcard certificate) and the method returns true. All other
|
||||
// domains go through ACME prefetch and the method returns false.
|
||||
//
|
||||
// When AddDomain returns true the caller is responsible for sending any
|
||||
// certificate-ready notifications after the surrounding operation (e.g.
|
||||
// mapping update) has committed successfully.
|
||||
func (mgr *Manager) AddDomain(d domain.Domain, accountID, serviceID string) (wildcardHit bool) {
|
||||
name := d.PunycodeString()
|
||||
if e := mgr.findWildcardEntry(name); e != nil {
|
||||
mgr.mu.Lock()
|
||||
mgr.domains[d] = &domainInfo{
|
||||
accountID: accountID,
|
||||
serviceID: serviceID,
|
||||
state: domainReady,
|
||||
}
|
||||
mgr.mu.Unlock()
|
||||
mgr.logger.Debugf("domain %q matches wildcard %q, using static certificate", name, e.pattern)
|
||||
return true
|
||||
}
|
||||
|
||||
mgr.mu.Lock()
|
||||
mgr.domains[d] = &domainInfo{
|
||||
accountID: accountID,
|
||||
@@ -125,13 +324,19 @@ func (mgr *Manager) AddDomain(d domain.Domain, accountID, serviceID string) {
|
||||
mgr.mu.Unlock()
|
||||
|
||||
go mgr.prefetchCertificate(d)
|
||||
return false
|
||||
}
|
||||
|
||||
// prefetchCertificate proactively triggers certificate generation for a domain.
|
||||
// It acquires a distributed lock to prevent multiple replicas from issuing
|
||||
// duplicate ACME requests. The second replica will block until the first
|
||||
// finishes, then find the certificate in the cache.
|
||||
// ACME and periodic disk reads race; whichever produces a valid certificate
|
||||
// first wins. This handles cases where locking is unreliable and another
|
||||
// replica already wrote the cert to the shared cache.
|
||||
func (mgr *Manager) prefetchCertificate(d domain.Domain) {
|
||||
time.Sleep(time.Duration(rand.IntN(200)) * time.Millisecond)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
@@ -147,22 +352,105 @@ func (mgr *Manager) prefetchCertificate(d domain.Domain) {
|
||||
defer unlock()
|
||||
}
|
||||
|
||||
hello := &tls.ClientHelloInfo{
|
||||
ServerName: name,
|
||||
Conn: &dummyConn{ctx: ctx},
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
cert, err := mgr.GetCertificate(hello)
|
||||
elapsed := time.Since(start)
|
||||
if err != nil {
|
||||
mgr.logger.Warnf("prefetch certificate for domain %q in %s: %v", name, elapsed.String(), err)
|
||||
mgr.setDomainState(d, domainFailed, err.Error())
|
||||
if cert, err := mgr.readCertFromDisk(ctx, name); err == nil {
|
||||
mgr.logger.Infof("certificate for domain %q already on disk, skipping ACME", name)
|
||||
mgr.recordAndNotify(ctx, d, name, cert, 0)
|
||||
return
|
||||
}
|
||||
|
||||
mgr.setDomainState(d, domainReady, "")
|
||||
// Run ACME in a goroutine so we can race it against periodic disk reads.
|
||||
// autocert uses its own internal context and cannot be cancelled externally.
|
||||
type acmeResult struct {
|
||||
cert *tls.Certificate
|
||||
err error
|
||||
}
|
||||
acmeCh := make(chan acmeResult, 1)
|
||||
hello := &tls.ClientHelloInfo{ServerName: name, Conn: &dummyConn{ctx: ctx}}
|
||||
go func() {
|
||||
cert, err := mgr.GetCertificate(hello)
|
||||
acmeCh <- acmeResult{cert, err}
|
||||
}()
|
||||
|
||||
start := time.Now()
|
||||
diskTicker := time.NewTicker(5 * time.Second)
|
||||
defer diskTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case res := <-acmeCh:
|
||||
elapsed := time.Since(start)
|
||||
if res.err != nil {
|
||||
mgr.logger.Warnf("prefetch certificate for domain %q in %s: %v", name, elapsed.String(), res.err)
|
||||
mgr.setDomainState(d, domainFailed, res.err.Error())
|
||||
return
|
||||
}
|
||||
mgr.recordAndNotify(ctx, d, name, res.cert, elapsed)
|
||||
return
|
||||
|
||||
case <-diskTicker.C:
|
||||
cert, err := mgr.readCertFromDisk(context.Background(), name)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
mgr.logger.Infof("certificate for domain %q appeared on disk after %s", name, time.Since(start).Round(time.Millisecond))
|
||||
// Drain the ACME goroutine before marking ready — autocert holds
|
||||
// an internal write lock on certState while ACME is in flight.
|
||||
go func() {
|
||||
select {
|
||||
case <-acmeCh:
|
||||
default:
|
||||
}
|
||||
mgr.recordAndNotify(context.Background(), d, name, cert, 0)
|
||||
}()
|
||||
return
|
||||
|
||||
case <-ctx.Done():
|
||||
mgr.logger.Warnf("prefetch certificate for domain %q timed out", name)
|
||||
mgr.setDomainState(d, domainFailed, ctx.Err().Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readCertFromDisk reads and parses a certificate directly from the autocert
|
||||
// DirCache, bypassing autocert's internal certState mutex. Safe to call
|
||||
// concurrently with an in-flight ACME request for the same domain.
|
||||
func (mgr *Manager) readCertFromDisk(ctx context.Context, name string) (*tls.Certificate, error) {
|
||||
if mgr.Cache == nil {
|
||||
return nil, fmt.Errorf("no cache configured")
|
||||
}
|
||||
data, err := mgr.Cache.Get(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
privBlock, certsPEM := pem.Decode(data)
|
||||
if privBlock == nil || !strings.Contains(privBlock.Type, "PRIVATE") {
|
||||
return nil, fmt.Errorf("no private key in cache for %q", name)
|
||||
}
|
||||
cert, err := tls.X509KeyPair(certsPEM, pem.EncodeToMemory(privBlock))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse cached certificate for %q: %w", name, err)
|
||||
}
|
||||
if len(cert.Certificate) > 0 {
|
||||
leaf, err := x509.ParseCertificate(cert.Certificate[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse leaf for %q: %w", name, err)
|
||||
}
|
||||
if time.Now().After(leaf.NotAfter) {
|
||||
return nil, fmt.Errorf("cached certificate for %q expired at %s", name, leaf.NotAfter)
|
||||
}
|
||||
cert.Leaf = leaf
|
||||
}
|
||||
return &cert, nil
|
||||
}
|
||||
|
||||
// recordAndNotify records metrics, marks the domain ready, logs cert details,
|
||||
// and notifies the cert notifier.
|
||||
func (mgr *Manager) recordAndNotify(ctx context.Context, d domain.Domain, name string, cert *tls.Certificate, elapsed time.Duration) {
|
||||
if elapsed > 0 && mgr.metrics != nil {
|
||||
mgr.metrics.RecordCertificateIssuance(elapsed)
|
||||
}
|
||||
mgr.setDomainState(d, domainReady, "")
|
||||
now := time.Now()
|
||||
if cert != nil && cert.Leaf != nil {
|
||||
leaf := cert.Leaf
|
||||
@@ -178,11 +466,9 @@ func (mgr *Manager) prefetchCertificate(d domain.Domain) {
|
||||
} else {
|
||||
mgr.logger.Infof("certificate for domain %q ready in %s", name, elapsed.Round(time.Millisecond))
|
||||
}
|
||||
|
||||
mgr.mu.RLock()
|
||||
info := mgr.domains[d]
|
||||
mgr.mu.RUnlock()
|
||||
|
||||
if info != nil && mgr.certNotifier != nil {
|
||||
if err := mgr.certNotifier.NotifyCertificateIssued(ctx, info.accountID, info.serviceID, name); err != nil {
|
||||
mgr.logger.Warnf("notify certificate ready for domain %q: %v", name, err)
|
||||
|
||||
@@ -2,6 +2,16 @@ package acme
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -10,7 +20,8 @@ import (
|
||||
)
|
||||
|
||||
func TestHostPolicy(t *testing.T) {
|
||||
mgr := NewManager(t.TempDir(), "https://acme.example.com/directory", "", "", nil, nil, "")
|
||||
mgr, err := NewManager(ManagerConfig{CertDir: t.TempDir(), ACMEURL: "https://acme.example.com/directory"}, nil, nil, nil)
|
||||
require.NoError(t, err)
|
||||
mgr.AddDomain("example.com", "acc1", "rp1")
|
||||
|
||||
// Wait for the background prefetch goroutine to finish so the temp dir
|
||||
@@ -70,7 +81,8 @@ func TestHostPolicy(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDomainStates(t *testing.T) {
|
||||
mgr := NewManager(t.TempDir(), "https://acme.example.com/directory", "", "", nil, nil, "")
|
||||
mgr, err := NewManager(ManagerConfig{CertDir: t.TempDir(), ACMEURL: "https://acme.example.com/directory"}, nil, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 0, mgr.PendingCerts(), "initially zero")
|
||||
assert.Equal(t, 0, mgr.TotalDomains(), "initially zero domains")
|
||||
@@ -100,3 +112,193 @@ func TestDomainStates(t *testing.T) {
|
||||
assert.Contains(t, failed, "b.example.com")
|
||||
assert.Empty(t, mgr.ReadyDomains())
|
||||
}
|
||||
|
||||
func TestParseWildcard(t *testing.T) {
|
||||
tests := []struct {
|
||||
pattern string
|
||||
wantSuffix string
|
||||
wantOK bool
|
||||
}{
|
||||
{"*.example.com", ".example.com", true},
|
||||
{"*.foo.example.com", ".foo.example.com", true},
|
||||
{"*.COM", ".com", true}, // single-label TLD
|
||||
{"example.com", "", false}, // no wildcard prefix
|
||||
{"*example.com", "", false}, // missing dot
|
||||
{"**.example.com", "", false}, // double star
|
||||
{"", "", false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.pattern, func(t *testing.T) {
|
||||
suffix, ok := parseWildcard(tc.pattern)
|
||||
assert.Equal(t, tc.wantOK, ok)
|
||||
if ok {
|
||||
assert.Equal(t, tc.wantSuffix, suffix)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchesWildcard(t *testing.T) {
|
||||
wcDir := t.TempDir()
|
||||
generateSelfSignedCert(t, wcDir, "example", "*.example.com")
|
||||
|
||||
acmeDir := t.TempDir()
|
||||
mgr, err := NewManager(ManagerConfig{CertDir: acmeDir, ACMEURL: "https://acme.example.com/directory", WildcardDir: wcDir}, nil, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
host string
|
||||
match bool
|
||||
}{
|
||||
{"foo.example.com", true},
|
||||
{"bar.example.com", true},
|
||||
{"FOO.Example.COM", true}, // case insensitive
|
||||
{"example.com", false}, // bare parent
|
||||
{"sub.foo.example.com", false}, // multi-level
|
||||
{"notexample.com", false},
|
||||
{"", false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.host, func(t *testing.T) {
|
||||
assert.Equal(t, tc.match, mgr.findWildcardEntry(tc.host) != nil)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// generateSelfSignedCert creates a temporary self-signed certificate and key
|
||||
// for testing purposes. The baseName controls the output filenames:
|
||||
// <baseName>.crt and <baseName>.key.
|
||||
func generateSelfSignedCert(t *testing.T, dir, baseName string, dnsNames ...string) {
|
||||
t.Helper()
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: dnsNames[0]},
|
||||
DNSNames: dnsNames,
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
require.NoError(t, err)
|
||||
|
||||
certFile, err := os.Create(filepath.Join(dir, baseName+".crt"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, pem.Encode(certFile, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}))
|
||||
require.NoError(t, certFile.Close())
|
||||
|
||||
keyDER, err := x509.MarshalECPrivateKey(key)
|
||||
require.NoError(t, err)
|
||||
keyFile, err := os.Create(filepath.Join(dir, baseName+".key"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, pem.Encode(keyFile, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}))
|
||||
require.NoError(t, keyFile.Close())
|
||||
}
|
||||
|
||||
func TestWildcardAddDomainSkipsACME(t *testing.T) {
|
||||
wcDir := t.TempDir()
|
||||
generateSelfSignedCert(t, wcDir, "example", "*.example.com")
|
||||
|
||||
acmeDir := t.TempDir()
|
||||
mgr, err := NewManager(ManagerConfig{CertDir: acmeDir, ACMEURL: "https://acme.example.com/directory", WildcardDir: wcDir}, nil, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add a wildcard-matching domain — should be immediately ready.
|
||||
mgr.AddDomain("foo.example.com", "acc1", "svc1")
|
||||
assert.Equal(t, 0, mgr.PendingCerts(), "wildcard domain should not be pending")
|
||||
assert.Equal(t, []string{"foo.example.com"}, mgr.ReadyDomains())
|
||||
|
||||
// Add a non-wildcard domain — should go through ACME (pending then failed).
|
||||
mgr.AddDomain("other.net", "acc2", "svc2")
|
||||
assert.Equal(t, 2, mgr.TotalDomains())
|
||||
|
||||
// Wait for the ACME prefetch to fail.
|
||||
assert.Eventually(t, func() bool {
|
||||
return mgr.PendingCerts() == 0
|
||||
}, 30*time.Second, 100*time.Millisecond)
|
||||
|
||||
assert.Equal(t, []string{"foo.example.com"}, mgr.ReadyDomains())
|
||||
assert.Contains(t, mgr.FailedDomains(), "other.net")
|
||||
}
|
||||
|
||||
func TestWildcardGetCertificate(t *testing.T) {
|
||||
wcDir := t.TempDir()
|
||||
generateSelfSignedCert(t, wcDir, "example", "*.example.com")
|
||||
|
||||
acmeDir := t.TempDir()
|
||||
mgr, err := NewManager(ManagerConfig{CertDir: acmeDir, ACMEURL: "https://acme.example.com/directory", WildcardDir: wcDir}, nil, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
mgr.AddDomain("foo.example.com", "acc1", "svc1")
|
||||
|
||||
// GetCertificate for a wildcard-matching domain should return the static cert.
|
||||
cert, err := mgr.GetCertificate(&tls.ClientHelloInfo{ServerName: "foo.example.com"})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cert)
|
||||
assert.Contains(t, cert.Leaf.DNSNames, "*.example.com")
|
||||
}
|
||||
|
||||
func TestMultipleWildcards(t *testing.T) {
|
||||
wcDir := t.TempDir()
|
||||
generateSelfSignedCert(t, wcDir, "example", "*.example.com")
|
||||
generateSelfSignedCert(t, wcDir, "other", "*.other.org")
|
||||
|
||||
acmeDir := t.TempDir()
|
||||
mgr, err := NewManager(ManagerConfig{CertDir: acmeDir, ACMEURL: "https://acme.example.com/directory", WildcardDir: wcDir}, nil, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.ElementsMatch(t, []string{"*.example.com", "*.other.org"}, mgr.WildcardPatterns())
|
||||
|
||||
// Both wildcards should resolve.
|
||||
mgr.AddDomain("foo.example.com", "acc1", "svc1")
|
||||
mgr.AddDomain("bar.other.org", "acc2", "svc2")
|
||||
|
||||
assert.Equal(t, 0, mgr.PendingCerts())
|
||||
assert.ElementsMatch(t, []string{"foo.example.com", "bar.other.org"}, mgr.ReadyDomains())
|
||||
|
||||
// GetCertificate routes to the correct cert.
|
||||
cert1, err := mgr.GetCertificate(&tls.ClientHelloInfo{ServerName: "foo.example.com"})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, cert1.Leaf.DNSNames, "*.example.com")
|
||||
|
||||
cert2, err := mgr.GetCertificate(&tls.ClientHelloInfo{ServerName: "bar.other.org"})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, cert2.Leaf.DNSNames, "*.other.org")
|
||||
|
||||
// Non-matching domain falls through to ACME.
|
||||
mgr.AddDomain("custom.net", "acc3", "svc3")
|
||||
assert.Eventually(t, func() bool {
|
||||
return mgr.PendingCerts() == 0
|
||||
}, 30*time.Second, 100*time.Millisecond)
|
||||
assert.Contains(t, mgr.FailedDomains(), "custom.net")
|
||||
}
|
||||
|
||||
func TestWildcardDirEmpty(t *testing.T) {
|
||||
wcDir := t.TempDir()
|
||||
// Empty directory — no .crt files.
|
||||
_, err := NewManager(ManagerConfig{CertDir: t.TempDir(), ACMEURL: "https://acme.example.com/directory", WildcardDir: wcDir}, nil, nil, nil)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no .crt files found")
|
||||
}
|
||||
|
||||
func TestWildcardDirNonWildcardCert(t *testing.T) {
|
||||
wcDir := t.TempDir()
|
||||
// Certificate without a wildcard SAN.
|
||||
generateSelfSignedCert(t, wcDir, "plain", "plain.example.com")
|
||||
|
||||
_, err := NewManager(ManagerConfig{CertDir: t.TempDir(), ACMEURL: "https://acme.example.com/directory", WildcardDir: wcDir}, nil, nil, nil)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no wildcard SANs")
|
||||
}
|
||||
|
||||
func TestNoWildcardDir(t *testing.T) {
|
||||
// Empty string means no wildcard dir — pure ACME mode.
|
||||
mgr, err := NewManager(ManagerConfig{CertDir: t.TempDir(), ACMEURL: "https://acme.example.com/directory"}, nil, nil, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, mgr.WildcardPatterns())
|
||||
}
|
||||
|
||||
@@ -67,6 +67,13 @@ func (w *Watcher) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, erro
|
||||
return w.cert, nil
|
||||
}
|
||||
|
||||
// Leaf returns the parsed leaf certificate, or nil if not yet loaded.
|
||||
func (w *Watcher) Leaf() *x509.Certificate {
|
||||
w.mu.RLock()
|
||||
defer w.mu.RUnlock()
|
||||
return w.leaf
|
||||
}
|
||||
|
||||
// Watch starts watching for certificate file changes. It blocks until
|
||||
// ctx is cancelled. It uses fsnotify for immediate detection and falls
|
||||
// back to polling if fsnotify is unavailable (e.g. on NFS).
|
||||
|
||||
@@ -1,64 +1,106 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
|
||||
"github.com/netbirdio/netbird/proxy/internal/proxy"
|
||||
"github.com/netbirdio/netbird/proxy/internal/responsewriter"
|
||||
)
|
||||
|
||||
type Metrics struct {
|
||||
requestsTotal prometheus.Counter
|
||||
activeRequests prometheus.Gauge
|
||||
configuredDomains prometheus.Gauge
|
||||
pathsPerDomain *prometheus.GaugeVec
|
||||
requestDuration *prometheus.HistogramVec
|
||||
backendDuration *prometheus.HistogramVec
|
||||
ctx context.Context
|
||||
requestsTotal metric.Int64Counter
|
||||
activeRequests metric.Int64UpDownCounter
|
||||
configuredDomains metric.Int64UpDownCounter
|
||||
totalPaths metric.Int64UpDownCounter
|
||||
requestDuration metric.Int64Histogram
|
||||
backendDuration metric.Int64Histogram
|
||||
certificateIssueDuration metric.Int64Histogram
|
||||
|
||||
mappingsMux sync.Mutex
|
||||
mappingPaths map[string]int
|
||||
}
|
||||
|
||||
func New(reg prometheus.Registerer) *Metrics {
|
||||
promFactory := promauto.With(reg)
|
||||
return &Metrics{
|
||||
requestsTotal: promFactory.NewCounter(prometheus.CounterOpts{
|
||||
Name: "netbird_proxy_requests_total",
|
||||
Help: "Total number of requests made to the netbird proxy",
|
||||
}),
|
||||
activeRequests: promFactory.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "netbird_proxy_active_requests_count",
|
||||
Help: "Current in-flight requests handled by the netbird proxy",
|
||||
}),
|
||||
configuredDomains: promFactory.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "netbird_proxy_domains_count",
|
||||
Help: "Current number of domains configured on the netbird proxy",
|
||||
}),
|
||||
pathsPerDomain: promFactory.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "netbird_proxy_paths_count",
|
||||
Help: "Current number of paths configured on the netbird proxy labelled by domain",
|
||||
},
|
||||
[]string{"domain"},
|
||||
),
|
||||
requestDuration: promFactory.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "netbird_proxy_request_duration_seconds",
|
||||
Help: "Duration of requests made to the netbird proxy",
|
||||
Buckets: []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10},
|
||||
},
|
||||
[]string{"status", "size", "method", "host", "path"},
|
||||
),
|
||||
backendDuration: promFactory.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Name: "netbird_proxy_backend_duration_seconds",
|
||||
Help: "Duration of peer round trip time from the netbird proxy",
|
||||
Buckets: []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10},
|
||||
},
|
||||
[]string{"status", "size", "method", "host", "path"},
|
||||
),
|
||||
func New(ctx context.Context, meter metric.Meter) (*Metrics, error) {
|
||||
requestsTotal, err := meter.Int64Counter(
|
||||
"proxy.http.request.counter",
|
||||
metric.WithUnit("1"),
|
||||
metric.WithDescription("Total number of requests made to the netbird proxy"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
activeRequests, err := meter.Int64UpDownCounter(
|
||||
"proxy.http.active_requests",
|
||||
metric.WithUnit("1"),
|
||||
metric.WithDescription("Current in-flight requests handled by the netbird proxy"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
configuredDomains, err := meter.Int64UpDownCounter(
|
||||
"proxy.domains.count",
|
||||
metric.WithUnit("1"),
|
||||
metric.WithDescription("Current number of domains configured on the netbird proxy"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
totalPaths, err := meter.Int64UpDownCounter(
|
||||
"proxy.paths.count",
|
||||
metric.WithUnit("1"),
|
||||
metric.WithDescription("Total number of paths configured on the netbird proxy"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
requestDuration, err := meter.Int64Histogram(
|
||||
"proxy.http.request.duration.ms",
|
||||
metric.WithUnit("milliseconds"),
|
||||
metric.WithDescription("Duration of requests made to the netbird proxy"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
backendDuration, err := meter.Int64Histogram(
|
||||
"proxy.backend.duration.ms",
|
||||
metric.WithUnit("milliseconds"),
|
||||
metric.WithDescription("Duration of peer round trip time from the netbird proxy"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
certificateIssueDuration, err := meter.Int64Histogram(
|
||||
"proxy.certificate.issue.duration.ms",
|
||||
metric.WithUnit("milliseconds"),
|
||||
metric.WithDescription("Duration of ACME certificate issuance"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Metrics{
|
||||
ctx: ctx,
|
||||
requestsTotal: requestsTotal,
|
||||
activeRequests: activeRequests,
|
||||
configuredDomains: configuredDomains,
|
||||
totalPaths: totalPaths,
|
||||
requestDuration: requestDuration,
|
||||
backendDuration: backendDuration,
|
||||
certificateIssueDuration: certificateIssueDuration,
|
||||
mappingPaths: make(map[string]int),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type responseInterceptor struct {
|
||||
@@ -80,23 +122,19 @@ func (w *responseInterceptor) Write(b []byte) (int, error) {
|
||||
|
||||
func (m *Metrics) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
m.requestsTotal.Inc()
|
||||
m.activeRequests.Inc()
|
||||
m.requestsTotal.Add(m.ctx, 1)
|
||||
m.activeRequests.Add(m.ctx, 1)
|
||||
|
||||
interceptor := &responseInterceptor{PassthroughWriter: responsewriter.New(w)}
|
||||
|
||||
start := time.Now()
|
||||
next.ServeHTTP(interceptor, r)
|
||||
duration := time.Since(start)
|
||||
defer func() {
|
||||
duration := time.Since(start)
|
||||
m.activeRequests.Add(m.ctx, -1)
|
||||
m.requestDuration.Record(m.ctx, duration.Milliseconds())
|
||||
}()
|
||||
|
||||
m.activeRequests.Desc()
|
||||
m.requestDuration.With(prometheus.Labels{
|
||||
"status": strconv.Itoa(interceptor.status),
|
||||
"size": strconv.Itoa(interceptor.size),
|
||||
"method": r.Method,
|
||||
"host": r.Host,
|
||||
"path": r.URL.Path,
|
||||
}).Observe(duration.Seconds())
|
||||
next.ServeHTTP(interceptor, r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -108,44 +146,52 @@ func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
|
||||
func (m *Metrics) RoundTripper(next http.RoundTripper) http.RoundTripper {
|
||||
return roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
||||
labels := prometheus.Labels{
|
||||
"method": req.Method,
|
||||
"host": req.Host,
|
||||
// Fill potentially empty labels with default values to avoid cardinality issues.
|
||||
"path": "/",
|
||||
"status": "0",
|
||||
"size": "0",
|
||||
}
|
||||
if req.URL != nil {
|
||||
labels["path"] = req.URL.Path
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
res, err := next.RoundTrip(req)
|
||||
duration := time.Since(start)
|
||||
|
||||
// Not all labels will be available if there was an error.
|
||||
if res != nil {
|
||||
labels["status"] = strconv.Itoa(res.StatusCode)
|
||||
labels["size"] = strconv.Itoa(int(res.ContentLength))
|
||||
}
|
||||
|
||||
m.backendDuration.With(labels).Observe(duration.Seconds())
|
||||
m.backendDuration.Record(m.ctx, duration.Milliseconds())
|
||||
|
||||
return res, err
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Metrics) AddMapping(mapping proxy.Mapping) {
|
||||
m.configuredDomains.Inc()
|
||||
m.pathsPerDomain.With(prometheus.Labels{
|
||||
"domain": mapping.Host,
|
||||
}).Set(float64(len(mapping.Paths)))
|
||||
m.mappingsMux.Lock()
|
||||
defer m.mappingsMux.Unlock()
|
||||
|
||||
newPathCount := len(mapping.Paths)
|
||||
oldPathCount, exists := m.mappingPaths[mapping.Host]
|
||||
|
||||
if !exists {
|
||||
m.configuredDomains.Add(m.ctx, 1)
|
||||
}
|
||||
|
||||
pathDelta := newPathCount - oldPathCount
|
||||
if pathDelta != 0 {
|
||||
m.totalPaths.Add(m.ctx, int64(pathDelta))
|
||||
}
|
||||
|
||||
m.mappingPaths[mapping.Host] = newPathCount
|
||||
}
|
||||
|
||||
func (m *Metrics) RemoveMapping(mapping proxy.Mapping) {
|
||||
m.configuredDomains.Dec()
|
||||
m.pathsPerDomain.With(prometheus.Labels{
|
||||
"domain": mapping.Host,
|
||||
}).Set(0)
|
||||
m.mappingsMux.Lock()
|
||||
defer m.mappingsMux.Unlock()
|
||||
|
||||
oldPathCount, exists := m.mappingPaths[mapping.Host]
|
||||
if !exists {
|
||||
// Nothing to remove
|
||||
return
|
||||
}
|
||||
|
||||
m.configuredDomains.Add(m.ctx, -1)
|
||||
m.totalPaths.Add(m.ctx, -int64(oldPathCount))
|
||||
|
||||
delete(m.mappingPaths, mapping.Host)
|
||||
}
|
||||
|
||||
// RecordCertificateIssuance records the duration of a certificate issuance.
|
||||
func (m *Metrics) RecordCertificateIssuance(duration time.Duration) {
|
||||
m.certificateIssueDuration.Record(m.ctx, duration.Milliseconds())
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
package metrics_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"go.opentelemetry.io/otel/exporters/prometheus"
|
||||
"go.opentelemetry.io/otel/sdk/metric"
|
||||
|
||||
"github.com/netbirdio/netbird/proxy/internal/metrics"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
type testRoundTripper struct {
|
||||
@@ -47,7 +51,19 @@ func TestMetrics_RoundTripper(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
m := metrics.New(prometheus.NewRegistry())
|
||||
exporter, err := prometheus.New()
|
||||
if err != nil {
|
||||
t.Fatalf("create prometheus exporter: %v", err)
|
||||
}
|
||||
|
||||
provider := metric.NewMeterProvider(metric.WithReader(exporter))
|
||||
pkg := reflect.TypeOf(metrics.Metrics{}).PkgPath()
|
||||
meter := provider.Meter(pkg)
|
||||
|
||||
m, err := metrics.New(context.Background(), meter)
|
||||
if err != nil {
|
||||
t.Fatalf("create metrics: %v", err)
|
||||
}
|
||||
|
||||
for name, test := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
|
||||
@@ -19,14 +19,17 @@ import (
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
"github.com/pires/go-proxyproto"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
prometheus2 "github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"go.opentelemetry.io/otel/exporters/prometheus"
|
||||
"go.opentelemetry.io/otel/sdk/metric"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
@@ -42,7 +45,7 @@ import (
|
||||
proxygrpc "github.com/netbirdio/netbird/proxy/internal/grpc"
|
||||
"github.com/netbirdio/netbird/proxy/internal/health"
|
||||
"github.com/netbirdio/netbird/proxy/internal/k8s"
|
||||
"github.com/netbirdio/netbird/proxy/internal/metrics"
|
||||
proxymetrics "github.com/netbirdio/netbird/proxy/internal/metrics"
|
||||
"github.com/netbirdio/netbird/proxy/internal/proxy"
|
||||
"github.com/netbirdio/netbird/proxy/internal/roundtrip"
|
||||
"github.com/netbirdio/netbird/proxy/internal/types"
|
||||
@@ -63,7 +66,7 @@ type Server struct {
|
||||
debug *http.Server
|
||||
healthServer *health.Server
|
||||
healthChecker *health.Checker
|
||||
meter *metrics.Metrics
|
||||
meter *proxymetrics.Metrics
|
||||
|
||||
// hijackTracker tracks hijacked connections (e.g. WebSocket upgrades)
|
||||
// so they can be closed during graceful shutdown, since http.Server.Shutdown
|
||||
@@ -94,6 +97,11 @@ type Server struct {
|
||||
// CertLockMethod controls how ACME certificate locks are coordinated
|
||||
// across replicas. Default: CertLockAuto (detect environment).
|
||||
CertLockMethod acme.CertLockMethod
|
||||
// WildcardCertDir is an optional directory containing wildcard certificate
|
||||
// pairs (<name>.crt / <name>.key). Wildcard patterns are extracted from
|
||||
// the certificates' SAN lists. Matching domains use these static certs
|
||||
// instead of ACME.
|
||||
WildcardCertDir string
|
||||
|
||||
// DebugEndpointEnabled enables the debug HTTP endpoint.
|
||||
DebugEndpointEnabled bool
|
||||
@@ -152,8 +160,19 @@ func (s *Server) NotifyCertificateIssued(ctx context.Context, accountID, service
|
||||
func (s *Server) ListenAndServe(ctx context.Context, addr string) (err error) {
|
||||
s.initDefaults()
|
||||
|
||||
reg := prometheus.NewRegistry()
|
||||
s.meter = metrics.New(reg)
|
||||
exporter, err := prometheus.New()
|
||||
if err != nil {
|
||||
return fmt.Errorf("create prometheus exporter: %w", err)
|
||||
}
|
||||
|
||||
provider := metric.NewMeterProvider(metric.WithReader(exporter))
|
||||
pkg := reflect.TypeOf(Server{}).PkgPath()
|
||||
meter := provider.Meter(pkg)
|
||||
|
||||
s.meter, err = proxymetrics.New(ctx, meter)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create metrics: %w", err)
|
||||
}
|
||||
|
||||
mgmtConn, err := s.dialManagement()
|
||||
if err != nil {
|
||||
@@ -193,7 +212,7 @@ func (s *Server) ListenAndServe(ctx context.Context, addr string) (err error) {
|
||||
|
||||
s.startDebugEndpoint()
|
||||
|
||||
if err := s.startHealthServer(reg); err != nil {
|
||||
if err := s.startHealthServer(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -284,12 +303,12 @@ func (s *Server) startDebugEndpoint() {
|
||||
}
|
||||
|
||||
// startHealthServer launches the health probe and metrics server.
|
||||
func (s *Server) startHealthServer(reg *prometheus.Registry) error {
|
||||
func (s *Server) startHealthServer() error {
|
||||
healthAddr := s.HealthAddress
|
||||
if healthAddr == "" {
|
||||
healthAddr = defaultHealthAddr
|
||||
}
|
||||
s.healthServer = health.NewServer(healthAddr, s.healthChecker, s.Logger, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}))
|
||||
s.healthServer = health.NewServer(healthAddr, s.healthChecker, s.Logger, promhttp.HandlerFor(prometheus2.DefaultGatherer, promhttp.HandlerOpts{EnableOpenMetrics: true}))
|
||||
healthListener, err := net.Listen("tcp", healthAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("health probe server listen on %s: %w", healthAddr, err)
|
||||
@@ -423,7 +442,20 @@ func (s *Server) configureTLS(ctx context.Context) (*tls.Config, error) {
|
||||
"acme_server": s.ACMEDirectory,
|
||||
"challenge_type": s.ACMEChallengeType,
|
||||
}).Debug("ACME certificates enabled, configuring certificate manager")
|
||||
s.acme = acme.NewManager(s.CertificateDirectory, s.ACMEDirectory, s.ACMEEABKID, s.ACMEEABHMACKey, s, s.Logger, s.CertLockMethod)
|
||||
var err error
|
||||
s.acme, err = acme.NewManager(acme.ManagerConfig{
|
||||
CertDir: s.CertificateDirectory,
|
||||
ACMEURL: s.ACMEDirectory,
|
||||
EABKID: s.ACMEEABKID,
|
||||
EABHMACKey: s.ACMEEABHMACKey,
|
||||
LockMethod: s.CertLockMethod,
|
||||
WildcardDir: s.WildcardCertDir,
|
||||
}, s, s.Logger, s.meter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create ACME manager: %w", err)
|
||||
}
|
||||
|
||||
go s.acme.WatchWildcards(ctx)
|
||||
|
||||
if s.ACMEChallengeType == "http-01" {
|
||||
s.http = &http.Server{
|
||||
@@ -439,6 +471,10 @@ func (s *Server) configureTLS(ctx context.Context) (*tls.Config, error) {
|
||||
}
|
||||
tlsConfig = s.acme.TLSConfig()
|
||||
|
||||
// autocert.Manager.TLSConfig() wires its own GetCertificate, which
|
||||
// bypasses our override that checks wildcards first.
|
||||
tlsConfig.GetCertificate = s.acme.GetCertificate
|
||||
|
||||
// ServerName needs to be set to allow for ACME to work correctly
|
||||
// when using CNAME URLs to access the proxy.
|
||||
tlsConfig.ServerName = s.ProxyURL
|
||||
@@ -661,8 +697,9 @@ func (s *Server) addMapping(ctx context.Context, mapping *proto.ProxyMapping) er
|
||||
if err := s.netbird.AddPeer(ctx, accountID, d, authToken, serviceID); err != nil {
|
||||
return fmt.Errorf("create peer for domain %q: %w", d, err)
|
||||
}
|
||||
var wildcardHit bool
|
||||
if s.acme != nil {
|
||||
s.acme.AddDomain(d, string(accountID), serviceID)
|
||||
wildcardHit = s.acme.AddDomain(d, string(accountID), serviceID)
|
||||
}
|
||||
|
||||
// Pass the mapping through to the update function to avoid duplicating the
|
||||
@@ -672,6 +709,13 @@ func (s *Server) addMapping(ctx context.Context, mapping *proto.ProxyMapping) er
|
||||
s.removeMapping(ctx, mapping)
|
||||
return fmt.Errorf("update mapping for domain %q: %w", d, err)
|
||||
}
|
||||
|
||||
if wildcardHit {
|
||||
if err := s.NotifyCertificateIssued(ctx, string(accountID), serviceID, string(d)); err != nil {
|
||||
s.Logger.Warnf("notify certificate ready for domain %q: %v", d, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2822,6 +2822,16 @@ components:
|
||||
type: string
|
||||
description: "City name from geolocation"
|
||||
example: "San Francisco"
|
||||
bytes_upload:
|
||||
type: integer
|
||||
format: int64
|
||||
description: "Bytes uploaded (request body size)"
|
||||
example: 1024
|
||||
bytes_download:
|
||||
type: integer
|
||||
format: int64
|
||||
description: "Bytes downloaded (response body size)"
|
||||
example: 8192
|
||||
required:
|
||||
- id
|
||||
- service_id
|
||||
@@ -2831,6 +2841,8 @@ components:
|
||||
- path
|
||||
- duration_ms
|
||||
- status_code
|
||||
- bytes_upload
|
||||
- bytes_download
|
||||
ProxyAccessLogsResponse:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.26.0
|
||||
// protoc v6.33.3
|
||||
// protoc v6.33.0
|
||||
// source: management.proto
|
||||
|
||||
package proto
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.26.0
|
||||
// protoc v6.33.3
|
||||
// protoc v6.33.0
|
||||
// source: proxy_service.proto
|
||||
|
||||
package proto
|
||||
@@ -740,6 +740,8 @@ type AccessLog struct {
|
||||
AuthMechanism string `protobuf:"bytes,11,opt,name=auth_mechanism,json=authMechanism,proto3" json:"auth_mechanism,omitempty"`
|
||||
UserId string `protobuf:"bytes,12,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
|
||||
AuthSuccess bool `protobuf:"varint,13,opt,name=auth_success,json=authSuccess,proto3" json:"auth_success,omitempty"`
|
||||
BytesUpload int64 `protobuf:"varint,14,opt,name=bytes_upload,json=bytesUpload,proto3" json:"bytes_upload,omitempty"`
|
||||
BytesDownload int64 `protobuf:"varint,15,opt,name=bytes_download,json=bytesDownload,proto3" json:"bytes_download,omitempty"`
|
||||
}
|
||||
|
||||
func (x *AccessLog) Reset() {
|
||||
@@ -865,6 +867,20 @@ func (x *AccessLog) GetAuthSuccess() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *AccessLog) GetBytesUpload() int64 {
|
||||
if x != nil {
|
||||
return x.BytesUpload
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *AccessLog) GetBytesDownload() int64 {
|
||||
if x != nil {
|
||||
return x.BytesDownload
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type AuthenticateRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
@@ -1698,7 +1714,7 @@ var file_proxy_service_proto_rawDesc = []byte{
|
||||
0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
|
||||
0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x03, 0x6c, 0x6f, 0x67, 0x22,
|
||||
0x17, 0x0a, 0x15, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67,
|
||||
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xa0, 0x03, 0x0a, 0x09, 0x41, 0x63, 0x63,
|
||||
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xea, 0x03, 0x0a, 0x09, 0x41, 0x63, 0x63,
|
||||
0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x12, 0x38, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74,
|
||||
0x61, 0x6d, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67,
|
||||
0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65,
|
||||
@@ -1724,153 +1740,158 @@ var file_proxy_service_proto_rawDesc = []byte{
|
||||
0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x61, 0x75, 0x74, 0x68,
|
||||
0x5f, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b,
|
||||
0x61, 0x75, 0x74, 0x68, 0x53, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x22, 0xb6, 0x01, 0x0a, 0x13,
|
||||
0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75,
|
||||
0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||
0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69,
|
||||
0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74,
|
||||
0x49, 0x64, 0x12, 0x39, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x03,
|
||||
0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
|
||||
0x74, 0x2e, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
|
||||
0x74, 0x48, 0x00, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x2a, 0x0a,
|
||||
0x03, 0x70, 0x69, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e,
|
||||
0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65,
|
||||
0x73, 0x74, 0x48, 0x00, 0x52, 0x03, 0x70, 0x69, 0x6e, 0x42, 0x09, 0x0a, 0x07, 0x72, 0x65, 0x71,
|
||||
0x75, 0x65, 0x73, 0x74, 0x22, 0x2d, 0x0a, 0x0f, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64,
|
||||
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77,
|
||||
0x6f, 0x72, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77,
|
||||
0x6f, 0x72, 0x64, 0x22, 0x1e, 0x0a, 0x0a, 0x50, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
|
||||
0x74, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03,
|
||||
0x70, 0x69, 0x6e, 0x22, 0x55, 0x0a, 0x14, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63,
|
||||
0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73,
|
||||
0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x73, 0x75,
|
||||
0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e,
|
||||
0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65,
|
||||
0x73, 0x73, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0xf3, 0x01, 0x0a, 0x17, 0x53,
|
||||
0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52,
|
||||
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63,
|
||||
0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76,
|
||||
0x69, 0x63, 0x65, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74,
|
||||
0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75,
|
||||
0x6e, 0x74, 0x49, 0x64, 0x12, 0x2f, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03,
|
||||
0x20, 0x01, 0x28, 0x0e, 0x32, 0x17, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
|
||||
0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73,
|
||||
0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x2d, 0x0a, 0x12, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69,
|
||||
0x63, 0x61, 0x74, 0x65, 0x5f, 0x69, 0x73, 0x73, 0x75, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28,
|
||||
0x08, 0x52, 0x11, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x49, 0x73,
|
||||
0x73, 0x75, 0x65, 0x64, 0x12, 0x28, 0x0a, 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65,
|
||||
0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0c, 0x65,
|
||||
0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x88, 0x01, 0x01, 0x42, 0x10,
|
||||
0x0a, 0x0e, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
|
||||
0x22, 0x1a, 0x0a, 0x18, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70,
|
||||
0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xb8, 0x01, 0x0a,
|
||||
0x16, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72,
|
||||
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69,
|
||||
0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x72,
|
||||
0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e,
|
||||
0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f,
|
||||
0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x30, 0x0a, 0x14, 0x77,
|
||||
0x69, 0x72, 0x65, 0x67, 0x75, 0x61, 0x72, 0x64, 0x5f, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f,
|
||||
0x6b, 0x65, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x77, 0x69, 0x72, 0x65, 0x67,
|
||||
0x75, 0x61, 0x72, 0x64, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x12, 0x18, 0x0a,
|
||||
0x07, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07,
|
||||
0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x22, 0x6f, 0x0a, 0x17, 0x43, 0x72, 0x65, 0x61, 0x74,
|
||||
0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
|
||||
0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20,
|
||||
0x01, 0x28, 0x08, 0x52, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x28, 0x0a, 0x0d,
|
||||
0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20,
|
||||
0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73,
|
||||
0x61, 0x67, 0x65, 0x88, 0x01, 0x01, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72,
|
||||
0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x65, 0x0a, 0x11, 0x47, 0x65, 0x74, 0x4f,
|
||||
0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a,
|
||||
0x61, 0x75, 0x74, 0x68, 0x53, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x62,
|
||||
0x79, 0x74, 0x65, 0x73, 0x5f, 0x75, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x0e, 0x20, 0x01, 0x28,
|
||||
0x03, 0x52, 0x0b, 0x62, 0x79, 0x74, 0x65, 0x73, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x25,
|
||||
0x0a, 0x0e, 0x62, 0x79, 0x74, 0x65, 0x73, 0x5f, 0x64, 0x6f, 0x77, 0x6e, 0x6c, 0x6f, 0x61, 0x64,
|
||||
0x18, 0x0f, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x62, 0x79, 0x74, 0x65, 0x73, 0x44, 0x6f, 0x77,
|
||||
0x6e, 0x6c, 0x6f, 0x61, 0x64, 0x22, 0xb6, 0x01, 0x0a, 0x13, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e,
|
||||
0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a,
|
||||
0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a,
|
||||
0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c,
|
||||
0x72, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x0b, 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x72, 0x6c, 0x22,
|
||||
0x26, 0x0a, 0x12, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x73,
|
||||
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x22, 0x55, 0x0a, 0x16, 0x56, 0x61, 0x6c, 0x69, 0x64,
|
||||
0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
|
||||
0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x65, 0x73,
|
||||
0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x8c,
|
||||
0x01, 0x0a, 0x17, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69,
|
||||
0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61,
|
||||
0x6c, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64,
|
||||
0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x73, 0x65,
|
||||
0x72, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75,
|
||||
0x73, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x64, 0x65, 0x6e, 0x69,
|
||||
0x65, 0x64, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||
0x0c, 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x2a, 0x64, 0x0a,
|
||||
0x16, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64,
|
||||
0x61, 0x74, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x44, 0x41, 0x54,
|
||||
0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, 0x00,
|
||||
0x12, 0x18, 0x0a, 0x14, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f,
|
||||
0x4d, 0x4f, 0x44, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x01, 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50,
|
||||
0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x52, 0x45, 0x4d, 0x4f, 0x56, 0x45,
|
||||
0x44, 0x10, 0x02, 0x2a, 0x46, 0x0a, 0x0f, 0x50, 0x61, 0x74, 0x68, 0x52, 0x65, 0x77, 0x72, 0x69,
|
||||
0x74, 0x65, 0x4d, 0x6f, 0x64, 0x65, 0x12, 0x18, 0x0a, 0x14, 0x50, 0x41, 0x54, 0x48, 0x5f, 0x52,
|
||||
0x45, 0x57, 0x52, 0x49, 0x54, 0x45, 0x5f, 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54, 0x10, 0x00,
|
||||
0x12, 0x19, 0x0a, 0x15, 0x50, 0x41, 0x54, 0x48, 0x5f, 0x52, 0x45, 0x57, 0x52, 0x49, 0x54, 0x45,
|
||||
0x5f, 0x50, 0x52, 0x45, 0x53, 0x45, 0x52, 0x56, 0x45, 0x10, 0x01, 0x2a, 0xc8, 0x01, 0x0a, 0x0b,
|
||||
0x50, 0x72, 0x6f, 0x78, 0x79, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x18, 0x0a, 0x14, 0x50,
|
||||
0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x50, 0x45, 0x4e, 0x44,
|
||||
0x49, 0x4e, 0x47, 0x10, 0x00, 0x12, 0x17, 0x0a, 0x13, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53,
|
||||
0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x41, 0x43, 0x54, 0x49, 0x56, 0x45, 0x10, 0x01, 0x12, 0x23,
|
||||
0x0a, 0x1f, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x54,
|
||||
0x55, 0x4e, 0x4e, 0x45, 0x4c, 0x5f, 0x4e, 0x4f, 0x54, 0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45,
|
||||
0x44, 0x10, 0x02, 0x12, 0x24, 0x0a, 0x20, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41,
|
||||
0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x39, 0x0a, 0x08,
|
||||
0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b,
|
||||
0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x61, 0x73, 0x73,
|
||||
0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x08, 0x70,
|
||||
0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x2a, 0x0a, 0x03, 0x70, 0x69, 0x6e, 0x18, 0x04,
|
||||
0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
|
||||
0x74, 0x2e, 0x50, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x03,
|
||||
0x70, 0x69, 0x6e, 0x42, 0x09, 0x0a, 0x07, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x2d,
|
||||
0x0a, 0x0f, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
|
||||
0x74, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x01, 0x20,
|
||||
0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0x1e, 0x0a,
|
||||
0x0a, 0x50, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x70,
|
||||
0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x70, 0x69, 0x6e, 0x22, 0x55, 0x0a,
|
||||
0x14, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73,
|
||||
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73,
|
||||
0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12,
|
||||
0x23, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e,
|
||||
0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x54,
|
||||
0x6f, 0x6b, 0x65, 0x6e, 0x22, 0xf3, 0x01, 0x0a, 0x17, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61,
|
||||
0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
|
||||
0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x12,
|
||||
0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20,
|
||||
0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x2f,
|
||||
0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x17,
|
||||
0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78,
|
||||
0x79, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12,
|
||||
0x2d, 0x0a, 0x12, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x5f, 0x69,
|
||||
0x73, 0x73, 0x75, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x63, 0x65, 0x72,
|
||||
0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x49, 0x73, 0x73, 0x75, 0x65, 0x64, 0x12, 0x28,
|
||||
0x0a, 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18,
|
||||
0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65,
|
||||
0x73, 0x73, 0x61, 0x67, 0x65, 0x88, 0x01, 0x01, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x65, 0x72, 0x72,
|
||||
0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x1a, 0x0a, 0x18, 0x53, 0x65,
|
||||
0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65,
|
||||
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xb8, 0x01, 0x0a, 0x16, 0x43, 0x72, 0x65, 0x61, 0x74,
|
||||
0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
|
||||
0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18,
|
||||
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64,
|
||||
0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12,
|
||||
0x14, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05,
|
||||
0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x30, 0x0a, 0x14, 0x77, 0x69, 0x72, 0x65, 0x67, 0x75, 0x61,
|
||||
0x72, 0x64, 0x5f, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x04, 0x20,
|
||||
0x01, 0x28, 0x09, 0x52, 0x12, 0x77, 0x69, 0x72, 0x65, 0x67, 0x75, 0x61, 0x72, 0x64, 0x50, 0x75,
|
||||
0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6c, 0x75, 0x73, 0x74,
|
||||
0x65, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65,
|
||||
0x72, 0x22, 0x6f, 0x0a, 0x17, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79,
|
||||
0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07,
|
||||
0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x73,
|
||||
0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x28, 0x0a, 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f,
|
||||
0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52,
|
||||
0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x88, 0x01, 0x01,
|
||||
0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61,
|
||||
0x67, 0x65, 0x22, 0x65, 0x0a, 0x11, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c,
|
||||
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20,
|
||||
0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75,
|
||||
0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63,
|
||||
0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x72, 0x65, 0x64, 0x69, 0x72, 0x65,
|
||||
0x63, 0x74, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x72, 0x65,
|
||||
0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x72, 0x6c, 0x22, 0x26, 0x0a, 0x12, 0x47, 0x65, 0x74,
|
||||
0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
|
||||
0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72,
|
||||
0x6c, 0x22, 0x55, 0x0a, 0x16, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73,
|
||||
0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64,
|
||||
0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d,
|
||||
0x61, 0x69, 0x6e, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74,
|
||||
0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73,
|
||||
0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x8c, 0x01, 0x0a, 0x17, 0x56, 0x61, 0x6c,
|
||||
0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70,
|
||||
0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x18, 0x01, 0x20,
|
||||
0x01, 0x28, 0x08, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73,
|
||||
0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65,
|
||||
0x72, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x65, 0x6d, 0x61, 0x69,
|
||||
0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x73, 0x65, 0x72, 0x45, 0x6d, 0x61,
|
||||
0x69, 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x61,
|
||||
0x73, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x64, 0x65, 0x6e, 0x69, 0x65,
|
||||
0x64, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x2a, 0x64, 0x0a, 0x16, 0x50, 0x72, 0x6f, 0x78, 0x79,
|
||||
0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x54, 0x79, 0x70,
|
||||
0x65, 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45,
|
||||
0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, 0x00, 0x12, 0x18, 0x0a, 0x14, 0x55, 0x50,
|
||||
0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x4d, 0x4f, 0x44, 0x49, 0x46, 0x49,
|
||||
0x45, 0x44, 0x10, 0x01, 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54,
|
||||
0x59, 0x50, 0x45, 0x5f, 0x52, 0x45, 0x4d, 0x4f, 0x56, 0x45, 0x44, 0x10, 0x02, 0x2a, 0x46, 0x0a,
|
||||
0x0f, 0x50, 0x61, 0x74, 0x68, 0x52, 0x65, 0x77, 0x72, 0x69, 0x74, 0x65, 0x4d, 0x6f, 0x64, 0x65,
|
||||
0x12, 0x18, 0x0a, 0x14, 0x50, 0x41, 0x54, 0x48, 0x5f, 0x52, 0x45, 0x57, 0x52, 0x49, 0x54, 0x45,
|
||||
0x5f, 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54, 0x10, 0x00, 0x12, 0x19, 0x0a, 0x15, 0x50, 0x41,
|
||||
0x54, 0x48, 0x5f, 0x52, 0x45, 0x57, 0x52, 0x49, 0x54, 0x45, 0x5f, 0x50, 0x52, 0x45, 0x53, 0x45,
|
||||
0x52, 0x56, 0x45, 0x10, 0x01, 0x2a, 0xc8, 0x01, 0x0a, 0x0b, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x53,
|
||||
0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x18, 0x0a, 0x14, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53,
|
||||
0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, 0x00, 0x12,
|
||||
0x17, 0x0a, 0x13, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f,
|
||||
0x41, 0x43, 0x54, 0x49, 0x56, 0x45, 0x10, 0x01, 0x12, 0x23, 0x0a, 0x1f, 0x50, 0x52, 0x4f, 0x58,
|
||||
0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x54, 0x55, 0x4e, 0x4e, 0x45, 0x4c, 0x5f,
|
||||
0x4e, 0x4f, 0x54, 0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, 0x02, 0x12, 0x24, 0x0a,
|
||||
0x20, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x43, 0x45,
|
||||
0x52, 0x54, 0x49, 0x46, 0x49, 0x43, 0x41, 0x54, 0x45, 0x5f, 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e,
|
||||
0x47, 0x10, 0x03, 0x12, 0x23, 0x0a, 0x1f, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41,
|
||||
0x54, 0x55, 0x53, 0x5f, 0x43, 0x45, 0x52, 0x54, 0x49, 0x46, 0x49, 0x43, 0x41, 0x54, 0x45, 0x5f,
|
||||
0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, 0x03, 0x12, 0x23, 0x0a, 0x1f, 0x50, 0x52, 0x4f,
|
||||
0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x43, 0x45, 0x52, 0x54, 0x49, 0x46,
|
||||
0x49, 0x43, 0x41, 0x54, 0x45, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x04, 0x12, 0x16,
|
||||
0x0a, 0x12, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x45,
|
||||
0x52, 0x52, 0x4f, 0x52, 0x10, 0x05, 0x32, 0xfc, 0x04, 0x0a, 0x0c, 0x50, 0x72, 0x6f, 0x78, 0x79,
|
||||
0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x5f, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x4d, 0x61,
|
||||
0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x23, 0x2e, 0x6d, 0x61,
|
||||
0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70,
|
||||
0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
|
||||
0x1a, 0x24, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65,
|
||||
0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65,
|
||||
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x30, 0x01, 0x12, 0x54, 0x0a, 0x0d, 0x53, 0x65, 0x6e, 0x64,
|
||||
0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x12, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
|
||||
0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73,
|
||||
0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x6d, 0x61,
|
||||
0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63,
|
||||
0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x51,
|
||||
0x0a, 0x0c, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x12, 0x1f,
|
||||
0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x68,
|
||||
0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
|
||||
0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74,
|
||||
0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
|
||||
0x65, 0x12, 0x5d, 0x0a, 0x10, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55,
|
||||
0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x04, 0x12, 0x16, 0x0a, 0x12, 0x50, 0x52, 0x4f, 0x58,
|
||||
0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x05,
|
||||
0x32, 0xfc, 0x04, 0x0a, 0x0c, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63,
|
||||
0x65, 0x12, 0x5f, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55,
|
||||
0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
|
||||
0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64,
|
||||
0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64,
|
||||
0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x6d, 0x61, 0x6e,
|
||||
0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74,
|
||||
0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
|
||||
0x12, 0x5a, 0x0a, 0x0f, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50,
|
||||
0x65, 0x65, 0x72, 0x12, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
|
||||
0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72,
|
||||
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
|
||||
0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79,
|
||||
0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4b, 0x0a, 0x0a,
|
||||
0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x12, 0x1d, 0x2e, 0x6d, 0x61, 0x6e,
|
||||
0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55,
|
||||
0x52, 0x4c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
|
||||
0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52,
|
||||
0x4c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5a, 0x0a, 0x0f, 0x56, 0x61, 0x6c,
|
||||
0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x22, 0x2e, 0x6d,
|
||||
0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61,
|
||||
0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
|
||||
0x1a, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61,
|
||||
0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73,
|
||||
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
|
||||
0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69,
|
||||
0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
|
||||
0x30, 0x01, 0x12, 0x54, 0x0a, 0x0d, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73,
|
||||
0x4c, 0x6f, 0x67, 0x12, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
|
||||
0x2e, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65,
|
||||
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
|
||||
0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67,
|
||||
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x51, 0x0a, 0x0c, 0x41, 0x75, 0x74, 0x68,
|
||||
0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x12, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
|
||||
0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61,
|
||||
0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
|
||||
0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63,
|
||||
0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5d, 0x0a, 0x10, 0x53,
|
||||
0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12,
|
||||
0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e,
|
||||
0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71,
|
||||
0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
|
||||
0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61,
|
||||
0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5a, 0x0a, 0x0f, 0x43, 0x72,
|
||||
0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x12, 0x22, 0x2e,
|
||||
0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74,
|
||||
0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
|
||||
0x74, 0x1a, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43,
|
||||
0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65,
|
||||
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4b, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44,
|
||||
0x43, 0x55, 0x52, 0x4c, 0x12, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
|
||||
0x74, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x71, 0x75,
|
||||
0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
|
||||
0x2e, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x73, 0x70, 0x6f,
|
||||
0x6e, 0x73, 0x65, 0x12, 0x5a, 0x0a, 0x0f, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53,
|
||||
0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
|
||||
0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73,
|
||||
0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x6d, 0x61, 0x6e,
|
||||
0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65,
|
||||
0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42,
|
||||
0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f,
|
||||
0x33,
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
@@ -115,6 +115,8 @@ message AccessLog {
|
||||
string auth_mechanism = 11;
|
||||
string user_id = 12;
|
||||
bool auth_success = 13;
|
||||
int64 bytes_upload = 14;
|
||||
int64 bytes_download = 15;
|
||||
}
|
||||
|
||||
message AuthenticateRequest {
|
||||
|
||||
209
tools/idp-migrate/DEVELOPMENT.md
Normal file
209
tools/idp-migrate/DEVELOPMENT.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# IdP Migration Tool — Developer Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This tool migrates NetBird deployments from an external IdP (Auth0, Zitadel, Okta, etc.) to the embedded Dex IdP introduced in v0.62.0. It does two things:
|
||||
|
||||
1. **DB migration** — Re-encodes every user ID from `{original_id}` to Dex's protobuf-encoded format `base64(proto{original_id, connector_id})`.
|
||||
2. **Config generation** — Transforms `management.json` by replacing `IdpManagerConfig` with `EmbeddedIdP` and updating `HttpConfig` fields.
|
||||
|
||||
## Code Layout
|
||||
|
||||
```
|
||||
tools/idp-migrate/
|
||||
├── main.go # CLI entry point, connector resolution, config generation
|
||||
├── main_test.go # 22 tests covering all exported/internal functions
|
||||
├── DEVELOPMENT.md # this file
|
||||
└── MIGRATION_GUIDE.md # operator-facing step-by-step guide
|
||||
|
||||
management/server/idp/migration/
|
||||
├── migration.go # Server interface, MigrateUsersToStaticConnectors(), PopulateUserInfo(), migrateUser(), reconcileActivityStore()
|
||||
├── migration_test.go # 6 top-level tests (with subtests) using hand-written mocks
|
||||
└── store.go # Store, EventStore interfaces, SchemaCheck, RequiredSchema, SchemaError types
|
||||
|
||||
management/server/store/
|
||||
└── sql_store_idp_migration.go # CheckSchema(), ListUsers(), UpdateUserInfo(), UpdateUserID(), txDeferFKConstraints() on SqlStore
|
||||
|
||||
management/server/activity/store/
|
||||
├── sql_store_idp_migration.go # UpdateUserID() on activity Store
|
||||
└── sql_store_idp_migration_test.go # 5 subtests for activity UpdateUserID
|
||||
|
||||
```
|
||||
|
||||
## Release / Distribution
|
||||
|
||||
The tool is included in `.goreleaser.yaml` as the `netbird-idp-migrate` build target. Each NetBird release produces pre-built archives for Linux (amd64, arm64, arm) that are uploaded to GitHub Releases. The archive naming convention is:
|
||||
|
||||
```
|
||||
netbird-idp-migrate_<version>_linux_<arch>.tar.gz
|
||||
```
|
||||
|
||||
The build requires `CGO_ENABLED=1` because it links the SQLite driver used by `SqlStore`. The cross-compilation setup (CC env for arm64/arm) mirrors the `netbird-mgmt` build.
|
||||
|
||||
## CLI Flags
|
||||
|
||||
| Flag | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `--config` | string | *(required)* | Path to management.json |
|
||||
| `--datadir` | string | `""` | Override data directory from config |
|
||||
| `--idp-seed-info` | string | `""` | Base64-encoded connector JSON (overrides auto-detection) |
|
||||
| `--dry-run` | bool | `false` | Preview changes without writing |
|
||||
| `--force` | bool | `false` | Skip interactive confirmation prompt |
|
||||
| `--skip-config` | bool | `false` | Skip config generation (DB-only migration) |
|
||||
| `--skip-populate-user-info` | bool | `false` | Skip populating user info (user ID migration only) |
|
||||
| `--log-level` | string | `"info"` | Log level (debug, info, warn, error) |
|
||||
|
||||
## Migration Flow
|
||||
|
||||
### Phase 0: Schema Validation
|
||||
|
||||
`validateSchema()` opens the store and calls `CheckSchema(RequiredSchema)` to verify that all tables and columns required by the migration exist in the database. If anything is missing, the tool exits with a descriptive error instructing the operator to start the management server (v0.62.0+) at least once so that automatic GORM migrations create the required schema.
|
||||
|
||||
### Phase 1: Populate User Info
|
||||
|
||||
Unless `--skip-populate-user-info` is set, `populateUserInfoFromIDP()` runs before connector resolution:
|
||||
|
||||
1. Creates an IDP manager from the existing `IdpManagerConfig` in management.json.
|
||||
2. Calls `idpManager.GetAllAccounts()` to fetch email and name for all users from the external IDP.
|
||||
3. Calls `migration.PopulateUserInfo()` which iterates over all store users, skipping service users and users that already have both email and name populated. For Dex-encoded user IDs, it decodes back to the original IDP ID for lookup.
|
||||
4. Updates the store with any missing email/name values.
|
||||
|
||||
This ensures user contact info is preserved before the ID migration makes the original IDP IDs inaccessible.
|
||||
|
||||
### Phase 2: Connector Resolution
|
||||
|
||||
`resolveConnector()` uses a 3-tier priority:
|
||||
|
||||
1. `--idp-seed-info` flag — explicit base64-encoded connector JSON
|
||||
2. `IDP_SEED_INFO` env var — same format, read via `migration.SeedConnectorFromEnv()`
|
||||
3. Auto-detect from `management.json` — reads `IdpManagerConfig.ClientConfig` fields and maps `ManagerType` to a Dex connector type:
|
||||
|
||||
| ManagerType | Dex Connector Type | Notes |
|
||||
|-------------|--------------------|----|
|
||||
| `keycloak` | `keycloak` | |
|
||||
| `okta` | `okta` | |
|
||||
| `authentik` | `authentik` | |
|
||||
| `pocketid` | `pocketid` | |
|
||||
| `auth0` | `oidc` (generic) | |
|
||||
| `azure` | `entra` | |
|
||||
| `google` | `google` | |
|
||||
| `zitadel` | **error** | Uses service account credentials — requires `--idp-seed-info` |
|
||||
| `jumpcloud` | **error** | No Dex connector available |
|
||||
| *(unknown)* | `oidc` (fallback) | Requires non-empty `ClientSecret` |
|
||||
|
||||
**Why Zitadel can't be auto-detected**: Zitadel's `IdpManagerConfig.ClientConfig` contains service account credentials (a login name like `netbird-service-account` and possibly a PAT), not OAuth client credentials. These can't be used as an OIDC connector's `clientID`/`clientSecret`. The user must create a confidential Web application in Zitadel and provide it via `--idp-seed-info`.
|
||||
|
||||
Additionally, `buildConnectorFromConfig` validates that `ClientSecret` is non-empty for all providers. If the secret is missing, the tool errors with instructions to use `--idp-seed-info`.
|
||||
|
||||
### Phase 3: DB Migration
|
||||
|
||||
`migrateDB()` orchestrates the database migration:
|
||||
|
||||
1. `openStores()` opens the main store (`SqlStore`) and activity store (non-fatal if missing).
|
||||
2. Type-asserts both to `migration.Store` / `migration.EventStore`.
|
||||
3. `previewUsers()` scans all users — counts pending vs already-migrated (using `DecodeDexUserID`).
|
||||
4. `confirmPrompt()` asks for interactive confirmation (unless `--force` or `--dry-run`).
|
||||
5. Calls `migration.MigrateUsersToStaticConnectors(srv, conn)`:
|
||||
- **Reconciliation pass**: fixes activity store references for users already migrated in the main DB but whose events still reference old IDs (from a previous partial failure).
|
||||
- **Main loop**: for each non-migrated user, calls `migrateUser()` which atomically updates the user ID in both the main store and activity store.
|
||||
- **Dry-run**: logs what would happen, skips all writes.
|
||||
|
||||
`SqlStore.UpdateUserID()` atomically updates the user's primary key and all foreign key references (peers, PATs, groups, policies, jobs, etc.) in a single transaction.
|
||||
|
||||
### Phase 4: Config Generation
|
||||
|
||||
Unless `--skip-config` is set, `generateConfig()` runs:
|
||||
|
||||
1. **Derive domain** — `deriveDomain()` priority:
|
||||
1. `HttpConfig.LetsEncryptDomain` (most explicit)
|
||||
2. Parse host from `HttpConfig.OIDCConfigEndpoint`
|
||||
3. Parse host from `HttpConfig.AuthIssuer`
|
||||
4. Parse host from `IdpManagerConfig.ClientConfig.Issuer` (last resort)
|
||||
|
||||
2. **Transform JSON** — reads existing config as raw JSON to preserve all fields, then:
|
||||
- Removes `IdpManagerConfig`
|
||||
- Adds `EmbeddedIdP` with the static connector, redirect URIs, etc.
|
||||
- Overrides the connector's `redirectURI` to use the derived management domain (not the IdP issuer)
|
||||
- Updates `HttpConfig`: `AuthIssuer`, `AuthAudience`, `AuthClientID`, `CLIAuthAudience`, `AuthKeysLocation`, `OIDCConfigEndpoint`, `IdpSignKeyRefreshEnabled`
|
||||
- Sets `AuthUserIDClaim` to `"sub"`
|
||||
- Generates `PKCEAuthorizationFlow` with Dex endpoints
|
||||
|
||||
3. **Write** — backs up original as `management.json.bak`, writes new config. In dry-run mode, prints to stdout instead.
|
||||
|
||||
## Interface Decoupling
|
||||
|
||||
Migration methods (`ListUsers`, `UpdateUserID`) are **not** on the core `store.Store` or `activity.Store` interfaces. Instead, they're defined in `migration/store.go`:
|
||||
|
||||
```go
|
||||
type Store interface {
|
||||
ListUsers(ctx context.Context) ([]*types.User, error)
|
||||
UpdateUserID(ctx context.Context, accountID, oldUserID, newUserID string) error
|
||||
UpdateUserInfo(ctx context.Context, userID, email, name string) error
|
||||
CheckSchema(checks []SchemaCheck) []SchemaError
|
||||
}
|
||||
|
||||
type EventStore interface {
|
||||
UpdateUserID(ctx context.Context, oldUserID, newUserID string) error
|
||||
}
|
||||
```
|
||||
|
||||
A `Server` interface wraps both stores for dependency injection:
|
||||
|
||||
```go
|
||||
type Server interface {
|
||||
Store() Store
|
||||
EventStore() EventStore // may return nil
|
||||
}
|
||||
```
|
||||
|
||||
The concrete `SqlStore` types already have these methods (in their respective `sql_store_idp_migration.go` files), so they satisfy the interfaces via Go's structural typing — zero changes needed on the core store interfaces. At runtime, the standalone tool type-asserts:
|
||||
|
||||
```go
|
||||
migStore, ok := mainStore.(migration.Store)
|
||||
```
|
||||
|
||||
This keeps migration concerns completely separate from the core store contract.
|
||||
|
||||
## Dex User ID Encoding
|
||||
|
||||
`EncodeDexUserID(userID, connectorID)` produces a manually-encoded protobuf with two string fields, then base64-encodes the result (raw, no padding). `DecodeDexUserID` reverses this. The migration loop uses `DecodeDexUserID` to detect already-migrated users (decode succeeds → skip).
|
||||
|
||||
See `idp/dex/provider.go` for the implementation.
|
||||
|
||||
## Standalone Tool
|
||||
|
||||
The standalone tool (`tools/idp-migrate/main.go`) is the primary migration entry point. It opens stores directly, runs schema validation, populates user info from the external IDP, migrates user IDs, and generates the new config — then exits.
|
||||
|
||||
Previously, the combined server (`modules.go`) had a `seedIDPConnectors()` method that ran the same migration at startup via the `IDP_SEED_INFO` env var. This combined server path has been removed; migration is now handled exclusively by the standalone tool.
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Migration library
|
||||
go test -v ./management/server/idp/migration/...
|
||||
|
||||
# Standalone tool
|
||||
go test -v ./tools/idp-migrate/...
|
||||
|
||||
# Activity store migration tests
|
||||
go test -v -run TestUpdateUserID ./management/server/activity/store/...
|
||||
|
||||
# Build locally
|
||||
go build ./tools/idp-migrate/
|
||||
```
|
||||
|
||||
## Clean Removal
|
||||
|
||||
When migration tooling is no longer needed, delete:
|
||||
|
||||
1. `tools/idp-migrate/` — entire directory
|
||||
2. `management/server/idp/migration/` — entire directory
|
||||
3. `management/server/store/sql_store_idp_migration.go` — migration methods on main SqlStore
|
||||
4. `management/server/activity/store/sql_store_idp_migration.go` — migration method on activity Store
|
||||
5. `management/server/activity/store/sql_store_idp_migration_test.go` — tests for the above
|
||||
6. In `.goreleaser.yaml`:
|
||||
- Remove the `netbird-idp-migrate` build entry
|
||||
- Remove the `netbird-idp-migrate` archive entry
|
||||
7. Run `go mod tidy`
|
||||
|
||||
No core interfaces or mocks need editing — that's the point of the decoupling.
|
||||
477
tools/idp-migrate/MIGRATION_GUIDE.md
Normal file
477
tools/idp-migrate/MIGRATION_GUIDE.md
Normal file
@@ -0,0 +1,477 @@
|
||||
# Migrating from External IdP to Embedded IdP
|
||||
|
||||
This guide walks through migrating a self-hosted NetBird deployment from an external identity provider to the embedded Dex-based IdP introduced in v0.62.0.
|
||||
|
||||
## Overview
|
||||
|
||||
The migration tool does two things:
|
||||
|
||||
1. **Re-encodes user IDs** in the database to include the external connector ID, so Dex can route returning users to the correct external provider.
|
||||
2. **Generates a new `management.json`** that replaces `IdpManagerConfig` with `EmbeddedIdP` and updates OAuth2 endpoints to the embedded Dex issuer.
|
||||
|
||||
After migration, existing users keep logging in through the same external provider — Dex acts as a broker in front of it. No passwords or credentials change.
|
||||
|
||||
---
|
||||
|
||||
## Before You Begin
|
||||
|
||||
### Prerequisites
|
||||
|
||||
| Requirement | Details |
|
||||
|-------------|---------|
|
||||
| NetBird version | `<INSERT_VERSION>` or later |
|
||||
| Config access | You can read and write `management.json` |
|
||||
| Server downtime | The management server **must be stopped** during migration |
|
||||
| Backups | Back up your database and config before starting |
|
||||
|
||||
### Supported Providers
|
||||
|
||||
| Provider | Auto-detected | Connector type | Extra setup needed? |
|
||||
|----------|:---:|----------------|---------------------|
|
||||
| Auth0 | ✅ | Generic OIDC | No |
|
||||
| Azure AD | ✅ | Entra | No |
|
||||
| Keycloak | ✅ | Keycloak | No |
|
||||
| Okta | ✅ | OIDC | No |
|
||||
| Authentik | ✅ | OIDC | No |
|
||||
| PocketID | ✅ | OIDC | No |
|
||||
| Google | ✅ | Google | No |
|
||||
| Zitadel | ❌ | Zitadel | Yes — see [Step 2](#step-2-prepare-your-provider-if-required) |
|
||||
| JumpCloud | ❌ | — | No Dex connector; manual OIDC setup required |
|
||||
|
||||
> **Which path do I follow?**
|
||||
>
|
||||
> - **Auto-detected provider** → Skip Step 2 entirely. The tool reads your `management.json` and builds the connector automatically.
|
||||
> - **Zitadel** → You must complete Step 2 to create an OAuth app and supply connector credentials.
|
||||
> - **JumpCloud or other unsupported provider** → You must complete Step 2 to provide a custom OIDC connector.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Get the Migration Tool
|
||||
|
||||
**Option A — Download a pre-built binary:**
|
||||
|
||||
```bash
|
||||
# Replace VERSION with the release tag, and adjust the architecture as needed
|
||||
curl -L -o netbird-idp-migrate.tar.gz \
|
||||
https://github.com/netbirdio/netbird/releases/download/VERSION/netbird-idp-migrate_VERSION_linux_amd64.tar.gz
|
||||
tar xzf netbird-idp-migrate.tar.gz
|
||||
chmod +x netbird-idp-migrate
|
||||
```
|
||||
|
||||
Available architectures: `linux_amd64`, `linux_arm64`, `linux_arm`.
|
||||
|
||||
**Option B — Build from source** (requires Go 1.25+ and a C compiler for CGO/SQLite):
|
||||
|
||||
```bash
|
||||
go build -o netbird-idp-migrate ./tools/idp-migrate/
|
||||
```
|
||||
|
||||
Copy the binary to the management server host if you built it elsewhere.
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Prepare Your Provider (if required)
|
||||
|
||||
> **Auto-detected providers (Auth0, Azure AD, Keycloak, Okta, Authentik, PocketID, Google):** Skip this step — proceed to [Step 3](#step-3-stop-the-management-server).
|
||||
|
||||
### Zitadel
|
||||
|
||||
Zitadel requires manual connector setup because the management server's service account credentials cannot be reused as OAuth client credentials for the Dex OIDC connector.
|
||||
|
||||
1. Open the Zitadel console at `https://<your-zitadel-domain>/ui/console`.
|
||||
2. Go to **Projects** → select the NetBird project → **Applications**.
|
||||
3. Click **New** and create an application:
|
||||
- **Name:** `netbird-dex`
|
||||
- **Type:** Web
|
||||
- **Authentication Method:** Code
|
||||
4. Set the redirect URI to `https://<your-management-domain>/oauth2/callback`.
|
||||
5. Save and copy the **Client ID** and **Client Secret**.
|
||||
6. Under **Token Settings**, enable both:
|
||||
- ✅ User roles inside ID token
|
||||
- ✅ User Info inside ID token
|
||||
7. Create a `connector.json` file:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "zitadel",
|
||||
"name": "zitadel",
|
||||
"id": "zitadel",
|
||||
"config": {
|
||||
"issuer": "https://<your-zitadel-domain>",
|
||||
"clientID": "<client-id>",
|
||||
"clientSecret": "<client-secret>",
|
||||
"redirectURI": "https://<your-management-domain>/oauth2/callback"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You will pass this file in Step 5 with the `--idp-seed-info` flag.
|
||||
|
||||
See also: [Zitadel setup guide](https://docs.netbird.io/selfhosted/identity-providers/zitadel).
|
||||
|
||||
### Custom / Unsupported Provider (JumpCloud, etc.)
|
||||
|
||||
For providers without built-in detection, create a generic OIDC `connector.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "oidc",
|
||||
"name": "My Provider",
|
||||
"id": "my-provider",
|
||||
"config": {
|
||||
"issuer": "https://idp.example.com",
|
||||
"clientID": "my-client-id",
|
||||
"clientSecret": "my-client-secret",
|
||||
"redirectURI": "https://<your-management-domain>/oauth2/callback"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You will pass this file in Step 5 with the `--idp-seed-info` flag.
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Stop the Management Server
|
||||
|
||||
<details>
|
||||
<summary><strong>Systemd / bare-metal</strong></summary>
|
||||
|
||||
```bash
|
||||
sudo systemctl stop netbird-management
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Docker Compose</strong></summary>
|
||||
|
||||
```bash
|
||||
docker compose stop management
|
||||
```
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Back Up Your Data
|
||||
|
||||
The tool creates `management.json.bak` automatically, but always make your own backups.
|
||||
|
||||
<details>
|
||||
<summary><strong>Systemd / bare-metal (SQLite)</strong></summary>
|
||||
|
||||
```bash
|
||||
cp /var/lib/netbird/store.db /var/lib/netbird/store.db.bak
|
||||
cp /etc/netbird/management.json /etc/netbird/management.json.bak
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Docker Compose (SQLite in a named volume)</strong></summary>
|
||||
|
||||
Find the volume and its host path:
|
||||
|
||||
```bash
|
||||
# Identify the volume name
|
||||
VOLUME_NAME=$(docker volume ls --format '{{ .Name }}' | grep -i management)
|
||||
echo "Volume: $VOLUME_NAME"
|
||||
|
||||
# Get the host path
|
||||
VOLUME_PATH=$(docker volume inspect "$VOLUME_NAME" --format '{{ .Mountpoint }}')
|
||||
echo "Path: $VOLUME_PATH"
|
||||
|
||||
# Verify store.db exists, then back up
|
||||
sudo ls "$VOLUME_PATH/store.db"
|
||||
sudo cp "$VOLUME_PATH/store.db" "$VOLUME_PATH/store.db.bak"
|
||||
cp ~/netbird/management.json ~/netbird/management.json.bak
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>PostgreSQL</strong></summary>
|
||||
|
||||
```bash
|
||||
pg_dump -h <host> -U <user> -d <database> -f netbird-backup.sql
|
||||
cp /etc/netbird/management.json /etc/netbird/management.json.bak
|
||||
```
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Run the Migration
|
||||
|
||||
### 5a. Dry run (always do this first)
|
||||
|
||||
This previews what will happen without writing any changes.
|
||||
|
||||
**Auto-detected providers:**
|
||||
|
||||
```bash
|
||||
./netbird-idp-migrate \
|
||||
--config /etc/netbird/management.json \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
**Zitadel / custom providers** (pass the `connector.json` from Step 2):
|
||||
|
||||
```bash
|
||||
./netbird-idp-migrate \
|
||||
--config /etc/netbird/management.json \
|
||||
--idp-seed-info "$(base64 < connector.json)" \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
> **Docker users:** If your database is in a volume that doesn't match the `Datadir` in `management.json`, add `--datadir`:
|
||||
>
|
||||
> ```bash
|
||||
> ./netbird-idp-migrate \
|
||||
> --config ~/netbird/management.json \
|
||||
> --datadir /var/lib/docker/volumes/<volume-name>/_data \
|
||||
> --dry-run
|
||||
> ```
|
||||
|
||||
You should see output like:
|
||||
|
||||
```
|
||||
INFO resolved connector: type=oidc, id=auth0, name=auth0
|
||||
INFO found 12 total users: 12 pending migration, 0 already migrated
|
||||
INFO [DRY RUN] would migrate user abc123 -> CgZhYmMxMjMSB3ppdGFkZWw (account: acct-1)
|
||||
...
|
||||
INFO [DRY RUN] migration summary: 12 users would be migrated, 0 already migrated
|
||||
INFO derived domain for embedded IdP: mgmt.example.com
|
||||
INFO [DRY RUN] new management.json would be:
|
||||
{ ... }
|
||||
```
|
||||
|
||||
**Verify before proceeding:**
|
||||
|
||||
- ✅ Connector type and ID match your provider.
|
||||
- ✅ User count matches what you expect.
|
||||
- ✅ Generated config has the correct domain and endpoints.
|
||||
|
||||
### 5b. Execute the migration
|
||||
|
||||
Run the same command without `--dry-run`:
|
||||
|
||||
```bash
|
||||
# Auto-detected providers
|
||||
./netbird-idp-migrate --config /etc/netbird/management.json
|
||||
|
||||
# Zitadel / custom providers
|
||||
./netbird-idp-migrate \
|
||||
--config /etc/netbird/management.json \
|
||||
--idp-seed-info "$(base64 < connector.json)"
|
||||
```
|
||||
|
||||
The tool will show a summary and prompt for confirmation:
|
||||
|
||||
```
|
||||
About to migrate 12 users. This cannot be easily undone. Continue? [y/N]
|
||||
```
|
||||
|
||||
Type `y` and press Enter.
|
||||
|
||||
### 5c. Review the new config
|
||||
|
||||
Open `/etc/netbird/management.json` and verify:
|
||||
|
||||
- ✅ `IdpManagerConfig` is **removed**.
|
||||
- ✅ `EmbeddedIdP` is present with `"Enabled": true` and your connector in `StaticConnectors`.
|
||||
- ✅ `HttpConfig.AuthIssuer` is `https://<your-domain>/oauth2`.
|
||||
- ✅ `HttpConfig.AuthClientID` is `"netbird-dashboard"`.
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Post-Migration Configuration
|
||||
|
||||
### 6a. Update your reverse proxy
|
||||
|
||||
The embedded Dex IdP is served under `/oauth2/`. Your reverse proxy must route this path to the management server.
|
||||
|
||||
<details>
|
||||
<summary><strong>Caddy</strong></summary>
|
||||
|
||||
Add to your `Caddyfile`, inside the site block for your management domain:
|
||||
|
||||
```
|
||||
reverse_proxy /oauth2/* management:80
|
||||
```
|
||||
|
||||
Place it alongside existing `/api/*` and `/management.ManagementService/*` routes, then reload:
|
||||
|
||||
```bash
|
||||
docker compose restart caddy
|
||||
# or
|
||||
sudo systemctl reload caddy
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Nginx</strong></summary>
|
||||
|
||||
```nginx
|
||||
location /oauth2/ {
|
||||
proxy_pass http://management:80;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
```
|
||||
|
||||
Reload nginx after adding the route.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Traefik</strong></summary>
|
||||
|
||||
Add a route matching the `/oauth2/` path prefix, forwarding to the management service.
|
||||
</details>
|
||||
|
||||
**Verify the route works:**
|
||||
|
||||
```bash
|
||||
curl -s https://<your-domain>/oauth2/.well-known/openid-configuration | head -5
|
||||
```
|
||||
|
||||
Expected: a JSON response with `"issuer": "https://<your-domain>/oauth2"`.
|
||||
|
||||
### 6b. Update dashboard environment
|
||||
|
||||
If your dashboard uses a separate `dashboard.env` or environment variables, update the OAuth settings:
|
||||
|
||||
```bash
|
||||
# Before (external IdP)
|
||||
AUTH_AUTHORITY=https://external-idp.example.com
|
||||
AUTH_CLIENT_ID=old-client-id
|
||||
AUTH_AUDIENCE=old-audience
|
||||
|
||||
# After (embedded Dex)
|
||||
AUTH_AUTHORITY=https://<your-domain>/oauth2
|
||||
AUTH_CLIENT_ID=netbird-dashboard
|
||||
AUTH_AUDIENCE=netbird-dashboard
|
||||
```
|
||||
|
||||
Restart the dashboard after updating.
|
||||
|
||||
---
|
||||
|
||||
## Step 7: Start and Verify
|
||||
|
||||
### Start the management server
|
||||
|
||||
```bash
|
||||
# Systemd
|
||||
sudo systemctl start netbird-management
|
||||
|
||||
# Docker Compose
|
||||
docker compose up -d management
|
||||
```
|
||||
|
||||
### Verify everything works
|
||||
|
||||
1. **OIDC discovery:** Open `https://<your-domain>/oauth2/.well-known/openid-configuration` — it should return valid JSON.
|
||||
2. **Dashboard login:** Log in to the dashboard — you should be redirected through your external IdP as before.
|
||||
3. **Data integrity:** Check that peers are visible and policies are intact.
|
||||
|
||||
> **Tip:** Use an incognito/private browser window or clear cookies for your first login. Stale tokens from the old IdP will fail validation.
|
||||
|
||||
---
|
||||
|
||||
## Command Reference
|
||||
|
||||
```
|
||||
Usage: netbird-idp-migrate [flags]
|
||||
|
||||
Flags:
|
||||
--config string Path to management.json (required)
|
||||
--datadir string Override data directory from config
|
||||
--idp-seed-info string Base64-encoded connector JSON (overrides auto-detection)
|
||||
--dry-run Preview changes without writing
|
||||
--force Skip confirmation prompt
|
||||
--skip-config Skip config generation (DB migration only)
|
||||
--log-level string Log level: debug, info, warn, error (default "info")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Advanced Scenarios
|
||||
|
||||
### DB-only migration (manual config editing)
|
||||
|
||||
Migrate user IDs in the database but skip config generation:
|
||||
|
||||
```bash
|
||||
./netbird-idp-migrate \
|
||||
--config /etc/netbird/management.json \
|
||||
--skip-config
|
||||
```
|
||||
|
||||
### Non-interactive (CI / scripts)
|
||||
|
||||
```bash
|
||||
./netbird-idp-migrate \
|
||||
--config /etc/netbird/management.json \
|
||||
--force
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "store does not support migration operations"
|
||||
|
||||
The store implementation is missing the required `ListUsers`/`UpdateUserID` methods. Upgrade to v0.66.4+ binaries.
|
||||
|
||||
### "could not determine domain"
|
||||
|
||||
The tool couldn't infer your management server's domain. Either set `HttpConfig.LetsEncryptDomain` in `management.json` before running, or use `--skip-config` and configure the embedded IdP section manually.
|
||||
|
||||
### "could not open activity store"
|
||||
|
||||
This is a **warning**, not an error. If `events.db` doesn't exist (e.g., fresh install), activity event migration is skipped. User ID migration in the main database still proceeds normally.
|
||||
|
||||
### "no connector configuration found"
|
||||
|
||||
No IdP configuration was detected. Provide it explicitly with `--idp-seed-info`, set the `IDP_SEED_INFO` env var, or ensure `IdpManagerConfig` is present in `management.json`.
|
||||
|
||||
### "zitadel auto-detection is not supported"
|
||||
|
||||
Zitadel's management config uses service account credentials that aren't valid OAuth client credentials. Follow the [Zitadel setup](#zitadel) in Step 2 to create a dedicated OAuth application.
|
||||
|
||||
### "no client secret found"
|
||||
|
||||
The Dex OIDC connector requires a confidential OAuth client with a client secret. If `IdpManagerConfig.ClientConfig.ClientSecret` is empty in your config, provide the connector credentials via `--idp-seed-info`.
|
||||
|
||||
### "Errors.App.NotFound" from Zitadel after migration
|
||||
|
||||
The dashboard is still redirecting to Zitadel's `/oauth/v2/` endpoint instead of the management server's `/oauth2` endpoint. Set `AUTH_AUTHORITY=https://<your-domain>/oauth2` in your dashboard environment — see [Step 6b](#6b-update-dashboard-environment).
|
||||
|
||||
### OIDC discovery returns 404
|
||||
|
||||
The `/oauth2/` path is not being routed to the management server. Add a reverse proxy route — see [Step 6a](#6a-update-your-reverse-proxy).
|
||||
|
||||
### "jumpcloud does not have a supported Dex connector type"
|
||||
|
||||
JumpCloud has no native Dex connector. Configure a generic OIDC connector manually with `--idp-seed-info` — see [Custom providers](#custom--unsupported-provider-jumpcloud-etc) in Step 2.
|
||||
|
||||
### "failed to create embedded IDP service: cannot disable local authentication..."
|
||||
|
||||
The embedded IdP didn't support `StaticConnectors` in the config until post-`<INSERT_VERSION>`. Upgrade to a version that includes this fix.
|
||||
|
||||
### Partial failure / re-running
|
||||
|
||||
The migration is **idempotent**. Already-migrated users are detected and skipped. If the tool fails partway through, fix the underlying issue and re-run — it picks up where it left off.
|
||||
|
||||
---
|
||||
|
||||
## Rolling Back
|
||||
|
||||
If something goes wrong after migration:
|
||||
|
||||
1. **Stop** the management server.
|
||||
2. **Restore the database:**
|
||||
- SQLite (bare-metal): `cp /var/lib/netbird/store.db.bak /var/lib/netbird/store.db`
|
||||
- SQLite (Docker volume): `sudo cp $VOLUME_PATH/store.db.bak $VOLUME_PATH/store.db`
|
||||
- PostgreSQL: restore from your `pg_dump` backup
|
||||
3. **Restore the config:** `cp /etc/netbird/management.json.bak /etc/netbird/management.json`
|
||||
4. **Revert** any reverse proxy or dashboard env changes.
|
||||
5. **Start** the management server.
|
||||
613
tools/idp-migrate/main.go
Normal file
613
tools/idp-migrate/main.go
Normal file
@@ -0,0 +1,613 @@
|
||||
// Package main provides a standalone CLI tool to migrate user IDs from an
|
||||
// external IdP format to the embedded Dex IdP format used by NetBird >= v0.62.0.
|
||||
//
|
||||
// This tool reads management.json to auto-detect the current external IdP
|
||||
// configuration (issuer, clientID, clientSecret, type) and re-encodes all user
|
||||
// IDs in the database to the Dex protobuf-encoded format. It works independently
|
||||
// of migrate.sh and the combined server, allowing operators to migrate their
|
||||
// database before switching to the combined server.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// netbird-idp-migrate --config /etc/netbird/management.json [--dry-run] [--force]
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/idp/dex"
|
||||
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
|
||||
activitystore "github.com/netbirdio/netbird/management/server/activity/store"
|
||||
"github.com/netbirdio/netbird/management/server/idp"
|
||||
"github.com/netbirdio/netbird/management/server/idp/migration"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
"github.com/netbirdio/netbird/util/crypt"
|
||||
)
|
||||
|
||||
// migrationServer implements migration.Server by wrapping the migration-specific interfaces.
|
||||
type migrationServer struct {
|
||||
store migration.Store
|
||||
eventStore migration.EventStore
|
||||
}
|
||||
|
||||
func (s *migrationServer) Store() migration.Store { return s.store }
|
||||
func (s *migrationServer) EventStore() migration.EventStore { return s.eventStore }
|
||||
|
||||
func main() {
|
||||
var (
|
||||
configPath string
|
||||
dataDir string
|
||||
idpSeedInfo string
|
||||
dryRun bool
|
||||
force bool
|
||||
skipConfig bool
|
||||
skipPopulateUserInfo bool
|
||||
logLevel string
|
||||
)
|
||||
|
||||
flag.StringVar(&configPath, "config", "", "path to management.json (required)")
|
||||
flag.StringVar(&dataDir, "datadir", "", "override data directory from config")
|
||||
flag.StringVar(&idpSeedInfo, "idp-seed-info", "", "base64-encoded connector JSON (overrides auto-detection)")
|
||||
flag.BoolVar(&dryRun, "dry-run", false, "preview changes without writing")
|
||||
flag.BoolVar(&force, "force", false, "skip confirmation prompt")
|
||||
flag.BoolVar(&skipConfig, "skip-config", false, "skip config generation (DB migration only)")
|
||||
flag.BoolVar(&skipPopulateUserInfo, "skip-populate-user-info", false, "skip populating user info (user id migration only)")
|
||||
flag.StringVar(&logLevel, "log-level", "info", "log level (debug, info, warn, error)")
|
||||
flag.Parse()
|
||||
|
||||
if err := util.InitLog(logLevel, util.LogConsole); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to init logger: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := run(configPath, dataDir, idpSeedInfo, dryRun, force, skipConfig, skipPopulateUserInfo); err != nil {
|
||||
log.Fatalf("migration failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func run(configPath, dataDirOverride, idpSeedInfo string, dryRun, force, skipConfig, skipPopulateUserInfo bool) error {
|
||||
if configPath == "" {
|
||||
return fmt.Errorf("--config is required")
|
||||
}
|
||||
|
||||
cfg, err := loadConfig(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
|
||||
effectiveDataDir := cfg.Datadir
|
||||
if dataDirOverride != "" {
|
||||
effectiveDataDir = dataDirOverride
|
||||
}
|
||||
if effectiveDataDir == "" {
|
||||
return fmt.Errorf("data directory not set: use --datadir or set Datadir in management.json")
|
||||
}
|
||||
|
||||
// Validate the database schema before attempting any operations.
|
||||
if err := validateSchema(cfg, effectiveDataDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !skipPopulateUserInfo {
|
||||
err := populateUserInfoFromIDP(cfg, effectiveDataDir, dryRun)
|
||||
if err != nil {
|
||||
return fmt.Errorf("populate user info: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
conn, err := resolveConnector(idpSeedInfo, cfg)
|
||||
if err != nil && errors.Is(err, ErrNoIdpManagerConfig) {
|
||||
return fmt.Errorf("no connector configuration found: provide --idp-seed-info, set IDP_SEED_INFO env var, or configure IdpManagerConfig in management.json")
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve connector: %w", err)
|
||||
}
|
||||
if conn.ID == "" {
|
||||
return fmt.Errorf("connector ID is empty")
|
||||
}
|
||||
|
||||
log.Infof("resolved connector: type=%s, id=%s, name=%s", conn.Type, conn.ID, conn.Name)
|
||||
|
||||
if err := migrateDB(cfg, effectiveDataDir, conn, dryRun, force); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if skipConfig {
|
||||
log.Info("skipping config generation (--skip-config)")
|
||||
return nil
|
||||
}
|
||||
|
||||
return generateConfig(configPath, conn, cfg, dryRun)
|
||||
}
|
||||
|
||||
// validateSchema opens the store and checks that all required tables and columns
|
||||
// exist. If anything is missing, it returns a descriptive error telling the user
|
||||
// to upgrade their management server.
|
||||
func validateSchema(cfg *nbconfig.Config, dataDir string) error {
|
||||
ctx := context.Background()
|
||||
migStore, migEventStore, cleanup, err := openStores(ctx, cfg, dataDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
errs := migStore.CheckSchema(migration.RequiredSchema)
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("%s", formatSchemaErrors(errs))
|
||||
}
|
||||
|
||||
if migEventStore != nil {
|
||||
eventErrs := migEventStore.CheckSchema(migration.RequiredEventSchema)
|
||||
if len(eventErrs) > 0 {
|
||||
return fmt.Errorf("activity store schema check failed (upgrade management server first):\n%s", formatSchemaErrors(eventErrs))
|
||||
}
|
||||
}
|
||||
|
||||
log.Info("database schema check passed")
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatSchemaErrors returns a user-friendly message listing all missing schema
|
||||
// elements and instructing the operator to upgrade.
|
||||
func formatSchemaErrors(errs []migration.SchemaError) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("database schema is incomplete — the following tables/columns are missing:\n")
|
||||
for _, e := range errs {
|
||||
b.WriteString(fmt.Sprintf(" - %s\n", e.String()))
|
||||
}
|
||||
b.WriteString("\nPlease start the NetBird management server (v0.66.4+) at least once so that automatic database migrations create the required schema, then re-run this tool.\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// populateUserInfoFromIDP creates an IDP manager from the config, fetches all
|
||||
// user data (email, name) from the external IDP, and updates the store for users
|
||||
// that are missing this information.
|
||||
func populateUserInfoFromIDP(cfg *nbconfig.Config, dataDir string, dryRun bool) error {
|
||||
ctx := context.Background()
|
||||
|
||||
if cfg.IdpManagerConfig == nil {
|
||||
return fmt.Errorf("IdpManagerConfig is not set in management.json; cannot fetch user info from IDP")
|
||||
}
|
||||
|
||||
idpManager, err := idp.NewManager(ctx, *cfg.IdpManagerConfig, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create IDP manager: %w", err)
|
||||
}
|
||||
if idpManager == nil {
|
||||
return fmt.Errorf("IDP manager type is 'none' or empty; cannot fetch user info")
|
||||
}
|
||||
|
||||
log.Infof("created IDP manager (type: %s)", cfg.IdpManagerConfig.ManagerType)
|
||||
|
||||
migStore, _, cleanup, err := openStores(ctx, cfg, dataDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
srv := &migrationServer{store: migStore}
|
||||
return migration.PopulateUserInfo(srv, idpManager, dryRun)
|
||||
}
|
||||
|
||||
// openStores opens the main and activity stores, returning migration-specific interfaces.
|
||||
// The caller must call the returned cleanup function to close the stores.
|
||||
func openStores(ctx context.Context, cfg *nbconfig.Config, dataDir string) (migration.Store, migration.EventStore, func(), error) {
|
||||
engine := cfg.StoreConfig.Engine
|
||||
if engine == "" {
|
||||
engine = types.SqliteStoreEngine
|
||||
}
|
||||
|
||||
mainStore, err := store.NewStore(ctx, engine, dataDir, nil, true)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("open main store: %w", err)
|
||||
}
|
||||
|
||||
if cfg.DataStoreEncryptionKey != "" {
|
||||
fieldEncrypt, err := crypt.NewFieldEncrypt(cfg.DataStoreEncryptionKey)
|
||||
if err != nil {
|
||||
_ = mainStore.Close(ctx)
|
||||
return nil, nil, nil, fmt.Errorf("init field encryption: %w", err)
|
||||
}
|
||||
mainStore.SetFieldEncrypt(fieldEncrypt)
|
||||
}
|
||||
|
||||
migStore, ok := mainStore.(migration.Store)
|
||||
if !ok {
|
||||
_ = mainStore.Close(ctx)
|
||||
return nil, nil, nil, fmt.Errorf("store does not support migration operations (ListUsers/UpdateUserID)")
|
||||
}
|
||||
|
||||
cleanup := func() { _ = mainStore.Close(ctx) }
|
||||
|
||||
var migEventStore migration.EventStore
|
||||
actStore, err := activitystore.NewSqlStore(ctx, dataDir, cfg.DataStoreEncryptionKey)
|
||||
if err != nil {
|
||||
log.Warnf("could not open activity store (events.db may not exist): %v", err)
|
||||
} else {
|
||||
migEventStore = actStore
|
||||
prevCleanup := cleanup
|
||||
cleanup = func() { _ = actStore.Close(ctx); prevCleanup() }
|
||||
}
|
||||
|
||||
return migStore, migEventStore, cleanup, nil
|
||||
}
|
||||
|
||||
// migrateDB opens the stores, previews pending users, and runs the DB migration.
|
||||
func migrateDB(cfg *nbconfig.Config, dataDir string, conn *dex.Connector, dryRun, force bool) error {
|
||||
ctx := context.Background()
|
||||
|
||||
migStore, migEventStore, cleanup, err := openStores(ctx, cfg, dataDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
pending, err := previewUsers(ctx, migStore)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if pending == 0 {
|
||||
log.Info("no users need migration — all done")
|
||||
return nil
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
if err := os.Setenv("NB_IDP_MIGRATION_DRY_RUN", "true"); err != nil {
|
||||
return fmt.Errorf("set dry-run env: %w", err)
|
||||
}
|
||||
defer os.Unsetenv("NB_IDP_MIGRATION_DRY_RUN") //nolint:errcheck
|
||||
}
|
||||
|
||||
if !dryRun && !force {
|
||||
if !confirmPrompt(pending) {
|
||||
log.Info("migration cancelled by user")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
srv := &migrationServer{store: migStore, eventStore: migEventStore}
|
||||
if err := migration.MigrateUsersToStaticConnectors(srv, conn); err != nil {
|
||||
return fmt.Errorf("migrate users: %w", err)
|
||||
}
|
||||
|
||||
if !dryRun {
|
||||
log.Info("DB migration completed successfully")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// previewUsers counts pending vs already-migrated users and logs a summary.
|
||||
// Returns the number of users still needing migration.
|
||||
func previewUsers(ctx context.Context, migStore migration.Store) (int, error) {
|
||||
users, err := migStore.ListUsers(ctx)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("list users: %w", err)
|
||||
}
|
||||
|
||||
var pending, alreadyMigrated int
|
||||
for _, u := range users {
|
||||
if _, _, decErr := dex.DecodeDexUserID(u.Id); decErr == nil {
|
||||
alreadyMigrated++
|
||||
} else {
|
||||
pending++
|
||||
}
|
||||
}
|
||||
|
||||
log.Infof("found %d total users: %d pending migration, %d already migrated", len(users), pending, alreadyMigrated)
|
||||
return pending, nil
|
||||
}
|
||||
|
||||
// confirmPrompt asks the user for interactive confirmation. Returns true if they accept.
|
||||
func confirmPrompt(pending int) bool {
|
||||
log.Infof("About to migrate %d users. This cannot be easily undone. Continue? [y/N] ", pending)
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
answer, _ := reader.ReadString('\n')
|
||||
answer = strings.TrimSpace(strings.ToLower(answer))
|
||||
return answer == "y" || answer == "yes"
|
||||
}
|
||||
|
||||
// loadConfig reads management.json into the management config struct.
|
||||
func loadConfig(path string) (*nbconfig.Config, error) {
|
||||
cfg := &nbconfig.Config{}
|
||||
if _, err := util.ReadJsonWithEnvSub(path, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// resolveConnector determines the Dex connector using a three-tier priority:
|
||||
// 1. --idp-seed-info flag (explicit base64-encoded JSON)
|
||||
// 2. IDP_SEED_INFO env var
|
||||
// 3. Auto-detect from management.json's IdpManagerConfig
|
||||
func resolveConnector(flagValue string, cfg *nbconfig.Config) (*dex.Connector, error) {
|
||||
// Priority 1: explicit flag
|
||||
if flagValue != "" {
|
||||
return decodeConnector(flagValue)
|
||||
}
|
||||
|
||||
// Priority 2: env var
|
||||
conn, err := migration.SeedConnectorFromEnv()
|
||||
if err != nil && !errors.Is(err, migration.ErrNoSeedInfo) {
|
||||
return nil, fmt.Errorf("reading the IDP_SEED_INFO env var: %w", err)
|
||||
}
|
||||
|
||||
// If env var is set, return it, otherwise it was empty and we'll try auto-detect
|
||||
if conn != nil {
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// Priority 3: auto-detect from config
|
||||
return buildConnectorFromConfig(cfg)
|
||||
}
|
||||
|
||||
// decodeConnector base64-decodes and JSON-unmarshals a connector.
|
||||
func decodeConnector(encoded string) (*dex.Connector, error) {
|
||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("base64 decode: %w", err)
|
||||
}
|
||||
|
||||
var conn dex.Connector
|
||||
if err := json.Unmarshal(decoded, &conn); err != nil {
|
||||
return nil, fmt.Errorf("json unmarshal: %w", err)
|
||||
}
|
||||
|
||||
return &conn, nil
|
||||
}
|
||||
|
||||
var ErrNoIdpManagerConfig = errors.New("no IdpManagerConfig or IdpManagerConfig.ClientConfig found in management config file")
|
||||
|
||||
// buildConnectorFromConfig constructs a Dex connector from management.json's
|
||||
// IdpManagerConfig fields (issuer, clientID, clientSecret, type).
|
||||
//
|
||||
// Some providers (Zitadel, Google Workspace) use service account credentials in
|
||||
// IdpManagerConfig that are NOT valid OAuth client credentials. For these providers,
|
||||
// auto-detection returns an error with instructions to use --idp-seed-info instead.
|
||||
func buildConnectorFromConfig(cfg *nbconfig.Config) (*dex.Connector, error) {
|
||||
idpCfg := cfg.IdpManagerConfig
|
||||
if idpCfg == nil || idpCfg.ClientConfig == nil {
|
||||
return nil, ErrNoIdpManagerConfig
|
||||
}
|
||||
|
||||
managerType := strings.ToLower(idpCfg.ManagerType)
|
||||
|
||||
connType, err := mapManagerTypeToConnectorType(managerType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Zitadel's IdpManagerConfig uses a service account (not an OAuth app).
|
||||
// The ClientID is a service user login name (e.g., "netbird-service-account"),
|
||||
// not a Zitadel OAuth application client ID. These credentials cannot be used
|
||||
// for the Dex OIDC connector.
|
||||
if managerType == "zitadel" {
|
||||
issuer := idpCfg.ClientConfig.Issuer
|
||||
if issuer == "" && cfg.HttpConfig != nil {
|
||||
issuer = cfg.HttpConfig.AuthIssuer
|
||||
}
|
||||
return nil, fmt.Errorf(`zitadel auto-detection is not supported because the management server uses service account credentials (ClientID: %q) which are not valid OAuth client credentials for the Dex connector.
|
||||
|
||||
You need to create a confidential OAuth application in Zitadel:
|
||||
|
||||
1. Open the Zitadel console at %s/ui/console
|
||||
2. Navigate to Projects → select the NetBird project → Applications
|
||||
3. Create a new application:
|
||||
Name: netbird-dex
|
||||
Type: Web
|
||||
Authentication Method: POST (sends client_id + client_secret in body)
|
||||
4. Add redirect URI: https://<your-management-domain>/oauth2/callback
|
||||
5. Copy the generated client ID and client secret
|
||||
6. Create connector.json:
|
||||
{
|
||||
"type": "zitadel",
|
||||
"name": "zitadel",
|
||||
"id": "zitadel",
|
||||
"config": {
|
||||
"issuer": "%s",
|
||||
"clientID": "<client-id-from-step-5>",
|
||||
"clientSecret": "<client-secret-from-step-5>",
|
||||
"redirectURI": "https://<your-management-domain>/oauth2/callback"
|
||||
}
|
||||
}
|
||||
7. Run: netbird-idp-migrate --config management.json --idp-seed-info "$(base64 < connector.json)"`,
|
||||
idpCfg.ClientConfig.ClientID,
|
||||
strings.TrimSuffix(issuer, "/"),
|
||||
issuer,
|
||||
)
|
||||
}
|
||||
|
||||
issuer := idpCfg.ClientConfig.Issuer
|
||||
if issuer == "" && cfg.HttpConfig != nil {
|
||||
issuer = cfg.HttpConfig.AuthIssuer
|
||||
}
|
||||
if issuer == "" {
|
||||
return nil, fmt.Errorf("could not determine OIDC issuer from config (neither ClientConfig.Issuer nor HttpConfig.AuthIssuer set)")
|
||||
}
|
||||
|
||||
clientSecret := idpCfg.ClientConfig.ClientSecret
|
||||
if clientSecret == "" {
|
||||
return nil, fmt.Errorf("no client secret found in IdpManagerConfig.ClientConfig; the Dex OIDC connector requires a confidential client with a secret — provide credentials via --idp-seed-info")
|
||||
}
|
||||
|
||||
// Use issuer as a reasonable default for redirectURI; generateConfig() will
|
||||
// override this with the correct management domain.
|
||||
redirectURI := strings.TrimSuffix(issuer, "/") + "/oauth2/callback"
|
||||
|
||||
connID := managerType
|
||||
|
||||
return &dex.Connector{
|
||||
Type: connType,
|
||||
Name: idpCfg.ManagerType,
|
||||
ID: connID,
|
||||
Config: map[string]interface{}{
|
||||
"issuer": issuer,
|
||||
"clientID": idpCfg.ClientConfig.ClientID,
|
||||
"clientSecret": clientSecret,
|
||||
"redirectURI": redirectURI,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// mapManagerTypeToConnectorType maps management.json ManagerType values to the
|
||||
// connector type strings that Dex uses. These must match the types in
|
||||
// idp/dex/connector.go's buildStorageConnector switch.
|
||||
func mapManagerTypeToConnectorType(managerType string) (string, error) {
|
||||
switch strings.ToLower(managerType) {
|
||||
case "zitadel":
|
||||
return "zitadel", nil
|
||||
case "keycloak":
|
||||
return "keycloak", nil
|
||||
case "okta":
|
||||
return "okta", nil
|
||||
case "authentik":
|
||||
return "authentik", nil
|
||||
case "pocketid":
|
||||
return "pocketid", nil
|
||||
case "auth0":
|
||||
// Auth0 uses generic OIDC in Dex (no named connector)
|
||||
return "oidc", nil
|
||||
case "azure":
|
||||
return "entra", nil
|
||||
case "google":
|
||||
return "google", nil
|
||||
case "jumpcloud":
|
||||
return "", fmt.Errorf("jumpcloud does not have a supported Dex connector type")
|
||||
default:
|
||||
// Generic OIDC fallback
|
||||
return "oidc", nil
|
||||
}
|
||||
}
|
||||
|
||||
// generateConfig reads the existing management.json as raw JSON, removes
|
||||
// IdpManagerConfig, adds EmbeddedIdP, updates HttpConfig fields, and writes
|
||||
// the result. In dry-run mode, it prints the new config to stdout instead.
|
||||
func generateConfig(configPath string, conn *dex.Connector, cfg *nbconfig.Config, dryRun bool) error {
|
||||
domain, err := deriveDomain(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("derive domain: %w", err)
|
||||
}
|
||||
log.Infof("derived domain for embedded IdP: %s", domain)
|
||||
|
||||
// Read existing config as raw JSON to preserve all fields
|
||||
raw, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read config file: %w", err)
|
||||
}
|
||||
|
||||
var configMap map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &configMap); err != nil {
|
||||
return fmt.Errorf("parse config JSON: %w", err)
|
||||
}
|
||||
|
||||
// Remove old IdP config
|
||||
delete(configMap, "IdpManagerConfig")
|
||||
|
||||
// Ensure the connector's redirectURI points to the management server (Dex callback),
|
||||
// not the external IdP. The auto-detection may have used the IdP issuer URL.
|
||||
connConfig := make(map[string]interface{}, len(conn.Config))
|
||||
for k, v := range conn.Config {
|
||||
connConfig[k] = v
|
||||
}
|
||||
connConfig["redirectURI"] = fmt.Sprintf("https://%s/oauth2/callback", domain)
|
||||
|
||||
// Add minimal EmbeddedIdP section
|
||||
configMap["EmbeddedIdP"] = map[string]interface{}{
|
||||
"Enabled": true,
|
||||
"Issuer": fmt.Sprintf("https://%s/oauth2", domain),
|
||||
"DashboardRedirectURIs": []string{
|
||||
fmt.Sprintf("https://%s/nb-auth", domain),
|
||||
fmt.Sprintf("https://%s/nb-silent-auth", domain),
|
||||
},
|
||||
"StaticConnectors": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": conn.Type,
|
||||
"name": conn.Name,
|
||||
"id": conn.ID,
|
||||
"config": connConfig,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
newJSON, err := json.MarshalIndent(configMap, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal new config: %w", err)
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
log.Info("[DRY RUN] new management.json would be:")
|
||||
log.Infoln(string(newJSON))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Backup original
|
||||
backupPath := configPath + ".bak"
|
||||
if err := os.WriteFile(backupPath, raw, 0600); err != nil {
|
||||
return fmt.Errorf("write backup: %w", err)
|
||||
}
|
||||
log.Infof("backed up original config to %s", backupPath)
|
||||
|
||||
// Write new config
|
||||
if err := os.WriteFile(configPath, newJSON, 0600); err != nil {
|
||||
return fmt.Errorf("write new config: %w", err)
|
||||
}
|
||||
log.Infof("wrote new config to %s", configPath)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// deriveDomain determines the management server domain from existing config,
|
||||
// using a priority-based approach.
|
||||
func deriveDomain(cfg *nbconfig.Config) (string, error) {
|
||||
// Priority 1: LetsEncryptDomain (most explicit)
|
||||
if cfg.HttpConfig != nil && cfg.HttpConfig.LetsEncryptDomain != "" {
|
||||
return cfg.HttpConfig.LetsEncryptDomain, nil
|
||||
}
|
||||
|
||||
// Priority 2: parse from OIDCConfigEndpoint
|
||||
if cfg.HttpConfig != nil && cfg.HttpConfig.OIDCConfigEndpoint != "" {
|
||||
if host := hostFromURL(cfg.HttpConfig.OIDCConfigEndpoint); host != "" {
|
||||
return host, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: parse from AuthIssuer
|
||||
if cfg.HttpConfig != nil && cfg.HttpConfig.AuthIssuer != "" {
|
||||
if host := hostFromURL(cfg.HttpConfig.AuthIssuer); host != "" {
|
||||
return host, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 4: parse from IdpManagerConfig.ClientConfig.Issuer
|
||||
if cfg.IdpManagerConfig != nil && cfg.IdpManagerConfig.ClientConfig != nil && cfg.IdpManagerConfig.ClientConfig.Issuer != "" {
|
||||
if host := hostFromURL(cfg.IdpManagerConfig.ClientConfig.Issuer); host != "" {
|
||||
return host, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not determine domain: set HttpConfig.LetsEncryptDomain, HttpConfig.AuthIssuer, or HttpConfig.OIDCConfigEndpoint in management.json")
|
||||
}
|
||||
|
||||
// hostFromURL extracts the host (without port) from a URL string.
|
||||
func hostFromURL(rawURL string) string {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return u.Hostname()
|
||||
}
|
||||
|
||||
// Compile-time check that migrationServer implements migration.Server.
|
||||
var _ migration.Server = (*migrationServer)(nil)
|
||||
587
tools/idp-migrate/main_test.go
Normal file
587
tools/idp-migrate/main_test.go
Normal file
@@ -0,0 +1,587 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/idp/dex"
|
||||
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
|
||||
"github.com/netbirdio/netbird/management/server/idp"
|
||||
"github.com/netbirdio/netbird/management/server/idp/migration"
|
||||
)
|
||||
|
||||
// TestMigrationServerInterface is a compile-time check that migrationServer
|
||||
// implements the migration.Server interface.
|
||||
func TestMigrationServerInterface(t *testing.T) {
|
||||
var _ migration.Server = (*migrationServer)(nil)
|
||||
}
|
||||
|
||||
func TestResolveConnector_FlagOverridesEnv(t *testing.T) {
|
||||
flagConn := dex.Connector{
|
||||
Type: "oidc",
|
||||
Name: "from-flag",
|
||||
ID: "flag-id",
|
||||
Config: map[string]interface{}{
|
||||
"issuer": "https://flag.example.com",
|
||||
},
|
||||
}
|
||||
flagJSON, err := json.Marshal(flagConn)
|
||||
require.NoError(t, err)
|
||||
flagB64 := base64.StdEncoding.EncodeToString(flagJSON)
|
||||
|
||||
envConn := dex.Connector{
|
||||
Type: "oidc",
|
||||
Name: "from-env",
|
||||
ID: "env-id",
|
||||
Config: map[string]interface{}{
|
||||
"issuer": "https://env.example.com",
|
||||
},
|
||||
}
|
||||
envJSON, err := json.Marshal(envConn)
|
||||
require.NoError(t, err)
|
||||
envB64 := base64.StdEncoding.EncodeToString(envJSON)
|
||||
|
||||
t.Setenv("IDP_SEED_INFO", envB64)
|
||||
|
||||
cfg := &nbconfig.Config{
|
||||
IdpManagerConfig: &idp.Config{
|
||||
ManagerType: "zitadel",
|
||||
ClientConfig: &idp.ClientConfig{
|
||||
Issuer: "https://config.example.com",
|
||||
ClientID: "config-client",
|
||||
ClientSecret: "config-secret",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Flag takes priority over env and config
|
||||
conn, err := resolveConnector(flagB64, cfg)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, conn)
|
||||
assert.Equal(t, "flag-id", conn.ID)
|
||||
assert.Equal(t, "from-flag", conn.Name)
|
||||
|
||||
// Empty flag → env takes priority over config
|
||||
conn, err = resolveConnector("", cfg)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, conn)
|
||||
assert.Equal(t, "env-id", conn.ID)
|
||||
assert.Equal(t, "from-env", conn.Name)
|
||||
|
||||
// Empty flag + no env → config auto-detect (uses keycloak which works with auto-detect)
|
||||
t.Setenv("IDP_SEED_INFO", "")
|
||||
cfgKeycloak := &nbconfig.Config{
|
||||
IdpManagerConfig: &idp.Config{
|
||||
ManagerType: "keycloak",
|
||||
ClientConfig: &idp.ClientConfig{
|
||||
Issuer: "https://kc.example.com/realms/test",
|
||||
ClientID: "config-client",
|
||||
ClientSecret: "config-secret",
|
||||
},
|
||||
},
|
||||
}
|
||||
conn, err = resolveConnector("", cfgKeycloak)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, conn)
|
||||
assert.Equal(t, "keycloak", conn.ID)
|
||||
}
|
||||
|
||||
func TestResolveConnector_InvalidBase64(t *testing.T) {
|
||||
cfg := &nbconfig.Config{}
|
||||
_, err := resolveConnector("not-valid-base64!!!", cfg)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "base64 decode")
|
||||
}
|
||||
|
||||
func TestResolveConnector_InvalidJSON(t *testing.T) {
|
||||
cfg := &nbconfig.Config{}
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte("not json"))
|
||||
_, err := resolveConnector(encoded, cfg)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "json unmarshal")
|
||||
}
|
||||
|
||||
func TestResolveConnector_EmptyConnectorID(t *testing.T) {
|
||||
conn := dex.Connector{
|
||||
Type: "oidc",
|
||||
Name: "no-id",
|
||||
ID: "",
|
||||
}
|
||||
data, err := json.Marshal(conn)
|
||||
require.NoError(t, err)
|
||||
|
||||
encoded := base64.StdEncoding.EncodeToString(data)
|
||||
result, err := resolveConnector(encoded, &nbconfig.Config{})
|
||||
require.NoError(t, err)
|
||||
// resolveConnector returns the connector; caller (run()) checks for empty ID
|
||||
assert.Equal(t, "", result.ID)
|
||||
}
|
||||
|
||||
func TestResolveConnector_NoConfigFallback(t *testing.T) {
|
||||
t.Setenv("IDP_SEED_INFO", "")
|
||||
|
||||
cfg := &nbconfig.Config{} // no IdpManagerConfig
|
||||
conn, err := resolveConnector("", cfg)
|
||||
require.ErrorIs(t, err, ErrNoIdpManagerConfig)
|
||||
assert.Nil(t, conn) // no connector found
|
||||
}
|
||||
|
||||
func TestBuildConnectorFromConfig_Zitadel(t *testing.T) {
|
||||
// Zitadel uses service account credentials in IdpManagerConfig, which are
|
||||
// not valid OAuth client credentials. Auto-detection should return an error
|
||||
// instructing the user to use --idp-seed-info.
|
||||
cfg := &nbconfig.Config{
|
||||
IdpManagerConfig: &idp.Config{
|
||||
ManagerType: "zitadel",
|
||||
ClientConfig: &idp.ClientConfig{
|
||||
Issuer: "https://zitadel.example.com",
|
||||
ClientID: "netbird-service-account",
|
||||
ClientSecret: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := buildConnectorFromConfig(cfg)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "zitadel auto-detection is not supported")
|
||||
assert.Contains(t, err.Error(), "--idp-seed-info")
|
||||
}
|
||||
|
||||
func TestBuildConnectorFromConfig_EmptyClientSecret(t *testing.T) {
|
||||
cfg := &nbconfig.Config{
|
||||
IdpManagerConfig: &idp.Config{
|
||||
ManagerType: "okta",
|
||||
ClientConfig: &idp.ClientConfig{
|
||||
Issuer: "https://dev-12345.okta.com",
|
||||
ClientID: "okta-id",
|
||||
ClientSecret: "", // empty secret
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := buildConnectorFromConfig(cfg)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no client secret")
|
||||
}
|
||||
|
||||
func TestBuildConnectorFromConfig_Auth0(t *testing.T) {
|
||||
cfg := &nbconfig.Config{
|
||||
IdpManagerConfig: &idp.Config{
|
||||
ManagerType: "auth0",
|
||||
ClientConfig: &idp.ClientConfig{
|
||||
Issuer: "https://tenant.auth0.com/",
|
||||
ClientID: "auth0-id",
|
||||
ClientSecret: "auth0-secret",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
conn, err := buildConnectorFromConfig(cfg)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, conn)
|
||||
assert.Equal(t, "oidc", conn.Type) // Auth0 maps to generic OIDC
|
||||
assert.Equal(t, "auth0", conn.ID)
|
||||
assert.Equal(t, "https://tenant.auth0.com/oauth2/callback", conn.Config["redirectURI"])
|
||||
}
|
||||
|
||||
func TestBuildConnectorFromConfig_Azure(t *testing.T) {
|
||||
cfg := &nbconfig.Config{
|
||||
IdpManagerConfig: &idp.Config{
|
||||
ManagerType: "azure",
|
||||
ClientConfig: &idp.ClientConfig{
|
||||
Issuer: "https://login.microsoftonline.com/tenant-id/v2.0",
|
||||
ClientID: "azure-id",
|
||||
ClientSecret: "azure-secret",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
conn, err := buildConnectorFromConfig(cfg)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, conn)
|
||||
assert.Equal(t, "entra", conn.Type) // Azure maps to entra
|
||||
assert.Equal(t, "azure", conn.ID)
|
||||
}
|
||||
|
||||
func TestBuildConnectorFromConfig_Google(t *testing.T) {
|
||||
cfg := &nbconfig.Config{
|
||||
IdpManagerConfig: &idp.Config{
|
||||
ManagerType: "google",
|
||||
ClientConfig: &idp.ClientConfig{
|
||||
Issuer: "https://accounts.google.com",
|
||||
ClientID: "google-id",
|
||||
ClientSecret: "google-secret",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
conn, err := buildConnectorFromConfig(cfg)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, conn)
|
||||
assert.Equal(t, "google", conn.Type)
|
||||
assert.Equal(t, "google", conn.ID)
|
||||
}
|
||||
|
||||
func TestBuildConnectorFromConfig_JumpCloud(t *testing.T) {
|
||||
cfg := &nbconfig.Config{
|
||||
IdpManagerConfig: &idp.Config{
|
||||
ManagerType: "jumpcloud",
|
||||
ClientConfig: &idp.ClientConfig{
|
||||
Issuer: "https://oauth.id.jumpcloud.com/",
|
||||
ClientID: "jc-id",
|
||||
ClientSecret: "jc-secret",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := buildConnectorFromConfig(cfg)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "jumpcloud")
|
||||
}
|
||||
|
||||
func TestBuildConnectorFromConfig_MissingClientConfig(t *testing.T) {
|
||||
cfg := &nbconfig.Config{
|
||||
IdpManagerConfig: &idp.Config{
|
||||
ManagerType: "keycloak",
|
||||
// no ClientConfig
|
||||
},
|
||||
}
|
||||
|
||||
conn, err := buildConnectorFromConfig(cfg)
|
||||
require.ErrorIs(t, err, ErrNoIdpManagerConfig)
|
||||
assert.Nil(t, conn) // returns nil, nil when no ClientConfig
|
||||
}
|
||||
|
||||
func TestBuildConnectorFromConfig_MissingIdpManagerConfig(t *testing.T) {
|
||||
cfg := &nbconfig.Config{}
|
||||
|
||||
conn, err := buildConnectorFromConfig(cfg)
|
||||
require.ErrorIs(t, err, ErrNoIdpManagerConfig)
|
||||
assert.Nil(t, conn)
|
||||
}
|
||||
|
||||
func TestBuildConnectorFromConfig_IssuerFallbackToHttpConfig(t *testing.T) {
|
||||
cfg := &nbconfig.Config{
|
||||
IdpManagerConfig: &idp.Config{
|
||||
ManagerType: "keycloak",
|
||||
ClientConfig: &idp.ClientConfig{
|
||||
// Issuer empty — should fall back to HttpConfig.AuthIssuer
|
||||
ClientID: "kc-id",
|
||||
ClientSecret: "kc-secret",
|
||||
},
|
||||
},
|
||||
HttpConfig: &nbconfig.HttpServerConfig{
|
||||
AuthIssuer: "https://keycloak.example.com/realms/myrealm",
|
||||
},
|
||||
}
|
||||
|
||||
conn, err := buildConnectorFromConfig(cfg)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, conn)
|
||||
assert.Equal(t, "keycloak", conn.Type)
|
||||
assert.Equal(t, "https://keycloak.example.com/realms/myrealm", conn.Config["issuer"])
|
||||
}
|
||||
|
||||
func TestBuildConnectorFromConfig_MissingIssuer(t *testing.T) {
|
||||
cfg := &nbconfig.Config{
|
||||
IdpManagerConfig: &idp.Config{
|
||||
ManagerType: "okta",
|
||||
ClientConfig: &idp.ClientConfig{
|
||||
ClientID: "okta-id",
|
||||
ClientSecret: "okta-secret",
|
||||
// Issuer empty, no HttpConfig either
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := buildConnectorFromConfig(cfg)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "issuer")
|
||||
}
|
||||
|
||||
func TestMapManagerTypeToConnectorType(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
wantErr bool
|
||||
}{
|
||||
{"zitadel", "zitadel", false},
|
||||
{"keycloak", "keycloak", false},
|
||||
{"okta", "okta", false},
|
||||
{"authentik", "authentik", false},
|
||||
{"pocketid", "pocketid", false},
|
||||
{"auth0", "oidc", false},
|
||||
{"azure", "entra", false},
|
||||
{"google", "google", false},
|
||||
{"jumpcloud", "", true},
|
||||
{"unknown-provider", "oidc", false}, // fallback to generic OIDC
|
||||
{"", "oidc", false}, // empty also falls through to default
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
result, err := mapManagerTypeToConnectorType(tt.input)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig(t *testing.T) {
|
||||
t.Run("valid config", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "management.json")
|
||||
|
||||
configJSON := `{
|
||||
"Datadir": "/var/lib/netbird",
|
||||
"DataStoreEncryptionKey": "example-encryption-key-0000",
|
||||
"StoreConfig": {
|
||||
"Engine": "sqlite"
|
||||
},
|
||||
"IdpManagerConfig": {
|
||||
"ManagerType": "zitadel",
|
||||
"ClientConfig": {
|
||||
"Issuer": "https://zitadel.example.com",
|
||||
"ClientID": "test-client",
|
||||
"ClientSecret": "test-secret"
|
||||
}
|
||||
}
|
||||
}`
|
||||
require.NoError(t, os.WriteFile(configPath, []byte(configJSON), 0600))
|
||||
|
||||
cfg, err := loadConfig(configPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "/var/lib/netbird", cfg.Datadir)
|
||||
assert.Equal(t, "example-encryption-key-0000", cfg.DataStoreEncryptionKey)
|
||||
require.NotNil(t, cfg.IdpManagerConfig)
|
||||
assert.Equal(t, "zitadel", cfg.IdpManagerConfig.ManagerType)
|
||||
assert.Equal(t, "test-client", cfg.IdpManagerConfig.ClientConfig.ClientID)
|
||||
})
|
||||
|
||||
t.Run("missing file", func(t *testing.T) {
|
||||
_, err := loadConfig("/nonexistent/path/management.json")
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("invalid json", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "bad.json")
|
||||
require.NoError(t, os.WriteFile(configPath, []byte("{invalid"), 0600))
|
||||
|
||||
_, err := loadConfig(configPath)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDecodeConnector(t *testing.T) {
|
||||
conn := dex.Connector{
|
||||
Type: "oidc",
|
||||
Name: "test",
|
||||
ID: "test-id",
|
||||
Config: map[string]interface{}{
|
||||
"issuer": "https://example.com",
|
||||
"clientID": "cid",
|
||||
"clientSecret": "csecret",
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(conn)
|
||||
require.NoError(t, err)
|
||||
encoded := base64.StdEncoding.EncodeToString(data)
|
||||
|
||||
result, err := decodeConnector(encoded)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "test-id", result.ID)
|
||||
assert.Equal(t, "oidc", result.Type)
|
||||
assert.Equal(t, "https://example.com", result.Config["issuer"])
|
||||
}
|
||||
|
||||
func TestDeriveDomain(t *testing.T) {
|
||||
t.Run("priority 1: LetsEncryptDomain", func(t *testing.T) {
|
||||
cfg := &nbconfig.Config{
|
||||
HttpConfig: &nbconfig.HttpServerConfig{
|
||||
LetsEncryptDomain: "mgmt.example.com",
|
||||
AuthIssuer: "https://other.example.com/oauth2",
|
||||
OIDCConfigEndpoint: "https://oidc.example.com/.well-known/openid-configuration",
|
||||
},
|
||||
}
|
||||
domain, err := deriveDomain(cfg)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "mgmt.example.com", domain)
|
||||
})
|
||||
|
||||
t.Run("priority 2: OIDCConfigEndpoint", func(t *testing.T) {
|
||||
cfg := &nbconfig.Config{
|
||||
HttpConfig: &nbconfig.HttpServerConfig{
|
||||
OIDCConfigEndpoint: "https://oidc.example.com/.well-known/openid-configuration",
|
||||
AuthIssuer: "https://issuer.example.com/oauth2",
|
||||
},
|
||||
}
|
||||
domain, err := deriveDomain(cfg)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "oidc.example.com", domain)
|
||||
})
|
||||
|
||||
t.Run("priority 3: AuthIssuer", func(t *testing.T) {
|
||||
cfg := &nbconfig.Config{
|
||||
HttpConfig: &nbconfig.HttpServerConfig{
|
||||
AuthIssuer: "https://issuer.example.com/oauth2",
|
||||
},
|
||||
}
|
||||
domain, err := deriveDomain(cfg)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "issuer.example.com", domain)
|
||||
})
|
||||
|
||||
t.Run("priority 4: IdpManagerConfig issuer", func(t *testing.T) {
|
||||
cfg := &nbconfig.Config{
|
||||
IdpManagerConfig: &idp.Config{
|
||||
ClientConfig: &idp.ClientConfig{
|
||||
Issuer: "https://zitadel.example.com",
|
||||
},
|
||||
},
|
||||
}
|
||||
domain, err := deriveDomain(cfg)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "zitadel.example.com", domain)
|
||||
})
|
||||
|
||||
t.Run("error when no domain found", func(t *testing.T) {
|
||||
cfg := &nbconfig.Config{}
|
||||
_, err := deriveDomain(cfg)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "could not determine domain")
|
||||
})
|
||||
}
|
||||
|
||||
func TestHostFromURL(t *testing.T) {
|
||||
assert.Equal(t, "example.com", hostFromURL("https://example.com/path"))
|
||||
assert.Equal(t, "example.com", hostFromURL("https://example.com:8080/path"))
|
||||
assert.Equal(t, "", hostFromURL("not-a-url"))
|
||||
}
|
||||
|
||||
func TestGenerateConfig(t *testing.T) {
|
||||
t.Run("generates valid config", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "management.json")
|
||||
|
||||
originalConfig := `{
|
||||
"Datadir": "/var/lib/netbird",
|
||||
"HttpConfig": {
|
||||
"LetsEncryptDomain": "mgmt.example.com",
|
||||
"AuthIssuer": "https://zitadel.example.com/oauth2",
|
||||
"AuthKeysLocation": "https://zitadel.example.com/oauth2/keys",
|
||||
"OIDCConfigEndpoint": "https://zitadel.example.com/.well-known/openid-configuration",
|
||||
"AuthClientID": "old-client-id",
|
||||
"AuthUserIDClaim": "preferred_username"
|
||||
},
|
||||
"IdpManagerConfig": {
|
||||
"ManagerType": "zitadel",
|
||||
"ClientConfig": {
|
||||
"Issuer": "https://zitadel.example.com",
|
||||
"ClientID": "zit-id",
|
||||
"ClientSecret": "zit-secret"
|
||||
}
|
||||
}
|
||||
}`
|
||||
require.NoError(t, os.WriteFile(configPath, []byte(originalConfig), 0600))
|
||||
|
||||
cfg := &nbconfig.Config{
|
||||
HttpConfig: &nbconfig.HttpServerConfig{
|
||||
LetsEncryptDomain: "mgmt.example.com",
|
||||
},
|
||||
}
|
||||
conn := &dex.Connector{
|
||||
Type: "zitadel",
|
||||
Name: "zitadel",
|
||||
ID: "zitadel",
|
||||
Config: map[string]interface{}{
|
||||
"issuer": "https://zitadel.example.com",
|
||||
"clientID": "zit-id",
|
||||
"clientSecret": "zit-secret",
|
||||
},
|
||||
}
|
||||
|
||||
err := generateConfig(configPath, conn, cfg, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check backup was created
|
||||
backupPath := configPath + ".bak"
|
||||
backupData, err := os.ReadFile(backupPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, originalConfig, string(backupData))
|
||||
|
||||
// Read and parse the new config
|
||||
newData, err := os.ReadFile(configPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
var result map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(newData, &result))
|
||||
|
||||
// IdpManagerConfig should be removed
|
||||
_, hasOldIdp := result["IdpManagerConfig"]
|
||||
assert.False(t, hasOldIdp, "IdpManagerConfig should be removed")
|
||||
|
||||
// EmbeddedIdP should be present with minimal fields
|
||||
embeddedIdP, ok := result["EmbeddedIdP"].(map[string]interface{})
|
||||
require.True(t, ok, "EmbeddedIdP should be present")
|
||||
assert.Equal(t, true, embeddedIdP["Enabled"])
|
||||
assert.Equal(t, "https://mgmt.example.com/oauth2", embeddedIdP["Issuer"])
|
||||
assert.Nil(t, embeddedIdP["LocalAuthDisabled"], "LocalAuthDisabled should not be set")
|
||||
assert.Nil(t, embeddedIdP["SignKeyRefreshEnabled"], "SignKeyRefreshEnabled should not be set")
|
||||
assert.Nil(t, embeddedIdP["CLIRedirectURIs"], "CLIRedirectURIs should not be set")
|
||||
|
||||
// HttpConfig should not be modified
|
||||
_, hasPKCE := result["PKCEAuthorizationFlow"]
|
||||
assert.False(t, hasPKCE, "PKCEAuthorizationFlow should not be added")
|
||||
|
||||
// Static connector's redirectURI should use the management domain
|
||||
connectors := embeddedIdP["StaticConnectors"].([]interface{})
|
||||
require.Len(t, connectors, 1)
|
||||
firstConn := connectors[0].(map[string]interface{})
|
||||
connCfg := firstConn["config"].(map[string]interface{})
|
||||
assert.Equal(t, "https://mgmt.example.com/oauth2/callback", connCfg["redirectURI"],
|
||||
"redirectURI should be overridden to use the management domain")
|
||||
|
||||
// Datadir should be preserved
|
||||
assert.Equal(t, "/var/lib/netbird", result["Datadir"])
|
||||
})
|
||||
|
||||
|
||||
t.Run("dry run does not write files", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "management.json")
|
||||
|
||||
originalConfig := `{"HttpConfig": {"LetsEncryptDomain": "mgmt.example.com"}}`
|
||||
require.NoError(t, os.WriteFile(configPath, []byte(originalConfig), 0600))
|
||||
|
||||
cfg := &nbconfig.Config{
|
||||
HttpConfig: &nbconfig.HttpServerConfig{
|
||||
LetsEncryptDomain: "mgmt.example.com",
|
||||
},
|
||||
}
|
||||
conn := &dex.Connector{Type: "oidc", Name: "test", ID: "test"}
|
||||
|
||||
err := generateConfig(configPath, conn, cfg, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Original should be unchanged
|
||||
data, err := os.ReadFile(configPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, originalConfig, string(data))
|
||||
|
||||
// No backup should exist
|
||||
_, err = os.Stat(configPath + ".bak")
|
||||
assert.True(t, os.IsNotExist(err))
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user