Fix SSH host metrics authentication bug after credential changes
Problem: When users changed host authentication credentials, the main SSH connection worked fine, but metrics collection failed with "All configured authentication methods failed" error. Root Cause: The metrics collection system (server-stats.ts) had its own separate authentication implementation that only supported legacy password/key fields and didn't integrate with the credential-based authentication system. Solution: - Remove duplicate HostRecord type, use unified SSHHostWithCredentials - Import and use ssh-host.service for consistent credential resolution - Update fetchAllHosts() to properly resolve credentials using getHostWithCredentials() - Enhance buildSshConfig() with same authentication logic as main connections - Add proper error handling for missing credentials - Support both credential-based and legacy authentication methods Benefits: - Eliminates authentication logic duplication (follows "good taste" principle) - Metrics collection now works after changing host credentials - Uses single source of truth for credential resolution - Maintains backward compatibility with legacy authentication - Better error messages for debugging authentication issues Now both main SSH connections and metrics collection use the same robust authentication system, eliminating the authentication divergence that caused the metrics failures. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -4,18 +4,10 @@ import fetch from 'node-fetch';
|
|||||||
import net from 'net';
|
import net from 'net';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import {Client, type ConnectConfig} from 'ssh2';
|
import {Client, type ConnectConfig} from 'ssh2';
|
||||||
|
import {sshHostService} from '../services/ssh-host.js';
|
||||||
|
import type {SSHHostWithCredentials} from '../services/ssh-host.js';
|
||||||
|
|
||||||
type HostRecord = {
|
// Removed HostRecord - using SSHHostWithCredentials from ssh-host service instead
|
||||||
id: number;
|
|
||||||
ip: string;
|
|
||||||
port: number;
|
|
||||||
username?: string;
|
|
||||||
authType?: 'password' | 'key' | string;
|
|
||||||
password?: string | null;
|
|
||||||
key?: string | null;
|
|
||||||
keyPassword?: string | null;
|
|
||||||
keyType?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type HostStatus = 'online' | 'offline';
|
type HostStatus = 'online' | 'offline';
|
||||||
|
|
||||||
@@ -69,7 +61,7 @@ const logger = {
|
|||||||
|
|
||||||
const hostStatuses: Map<number, StatusEntry> = new Map();
|
const hostStatuses: Map<number, StatusEntry> = new Map();
|
||||||
|
|
||||||
async function fetchAllHosts(): Promise<HostRecord[]> {
|
async function fetchAllHosts(): Promise<SSHHostWithCredentials[]> {
|
||||||
const url = 'http://localhost:8081/ssh/db/host/internal';
|
const url = 'http://localhost:8081/ssh/db/host/internal';
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(url, {
|
const resp = await fetch(url, {
|
||||||
@@ -79,30 +71,55 @@ async function fetchAllHosts(): Promise<HostRecord[]> {
|
|||||||
throw new Error(`DB service error: ${resp.status} ${resp.statusText}`);
|
throw new Error(`DB service error: ${resp.status} ${resp.statusText}`);
|
||||||
}
|
}
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
const hosts: HostRecord[] = (Array.isArray(data) ? data : []).map((h: any) => ({
|
const rawHosts = Array.isArray(data) ? data : [];
|
||||||
id: Number(h.id),
|
|
||||||
ip: String(h.ip),
|
// Resolve credentials for each host using the same logic as main SSH connections
|
||||||
port: Number(h.port) || 22,
|
const hostsWithCredentials: SSHHostWithCredentials[] = [];
|
||||||
username: h.username,
|
for (const rawHost of rawHosts) {
|
||||||
authType: h.authType,
|
try {
|
||||||
password: h.password ?? null,
|
// Use the ssh-host service to properly resolve credentials
|
||||||
key: h.key ?? null,
|
const host = await sshHostService.getHostWithCredentials(rawHost.userId, rawHost.id);
|
||||||
keyPassword: h.keyPassword ?? null,
|
if (host) {
|
||||||
keyType: h.keyType ?? null,
|
hostsWithCredentials.push(host);
|
||||||
})).filter(h => !!h.id && !!h.ip && !!h.port);
|
}
|
||||||
return hosts;
|
} catch (err) {
|
||||||
|
logger.warn(`Failed to resolve credentials for host ${rawHost.id}: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hostsWithCredentials.filter(h => !!h.id && !!h.ip && !!h.port);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to fetch hosts from database service', err);
|
logger.error('Failed to fetch hosts from database service', err);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchHostById(id: number): Promise<HostRecord | undefined> {
|
async function fetchHostById(id: number): Promise<SSHHostWithCredentials | undefined> {
|
||||||
const all = await fetchAllHosts();
|
try {
|
||||||
return all.find(h => h.id === id);
|
// Get all users that might own this host
|
||||||
|
const url = 'http://localhost:8081/ssh/db/host/internal';
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
headers: {'x-internal-request': '1'}
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(`DB service error: ${resp.status} ${resp.statusText}`);
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
const rawHost = (Array.isArray(data) ? data : []).find((h: any) => h.id === id);
|
||||||
|
|
||||||
|
if (!rawHost) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use ssh-host service to properly resolve credentials
|
||||||
|
return await sshHostService.getHostWithCredentials(rawHost.userId, id);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Failed to fetch host ${id}`, err);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSshConfig(host: HostRecord): ConnectConfig {
|
function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
|
||||||
const base: ConnectConfig = {
|
const base: ConnectConfig = {
|
||||||
host: host.ip,
|
host: host.ip,
|
||||||
port: host.port || 22,
|
port: host.port || 22,
|
||||||
@@ -111,37 +128,41 @@ function buildSshConfig(host: HostRecord): ConnectConfig {
|
|||||||
algorithms: {}
|
algorithms: {}
|
||||||
} as ConnectConfig;
|
} as ConnectConfig;
|
||||||
|
|
||||||
|
// Use the same authentication logic as main SSH connections
|
||||||
if (host.authType === 'password') {
|
if (host.authType === 'password') {
|
||||||
(base as any).password = host.password || '';
|
if (!host.password) {
|
||||||
} else if (host.authType === 'key') {
|
throw new Error(`No password available for host ${host.ip}`);
|
||||||
if (host.key) {
|
|
||||||
try {
|
|
||||||
if (!host.key.includes('-----BEGIN') || !host.key.includes('-----END')) {
|
|
||||||
throw new Error('Invalid private key format');
|
|
||||||
}
|
|
||||||
|
|
||||||
const cleanKey = host.key.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
||||||
|
|
||||||
(base as any).privateKey = Buffer.from(cleanKey, 'utf8');
|
|
||||||
|
|
||||||
if (host.keyPassword) {
|
|
||||||
(base as any).passphrase = host.keyPassword;
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (keyError) {
|
|
||||||
logger.error(`SSH key format error for host ${host.ip}: ${keyError.message}`);
|
|
||||||
if (host.password) {
|
|
||||||
(base as any).password = host.password;
|
|
||||||
} else {
|
|
||||||
throw new Error(`Invalid SSH key format for host ${host.ip}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
(base as any).password = host.password;
|
||||||
|
} else if (host.authType === 'key') {
|
||||||
|
if (!host.key) {
|
||||||
|
throw new Error(`No SSH key available for host ${host.ip}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!host.key.includes('-----BEGIN') || !host.key.includes('-----END')) {
|
||||||
|
throw new Error('Invalid private key format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanKey = host.key.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||||
|
|
||||||
|
(base as any).privateKey = Buffer.from(cleanKey, 'utf8');
|
||||||
|
|
||||||
|
if (host.keyPassword) {
|
||||||
|
(base as any).passphrase = host.keyPassword;
|
||||||
|
}
|
||||||
|
} catch (keyError) {
|
||||||
|
logger.error(`SSH key format error for host ${host.ip}: ${keyError instanceof Error ? keyError.message : 'Unknown error'}`);
|
||||||
|
throw new Error(`Invalid SSH key format for host ${host.ip}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unsupported authentication type '${host.authType}' for host ${host.ip}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withSshConnection<T>(host: HostRecord, fn: (client: Client) => Promise<T>): Promise<T> {
|
async function withSshConnection<T>(host: SSHHostWithCredentials, fn: (client: Client) => Promise<T>): Promise<T> {
|
||||||
return new Promise<T>((resolve, reject) => {
|
return new Promise<T>((resolve, reject) => {
|
||||||
const client = new Client();
|
const client = new Client();
|
||||||
let settled = false;
|
let settled = false;
|
||||||
@@ -225,7 +246,7 @@ function kibToGiB(kib: number): number {
|
|||||||
return kib / (1024 * 1024);
|
return kib / (1024 * 1024);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function collectMetrics(host: HostRecord): Promise<{
|
async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
||||||
cpu: { percent: number | null; cores: number | null; load: [number, number, number] | null };
|
cpu: { percent: number | null; cores: number | null; load: [number, number, number] | null };
|
||||||
memory: { percent: number | null; usedGiB: number | null; totalGiB: number | null };
|
memory: { percent: number | null; usedGiB: number | null; totalGiB: number | null };
|
||||||
disk: { percent: number | null; usedHuman: string | null; totalHuman: string | null };
|
disk: { percent: number | null; usedHuman: string | null; totalHuman: string | null };
|
||||||
|
|||||||
Reference in New Issue
Block a user