8 Commits

Author SHA1 Message Date
net909
4bf87156e3 version 2025-10-10 20:47:20 +08:00
net909
475c14804a 更新composer 2025-10-10 20:11:35 +08:00
net909
531ad68847 Merge branch 'main' of ssh://ssh.github.com:443/netcccyun/dnsmgr 2025-10-10 20:10:54 +08:00
net909
f86c68fc6a 新增K8S部署 2025-10-10 20:10:12 +08:00
dependabot[bot]
460067a5e7 Bump phpmailer/phpmailer from 6.10.0 to 6.11.1 (#324)
Bumps [phpmailer/phpmailer](https://github.com/PHPMailer/PHPMailer) from 6.10.0 to 6.11.1.
- [Release notes](https://github.com/PHPMailer/PHPMailer/releases)
- [Changelog](https://github.com/PHPMailer/PHPMailer/blob/master/changelog.md)
- [Commits](https://github.com/PHPMailer/PHPMailer/compare/v6.10.0...v6.11.1)

---
updated-dependencies:
- dependency-name: phpmailer/phpmailer
  dependency-version: 6.11.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-02 10:39:20 +08:00
net909
6ffa9e003a 新增天翼云部署 2025-09-29 10:45:38 +08:00
net909
c5ed1c6990 增加fnOS部署,堡塔云WAF支持部署本身证书 2025-09-17 20:46:11 +08:00
net909
2b51a2d015 新增南墙WAF、小皮面板部署 2025-09-03 19:48:18 +08:00
16 changed files with 1292 additions and 39 deletions

View File

@@ -285,11 +285,22 @@ class DeployHelper
],
],
'taskinputs' => [
'type' => [
'name' => '部署类型',
'type' => 'radio',
'options' => [
'0' => '网站的证书',
'1' => '面板本身的证书',
],
'value' => '0',
'required' => true,
],
'sites' => [
'name' => '网站名称列表',
'type' => 'textarea',
'placeholder' => '填写要部署证书的网站名称,每行一个',
'required' => true,
'show' => 'type==0',
],
],
],
@@ -489,6 +500,59 @@ class DeployHelper
],
'taskinputs' => [],
],
'uusec' => [
'name' => '南墙WAF',
'class' => 1,
'icon' => 'waf.png',
'desc' => '',
'note' => null,
'inputs' => [
'url' => [
'name' => '控制台地址',
'type' => 'input',
'placeholder' => '南墙WAF控制台地址',
'note' => '填写规则如http://192.168.1.100:4443 ,不要带其他后缀',
'required' => true,
],
'username' => [
'name' => '用户名',
'type' => 'input',
'placeholder' => '',
'required' => true,
],
'password' => [
'name' => '密码',
'type' => 'input',
'placeholder' => '',
'required' => true,
],
'proxy' => [
'name' => '使用代理服务器',
'type' => 'radio',
'options' => [
'0' => '否',
'1' => '是',
],
'value' => '0'
],
],
'taskinputs' => [
'id' => [
'name' => '证书ID',
'type' => 'input',
'placeholder' => '',
'note' => '在证书管理查看证书的ID注意域名是否与证书匹配',
'required' => true,
],
'name' => [
'name' => '证书名称',
'type' => 'input',
'placeholder' => '',
'note' => '在证书管理查看证书的名称',
'required' => true,
],
],
],
'opanel' => [
'name' => '1Panel',
'class' => 1,
@@ -649,6 +713,47 @@ class DeployHelper
],
],
],
'xp' => [
'name' => '小皮面板',
'class' => 1,
'icon' => 'xp.png',
'desc' => '',
'note' => null,
'tasknote' => '',
'inputs' => [
'url' => [
'name' => '面板地址',
'type' => 'input',
'placeholder' => '小皮面板地址',
'note' => '填写规则如http://192.168.1.100:8888 ,不要带其他后缀',
'required' => true,
],
'apikey' => [
'name' => '接口密钥',
'type' => 'input',
'placeholder' => '设置->OpenAPI接口',
'required' => true,
],
'proxy' => [
'name' => '使用代理服务器',
'type' => 'radio',
'options' => [
'0' => '否',
'1' => '是',
],
'value' => '0'
],
],
'taskinputs' => [
'sites' => [
'name' => '网站名称列表',
'type' => 'textarea',
'placeholder' => '填写要部署证书的网站名称,每行一个',
'note' => '网站名称,即为网站创建时绑定的第一个域名',
'required' => true,
],
],
],
'synology' => [
'name' => '群晖面板',
'class' => 1,
@@ -743,6 +848,47 @@ class DeployHelper
],
'taskinputs' => [],
],
'fnos' => [
'name' => '飞牛OS',
'class' => 1,
'icon' => 'fnos.png',
'desc' => '更新飞牛OS的证书',
'note' => '请先配置sudo免密<br/>
sudo visudo<br/>
#在文件最后一行增加以下内容需要将username替换成自己的用户名<br/>
username ALL=(ALL) NOPASSWD: NOPASSWD: ALL<br/>
ctrl+x 保存退出',
'tasknote' => '系统会根据关联SSL证书的域名自动更新对应证书',
'inputs' => [
'host' => [
'name' => '主机地址',
'type' => 'input',
'placeholder' => '填写IP地址或域名需开启SSH功能',
'required' => true,
],
'port' => [
'name' => 'SSH端口',
'type' => 'input',
'placeholder' => '',
'value' => '22',
'required' => true,
],
'username' => [
'name' => '用户名',
'type' => 'input',
'placeholder' => '登录用户名',
'value' => '',
'required' => true,
],
'password' => [
'name' => '密码',
'type' => 'input',
'placeholder' => '登录密码',
'required' => true,
],
],
'taskinputs' => [],
],
'proxmox' => [
'name' => 'Proxmox VE',
'class' => 1,
@@ -789,6 +935,56 @@ class DeployHelper
],
],
],
'k8s' => [
'name' => 'K8S',
'class' => 1,
'icon' => 'server.png',
'desc' => '部署到K8S集群的Secret和Ingress',
'note' => '支持部署到K8S集群的Secret和Ingress',
'tasknote' => '',
'inputs' => [
'name' => [
'name' => '名称',
'type' => 'input',
'placeholder' => '仅用于区分',
'required' => true,
],
'kubeconfig' => [
'name' => 'kubeconfig',
'type' => 'textarea',
'placeholder' => '',
'required' => true,
],
'proxy' => [
'name' => '使用代理服务器',
'type' => 'radio',
'options' => [
'0' => '否',
'1' => '是',
],
'value' => '0'
],
],
'taskinputs' => [
'namespace' => [
'name' => '命名空间',
'type' => 'input',
'value' => 'default',
'required' => true,
],
'secret_name' => [
'name' => 'Secret名称',
'type' => 'input',
'placeholder' => '如果Secret不存在则自动创建',
'required' => true,
],
'ingresses' => [
'name' => 'Ingress名称',
'type' => 'input',
'placeholder' => '多个用英文逗号分隔可留空留空则只更新Secret',
],
],
],
'aliyun' => [
'name' => '阿里云',
'class' => 2,
@@ -1418,6 +1614,54 @@ class DeployHelper
],
],
],
'ksyun' => [
'name' => '金山云',
'class' => 2,
'icon' => 'ksyun.ico',
'desc' => '支持部署到金山云CDN',
'note' => '支持部署到金山云CDN',
'inputs' => [
'AccessKeyId' => [
'name' => 'AccessKeyId',
'type' => 'input',
'placeholder' => '',
'required' => true,
],
'SecretAccessKey' => [
'name' => 'SecretAccessKey',
'type' => 'input',
'placeholder' => '',
'required' => true,
],
'proxy' => [
'name' => '使用代理服务器',
'type' => 'radio',
'options' => [
'0' => '否',
'1' => '是',
],
'value' => '0'
],
],
'taskinputs' => [
'product' => [
'name' => '要部署的产品',
'type' => 'select',
'options' => [
['value'=>'cdn', 'label'=>'CDN'],
],
'value' => 'cdn',
'required' => true,
],
'domain' => [
'name' => '绑定的域名',
'type' => 'input',
'placeholder' => '多个域名可使用,分隔',
'show' => 'product==\'cdn\'',
'required' => true,
],
],
],
'huoshan' => [
'name' => '火山引擎',
'class' => 2,

209
app/lib/client/Ksyun.php Normal file
View File

@@ -0,0 +1,209 @@
<?php
namespace app\lib\client;
use Exception;
/**
* 金山云
*/
class Ksyun
{
private $AccessKeyId;
private $SecretAccessKey;
private $endpoint;
private $service;
private $region;
private $proxy = false;
public function __construct($AccessKeyId, $SecretAccessKey, $endpoint, $service, $region, $proxy = false)
{
$this->AccessKeyId = $AccessKeyId;
$this->SecretAccessKey = $SecretAccessKey;
$this->endpoint = $endpoint;
$this->service = $service;
$this->region = $region;
$this->proxy = $proxy;
}
/**
* @param string $method 请求方法
* @param string $action 方法名称
* @param array $params 请求参数
* @return array
* @throws Exception
*/
public function request($method, $action, $version, $path = '/', $params = [])
{
if (!empty($params)) {
$params = array_filter($params, function ($a) {
return $a !== null;
});
}
$body = '';
$query = [];
if ($method == 'GET') {
$query = $params;
} else {
$body = !empty($params) ? json_encode($params) : '';
}
$time = time();
$headers = [
'Host' => $this->endpoint,
'X-Amz-Date' => gmdate("Ymd\THis\Z", $time),
'X-Version' => $version,
'X-Action' => $action,
];
$authorization = $this->generateSign($method, $path, $query, $headers, $body, $time);
$headers['Authorization'] = $authorization;
$headers['Accept'] = 'application/json';
if ($body) {
$headers['Content-Type'] = 'application/json';
}
$url = 'https://' . $this->endpoint . $path;
if (!empty($query)) {
$url .= '?' . http_build_query($query);
}
$header = [];
foreach ($headers as $key => $value) {
$header[] = $key . ': ' . $value;
}
return $this->curl($method, $url, $body, $header);
}
private function generateSign($method, $path, $query, $headers, $body, $time)
{
$algorithm = "AWS4-HMAC-SHA256";
// step 1: build canonical request string
$httpRequestMethod = $method;
$canonicalUri = $this->getCanonicalURI($path);
$canonicalQueryString = $this->getCanonicalQueryString($query);
[$canonicalHeaders, $signedHeaders] = $this->getCanonicalHeaders($headers);
$hashedRequestPayload = hash("sha256", $body);
$canonicalRequest = $httpRequestMethod . "\n"
. $canonicalUri . "\n"
. $canonicalQueryString . "\n"
. $canonicalHeaders . "\n"
. $signedHeaders . "\n"
. $hashedRequestPayload;
// step 2: build string to sign
$date = gmdate("Ymd\THis\Z", $time);
$shortDate = substr($date, 0, 8);
$credentialScope = $shortDate . '/' . $this->region . '/' . $this->service . '/aws4_request';
$hashedCanonicalRequest = hash("sha256", $canonicalRequest);
$stringToSign = $algorithm . "\n"
. $date . "\n"
. $credentialScope . "\n"
. $hashedCanonicalRequest;
// step 3: sign string
$kDate = hash_hmac("sha256", $shortDate, 'AWS4' . $this->SecretAccessKey, true);
$kRegion = hash_hmac("sha256", $this->region, $kDate, true);
$kService = hash_hmac("sha256", $this->service, $kRegion, true);
$kSigning = hash_hmac("sha256", "aws4_request", $kService, true);
$signature = hash_hmac("sha256", $stringToSign, $kSigning);
// step 4: build authorization
$credential = $this->AccessKeyId . '/' . $credentialScope;
$authorization = $algorithm . ' Credential=' . $credential . ", SignedHeaders=" . $signedHeaders . ", Signature=" . $signature;
return $authorization;
}
private function escape($str)
{
$search = ['+', '*', '%7E'];
$replace = ['%20', '%2A', '~'];
return str_replace($search, $replace, urlencode($str));
}
private function getCanonicalURI($path)
{
if (empty($path)) return '/';
$pattens = explode('/', $path);
$pattens = array_map(function ($item) {
return $this->escape($item);
}, $pattens);
$canonicalURI = implode('/', $pattens);
return $canonicalURI;
}
private function getCanonicalQueryString($parameters)
{
if (empty($parameters)) return '';
ksort($parameters);
$canonicalQueryString = '';
foreach ($parameters as $key => $value) {
if (!is_array($value)) {
$canonicalQueryString .= '&' . $this->escape($key) . '=' . $this->escape($value);
} else {
sort($value);
foreach ($value as $v) {
$canonicalQueryString .= '&' . $this->escape($key) . '=' . $this->escape($v);
}
}
}
return substr($canonicalQueryString, 1);
}
private function getCanonicalHeaders($oldheaders)
{
$headers = array();
foreach ($oldheaders as $key => $value) {
$headers[strtolower($key)] = trim($value);
}
ksort($headers);
$canonicalHeaders = '';
$signedHeaders = '';
foreach ($headers as $key => $value) {
$canonicalHeaders .= $key . ':' . $value . "\n";
$signedHeaders .= $key . ';';
}
$signedHeaders = substr($signedHeaders, 0, -1);
return [$canonicalHeaders, $signedHeaders];
}
private function curl($method, $url, $body, $header)
{
$ch = curl_init($url);
if ($this->proxy) {
curl_set_proxy($ch);
}
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_HTTPHEADER, $header);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
if (!empty($body)) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$response = curl_exec($ch);
$errno = curl_errno($ch);
if ($errno) {
$errmsg = curl_error($ch);
curl_close($ch);
throw new Exception('Curl error: ' . $errmsg);
}
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$arr = json_decode($response, true);
if ($httpCode == 200) {
return $arr;
} else {
if (isset($arr['Error']['Message'])) {
throw new Exception($arr['Error']['Message']);
} else {
throw new Exception('返回数据解析失败(http_code=' . $httpCode . ')');
}
}
}
}

