From 43aaac4bcc2bd68d29fbb3031436059996f30828 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 18 Nov 2025 16:41:31 +0000 Subject: [PATCH] Add backend support for global SSH keys Co-authored-by: whyour <22700758+whyour@users.noreply.github.com> --- back/api/index.ts | 2 + back/api/sshKey.ts | 142 ++++++++++++++++++++++++++++++++++ back/data/sshKey.ts | 37 +++++++++ back/loaders/initTask.ts | 3 + back/services/globalSshKey.ts | 130 +++++++++++++++++++++++++++++++ back/services/sshKey.ts | 8 ++ 6 files changed, 322 insertions(+) create mode 100644 back/api/sshKey.ts create mode 100644 back/data/sshKey.ts create mode 100644 back/services/globalSshKey.ts diff --git a/back/api/index.ts b/back/api/index.ts index 6dcab62f..4d2d96a7 100644 --- a/back/api/index.ts +++ b/back/api/index.ts @@ -11,6 +11,7 @@ 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(); @@ -26,6 +27,7 @@ export default () => { subscription(app); update(app); health(app); + sshKey(app); return app; }; diff --git a/back/api/sshKey.ts b/back/api/sshKey.ts new file mode 100644 index 00000000..4c088a85 --- /dev/null +++ b/back/api/sshKey.ts @@ -0,0 +1,142 @@ +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); + } + }, + ); +}; diff --git a/back/data/sshKey.ts b/back/data/sshKey.ts new file mode 100644 index 00000000..78824aa2 --- /dev/null +++ b/back/data/sshKey.ts @@ -0,0 +1,37 @@ +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 {} +export const SshKeyModel = sequelize.define('SshKey', { + alias: { type: DataTypes.STRING, unique: true }, + private_key: DataTypes.TEXT, + remarks: DataTypes.STRING, + status: DataTypes.NUMBER, + timestamp: DataTypes.STRING, +}); diff --git a/back/loaders/initTask.ts b/back/loaders/initTask.ts index 9a4db07c..f8c5c235 100644 --- a/back/loaders/initTask.ts +++ b/back/loaders/initTask.ts @@ -2,6 +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 config from '../config'; import { fileExist } from '../config/util'; import { join } from 'path'; @@ -10,6 +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); // 生成内置token let tokenCommand = `ts-node-transpile-only ${join( @@ -59,6 +61,7 @@ export default async () => { systemService.updateTimezone(data.info); } + await globalSshKeyService.applyGlobalSshKeys(); await subscriptionService.setSshConfig(); const subs = await subscriptionService.list(); for (const sub of subs) { diff --git a/back/services/globalSshKey.ts b/back/services/globalSshKey.ts new file mode 100644 index 00000000..fe01b55e --- /dev/null +++ b/back/services/globalSshKey.ts @@ -0,0 +1,130 @@ +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 { + const docs = await this.insert(payloads); + await this.applyGlobalSshKeys(); + return docs; + } + + public async insert(payloads: SshKey[]): Promise { + 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 { + 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 { + 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 { + 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 { + const docs = await SshKeyModel.findAll({ + where: { ...query }, + order: [['createdAt', 'DESC'], ...sort], + }); + return docs.map((x) => x.get({ plain: true })); + } + + public async getDb(query: FindOptions['where']): Promise { + 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, + ); + } + } + } +} diff --git a/back/services/sshKey.ts b/back/services/sshKey.ts index a453e34d..5320c584 100644 --- a/back/services/sshKey.ts +++ b/back/services/sshKey.ts @@ -131,4 +131,12 @@ export default class SshKeyService { } } } + + public async addGlobalSSHKey(key: string, alias: string): Promise { + await this.generatePrivateKeyFile(`global_${alias}`, key); + } + + public async removeGlobalSSHKey(alias: string): Promise { + await this.removePrivateKeyFile(`global_${alias}`); + } }