Skip to content

v2.12.0: Multi-instance Portainer, self-update, and bug fixes#75

Open
Will-Luck wants to merge 47 commits intomainfrom
dev
Open

v2.12.0: Multi-instance Portainer, self-update, and bug fixes#75
Will-Luck wants to merge 47 commits intomainfrom
dev

Conversation

@Will-Luck
Copy link
Owner

Summary

  • Multi-instance Portainer support: Connect multiple Portainer instances with per-endpoint toggles, containers appear as dashboard host groups, BoltDB migration for instance storage
  • Portainer self-update: Via portainer-updater helper container
  • NPM resolver hardening: Auto-detects local IPs to prevent cross-host port shadowing, skips wildcard domains
  • 10+ bug fixes: History page scan summaries, failed approval recording, images page alignment, filter bar borders, container detail for remote containers, scoped key lookups, and more

What changed

  • 40 commits since v2.11.1
  • New BoltDB bucket: portainer_instances with CRUD + migration from legacy single-instance settings
  • Multi-Portainer scanning with per-endpoint filtering and local socket auto-blocking
  • Portainer connector UI (settings page) with endpoint toggles
  • Smart local socket detection (IsLocalSocket) to prevent scanning the host Docker twice
  • NPM resolver improvements (local IP detection, wildcard skip)
  • Portainer self-update feature via helper container
  • Images page: column alignment, red unused badge
  • History page: scan summary row fix, failed approval recording
  • Filter bar bottom border

Test plan

  • Bug hunt on the diff
  • Build and deploy to test environment
  • Verify multi-Portainer connector UI
  • Verify Portainer self-update flow
  • Verify NPM URL resolution with multiple hosts
  • Verify history and images page fixes
  • Run full test suite

🤖 Generated with Claude Code

web-flow added 30 commits March 10, 2026 23:17
When accessing the dashboard via NPM domain (sen.lucknet.uk), the HTTP
Host header was used for NPM ForwardHost matching. Since NPM stores IPs
not domains, no proxy hosts matched and every port fell back to
sen.lucknet.uk:<port>.

For local containers, use Lookup() (matches against the resolver's
configured sentinelHost IP) instead of LookupForHost() with the request
domain. Cluster containers still use LookupForHost() with the remote
host's IP.
NPM proxy hosts with wildcard domains like *.s3.garage.example.com
produced broken URLs. The resolver now picks the first non-wildcard
domain from the list, falling back to skipping the entry entirely
if all domains are wildcards.
The filter bar on history, logs, and images pages was missing the bottom
border that the dashboard had. Added explicit border-bottom to .filter-bar.
… queue entries

Portainer settings now take effect immediately without a container restart,
using the same factory pattern as the NPM connector. The connection test
always recreates the provider from current DB settings so token changes are
picked up. Portainer endpoints that point at the same Docker socket Sentinel
monitors no longer produce duplicate queue entries (container IDs are
compared against the local scan). Updated help text and integration
descriptions for accuracy.
The dashboard stat card used a local-only pending count that excluded
Portainer queue items, while the nav badge used the full queue length.
Both now use the queue length directly so they always match. Removed
the checkmark icon from the zero-state as it added no value.
The detail page handler only knew about local and cluster containers.
Portainer containers (host=portainer:N) fell through to the cluster
lookup which returned "not found". Added a Portainer branch that
extracts the endpoint ID, fetches containers from the Portainer API,
and builds the detail view with policy, version, and queue info.
Portainer and cluster container detail pages looked up history and
snapshots using the bare container name, but records are stored under
scoped keys (e.g. "portainer:3::name"). Now uses hostFilter::name
when a host filter is present.
Covers data model, local socket detection, connector UI,
dashboard integration, engine changes, and migration path.
13 tasks across 7 chunks: store CRUD, migration, engine multi-instance
scanning, local socket detection, web API, dashboard host groups, and
frontend connector cards.
Adds MigratePortainerSettings() which converts the flat portainer_url/
portainer_token/portainer_enabled settings keys into a PortainerInstance
record (id "p1", name "Portainer") and clears the old keys. Safe to call
multiple times: skips if any instances already exist. Also adds
DeleteSetting() to bolt.go.
Replace single-instance Portainer settings (flat portainer_enabled/url/token
keys) with a full instance CRUD API backed by PortainerInstanceStore. The
PortainerProvider interface now takes instanceID parameters on all methods.

New routes: GET/POST /api/portainer/instances, PUT/DELETE instances/{id},
POST instances/{id}/test, GET instances/{id}/endpoints,
PUT instances/{id}/endpoints/{epid}.

