fix: don't send unused credentials to a Protect-only console

Found by running this against a real UNVR4 rather than only the fake one.

setDefaults fills an unset user with the placeholder "unifipoller". For a
Protect-only console that placeholder was reaching Login(), the console answered
403, and the controller died during initialisation -- the same symptom #1066 set
out to fix, one layer further in. A Protect API key and no local account is the
config an operator actually writes for a UNVR, so this was the common case, not
an edge one.

Nothing on such a console uses a session unless Protect logs are wanted: the
Integration API authenticates with the key alone. So withhold the credentials
entirely in that case, and say so in the startup summary rather than naming a
username that is never sent.

Also pins the go.mod bump to unifi v6.0.1, which is the release that carries
NewProtectClient (unpoller/unifi#240).

Verified end to end against a UNVR4 (UniFi OS 5.1.31, Protect 7.2.105): the
controller comes up, logs "Auth: Protect API key only (no session needed)",
makes no login request at all, and exports 37 unpoller_protect_* series across
9 cameras and 2 bridges with unpoller_controller_up = 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVreutpEATmBjm6PBw9RjQ
This commit is contained in:
Cooper Ry Lees
2026-08-31 13:08:34 +00:00
co-authored by Claude Opus 5
parent 31cc5a0f31
commit a73a2e0ab2
3 changed files with 74 additions and 5 deletions
+10
View File
@@ -186,6 +186,16 @@ func (u *InputUnifi) getUnifi(c *Controller) error {
DebugLog: u.LogDebugf,
}
// Nothing on a Protect-only console uses a session unless Protect logs are wanted: the
// Integration API authenticates with the key alone. Withholding the credentials here
// keeps NewProtectClient from attempting -- and logging -- a login on every re-auth,
// which matters because an unset user is filled in above with a placeholder that no
// console will ever accept.
if *c.DisableNetwork && !*c.SaveProtectLogs {
cfg.User = ""
cfg.Pass = ""
}
var lastErr error
backoff := 30 * time.Second
+55 -3
View File
@@ -280,6 +280,44 @@ func TestProtectOnlyWarnsWithoutAPIKey(t *testing.T) {
assert.Contains(t, logger.errors(), "no protect_api_key")
}
// A key-only config is what an operator actually writes for a UNVR, and setDefaults fills the
// unset user with "unifipoller". Sending that placeholder made the console answer 403 and
// killed the controller outright, so the credentials must not be sent when no session can be
// used -- and the startup summary must not claim an authentication that never happens.
func TestProtectOnlyDoesNotSendUnusedCredentials(t *testing.T) {
t.Parallel()
fake := &unvr{}
srv := fake.start(t)
logger := &captureLogger{}
u := newProtectOnlyInput(srv.URL, func(c *inputunifi.Controller) {
c.User = ""
c.Pass = ""
})
require.NoError(t, u.Initialize(logger))
a := assert.New(t)
require.NotNil(t, u.Controllers[0].Unifi)
a.NotContains(fake.paths(), unifi.APILoginPathNew, "no session is usable, so none should be attempted")
a.Contains(logger.infoLines(), "Protect API key only (no session needed)")
a.NotContains(logger.infoLines(), "unifipoller")
}
// With Protect logs enabled the session is genuinely needed, so the credentials are sent.
func TestProtectOnlyLogsInForProtectLogs(t *testing.T) {
t.Parallel()
enabled := true
fake := &unvr{}
srv := fake.start(t)
u := newProtectOnlyInput(srv.URL, func(c *inputunifi.Controller) { c.SaveProtectLogs = &enabled })
require.NoError(t, u.Initialize(nil))
assert.Contains(t, fake.paths(), unifi.APILoginPathNew)
}
// disable_network has to survive four independent binding paths -- toml, json, yaml and the
// UP_ environment -- each driven by its own struct tag. A typo in any one tag leaves the
// option silently inert for users of that format. Note the env name derives from the xml tag.
@@ -355,13 +393,20 @@ func TestShippedExamplesDoNotDisableNetwork(t *testing.T) {
// captureLogger records what Initialize logs, so the startup warnings can be asserted on.
type captureLogger struct {
mu sync.Mutex
errs []string
mu sync.Mutex
errs []string
infos []string
}
func (l *captureLogger) Logf(string, ...any) {}
func (l *captureLogger) LogDebugf(string, ...any) {}
func (l *captureLogger) Logf(msg string, v ...any) {
l.mu.Lock()
defer l.mu.Unlock()
l.infos = append(l.infos, fmt.Sprintf(msg, v...))
}
func (l *captureLogger) LogErrorf(msg string, v ...any) {
l.mu.Lock()
defer l.mu.Unlock()
@@ -375,3 +420,10 @@ func (l *captureLogger) errors() string {
return strings.Join(l.errs, "\n")
}
func (l *captureLogger) infoLines() string {
l.mu.Lock()
defer l.mu.Unlock()
return strings.Join(l.infos, "\n")
}
+9 -2
View File
@@ -191,9 +191,16 @@ func (u *InputUnifi) logController(c *Controller) {
u.Logf(" => Version: %s (%s)", c.Unifi.ServerVersion, c.Unifi.UUID)
}
if c.Remote {
switch {
case c.Remote:
u.Logf(" => API Key: %v", c.APIKey != "")
} else {
case *c.DisableNetwork && !*c.SaveProtectLogs:
// getUnifi withholds the credentials entirely in this case, so naming a user here
// would describe an authentication that never happens.
u.Logf(" => Auth: Protect API key only (no session needed)")
case *c.DisableNetwork:
u.Logf(" => Username: %s (has password: %v) — used only for Protect logs", c.User, c.Pass != "")
default:
u.Logf(" => Username: %s (has password: %v) (has api-key: %v)", c.User, c.Pass != "", c.APIKey != "")
}