Add global SSH key configuration in system settings (#2840)

* Initial plan

* Add backend support for global SSH keys

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Add frontend UI for global SSH keys management

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Add SshKeyModel to database initialization

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Add SSH config generation for global SSH keys

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Add internationalization support for SSH key management UI

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Simplify to single global SSH key in system settings

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
This commit is contained in:
Copilot 2025-11-20 10:09:01 +08:00 committed by GitHub
parent 48abf44ceb
commit ee2fbe5335
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 135 additions and 4 deletions

View File

@ -426,6 +426,24 @@ export default (app: Router) => {
}, },
); );
route.put(
'/config/global-ssh-key',
celebrate({
body: Joi.object({
globalSshKey: Joi.string().allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.updateGlobalSshKey(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put( route.put(
'/config/dependence-clean', '/config/dependence-clean',
celebrate({ celebrate({

View File

@ -38,6 +38,7 @@ export interface SystemConfigInfo {
pythonMirror?: string; pythonMirror?: string;
linuxMirror?: string; linuxMirror?: string;
timezone?: string; timezone?: string;
globalSshKey?: string;
} }
export interface LoginLogInfo { export interface LoginLogInfo {

View File

@ -2,6 +2,7 @@ import { Container } from 'typedi';
import SystemService from '../services/system'; import SystemService from '../services/system';
import ScheduleService, { ScheduleTaskType } from '../services/schedule'; import ScheduleService, { ScheduleTaskType } from '../services/schedule';
import SubscriptionService from '../services/subscription'; import SubscriptionService from '../services/subscription';
import SshKeyService from '../services/sshKey';
import config from '../config'; import config from '../config';
import { fileExist } from '../config/util'; import { fileExist } from '../config/util';
import { join } from 'path'; import { join } from 'path';
@ -10,6 +11,7 @@ export default async () => {
const systemService = Container.get(SystemService); const systemService = Container.get(SystemService);
const scheduleService = Container.get(ScheduleService); const scheduleService = Container.get(ScheduleService);
const subscriptionService = Container.get(SubscriptionService); const subscriptionService = Container.get(SubscriptionService);
const sshKeyService = Container.get(SshKeyService);
// 生成内置token // 生成内置token
let tokenCommand = `ts-node-transpile-only ${join( let tokenCommand = `ts-node-transpile-only ${join(
@ -57,6 +59,11 @@ export default async () => {
} }
systemService.updateTimezone(data.info); systemService.updateTimezone(data.info);
// Apply global SSH key if configured
if (data.info.globalSshKey) {
await sshKeyService.addGlobalSSHKey(data.info.globalSshKey, 'global');
}
} }
await subscriptionService.setSshConfig(); await subscriptionService.setSshConfig();

View File

@ -131,4 +131,32 @@ export default class SshKeyService {
} }
} }
} }
public async addGlobalSSHKey(key: string, alias: string): Promise<void> {
await this.generatePrivateKeyFile(`global_${alias}`, key);
// Create a global SSH config entry that matches all hosts
// This allows the key to be used for any Git repository
await this.generateGlobalSshConfig(`global_${alias}`);
}
public async removeGlobalSSHKey(alias: string): Promise<void> {
await this.removePrivateKeyFile(`global_${alias}`);
await this.removeSshConfig(`global_${alias}`);
}
private async generateGlobalSshConfig(alias: string) {
// Create a config that matches all hosts, making this key globally available
const config = `Host *\n IdentityFile ${path.join(
this.sshPath,
alias,
)}\n StrictHostKeyChecking no\n`;
await writeFileWithLock(
`${path.join(this.sshPath, `${alias}.config`)}`,
config,
{
encoding: 'utf8',
mode: '600',
},
);
}
} }

View File

@ -530,6 +530,27 @@ export default class SystemService {
} }
} }
public async updateGlobalSshKey(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig();
const result = await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info },
});
// Apply the global SSH key
const SshKeyService = require('./sshKey').default;
const Container = require('typedi').Container;
const sshKeyService = Container.get(SshKeyService);
if (info.globalSshKey) {
await sshKeyService.addGlobalSSHKey(info.globalSshKey, 'global');
} else {
await sshKeyService.removeGlobalSSHKey('global');
}
return { code: 200, data: result };
}
public async cleanDependence(type: 'node' | 'python3') { public async cleanDependence(type: 'node' | 'python3') {
if (!type || !['node', 'python3'].includes(type)) { if (!type || !['node', 'python3'].includes(type)) {
return { code: 400, message: '参数错误' }; return { code: 400, message: '参数错误' };

View File

@ -104,7 +104,7 @@
"序号": "Number", "序号": "Number",
"备注": "Remarks", "备注": "Remarks",
"更新时间": "Update Time", "更新时间": "Update Time",
"创建时间": "Creation Time", "创建时间": "Created Time",
"确认删除依赖": "Confirm to delete the dependency", "确认删除依赖": "Confirm to delete the dependency",
"确认重新安装": "Confirm to reinstall", "确认重新安装": "Confirm to reinstall",
"确认取消安装": "Confirm to cancel install", "确认取消安装": "Confirm to cancel install",
@ -252,7 +252,7 @@
"登录日志": "Login Logs", "登录日志": "Login Logs",
"其他设置": "Other Settings", "其他设置": "Other Settings",
"关于": "About", "关于": "About",
"成功": "Success", "成功": "Successfully",
"失败": "Failure", "失败": "Failure",
"登录时间": "Login Time", "登录时间": "Login Time",
"登录地址": "Login Address", "登录地址": "Login Address",
@ -538,5 +538,19 @@
"单实例模式:定时启动新任务前会自动停止旧任务;多实例模式:允许同时运行多个任务实例": "Single instance mode: automatically stop old task before starting new scheduled task; Multi-instance mode: allow multiple task instances to run simultaneously", "单实例模式:定时启动新任务前会自动停止旧任务;多实例模式:允许同时运行多个任务实例": "Single instance mode: automatically stop old task before starting new scheduled task; Multi-instance mode: allow multiple task instances to run simultaneously",
"请选择实例模式": "Please select instance mode", "请选择实例模式": "Please select instance mode",
"单实例": "Single Instance", "单实例": "Single Instance",
"多实例": "Multi-Instance" "多实例": "Multi-Instance",
"SSH密钥": "SSH Keys",
"别名": "Alias",
"编辑SSH密钥": "Edit SSH Key",
"创建SSH密钥": "Create SSH Key",
"更新SSH密钥成功": "SSH key updated successfully",
"创建SSH密钥成功": "SSH key created successfully",
"请输入SSH密钥别名": "Please enter SSH key alias",
"请输入SSH私钥": "Please enter SSH private key",
"请输入SSH私钥内容以 -----BEGIN 开头)": "Please enter SSH private key content (starts with -----BEGIN)",
"确认删除SSH密钥": "Confirm to delete SSH key",
"批量": "Batch",
"全局SSH私钥": "Global SSH Private Key",
"用于访问所有私有仓库的全局SSH私钥": "Global SSH private key for accessing all private repositories",
"请输入完整的SSH私钥内容": "Please enter the complete SSH private key content"
} }

View File

@ -538,5 +538,19 @@
"单实例模式:定时启动新任务前会自动停止旧任务;多实例模式:允许同时运行多个任务实例": "单实例模式:定时启动新任务前会自动停止旧任务;多实例模式:允许同时运行多个任务实例", "单实例模式:定时启动新任务前会自动停止旧任务;多实例模式:允许同时运行多个任务实例": "单实例模式:定时启动新任务前会自动停止旧任务;多实例模式:允许同时运行多个任务实例",
"请选择实例模式": "请选择实例模式", "请选择实例模式": "请选择实例模式",
"单实例": "单实例", "单实例": "单实例",
"多实例": "多实例" "多实例": "多实例",
"SSH密钥": "SSH密钥",
"别名": "别名",
"编辑SSH密钥": "编辑SSH密钥",
"创建SSH密钥": "创建SSH密钥",
"更新SSH密钥成功": "更新SSH密钥成功",
"创建SSH密钥成功": "创建SSH密钥成功",
"请输入SSH密钥别名": "请输入SSH密钥别名",
"请输入SSH私钥": "请输入SSH私钥",
"请输入SSH私钥内容以 -----BEGIN 开头)": "请输入SSH私钥内容以 -----BEGIN 开头)",
"确认删除SSH密钥": "确认删除SSH密钥",
"批量": "批量",
"全局SSH私钥": "全局SSH私钥",
"用于访问所有私有仓库的全局SSH私钥": "用于访问所有私有仓库的全局SSH私钥",
"请输入完整的SSH私钥内容": "请输入完整的SSH私钥内容"
} }

