feat: improve proxy account setup and backup portability
This commit is contained in:
+83
-16
@@ -81,6 +81,7 @@ import { CollectorStore } from "./collector-store";
|
||||
import type { CollectorPersistenceState } from "../shared/collector";
|
||||
import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types";
|
||||
import { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } from "./online-backup";
|
||||
import { captureOnlineProxyList, getManagedOnlineProxyListPath, writeImportedOnlineProxyList } from "./online-proxy-list";
|
||||
import { overlayLiveUsageCounters } from "./settings-live-overlay";
|
||||
import { getLegacyDesktopLogDirectory, migrateLogDirectories, prepareLogDirectory, resolveLogDirectory } from "./log-storage";
|
||||
import { normalizeStatisticsLedger, saveStatisticsLedger } from "./statistics-ledger";
|
||||
@@ -88,7 +89,8 @@ import { NotificationOutbox } from "./notification-outbox";
|
||||
import { sendNotification } from "./notify";
|
||||
import { DownloadHealthMonitor } from "./download-health-monitor";
|
||||
import { shouldDeferAutoResumeToDailyStart } from "./daily-start-scheduler";
|
||||
import { configureNetworkProxy, shutdownNetworkProxy } from "./network-proxy";
|
||||
import { configureNetworkProxy, getNetworkProxyState, shutdownNetworkProxy } from "./network-proxy";
|
||||
import { createProxyOnlyAccountError, resolveProxyOnlyAccountErrorCode } from "./proxy-account-errors";
|
||||
|
||||
function sanitizeSettingsPatch(partial: Partial<AppSettings>): Partial<AppSettings> {
|
||||
const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined);
|
||||
@@ -507,10 +509,20 @@ export class AppController {
|
||||
return getDebugSetupCheck(this.storagePaths.baseDir);
|
||||
}
|
||||
|
||||
private audit(level: "INFO" | "WARN" | "ERROR", message: string, fields?: Record<string, unknown>): void {
|
||||
logAuditEvent(level, message, fields);
|
||||
logTraceEvent(level, "audit", message, fields);
|
||||
}
|
||||
private audit(level: "INFO" | "WARN" | "ERROR", message: string, fields?: Record<string, unknown>): void {
|
||||
logAuditEvent(level, message, fields);
|
||||
logTraceEvent(level, "audit", message, fields);
|
||||
}
|
||||
|
||||
private assertProxyOnlyAccountSetup(): void {
|
||||
const code = resolveProxyOnlyAccountErrorCode(this.settings, getNetworkProxyState());
|
||||
if (code) throw createProxyOnlyAccountError(code);
|
||||
}
|
||||
|
||||
private throwProxyOnlyAccountFailure(failureText: string): void {
|
||||
const code = resolveProxyOnlyAccountErrorCode(this.settings, getNetworkProxyState(), failureText);
|
||||
if (code) throw createProxyOnlyAccountError(code);
|
||||
}
|
||||
|
||||
public setTraceEnabled(enabled: boolean, note = "", durationMs?: number): SupportTraceConfig {
|
||||
const next = setTraceEnabled(enabled, note, durationMs);
|
||||
@@ -545,7 +557,7 @@ export class AppController {
|
||||
: normalizeSettings({ ...restoredSettings, logStorageLocation: this.settings.logStorageLocation });
|
||||
}
|
||||
|
||||
private createBackupImportRollback(includeDownloads: boolean): () => void {
|
||||
private createBackupImportRollback(includeDownloads: boolean, additionalFiles: readonly string[] = []): () => void {
|
||||
const baseDir = this.storagePaths.baseDir;
|
||||
const files = [
|
||||
this.storagePaths.configFile,
|
||||
@@ -554,7 +566,8 @@ export class AppController {
|
||||
`${this.storagePaths.configFile}.bak.tmp`,
|
||||
path.join(baseDir, "debug_host.txt"),
|
||||
path.join(baseDir, "debug_port.txt"),
|
||||
path.join(baseDir, "debug_allowlist.txt")
|
||||
path.join(baseDir, "debug_allowlist.txt"),
|
||||
...additionalFiles
|
||||
];
|
||||
if (includeDownloads) {
|
||||
files.push(
|
||||
@@ -570,7 +583,7 @@ export class AppController {
|
||||
`${this.storagePaths.statisticsFile}.tmp`
|
||||
);
|
||||
}
|
||||
return createFileRollback(files);
|
||||
return createFileRollback([...new Set(files)]);
|
||||
}
|
||||
|
||||
private rollbackImportPersistence(rollback: () => void, context: string): void {
|
||||
@@ -589,7 +602,12 @@ export class AppController {
|
||||
}
|
||||
}
|
||||
|
||||
private async applySettingsOnlyBackup(importedSettings: AppSettings, remoteDiagnostics?: unknown, restoreRemoteDiagnostics = false): Promise<void> {
|
||||
private async applySettingsOnlyBackup(
|
||||
importedSettings: AppSettings,
|
||||
remoteDiagnostics?: unknown,
|
||||
restoreRemoteDiagnostics = false,
|
||||
onlineProxyListContent?: string | null
|
||||
): Promise<{ proxyListRestored: boolean; proxyOnlyDisabled: boolean }> {
|
||||
const barrier = await acquirePersistenceBarrier();
|
||||
const previousSettings = this.settings;
|
||||
let restoredSettings: AppSettings | null = null;
|
||||
@@ -597,10 +615,32 @@ export class AppController {
|
||||
let remoteRestore: ReturnType<typeof resolveRemoteDiagnosticsRestore> = null;
|
||||
let logStorageChanged = false;
|
||||
let runtimeApplied = false;
|
||||
let proxyListRestored = false;
|
||||
let proxyOnlyDisabled = false;
|
||||
try {
|
||||
restoredSettings = this.prepareImportedLogStorage(normalizeSettings(importedSettings));
|
||||
if (typeof onlineProxyListContent === "string") {
|
||||
restoredSettings = normalizeSettings({
|
||||
...restoredSettings,
|
||||
proxyListPath: getManagedOnlineProxyListPath(this.storagePaths.baseDir)
|
||||
});
|
||||
} else if (onlineProxyListContent === null) {
|
||||
proxyOnlyDisabled = restoredSettings.proxyDownloadEnabled || Boolean(restoredSettings.proxyListPath.trim());
|
||||
restoredSettings = normalizeSettings({
|
||||
...restoredSettings,
|
||||
proxyDownloadEnabled: false,
|
||||
proxyListPath: ""
|
||||
});
|
||||
}
|
||||
this.overlayLiveUsageCounters(restoredSettings);
|
||||
rollback = this.createBackupImportRollback(false);
|
||||
const managedProxyListPath = typeof onlineProxyListContent === "string"
|
||||
? getManagedOnlineProxyListPath(this.storagePaths.baseDir)
|
||||
: null;
|
||||
rollback = this.createBackupImportRollback(false, managedProxyListPath ? [managedProxyListPath] : []);
|
||||
if (typeof onlineProxyListContent === "string") {
|
||||
writeImportedOnlineProxyList(this.storagePaths.baseDir, onlineProxyListContent);
|
||||
proxyListRestored = true;
|
||||
}
|
||||
saveSettings(this.storagePaths, restoredSettings);
|
||||
remoteRestore = restoreRemoteDiagnostics
|
||||
? this.persistRemoteDiagnosticsFromBackup(remoteDiagnostics)
|
||||
@@ -651,6 +691,7 @@ export class AppController {
|
||||
throw error;
|
||||
}
|
||||
this.auditRemoteDiagnosticsRestore(remoteRestore, restoreRemoteDiagnostics);
|
||||
return { proxyListRestored, proxyOnlyDisabled };
|
||||
}
|
||||
|
||||
public updateSettings(partial: Partial<AppSettings>): AppSettings {
|
||||
@@ -720,6 +761,9 @@ export class AppController {
|
||||
const applied = applyAccountCommand(this.settings, command);
|
||||
let checkedStatus: DebridAccountStatus | null = null;
|
||||
const redactions = collectAccountStatusRedactionValues(applied.settings, command);
|
||||
if (command.action !== "delete" && ["megadebrid-api", "megadebrid-web", "debridlink-api", "realdebrid-api", "deepbrid-api"].includes(command.kind)) {
|
||||
this.assertProxyOnlyAccountSetup();
|
||||
}
|
||||
if (command.action !== "delete" && applied.response.accountId && (command.kind === "megadebrid-api" || command.kind === "megadebrid-web")) {
|
||||
const mode = command.kind === "megadebrid-web" ? "web" : "api";
|
||||
const account = getMegaDebridAccountsForMode(applied.settings, mode).find((entry) => entry.id === applied.response.accountId);
|
||||
@@ -743,6 +787,7 @@ export class AppController {
|
||||
checkedStatus = sanitizeDebridAccountStatus(checkedStatus, redactions);
|
||||
}
|
||||
if (checkedStatus && !checkedStatus.valid) {
|
||||
this.throwProxyOnlyAccountFailure(checkedStatus.message || "");
|
||||
throw new Error(checkedStatus.message || "Zugangsdaten ungültig");
|
||||
}
|
||||
this.updateSettings(applied.settings);
|
||||
@@ -766,6 +811,7 @@ export class AppController {
|
||||
}
|
||||
|
||||
public async checkAccountCredentials(input: AccountCredentialCheckInput): Promise<DebridAccountStatus> {
|
||||
this.assertProxyOnlyAccountSetup();
|
||||
const redactions = collectAccountStatusRedactionValues(this.settings, input);
|
||||
if (input.kind === "deepbrid-api") {
|
||||
const key = input.secret?.trim() || this.settings.deepbridApiKey.trim();
|
||||
@@ -774,6 +820,7 @@ export class AppController {
|
||||
if (!input.secret && input.accountId === "svc-deepbrid" && this.settings.deepbridApiKey.trim()) {
|
||||
this.manager.applyDebridAccountStatuses([status]);
|
||||
}
|
||||
if (!status.valid) this.throwProxyOnlyAccountFailure(status.message || "");
|
||||
return status;
|
||||
}
|
||||
if (input.kind === "realdebrid-api" || input.kind === "realdebrid-web") {
|
||||
@@ -802,6 +849,7 @@ export class AppController {
|
||||
if (!input.secret && getRealDebridAccounts(this.settings).some((entry) => entry.id === status.accountId)) {
|
||||
this.manager.applyDebridAccountStatuses([status]);
|
||||
}
|
||||
if (!status.valid) this.throwProxyOnlyAccountFailure(status.message || "");
|
||||
return status;
|
||||
}
|
||||
if (input.kind === "megadebrid-api" || input.kind === "megadebrid-web") {
|
||||
@@ -814,6 +862,7 @@ export class AppController {
|
||||
if (!input.secret && getMegaDebridAccountsForMode(this.settings, mode).some((entry) => entry.id === status.accountId)) {
|
||||
this.manager.applyDebridAccountStatuses([status]);
|
||||
}
|
||||
if (!status.valid) this.throwProxyOnlyAccountFailure(status.message || "");
|
||||
return status;
|
||||
}
|
||||
const key = input.secret?.trim()
|
||||
@@ -824,6 +873,7 @@ export class AppController {
|
||||
if (!input.secret && parseDebridLinkApiKeys(this.settings.debridLinkApiKeys).some((entry) => entry.id === status.accountId)) {
|
||||
this.manager.applyDebridAccountStatuses([status]);
|
||||
}
|
||||
if (!status.valid) this.throwProxyOnlyAccountFailure(status.message || "");
|
||||
return status;
|
||||
}
|
||||
|
||||
@@ -965,6 +1015,7 @@ export class AppController {
|
||||
}
|
||||
|
||||
public async openRealDebridLoginWindow(request: RealDebridLoginRequest): Promise<void> {
|
||||
this.assertProxyOnlyAccountSetup();
|
||||
const accountId = String(request.accountId || "").trim();
|
||||
if (!isRealDebridWebAccountId(accountId)) {
|
||||
throw new Error("Account-Payload ist ungültig");
|
||||
@@ -1060,8 +1111,9 @@ export class AppController {
|
||||
}
|
||||
}
|
||||
|
||||
public async openAllDebridLoginWindow(): Promise<void> {
|
||||
this.audit("INFO", "AllDebrid Login-Fenster geöffnet");
|
||||
public async openAllDebridLoginWindow(): Promise<void> {
|
||||
this.assertProxyOnlyAccountSetup();
|
||||
this.audit("INFO", "AllDebrid Login-Fenster geöffnet");
|
||||
await this.allDebridWebFallback.openLoginWindow();
|
||||
}
|
||||
|
||||
@@ -1090,6 +1142,7 @@ export class AppController {
|
||||
}
|
||||
|
||||
public async checkDebridAccounts(scope: AccountCheckScope = "active"): Promise<DebridAccountStatus[]> {
|
||||
this.assertProxyOnlyAccountSetup();
|
||||
const checkedStatuses = sanitizeDebridAccountStatuses(
|
||||
await checkAllDebridAccounts(
|
||||
this.settings,
|
||||
@@ -1100,6 +1153,12 @@ export class AppController {
|
||||
collectAccountStatusRedactionValues(this.settings)
|
||||
);
|
||||
const statuses = retainConfiguredRealDebridStatuses(this.settings, checkedStatuses);
|
||||
const proxyFailure = statuses.find((status) => !status.valid && resolveProxyOnlyAccountErrorCode(
|
||||
this.settings,
|
||||
getNetworkProxyState(),
|
||||
status.message || ""
|
||||
));
|
||||
if (proxyFailure) this.throwProxyOnlyAccountFailure(proxyFailure.message || "");
|
||||
this.manager.applyDebridAccountStatuses(statuses);
|
||||
this.audit("INFO", "Debrid-Accounts geprueft", {
|
||||
total: statuses.length,
|
||||
@@ -1357,20 +1416,28 @@ export class AppController {
|
||||
}
|
||||
|
||||
public async exportOnlineBackup(): Promise<{ key: string }> {
|
||||
const created = createOnlineBackup({ ...this.settings }, APP_VERSION);
|
||||
const proxyListContent = captureOnlineProxyList(this.settings);
|
||||
const created = createOnlineBackup({ ...this.settings }, APP_VERSION, undefined, proxyListContent);
|
||||
await uploadOnlineBackup(created.record, ONLINE_BACKUP_API_URL);
|
||||
this.audit("INFO", "Online-Sicherung erstellt", { kind: "settings-only" });
|
||||
this.audit("INFO", "Online-Sicherung erstellt", { kind: "settings-only", proxyListIncluded: proxyListContent !== undefined });
|
||||
return { key: created.key };
|
||||
}
|
||||
|
||||
public async importOnlineBackup(key: string): Promise<{ restored: boolean; relaunch: false; message: string }> {
|
||||
const payload = await downloadOnlineBackup(key, ONLINE_BACKUP_API_URL);
|
||||
await this.applySettingsOnlyBackup(payload.settings);
|
||||
const result = await this.applySettingsOnlyBackup(payload.settings, undefined, false, payload.proxyList?.content ?? null);
|
||||
this.audit("INFO", "Online-Sicherung importiert", {
|
||||
kind: "settings-only",
|
||||
proxyListRestored: result.proxyListRestored,
|
||||
proxyOnlyDisabled: result.proxyOnlyDisabled,
|
||||
accountSummary: buildAccountSummary(this.settings)
|
||||
});
|
||||
return { restored: true, relaunch: false, message: "Einstellungen aus Online-Sicherung wiederhergestellt" };
|
||||
const message = result.proxyListRestored
|
||||
? "Einstellungen und Proxy-Liste aus Online-Sicherung wiederhergestellt"
|
||||
: result.proxyOnlyDisabled
|
||||
? "Einstellungen wiederhergestellt; Proxy-only wurde deaktiviert, weil die Online-Sicherung keine Proxy-Liste enthält"
|
||||
: "Einstellungen aus Online-Sicherung wiederhergestellt";
|
||||
return { restored: true, relaunch: false, message };
|
||||
}
|
||||
|
||||
public async exportSupportBundle(): Promise<{ buffer: Buffer; defaultFileName: string }> {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import crypto from "node:crypto";
|
||||
import zlib from "node:zlib";
|
||||
import type { AppSettings } from "../shared/types";
|
||||
import { MAX_ONLINE_PROXY_LIST_BYTES, validateOnlineProxyListContent } from "./online-proxy-list";
|
||||
|
||||
const KEY_PREFIX = "MDD2-";
|
||||
const KEY_BODY_LENGTH = 70;
|
||||
@@ -12,7 +13,8 @@ const AUTH_TAG_LENGTH = 16;
|
||||
const BLOB_VERSION = 1;
|
||||
const MAX_BLOB_BYTES = 256 * 1024;
|
||||
const MAX_RESPONSE_BYTES = 512 * 1024;
|
||||
const MAX_PLAINTEXT_BYTES = 512 * 1024;
|
||||
const MAX_SETTINGS_PLAINTEXT_BYTES = 512 * 1024;
|
||||
const MAX_PLAINTEXT_BYTES = 9 * 1024 * 1024;
|
||||
const REQUEST_TIMEOUT_MS = 12_000;
|
||||
const KEY_CONTEXT = Buffer.from("MDD2-ONLINE-KEY-V1", "utf8");
|
||||
const AAD_CONTEXT = Buffer.from("MDD-ONLINE-BACKUP-V1", "utf8");
|
||||
@@ -23,6 +25,10 @@ export interface OnlineSettingsPayload {
|
||||
appVersion: string;
|
||||
exportedAt: string;
|
||||
settings: AppSettings;
|
||||
proxyList?: {
|
||||
version: 1;
|
||||
content: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OnlineBackupRecord {
|
||||
@@ -81,6 +87,13 @@ function validatePayload(value: unknown): OnlineSettingsPayload {
|
||||
) {
|
||||
throw new Error("Online-Sicherung enthält keine gültigen Einstellungen");
|
||||
}
|
||||
if (record.proxyList !== undefined) {
|
||||
const proxyList = record.proxyList as Record<string, unknown> | null;
|
||||
if (!proxyList || Array.isArray(proxyList) || proxyList.version !== 1 || typeof proxyList.content !== "string"
|
||||
|| Buffer.byteLength(proxyList.content, "utf8") > MAX_ONLINE_PROXY_LIST_BYTES) {
|
||||
throw new Error("Online-Sicherung enthält keine gültigen Einstellungen");
|
||||
}
|
||||
}
|
||||
return record as unknown as OnlineSettingsPayload;
|
||||
}
|
||||
|
||||
@@ -152,22 +165,35 @@ export function parseOnlineBackupKey(key: string): ParsedOnlineBackupKey {
|
||||
return { id: idBytes.toString("base64url"), idBytes: Buffer.from(idBytes), masterKey: Buffer.from(masterKey) };
|
||||
}
|
||||
|
||||
export function createOnlineBackup(settings: AppSettings, appVersion: string, exportedAt = new Date().toISOString()): CreatedOnlineBackup {
|
||||
export function createOnlineBackup(
|
||||
settings: AppSettings,
|
||||
appVersion: string,
|
||||
exportedAt = new Date().toISOString(),
|
||||
proxyListContent?: string
|
||||
): CreatedOnlineBackup {
|
||||
if (proxyListContent !== undefined) validateOnlineProxyListContent(proxyListContent);
|
||||
const idBytes = crypto.randomBytes(RECORD_ID_LENGTH);
|
||||
const masterKey = crypto.randomBytes(MASTER_KEY_LENGTH);
|
||||
const key = encodeKey(idBytes, masterKey);
|
||||
const encryptionKey = deriveSecret(masterKey, idBytes, "ENCRYPTION");
|
||||
const nonce = crypto.randomBytes(NONCE_LENGTH);
|
||||
const settingsSnapshot = JSON.parse(JSON.stringify(settings)) as AppSettings;
|
||||
if (Buffer.byteLength(JSON.stringify(settingsSnapshot), "utf8") > MAX_SETTINGS_PLAINTEXT_BYTES) {
|
||||
throw new Error("Einstellungen sind für eine Online-Sicherung zu groß");
|
||||
}
|
||||
const payload: OnlineSettingsPayload = {
|
||||
version: 1,
|
||||
kind: "settings-only",
|
||||
appVersion,
|
||||
exportedAt,
|
||||
settings: JSON.parse(JSON.stringify(settings)) as AppSettings
|
||||
settings: settingsSnapshot,
|
||||
...(proxyListContent === undefined ? {} : { proxyList: { version: 1 as const, content: proxyListContent } })
|
||||
};
|
||||
const plaintext = Buffer.from(JSON.stringify(payload), "utf8");
|
||||
if (plaintext.length > MAX_PLAINTEXT_BYTES) {
|
||||
throw new Error("Einstellungen sind für eine Online-Sicherung zu groß");
|
||||
throw new Error(proxyListContent === undefined
|
||||
? "Einstellungen sind für eine Online-Sicherung zu groß"
|
||||
: "Einstellungen und Proxy-Liste sind für eine Online-Sicherung zu groß");
|
||||
}
|
||||
const compressed = zlib.gzipSync(plaintext, { level: 9 });
|
||||
const cipher = crypto.createCipheriv("aes-256-gcm", encryptionKey, nonce, { authTagLength: AUTH_TAG_LENGTH });
|
||||
@@ -175,7 +201,9 @@ export function createOnlineBackup(settings: AppSettings, appVersion: string, ex
|
||||
const ciphertext = Buffer.concat([cipher.update(compressed), cipher.final()]);
|
||||
const blobBytes = Buffer.concat([Buffer.from([BLOB_VERSION]), nonce, cipher.getAuthTag(), ciphertext]);
|
||||
if (blobBytes.length > MAX_BLOB_BYTES) {
|
||||
throw new Error("Einstellungen sind für eine Online-Sicherung zu groß");
|
||||
throw new Error(proxyListContent === undefined
|
||||
? "Einstellungen sind für eine Online-Sicherung zu groß"
|
||||
: "Einstellungen und Proxy-Liste sind für eine Online-Sicherung zu groß");
|
||||
}
|
||||
const parsed = parseOnlineBackupKey(key);
|
||||
const deleteVerifier = crypto.createHash("sha256").update(deriveDeleteSecret(parsed)).digest("base64url");
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { AppSettings } from "../shared/types";
|
||||
import { parseProxyList } from "./proxy-segmented-download";
|
||||
|
||||
export const MAX_ONLINE_PROXY_LIST_BYTES = 8 * 1024 * 1024;
|
||||
const MANAGED_PROXY_LIST_FILE = "proxy-list-online-backup.txt";
|
||||
|
||||
export function validateOnlineProxyListContent(content: string): void {
|
||||
const size = Buffer.byteLength(content, "utf8");
|
||||
if (size <= 0) throw new Error("Die Proxy-Liste ist leer");
|
||||
if (size > MAX_ONLINE_PROXY_LIST_BYTES) throw new Error("Die Proxy-Liste ist für eine Online-Sicherung zu groß");
|
||||
if (parseProxyList(content) <= 0) throw new Error("Die Proxy-Liste enthält keine gültigen HTTP-Proxys");
|
||||
}
|
||||
|
||||
export function captureOnlineProxyList(settings: Pick<AppSettings, "proxyDownloadEnabled" | "proxyListPath">): string | undefined {
|
||||
const filePath = String(settings.proxyListPath || "").trim();
|
||||
if (!filePath) {
|
||||
if (settings.proxyDownloadEnabled) throw new Error("Proxy-only ist aktiviert, aber es ist keine Proxy-Liste hinterlegt");
|
||||
return undefined;
|
||||
}
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(path.resolve(filePath));
|
||||
} catch {
|
||||
throw new Error("Die hinterlegte Proxy-Liste kann nicht gelesen werden");
|
||||
}
|
||||
if (!stat.isFile()) throw new Error("Die hinterlegte Proxy-Liste kann nicht gelesen werden");
|
||||
if (stat.size <= 0) throw new Error("Die Proxy-Liste ist leer");
|
||||
if (stat.size > MAX_ONLINE_PROXY_LIST_BYTES) throw new Error("Die Proxy-Liste ist für eine Online-Sicherung zu groß");
|
||||
let content: string;
|
||||
try {
|
||||
content = fs.readFileSync(path.resolve(filePath), "utf8");
|
||||
} catch {
|
||||
throw new Error("Die hinterlegte Proxy-Liste kann nicht gelesen werden");
|
||||
}
|
||||
validateOnlineProxyListContent(content);
|
||||
return content;
|
||||
}
|
||||
|
||||
export function getManagedOnlineProxyListPath(baseDir: string): string {
|
||||
return path.join(baseDir, MANAGED_PROXY_LIST_FILE);
|
||||
}
|
||||
|
||||
export function writeImportedOnlineProxyList(baseDir: string, content: string): string {
|
||||
validateOnlineProxyListContent(content);
|
||||
const filePath = getManagedOnlineProxyListPath(baseDir);
|
||||
const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
let descriptor: number | null = null;
|
||||
try {
|
||||
descriptor = fs.openSync(tempPath, "wx", 0o600);
|
||||
fs.writeFileSync(descriptor, content, "utf8");
|
||||
fs.fsyncSync(descriptor);
|
||||
fs.closeSync(descriptor);
|
||||
descriptor = null;
|
||||
fs.renameSync(tempPath, filePath);
|
||||
return filePath;
|
||||
} catch (error) {
|
||||
if (descriptor !== null) {
|
||||
try { fs.closeSync(descriptor); } catch {}
|
||||
}
|
||||
try { fs.rmSync(tempPath, { force: true }); } catch {}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { AppSettings } from "../shared/types";
|
||||
import type { NetworkProxyState } from "./network-proxy";
|
||||
|
||||
export type ProxyOnlyAccountErrorCode =
|
||||
| "proxy_list_missing"
|
||||
| "proxy_list_unreadable"
|
||||
| "proxy_list_empty"
|
||||
| "proxy_index_unavailable"
|
||||
| "proxy_unreachable";
|
||||
|
||||
export const PROXY_ONLY_ACCOUNT_ERROR_PREFIX = "proxy_only_account:";
|
||||
|
||||
const TRANSPORT_FAILURE_PATTERN = /proxy|fetch failed|network|econn|enotfound|eai_again|etimedout|und_err|socket hang up|err_proxy|tunneling socket|timeout|aborted due to timeout|HTTP 407|connection (?:closed|refused|reset|timed out)/i;
|
||||
|
||||
export function resolveProxyOnlyAccountErrorCode(
|
||||
settings: Pick<AppSettings, "proxyDownloadEnabled" | "proxyListPath">,
|
||||
state: NetworkProxyState,
|
||||
failureText = ""
|
||||
): ProxyOnlyAccountErrorCode | null {
|
||||
if (!settings.proxyDownloadEnabled || state.status === "disabled") return null;
|
||||
if (state.status === "blocked") {
|
||||
if (state.reason === "proxy_file_unavailable") {
|
||||
return settings.proxyListPath.trim() ? "proxy_list_unreadable" : "proxy_list_missing";
|
||||
}
|
||||
if (state.reason === "no_valid_proxies") return "proxy_list_empty";
|
||||
return "proxy_index_unavailable";
|
||||
}
|
||||
return TRANSPORT_FAILURE_PATTERN.test(failureText) ? "proxy_unreachable" : null;
|
||||
}
|
||||
|
||||
export function createProxyOnlyAccountError(code: ProxyOnlyAccountErrorCode): Error {
|
||||
return new Error(`${PROXY_ONLY_ACCOUNT_ERROR_PREFIX}${code}`);
|
||||
}
|
||||
Reference in New Issue
Block a user