mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-02 09:00:22 +00:00
fix(config): migrate bundled private-network aliases (#60862)
* refactor(plugin-sdk): centralize private-network opt-in semantics * fix(config): migrate bundled private-network aliases * fix(config): add bundled private-network doctor adapters * fix(config): expose bundled channel migration hooks * fix(config): prefer canonical private-network key * test(config): refresh rebased private-network outputs
This commit is contained in:
@@ -33,6 +33,7 @@ import {
|
||||
type OpenClawConfig,
|
||||
} from "./channel-api.js";
|
||||
import { NextcloudTalkConfigSchema } from "./config-schema.js";
|
||||
import { nextcloudTalkDoctor } from "./doctor.js";
|
||||
import { monitorNextcloudTalkProvider } from "./monitor.js";
|
||||
import {
|
||||
looksLikeNextcloudTalkTargetId,
|
||||
@@ -141,6 +142,7 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
|
||||
}),
|
||||
},
|
||||
auth: nextcloudTalkApprovalAuth,
|
||||
doctor: nextcloudTalkDoctor,
|
||||
groups: {
|
||||
resolveRequireMention: ({ cfg, accountId, groupId }) => {
|
||||
const account = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId });
|
||||
|
||||
@@ -23,6 +23,14 @@ export const NextcloudTalkRoomSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const NextcloudTalkNetworkSchema = z
|
||||
.object({
|
||||
/** Dangerous opt-in for self-hosted Nextcloud Talk on trusted private/internal hosts. */
|
||||
dangerouslyAllowPrivateNetwork: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
export const NextcloudTalkAccountSchemaBase = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
@@ -43,8 +51,8 @@ export const NextcloudTalkAccountSchemaBase = z
|
||||
groupAllowFrom: z.array(z.string()).optional(),
|
||||
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
||||
rooms: z.record(z.string(), NextcloudTalkRoomSchema.optional()).optional(),
|
||||
/** Allow fetching from private/internal IP addresses (e.g. localhost). Required for self-hosted Nextcloud on LAN/VPN. */
|
||||
allowPrivateNetwork: z.boolean().optional(),
|
||||
/** Network policy overrides for self-hosted Nextcloud Talk on trusted private/internal hosts. */
|
||||
network: NextcloudTalkNetworkSchema,
|
||||
...ReplyRuntimeConfigSchemaShape,
|
||||
})
|
||||
.strict();
|
||||
|
||||
104
extensions/nextcloud-talk/src/doctor-contract.ts
Normal file
104
extensions/nextcloud-talk/src/doctor-contract.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type {
|
||||
ChannelDoctorConfigMutation,
|
||||
ChannelDoctorLegacyConfigRule,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
hasLegacyFlatAllowPrivateNetworkAlias,
|
||||
migrateLegacyFlatAllowPrivateNetworkAlias,
|
||||
} from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hasLegacyAllowPrivateNetworkInAccounts(value: unknown): boolean {
|
||||
const accounts = isRecord(value) ? value : null;
|
||||
return Boolean(
|
||||
accounts &&
|
||||
Object.values(accounts).some((account) =>
|
||||
hasLegacyFlatAllowPrivateNetworkAlias(isRecord(account) ? account : {}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
|
||||
{
|
||||
path: ["channels", "nextcloud-talk"],
|
||||
message:
|
||||
"channels.nextcloud-talk.allowPrivateNetwork is legacy; use channels.nextcloud-talk.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).",
|
||||
match: (value) => hasLegacyFlatAllowPrivateNetworkAlias(isRecord(value) ? value : {}),
|
||||
},
|
||||
{
|
||||
path: ["channels", "nextcloud-talk", "accounts"],
|
||||
message:
|
||||
"channels.nextcloud-talk.accounts.<id>.allowPrivateNetwork is legacy; use channels.nextcloud-talk.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).",
|
||||
match: hasLegacyAllowPrivateNetworkInAccounts,
|
||||
},
|
||||
];
|
||||
|
||||
export function normalizeCompatibilityConfig({
|
||||
cfg,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
}): ChannelDoctorConfigMutation {
|
||||
const channels = isRecord(cfg.channels) ? cfg.channels : null;
|
||||
const nextcloudTalk = isRecord(channels?.["nextcloud-talk"]) ? channels["nextcloud-talk"] : null;
|
||||
if (!nextcloudTalk) {
|
||||
return { config: cfg, changes: [] };
|
||||
}
|
||||
|
||||
const changes: string[] = [];
|
||||
let updatedNextcloudTalk = nextcloudTalk;
|
||||
let changed = false;
|
||||
|
||||
const topLevel = migrateLegacyFlatAllowPrivateNetworkAlias({
|
||||
entry: updatedNextcloudTalk,
|
||||
pathPrefix: "channels.nextcloud-talk",
|
||||
changes,
|
||||
});
|
||||
updatedNextcloudTalk = topLevel.entry;
|
||||
changed = changed || topLevel.changed;
|
||||
|
||||
const accounts = isRecord(updatedNextcloudTalk.accounts) ? updatedNextcloudTalk.accounts : null;
|
||||
if (accounts) {
|
||||
let accountsChanged = false;
|
||||
const nextAccounts: Record<string, unknown> = { ...accounts };
|
||||
for (const [accountId, accountValue] of Object.entries(accounts)) {
|
||||
const account = isRecord(accountValue) ? accountValue : null;
|
||||
if (!account) {
|
||||
continue;
|
||||
}
|
||||
const migrated = migrateLegacyFlatAllowPrivateNetworkAlias({
|
||||
entry: account,
|
||||
pathPrefix: `channels.nextcloud-talk.accounts.${accountId}`,
|
||||
changes,
|
||||
});
|
||||
if (!migrated.changed) {
|
||||
continue;
|
||||
}
|
||||
nextAccounts[accountId] = migrated.entry;
|
||||
accountsChanged = true;
|
||||
}
|
||||
if (accountsChanged) {
|
||||
updatedNextcloudTalk = { ...updatedNextcloudTalk, accounts: nextAccounts };
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return { config: cfg, changes: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
config: {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
"nextcloud-talk":
|
||||
updatedNextcloudTalk as NonNullable<OpenClawConfig["channels"]>["nextcloud-talk"],
|
||||
},
|
||||
},
|
||||
changes,
|
||||
};
|
||||
}
|
||||
34
extensions/nextcloud-talk/src/doctor.test.ts
Normal file
34
extensions/nextcloud-talk/src/doctor.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { nextcloudTalkDoctor } from "./doctor.js";
|
||||
|
||||
describe("nextcloud-talk doctor", () => {
|
||||
it("normalizes legacy private-network aliases", () => {
|
||||
const normalize = nextcloudTalkDoctor.normalizeCompatibilityConfig;
|
||||
expect(normalize).toBeDefined();
|
||||
if (!normalize) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = normalize({
|
||||
cfg: {
|
||||
channels: {
|
||||
"nextcloud-talk": {
|
||||
allowPrivateNetwork: true,
|
||||
accounts: {
|
||||
work: {
|
||||
allowPrivateNetwork: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(result.config.channels?.["nextcloud-talk"]?.network).toEqual({
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
});
|
||||
expect(result.config.channels?.["nextcloud-talk"]?.accounts?.work?.network).toEqual({
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
106
extensions/nextcloud-talk/src/doctor.ts
Normal file
106
extensions/nextcloud-talk/src/doctor.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type {
|
||||
ChannelDoctorAdapter,
|
||||
ChannelDoctorConfigMutation,
|
||||
ChannelDoctorLegacyConfigRule,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
hasLegacyFlatAllowPrivateNetworkAlias,
|
||||
migrateLegacyFlatAllowPrivateNetworkAlias,
|
||||
} from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hasLegacyAllowPrivateNetworkInAccounts(value: unknown): boolean {
|
||||
const accounts = isRecord(value) ? value : null;
|
||||
return Boolean(
|
||||
accounts &&
|
||||
Object.values(accounts).some((account) =>
|
||||
hasLegacyFlatAllowPrivateNetworkAlias(isRecord(account) ? account : {}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeNextcloudTalkCompatibilityConfig(cfg: OpenClawConfig): ChannelDoctorConfigMutation {
|
||||
const channels = isRecord(cfg.channels) ? cfg.channels : null;
|
||||
const nextcloudTalk = isRecord(channels?.["nextcloud-talk"]) ? channels["nextcloud-talk"] : null;
|
||||
if (!nextcloudTalk) {
|
||||
return { config: cfg, changes: [] };
|
||||
}
|
||||
|
||||
const changes: string[] = [];
|
||||
let updatedNextcloudTalk = nextcloudTalk;
|
||||
let changed = false;
|
||||
|
||||
const topLevel = migrateLegacyFlatAllowPrivateNetworkAlias({
|
||||
entry: updatedNextcloudTalk,
|
||||
pathPrefix: "channels.nextcloud-talk",
|
||||
changes,
|
||||
});
|
||||
updatedNextcloudTalk = topLevel.entry;
|
||||
changed = changed || topLevel.changed;
|
||||
|
||||
const accounts = isRecord(updatedNextcloudTalk.accounts) ? updatedNextcloudTalk.accounts : null;
|
||||
if (accounts) {
|
||||
let accountsChanged = false;
|
||||
const nextAccounts: Record<string, unknown> = { ...accounts };
|
||||
for (const [accountId, accountValue] of Object.entries(accounts)) {
|
||||
const account = isRecord(accountValue) ? accountValue : null;
|
||||
if (!account) {
|
||||
continue;
|
||||
}
|
||||
const migrated = migrateLegacyFlatAllowPrivateNetworkAlias({
|
||||
entry: account,
|
||||
pathPrefix: `channels.nextcloud-talk.accounts.${accountId}`,
|
||||
changes,
|
||||
});
|
||||
if (!migrated.changed) {
|
||||
continue;
|
||||
}
|
||||
nextAccounts[accountId] = migrated.entry;
|
||||
accountsChanged = true;
|
||||
}
|
||||
if (accountsChanged) {
|
||||
updatedNextcloudTalk = { ...updatedNextcloudTalk, accounts: nextAccounts };
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return { config: cfg, changes: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
config: {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
"nextcloud-talk":
|
||||
updatedNextcloudTalk as NonNullable<OpenClawConfig["channels"]>["nextcloud-talk"],
|
||||
},
|
||||
},
|
||||
changes,
|
||||
};
|
||||
}
|
||||
|
||||
const NEXTCLOUD_TALK_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [
|
||||
{
|
||||
path: ["channels", "nextcloud-talk"],
|
||||
message:
|
||||
"channels.nextcloud-talk.allowPrivateNetwork is legacy; use channels.nextcloud-talk.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).",
|
||||
match: (value) => hasLegacyFlatAllowPrivateNetworkAlias(isRecord(value) ? value : {}),
|
||||
},
|
||||
{
|
||||
path: ["channels", "nextcloud-talk", "accounts"],
|
||||
message:
|
||||
"channels.nextcloud-talk.accounts.<id>.allowPrivateNetwork is legacy; use channels.nextcloud-talk.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).",
|
||||
match: hasLegacyAllowPrivateNetworkInAccounts,
|
||||
},
|
||||
];
|
||||
|
||||
export const nextcloudTalkDoctor: ChannelDoctorAdapter = {
|
||||
legacyConfigRules: NEXTCLOUD_TALK_LEGACY_CONFIG_RULES,
|
||||
normalizeCompatibilityConfig: ({ cfg }) => normalizeNextcloudTalkCompatibilityConfig(cfg),
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { ssrfPolicyFromPrivateNetworkOptIn } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { fetchWithSsrFGuard, type RuntimeEnv } from "../runtime-api.js";
|
||||
import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
|
||||
import { normalizeResolvedSecretInputString } from "./secret-input.js";
|
||||
@@ -111,7 +112,7 @@ export async function resolveNextcloudTalkRoomKind(params: {
|
||||
},
|
||||
},
|
||||
auditContext: "nextcloud-talk.room-info",
|
||||
policy: account.config?.allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined,
|
||||
policy: ssrfPolicyFromPrivateNetworkOptIn(account.config),
|
||||
});
|
||||
try {
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
export { ssrfPolicyFromPrivateNetworkOptIn } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
export { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime";
|
||||
export { fetchWithSsrFGuard } from "../runtime-api.js";
|
||||
export { resolveNextcloudTalkAccount } from "./accounts.js";
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getNextcloudTalkRuntime,
|
||||
resolveMarkdownTableMode,
|
||||
resolveNextcloudTalkAccount,
|
||||
ssrfPolicyFromPrivateNetworkOptIn,
|
||||
} from "./send.runtime.js";
|
||||
import type { CoreConfig, NextcloudTalkSendResult } from "./types.js";
|
||||
|
||||
@@ -130,7 +131,7 @@ export async function sendMessageNextcloudTalk(
|
||||
body: bodyStr,
|
||||
},
|
||||
auditContext: "nextcloud-talk-send",
|
||||
policy: account.config?.allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined,
|
||||
policy: ssrfPolicyFromPrivateNetworkOptIn(account.config),
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -218,7 +219,7 @@ export async function sendReactionNextcloudTalk(
|
||||
body,
|
||||
},
|
||||
auditContext: "nextcloud-talk-reaction",
|
||||
policy: account.config?.allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined,
|
||||
policy: ssrfPolicyFromPrivateNetworkOptIn(account.config),
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -22,6 +22,11 @@ export type NextcloudTalkRoomConfig = {
|
||||
systemPrompt?: string;
|
||||
};
|
||||
|
||||
export type NextcloudTalkNetworkConfig = {
|
||||
/** Dangerous opt-in for self-hosted Nextcloud Talk on trusted private/internal hosts. */
|
||||
dangerouslyAllowPrivateNetwork?: boolean;
|
||||
};
|
||||
|
||||
export type NextcloudTalkAccountConfig = {
|
||||
/** Optional display name for this account (used in CLI/UI lists). */
|
||||
name?: string;
|
||||
@@ -75,8 +80,8 @@ export type NextcloudTalkAccountConfig = {
|
||||
responsePrefix?: string;
|
||||
/** Media upload max size in MB. */
|
||||
mediaMaxMb?: number;
|
||||
/** Allow fetching from private/internal IP addresses (e.g. localhost). Required for self-hosted Nextcloud on LAN/VPN. */
|
||||
allowPrivateNetwork?: boolean;
|
||||
/** Network policy overrides for self-hosted Nextcloud Talk on trusted private/internal hosts. */
|
||||
network?: NextcloudTalkNetworkConfig;
|
||||
};
|
||||
|
||||
export type NextcloudTalkConfig = {
|
||||
|
||||
Reference in New Issue
Block a user