Existing container detail handler updated to parse the new
"portainer:instanceID:epID" host filter format while remaining backwards
compatible with the legacy "portainer:epID" format.

Note: cmd/sentinel/ adapters will not compile until Task 7 updates them.
Three bugs found during live testing on the test cluster:

1. Portainer instances added via the API had no live scanner (only
   boot-time instances worked). Added ConnectInstance/DisconnectInstance
   to PortainerProvider, called from create/update/delete handlers.

2. Portainer self-signed certs caused TLS verification failures. Added
   InsecureSkipVerify to the Portainer HTTP client (standard for homelab
   and private network setups).

3. csrfToken is a function reference (window.csrfToken = getCSRFToken)
   but connectors.html passed it as a value. Changed all 11 occurrences
   to csrfToken() calls.
IsLocalSocket() was defined but never called. Now applied in two places:

1. Scanner.Endpoints() filters out local socket endpoints so the engine
   never scans them (defence in depth).

2. Test Connection handler auto-marks new local socket endpoints as
   blocked with reason "local Docker socket (duplicates direct
   monitoring)" so users see why the endpoint is disabled.

Updated scanner tests to use TCP URLs for mock endpoints (empty URL +
EndpointDocker type now correctly triggers IsLocalSocket).
Only auto-block unix:// endpoints when the Portainer instance runs on the
same host as Sentinel. Previously all unix:// endpoints were blocked
regardless of host, which incorrectly disabled remote Portainer instances.

- Add isLocalPortainerInstance() to compare Portainer URL against local IPs
- Remove over-aggressive IsLocalSocket filter from Scanner.Endpoints()
- Wire engine into multiPortainerAdapter so runtime-added instances are
  scanned without restart
- Reconnect engine after endpoint config changes (test, update)
- Add unit tests for local detection helpers
web-flow added 17 commits March 12, 2026 00:10
Use the official portainer/portainer-updater container to safely update
Portainer instances without crashing the API mid-request. The updater
mounts the Docker socket directly, bypasses the Portainer API, and
survives the stop/recreate cycle.

- Add UpdatePortainerSelf to scanner with pull, create, start, recovery
- Add IsPortainerImage detection (CE, EE, GHCR, registry prefixes)
- Route Portainer images through self-updater in apiUpdate, apiUpdateToVersion
- Add approvePortainerUpdate for queue approval path (was missing)
- Add PullImage, CreateContainer, StartContainer, WaitContainer, WaitForRecovery
  and RemoveContainer to Portainer client
- Add smart local socket blocking (isLocalPortainerInstance)
- Live tested: Portainer 2.19.0 -> 2.39.0 on .61 test cluster
…hadowing

Replace single SENTINEL_HOST string match with a local address set built
from net.InterfaceAddrs(), hostname, host.docker.internal DNS, and the
SENTINEL_HOST env var (additive override). Fixes port 8080 on .64
shadowing port 8080 on .57 when SENTINEL_HOST was unset.

Also consolidates unreleased changelog entries under [Unreleased].
Scan summary rows were rendered as regular container rows, causing
truncated text in the Container column and broken /container/history-0
URLs when clicked. Now they render with colspan spanning Container +
Version columns, no click handler, a "Summary" badge, and proper text
wrapping at all viewport widths.
colspan shifts nth-child indices by one in scan summary rows, so the
Outcome and Duration cells inherit the wrong column styles. Explicit
text-align: center on nth-child(3) and nth-child(4) within
.scan-summary-row corrects the alignment.
When an approved update failed (e.g. Portainer agent disconnected),
only a log line was written and the update vanished from the queue
with no history record. Now writes a "failed" UpdateRecord with the
error message and elapsed duration so failures are visible on the
history page.
Grey badge-muted blended into the dark background. Switched to
badge-error (red) for better visibility.
Size is now right-aligned so numbers line up, Status and Actions are
centred so badges and buttons sit under their headers.
DetectLocalAddrs was including container-internal addresses (172.17.x.x,
localhost, hostname) which never match NPM ForwardHost values. This caused
Lookup() to silently filter out all proxies when SENTINEL_HOST was not set,
making port chips fall back to raw IP:port links.

Now only includes routable addresses: explicit SENTINEL_HOST values and
Docker host IP via host.docker.internal. Returns an empty set when neither
is available, which disables filtering (safe fallback matching all proxies).
…t shadowing

When hostAddr from the HTTP request is a valid IP (direct IP access or
SENTINEL_HOST), use LookupForHost to match only NPM proxies forwarding
to that specific host. Falls back to Lookup() when accessed via domain.

Fixes regression from 1c18dec where empty localAddrs disabled all
filtering, allowing port 8080 on host A to shadow port 8080 on host B.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants