Skip to content

feat: standalone Node.js server + web admin UI for VM hosting#174

Open
charindithjaindu wants to merge 5 commits into
Draculabo:mainfrom
charindithjaindu:feat/standalone-server-web-ui
Open

feat: standalone Node.js server + web admin UI for VM hosting#174
charindithjaindu wants to merge 5 commits into
Draculabo:mainfrom
charindithjaindu:feat/standalone-server-web-ui

Conversation

@charindithjaindu
Copy link
Copy Markdown

No description provided.

charindithjaindu and others added 3 commits May 7, 2026 12:46
Introduces a non-Electron deploy path so the manager can run on a VM with
multi-account pooling and an OpenAI/Anthropic-compatible proxy reachable
through a browser dashboard. Existing Electron app is unaffected.

- Lazy-load Electron via src/utils/electronShim.ts; security/keychain
  fall back to a file-based master key when running under plain Node.
- Standalone server entry (src/standalone/) boots NestJS proxy + a
  Fastify management API on port 8046.
- Admin auth via session tokens; password and proxy API key sourced
  from .env (AGM_ADMIN_PASSWORD, AGM_API_KEY) with a dependency-free
  loader.
- Account API now exposes per-model quota detail (percentage, reset
  time, max output tokens, recommended) plus subscription tier and AI
  credits, with a refresh-quota endpoint.
- React/Tailwind web UI (src/web-ui/) covers login, account list with
  quota bars, paste-the-code OAuth flow, and proxy controls. Built
  output is served from the management server with SPA fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Web UI and management API already share port 8046; move the SPA from /