View File

@@ -35,6 +35,12 @@ class btwaf implements DeployInterface
public function deploy($fullchain, $privatekey, $config, &$info)
{
if ($config['type'] == '1') {
$this->deployPanel($fullchain, $privatekey);
$this->log("面板证书部署成功");
return;
}
$sites = explode("\n", $config['sites']);
$success = 0;
$errmsg = null;
@@ -105,6 +111,24 @@ class btwaf implements DeployInterface
}
}
private function deployPanel($fullchain, $privatekey)
{
$path = '/api/config/set_cert';
$data = [
'certContent' => $fullchain,
'keyContent' => $privatekey,
];
$response = $this->request($path, $data);
$result = json_decode($response, true);
if (isset($result['code']) && $result['code'] == 0) {
return true;
} elseif (isset($result['res'])) {
throw new Exception($result['res']);
} else {
throw new Exception($response ? $response : '返回数据解析失败');
}
}
public function setLogger($func)
{
$this->logger = $func;

132
app/lib/deploy/fnos.php Normal file
View File

@@ -0,0 +1,132 @@
<?php
namespace app\lib\deploy;
use app\lib\DeployInterface;
use Exception;
class fnos implements DeployInterface
{
private $logger;
private $config;
public function __construct($config)
{
$this->config = $config;
}
public function check()
{
$this->connect();
}
public function deploy($fullchain, $privatekey, $config, &$info)
{
$domains = $config['domainList'];
if (empty($domains)) throw new Exception('没有设置要部署的域名');
$certInfo = openssl_x509_parse($fullchain, true);
if (!$certInfo) throw new Exception('证书解析失败');
$connection = $this->connect();
$cert_all = $this->exec($connection, '获取证书列表', 'cat /usr/trim/etc/network_cert_all.conf');
$list = json_decode($cert_all, true);
if (!$list) throw new Exception('获取证书列表失败');
$success = 0;
foreach ($list as $row) {
if (empty($row['san'])) continue;
$cert_domains = $row['san'];
$flag = false;
foreach ($cert_domains as $domain) {
if (in_array($domain, $domains)) {
$flag = true;
break;
}
}
if ($flag) {
$certPath = $row['certificate'];
$keyPath = $row['privateKey'];
$certDir = dirname($certPath);
$this->exec($connection, '上传证书文件', "sudo tee ".$certPath." > /dev/null <<'EOF'\n".$fullchain."\nEOF");
$this->exec($connection, '上传私钥文件', "sudo tee ".$keyPath." > /dev/null <<'EOF'\n".$privatekey."\nEOF");
$this->exec($connection, '刷新目录权限', 'sudo chmod 0755 "'.$certDir.'" -R');
$this->exec($connection, '更新数据表', 'sudo -u postgres psql -d trim_connect -c "UPDATE cert SET valid_to='.$certInfo['validTo_time_t'].'000,valid_from='.$certInfo['validFrom_time_t'].'000,issued_by=\''.$certInfo['issuer']['CN'].'\',updated_time='.getMillisecond().' WHERE private_key=\''.$keyPath.'\'"');
$this->log('证书 '.$row['domain'].' 更新成功');
$success++;
}
}
if ($success == 0) {
throw new Exception('没有要更新的证书');
} else {
$this->exec($connection, '重启webdav', 'sudo systemctl restart webdav.service');
$this->exec($connection, '重启smbftpd', 'sudo systemctl restart smbftpd.service');
$this->exec($connection, '重启trim_nginx', 'sudo systemctl restart trim_nginx.service');
}
}
private function exec($connection, $name, $cmd)
{
$stream = ssh2_exec($connection, $cmd);
$errorStream = ssh2_fetch_stream($stream, SSH2_STREAM_STDERR);
if (!$stream || !$errorStream) {
throw new Exception($name.'执行命令失败');
}
stream_set_blocking($stream, true);
stream_set_blocking($errorStream, true);
$output = stream_get_contents($stream);
$errorOutput = stream_get_contents($errorStream);
fclose($stream);
fclose($errorStream);
if (trim($errorOutput)) {
if (strpos($errorOutput, 'a password is required') !== false) {
throw new Exception('权限不足,请先配置 sudo 免密');
}
throw new Exception($name.'失败:' . trim($errorOutput));
} else {
if (strlen($output) > 200) {
return $output;
}
$this->log($name.'成功 ' . trim($output));
return $output;
}
}
private function connect()
{
if (!function_exists('ssh2_connect')) {
throw new Exception('ssh2扩展未安装');
}
if (empty($this->config['host']) || empty($this->config['port']) || empty($this->config['username']) || empty($this->config['password'])) {
throw new Exception('必填参数不能为空');
}
if (!filter_var($this->config['host'], FILTER_VALIDATE_IP) && !filter_var($this->config['host'], FILTER_VALIDATE_DOMAIN)) {
throw new Exception('主机地址不合法');
}
if (!is_numeric($this->config['port']) || $this->config['port'] < 1 || $this->config['port'] > 65535) {
throw new Exception('SSH端口不合法');
}
$connection = ssh2_connect($this->config['host'], intval($this->config['port']));
if (!$connection) {
throw new Exception('SSH连接失败');
}
if (!ssh2_auth_password($connection, $this->config['username'], $this->config['password'])) {
throw new Exception('用户名或密码错误');
}
return $connection;
}
public function setLogger($func)
{
$this->logger = $func;
}
private function log($txt)
{
if ($this->logger) {
call_user_func($this->logger, $txt);
}
}
}

202
app/lib/deploy/k8s.php Normal file
View File

@@ -0,0 +1,202 @@
<?php
namespace app\lib\deploy;
use app\lib\DeployInterface;
use Symfony\Component\Yaml\Yaml;
use Exception;
class k8s implements DeployInterface
{
private $logger;
private $kubeconfig;
private $server;
private $bearerToken;
private $tls = [];
private $proxy;
public function __construct($config)
{
$this->kubeconfig = $config['kubeconfig'];
$this->proxy = $config['proxy'] == 1;
}
public function check()
{
if (empty($this->kubeconfig)) throw new Exception('Kubeconfig不能为空');
$this->verify();
}
public function deploy($fullchain, $privatekey, $config, &$info)
{
$namespace = $config['namespace'];
$secretName = $config['secret_name'];
if (empty($namespace)) throw new Exception('命名空间不能为空');
if (empty($secretName)) throw new Exception('Secret名称不能为空');
$this->parse();
$secretPayload = [
'apiVersion' => 'v1',
'kind' => 'Secret',
'metadata' => ['name' => $secretName, 'namespace' => $namespace],
'type' => 'kubernetes.io/tls',
'data' => [
'tls.crt' => base64_encode($config['fullchain']),
'tls.key' => base64_encode($config['privatekey']),
],
];
$secretUrl = '/api/v1/namespaces/' . $namespace . '/secrets/' . $secretName;
list($sCode, $sBody, $sErr) = $this->k8s_request('GET', $secretUrl);
if ($sCode === 404) {
$createUrl = '/api/v1/namespaces/' . $namespace . '/secrets';
$this->log('Secret:' . $secretName . ' 不存在,正在创建...');
list($cCode, $cBody, $cErr) = $this->k8s_request('POST', $createUrl, json_encode($secretPayload));
if ($cCode < 200 || $cCode >= 300) throw new Exception("创建Secret失败 (HTTP $cCode): $cBody | $cErr");
$this->log('Secret:' . $namespace . ' 创建成功');
} elseif ($sCode >= 200 && $sCode < 300) {
$this->log('Secret:' . $secretName . ' 已存在,正在更新...');
$patch = ['data' => $secretPayload['data'], 'type' => 'kubernetes.io/tls'];
list($pCode, $pBody, $pErr) = $this->k8s_request('PATCH', $secretUrl, json_encode($patch));
if ($pCode < 200 || $pCode >= 300) throw new Exception("更新Secret失败 (HTTP $pCode): $pBody | $pErr");
$this->log('Secret:' . $secretName . ' 更新成功');
} else {
throw new Exception("获取Secret失败 (HTTP $sCode): $sBody | $sErr");
}
// Bind Secret to specified Ingresses (merge spec.tls & hosts) ----
if (!empty($config['ingresses'])) {
$ingressUrl = '/apis/networking.k8s.io/v1/namespaces/' . $namespace . '/ingresses';
foreach (explode(',', $config['ingresses']) as $ingName) {
list($gCode, $gBody, $gErr) = $this->k8s_request('GET', $ingressUrl . '/' . $ingName);
if ($gCode < 200 || $gCode >= 300) throw new Exception("获取Ingress '$ingName' 失败 (HTTP $gCode): $gBody | $gErr");
$ing = json_decode($gBody, true);
if (!$ing) throw new Exception("解析Ingress '$ingName' JSON失败: $gBody");
// collect hosts from spec.rules
$hosts = [];
foreach (($ing['spec']['rules'] ?? []) as $rule) {
if (!empty($rule['host'])) $hosts[] = $rule['host'];
}
$hosts = array_values(array_unique($hosts));
// merge/ensure spec.tls entry
$tls = $ing['spec']['tls'] ?? [];
$found = false;
foreach ($tls as &$entry) {
if (($entry['secretName'] ?? '') === $secretName) {
$found = true;
$existingHosts = $entry['hosts'] ?? [];
$entry['hosts'] = array_values(array_unique(array_merge($existingHosts, $hosts)));
}
}
unset($entry);
if (!$found) {
$tls[] = ['secretName' => $secretName, 'hosts' => $hosts];
}
$patch = ['spec' => ['tls' => $tls]];
list($iCode, $iBody, $iErr) = $this->k8s_request('PATCH', $ingressUrl . '/' . $ingName, json_encode($patch, JSON_UNESCAPED_SLASHES));
if ($iCode < 200 || $iCode >= 300) throw new Exception("更新Ingress '$ingName' 失败 (HTTP $iCode): $iBody | $iErr");
$this->log("Ingress '$ingName' 更新TLS成功");
}
}
}
private function parse()
{
$kcfg = Yaml::parse($this->kubeconfig);
if (!$kcfg) throw new Exception('Kubeconfig格式错误');
$curr = $kcfg['current-context'] ?? null;
if (!$curr) throw new Exception('Kubeconfig缺少current-context');
$contexts = $this->index_by_name($kcfg['contexts'] ?? []);
$clusters = $this->index_by_name($kcfg['clusters'] ?? []);
$users = $this->index_by_name($kcfg['users'] ?? []);
$ctx = $contexts[$curr] ?? null;
if (!$ctx) throw new Exception("Kubeconfig中找不到current-context: $curr");
$clusterName = $ctx['context']['cluster'] ?? null;
$userName = $ctx['context']['user'] ?? null;
if (!$clusterName || !$userName) throw new Exception("Kubeconfig中context缺少cluster或user: $curr");
$cluster = $clusters[$clusterName] ?? null;
$user = $users[$userName] ?? null;
if (!$cluster) throw new Exception("Kubeconfig中找不到cluster: $clusterName");
if (!$user) throw new Exception("Kubeconfig中找不到user: $userName");
$this->server = $cluster['cluster']['server'] ?? null;
if (!$this->server) throw new Exception("Kubeconfig中找不到cluster.server");
$this->server = rtrim($this->server, '/');
$this->bearerToken = $user['user']['token'] ?? ($user['user']['auth-provider']['config']['access-token'] ?? null);
$clientCertFile = $clientKeyFile = null;
if (!empty($user['user']['client-certificate-data']) && !empty($user['user']['client-key-data'])) {
$clientCertFile = tempnam(sys_get_temp_dir(), 'kcc_');
$clientKeyFile = tempnam(sys_get_temp_dir(), 'kck_');
file_put_contents($clientCertFile, base64_decode($user['user']['client-certificate-data']));
file_put_contents($clientKeyFile, base64_decode($user['user']['client-key-data']));
} elseif (!empty($user['user']['client-certificate']) && !empty($user['user']['client-key'])) {
$clientCertFile = $user['user']['client-certificate'];
$clientKeyFile = $user['user']['client-key'];
}
$this->tls = ['cert' => $clientCertFile, 'key' => $clientKeyFile];
}
private function verify()
{
$this->parse();
list($vCode, $vBody, $vErr) = $this->k8s_request('GET', '/version');
if ($vErr) throw new Exception("连接Kubernetes API服务器失败: $vErr");
if ($vCode != 200) throw new Exception("连接Kubernetes API服务器失败: HTTP $vCode $vBody");
}
private function k8s_request($method, $path, $body = null)
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $this->server . $path);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
$headers = ['Accept: application/json'];
if ($this->bearerToken) $headers[] = 'Authorization: Bearer ' . $this->bearerToken;
if ($body !== null) $headers[] = 'Content-Type: application/json';
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
if (!empty($this->tls['cert']) && !empty($this->tls['key'])) {
curl_setopt($ch, CURLOPT_SSLCERT, $this->tls['cert']);
curl_setopt($ch, CURLOPT_SSLKEY, $this->tls['key']);
}
$resp = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$err = curl_error($ch);
curl_close($ch);
return [$code, $resp, $err];
}
private function index_by_name($arr)
{
$out = [];
foreach ($arr as $item) {
if (isset($item['name'])) $out[$item['name']] = $item;
}
return $out;
}
public function setLogger($func)
{
$this->logger = $func;
}
private function log($txt)
{
if ($this->logger) {
call_user_func($this->logger, $txt);
}
}
}

