diff --git a/.github/docker/Dockerfile b/.github/docker/Dockerfile
new file mode 100644
index 0000000..1a7be30
--- /dev/null
+++ b/.github/docker/Dockerfile
@@ -0,0 +1,82 @@
+ARG ALPINE_VERSION=3.19
+FROM alpine:${ALPINE_VERSION}
+# Setup document root
+WORKDIR /app/www
+
+# Install packages and remove default server definition
+RUN apk add --no-cache \
+ bash \
+ curl \
+ nginx \
+ php82 \
+ php82-ctype \
+ php82-curl \
+ php82-dom \
+ php82-fileinfo \
+ php82-fpm \
+ php82-ftp \
+ php82-gd \
+ php82-gettext \
+ php82-intl \
+ php82-iconv \
+ php82-mbstring \
+ php82-mysqli \
+ php82-opcache \
+ php82-openssl \
+ php82-phar \
+ php82-sodium \
+ php82-session \
+ php82-simplexml \
+ php82-tokenizer \
+ php82-xml \
+ php82-xmlreader \
+ php82-xmlwriter \
+ php82-zip \
+ php82-pdo \
+ php82-pdo_mysql \
+ php82-pdo_sqlite \
+ php82-pecl-swoole \
+ php82-pecl-ssh2 \
+ supervisor
+
+RUN rm -rf /var/cache/apk/* /tmp/*
+
+# Configure nginx - http
+COPY config/nginx.conf /etc/nginx/nginx.conf
+
+# Configure PHP-FPM
+ENV PHP_INI_DIR /etc/php82
+COPY config/fpm-pool.conf ${PHP_INI_DIR}/php-fpm.d/www.conf
+COPY config/php.ini ${PHP_INI_DIR}/conf.d/custom.ini
+
+# Configure supervisord
+COPY config/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
+
+# Add application
+RUN mkdir -p /usr/src && wget --no-cache https://github.com/netcccyun/dnsmgr/archive/refs/heads/main.zip -O /usr/src/www.zip && unzip /usr/src/www.zip -d /usr/src/ && mv /usr/src/dnsmgr-main /usr/src/www && rm -f /usr/src/www.zip
+
+# Install composer
+RUN wget https://getcomposer.org/download/latest-stable/composer.phar -O /usr/local/bin/composer && chmod +x /usr/local/bin/composer
+
+RUN composer install -d /usr/src/www --no-interaction --no-dev --optimize-autoloader
+
+RUN adduser -D -s /sbin/nologin -g www www && chown -R www.www /usr/src/www /var/lib/nginx /var/log/nginx
+
+# crontab
+RUN echo "* * * * * cd /app/www && /usr/bin/php82 think certtask" | crontab -u www -
+
+COPY config/run_tasks.sh /app/run_tasks.sh
+RUN chmod +x /app/run_tasks.sh
+
+# copy entrypoint script
+COPY entrypoint.sh /entrypoint.sh
+ENTRYPOINT ["sh", "/entrypoint.sh"]
+
+# Expose the port nginx is reachable on
+EXPOSE 80
+
+# Let supervisord start nginx & php-fpm
+CMD /usr/sbin/crond && /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
+
+# Configure a healthcheck to validate that everything is up&running
+HEALTHCHECK --timeout=10s CMD curl --silent --fail http://127.0.0.1/fpm-ping || exit 1
diff --git a/.github/docker/config/fpm-pool.conf b/.github/docker/config/fpm-pool.conf
new file mode 100644
index 0000000..4d6d9bd
--- /dev/null
+++ b/.github/docker/config/fpm-pool.conf
@@ -0,0 +1,21 @@
+[global]
+error_log = /dev/stderr
+
+[www]
+listen = /run/php-fpm.sock
+listen.backlog = 8192
+listen.allowed_clients = 127.0.0.1
+listen.owner = www
+listen.group = www
+listen.mode = 0666
+user = www
+group = www
+pm.status_path = /fpm-status
+pm = ondemand
+pm.max_children = 100
+pm.process_idle_timeout = 60s;
+pm.max_requests = 1000
+clear_env = no
+catch_workers_output = yes
+decorate_workers_output = no
+ping.path = /fpm-ping
diff --git a/.github/docker/config/nginx.conf b/.github/docker/config/nginx.conf
new file mode 100644
index 0000000..ea18d41
--- /dev/null
+++ b/.github/docker/config/nginx.conf
@@ -0,0 +1,94 @@
+user www;
+worker_processes auto;
+error_log stderr warn;
+pid /run/nginx.pid;
+
+events {
+ worker_connections 1024;
+}
+
+http {
+ include mime.types;
+ # Threat files with a unknown filetype as binary
+ default_type application/octet-stream;
+
+ # Define custom log format to include reponse times
+ log_format main_timed '$remote_addr - $remote_user [$time_local] "$request" '
+ '$status $body_bytes_sent "$http_referer" '
+ '"$http_user_agent" "$http_x_forwarded_for" '
+ '$request_time $upstream_response_time $pipe $upstream_cache_status';
+
+ access_log /dev/stdout main_timed;
+ error_log /dev/stderr crit;
+
+ keepalive_timeout 65;
+
+ server_tokens off;
+
+ # Enable gzip compression by default
+ gzip on;
+ gzip_min_length 1k;
+ gzip_buffers 4 16k;
+ gzip_proxied expired no-cache no-store private auth;
+ gzip_types text/plain application/javascript application/x-javascript text/javascript text/css application/xml;
+ gzip_vary on;
+ gzip_disable "MSIE [1-6]\.";
+
+ # Include server configs
+ server {
+ listen [::]:80 default_server;
+ listen 80 default_server;
+ server_name _;
+
+ sendfile on;
+ tcp_nodelay on;
+ absolute_redirect off;
+
+ root /app/www/public;
+ index index.php;
+
+ # Pass the PHP scripts to PHP-FPM listening on php-fpm.sock
+ location ~ \.php$ {
+ try_files $uri =404;
+ fastcgi_split_path_info ^(.+\.php)(/.+)$;
+ fastcgi_pass unix:/run/php-fpm.sock;
+ fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
+ fastcgi_index index.php;
+ include fastcgi_params;
+ }
+
+ #rewrite rule for pretty urls
+ location / {
+ if (!-e $request_filename){
+ rewrite ^(.*)$ /index.php?s=$1 last; break;
+ }
+ }
+
+ # Set the cache-control headers on assets to cache for 5 days
+ location ~* \.(jpg|jpeg|gif|png|ico|bmp)$ {
+ access_log off;
+ expires 30d;
+ }
+
+ location ~* \.(css|js)$ {
+ access_log off;
+ expires 12h;
+ }
+
+ # Deny access to . files, for security
+ location ~ /\. {
+ log_not_found off;
+ deny all;
+ }
+
+ # Allow fpm ping and status from localhost
+ location ~ ^/(fpm-status|fpm-ping)$ {
+ access_log off;
+ allow 127.0.0.1;
+ deny all;
+ fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
+ include fastcgi_params;
+ fastcgi_pass unix:/run/php-fpm.sock;
+ }
+ }
+}
diff --git a/.github/docker/config/php.ini b/.github/docker/config/php.ini
new file mode 100644
index 0000000..17461ea
--- /dev/null
+++ b/.github/docker/config/php.ini
@@ -0,0 +1,15 @@
+[PHP]
+short_open_tag = On
+expose_php = Off
+max_execution_time = 300
+post_max_size = 50M
+upload_max_filesize = 50M
+[Date]
+date.timezone = PRC
+[Opcache]
+opcache.enable=1
+opcache.enable_cli=1
+opcache.memory_consumption=128
+opcache.interned_strings_buffer=32
+opcache.max_accelerated_files=10000
+opcache.revalidate_freq=30
diff --git a/.github/docker/config/run_tasks.sh b/.github/docker/config/run_tasks.sh
new file mode 100644
index 0000000..f9d158c
--- /dev/null
+++ b/.github/docker/config/run_tasks.sh
@@ -0,0 +1,7 @@
+#!/bin/bash
+
+if [ -f "/app/www/.env" ]; then
+ php /app/www/think dmtask
+else
+ exit 0
+fi
diff --git a/.github/docker/config/supervisord.conf b/.github/docker/config/supervisord.conf
new file mode 100644
index 0000000..8dc44a2
--- /dev/null
+++ b/.github/docker/config/supervisord.conf
@@ -0,0 +1,37 @@
+[supervisord]
+nodaemon=true
+logfile=/dev/null
+logfile_maxbytes=0
+pidfile=/run/supervisord.pid
+
+[program:php-fpm]
+command=php-fpm82 -F
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+stderr_logfile=/dev/stderr
+stderr_logfile_maxbytes=0
+autostart=true
+autorestart=false
+startretries=0
+
+[program:nginx]
+command=nginx -g 'daemon off;'
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+stderr_logfile=/dev/stderr
+stderr_logfile_maxbytes=0
+autostart=true
+autorestart=false
+startretries=0
+
+[program:dmtask]
+command=php think dmtask
+user=www
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+stderr_logfile=/dev/stderr
+stderr_logfile_maxbytes=0
+autostart=true
+autorestart=true
+startsecs=5
+startretries=99999
diff --git a/.github/docker/entrypoint.sh b/.github/docker/entrypoint.sh
new file mode 100644
index 0000000..d76584a
--- /dev/null
+++ b/.github/docker/entrypoint.sh
@@ -0,0 +1,18 @@
+#!/bin/bash
+
+set -e
+
+if [ ! -f /app/www/public/index.php ] || [ ! -f /app/firstrun ]; then
+ echo 'Copying new files'
+ \cp -a /usr/src/www /app/
+
+ if [ -d /app/www/runtime/cache ]; then
+ rm -rf /app/www/runtime/*
+ fi
+
+ chown -R www.www /app/www
+
+ touch /app/firstrun
+fi
+
+exec "$@"
\ No newline at end of file
diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml
new file mode 100644
index 0000000..fe7eb96
--- /dev/null
+++ b/.github/workflows/docker-build.yml
@@ -0,0 +1,53 @@
+# 手动触发:构建多架构镜像(amd64 / arm64),仅推送 latest 至 Docker Hub 与华为云 SWR。
+# Dockerfile 与构建上下文位于 .github/docker/ 目录。
+#
+# 需在仓库 Settings → Secrets 中配置:
+# DOCKERHUB_USERNAME / DOCKERHUB_TOKEN(Docker Hub 访问令牌)
+# HUAWEI_SWR_USERNAME / HUAWEI_SWR_PASSWORD(华为云 SWR 登录凭证,与本地 docker login swr.cn-east-3.myhuaweicloud.com 一致)
+
+name: Docker Build
+
+on:
+ workflow_dispatch:
+
+jobs:
+ build-and-push:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Log in to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Log in to Huawei SWR
+ uses: docker/login-action@v3
+ with:
+ registry: swr.cn-east-3.myhuaweicloud.com
+ username: ${{ secrets.HUAWEI_SWR_USERNAME }}
+ password: ${{ secrets.HUAWEI_SWR_PASSWORD }}
+
+ - name: Build and push (Docker Hub + Huawei SWR, latest only)
+ uses: docker/build-push-action@v6
+ with:
+ context: .github/docker
+ file: .github/docker/Dockerfile
+ platforms: linux/amd64,linux/arm64
+ push: true
+ tags: |
+ netcccyun/dnsmgr:latest
+ swr.cn-east-3.myhuaweicloud.com/netcccyun/dnsmgr:latest
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
diff --git a/app/lib/CertHelper.php b/app/lib/CertHelper.php
index 301786a..a325c65 100644
--- a/app/lib/CertHelper.php
+++ b/app/lib/CertHelper.php
@@ -257,7 +257,7 @@ location / {
'wildcard' => false,
'max_domains' => 1,
'cname' => false,
- 'note' => '每个自然年有20张免费证书额度,证书到期或吊销不释放额度。需要先进入阿里云控制台-数字证书管理服务,购买个人测试证书资源包。',
+ 'note' => '每个自然年有20张免费证书额度,证书到期或吊销不释放额度。需要先进入阿里云控制台-数字证书管理服务,购买测试证书,并在联系人管理添加联系人。',
'inputs' => [
'AccessKeyId' => [
'name' => 'AccessKeyId',
@@ -271,24 +271,6 @@ location / {
'placeholder' => '',
'required' => true,
],
- 'username' => [
- 'name' => '姓名',
- 'type' => 'input',
- 'placeholder' => '申请联系人的姓名',
- 'required' => true,
- ],
- 'phone' => [
- 'name' => '手机号码',
- 'type' => 'input',
- 'placeholder' => '申请联系人的手机号码',
- 'required' => true,
- ],
- 'email' => [
- 'name' => '邮箱地址',
- 'type' => 'input',
- 'placeholder' => '申请联系人的邮箱地址',
- 'required' => true,
- ],
'proxy' => [
'name' => '使用代理服务器',
'type' => 'radio',
diff --git a/app/lib/cert/aliyun.php b/app/lib/cert/aliyun.php
index 2611d4a..1a649a3 100644
--- a/app/lib/cert/aliyun.php
+++ b/app/lib/cert/aliyun.php
@@ -27,18 +27,18 @@ class aliyun implements CertInterface
public function register()
{
- if (empty($this->AccessKeyId) || empty($this->AccessKeySecret) || empty($this->config['username']) || empty($this->config['phone']) || empty($this->config['email'])) throw new Exception('必填参数不能为空');
- $param = ['Action' => 'ListUserCertificateOrder'];
+ if (empty($this->AccessKeyId) || empty($this->AccessKeySecret)) throw new Exception('必填参数不能为空');
+ $param = ['Action' => 'ListInstances'];
$this->request($param, true);
return true;
}
public function buyCert($domainList, &$order)
{
- $param = ['Action' => 'DescribePackageState', 'ProductCode' => 'digicert-free-1-free'];
+ $param = ['Action' => 'GetInstanceSummary', 'InstanceType' => 'TEST'];
$data = $this->request($param, true);
- if (!isset($data['TotalCount']) || $data['TotalCount'] == 0) throw new Exception('没有可用的免费证书资源包');
- $this->log('证书资源包总数量:' . $data['TotalCount'] . ',已使用数量:' . $data['UsedCount']);
+ if (!isset($data['InactiveCount']) || $data['InactiveCount'] == 0) throw new Exception('没有待使用的测试证书实例,请先购买测试证书');
+ $this->log('实例总个数:' . $data['TotalCount'] . ',实例待使用总数:' . $data['InactiveCount']);
}
public function createOrder($domainList, &$order, $keytype, $keysize)
@@ -46,31 +46,92 @@ class aliyun implements CertInterface
if (empty($domainList)) throw new Exception('域名列表不能为空');
$domain = $domainList[0];
$param = [
- 'Action' => 'CreateCertificateRequest',
- 'ProductCode' => 'digicert-free-1-free',
- 'Username' => $this->config['username'],
- 'Phone' => $this->config['phone'],
- 'Email' => $this->config['email'],
- 'Domain' => $domain,
- 'ValidateType' => 'DNS'
+ 'Action' => 'ListInstances',
+ 'Status' => 'inactive',
+ 'InstanceType' => 'TEST',
];
$data = $this->request($param, true);
- if (empty($data['OrderId'])) throw new Exception('证书申请失败,OrderId为空');
- $order['OrderId'] = $data['OrderId'];
-
- sleep(3);
+ if (empty($data['InstanceList'])) throw new Exception('待使用的测试证书实例列表为空');
+ $instanceId = $data['InstanceList'][0]['InstanceId'];
$param = [
- 'Action' => 'DescribeCertificateState',
- 'OrderId' => $order['OrderId'],
+ 'Action' => 'ListContact',
];
$data = $this->request($param, true);
+ if (empty($data['ContactList'])) throw new Exception('联系人列表为空,请先添加联系人');
+ $contactId = $data['ContactList'][0]['ContactId'];
+
+ if ($keytype == 'ECC') $KeyAlgorithm = 'ECC_256';
+ else if ($keysize == '3072') $KeyAlgorithm = 'RSA_3072';
+ else $KeyAlgorithm = 'RSA_2048';
+ $param = [
+ 'Action' => 'UpdateInstance',
+ 'InstanceId' => $instanceId,
+ 'Domain' => $domain,
+ 'KeyAlgorithm' => $KeyAlgorithm,
+ 'AutoReissue' => 'disable',
+ 'ContactIdList.1' => $contactId,
+ 'ValidateType' => 'DNS'
+ ];
+ try {
+ $this->request($param);
+ } catch (Exception $e) {
+ throw new Exception('更新证书实例失败:' . $e->getMessage());
+ }
+
+ $param = [
+ 'Action' => 'ApplyCertificate',
+ 'InstanceId' => $instanceId
+ ];
+ try {
+ $this->request($param);
+ } catch (Exception $e) {
+ throw new Exception('申请证书失败:' . $e->getMessage());
+ }
+
+ sleep(1);
+
+ $status = '';
+ do {
+ $param = [
+ 'Action' => 'GetTaskAttribute',
+ 'TaskId' => $instanceId
+ ];
+ try {
+ $data = $this->request($param, true);
+ $status = $data['TaskStatus'];
+ } catch (Exception $e) {
+ throw new Exception('申请证书提交结果查询失败:' . $e->getMessage());
+ }
+ if ($status == 'processing') {
+ sleep(1);
+ } elseif ($status == 'failed') {
+ throw new Exception('申请证书失败:' . $data['TaskMessage']);
+ } else {
+ break;
+ }
+ } while ($status == 'processing');
+
+
+ $param = [
+ 'Action' => 'GetInstanceDetail',
+ 'InstanceId' => $instanceId
+ ];
+ try {
+ $data = $this->request($param, true);
+ } catch (Exception $e) {
+ throw new Exception('获取实例详情失败:' . $e->getMessage());
+ }
+
+ $order['InstanceId'] = $instanceId;
$dnsList = [];
- if ($data['Type'] == 'domain_verify') {
- $mainDomain = getMainDomain($domain);
- $name = substr($data['RecordDomain'], 0, -(strlen($mainDomain) + 1));
- $dnsList[$mainDomain][] = ['name' => $name, 'type' => $data['RecordType'], 'value' => $data['RecordValue']];
+ if (!empty($data['DomainValidationList'])) {
+ foreach ($data['DomainValidationList'] as $opts) {
+ $mainDomain = getMainDomain($opts['Domain']);
+ $name = substr($opts['ValidationKey'] . '.' . $opts['RootDomain'], 0, - (strlen($mainDomain) + 1));
+ $dnsList[$mainDomain][] = ['name' => $name, 'type' => $opts['ValidationType'], 'value' => $opts['ValidationValue']];
+ }
}
return $dnsList;
@@ -81,13 +142,13 @@ class aliyun implements CertInterface
public function getAuthStatus($domainList, $order)
{
$param = [
- 'Action' => 'DescribeCertificateState',
- 'OrderId' => $order['OrderId'],
+ 'Action' => 'GetInstanceDetail',
+ 'InstanceId' => $order['InstanceId'],
];
$data = $this->request($param, true);
- if ($data['Type'] == 'certificate') {
+ if ($data['Status'] == 'normal') {
return true;
- } elseif ($data['Type'] == 'verify_fail') {
+ } elseif ($data['Status'] == 'closed') {
throw new Exception('证书审核失败');
} else {
return false;
@@ -97,12 +158,19 @@ class aliyun implements CertInterface
public function finalizeOrder($domainList, $order, $keytype, $keysize)
{
$param = [
- 'Action' => 'DescribeCertificateState',
- 'OrderId' => $order['OrderId'],
+ 'Action' => 'GetInstanceDetail',
+ 'InstanceId' => $order['InstanceId'],
];
$data = $this->request($param, true);
- $fullchain = $data['Certificate'];
- $private_key = $data['PrivateKey'];
+ if (empty($data['CertificateId'])) throw new Exception('证书ID不存在');
+
+ $param = [
+ 'Action' => 'GetUserCertificateDetail',
+ 'CertId' => $data['CertificateId'],
+ ];
+ $data = $this->request($param, true);
+ $fullchain = $data['Cert'];
+ $private_key = $data['Key'];
if (empty($fullchain) || empty($private_key)) throw new Exception('证书内容获取失败');
$certInfo = openssl_x509_parse($fullchain, true);
@@ -113,8 +181,8 @@ class aliyun implements CertInterface
public function revoke($order, $pem)
{
$param = [
- 'Action' => 'CancelCertificateForPackageRequest',
- 'OrderId' => $order['OrderId'],
+ 'Action' => 'RevokeCertificate',
+ 'InstanceId' => $order['InstanceId'],
];
$this->request($param);
}
@@ -122,22 +190,14 @@ class aliyun implements CertInterface
public function cancel($order)
{
$param = [
- 'Action' => 'DescribeCertificateState',
- 'OrderId' => $order['OrderId'],
+ 'Action' => 'GetInstanceDetail',
+ 'InstanceId' => $order['InstanceId'],
];
$data = $this->request($param, true);
- if ($data['Type'] == 'domain_verify' || $data['Type'] == 'process') {
+ if ($data['Status'] == 'pending') {
$param = [
- 'Action' => 'CancelOrderRequest',
- 'OrderId' => $order['OrderId'],
- ];
- $this->request($param);
- usleep(500000);
- }
- if ($data['Type'] == 'domain_verify' || $data['Type'] == 'process' || $data['Type'] == 'payed' || $data['Type'] == 'verify_fail') {
- $param = [
- 'Action' => 'DeleteCertificateRequest',
- 'OrderId' => $order['OrderId'],
+ 'Action' => 'CancelPendingCertificate',
+ 'InstanceId' => $order['InstanceId'],
];
$this->request($param);
}