to /admin so the path-space stays clear when more endpoints are added.
Vite builds with base /admin/ so asset paths resolve correctly, the
management server redirects / and /admin to /admin/, and the SPA
fallback only fires for /admin/* paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reference single-domain nginx config that fronts the management API,
web admin, and proxy endpoints on a single TLS host with per-route rate
limits. Includes a short README covering certbot, firewall, and
hardening steps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@@ -253,7 +253,9 @@ export class CloudMonitorService {
return info.display_name || name.replace('models/', '').replace(/-/g, ' ');
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential runtime error: info.display_name is accessed without verifying that info is an object. If info is undefined or not an object, this will throw an exception and may disrupt the polling process for all accounts.

Recommended solution:
Add a type check to ensure info is an object before accessing display_name:

return (typeof info === 'object' && info && 'display_name' in info && info.display_name) || name.replace('models/', '').replace(/-/g, ' ');

Comment thread src/standalone/auth.ts
Comment on lines +5 to +14
const sessions = new Map<string, { expiresAt: number }>();

function cleanupExpired() {
const now = Date.now();
for (const [token, info] of sessions) {
if (info.expiresAt <= now) {
sessions.delete(token);
}
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concurrency/Data Race Issue with In-Memory Session Store

The sessions Map is accessed and mutated by multiple functions (cleanupExpired, issueToken, revokeToken, isTokenValid) without any synchronization. In a multi-threaded or clustered Fastify deployment, this can lead to data races, inconsistent state, or memory leaks. For example, simultaneous calls to cleanupExpired and issueToken could interfere with each other.

Recommendation:

  • For production, use a process-safe session store (e.g., Redis) or implement locking/mutex mechanisms if you must use in-memory storage.
  • If this is strictly for single-process, standalone use, document this limitation clearly to avoid misuse.

Comment thread src/standalone/auth.ts
Comment on lines +7 to +14
function cleanupExpired() {
const now = Date.now();
for (const [token, info] of sessions) {
if (info.expiresAt <= now) {
sessions.delete(token);
}
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential Performance Bottleneck in Token Cleanup

The cleanupExpired function iterates over all sessions and is called on every token issuance and validation. As the number of sessions grows, this could become a significant performance bottleneck, especially under high load.

Recommendation:

  • Consider using a more efficient data structure for session expiry (e.g., a priority queue or a sorted map by expiry time), or schedule periodic cleanup rather than invoking it on every operation.

Comment thread src/standalone/env.ts
return { path: null, loaded: 0 };
}

const raw = fs.readFileSync(candidate, 'utf-8');
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The call to fs.readFileSync(candidate, 'utf-8') is not wrapped in a try-catch block. If the file is deleted or becomes unreadable between the existence check and this read, the function will throw and potentially crash the process.

Recommendation: Wrap the file read in a try-catch block and handle errors gracefully, returning { path: candidate, loaded: 0 } or a similar safe fallback.

Comment thread src/standalone/main.ts
Comment on lines +24 to +28
if (!adminPassword) {
throw new Error(
'AGM_ADMIN_PASSWORD is required. Set it in your .env (see .env.example) before starting the server.',
);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security Issue: Weak Admin Password Allowed

The code only checks for the presence of AGM_ADMIN_PASSWORD, but does not enforce any minimum strength or length requirements. This could allow weak passwords, increasing the risk of unauthorized access.

Recommendation:
Add a check to enforce a minimum password length and consider validating password complexity. For example:

if (!adminPassword || adminPassword.length < 12) {
  throw new Error('AGM_ADMIN_PASSWORD must be at least 12 characters long.');
}

Comment thread src/utils/security.ts
Comment on lines 93 to 100
}

async function readSafeStorageKey(keyPath: string): Promise<Buffer | null> {
if (!safeStorage.isEncryptionAvailable()) {
const safeStorage = getElectronSafeStorage();
if (!safeStorage || !safeStorage.isEncryptionAvailable()) {
return null;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type Validation Issue:
In readSafeStorageKey, after reading the file, there is no validation that encryptedKey is a Buffer. If the file is corrupted or written incorrectly, fs.readFile may return unexpected data, leading to runtime errors during decryption.

Recommendation:
Add a type check before decryption:

if (!Buffer.isBuffer(encryptedKey)) {
  logger.error('Security: safeStorage key file is not a buffer');
  return null;
}

This will prevent cryptographic operations on invalid data.

Comment thread src/web-ui/App.tsx
Comment on lines +595 to +600
useEffect(() => {
if (!authed) return;
reloadAll();
const id = setInterval(reloadAll, 10_000);
return () => clearInterval(id);
}, [authed, reloadAll]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential Race Condition and Performance Issue in Polling

The useEffect hook sets up a polling interval (every 10 seconds) to call reloadAll, which triggers both reloadAccounts and reloadProxy. If either of these async functions takes longer than 10 seconds, multiple overlapping requests may occur, leading to race conditions, inconsistent UI state, or unnecessary backend load.

Recommended Solution:

  • Use a mechanism to ensure only one polling request is active at a time, such as a flag or abort controller.
  • Alternatively, consider using a debounced or sequential polling approach:
useEffect(() => {
  if (!authed) return;
  let active = true;
  const poll = async () => {
    await reloadAll();
    if (active) setTimeout(poll, 10000);
  };
  poll();
  return () => { active = false; };
}, [authed, reloadAll]);

This ensures that a new poll only starts after the previous one completes.

Comment thread src/web-ui/App.tsx
Comment on lines +218 to +221
{apiKey
? revealed
? apiKey
: '•'.repeat(Math.min(apiKey.length, 32))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API Key Masking May Leak Key Length

When the API key is hidden, the UI displays a string of bullet characters ('•'.repeat(Math.min(apiKey.length, 32))). This reveals the length of the API key (up to 32), which could be sensitive information and may aid attackers in brute-forcing the key.

Recommended Solution:

  • Always display a fixed number of masking characters, regardless of the actual key length:
: '•'.repeat(32)

This prevents leaking the key length.

Comment thread src/web-ui/api.ts
Comment on lines +84 to +85
const text = await res.text();
const body = text ? JSON.parse(text) : null;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The request function attempts to parse all response bodies as JSON without checking the Content-Type header. This can result in runtime errors if the response is not JSON (e.g., empty or plain text responses). To improve robustness, check if the response's Content-Type includes application/json before parsing, and handle parsing errors gracefully:

const contentType = res.headers.get('Content-Type') || '';
let body = null;
if (text && contentType.includes('application/json')) {
  try {
    body = JSON.parse(text);
  } catch (e) {
    throw new Error('Failed to parse JSON response');
  }
}

Comment thread src/web-ui/api.ts
Comment on lines +87 to +88
const message = body?.error ?? `${res.status} ${res.statusText}`;
throw new Error(message);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling logic in the request function directly exposes backend error messages to the client by using body?.error as the thrown error message. This could leak sensitive information if the backend returns detailed error messages. Consider sanitizing or generalizing error messages before surfacing them to the user, for example:

const message = res.status >= 500 ? 'Server error' : (body?.error ?? `${res.status} ${res.statusText}`);
throw new Error(message);

Two hardening items called out in the deploy README, now wired up so
production setups don't need source patches:

- AGM_BIND_HOST env var controls the listen interface for both the
  NestJS proxy and the Fastify management server. Default stays at
  0.0.0.0 to preserve current behavior; recommended production value
  is 127.0.0.1 (loopback only) when nginx fronts the app.
- @fastify/rate-limit registered globally (300/min, /api/health and
  /admin allowlisted) with a tight per-route override on
  POST /api/auth/login (5/min/IP) so brute-force attempts return 429.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread src/server/main.ts
@@ -28,9 +28,10 @@ export async function bootstrapNestServer(config: ProxyConfig): Promise<boolean>
// Enable CORS
app.enableCors();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security Issue: Unrestricted CORS

The call to app.enableCors() on line 29 enables Cross-Origin Resource Sharing (CORS) for all origins by default. This can expose the server to cross-origin requests from any domain, which may not be secure in production environments.

Recommendation:
Specify allowed origins explicitly or make CORS configuration part of your ProxyConfig. For example:

app.enableCors({ origin: ['https://yourdomain.com'] });

Or derive the allowed origins from configuration to avoid exposing the server unnecessarily.

Strip the 443 server block, ACME challenge location, and 80 -> 443
redirect from the reference nginx config. `certbot --nginx` rewrites
the file in place to add TLS, so shipping the HTTP-only baseline is
both shorter and matches the actual workflow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Draculabo
Copy link
Copy Markdown
Owner

Hi, thank you for the PR.

Is this change strictly necessary? I try to follow the principle of "Occam's Razor"—avoiding unnecessary complexity unless it's essential. Approving this would increase the long-term maintenance burden of the project.

Could you please open an issue first to explain:

What exactly this PR does?

Why is it needed and what specific problem does it solve?

Is this a genuine pain point for users?

I’d like to understand the rationale before committing to the additional maintenance.

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