79
app/lib/deploy/ksyun.php Normal file
View File

@@ -0,0 +1,79 @@
<?php
namespace app\lib\deploy;
use app\lib\DeployInterface;
use app\lib\client\Ksyun as KsyunClient;
use Exception;
class ksyun implements DeployInterface
{
private $logger;
private $AccessKeyId;
private $SecretAccessKey;
private $proxy;
public function __construct($config)
{
$this->AccessKeyId = $config['AccessKeyId'];
$this->SecretAccessKey = $config['SecretAccessKey'];
$this->proxy = isset($config['proxy']) ? $config['proxy'] == 1 : false;
}
public function check()
{
if (empty($this->AccessKeyId) || empty($this->SecretAccessKey)) throw new Exception('必填参数不能为空');
$client = new KsyunClient($this->AccessKeyId, $this->SecretAccessKey, 'cdn.api.ksyun.com', 'cdn', 'cn-shanghai-2', $this->proxy);
$client->request('GET', 'GetCertificates', '2016-09-01', '/2016-09-01/cert/GetCertificates');
return true;
}
public function deploy($fullchain, $privatekey, $config, &$info)
{
$this->deploy_cdn($fullchain, $privatekey, $config, $info);
}
public function deploy_cdn($fullchain, $privatekey, $config, &$info)
{
if (empty($config['domain'])) throw new Exception('绑定的域名不能为空');
$certInfo = openssl_x509_parse($fullchain, true);
if (!$certInfo) throw new Exception('证书解析失败');
$config['cert_name'] = str_replace('*.', '', $certInfo['subject']['CN']) . '-' . $certInfo['validFrom_time_t'];
$domains = explode(',', $config['domain']);
$client = new KsyunClient($this->AccessKeyId, $this->SecretAccessKey, 'cdn.api.ksyun.com', 'cdn', 'cn-shanghai-2', $this->proxy);
$param = [
'PageSize' => 100,
'PageNumber' => 1,
];
$domain_ids = [];
$result = $client->request('GET', 'GetCdnDomains', '2019-06-01', '/2019-06-01/domain/GetCdnDomains', $param);
foreach ($result['Domains'] as $row) {
if (in_array($row['DomainName'], $domains)) {
$domain_ids[] = $row['DomainId'];
}
}
if (count($domain_ids) == 0) throw new Exception('未找到对应的CDN域名');
$param = [
'Enable' => 'on',
'DomainIds' => implode(',', $domain_ids),
'CertificateName' => $config['cert_name'],
'ServerCertificate' => $fullchain,
'PrivateKey' => $privatekey,
];
$result = $client->request('POST', 'ConfigCertificate', '2016-09-01', '/2016-09-01/cert/ConfigCertificate', $param);
$this->log('CDN证书部署成功证书ID' . $result['CertificateId']);
}
public function setLogger($func)
{
$this->logger = $func;
}
private function log($txt)
{
if ($this->logger) {
call_user_func($this->logger, $txt);
}
}
}

