Add backend support for global SSH keys

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2025-11-18 16:41:31 +00:00
parent e41a5facda
commit 43aaac4bcc
6 changed files with 322 additions and 0 deletions

View File

@ -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;
};

142
back/api/sshKey.ts Normal file
View File

@ -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);
}
},
);
};

37
back/data/sshKey.ts Normal file
View File

@ -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, 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

@ -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) {

View File

@ -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<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

@ -131,4 +131,12 @@ export default class SshKeyService {
}
}
}
public async addGlobalSSHKey(key: string, alias: string): Promise<void> {
await this.generatePrivateKeyFile(`global_${alias}`, key);
}
public async removeGlobalSSHKey(alias: string): Promise<void> {
await this.removePrivateKeyFile(`global_${alias}`);
}
}