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