Improve transport safety and log redaction
This commit is contained in:
41
plugin/core/logging.ts
Normal file
41
plugin/core/logging.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
const DEFAULT_VISIBLE_EDGE = 4;
|
||||||
|
|
||||||
|
export type RedactableValue = string | null | undefined;
|
||||||
|
|
||||||
|
export function redactSecret(value: RedactableValue, visibleEdge: number = DEFAULT_VISIBLE_EDGE): string {
|
||||||
|
return redactValue(value, { visibleEdge, label: "secret" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redactPairingCode(value: RedactableValue, visibleEdge: number = DEFAULT_VISIBLE_EDGE): string {
|
||||||
|
return redactValue(value, { visibleEdge, label: "pairingCode" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redactKey(value: RedactableValue, visibleEdge: number = DEFAULT_VISIBLE_EDGE): string {
|
||||||
|
return redactValue(value, { visibleEdge, label: "key" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redactValue(
|
||||||
|
value: RedactableValue,
|
||||||
|
options: { visibleEdge?: number; label?: string } = {}
|
||||||
|
): string {
|
||||||
|
const visibleEdge = options.visibleEdge ?? DEFAULT_VISIBLE_EDGE;
|
||||||
|
const label = options.label ?? "value";
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return `<redacted:${label}:empty>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.length <= visibleEdge * 2) {
|
||||||
|
return `<redacted:${label}:${value.length}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${value.slice(0, visibleEdge)}…${value.slice(-visibleEdge)} <redacted:${label}:${value.length}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
@@ -17,12 +17,15 @@ import {
|
|||||||
buildPairRequest,
|
buildPairRequest,
|
||||||
buildPairSuccess,
|
buildPairSuccess,
|
||||||
buildRePairRequired,
|
buildRePairRequired,
|
||||||
|
CodecError,
|
||||||
decodeBuiltin,
|
decodeBuiltin,
|
||||||
encodeBuiltin,
|
encodeBuiltin,
|
||||||
|
encodeRuleMessage,
|
||||||
extractAuthRequestSigningInput,
|
extractAuthRequestSigningInput,
|
||||||
isBuiltinMessage,
|
isBuiltinMessage,
|
||||||
isTimestampFresh,
|
isTimestampFresh,
|
||||||
isValidAuthNonce,
|
isValidAuthNonce,
|
||||||
|
parseRuleMessage,
|
||||||
type AuthRequestPayload,
|
type AuthRequestPayload,
|
||||||
type HeartbeatPayload
|
type HeartbeatPayload
|
||||||
} from "../../../Yonexus.Protocol/src/index.js";
|
} from "../../../Yonexus.Protocol/src/index.js";
|
||||||
@@ -43,6 +46,7 @@ import {
|
|||||||
createDiscordNotificationService,
|
createDiscordNotificationService,
|
||||||
type DiscordNotificationService
|
type DiscordNotificationService
|
||||||
} from "../notifications/discord.js";
|
} from "../notifications/discord.js";
|
||||||
|
import { safeErrorMessage } from "./logging.js";
|
||||||
|
|
||||||
export interface YonexusServerRuntimeOptions {
|
export interface YonexusServerRuntimeOptions {
|
||||||
config: YonexusServerConfig;
|
config: YonexusServerConfig;
|
||||||
@@ -143,10 +147,27 @@ export class YonexusServerRuntime {
|
|||||||
|
|
||||||
async handleMessage(connection: ClientConnection, raw: string): Promise<void> {
|
async handleMessage(connection: ClientConnection, raw: string): Promise<void> {
|
||||||
if (!isBuiltinMessage(raw)) {
|
if (!isBuiltinMessage(raw)) {
|
||||||
|
// Handle rule message - rewrite and dispatch
|
||||||
|
await this.handleRuleMessage(connection, raw);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const envelope = decodeBuiltin(raw);
|
let envelope: BuiltinEnvelope;
|
||||||
|
try {
|
||||||
|
envelope = decodeBuiltin(raw);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof CodecError ? error.message : "Invalid builtin message";
|
||||||
|
this.options.transport.sendToConnection(
|
||||||
|
connection,
|
||||||
|
encodeBuiltin(
|
||||||
|
buildError(
|
||||||
|
{ code: "MALFORMED_MESSAGE", message },
|
||||||
|
{ timestamp: this.now() }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (envelope.type === "hello") {
|
if (envelope.type === "hello") {
|
||||||
await this.handleHello(connection, envelope as BuiltinEnvelope<"hello", HelloPayload>);
|
await this.handleHello(connection, envelope as BuiltinEnvelope<"hello", HelloPayload>);
|
||||||
@@ -174,7 +195,21 @@ export class YonexusServerRuntime {
|
|||||||
connection,
|
connection,
|
||||||
envelope as BuiltinEnvelope<"heartbeat", HeartbeatPayload>
|
envelope as BuiltinEnvelope<"heartbeat", HeartbeatPayload>
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.options.transport.sendToConnection(
|
||||||
|
connection,
|
||||||
|
encodeBuiltin(
|
||||||
|
buildError(
|
||||||
|
{
|
||||||
|
code: "MALFORMED_MESSAGE",
|
||||||
|
message: `Unsupported builtin type: ${String(envelope.type)}`
|
||||||
|
},
|
||||||
|
{ requestId: envelope.requestId, timestamp: this.now() }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleHello(
|
private async handleHello(
|
||||||
@@ -803,6 +838,123 @@ export class YonexusServerRuntime {
|
|||||||
private async persist(): Promise<void> {
|
private async persist(): Promise<void> {
|
||||||
await this.options.store.save(this.registry.clients.values());
|
await this.options.store.save(this.registry.clients.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a rule message to a specific client.
|
||||||
|
*
|
||||||
|
* @param identifier - The target client identifier
|
||||||
|
* @param message - The complete rule message with identifier and content
|
||||||
|
* @returns True if message was sent, false if client not connected/authenticated
|
||||||
|
*/
|
||||||
|
sendMessageToClient(identifier: string, message: string): boolean {
|
||||||
|
const session = this.registry.sessions.get(identifier);
|
||||||
|
if (!session || !session.isAuthenticated) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the message is a properly formatted rule message
|
||||||
|
try {
|
||||||
|
// Quick check: must not be a builtin message and must have :: delimiter
|
||||||
|
if (message.startsWith("builtin::")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const delimiterIndex = message.indexOf("::");
|
||||||
|
if (delimiterIndex === -1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
parseRuleMessage(message);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.options.transport.send(identifier, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a rule message to a specific client using separate rule identifier and content.
|
||||||
|
*
|
||||||
|
* @param identifier - The target client identifier
|
||||||
|
* @param ruleIdentifier - The rule identifier
|
||||||
|
* @param content - The message content
|
||||||
|
* @returns True if message was sent, false if client not connected/authenticated or invalid format
|
||||||
|
*/
|
||||||
|
sendRuleMessageToClient(identifier: string, ruleIdentifier: string, content: string): boolean {
|
||||||
|
const session = this.registry.sessions.get(identifier);
|
||||||
|
if (!session || !session.isAuthenticated) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const encoded = encodeRuleMessage(ruleIdentifier, content);
|
||||||
|
return this.options.transport.send(identifier, encoded);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle incoming rule message from a client.
|
||||||
|
* Rewrites the message to include sender identifier before dispatch.
|
||||||
|
*
|
||||||
|
* @param connection - The client connection
|
||||||
|
* @param raw - The raw rule message
|
||||||
|
*/
|
||||||
|
private async handleRuleMessage(connection: ClientConnection, raw: string): Promise<void> {
|
||||||
|
// Get sender identifier from connection or session
|
||||||
|
let senderIdentifier = connection.identifier;
|
||||||
|
if (!senderIdentifier) {
|
||||||
|
// Try to find identifier from WebSocket
|
||||||
|
for (const [id, session] of this.registry.sessions.entries()) {
|
||||||
|
if (session.socket === connection.ws) {
|
||||||
|
senderIdentifier = id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!senderIdentifier) {
|
||||||
|
// Cannot determine sender - close connection
|
||||||
|
connection.ws.close(1008, "Cannot identify sender");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = this.registry.sessions.get(senderIdentifier);
|
||||||
|
if (!session || !session.isAuthenticated) {
|
||||||
|
// Only accept rule messages from authenticated clients
|
||||||
|
connection.ws.close(1008, "Not authenticated");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = parseRuleMessage(raw);
|
||||||
|
const rewritten = `${parsed.ruleIdentifier}::${senderIdentifier}::${parsed.content}`;
|
||||||
|
|
||||||
|
// TODO: Dispatch to registered rules via rule registry
|
||||||
|
// For now, just log the rewritten message
|
||||||
|
// this.ruleRegistry.dispatch(rewritten);
|
||||||
|
|
||||||
|
// Update last activity
|
||||||
|
session.lastActivityAt = this.now();
|
||||||
|
|
||||||
|
// Future: dispatch to rule registry
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
void rewritten;
|
||||||
|
} catch (error) {
|
||||||
|
// Malformed rule message
|
||||||
|
this.options.transport.sendToConnection(
|
||||||
|
connection,
|
||||||
|
encodeBuiltin(
|
||||||
|
buildError(
|
||||||
|
{
|
||||||
|
code: "MALFORMED_MESSAGE",
|
||||||
|
message: safeErrorMessage(error) || "Invalid rule message format"
|
||||||
|
},
|
||||||
|
{ timestamp: this.now() }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createYonexusServerRuntime(
|
export function createYonexusServerRuntime(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { WebSocketServer, WebSocket, RawData } from "ws";
|
import { WebSocketServer, WebSocket, RawData } from "ws";
|
||||||
import type { YonexusServerConfig } from "./config.js";
|
import type { YonexusServerConfig } from "./config.js";
|
||||||
import type { ClientRecord } from "./persistence.js";
|
import { safeErrorMessage } from "./logging.js";
|
||||||
|
|
||||||
export interface ClientConnection {
|
export interface ClientConnection {
|
||||||
readonly identifier: string | null;
|
readonly identifier: string | null;
|
||||||
@@ -221,7 +221,7 @@ export class YonexusServerTransport implements ServerTransport {
|
|||||||
|
|
||||||
ws.on("error", (error: Error) => {
|
ws.on("error", (error: Error) => {
|
||||||
// Log error but let close handler clean up
|
// Log error but let close handler clean up
|
||||||
console.error("WebSocket error:", error.message);
|
console.error("[Yonexus.Server] WebSocket error:", safeErrorMessage(error));
|
||||||
});
|
});
|
||||||
|
|
||||||
if (this.options.onConnect) {
|
if (this.options.onConnect) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { PairingRequest } from "../services/pairing.js";
|
import type { PairingRequest } from "../services/pairing.js";
|
||||||
|
import { redactPairingCode } from "../core/logging.js";
|
||||||
|
|
||||||
export interface DiscordNotificationService {
|
export interface DiscordNotificationService {
|
||||||
/**
|
/**
|
||||||
@@ -36,11 +37,16 @@ export function createDiscordNotificationService(
|
|||||||
): DiscordNotificationService {
|
): DiscordNotificationService {
|
||||||
return {
|
return {
|
||||||
async sendPairingNotification(request: PairingRequest): Promise<boolean> {
|
async sendPairingNotification(request: PairingRequest): Promise<boolean> {
|
||||||
const message = formatPairingMessage(request);
|
const redactedCode = redactPairingCode(request.pairingCode);
|
||||||
|
|
||||||
// Log to console (visible in OpenClaw logs)
|
// Log to console (visible in OpenClaw logs)
|
||||||
console.log("[Yonexus.Server] Pairing notification (Discord DM stub):");
|
console.log("[Yonexus.Server] Pairing notification (Discord DM stub):", {
|
||||||
console.log(message);
|
identifier: request.identifier,
|
||||||
|
pairingCode: redactedCode,
|
||||||
|
expiresAt: request.expiresAt,
|
||||||
|
ttlSeconds: request.ttlSeconds,
|
||||||
|
adminUserId: config.adminUserId
|
||||||
|
});
|
||||||
|
|
||||||
// TODO: Replace with actual Discord bot integration
|
// TODO: Replace with actual Discord bot integration
|
||||||
// Example with discord.js:
|
// Example with discord.js:
|
||||||
@@ -59,7 +65,7 @@ export function createDiscordNotificationService(
|
|||||||
/**
|
/**
|
||||||
* Format a pairing request as a Discord DM message.
|
* Format a pairing request as a Discord DM message.
|
||||||
*/
|
*/
|
||||||
function formatPairingMessage(request: PairingRequest): string {
|
export function formatPairingMessage(request: PairingRequest): string {
|
||||||
const expiresDate = new Date(request.expiresAt * 1000);
|
const expiresDate = new Date(request.expiresAt * 1000);
|
||||||
const expiresStr = expiresDate.toISOString();
|
const expiresStr = expiresDate.toISOString();
|
||||||
|
|
||||||
@@ -89,7 +95,7 @@ export function createMockNotificationService(
|
|||||||
async sendPairingNotification(request: PairingRequest): Promise<boolean> {
|
async sendPairingNotification(request: PairingRequest): Promise<boolean> {
|
||||||
console.log("[Yonexus.Server] Mock pairing notification:");
|
console.log("[Yonexus.Server] Mock pairing notification:");
|
||||||
console.log(` Identifier: ${request.identifier}`);
|
console.log(` Identifier: ${request.identifier}`);
|
||||||
console.log(` Pairing Code: ${request.pairingCode}`);
|
console.log(` Pairing Code: ${redactPairingCode(request.pairingCode)}`);
|
||||||
console.log(` Success: ${shouldSucceed}`);
|
console.log(` Success: ${shouldSucceed}`);
|
||||||
return shouldSucceed;
|
return shouldSucceed;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user