Simplify to single global SSH key in system settings

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2025-11-19 15:59:10 +00:00
parent 06123c1fca
commit c3322fb7ad
15 changed files with 83 additions and 721 deletions

View File

@ -11,7 +11,6 @@ import system from './system';
import subscription from './subscription';
import update from './update';
import health from './health';
import sshKey from './sshKey';
export default () => {
const app = Router();
@ -27,7 +26,6 @@ export default () => {
subscription(app);
update(app);
health(app);
sshKey(app);
return app;
};

View File

@ -1,142 +0,0 @@
import { Joi, celebrate } from 'celebrate';
import { NextFunction, Request, Response, Router } from 'express';
import { Container } from 'typedi';
import { Logger } from 'winston';
import GlobalSshKeyService from '../services/globalSshKey';
const route = Router();
export default (app: Router) => {
app.use('/sshKeys', route);
route.get('/', async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const globalSshKeyService = Container.get(GlobalSshKeyService);
const data = await globalSshKeyService.list(
req.query.searchValue as string,
);
return res.send({ code: 200, data });
} catch (e) {
logger.error('🔥 error: %o', e);
return next(e);
}
});
route.post(
'/',
celebrate({
body: Joi.array().items(
Joi.object({
alias: Joi.string().required(),
private_key: Joi.string().required(),
remarks: Joi.string().optional().allow(''),
}),
),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const globalSshKeyService = Container.get(GlobalSshKeyService);
if (!req.body?.length) {
return res.send({ code: 400, message: '参数不正确' });
}
const data = await globalSshKeyService.create(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/',
celebrate({
body: Joi.object({
alias: Joi.string().required(),
private_key: Joi.string().required(),
remarks: Joi.string().optional().allow('').allow(null),
id: Joi.number().required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const globalSshKeyService = Container.get(GlobalSshKeyService);
const data = await globalSshKeyService.update(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.delete(
'/',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const globalSshKeyService = Container.get(GlobalSshKeyService);
const data = await globalSshKeyService.remove(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/disable',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const globalSshKeyService = Container.get(GlobalSshKeyService);
const data = await globalSshKeyService.disabled(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/enable',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const globalSshKeyService = Container.get(GlobalSshKeyService);
const data = await globalSshKeyService.enabled(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.get(
'/:id',
celebrate({
params: Joi.object({
id: Joi.number().required(),
}),
}),
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const globalSshKeyService = Container.get(GlobalSshKeyService);
const data = await globalSshKeyService.getDb({ id: req.params.id });
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
};

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(
'/config/dependence-clean',
celebrate({

View File

@ -1,37 +0,0 @@
import { DataTypes, Model } from 'sequelize';
import { sequelize } from '.';
export class SshKey {
id?: number;
alias: string;
private_key: string;
remarks?: string;
status?: SshKeyStatus;
timestamp?: string;
constructor(options: SshKey) {
this.id = options.id;
this.alias = options.alias;
this.private_key = options.private_key;
this.remarks = options.remarks || '';
this.status =
typeof options.status === 'number' && SshKeyStatus[options.status]
? options.status
: SshKeyStatus.normal;
this.timestamp = new Date().toString();
}
}
export enum SshKeyStatus {
'normal',
'disabled',
}
export interface SshKeyInstance extends Model<SshKey, SshKey>, SshKey {}
export const SshKeyModel = sequelize.define<SshKeyInstance>('SshKey', {
alias: { type: DataTypes.STRING, unique: true },
private_key: DataTypes.TEXT,
remarks: DataTypes.STRING,
status: DataTypes.NUMBER,
timestamp: DataTypes.STRING,
});

View File

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

View File

@ -6,7 +6,6 @@ import { AppModel } from '../data/open';
import { SystemModel } from '../data/system';
import { SubscriptionModel } from '../data/subscription';
import { CrontabViewModel } from '../data/cronView';
import { SshKeyModel } from '../data/sshKey';
import { sequelize } from '../data';
export default async () => {
@ -18,7 +17,6 @@ export default async () => {
await EnvModel.sync();
await SubscriptionModel.sync();
await CrontabViewModel.sync();
await SshKeyModel.sync();
// 初始化新增字段
const migrations = [

View File

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

View File

@ -1,130 +0,0 @@
import { Service, Inject } from 'typedi';
import winston from 'winston';
import { FindOptions, Op } from 'sequelize';
import { SshKey, SshKeyModel, SshKeyStatus } from '../data/sshKey';
import SshKeyService from './sshKey';
@Service()
export default class GlobalSshKeyService {
constructor(
@Inject('logger') private logger: winston.Logger,
private sshKeyService: SshKeyService,
) {}
public async create(payloads: SshKey[]): Promise<SshKey[]> {
const docs = await this.insert(payloads);
await this.applyGlobalSshKeys();
return docs;
}
public async insert(payloads: SshKey[]): Promise<SshKey[]> {
const result: SshKey[] = [];
for (const key of payloads) {
const doc = await SshKeyModel.create(new SshKey(key), { returning: true });
result.push(doc.get({ plain: true }));
}
return result;
}
public async update(payload: SshKey): Promise<SshKey> {
const doc = await this.getDb({ id: payload.id });
const key = new SshKey({ ...doc, ...payload });
const newDoc = await this.updateDb(key);
await this.applyGlobalSshKeys();
return newDoc;
}
private async updateDb(payload: SshKey): Promise<SshKey> {
await SshKeyModel.update({ ...payload }, { where: { id: payload.id } });
return await this.getDb({ id: payload.id });
}
public async remove(ids: number[]) {
const docs = await SshKeyModel.findAll({ where: { id: ids } });
for (const doc of docs) {
const key = doc.get({ plain: true });
await this.sshKeyService.removeGlobalSSHKey(key.alias);
}
await SshKeyModel.destroy({ where: { id: ids } });
}
public async list(searchText: string = ''): Promise<SshKey[]> {
let condition = {};
if (searchText) {
const encodeText = encodeURI(searchText);
const reg = {
[Op.or]: [
{ [Op.like]: `%${searchText}%` },
{ [Op.like]: `%${encodeText}%` },
],
};
condition = {
[Op.or]: [
{
alias: reg,
},
{
remarks: reg,
},
],
};
}
try {
const result = await this.find(condition);
return result;
} catch (error) {
throw error;
}
}
private async find(query: any, sort: any = []): Promise<SshKey[]> {
const docs = await SshKeyModel.findAll({
where: { ...query },
order: [['createdAt', 'DESC'], ...sort],
});
return docs.map((x) => x.get({ plain: true }));
}
public async getDb(query: FindOptions<SshKey>['where']): Promise<SshKey> {
const doc: any = await SshKeyModel.findOne({ where: { ...query } });
if (!doc) {
throw new Error(`SshKey ${JSON.stringify(query)} not found`);
}
return doc.get({ plain: true });
}
public async disabled(ids: number[]) {
const docs = await SshKeyModel.findAll({ where: { id: ids } });
for (const doc of docs) {
const key = doc.get({ plain: true });
await this.sshKeyService.removeGlobalSSHKey(key.alias);
}
await SshKeyModel.update(
{ status: SshKeyStatus.disabled },
{ where: { id: ids } },
);
}
public async enabled(ids: number[]) {
await SshKeyModel.update(
{ status: SshKeyStatus.normal },
{ where: { id: ids } },
);
await this.applyGlobalSshKeys();
}
public async applyGlobalSshKeys() {
const keys = await this.list();
for (const key of keys) {
if (key.status === SshKeyStatus.normal) {
// For global SSH keys, we generate the key file
// Git will automatically use keys from ~/.ssh with standard names
await this.sshKeyService.addGlobalSSHKey(
key.private_key,
key.alias,
);
}
}
}
}

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') {
if (!type || !['node', 'python3'].includes(type)) {
return { code: 400, message: '参数错误' };

View File

@ -42,12 +42,6 @@ export default {
icon: <IconFont type="ql-icon-env" />,
component: '@/pages/env/index',
},
{
path: '/sshKey',
name: intl.get('SSH密钥'),
icon: <IconFont type="ql-icon-key" />,
component: '@/pages/sshKey/index',
},
{
path: '/config',
name: intl.get('配置文件'),

View File

@ -549,5 +549,8 @@
"请输入SSH私钥": "Please enter SSH private key",
"请输入SSH私钥内容以 -----BEGIN 开头)": "Please enter SSH private key content (starts with -----BEGIN)",
"确认删除SSH密钥": "Confirm to delete SSH key",
"批量": "Batch"
"批量": "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

@ -549,5 +549,8 @@
"请输入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',
'cron-concurrency': 'cronConcurrency',
timezone: 'timezone',
'global-ssh-key': 'globalSshKey',
};
const exportModules = [
@ -54,6 +55,7 @@ const Other = ({
logRemoveFrequency?: number | null;
cronConcurrency?: number | null;
timezone?: string | null;
globalSshKey?: string | null;
}>();
const [form] = Form.useForm();
const [exportLoading, setExportLoading] = useState(false);
@ -308,6 +310,32 @@ const Other = ({
</Button>
</Input.Group>
</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">
<Select
defaultValue={localStorage.getItem('lang') || ''}

View File

@ -1,300 +0,0 @@
import useTableScrollHeight from '@/hooks/useTableScrollHeight';
import { SharedContext } from '@/layouts';
import config from '@/utils/config';
import { request } from '@/utils/http';
import {
CheckCircleOutlined,
DeleteOutlined,
EditOutlined,
StopOutlined,
} from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-layout';
import { useOutletContext } from '@umijs/max';
import {
Button,
Input,
Modal,
Space,
Table,
Tag,
Tooltip,
Typography,
message,
} from 'antd';
import dayjs from 'dayjs';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import intl from 'react-intl-universal';
import { useVT } from 'virtualizedtableforantd4';
import Copy from '../../components/copy';
import SshKeyModal from './modal';
const { Paragraph, Text } = Typography;
const { Search } = Input;
enum Status {
'已启用',
'已禁用',
}
enum StatusColor {
'success',
'error',
}
enum OperationName {
'启用',
'禁用',
}
enum OperationPath {
'enable',
'disable',
}
const SshKey = () => {
const { headerStyle, isPhone, theme } = useOutletContext<SharedContext>();
const columns: any = [
{
title: intl.get('序号'),
width: 80,
render: (text: string, record: any, index: number) => {
return <span style={{ cursor: 'text' }}>{index + 1} </span>;
},
},
{
title: intl.get('别名'),
dataIndex: 'alias',
key: 'alias',
sorter: (a: any, b: any) => a.alias.localeCompare(b.alias),
render: (text: string, record: any) => {
return (
<div style={{ display: 'flex', alignItems: 'center' }}>
<Tooltip title={text} placement="topLeft">
<div className="text-ellipsis">{text}</div>
</Tooltip>
<Copy text={text} />
</div>
);
},
},
{
title: intl.get('备注'),
dataIndex: 'remarks',
key: 'remarks',
width: '35%',
sorter: (a: any, b: any) => (a.remarks || '').localeCompare(b.remarks || ''),
render: (text: string, record: any) => {
return (
<Tooltip title={text} placement="topLeft">
<div className="text-ellipsis">{text}</div>
</Tooltip>
);
},
},
{
title: intl.get('状态'),
key: 'status',
dataIndex: 'status',
width: 90,
filters: [
{
text: intl.get('已启用'),
value: 0,
},
{
text: intl.get('已禁用'),
value: 1,
},
],
onFilter: (value: number, record: any) => record.status === value,
render: (value: number, record: any) => {
return <Tag color={StatusColor[value]}>{Status[value]}</Tag>;
},
},
{
title: intl.get('创建时间'),
dataIndex: 'createdAt',
key: 'createdAt',
width: 185,
sorter: (a: any, b: any) => {
return (
dayjs(a.createdAt || 0).valueOf() - dayjs(b.createdAt || 0).valueOf()
);
},
render: (text: string, record: any) => {
const d = dayjs(text);
return d.isValid() ? d.format('YYYY-MM-DD HH:mm:ss') : '-';
},
},
{
title: intl.get('操作'),
key: 'action',
width: 120,
render: (text: string, record: any, index: number) => {
return (
<Space size="middle">
<Tooltip title={OperationName[record.status]}>
<a
onClick={() => {
operateSSHKey(
[record.id],
record.status === 0 ? OperationPath[1] : OperationPath[0],
);
}}
>
{OperationName[record.status]}
</a>
</Tooltip>
<Tooltip title={intl.get('编辑')}>
<a
onClick={() => {
editSSHKey(record);
}}
>
<EditOutlined />
</a>
</Tooltip>
<Tooltip title={intl.get('删除')}>
<a
onClick={() => {
deleteSSHKey(record);
}}
>
<DeleteOutlined />
</a>
</Tooltip>
</Space>
);
},
},
];
const [value, setValue] = useState<any>();
const [loading, setLoading] = useState(true);
const [isModalVisible, setIsModalVisible] = useState(false);
const [searchValue, setSearchValue] = useState('');
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const tableRef = useRef<HTMLDivElement>(null);
const [data, setData] = useState<any[]>([]);
const scrollHeight = useTableScrollHeight(tableRef, data, [
headerStyle.marginTop,
]);
const [vt] = useVT(() => ({ scroll: { y: scrollHeight } }), [scrollHeight]);
const getSSHKeys = (needLoading = true) => {
setLoading(needLoading);
request
.get(`${config.apiPrefix}sshKeys?searchValue=${searchValue}`)
.then(({ code, data }) => {
if (code === 200) {
setData(data);
}
})
.finally(() => setLoading(false));
};
const editSSHKey = (record: any) => {
setValue(record);
setIsModalVisible(true);
};
const deleteSSHKey = (record: any) => {
Modal.confirm({
title: intl.get('确认删除'),
content: (
<>
{intl.get('确认删除SSH密钥')}
<Text style={{ wordBreak: 'break-all' }} type="warning">
{record.alias}
</Text>
{intl.get('吗')}
</>
),
onOk() {
request
.delete(`${config.apiPrefix}sshKeys`, [record.id])
.then(({ code, data }) => {
if (code === 200) {
message.success(intl.get('删除成功'));
getSSHKeys();
}
});
},
onCancel() {
console.log('Cancel');
},
});
};
const operateSSHKey = (ids: any[], operationPath: string) => {
request
.put(`${config.apiPrefix}sshKeys/${operationPath}`, ids)
.then(({ code, data }) => {
if (code === 200) {
message.success(`${intl.get('批量')}${OperationName[OperationPath[operationPath]]}${intl.get('成功')}`);
getSSHKeys(false);
}
});
};
const onSearch = (value: string) => {
setSearchValue(value);
};
const handleCancel = (keys?: any[]) => {
setIsModalVisible(false);
if (keys) {
getSSHKeys();
}
};
const addSSHKey = () => {
setValue(null);
setIsModalVisible(true);
};
useEffect(() => {
getSSHKeys();
}, [searchValue]);
const rowSelection = {
selectedRowKeys,
onChange: (selectedRowKeys: React.Key[], selectedRows: any[]) => {
setSelectedRowKeys(selectedRowKeys);
},
};
return (
<PageContainer
className="ql-container-wrapper"
title={intl.get('SSH密钥')}
extra={[
<Button key="1" type="primary" onClick={addSSHKey}>
{intl.get('新建')}
</Button>,
]}
header={{
style: headerStyle,
}}
>
<div ref={tableRef}>
<Table
columns={columns}
rowSelection={rowSelection}
dataSource={data}
rowKey="id"
loading={loading}
pagination={false}
scroll={{ x: 768 }}
sticky
components={vt}
size="middle"
/>
</div>
{isModalVisible && (
<SshKeyModal sshKey={value} handleCancel={handleCancel} />
)}
</PageContainer>
);
};
export default SshKey;

View File

@ -1,97 +0,0 @@
import intl from 'react-intl-universal';
import React, { useEffect, useState } from 'react';
import { Modal, message, Input, Form } from 'antd';
import { request } from '@/utils/http';
import config from '@/utils/config';
const SshKeyModal = ({
sshKey,
handleCancel,
}: {
sshKey?: any;
handleCancel: (keys?: any[]) => void;
}) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const handleOk = async (values: any) => {
setLoading(true);
const method = sshKey ? 'put' : 'post';
const payload = sshKey ? { ...values, id: sshKey.id } : [values];
try {
const { code, data } = await request[method](
`${config.apiPrefix}sshKeys`,
payload,
);
if (code === 200) {
message.success(
sshKey ? intl.get('更新SSH密钥成功') : intl.get('创建SSH密钥成功'),
);
handleCancel(data);
}
setLoading(false);
} catch (error: any) {
setLoading(false);
}
};
return (
<Modal
title={sshKey ? intl.get('编辑SSH密钥') : intl.get('创建SSH密钥')}
open={true}
forceRender
centered
maskClosable={false}
onOk={() => {
form
.validateFields()
.then((values) => {
handleOk(values);
})
.catch((info) => {
console.log('Validate Failed:', info);
});
}}
onCancel={() => handleCancel()}
confirmLoading={loading}
>
<Form form={form} layout="vertical" name="ssh_key_modal" initialValues={sshKey}>
<Form.Item
name="alias"
label={intl.get('别名')}
rules={[
{
required: true,
message: intl.get('请输入SSH密钥别名'),
whitespace: true,
},
]}
>
<Input placeholder={intl.get('请输入SSH密钥别名')} disabled={!!sshKey} />
</Form.Item>
<Form.Item
name="private_key"
label={intl.get('私钥')}
rules={[
{
required: true,
message: intl.get('请输入SSH私钥'),
whitespace: true,
},
]}
>
<Input.TextArea
autoSize={{ minRows: 4, maxRows: 12 }}
placeholder={intl.get('请输入SSH私钥内容以 -----BEGIN 开头)')}
/>
</Form.Item>
<Form.Item name="remarks" label={intl.get('备注')}>
<Input placeholder={intl.get('请输入备注')} />
</Form.Item>
</Form>
</Modal>
);
};
export default SshKeyModal;