Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
664fe90c10 |
@@ -793,6 +793,10 @@ const en: Messages = {
|
||||
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent is not running',
|
||||
'hostDetails.agentForwarding.agentNotRunningHint': 'Enable OpenSSH Authentication Agent service in Windows Services (services.msc) for agent forwarding to work.',
|
||||
'hostDetails.section.agentForwarding': 'SSH Agent',
|
||||
'hostDetails.section.legacyAlgorithms': 'Legacy Algorithms',
|
||||
'hostDetails.legacyAlgorithms': 'Allow Legacy Algorithms',
|
||||
'hostDetails.legacyAlgorithms.desc': 'Enable deprecated SSH algorithms (diffie-hellman-group1, ssh-dss, 3des-cbc, etc.) for connecting to older network equipment.',
|
||||
'hostDetails.legacyAlgorithms.warning': 'These algorithms have known security weaknesses. Only enable for legacy devices that do not support modern cryptography.',
|
||||
'hostDetails.jumpHosts': 'Proxy via Hosts',
|
||||
'hostDetails.jumpHosts.hops': '{count} hop(s)',
|
||||
'hostDetails.jumpHosts.direct': 'Direct',
|
||||
|
||||
@@ -508,6 +508,10 @@ const zhCN: Messages = {
|
||||
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent 未运行',
|
||||
'hostDetails.agentForwarding.agentNotRunningHint': '请在 Windows 服务管理器 (services.msc) 中启用 OpenSSH Authentication Agent 服务。',
|
||||
'hostDetails.section.agentForwarding': 'SSH 代理',
|
||||
'hostDetails.section.legacyAlgorithms': '旧版算法',
|
||||
'hostDetails.legacyAlgorithms': '允许旧版算法',
|
||||
'hostDetails.legacyAlgorithms.desc': '启用已弃用的 SSH 算法(diffie-hellman-group1、ssh-dss、3des-cbc 等)以连接老旧网络设备。',
|
||||
'hostDetails.legacyAlgorithms.warning': '这些算法存在已知安全漏洞,仅建议在老旧设备不支持现代加密时启用。',
|
||||
'hostDetails.jumpHosts': '通过主机代理',
|
||||
'hostDetails.jumpHosts.hops': '{count} 跳',
|
||||
'hostDetails.jumpHosts.direct': '直连',
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Plus,
|
||||
Settings2,
|
||||
Shield,
|
||||
ShieldAlert,
|
||||
Tag,
|
||||
TerminalSquare,
|
||||
User,
|
||||
@@ -1230,6 +1231,30 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Legacy Algorithms */}
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldAlert size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t("hostDetails.section.legacyAlgorithms")}</p>
|
||||
</div>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.legacyAlgorithms")}
|
||||
enabled={!!form.legacyAlgorithms}
|
||||
onToggle={() => update("legacyAlgorithms", !form.legacyAlgorithms)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.legacyAlgorithms.desc")}
|
||||
</p>
|
||||
{form.legacyAlgorithms && (
|
||||
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
|
||||
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400">
|
||||
{t("hostDetails.legacyAlgorithms.warning")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Proxy via Hosts (Jump Hosts / ProxyJump) */}
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -42,6 +42,7 @@ interface SFTPModalProps {
|
||||
proxy?: NetcattyProxyConfig;
|
||||
jumpHosts?: NetcattyJumpHost[];
|
||||
sftpSudo?: boolean;
|
||||
legacyAlgorithms?: boolean;
|
||||
};
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -526,7 +527,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
// Find the files to pass to confirm dialog
|
||||
if (fileNames.length === 0) return;
|
||||
if (!confirm(t("sftp.deleteConfirm.title", { count: fileNames.length }))) return;
|
||||
|
||||
|
||||
// Delete files
|
||||
(async () => {
|
||||
try {
|
||||
|
||||
@@ -1647,6 +1647,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
proxy: proxyConfig,
|
||||
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
|
||||
sftpSudo: host.sftpSudo,
|
||||
legacyAlgorithms: host.legacyAlgorithms,
|
||||
};
|
||||
})()}
|
||||
open={showSFTP && status === "connected"}
|
||||
|
||||
@@ -20,6 +20,7 @@ interface UseSftpModalSessionParams {
|
||||
proxy?: NetcattyProxyConfig;
|
||||
jumpHosts?: NetcattyJumpHost[];
|
||||
sftpSudo?: boolean;
|
||||
legacyAlgorithms?: boolean;
|
||||
};
|
||||
initialPath?: string;
|
||||
isLocalSession: boolean;
|
||||
@@ -39,6 +40,7 @@ interface UseSftpModalSessionParams {
|
||||
proxy?: NetcattyProxyConfig;
|
||||
jumpHosts?: NetcattyJumpHost[];
|
||||
sudo?: boolean;
|
||||
legacyAlgorithms?: boolean;
|
||||
}) => Promise<string>;
|
||||
closeSftp: (sftpId: string) => Promise<void>;
|
||||
listSftp: (sftpId: string, path: string) => Promise<RemoteFile[]>;
|
||||
@@ -112,6 +114,7 @@ export const useSftpModalSession = ({
|
||||
proxy: credentials.proxy,
|
||||
jumpHosts: credentials.jumpHosts,
|
||||
sudo: credentials.sftpSudo,
|
||||
legacyAlgorithms: credentials.legacyAlgorithms,
|
||||
});
|
||||
sftpIdRef.current = sftpId;
|
||||
return sftpId;
|
||||
@@ -131,6 +134,7 @@ export const useSftpModalSession = ({
|
||||
credentials.proxy,
|
||||
credentials.jumpHosts,
|
||||
credentials.sftpSudo,
|
||||
credentials.legacyAlgorithms,
|
||||
openSftp,
|
||||
]);
|
||||
|
||||
|
||||
@@ -402,6 +402,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
? (effectivePassphrase || sanitizeCredentialValue(attempt.key.passphrase))
|
||||
: undefined,
|
||||
agentForwarding: ctx.host.agentForwarding,
|
||||
legacyAlgorithms: ctx.host.legacyAlgorithms,
|
||||
cols: term.cols,
|
||||
rows: term.rows,
|
||||
charset: ctx.host.charset,
|
||||
|
||||
@@ -99,6 +99,8 @@ export interface Host {
|
||||
// Host-level keyword highlighting (overrides/extends global settings)
|
||||
keywordHighlightRules?: KeywordHighlightRule[];
|
||||
keywordHighlightEnabled?: boolean;
|
||||
// Legacy SSH algorithm support for older network equipment (switches, routers)
|
||||
legacyAlgorithms?: boolean;
|
||||
}
|
||||
|
||||
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519';
|
||||
@@ -194,7 +196,7 @@ export const parseKeyCombo = (keyStr: string): { modifiers: string[]; key: strin
|
||||
// Convert keyboard event to a key string
|
||||
export const keyEventToString = (e: KeyboardEvent, isMac: boolean): string => {
|
||||
const parts: string[] = [];
|
||||
|
||||
|
||||
if (isMac) {
|
||||
if (e.metaKey) parts.push('⌘');
|
||||
if (e.ctrlKey) parts.push('⌃');
|
||||
@@ -206,7 +208,7 @@ export const keyEventToString = (e: KeyboardEvent, isMac: boolean): string => {
|
||||
if (e.shiftKey) parts.push('Shift');
|
||||
if (e.metaKey) parts.push('Win');
|
||||
}
|
||||
|
||||
|
||||
// Get the key name
|
||||
let keyName = e.key;
|
||||
// Normalize special keys
|
||||
@@ -221,12 +223,12 @@ export const keyEventToString = (e: KeyboardEvent, isMac: boolean): string => {
|
||||
else if (keyName === 'Enter') keyName = '↵';
|
||||
else if (keyName === 'Tab') keyName = '⇥';
|
||||
else if (keyName.length === 1) keyName = keyName.toUpperCase();
|
||||
|
||||
|
||||
// Don't include modifier keys themselves
|
||||
if (['Meta', 'Control', 'Alt', 'Shift'].includes(e.key)) {
|
||||
return parts.join(' + ');
|
||||
}
|
||||
|
||||
|
||||
parts.push(keyName);
|
||||
return parts.join(' + ');
|
||||
};
|
||||
@@ -234,7 +236,7 @@ export const keyEventToString = (e: KeyboardEvent, isMac: boolean): string => {
|
||||
// Check if a keyboard event matches a key binding string
|
||||
export const matchesKeyBinding = (e: KeyboardEvent, keyStr: string, isMac: boolean): boolean => {
|
||||
if (!keyStr || keyStr === 'Disabled') return false;
|
||||
|
||||
|
||||
// Handle range patterns like "[1...9]"
|
||||
if (keyStr.includes('[1...9]')) {
|
||||
const basePattern = keyStr.replace('[1...9]', '');
|
||||
@@ -244,7 +246,7 @@ export const matchesKeyBinding = (e: KeyboardEvent, keyStr: string, isMac: boole
|
||||
const testStr = basePattern + key;
|
||||
return matchesKeyBinding(e, testStr.trim(), isMac);
|
||||
}
|
||||
|
||||
|
||||
// Handle arrow key patterns like "arrows"
|
||||
if (keyStr.includes('arrows')) {
|
||||
const basePattern = keyStr.replace('arrows', '');
|
||||
@@ -252,18 +254,18 @@ export const matchesKeyBinding = (e: KeyboardEvent, keyStr: string, isMac: boole
|
||||
// Check if it's an arrow key
|
||||
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(key)) return false;
|
||||
// Map arrow key to symbol for matching
|
||||
const arrowSymbol = key === 'ArrowUp' ? '↑'
|
||||
const arrowSymbol = key === 'ArrowUp' ? '↑'
|
||||
: key === 'ArrowDown' ? '↓'
|
||||
: key === 'ArrowLeft' ? '←'
|
||||
: '→';
|
||||
: key === 'ArrowLeft' ? '←'
|
||||
: '→';
|
||||
// Check modifiers match the base pattern
|
||||
const testStr = basePattern + arrowSymbol;
|
||||
return matchesKeyBinding(e, testStr.trim(), isMac);
|
||||
}
|
||||
|
||||
|
||||
const parsed = parseKeyCombo(keyStr);
|
||||
if (!parsed) return false;
|
||||
|
||||
|
||||
const { modifiers, key } = parsed;
|
||||
|
||||
const hasMacModifiers = modifiers.some((modifier) => ['⌘', '⌃', '⌥'].includes(modifier));
|
||||
@@ -271,14 +273,14 @@ export const matchesKeyBinding = (e: KeyboardEvent, keyStr: string, isMac: boole
|
||||
if ((!isMac && hasMacModifiers) || (isMac && hasPcModifiers)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Check modifiers
|
||||
if (isMac) {
|
||||
const needMeta = modifiers.includes('⌘');
|
||||
const needCtrl = modifiers.includes('⌃');
|
||||
const needAlt = modifiers.includes('⌥');
|
||||
const needShift = modifiers.includes('Shift');
|
||||
|
||||
|
||||
if (e.metaKey !== needMeta) return false;
|
||||
if (e.ctrlKey !== needCtrl) return false;
|
||||
if (e.altKey !== needAlt) return false;
|
||||
@@ -288,13 +290,13 @@ export const matchesKeyBinding = (e: KeyboardEvent, keyStr: string, isMac: boole
|
||||
const needAlt = modifiers.includes('Alt');
|
||||
const needShift = modifiers.includes('Shift');
|
||||
const needMeta = modifiers.includes('Win');
|
||||
|
||||
|
||||
if (e.ctrlKey !== needCtrl) return false;
|
||||
if (e.altKey !== needAlt) return false;
|
||||
if (e.shiftKey !== needShift) return false;
|
||||
if (e.metaKey !== needMeta) return false;
|
||||
}
|
||||
|
||||
|
||||
const normalizeKey = (rawKey: string): string => {
|
||||
let normalizedKey = rawKey;
|
||||
if (normalizedKey === ' ') normalizedKey = 'Space';
|
||||
@@ -524,17 +526,17 @@ export interface RemoteFile {
|
||||
|
||||
export type WorkspaceNode =
|
||||
| {
|
||||
id: string;
|
||||
type: 'pane';
|
||||
sessionId: string;
|
||||
}
|
||||
id: string;
|
||||
type: 'pane';
|
||||
sessionId: string;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
type: 'split';
|
||||
direction: 'horizontal' | 'vertical';
|
||||
children: WorkspaceNode[];
|
||||
sizes?: number[]; // relative sizes for children
|
||||
};
|
||||
id: string;
|
||||
type: 'split';
|
||||
direction: 'horizontal' | 'vertical';
|
||||
children: WorkspaceNode[];
|
||||
sizes?: number[]; // relative sizes for children
|
||||
};
|
||||
|
||||
export type WorkspaceViewMode = 'split' | 'focus';
|
||||
|
||||
|
||||
@@ -23,9 +23,9 @@ const { NetcattyAgent } = require("./netcattyAgent.cjs");
|
||||
const fileWatcherBridge = require("./fileWatcherBridge.cjs");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const { createProxySocket } = require("./proxyUtils.cjs");
|
||||
const {
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
const {
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
applyAuthToConnOpts,
|
||||
safeSend: authSafeSend,
|
||||
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
|
||||
@@ -254,6 +254,44 @@ const ensureRemoteDirForSession = async (sftpId, dirPath, requestedEncoding) =>
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build SSH algorithm configuration for SFTP connections.
|
||||
* When legacyEnabled is true, legacy algorithms are appended for older device compatibility.
|
||||
*/
|
||||
function buildSftpAlgorithms(legacyEnabled) {
|
||||
const algorithms = {
|
||||
cipher: [
|
||||
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
|
||||
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
|
||||
],
|
||||
kex: [
|
||||
'curve25519-sha256', 'curve25519-sha256@libssh.org',
|
||||
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
|
||||
'diffie-hellman-group14-sha256',
|
||||
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
|
||||
'diffie-hellman-group-exchange-sha256',
|
||||
],
|
||||
compress: ['none'],
|
||||
};
|
||||
|
||||
if (legacyEnabled) {
|
||||
algorithms.kex.push(
|
||||
'diffie-hellman-group14-sha1',
|
||||
'diffie-hellman-group1-sha1',
|
||||
);
|
||||
algorithms.cipher.push(
|
||||
'aes128-cbc', 'aes256-cbc', '3des-cbc',
|
||||
);
|
||||
algorithms.serverHostKey = [
|
||||
'ssh-ed25519', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521',
|
||||
'rsa-sha2-512', 'rsa-sha2-256',
|
||||
'ssh-rsa', 'ssh-dss',
|
||||
];
|
||||
}
|
||||
|
||||
return algorithms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to renderer safely
|
||||
*/
|
||||
@@ -307,22 +345,7 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
keepaliveCountMax: 3,
|
||||
// Enable keyboard-interactive authentication (required for 2FA/MFA)
|
||||
tryKeyboard: true,
|
||||
algorithms: {
|
||||
// Prioritize fastest ciphers (GCM modes are hardware-accelerated)
|
||||
cipher: [
|
||||
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
|
||||
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
|
||||
],
|
||||
// Prioritize modern key exchange algorithms for broad compatibility
|
||||
kex: [
|
||||
'curve25519-sha256', 'curve25519-sha256@libssh.org',
|
||||
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
|
||||
'diffie-hellman-group14-sha256',
|
||||
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
|
||||
'diffie-hellman-group-exchange-sha256',
|
||||
],
|
||||
compress: ['none'],
|
||||
},
|
||||
algorithms: buildSftpAlgorithms(options.legacyAlgorithms),
|
||||
};
|
||||
|
||||
// Auth - support agent (certificate), key, and password fallback
|
||||
@@ -726,6 +749,7 @@ async function openSftp(event, options) {
|
||||
// Enable keyboard-interactive authentication (required for 2FA/MFA)
|
||||
tryKeyboard: true,
|
||||
readyTimeout: 120000, // 2 minutes for 2FA input
|
||||
algorithms: buildSftpAlgorithms(options.legacyAlgorithms),
|
||||
};
|
||||
|
||||
// Use the tunneled socket if we have one
|
||||
|
||||
@@ -173,6 +173,45 @@ const log = (msg, data) => {
|
||||
console.log("[SSH]", msg, data ? JSON.stringify(data, null, 2) : "");
|
||||
};
|
||||
|
||||
/**
|
||||
* Build SSH algorithm configuration.
|
||||
* When legacyEnabled is true, legacy algorithms are appended to each list
|
||||
* (lower priority than modern ones) for compatibility with older network equipment.
|
||||
*/
|
||||
function buildAlgorithms(legacyEnabled) {
|
||||
const algorithms = {
|
||||
cipher: [
|
||||
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
|
||||
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
|
||||
],
|
||||
kex: [
|
||||
'curve25519-sha256', 'curve25519-sha256@libssh.org',
|
||||
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
|
||||
'diffie-hellman-group14-sha256',
|
||||
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
|
||||
'diffie-hellman-group-exchange-sha256',
|
||||
],
|
||||
compress: ['none'],
|
||||
};
|
||||
|
||||
if (legacyEnabled) {
|
||||
algorithms.kex.push(
|
||||
'diffie-hellman-group14-sha1',
|
||||
'diffie-hellman-group1-sha1',
|
||||
);
|
||||
algorithms.cipher.push(
|
||||
'aes128-cbc', 'aes256-cbc', '3des-cbc',
|
||||
);
|
||||
algorithms.serverHostKey = [
|
||||
'ssh-ed25519', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521',
|
||||
'rsa-sha2-512', 'rsa-sha2-256',
|
||||
'ssh-rsa', 'ssh-dss',
|
||||
];
|
||||
}
|
||||
|
||||
return algorithms;
|
||||
}
|
||||
|
||||
// Session storage - shared reference passed from main
|
||||
let sessions = null;
|
||||
let electronModule = null;
|
||||
@@ -277,22 +316,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
keepaliveCountMax: 3,
|
||||
// Enable keyboard-interactive authentication (required for 2FA/MFA)
|
||||
tryKeyboard: true,
|
||||
algorithms: {
|
||||
// Prioritize fastest ciphers (GCM modes are hardware-accelerated)
|
||||
cipher: [
|
||||
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
|
||||
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
|
||||
],
|
||||
// Prioritize modern key exchange algorithms for broad compatibility
|
||||
kex: [
|
||||
'curve25519-sha256', 'curve25519-sha256@libssh.org',
|
||||
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
|
||||
'diffie-hellman-group14-sha256',
|
||||
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
|
||||
'diffie-hellman-group-exchange-sha256',
|
||||
],
|
||||
compress: ['none'],
|
||||
},
|
||||
algorithms: buildAlgorithms(options.legacyAlgorithms),
|
||||
};
|
||||
|
||||
// Auth - support agent (certificate), key, password, and default key fallback
|
||||
@@ -465,22 +489,7 @@ async function startSSHSession(event, options) {
|
||||
keepaliveCountMax: 3,
|
||||
// Enable keyboard-interactive authentication (required for 2FA/MFA)
|
||||
tryKeyboard: true,
|
||||
algorithms: {
|
||||
// Prioritize fastest ciphers (GCM modes are hardware-accelerated)
|
||||
cipher: [
|
||||
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
|
||||
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
|
||||
],
|
||||
// Prioritize modern key exchange algorithms for broad compatibility
|
||||
kex: [
|
||||
'curve25519-sha256', 'curve25519-sha256@libssh.org',
|
||||
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
|
||||
'diffie-hellman-group14-sha256',
|
||||
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
|
||||
'diffie-hellman-group-exchange-sha256',
|
||||
],
|
||||
compress: ['none'],
|
||||
},
|
||||
algorithms: buildAlgorithms(options.legacyAlgorithms),
|
||||
};
|
||||
|
||||
// Authentication for final target
|
||||
|
||||
2
global.d.ts
vendored
2
global.d.ts
vendored
@@ -72,6 +72,8 @@ declare global {
|
||||
jumpHosts?: NetcattyJumpHost[];
|
||||
// SSH-level keepalive interval in seconds (0 = disabled)
|
||||
keepaliveInterval?: number;
|
||||
// Enable legacy SSH algorithms for older network equipment
|
||||
legacyAlgorithms?: boolean;
|
||||
// Use sudo for SFTP server
|
||||
sudo?: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user