Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
408 changes: 235 additions & 173 deletions api/agentmesh.pb.go

Large diffs are not rendered by default.

18 changes: 14 additions & 4 deletions api/agentmesh.proto
Original file line number Diff line number Diff line change
Expand Up @@ -767,7 +767,7 @@ message NodeServices {
}

// AdminStatusResponse answers GET /admin/status: everything the console
// shows an administrator.
// shows an administrator, except expired nodes (see GET /user/nodes).
message AdminStatusResponse {
repeated User users = 1;
repeated RouterLease active_routers = 2;
Expand All @@ -779,9 +779,9 @@ message AdminStatusResponse {
map<string, NodeServices> node_catalog = 7;
}

// UserStatusResponse answers GET /user/status: the caller and what it owns.
// The router fleet and the mesh policy describe the whole mesh and are set
// for an administrator only.
// UserStatusResponse answers GET /user/status: the caller and what it owns,
// except expired nodes (see GET /user/nodes). The router fleet and the mesh
// policy describe the whole mesh and are set for an administrator only.
message UserStatusResponse {
User user = 1;
repeated EnrolledNode enrolled_nodes = 2;
Expand All @@ -790,6 +790,16 @@ message UserStatusResponse {
PolicyConfig policy = 5;
}

// NodeListResponse answers GET /user/nodes: enrolled nodes, newest enrollment
// first, page_size (default 50, at most 200) at a time. state (admitted,
// expired or banned, repeatable) and role narrow the list. An administrator
// sees every owner's nodes and may pick one with owner_id; a user sees its own.
message NodeListResponse {
repeated EnrolledNode nodes = 1;
// Pass as page_token for the next page; empty on the last page.
string next_page_token = 2;
}

// ============================================================================
// Identity Evidence API
// ============================================================================
Expand Down
6 changes: 5 additions & 1 deletion cmd/agentmesh-control-plane/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ func main() {
if stsIssuerURL == "" {
stsIssuerURL = strings.TrimSpace(os.Getenv("AGENTMESH_STS_ISSUER_URL"))
}
// Options reads 0 as unset; the flag's 0 is a value the operator typed.
if nodeRetention <= 0 {
logger.Fatalf("--node-retention must be positive, got %s: node records are never kept forever", nodeRetention)
}

var auds []string
for _, aud := range strings.Split(allowedAudiencesFlag, ",") {
Expand Down Expand Up @@ -202,7 +206,7 @@ func main() {
rootCmd.Flags().DurationVar(&biscuitTTL, "biscuit-ttl", api.BiscuitTokenTTL, "Lifespan minted into every issued Biscuit's expiration fact. Capped to the OIDC token's own expiry when shorter.")
rootCmd.Flags().DurationVar(&oidcSessionTTL, "oidc-session-ttl", api.OIDCSessionTTL, "How long an OIDC enrollment stays refreshable before the identity must re-authenticate with the OIDC provider. Shorter values keep the provider authoritative for offboarding at the cost of more frequent interactive re-enrollment.")
rootCmd.Flags().DurationVar(&workloadSessionTTL, "workload-session-ttl", controlplane.DefaultWorkloadSessionTTL, "How long a workload-issuer enrollment stays refreshable without presenting a fresh platform JWT on /refresh.")
rootCmd.Flags().DurationVar(&nodeRetention, "node-retention", controlplane.DefaultNodeRetention, "How long an enrolled node's record is kept after its session expired before it is deleted. Banned nodes are always kept. 0 keeps every record forever.")
rootCmd.Flags().DurationVar(&nodeRetention, "node-retention", controlplane.DefaultNodeRetention, "How long an enrolled node's record is kept after its session expired before it is deleted. Banned nodes are always kept. Must be positive: records are never kept forever.")
rootCmd.Flags().DurationVar(&meshReconnectInterval, "mesh-reconnect-interval", controlplane.DefaultMeshReconnectInterval, "How often the event publisher re-reads the router leases and dials any router it is not connected to.")
rootCmd.Flags().StringVar(&adminTokenPath, "admin-token-path", "", "Path to file containing the token for authenticating policy REST API requests (or env AGENTMESH_ADMIN_TOKEN)")
rootCmd.Flags().StringVar(&stsIssuerURL, "sts-issuer-url", "", "Canonical external URL of this control plane for OIDC/STS issuer and discovery metadata (or env AGENTMESH_STS_ISSUER_URL)")
Expand Down
1 change: 1 addition & 0 deletions cmd/agentmesh-one/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ func main() {
rootCmd.Flags().DurationVar(&cpTunables.KeyGracePeriod, "control-plane-key-grace-period", 0, "How long rotated-out keys stay valid for verification (0 keeps the component default)")
rootCmd.Flags().DurationVar(&cpTunables.BiscuitTTL, "control-plane-biscuit-ttl", 0, "Lifespan minted into issued biscuits (0 keeps the component default)")
rootCmd.Flags().DurationVar(&cpTunables.WorkloadSessionTTL, "control-plane-workload-session-ttl", 0, "How long a workload-issuer enrollment stays refreshable without presenting a fresh platform JWT on /refresh (0 keeps the component default)")
rootCmd.Flags().DurationVar(&cpTunables.NodeRetention, "control-plane-node-retention", 0, "How long a node record is kept after its session expired before it is deleted (0 keeps the component default)")
rootCmd.Flags().BoolVar(&cpTunables.ManualEnrollment, "control-plane-manual-enrollment", false, "Queue bootstrap enrollments for admin approval instead of auto-approving")

// Embedded router tunables.
Expand Down
26 changes: 19 additions & 7 deletions internal/console/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ const TOAST_TIMEOUT_MS = 5000;

let refreshTimer = null;
let refreshInFlight = false;
// The session's role and user ID, learned from the first /user/status of a
// session. An admin's later refreshes read /admin/status alone: /user/status
// would list the same mesh a second time only to be overwritten.
let session = null;
// Last policy the server confirmed, so an edit is never silently overwritten by
// a background refresh or thrown away by a stray click.
let policyBaseline = '';
Expand Down Expand Up @@ -146,6 +150,8 @@ function showApp() {

function showLandingPage() {
stopAutoRefresh();
resetExpiredNodes();
session = null;
document.getElementById('landing-page').classList.add('active');
document.getElementById('app-container').style.display = 'none';
}
Expand Down Expand Up @@ -194,8 +200,10 @@ window.saveAdminToken = function() {

async function loadData() {
try {
// 1. Fetch user-scoped status
const response = await fetch('api/user/status');
// One status call per refresh: the user's own until the role is known,
// then the one that matches the role.
const endpoint = session !== null && session.role === 'admin' ? 'api/admin/status' : 'api/user/status';
const response = await fetch(endpoint);
if (response.status === 401 || response.status === 403) {
showLandingPage();
throw new Error('Unauthorized. Please login again.');
Expand All @@ -204,15 +212,19 @@ async function loadData() {
throw new Error(`HTTP error! status: ${response.status}`);
}
let data = await response.json();

const role = data.user ? data.user.role : 'user';
const userId = data.user ? data.user.id : '';

if (data.user) {
session = { role: data.user.role || 'user', id: data.user.id || '' };
}
const role = session !== null ? session.role : 'user';
const userId = session !== null ? session.id : '';

// Update UI components depending on Role
updateUIForRole(role, userId);

// 2. If user is admin, fetch full unfiltered admin status
if (role === 'admin') {
// The first call of an admin's session answered with the user's view;
// the mesh-wide one is what the admin console renders.
if (role === 'admin' && endpoint !== 'api/admin/status') {
const adminResp = await fetch('api/admin/status');
if (adminResp.ok) {
data = await adminResp.json();
Expand Down
142 changes: 142 additions & 0 deletions internal/console/public/expired-nodes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

const EXPIRED_NODES_PAGE_SIZE = 50;

// Where the expired-nodes list resumes, and which opening of the section a
// fetch belongs to, so a slow page never lands in a list reset since.
let expiredNodesPageToken = '';
let expiredNodesGeneration = 0;

document.addEventListener('DOMContentLoaded', () => {
const expiredNodes = document.getElementById('expired-nodes');
expiredNodes.addEventListener('toggle', () => {
if (expiredNodes.open) {
loadExpiredNodes(true);
}
});
document.getElementById('expired-nodes-more').addEventListener('click', () => loadExpiredNodes(false));
document.getElementById('table-expired-nodes').addEventListener('click', (e) => {
const button = e.target.closest('button[data-peer-id]');
if (button) {
revokeExpiredNode(button);
}
});
});

// Fetched on demand, never by the background refresh: expired rows stay for
// the retention period, and listing them all would slow every refresh.
async function loadExpiredNodes(reset) {
const more = document.getElementById('expired-nodes-more');
if (reset) {
expiredNodesGeneration++;
expiredNodesPageToken = '';
more.hidden = true;
setTableMessage('table-expired-nodes', 5, 'Loading...');
}
const generation = expiredNodesGeneration;
let url = `api/user/nodes?state=expired&page_size=${EXPIRED_NODES_PAGE_SIZE}`;
if (expiredNodesPageToken) {
url += `&page_token=${encodeURIComponent(expiredNodesPageToken)}`;
}

more.disabled = true;
try {
const response = await fetch(url);
if (response.status === 401 || response.status === 403) {
showLandingPage();
throw new Error('Unauthorized. Please login again.');
}
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (generation !== expiredNodesGeneration) {
return;
}

const tbody = document.getElementById('table-expired-nodes');
const rows = (data.nodes || []).map(expiredNodeRow).join('');
if (reset) {
tbody.innerHTML = rows;
} else {
tbody.insertAdjacentHTML('beforeend', rows);
}
if (!tbody.querySelector('tr')) {
setTableMessage('table-expired-nodes', 5, 'No expired nodes');
}
expiredNodesPageToken = data.next_page_token || '';
more.hidden = !expiredNodesPageToken;
applySearchFilter();
} catch (error) {
if (generation !== expiredNodesGeneration) {
return;
}
console.error('Failed to load expired nodes:', error);
// A failed "Load more" keeps the rows already shown and the button to retry.
if (reset) {
setTableMessage('table-expired-nodes', 5, `Error loading expired nodes: ${error.message}`, true);
} else {
showToast(`Failed to load more expired nodes: ${error.message}`, 'error');
}
} finally {
more.disabled = false;
}
}

// The Revoke button carries the peer ID as data, not inside an inline handler,
// where an HTML-escaped quote would be decoded back into the script.
function expiredNodeRow(node) {
const expired = node.expire_time ? new Date(node.expire_time).toLocaleString() : '';
return `
<tr>
<td>${peerCell(node.peer_id, node.labels)}</td>
<td>${escapeHTML(node.role)}</td>
<td>${escapeHTML(node.owner_id || '')}</td>
<td>${escapeHTML(expired)}</td>
<td><button class="btn btn-sm btn-danger" type="button" data-peer-id="${escapeHTML(node.peer_id)}">Revoke</button></td>
</tr>
`;
}

// Paging resumes after the last row served, so dropping a revoked row never
// shifts the next page.
async function revokeExpiredNode(button) {
if (!confirm('Revoke this node? It can no longer renew its expired session or rejoin the mesh.')) {
return;
}
button.disabled = true;
try {
await actionRequest(`api/user/revoke?id=${encodeURIComponent(button.dataset.peerId)}`);
} catch (error) {
// actionRequest already reported the failure.
button.disabled = false;
return;
}
const tbody = document.getElementById('table-expired-nodes');
button.closest('tr').remove();
if (!tbody.querySelector('tr:not([data-filter-empty])')) {
loadExpiredNodes(true);
}
}

// The landing page hides the app without a reload, so the next identity to log
// in must not inherit rows, or an in-flight page, fetched for this one.
function resetExpiredNodes() {
expiredNodesGeneration++;
expiredNodesPageToken = '';
document.getElementById('expired-nodes').open = false;
document.getElementById('table-expired-nodes').innerHTML = '';
document.getElementById('expired-nodes-more').hidden = true;
}
23 changes: 23 additions & 0 deletions internal/console/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,28 @@ <h2>Revoked Nodes</h2>
</div>
</div>
</div>
<!-- Loaded on open, never by the refresh: expired rows pile up for the
retention period, yet an expired OIDC node can renew until revoked. -->
<details id="expired-nodes" class="section-header-spaced">
<summary class="section-header"><h2>Expired Nodes</h2></summary>
<div class="card">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>Node ID</th>
<th>Type</th>
<th>Owner</th>
<th>Expired</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="table-expired-nodes"></tbody>
</table>
</div>
<button id="expired-nodes-more" class="btn btn-sm load-more" type="button" hidden>Load more</button>
</div>
</details>
</section>

<!-- Services View: self-reported by each node (see /nodes/catalog);
Expand Down Expand Up @@ -405,5 +427,6 @@ <h2>Mesh Security Policies</h2>
<div id="toast-container" class="toast-container" role="status" aria-live="polite"></div>
<script src="vendor/js-yaml.min.js"></script>
<script src="app.js"></script>
<script src="expired-nodes.js"></script>
</body>
</html>
18 changes: 18 additions & 0 deletions internal/console/public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,24 @@ body {
font-weight: 600;
}

/* A collapsible section: the disclosure marker sits beside its title. */
summary.section-header {
cursor: pointer;
}

summary.section-header h2 {
display: inline;
}

.load-more {
margin-top: 16px;
}

/* .btn sets display, which would otherwise beat the hidden attribute. */
.load-more[hidden] {
display: none;
}

/* Buttons */
.btn {
display: inline-flex;
Expand Down
7 changes: 5 additions & 2 deletions internal/console/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,11 @@ func TestNewServerEmbeddedAssets(t *testing.T) {
if code, body := get("/"); code != http.StatusOK || !strings.Contains(body, "<html") {
t.Errorf("GET / = %d, want 200 with the embedded index.html", code)
}
if code, _ := get("/app.js"); code != http.StatusOK {
t.Errorf("GET /app.js = %d, want 200", code)
// A missing file would get the SPA fallback, so a script must not be index.html.
for _, asset := range []string{"/app.js", "/expired-nodes.js"} {
if code, body := get(asset); code != http.StatusOK || strings.Contains(body, "<html") {
t.Errorf("GET %s = %d, want 200 with the embedded script", asset, code)
}
}
// SPA fallback: unknown paths serve index.html.
if code, body := get("/some/client/route"); code != http.StatusOK || !strings.Contains(body, "<html") {
Expand Down
Loading
Loading