View File

@@ -37,34 +37,20 @@ class ucloud implements DeployInterface
if (!$certInfo) throw new Exception('证书解析失败');
$cert_name = str_replace(['*', '.'], '', $certInfo['subject']['CN']) . '-' . $certInfo['validFrom_time_t'];
$param = [
'CertName' => $cert_name,
'UserCert' => $fullchain,
'PrivateKey' => $privatekey,
];
try {
$data = $this->client->request('GetCertificateV2', []);
$data = $this->client->request('AddCertificate', $param);
$this->log('添加证书成功,名称:' . $cert_name);
} catch (Exception $e) {
throw new Exception('获取证书列表失败 ' . $e->getMessage());
}
$exist = false;
foreach ($data['CertList'] as $cert) {
if (trim($cert['UserCert']) == trim($fullchain)) {
$cert_name = $cert['CertName'];
$exist = true;
}
}
if (!$exist) {
$param = [
'CertName' => $cert_name,
'UserCert' => $fullchain,
'PrivateKey' => $privatekey,
];
try {
$data = $this->client->request('AddCertificate', $param);
} catch (Exception $e) {
if (strpos($e->getMessage(), 'cert already exist') !== false) {
$this->log('证书已存在,名称:' . $cert_name);
} else {
throw new Exception('添加证书失败 ' . $e->getMessage());
}
$this->log('添加证书成功,名称:' . $cert_name);
} else {
$this->log('获取到已添加的证书,名称:' . $cert_name);
}
try {

103
app/lib/deploy/uusec.php Normal file
View File

@@ -0,0 +1,103 @@
<?php
namespace app\lib\deploy;
use app\lib\DeployInterface;
use Exception;
class uusec implements DeployInterface
{
private $logger;
private $url;
private $username;
private $password;
private $proxy;
private $accessToken;
public function __construct($config)
{
$this->url = rtrim($config['url'], '/');
$this->username = $config['username'];
$this->password = $config['password'];
$this->proxy = $config['proxy'] == 1;
}
public function check()
{
if (empty($this->url) || empty($this->password) || empty($this->password)) throw new Exception('用户名和密码不能为空');
$this->login();
}
public function deploy($fullchain, $privatekey, $config, &$info)
{
$id = $config['id'];
if (empty($id)) throw new Exception('证书ID不能为空');
$this->login();
$params = [
'id' => intval($id),
'type' => 1,
'name' => $config['name'],
'crt' => $fullchain,
'key' => $privatekey,
];
$result = $this->request('/api/v1/certs', $params, 'PUT');
if (is_string($result) && $result == 'OK') {
$this->log('证书ID:' . $id . '更新成功!');
} else {
throw new Exception('证书ID:' . $id . '更新失败,' . (isset($result['err']) ? $result['err'] : '未知错误'));
}
}
private function login()
{
$path = '/api/v1/users/login';
$params = [
'usr' => $this->username,
'pwd' => $this->password,
'otp' => '',
];
$result = $this->request($path, $params);
if (isset($result['token'])) {
$this->accessToken = $result['token'];
} else {
throw new Exception('登录失败,' . (isset($result['err']) ? $result['err'] : '未知错误'));
}
}
private function request($path, $params = null, $method = null)
{
$url = $this->url . $path;
$headers = [];
$body = null;
if ($this->accessToken) {
$headers['Authorization'] = 'Bearer ' . $this->accessToken;
}
if ($params) {
$headers['Content-Type'] = 'application/json;charset=UTF-8';
$body = json_encode($params);
}
$response = http_request($url, $body, null, null, $headers, $this->proxy, $method);
$result = json_decode($response['body'], true);
if ($response['code'] == 200) {
return $result;
} elseif (isset($result['message'])) {
throw new Exception($result['message']);
} else {
throw new Exception('请求失败HTTP状态码' . $response['code']);
}
}
public function setLogger($func)
{
$this->logger = $func;
}
private function log($txt)
{
if ($this->logger) {
call_user_func($this->logger, $txt);
}
}
}

113
app/lib/deploy/xp.php Normal file
View File

@@ -0,0 +1,113 @@
<?php
namespace app\lib\deploy;
use app\lib\DeployInterface;
use Exception;
class xp implements DeployInterface
{
private $logger;
private $url;
private $apikey;
private $proxy;
public function __construct($config)
{
$this->url = rtrim($config['url'], '/');
$this->apikey = $config['apikey'];
$this->proxy = $config['proxy'] == 1;
}
public function check()
{
if (empty($this->url) || empty($this->apikey)) throw new Exception('请填写面板地址和接口密钥');
$path = '/openApi/siteList';
$response = $this->request($path);
$result = json_decode($response, true);
if (isset($result['code']) && $result['code'] == 1000) {
return true;
} else {
throw new Exception(isset($result['message']) ? $result['message'] : '面板地址无法连接');
}
}
public function deploy($fullchain, $privatekey, $config, &$info)
{
$path = '/openApi/siteList';
$response = $this->request($path);
$result = json_decode($response, true);
if (isset($result['code']) && $result['code'] == 1000) {
$sites = explode("\n", $config['sites']);
$sites = array_map('trim', $sites);
$success = 0;
$errmsg = null;
foreach ($result['data'] as $item) {
if (!in_array($item['name'], $sites)) {
continue;
}
try {
$this->deploySite($item['id'], $fullchain, $privatekey);
$this->log("网站 {$item['name']} 证书部署成功");
$success++;
} catch (Exception $e) {
$errmsg = $e->getMessage();
$this->log("网站 {$item['name']} 证书部署失败:" . $errmsg);
}
}
if ($success == 0) {
throw new Exception($errmsg ? $errmsg : '要部署的网站不存在');
}
} elseif (isset($result['message'])) {
throw new Exception($result['message']);
} else {
throw new Exception($response ? $response : '返回数据解析失败');
}
}
private function deploySite($id, $fullchain, $privatekey)
{
$path = '/openApi/setSSL';
$data = [
'id' => $id,
'key' => $privatekey,
'pem' => $fullchain,
];
$response = $this->request($path, $data);
$result = json_decode($response, true);
if (isset($result['code']) && $result['code'] == 1000) {
return true;
} elseif (isset($result['message'])) {
throw new Exception($result['message']);
} else {
throw new Exception($response ? $response : '返回数据解析失败');
}
}
public function setLogger($func)
{
$this->logger = $func;
}
private function log($txt)
{
if ($this->logger) {
call_user_func($this->logger, $txt);
}
}
private function request($path, $params = null)
{
$url = $this->url . $path;
$headers = [
'XP-API-KEY' => $this->apikey,
];
$response = http_request($url, $params ? json_encode($params) : null, null, null, $headers, $this->proxy);
return $response['body'];
}
}

View File

@@ -55,7 +55,7 @@
<div class="form-group" v-show="set.type==2">
<label class="col-sm-3 control-label no-padding-right" is-required>备用解析记录</label>
<div class="col-sm-6">
<input type="text" name="backup_value" v-model="set.backup_value" placeholder="支持填写IPv4或CNAME地址" class="form-control" required>
<input type="text" name="backup_value" v-model="set.backup_value" placeholder="支持填写IP或CNAME地址" class="form-control" required>
</div>
</div>
<div class="form-group" v-show="set.type==2&&dnstype=='cloudflare'">

View File

@@ -58,6 +58,7 @@
"symfony/polyfill-mbstring": "^1.32",
"symfony/polyfill-php81": "^1.32",
"symfony/polyfill-php82": "^1.32",
"symfony/yaml": "^7.3",
"topthink/framework": "^8.1.0",
"topthink/think-orm": "^4.0",
"topthink/think-view": "^2.0"

186
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "876ad9987c672e0fa8d90dbe55321dc7",
"content-hash": "f7c4abfaf4cb80cd99107e9e1763e75c",
"packages": [
{
"name": "cccyun/php-whois",
@@ -445,16 +445,16 @@
},
{
"name": "phpmailer/phpmailer",
"version": "v6.10.0",
"version": "v6.11.1",
"source": {
"type": "git",
"url": "https://github.com/PHPMailer/PHPMailer.git",
"reference": "bf74d75a1fde6beaa34a0ddae2ec5fce0f72a144"
"reference": "d9e3b36b47f04b497a0164c5a20f92acb4593284"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/bf74d75a1fde6beaa34a0ddae2ec5fce0f72a144",
"reference": "bf74d75a1fde6beaa34a0ddae2ec5fce0f72a144",
"url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/d9e3b36b47f04b497a0164c5a20f92acb4593284",
"reference": "d9e3b36b47f04b497a0164c5a20f92acb4593284",
"shasum": ""
},
"require": {
@@ -475,6 +475,7 @@
},
"suggest": {
"decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication",
"ext-imap": "Needed to support advanced email address parsing according to RFC822",
"ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses",
"ext-openssl": "Needed for secure SMTP sending and DKIM signing",
"greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication",
@@ -514,7 +515,7 @@
"description": "PHPMailer is a full-featured email creation and transfer class for PHP",
"support": {
"issues": "https://github.com/PHPMailer/PHPMailer/issues",
"source": "https://github.com/PHPMailer/PHPMailer/tree/v6.10.0"
"source": "https://github.com/PHPMailer/PHPMailer/tree/v6.11.1"
},
"funding": [
{
@@ -522,7 +523,7 @@
"type": "github"
}
],
"time": "2025-04-24T15:19:31+00:00"
"time": "2025-09-30T11:54:53+00:00"
},
{
"name": "psr/container",
@@ -949,6 +950,89 @@
],
"time": "2024-09-25T14:21:43+00:00"
},
{
"name": "symfony/polyfill-ctype",
"version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-ctype.git",
"reference": "a3cc8b044a6ea513310cbd48ef7333b384945638"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638",
"reference": "a3cc8b044a6ea513310cbd48ef7333b384945638",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"provide": {
"ext-ctype": "*"
},
"suggest": {
"ext-ctype": "For best performance"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Ctype\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Gert de Pagter",
"email": "BackEndTea@gmail.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill for ctype functions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"ctype",
"polyfill",
"portable"
],
"support": {
"source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-09-09T11:45:10+00:00"
},
{
"name": "symfony/polyfill-intl-idn",
"version": "v1.33.0",
@@ -1366,6 +1450,82 @@
],
"time": "2024-09-09T11:45:10+00:00"
},
{
"name": "symfony/yaml",
"version": "v7.3.3",
"source": {
"type": "git",
"url": "https://github.com/symfony/yaml.git",
"reference": "d4f4a66866fe2451f61296924767280ab5732d9d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/yaml/zipball/d4f4a66866fe2451f61296924767280ab5732d9d",
"reference": "d4f4a66866fe2451f61296924767280ab5732d9d",
"shasum": ""
},
"require": {
"php": ">=8.2",
"symfony/deprecation-contracts": "^2.5|^3.0",
"symfony/polyfill-ctype": "^1.8"
},
"conflict": {
"symfony/console": "<6.4"
},
"require-dev": {
"symfony/console": "^6.4|^7.0"
},
"bin": [
"Resources/bin/yaml-lint"
],
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Yaml\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Loads and dumps YAML files",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/yaml/tree/v7.3.3"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-08-27T11:34:33+00:00"
},
{
"name": "topthink/framework",
"version": "v8.1.3",
@@ -1747,16 +1907,16 @@
},
{
"name": "symfony/var-dumper",
"version": "v7.3.2",
"version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/var-dumper.git",
"reference": "53205bea27450dc5c65377518b3275e126d45e75"
"reference": "b8abe7daf2730d07dfd4b2ee1cecbf0dd2fbdabb"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/var-dumper/zipball/53205bea27450dc5c65377518b3275e126d45e75",
"reference": "53205bea27450dc5c65377518b3275e126d45e75",
"url": "https://api.github.com/repos/symfony/var-dumper/zipball/b8abe7daf2730d07dfd4b2ee1cecbf0dd2fbdabb",
"reference": "b8abe7daf2730d07dfd4b2ee1cecbf0dd2fbdabb",
"shasum": ""
},
"require": {
@@ -1810,7 +1970,7 @@
"dump"
],
"support": {
"source": "https://github.com/symfony/var-dumper/tree/v7.3.2"
"source": "https://github.com/symfony/var-dumper/tree/v7.3.4"
},
"funding": [
{
@@ -1830,7 +1990,7 @@
"type": "tidelift"
}
],
"time": "2025-07-29T20:02:46+00:00"
"time": "2025-09-11T10:12:26+00:00"
},
{
"name": "topthink/think-trace",

View File

@@ -31,7 +31,7 @@ return [
'show_error_msg' => true,
'exception_tmpl' => \think\facade\App::getAppPath() . 'view/exception.tpl',
'version' => '1040',
'version' => '1041',
'dbversion' => '1040'
];

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
public/static/images/xp.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB