Compare commits

..

8 Commits

Author SHA1 Message Date
xiaojunnuo
f075a991f0 chore: 1 2026-04-17 19:34:01 +08:00
xiaojunnuo
edeb817c39 perf(technitium): 添加Technitium DNS Server插件支持
- 新增Technitium DNS Server插件,包含DNS提供商和授权配置
- 实现DNS记录创建、删除和域名列表获取功能
- 添加默认DNS传播等待时间配置
- 优化用户取消操作时的错误处理
- 为图标选择组件添加过滤功能
- 更新DNS提供商开发文档
2026-04-17 19:22:10 +08:00
xiaojunnuo
23b4658672 perf: apisix支持v2 2026-04-17 17:04:29 +08:00
ahe
5f95ee987f fix 站点IP监控提示权限不足 (#714) 2026-04-17 16:46:44 +08:00
xiaojunnuo
cc73f156a7 chore: 1 2026-04-17 00:56:21 +08:00
xiaojunnuo
ee72d10718 build: release 2026-04-12 00:29:18 +08:00
xiaojunnuo
831871d37f build: publish 2026-04-11 23:48:07 +08:00
xiaojunnuo
6072550ec1 build: trigger build image 2026-04-11 23:47:55 +08:00
32 changed files with 7933 additions and 38 deletions

View File

@@ -145,6 +145,20 @@ async doRequest(req: { action: string, data?: any }) {
utils: typeof utils;
accessService: IAccessService;
}
// this.ctx.http 只有request方法
// 方法参数
export type HttpRequestConfig<D = any> = {
skipSslVerify?: boolean;
skipCheckRes?: boolean;
logParams?: boolean;
logRes?: boolean;
logData?: boolean;
httpProxy?: string;
returnOriginRes?: boolean;
} & AxiosRequestConfig<D>;
*/
const res = await this.ctx.http.request({
url: "https://api.demo.cn/api/",

View File

@@ -105,6 +105,28 @@ async removeRecord(options: RemoveRecordOptions<DemoRecord>): Promise<void> {
}
```
### 6. 实现 getDomainListPage 方法
```typescript
/**
* 实现获取域名列表
*/
async getDomainListPage(req: PageSearch): Promise<PageRes<DomainRecord>> {
const pager = new Pager(req);
const res = await this.http.request({
// 请求接口获取域名列表
})
const list = res.Domains?.map(item => ({
id: item.Id,
domain: item.DomainName,
})) || []
return {
list,
total: res.Total,
}
}
```
### 6. 实例化插件
```typescript
@@ -204,11 +226,28 @@ export class DemoDnsProvider extends AbstractDnsProvider<DemoRecord> {
this.logger.info('删除域名解析成功:', fullRecord, value);
}
/**
* 实现获取域名列表
*/
async getDomainListPage(req: PageSearch): Promise<PageRes<DomainRecord>> {
const pager = new Pager(req);
const res = await this.http.request({
// 请求接口获取域名列表
})
const list = res.Domains?.map(item => ({
id: item.Id,
domain: item.DomainName,
})) || []
return {
list,
total: res.Total,
}
}
}
// 实例化这个 provider将其自动注册到系统中
if (isDev()) {
// 你的实现 要去掉这个 if不然生产环境将不会显示
new DemoDnsProvider();
}
new DemoDnsProvider();
```

View File

@@ -3,6 +3,25 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.39.10](https://github.com/certd/certd/compare/v1.39.9...v1.39.10) (2026-04-11)
### Bug Fixes
* 修复创建流水线无法选择通知的bug ([a88d0a6](https://github.com/certd/certd/commit/a88d0a6ae15cb6170d0b36e21daf89f0dbd5f681))
* 修复流水线任务编辑页面复制粘贴按钮在夜间模式显示问题 ([1e549df](https://github.com/certd/certd/commit/1e549dfd431ed74e2bcdfce63e5f640c51603af3))
* 修复用户管理添加用户无法上传头像的bug ([557e98c](https://github.com/certd/certd/commit/557e98c33f5462167d8f6289f70dad68bb114a97))
* 修复自定义插件删除后没有反注册的bug ([df98463](https://github.com/certd/certd/commit/df9846332596d2afaba53e66d2897aa1c598f9c4))
* 修复spaceship创建record报错的bug ([70b46d4](https://github.com/certd/certd/commit/70b46d4a8f89cf8eded21ebb237e8c8ce6c40d30))
### Performance Improvements
* 1panel支持先上传证书再选择证书 ([7a9eec8](https://github.com/certd/certd/commit/7a9eec88e8eddf40dba055c072b5b2b0f67c1407))
* 部署到1panel面板支持mux模式 ([d05129e](https://github.com/certd/certd/commit/d05129ec67893b0b639003a4bca6878d128f56ad))
* 流水线修改编辑之后,增加未保存提示 ([21620ac](https://github.com/certd/certd/commit/21620ac6bdeb57e43509156a77037fc07c44282a))
* 修复检查全部某些情况下无效的bug优化公共触发站点证书检查定时逻辑 ([ee53589](https://github.com/certd/certd/commit/ee535895a3166c6f9046963e28fa8f22f018b574))
* 增加域名管理 子域名检查提醒 ([2bdf183](https://github.com/certd/certd/commit/2bdf1832da73a3728f3ac415837bc26e70531cd6))
* 站点监控域名气泡增加端口显示 ([6ee718a](https://github.com/certd/certd/commit/6ee718a25265a9db2115343af9a1a01958f34b81))
## [1.39.9](https://github.com/certd/certd/compare/v1.39.8...v1.39.9) (2026-04-05)
### Bug Fixes

View File

@@ -70,5 +70,5 @@
"bugs": {
"url": "https://github.com/publishlab/node-acme-client/issues"
},
"gitHead": "1c634a702af9298d25542acc270d68f71d9b1049"
"gitHead": "112a565bf74c50e16c27a2c0a716588f84f39789"
}

View File

@@ -21,7 +21,8 @@ const defaultOpts = {
},
challengeRemoveFn: async () => {
throw new Error("Missing challengeRemoveFn()");
}
},
waitDnsDiffuseTime: 30,
};
/**

View File

@@ -577,7 +577,7 @@ class AcmeClient {
const verifyFn = async (abort) => {
if (this.opts.signal && this.opts.signal.aborted) {
abort();
abort(true);
throw new CancelError('用户取消');
}

View File

@@ -50,15 +50,18 @@ class Backoff {
async function retryPromise(fn, attempts, backoff, logger = log) {
let aborted = false;
let abortedFromUser = false;
try {
const setAbort = () => { aborted = true; }
const setAbort = (fromUser = false) => { aborted = true; abortedFromUser = fromUser; }
const data = await fn(setAbort);
return data;
}
catch (e) {
if (aborted){
logger(`用户取消重试`);
if (abortedFromUser){
logger(`用户取消重试`);
}
throw e;
}
if ( ((backoff.attempts + 1) >= attempts)) {

View File

@@ -68,6 +68,7 @@ export interface ClientAutoOptions {
preferredChain?: string;
signal?: AbortSignal;
profile?:string;
waitDnsDiffuseTime?: number;
}
export class Client {

View File

@@ -47,5 +47,5 @@
"tslib": "^2.8.1",
"typescript": "^5.4.2"
},
"gitHead": "1c634a702af9298d25542acc270d68f71d9b1049"
"gitHead": "112a565bf74c50e16c27a2c0a716588f84f39789"
}

View File

@@ -45,5 +45,5 @@
"tslib": "^2.8.1",
"typescript": "^5.4.2"
},
"gitHead": "1c634a702af9298d25542acc270d68f71d9b1049"
"gitHead": "112a565bf74c50e16c27a2c0a716588f84f39789"
}

View File

@@ -24,5 +24,5 @@
"prettier": "^2.8.8",
"tslib": "^2.8.1"
},
"gitHead": "1c634a702af9298d25542acc270d68f71d9b1049"
"gitHead": "112a565bf74c50e16c27a2c0a716588f84f39789"
}

View File

@@ -31,5 +31,5 @@
"tslib": "^2.8.1",
"typescript": "^5.4.2"
},
"gitHead": "1c634a702af9298d25542acc270d68f71d9b1049"
"gitHead": "112a565bf74c50e16c27a2c0a716588f84f39789"
}

View File

@@ -56,5 +56,5 @@
"fetch"
]
},
"gitHead": "1c634a702af9298d25542acc270d68f71d9b1049"
"gitHead": "112a565bf74c50e16c27a2c0a716588f84f39789"
}

View File

@@ -33,5 +33,5 @@
"tslib": "^2.8.1",
"typescript": "^5.4.2"
},
"gitHead": "1c634a702af9298d25542acc270d68f71d9b1049"
"gitHead": "112a565bf74c50e16c27a2c0a716588f84f39789"
}

View File

@@ -64,5 +64,5 @@
"typeorm": "^0.3.11",
"typescript": "^5.4.2"
},
"gitHead": "1c634a702af9298d25542acc270d68f71d9b1049"
"gitHead": "112a565bf74c50e16c27a2c0a716588f84f39789"
}

View File

@@ -46,5 +46,5 @@
"typeorm": "^0.3.11",
"typescript": "^5.4.2"
},
"gitHead": "1c634a702af9298d25542acc270d68f71d9b1049"
"gitHead": "112a565bf74c50e16c27a2c0a716588f84f39789"
}

View File

@@ -38,5 +38,5 @@
"tslib": "^2.8.1",
"typescript": "^5.4.2"
},
"gitHead": "1c634a702af9298d25542acc270d68f71d9b1049"
"gitHead": "112a565bf74c50e16c27a2c0a716588f84f39789"
}

View File

@@ -57,5 +57,5 @@
"tslib": "^2.8.1",
"typescript": "^5.4.2"
},
"gitHead": "1c634a702af9298d25542acc270d68f71d9b1049"
"gitHead": "112a565bf74c50e16c27a2c0a716588f84f39789"
}

View File

@@ -1,5 +1,5 @@
<template>
<a-select :value="value" @update:value="onChange">
<a-select :value="value" :filter-option="true" @update:value="onChange">
<a-select-option v-for="item of options" :key="item.value" :value="item.value" :label="item.label">
<span class="flex-o">
<fs-icon :icon="item.icon" class="fs-16 color-blue mr-5" />

View File

@@ -1,5 +1,5 @@
<template>
<icon-select class="dns-provider-selector" :value="modelValue" :options="options" @update:value="atChange"> </icon-select>
<icon-select class="dns-provider-selector" :value="modelValue" :options="options" :filter-option="true" @update:value="atChange"> </icon-select>
</template>
<script lang="ts">

View File

@@ -8,7 +8,6 @@
import { onActivated, onMounted, ref, Ref } from "vue";
import { useFs } from "@fast-crud/fast-crud";
import createCrudOptions from "./crud";
import { siteIpApi } from "./api";
defineOptions({
name: "SiteIpCertMonitor",
@@ -23,11 +22,6 @@ const { crudBinding, crudRef, crudExpose } = useFs({
},
});
const siteInfoRef: Ref<any> = ref({});
onMounted(async () => {
siteInfoRef.value = await siteIpApi.GetObj(props.siteId);
});
// 页面打开后获取列表数据
onMounted(() => {
crudExpose.doRefresh();

View File

@@ -32,6 +32,27 @@ export class ApisixAccess extends BaseAccess {
})
apiKey = '';
@AccessInput({
title: '版本',
component: {
name:"a-select",
options: [
{
label: "v3.x",
value: "3",
},
{
label: "v2.x",
value: "2",
},
]
},
helper: "apisix系统的版本",
value:"3",
required: true,
})
version = '3';
@AccessInput({
title: "测试",
@@ -49,17 +70,24 @@ export class ApisixAccess extends BaseAccess {
}
async getCertList(){
const sslPath = this.getSslPath();
const req = {
url :"/apisix/admin/ssls",
url :`/apisix/admin/${sslPath}`,
method: "get",
}
return await this.doRequest(req);
}
getSslPath(){
const sslPath = this.version === '3' ? 'ssls' : 'ssl';
return sslPath;
}
async createCert(opts:{cert:CertInfo}){
const certReader = new CertReader(opts.cert)
const sslPath = this.getSslPath();
const req = {
url :"/apisix/admin/ssls",
url :`/apisix/admin/${sslPath}`,
method: "post",
data:{
cert: opts.cert.crt,
@@ -72,8 +100,9 @@ export class ApisixAccess extends BaseAccess {
async updateCert (opts:{cert:CertInfo,id:string}){
const certReader = new CertReader(opts.cert)
const sslPath = this.getSslPath();
const req = {
url :`/apisix/admin/ssls/${opts.id}`,
url :`/apisix/admin/${sslPath}/${opts.id}`,
method: "put",
data:{
cert: opts.cert.crt,

View File

@@ -423,6 +423,7 @@ export class AcmeService {
signal: this.options.signal,
profile,
preferredChain,
waitDnsDiffuseTime: this.options.waitDnsDiffuseTime,
});
const crtString = crt.toString();

View File

@@ -1,7 +1,8 @@
import { AbstractDnsProvider, CreateRecordOptions, IsDnsProvider, RemoveRecordOptions } from '@certd/plugin-cert';
import { AbstractDnsProvider, CreateRecordOptions, DomainRecord, IsDnsProvider, RemoveRecordOptions } from '@certd/plugin-cert';
import { DemoAccess } from './access.js';
import { PageRes, PageSearch } from '@certd/pipeline';
import { isDev } from '../../utils/env.js';
import { DemoAccess } from './access.js';
type DemoRecord = {
// 这里定义Record记录的数据结构跟对应云平台接口返回值一样即可一般是拿到id就行用于删除txt解析记录清理申请痕迹
@@ -16,7 +17,7 @@ type DemoRecord = {
icon: 'clarity:plugin-line',
// 这里是对应的云平台的access类型名称
accessType: 'demo',
order:99,
order: 99,
})
export class DemoDnsProvider extends AbstractDnsProvider<DemoRecord> {
access!: DemoAccess;
@@ -74,6 +75,28 @@ export class DemoDnsProvider extends AbstractDnsProvider<DemoRecord> {
this.logger.info('删除域名解析成功:', fullRecord, value);
}
/**
*
* @param req 实现获取域名列表
* @returns
*/
async getDomainListPage(req: PageSearch): Promise<PageRes<DomainRecord>> {
const res = await this.http.request({
// 请求接口获取域名列表
})
const list = []
// const list = res.Domains?.map(item => ({
// id: item.Id,
// domain: item.DomainName,
// })) || []
return {
list,
total: res.Total,
}
}
}
//TODO 实例化这个provider将其自动注册到系统中

View File

@@ -82,7 +82,7 @@ export class SshAccess extends BaseAccess {
@AccessInput({
title: "伪终端",
helper: "如果登录报错all authentication methods failed可以尝试开启伪终端模式进行keyboard-interactive方式登录\n开启后对日志输出有一定的影响",
helper: "如果登录报错all authentication methods failed / unable to exec可以尝试开启伪终端模式进行keyboard-interactive方式登录\n开启后对日志输出有一定的影响",
component: {
name: "a-switch",
vModel: "checked",

View File

@@ -208,7 +208,7 @@ export class AsyncSsh2Client {
let hasErrorLog = false;
stream
.on("close", (code: any, signal: any) => {
this.logger.info(`[${this.connConf.host}][close]:code:${code}`);
this.logger.info(`[${this.connConf.host}][close]:code=${code}`);
/**
* ]pipeline 执行命令:[10.123.0.2][exec]:cd /d D:\nginx-1.27.5 && D:\nginx-1.27.5\nginx.exe -t && D:\nginx-1.27.5\nginx.exe -s reload
* [2025-07-09T10:24:11.219] [ERROR]pipeline - [10. 123.0. 2][error]: nginx: the configuration file D: \nginx-1.27. 5/conf/nginx. conf syntax is ok
@@ -279,7 +279,7 @@ export class AsyncSsh2Client {
}
stream
.on("close", (code: any) => {
this.logger.info("Stream :: close,code: " + code);
this.logger.info("Stream :: close,code = " + code);
resolve(output);
})
.on("data", (ret: Buffer) => {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,179 @@
import { AccessInput, BaseAccess, IsAccess, Pager, PageRes, PageSearch } from '@certd/pipeline';
import { DomainRecord } from '@certd/plugin-lib';
/**
* Technitium DNS Server 授权配置
*/
@IsAccess({
name: 'technitium',
title: 'Technitium DNS Server',
icon: 'clarity:server-line',
desc: 'Technitium DNS Server 自建DNS服务器授权',
})
export class TechnitiumAccess extends BaseAccess {
/**
* API地址
*/
@AccessInput({
title: 'API地址',
value: 'http://localhost:5380',
component: {
name: "a-input",
allowClear: true,
placeholder: 'http://localhost:5380',
},
required: true,
})
apiUrl = 'http://localhost:5380';
/**
* 用户名
*/
@AccessInput({
title: '用户名',
component: {
name: "a-input",
allowClear: true,
placeholder: 'admin',
},
required: false,
})
username = 'admin';
/**
* 密码
*/
@AccessInput({
title: '密码',
component: {
name: "a-input",
type: "password",
allowClear: true,
placeholder: '密码',
},
required: false,
encrypt: true,
})
password = '';
/**
* 测试按钮
*/
@AccessInput({
title: "测试",
component: {
name: "api-test",
action: "TestRequest"
},
helper: "点击测试接口是否正常"
})
testRequest = true;
token = '';
/**
* 通用API调用方法
*/
async doRequest(options: { url: string; method: 'get' | 'post'; params?: URLSearchParams }) {
// 每次请求前都获取最新的token
if (!options.url.includes('/api/user/login')) {
await this.getToken();
}
// 复制参数并添加token
const params = new URLSearchParams(options.params || '');
if (this.token && !options.url.includes('/api/user/login')) {
params.append('token', this.token);
}
let fullUrl = options.url;
if (params.toString()) {
fullUrl = `${options.url}?${params.toString()}`;
}
const response = await this.ctx.http.request({
url: fullUrl,
method: options.method,
});
if (response.status !== 'ok') {
throw new Error(`${response.errorMessage || 'API调用失败'}`);
}
return response;
}
/**
* 测试API连接
*/
async onTestRequest() {
// 测试获取区域列表
await this.GetDomainList({});
return "连接成功";
}
/**
* 获取域名列表
*/
async GetDomainList(req: PageSearch): Promise<PageRes<DomainRecord>> {
this.ctx.logger.info(`获取域名列表req:${JSON.stringify(req)}`);
const pager = new Pager(req);
// 构建API URL
const apiUrl = `${this.apiUrl}/api/zones/list`;
// 构建查询参数
const params = new URLSearchParams();
// 调用API获取区域列表
const response = await this.doRequest({ url: apiUrl, method: 'get', params: params });
const zones = response.response.zones || [];
const total = zones.length;
// 转换为DomainRecord格式
let list = zones.map((zone: any) => ({
id: zone.name,
domain: zone.name,
}));
// 应用分页
list = list.slice(pager.getOffset(), pager.getOffset() + pager.pageSize);
return {
total,
list
};
}
/**
* 获取API Token
*/
async getToken() {
const apiUrl = `${this.apiUrl}/api/user/login`;
const params = new URLSearchParams({
user: this.username,
pass: this.password,
});
// 直接使用ctx.http.request避免递归调用doRequest
const response = await this.ctx.http.request({
url: `${apiUrl}?${params.toString()}`,
method: 'post',
});
if (response.status !== 'ok') {
throw new Error(`登录失败: ${response.errorMessage || '未知错误'}`);
}
this.token = response.token;
return this.token;
}
}

View File

@@ -0,0 +1,97 @@
import { AbstractDnsProvider, CreateRecordOptions, IsDnsProvider, RemoveRecordOptions } from '@certd/plugin-cert';
import { TechnitiumAccess } from "./access.js"
type TechnitiumRecord = {
// 记录创建时返回的数据结构
zone: {
name: string;
type: string;
internal: boolean;
dnssecStatus: string;
disabled: boolean;
};
addedRecord: {
disabled: boolean;
name: string;
type: string;
ttl: number;
rData: {
text: string;
};
dnssecStatus: string;
lastUsedOn: string;
};
};
// 注册Technitium DNS Server的DNS提供商
@IsDnsProvider({
name: 'technitium',
title: 'Technitium DNS Server',
desc: 'Technitium DNS Server 自建DNS服务器',
icon: 'clarity:server-line',
accessType: 'technitium',
order: 10,
})
export class TechnitiumDnsProvider extends AbstractDnsProvider<TechnitiumRecord> {
access!: TechnitiumAccess;
async onInstance() {
this.access = this.ctx.access as TechnitiumAccess;
this.logger.debug('access', this.access);
}
/**
* 创建DNS解析记录用于验证域名所有权
*/
async createRecord(options: CreateRecordOptions): Promise<TechnitiumRecord> {
const { fullRecord, value, type, domain } = options;
this.logger.info('添加域名解析:', fullRecord, value, type, domain);
// 构建API URL
const apiUrl = `${this.access.apiUrl}/api/zones/records/add`;
// 构建查询参数
const params = new URLSearchParams({
zone: domain,
domain: fullRecord,
type: type,
text: value,
ttl: "60",
});
// 调用Technitium API创建TXT记录
const response = await this.access.doRequest({ url: apiUrl, method: 'post', params: params });
this.logger.info('创建域名解析成功:', fullRecord, value);
return response as TechnitiumRecord;
}
/**
* 删除DNS解析记录,清理申请痕迹
*/
async removeRecord(options: RemoveRecordOptions<TechnitiumRecord>): Promise<void> {
const { fullRecord, value, domain } = options.recordReq;
const record = options.recordRes;
this.logger.info('删除域名解析:', domain, fullRecord, value, record);
// 构建API URL
const apiUrl = `${this.access.apiUrl}/api/zones/records/delete`;
// 构建查询参数
const params = new URLSearchParams({
zone: domain,
domain: fullRecord,
type: 'TXT',
text: value,
});
// 调用Technitium API删除TXT记录
await this.access.doRequest({ url: apiUrl, method: 'post', params: params });
this.logger.info('删除域名解析成功:', fullRecord, value);
}
}
// 实例化这个provider将其自动注册到系统中
new TechnitiumDnsProvider();

View File

@@ -0,0 +1,2 @@
export * from './dns-provider.js';
export * from './access.js';

View File

@@ -1 +1 @@
01:26
23:47

View File

@@ -1 +1 @@
01:53
00:29