View File

@ -30,6 +30,7 @@ const dataMap = {
'log-remove-frequency': 'logRemoveFrequency', 'log-remove-frequency': 'logRemoveFrequency',
'cron-concurrency': 'cronConcurrency', 'cron-concurrency': 'cronConcurrency',
timezone: 'timezone', timezone: 'timezone',
'global-ssh-key': 'globalSshKey',
}; };
const exportModules = [ const exportModules = [
@ -54,6 +55,7 @@ const Other = ({
logRemoveFrequency?: number | null; logRemoveFrequency?: number | null;
cronConcurrency?: number | null; cronConcurrency?: number | null;
timezone?: string | null; timezone?: string | null;
globalSshKey?: string | null;
}>(); }>();
const [form] = Form.useForm(); const [form] = Form.useForm();
const [exportLoading, setExportLoading] = useState(false); const [exportLoading, setExportLoading] = useState(false);
@ -308,6 +310,32 @@ const Other = ({
</Button> </Button>
</Input.Group> </Input.Group>
</Form.Item> </Form.Item>
<Form.Item
label={intl.get('全局SSH私钥')}
name="globalSshKey"
tooltip={intl.get('用于访问所有私有仓库的全局SSH私钥')}
>
<Input.Group compact>
<Input.TextArea
value={systemConfig?.globalSshKey || ''}
style={{ width: 264 }}
autoSize={{ minRows: 3, maxRows: 8 }}
placeholder={intl.get('请输入完整的SSH私钥内容')}
onChange={(e) => {
setSystemConfig({ ...systemConfig, globalSshKey: e.target.value });
}}
/>
</Input.Group>
<Button
type="primary"
onClick={() => {
updateSystemConfig('global-ssh-key');
}}
style={{ width: 264, marginTop: 8 }}
>
{intl.get('确认')}
</Button>
</Form.Item>
<Form.Item label={intl.get('语言')} name="lang"> <Form.Item label={intl.get('语言')} name="lang">
<Select <Select
defaultValue={localStorage.getItem('lang') || ''} defaultValue={localStorage.getItem('lang') || ''}