Merge pull request #8 from StarVM-OpenSource/main

魔方财务模块:支持独立IPv4 初步兼容防火墙
This commit is contained in:
Meng Meng
2026-06-12 11:54:46 +08:00
committed by GitHub
5 changed files with 855 additions and 31 deletions
+347 -28
View File
@@ -37,10 +37,10 @@ function clicd_json_response($payload)
function clicd_MetaData()
{
return [
'DisplayName' => 'CLICD 对接模块 by 欢-Huan and ChatGPT 5.5',
'DisplayName' => 'CLICD 对接模块 by 欢-Huan and ChatGPT 5.5 and DeepSeek V4',
'APIVersion' => '1.1',
'HelpDoc' => 'https://github.com/MengMengCode/CLICD',
'version' => '1.0.1',
'version' => '1.0.5',
];
}
@@ -59,10 +59,19 @@ function clicd_ConfigOptions()
['type' => 'text', 'name' => '入站流量 GB', 'description' => 'in_out 模式下入站流量限制,0 表示不限制', 'default' => '0', 'key' => 'traffic_in_gb'],
['type' => 'text', 'name' => '出站流量 GB', 'description' => 'in_out 模式下出站流量限制,0 表示不限制', 'default' => '0', 'key' => 'traffic_out_gb'],
['type' => 'text', 'name' => 'IO 速度 MB/s', 'description' => '磁盘 IO 限制,0 表示不限制', 'default' => '0', 'key' => 'io_speed_mbps'],
['type' => 'dropdown', 'name' => '分配 NAT', 'description' => '开通时是否分配 NAT 端口映射', 'default' => 'true', 'key' => 'assign_nat', 'options' => ['true' => '启用', 'false' => '禁用']],
['type' => 'text', 'name' => 'NAT 端口数量', 'description' => '开通时分配的端口映射数量,最小 2', 'default' => '2', 'key' => 'port_mapping_count'],
['type' => 'text', 'name' => '快照配额', 'description' => '每台实例允许保留的快照数量', 'default' => '3', 'key' => 'snapshot_limit'],
['type' => 'text', 'name' => '额外端口', 'description' => '逗号分隔的容器端口,例如 80,443', 'default' => '', 'key' => 'extra_ports'],
['type' => 'dropdown', 'name' => '自动公网 IPv4', 'description' => '开通时是否从 CLICD 公网 IPv4 池分配独立 IPv4', 'default' => 'false', 'key' => 'assign_ipv4', 'options' => ['true' => '启用', 'false' => '禁用']],
['type' => 'text', 'name' => '公网 IPv4 数量', 'description' => '自动分配公网 IPv4 的数量,通常填写 1', 'default' => '1', 'key' => 'ipv4_count'],
['type' => 'text', 'name' => '指定公网 IPv4', 'description' => '指定分配的公网 IPv4,多个用逗号分隔;留空则从地址池自动分配', 'default' => '', 'key' => 'public_ipv4s'],
['type' => 'dropdown', 'name' => '自动 IPv6', 'description' => '开通时自动分配 IPv6', 'default' => 'false', 'key' => 'assign_ipv6', 'options' => ['true' => '启用', 'false' => '禁用']],
['type' => 'text', 'name' => 'IPv6 数量', 'description' => '自动分配 IPv6 的数量,通常填写 1', 'default' => '1', 'key' => 'ipv6_count'],
['type' => 'text', 'name' => '指定 IPv6', 'description' => '指定分配的 IPv6 地址,多个用逗号分隔;留空则从地址池自动分配', 'default' => '', 'key' => 'ipv6_addresses'],
['type' => 'dropdown', 'name' => 'SSH 鉴权模式', 'description' => 'auto_password=自动生成密码,password=使用指定密码,key=使用 SSH 公钥', 'default' => 'auto_password', 'key' => 'ssh_auth_mode', 'options' => ['auto_password' => '自动密码', 'password' => '指定密码', 'key' => 'SSH 公钥']],
['type' => 'text', 'name' => '指定 SSH 密码', 'description' => 'SSH 鉴权模式为 password 时使用;其他模式留空', 'default' => '', 'key' => 'ssh_password'],
['type' => 'text', 'name' => 'SSH 公钥', 'description' => 'SSH 鉴权模式为 key 时使用;填写完整 public key', 'default' => '', 'key' => 'ssh_public_key'],
['type' => 'dropdown', 'name' => '同步到期时间', 'description' => '开通/续费时把魔方到期日期同步到 CLICD,格式会转换为 YYYY-MM-DD', 'default' => 'true', 'key' => 'sync_expiry', 'options' => ['true' => '启用', 'false' => '禁用']],
];
}
@@ -203,16 +212,101 @@ function clicd_container_name($params)
return trim($name, '-.');
}
function clicd_public_host($params, $container = [])
function clicd_host_id($params)
{
foreach (['hostid', 'id', 'serviceid', 'service_id', 'relid'] as $key) {
if (!empty($params[$key]) && is_numeric($params[$key])) {
return (int)$params[$key];
}
}
return 0;
}
function clicd_first_string($value)
{
if (is_array($value)) {
foreach ($value as $item) {
if (is_array($item)) {
foreach (['address', 'ip', 'ipv4', 'public_ip', 'public_ipv4'] as $key) {
if (!empty($item[$key])) {
$itemValue = trim((string)$item[$key]);
if ($itemValue !== '') {
return $itemValue;
}
}
}
continue;
}
$itemValue = trim((string)$item);
if ($itemValue !== '') {
return $itemValue;
}
}
return '';
}
$value = trim((string)$value);
return $value;
}
function clicd_public_host_from_container($container = [])
{
if (is_array($container)) {
foreach (['nat_public_ip', 'public_ip', 'host_ip', 'external_ip', 'node_ip', 'nat_host'] as $key) {
foreach (['public_ipv4s', 'public_ipv4', 'public_ip', 'ipv4_addresses', 'ipv4', 'nat_public_ip', 'host_ip', 'external_ip', 'node_ip', 'nat_host'] as $key) {
if (!empty($container[$key])) {
return trim((string)$container[$key]);
$value = clicd_first_string($container[$key]);
if ($value !== '') {
return $value;
}
}
}
}
return '';
}
function clicd_public_ipv4_from_routing($params, $container = [])
{
if (!is_array($container)) {
return '';
}
$containerId = isset($container['id']) ? (string)$container['id'] : '';
$containerName = isset($container['name']) ? (string)$container['name'] : clicd_container_name($params);
$res = clicd_request($params, '/api/v1/routing', [], 'GET', 30);
if (!clicd_success($res) || empty($res['data']['ipv4_assignments']) || !is_array($res['data']['ipv4_assignments'])) {
return '';
}
foreach ($res['data']['ipv4_assignments'] as $assignment) {
if (!is_array($assignment)) {
continue;
}
$matchId = $containerId !== '' && isset($assignment['container_id']) && (string)$assignment['container_id'] === $containerId;
$matchName = $containerName !== '' && isset($assignment['container_name']) && (string)$assignment['container_name'] === $containerName;
if ($matchId || $matchName) {
return clicd_first_string($assignment['address'] ?? '');
}
}
return '';
}
function clicd_public_host($params, $container = [], $useRouting = false)
{
$fromContainer = clicd_public_host_from_container($container);
if ($fromContainer !== '') {
return $fromContainer;
}
if ($useRouting) {
$fromRouting = clicd_public_ipv4_from_routing($params, $container);
if ($fromRouting !== '') {
return $fromRouting;
}
}
foreach (['server_ip', 'ip'] as $key) {
if (!empty($params[$key])) {
$value = trim((string)$params[$key]);
@@ -403,6 +497,24 @@ function clicd_extra_ports($value)
return array_values(array_unique($ports));
}
function clicd_csv_values($value)
{
if (is_array($value)) {
$parts = $value;
} else {
$parts = preg_split('/[,;\s]+/', (string)$value);
}
$result = [];
foreach ($parts as $part) {
$part = trim((string)$part);
if ($part !== '') {
$result[] = $part;
}
}
return array_values(array_unique($result));
}
function clicd_expiry_from_params($params)
{
$options = $params['configoptions'] ?? [];
@@ -435,6 +547,21 @@ function clicd_container_payload($params)
{
$options = $params['configoptions'] ?? [];
$trafficMode = $options['traffic_mode'] ?? 'total';
$assignNat = clicd_bool_option($options['assign_nat'] ?? 'true', true);
$assignIpv4 = clicd_bool_option($options['assign_ipv4'] ?? 'false', false);
$assignIpv6 = clicd_bool_option($options['assign_ipv6'] ?? 'false', false);
$publicIpv4s = clicd_csv_values($options['public_ipv4s'] ?? '');
$ipv6Addresses = clicd_csv_values($options['ipv6_addresses'] ?? '');
if (!empty($publicIpv4s)) {
$assignIpv4 = true;
}
if (!empty($ipv6Addresses)) {
$assignIpv6 = true;
}
$sshAuthMode = strtolower(trim((string)($options['ssh_auth_mode'] ?? 'auto_password')));
if (!in_array($sshAuthMode, ['auto_password', 'password', 'key'], true)) {
$sshAuthMode = 'auto_password';
}
return [
'name' => clicd_container_name($params),
@@ -451,9 +578,18 @@ function clicd_container_payload($params)
'traffic_out_gb' => clicd_int_option($options, 'traffic_out_gb', 0),
'io_speed_mbps' => clicd_int_option($options, 'io_speed_mbps', 0),
'extra_ports' => clicd_extra_ports($options['extra_ports'] ?? ''),
'port_mapping_count' => max(2, clicd_int_option($options, 'port_mapping_count', 2)),
'port_mapping_count' => $assignNat ? max(2, clicd_int_option($options, 'port_mapping_count', 2)) : 0,
'assign_nat' => $assignNat,
'assign_ipv4' => $assignIpv4,
'ipv4_count' => max(1, clicd_int_option($options, 'ipv4_count', 1)),
'public_ipv4s' => $publicIpv4s,
'snapshot_limit' => max(1, clicd_int_option($options, 'snapshot_limit', 3)),
'assign_ipv6' => clicd_bool_option($options['assign_ipv6'] ?? 'false', false),
'assign_ipv6' => $assignIpv6,
'ipv6_count' => max(1, clicd_int_option($options, 'ipv6_count', 1)),
'ipv6_addresses' => $ipv6Addresses,
'ssh_auth_mode' => $sshAuthMode,
'ssh_password' => (string)($options['ssh_password'] ?? ''),
'ssh_public_key' => trim((string)($options['ssh_public_key'] ?? '')),
'expires_at' => clicd_expiry_from_params($params),
];
}
@@ -477,6 +613,9 @@ function clicd_request_value($key, $default = '')
{
if (function_exists('input')) {
$value = input('param.' . $key);
if ($value === null) {
$value = input('*.' . $key);
}
return $value === null ? $default : $value;
}
if (isset($_POST[$key])) {
@@ -821,16 +960,35 @@ function clicd_info_ajax($params)
];
}
function clicd_domain_status_from_container($container)
{
if (!is_array($container)) {
return 'Active';
}
if (!empty($container['policy_blocked'])) {
return 'Suspended';
}
$status = strtolower(trim((string)($container['status'] ?? '')));
if (in_array($status, ['suspended', 'blocked', 'policy_blocked', 'disabled'], true)) {
return 'Suspended';
}
return 'Active';
}
function clicd_update_host_from_container($params, $container)
{
if (empty($params['hostid']) || !is_array($container)) {
$hostId = clicd_host_id($params);
if ($hostId <= 0 || !is_array($container)) {
return;
}
$update = [
'domainstatus' => (($container['status'] ?? '') === 'running') ? 'Active' : 'Suspended',
'domainstatus' => clicd_domain_status_from_container($container),
'username' => 'root',
'dedicatedip' => clicd_public_host($params, $container),
'dedicatedip' => clicd_public_host($params, $container, true),
];
$sshPort = clicd_container_ssh_port($container);
@@ -844,7 +1002,7 @@ function clicd_update_host_from_container($params, $container)
}
try {
Db::name('host')->where('id', $params['hostid'])->update($update);
Db::name('host')->where('id', $hostId)->update($update);
} catch (\Exception $e) {
clicd_debug('host update failed', $e->getMessage());
}
@@ -879,21 +1037,24 @@ function clicd_CreateAccount($params)
return ['status' => 'error', 'msg' => clicd_message($res, '开通失败')];
}
$detail = clicd_find_container($params);
if (clicd_success($detail) && isset($detail['data'])) {
clicd_update_host_from_container($params, $detail['data']);
} elseif (!empty($params['hostid'])) {
$hostId = clicd_host_id($params);
if ($hostId > 0) {
try {
Db::name('host')->where('id', $params['hostid'])->update([
Db::name('host')->where('id', $hostId)->update([
'domainstatus' => 'Active',
'username' => 'root',
'dedicatedip' => clicd_public_host($params),
'dedicatedip' => clicd_public_ipv4_from_routing($params) ?: clicd_public_host($params),
]);
} catch (\Exception $e) {
return ['status' => 'error', 'msg' => '开通成功但同步魔方数据库失败: ' . $e->getMessage()];
}
}
$detail = clicd_find_container($params);
if (clicd_success($detail) && isset($detail['data'])) {
clicd_update_host_from_container($params, $detail['data']);
}
return ['status' => 'success', 'msg' => clicd_message($res, '开通成功')];
}
@@ -1001,9 +1162,10 @@ function clicd_CrackPassword($params, $new_pass)
}
$password = $res['data']['ssh_password'] ?? $res['data']['password'] ?? $new_pass;
if (!empty($params['hostid'])) {
$hostId = clicd_host_id($params);
if ($hostId > 0) {
try {
Db::name('host')->where('id', $params['hostid'])->update(['password' => clicd_store_password($password)]);
Db::name('host')->where('id', $hostId)->update(['password' => clicd_store_password($password)]);
$detail = clicd_find_container($params);
if (clicd_success($detail) && isset($detail['data'])) {
clicd_update_host_from_container($params, $detail['data']);
@@ -1262,19 +1424,155 @@ function clicd_webssh($params)
];
}
function clicd_firewallList($params)
{
$container = [];
$containerId = clicd_container_api_id($params, $container);
$res = clicd_request($params, '/api/v1/containers/' . rawurlencode($containerId) . '/firewall', [], 'GET', 30);
if (!clicd_success($res) || empty($res['data'])) {
return ['status' => 'error', 'msg' => clicd_message($res, '获取防火墙设置失败')];
}
return [
'status' => 200,
'msg' => '获取成功',
'data' => $res['data'],
];
}
function clicd_firewallUpdate($params)
{
$input = clicd_json_input();
$enabled = clicd_param_value($input, 'enabled', 'true');
$enabled = filter_var($enabled, FILTER_VALIDATE_BOOLEAN);
$rules = clicd_param_value($input, 'rules', '[]');
if (is_string($rules)) {
$decodedRules = json_decode($rules, true);
if (is_array($decodedRules)) {
$rules = $decodedRules;
}
}
if (!is_array($rules)) {
$rules = [];
}
$payload = [
'enabled' => $enabled,
'rules' => $rules,
];
$container = [];
$containerId = clicd_container_api_id($params, $container);
$res = clicd_request($params, '/api/v1/containers/' . rawurlencode($containerId) . '/firewall', $payload, 'PUT', 30);
if (!clicd_success($res)) {
return ['status' => 'error', 'msg' => clicd_message($res, '更新防火墙设置失败')];
}
// GET after PUT to confirm the actual state after CLICD processes it
$getRes = clicd_request($params, '/api/v1/containers/' . rawurlencode($containerId) . '/firewall', [], 'GET', 30);
$actualData = [];
if (clicd_success($getRes) && !empty($getRes['data']) && is_array($getRes['data'])) {
$actualData = $getRes['data'];
}
return [
'status' => 200,
'msg' => clicd_message($res, '防火墙设置已更新'),
'data' => $actualData,
];
}
function clicd_firewall_ajax($params)
{
$input = clicd_json_input();
$action = strtolower(trim((string)clicd_param_value($input, 'action', '')));
$debug = [clicd_debug_entry('Firewall ajax received', [
'action' => $action,
'input' => $input,
'query' => $_GET,
])];
$container = [];
$containerId = clicd_container_api_id($params, $container);
$debug[] = clicd_debug_entry('Container resolved', [
'container_id' => $containerId,
'container' => [
'id' => $container['id'] ?? null,
'uuid' => $container['uuid'] ?? null,
'name' => $container['name'] ?? null,
],
]);
if (!in_array($action, ['list', 'update'], true)) {
return ['status' => 'error', 'msg' => '未知防火墙操作', 'debug' => $debug];
}
if ($action === 'list') {
$call = clicd_request_debug($params, '/api/v1/containers/' . rawurlencode($containerId) . '/firewall', [], 'GET', 30);
$debug[] = $call['debug'];
$res = $call['response'];
if (!clicd_success($res) || empty($res['data'])) {
return ['status' => 'error', 'msg' => clicd_message($res, '获取防火墙设置失败'), 'debug' => $debug];
}
return [
'status' => 'success',
'msg' => '获取成功',
'data' => $res['data'],
'debug' => $debug,
];
}
// update
$enabled = clicd_param_value($input, 'enabled', 'true');
$enabled = filter_var($enabled, FILTER_VALIDATE_BOOLEAN);
$rules = clicd_param_value($input, 'rules', '[]');
if (is_string($rules)) {
$decodedRules = json_decode($rules, true);
if (is_array($decodedRules)) {
$rules = $decodedRules;
}
}
if (!is_array($rules)) {
$rules = [];
}
$payload = [
'enabled' => $enabled,
'rules' => $rules,
];
$call = clicd_request_debug($params, '/api/v1/containers/' . rawurlencode($containerId) . '/firewall', $payload, 'PUT', 30);
$debug[] = $call['debug'];
$res = $call['response'];
if (!clicd_success($res)) {
return ['status' => 'error', 'msg' => clicd_message($res, '更新防火墙设置失败'), 'debug' => $debug];
}
return [
'status' => 'success',
'msg' => clicd_message($res, '防火墙设置已更新'),
'data' => $res['data'] ?? [],
'debug' => $debug,
];
}
function clicd_AllowFunction()
{
return [
'client' => ['TrafficReset', 'randomPort', 'addNat', 'updateNat', 'deleteNat', 'natList', 'infoData', 'webssh'],
'admin' => ['TrafficReset', 'randomPort', 'addNat', 'updateNat', 'deleteNat', 'natList', 'infoData', 'webssh'],
'client' => ['TrafficReset', 'randomPort', 'addNat', 'updateNat', 'deleteNat', 'natList', 'infoData', 'webssh', 'firewallList', 'firewallUpdate'],
'admin' => ['TrafficReset', 'randomPort', 'addNat', 'updateNat', 'deleteNat', 'natList', 'infoData', 'webssh', 'firewallList', 'firewallUpdate'],
];
}
function clicd_ClientArea($params)
{
return [
'info' => ['name' => '实例信息'],
'nat' => ['name' => 'NAT转发'],
'info' => ['name' => '实例信息'],
'nat' => ['name' => 'NAT转发'],
'firewall' => ['name' => '防火墙'],
];
}
@@ -1287,8 +1585,11 @@ function clicd_ClientAreaOutput($params, $key)
if ($func === 'infoajax') {
clicd_json_response(clicd_info_ajax($params));
}
if ($func === 'firewallajax') {
clicd_json_response(clicd_firewall_ajax($params));
}
if (!in_array($key, ['info', 'nat'], true)) {
if (!in_array($key, ['info', 'nat', 'firewall'], true)) {
return '';
}
@@ -1298,6 +1599,7 @@ function clicd_ClientAreaOutput($params, $key)
}
$c = $res['data'];
$publicHost = clicd_public_host($params, $c, true);
if ($key === 'nat') {
$operation = clicd_handle_nat_post($params);
@@ -1315,8 +1617,8 @@ function clicd_ClientAreaOutput($params, $key)
'container' => $c,
'container_name'=> $c['name'] ?? clicd_container_name($params),
'ssh_port' => $c['ssh_port'] ?? '',
'server_ip' => $params['server_ip'] ?? parse_url(clicd_base_url($params), PHP_URL_HOST),
'nat_host' => $params['server_ip'] ?? parse_url(clicd_base_url($params), PHP_URL_HOST),
'server_ip' => $publicHost,
'nat_host' => $publicHost,
'operation_msg' => $operationMsg,
'service_id' => clicd_request_value('id', $params['hostid'] ?? ''),
'area_key' => 'nat',
@@ -1325,6 +1627,19 @@ function clicd_ClientAreaOutput($params, $key)
];
}
if ($key === 'firewall') {
return [
'template' => 'templates/firewall.html',
'vars' => [
'container' => $c,
'container_name' => $c['name'] ?? clicd_container_name($params),
'server_ip' => $publicHost,
'service_id' => clicd_request_value('id', $params['hostid'] ?? ''),
'area_key' => 'firewall',
],
];
}
$initialRxBytes = (int)($c['traffic_used_rx'] ?? $c['rx_bytes'] ?? 0);
$initialTxBytes = (int)($c['traffic_used_tx'] ?? $c['tx_bytes'] ?? 0);
$initialTrafficUsed = ($initialRxBytes || $initialTxBytes) ? round(($initialRxBytes + $initialTxBytes) / 1073741824, 2) : '-';
@@ -1342,8 +1657,8 @@ function clicd_ClientAreaOutput($params, $key)
'vars' => [
'container' => $c,
'status_text' => (($c['status'] ?? '') === 'running') ? '运行中' : '已关机',
'server_ip' => $params['server_ip'] ?? parse_url(clicd_base_url($params), PHP_URL_HOST),
'ssh_host' => $params['server_ip'] ?? parse_url(clicd_base_url($params), PHP_URL_HOST),
'server_ip' => $publicHost,
'ssh_host' => $publicHost,
'ssh_port' => $c['ssh_port'] ?? '',
'ssh_password' => $c['ssh_password'] ?? '',
'ipv4' => $c['ip'] ?? '',
@@ -1366,3 +1681,7 @@ function clicd_ClientAreaOutput($params, $key)
],
];
}
+1 -1
View File
@@ -1,4 +1,4 @@
<?php
<?php
$ws = isset($_GET['ws']) ? (string)$_GET['ws'] : (isset($_GET['amp;ws']) ? (string)$_GET['amp;ws'] : '');
$protocol = isset($_GET['protocol']) ? (string)$_GET['protocol'] : (isset($_GET['amp;protocol']) ? (string)$_GET['amp;protocol'] : '');
$container = isset($_GET['container']) ? (string)$_GET['container'] : (isset($_GET['amp;container']) ? (string)$_GET['amp;container'] : '');
+505
View File
@@ -0,0 +1,505 @@
<style>
.clicd-fw-panel{font-size:14px;color:#1f2937}
.clicd-fw-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-bottom:16px}
.clicd-fw-card{border:1px solid #e5e7eb;border-radius:6px;padding:12px;background:#fff}
.clicd-fw-label{color:#6b7280;font-size:12px;margin-bottom:4px}
.clicd-fw-value{font-size:18px;font-weight:600;word-break:break-all}
.clicd-fw-section{border:1px solid #e5e7eb;border-radius:6px;background:#fff;padding:12px;margin-top:8px}
.clicd-fw-title{font-weight:600;margin:18px 0 8px}
.clicd-fw-muted{color:#6b7280}
.clicd-fw-toggle-row{display:flex;align-items:center;gap:12px;margin-bottom:12px}
.clicd-fw-toggle{position:relative;display:inline-flex;width:48px;height:26px;cursor:pointer}
.clicd-fw-toggle input{opacity:0;width:0;height:0}
.clicd-fw-toggle-slider{position:absolute;inset:0;background:#d1d5db;border-radius:26px;transition:.25s}
.clicd-fw-toggle-slider:before{content:"";position:absolute;width:22px;height:22px;border-radius:50%;background:#fff;top:2px;left:2px;transition:.25s}.clicd-fw-toggle .clicd-fw-toggle-slider:before{width:16px;height:16px;top:2px;left:2px}
.clicd-fw-toggle input:checked+.clicd-fw-toggle-slider{background:#10b981}
.clicd-fw-toggle input:checked+.clicd-fw-toggle-slider:before{transform:translateX(22px)}
.clicd-fw-toggle-label{font-size:14px;font-weight:500}
.clicd-fw-status{font-size:13px;color:#6b7280}
.clicd-fw-rule-form{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:10px;align-items:end}
.clicd-fw-field label{display:block;color:#6b7280;font-size:12px;margin-bottom:4px}
.clicd-fw-input,.clicd-fw-select{width:100%;height:34px;border:1px solid #d1d5db;border-radius:4px;padding:6px 8px;box-sizing:border-box}
.clicd-fw-input:focus,.clicd-fw-select:focus{border-color:#2563eb;outline:none}
.clicd-fw-actions{display:flex;gap:8px;flex-wrap:wrap;align-items:end}
.clicd-fw-btn{height:34px;border:1px solid #2563eb;background:#2563eb;color:#fff;border-radius:4px;padding:0 12px;cursor:pointer;font-size:13px}
.clicd-fw-btn[disabled]{opacity:.6;cursor:not-allowed}
.clicd-fw-btn-secondary{border-color:#d1d5db;background:#fff;color:#374151}
.clicd-fw-btn-danger{border-color:#dc2626;background:#dc2626;color:#fff}
.clicd-fw-btn-sm{height:30px;padding:0 10px;font-size:12px}
.clicd-fw-rules{display:flex;flex-direction:column;gap:10px;margin-top:8px}
.clicd-fw-rule{border:1px solid #e5e7eb;border-radius:6px;background:#fff;padding:12px}
.clicd-fw-rule-header{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px;flex-wrap:wrap}
.clicd-fw-rule-direction{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:4px;font-size:12px;font-weight:600}
.clicd-fw-direction-in{background:#dbeafe;color:#1d4ed8}
.clicd-fw-direction-out{background:#fef3c7;color:#92400e}
.clicd-fw-rule-action{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:4px;font-size:12px;font-weight:600}
.clicd-fw-action-ACCEPT{background:#d1fae5;color:#065f46}
.clicd-fw-action-DROP{background:#fee2e2;color:#991b1b}
.clicd-fw-rule-desc{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:8px}
.clicd-fw-rule-detail{font-size:13px;color:#374151;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.clicd-fw-rule-detail .sep{color:#d1d5db}
.clicd-fw-rule-edit-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:8px;margin-top:8px;padding-top:8px;border-top:1px solid #e5e7eb}
.clicd-fw-message{border:1px solid #bfdbfe;background:#eff6ff;color:#1d4ed8;border-radius:6px;padding:10px 12px;margin-bottom:12px;display:none}
.clicd-fw-message.error{border-color:#fecaca;background:#fef2f2;color:#b91c1c}
.clicd-fw-debug{margin-top:12px;border:1px dashed #d1d5db;border-radius:6px;background:#f9fafb;padding:10px;color:#374151;white-space:pre-wrap;font-size:12px;display:none}
.clicd-fw-modal-mask{position:fixed;inset:0;background:rgba(15,23,42,.42);display:none;align-items:center;justify-content:center;z-index:9999;padding:16px}
.clicd-fw-modal{width:min(420px,100%);background:#fff;border-radius:6px;border:1px solid #e5e7eb;box-shadow:0 18px 48px rgba(15,23,42,.22);padding:16px}
.clicd-fw-modal-title{font-size:16px;font-weight:700;color:#111827;margin-bottom:8px}
.clicd-fw-modal-body{font-size:14px;color:#4b5563;line-height:1.6;margin-bottom:14px}
.clicd-fw-modal-actions{display:flex;justify-content:flex-end;gap:8px}
</style>
<div class="clicd-fw-panel" id="clicd-fw-panel" data-service-id="{$service_id}" data-area-key="{$area_key}">
<div class="clicd-fw-message" id="clicd-fw-message"></div>
<div class="clicd-fw-grid">
<div class="clicd-fw-card">
<div class="clicd-fw-label">实例名称</div>
<div class="clicd-fw-value">{$container_name}</div>
</div>
<div class="clicd-fw-card">
<div class="clicd-fw-label">IP 地址</div>
<div class="clicd-fw-value">{$server_ip}</div>
</div>
</div>
<div class="clicd-fw-section">
<div class="clicd-fw-toggle-row">
<label class="clicd-fw-toggle">
<input type="checkbox" id="clicd-fw-enabled">
<span class="clicd-fw-toggle-slider"></span>
</label>
<span class="clicd-fw-toggle-label">启用防火墙</span>
<span class="clicd-fw-status" id="clicd-fw-status-text">加载中...</span>
</div>
</div>
<div class="clicd-fw-title">添加规则</div>
<div class="clicd-fw-section" id="clicd-fw-add-section">
<div class="clicd-fw-rule-form">
<div class="clicd-fw-field">
<label>方向</label>
<select class="clicd-fw-select" id="clicd-fw-add-direction">
<option value="in">入站 (In)</option>
<option value="out">出站 (Out)</option>
</select>
</div>
<div class="clicd-fw-field">
<label>协议</label>
<select class="clicd-fw-select" id="clicd-fw-add-protocol">
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</select>
</div>
<div class="clicd-fw-field">
<label>端口</label>
<input class="clicd-fw-input" id="clicd-fw-add-port" type="text" placeholder="22 / 80,443 / 8000-9000">
</div>
<div class="clicd-fw-field">
<label>来源 IP</label>
<input class="clicd-fw-input" id="clicd-fw-add-source-ip" type="text" placeholder="留空表示所有">
</div>
<div class="clicd-fw-field">
<label>动作</label>
<select class="clicd-fw-select" id="clicd-fw-add-action">
<option value="ACCEPT">放行 (ACCEPT)</option>
<option value="DROP">拒绝 (DROP)</option>
</select>
</div>
<div class="clicd-fw-field">
<label>说明</label>
<input class="clicd-fw-input" id="clicd-fw-add-desc" type="text" placeholder="例如 Allow SSH">
</div>
<div class="clicd-fw-actions">
<button class="clicd-fw-btn" type="button" data-clicd-fw-action="add-rule">添加规则</button>
</div>
</div>
</div>
<div class="clicd-fw-title">防火墙规则</div>
<div id="clicd-fw-rules" class="clicd-fw-rules">
<div class="clicd-fw-section clicd-fw-muted">加载中...</div>
</div>
<pre class="clicd-fw-debug" id="clicd-fw-debug"></pre>
<div class="clicd-fw-modal-mask" id="clicd-fw-delete-modal">
<div class="clicd-fw-modal">
<div class="clicd-fw-modal-title">确认删除</div>
<div class="clicd-fw-modal-body" id="clicd-fw-delete-text">确认删除该规则?</div>
<div class="clicd-fw-modal-actions">
<button class="clicd-fw-btn clicd-fw-btn-secondary" type="button" id="clicd-fw-delete-cancel">取消</button>
<button class="clicd-fw-btn clicd-fw-btn-danger" type="button" id="clicd-fw-delete-confirm">删除</button>
</div>
</div>
</div>
</div>
<script>
(function(){
var panel = document.getElementById('clicd-fw-panel');
if (!panel || panel.getAttribute('data-bound') === '1') return;
panel.setAttribute('data-bound', '1');
var message = document.getElementById('clicd-fw-message');
var debugBox = document.getElementById('clicd-fw-debug');
var rulesContainer = document.getElementById('clicd-fw-rules');
var enabledCheckbox = document.getElementById('clicd-fw-enabled');
var statusText = document.getElementById('clicd-fw-status-text');
var deleteModal = document.getElementById('clicd-fw-delete-modal');
var deleteText = document.getElementById('clicd-fw-delete-text');
var deleteCancel = document.getElementById('clicd-fw-delete-cancel');
var deleteConfirm = document.getElementById('clicd-fw-delete-confirm');
var pendingDeleteRule = null;
var currentRules = [];
function showMessage(type, text) {
message.className = 'clicd-fw-message' + (type === 'error' ? ' error' : '');
message.style.display = 'block';
message.textContent = text || '';
}
function showDebug(data) {
debugBox.style.display = 'block';
debugBox.textContent = JSON.stringify(data || {}, null, 2);
}
function endpoint() {
return "{$MODULE_CUSTOM_API}";
}
function setBusy(busy) {
panel.querySelectorAll('button, input, select').forEach(function(el){ el.disabled = !!busy; });
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function portDisplay(port) {
return port || '所有';
}
function sourceIpDisplay(ip) {
return ip || '任意';
}
function directionLabel(dir) {
return dir === 'in' ? '入站' : '出站';
}
function actionLabel(action) {
return action === 'ACCEPT' ? '放行' : '拒绝';
}
function renderRules(rules) {
currentRules = Array.isArray(rules) ? rules : [];
if (currentRules.length === 0) {
rulesContainer.innerHTML = '<div class="clicd-fw-section clicd-fw-muted">暂无防火墙规则</div>';
return;
}
rulesContainer.innerHTML = currentRules.map(function(rule, idx) {
var dir = (rule.direction || 'in').toLowerCase();
var proto = (rule.protocol || 'tcp').toLowerCase();
var action = (rule.action || 'ACCEPT').toUpperCase();
var port = escapeHtml(portDisplay(rule.port));
var srcIp = escapeHtml(sourceIpDisplay(rule.source_ip));
var desc = escapeHtml(rule.description || '');
var ruleId = escapeHtml(rule.id || '');
var enabled = rule.enabled !== false;
var enabledChecked = enabled ? 'checked' : '';
var dirClass = dir === 'in' ? 'clicd-fw-direction-in' : 'clicd-fw-direction-out';
var actionClass = 'clicd-fw-action-' + action;
return '<div class="clicd-fw-rule" data-rule-id="' + ruleId + '" data-rule-index="' + idx + '">' +
'<div class="clicd-fw-rule-header">' +
'<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">' +
'<span class="clicd-fw-rule-direction ' + dirClass + '">' + (dir === 'in' ? '&#8593; 入站' : '&#8595; 出站') + '</span>' +
'<span class="clicd-fw-rule-action ' + actionClass + '">' + actionLabel(action) + '</span>' +
'<span style="font-size:13px;color:#6b7280">' + proto.toUpperCase() + '</span>' +
'<span style="font-size:13px;color:#374151">' +
(port !== '所有' ? '端口: ' + port : '') +
(srcIp !== '任意' && port !== '所有' ? ' | ' : '') +
(srcIp !== '任意' ? '来源: ' + srcIp : '') +
'</span>' +
'</div>' +
'<div style="display:flex;align-items:center;gap:6px">' +
'<label class="clicd-fw-toggle" style="width:36px;height:20px">' +
'<input type="checkbox" class="clicd-fw-rule-enabled" ' + enabledChecked + '>' +
'<span class="clicd-fw-toggle-slider" style="border-radius:20px"></span>' +
'</label>' +
'</div>' +
'</div>' +
'<div class="clicd-fw-rule-desc">' +
'<span style="font-size:13px;color:#374151;flex:1">' + (desc || '<span style="color:#9ca3af">无说明</span>') + '</span>' +
'</div>' +
'<div class="clicd-fw-rule-edit-row">' +
'<div class="clicd-fw-field"><label>方向</label><select class="clicd-fw-select clicd-fw-edit-field" data-field="direction">' +
'<option value="in"' + (dir === 'in' ? ' selected' : '') + '>入站</option>' +
'<option value="out"' + (dir === 'out' ? ' selected' : '') + '>出站</option>' +
'</select></div>' +
'<div class="clicd-fw-field"><label>协议</label><select class="clicd-fw-select clicd-fw-edit-field" data-field="protocol">' +
'<option value="tcp"' + (proto === 'tcp' ? ' selected' : '') + '>TCP</option>' +
'<option value="udp"' + (proto === 'udp' ? ' selected' : '') + '>UDP</option>' +
'</select></div>' +
'<div class="clicd-fw-field"><label>端口</label><input class="clicd-fw-input clicd-fw-edit-field" data-field="port" type="text" value="' + escapeHtml(rule.port || '') + '"></div>' +
'<div class="clicd-fw-field"><label>来源 IP</label><input class="clicd-fw-input clicd-fw-edit-field" data-field="source_ip" type="text" value="' + escapeHtml(rule.source_ip || '') + '"></div>' +
'<div class="clicd-fw-field"><label>动作</label><select class="clicd-fw-select clicd-fw-edit-field" data-field="action">' +
'<option value="ACCEPT"' + (action === 'ACCEPT' ? ' selected' : '') + '>放行</option>' +
'<option value="DROP"' + (action === 'DROP' ? ' selected' : '') + '>拒绝</option>' +
'</select></div>' +
'<div class="clicd-fw-field"><label>说明</label><input class="clicd-fw-input clicd-fw-edit-field" data-field="description" type="text" value="' + desc + '"></div>' +
'<div class="clicd-fw-actions" style="align-items:end">' +
'<button class="clicd-fw-btn clicd-fw-btn-secondary clicd-fw-btn-sm" type="button" data-clicd-fw-action="update-rule">保存</button>' +
'<button class="clicd-fw-btn clicd-fw-btn-danger clicd-fw-btn-sm" type="button" data-clicd-fw-action="delete-rule">删除</button>' +
'</div>' +
'</div>' +
'</div>';
}).join('');
// Bind toggle events for rule enabled/disabled
rulesContainer.querySelectorAll('.clicd-fw-rule-enabled').forEach(function(toggle, idx) {
toggle.addEventListener('change', function() {
var rule = currentRules[idx];
if (!rule) return;
rule.enabled = toggle.checked;
saveFirewall();
});
});
}
function getRuleFromItem(item) {
var idx = parseInt(item.getAttribute('data-rule-index'), 10);
if (isNaN(idx) || !currentRules[idx]) return null;
return { index: idx, rule: currentRules[idx] };
}
function getEditField(item, name) {
return item.querySelector('[data-field="' + name + '"]');
}
function updateRuleFromFields(item, idx) {
currentRules[idx].direction = getEditField(item, 'direction') ? getEditField(item, 'direction').value : 'in';
currentRules[idx].protocol = getEditField(item, 'protocol') ? getEditField(item, 'protocol').value : 'tcp';
currentRules[idx].port = getEditField(item, 'port') ? getEditField(item, 'port').value : '';
currentRules[idx].source_ip = getEditField(item, 'source_ip') ? getEditField(item, 'source_ip').value : '';
currentRules[idx].action = getEditField(item, 'action') ? getEditField(item, 'action').value : 'ACCEPT';
currentRules[idx].description = getEditField(item, 'description') ? getEditField(item, 'description').value : '';
}
function getAddRulePayload() {
return {
direction: document.getElementById('clicd-fw-add-direction').value,
protocol: document.getElementById('clicd-fw-add-protocol').value,
port: document.getElementById('clicd-fw-add-port').value,
source_ip: document.getElementById('clicd-fw-add-source-ip').value,
action: document.getElementById('clicd-fw-add-action').value,
description: document.getElementById('clicd-fw-add-desc').value,
enabled: true
};
}
function clearAddForm() {
document.getElementById('clicd-fw-add-port').value = '';
document.getElementById('clicd-fw-add-source-ip').value = '';
document.getElementById('clicd-fw-add-action').value = 'ACCEPT';
document.getElementById('clicd-fw-add-desc').value = '';
}
function saveFirewall() {
var enabled = enabledCheckbox.checked;
var rules = currentRules.map(function(r) {
return {
id: r.id || '',
direction: r.direction || 'in',
protocol: r.protocol || 'tcp',
port: r.port || '',
source_ip: r.source_ip || '',
action: r.action || 'ACCEPT',
description: r.description || '',
enabled: r.enabled !== false
};
});
setBusy(true);
var body = new URLSearchParams();
body.set('id', panel.getAttribute('data-service-id') || '');
body.set('func', 'firewallUpdate');
body.set('enabled', enabled ? 'true' : 'false');
body.set('rules', JSON.stringify(rules));
fetch(endpoint(), {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'Authorization': 'JWT {$Think.get.jwt}'
},
credentials: 'same-origin',
body: body.toString()
})
.then(function(res){ return res.text(); })
.then(function(text){
var data;
try { data = JSON.parse(text); } catch(e) { data = {status:'error', msg:'非 JSON 响应: ' + text}; }
showDebug((data.data && data.data.debug) || data.debug || data);
if (data.status === 200 || data.status === 'success') {
showMessage('success', data.msg || '防火墙设置已更新');
if (data.data && data.data.rules) {
currentRules = data.data.rules;
renderRules(currentRules);
}
updateStatusText();
} else {
showMessage('error', data.msg || '更新失败');
}
})
.catch(function(e){
showMessage('error', e.message || '请求失败');
showDebug({error: String(e)});
})
.finally(function(){
setBusy(false);
});
}
function loadFirewall() {
setBusy(true);
var body = new URLSearchParams();
body.set('id', panel.getAttribute('data-service-id') || '');
body.set('func', 'firewallList');
fetch(endpoint(), {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'Authorization': 'JWT {$Think.get.jwt}'
},
credentials: 'same-origin',
body: body.toString()
})
.then(function(res){ return res.text(); })
.then(function(text){
var data;
try { data = JSON.parse(text); } catch(e) { data = {status:'error', msg:'非 JSON 响应: ' + text}; }
showDebug((data.data && data.data.debug) || data.debug || data);
if (data.status === 200 || data.status === 'success') {
if (data.data) {
enabledCheckbox.checked = data.data.enabled === true || data.data.enabled === 'true' || data.data.enabled === 1;
currentRules = Array.isArray(data.data.rules) ? data.data.rules : [];
renderRules(currentRules);
updateStatusText();
}
} else {
showMessage('error', data.msg || '获取防火墙设置失败');
rulesContainer.innerHTML = '<div class="clicd-fw-section clicd-fw-muted">加载失败</div>';
}
})
.catch(function(e){
showMessage('error', e.message || '请求失败');
showDebug({error: String(e)});
rulesContainer.innerHTML = '<div class="clicd-fw-section clicd-fw-muted">加载失败</div>';
})
.finally(function(){
setBusy(false);
});
}
function updateStatusText() {
if (enabledCheckbox.checked) {
statusText.textContent = '已启用 - 默认拒绝所有流量,仅放行规则中定义的流量';
} else {
statusText.textContent = '已禁用 - 所有流量不受限制';
}
}
function openDeleteModal(rule, desc) {
pendingDeleteRule = rule;
if (deleteText) {
deleteText.textContent = '确认删除规则: ' + (desc || '未命名规则') + ' ?';
}
if (deleteModal) {
deleteModal.style.display = 'flex';
}
}
function closeDeleteModal() {
pendingDeleteRule = null;
if (deleteModal) {
deleteModal.style.display = 'none';
}
}
// Event delegation
panel.addEventListener('click', function(event) {
var button = event.target.closest('[data-clicd-fw-action]');
if (!button) return;
var action = button.getAttribute('data-clicd-fw-action');
if (action === 'add-rule') {
var payload = getAddRulePayload();
currentRules.push({
id: '',
direction: payload.direction,
protocol: payload.protocol,
port: payload.port,
source_ip: payload.source_ip,
action: payload.action,
description: payload.description,
enabled: true
});
renderRules(currentRules);
clearAddForm();
saveFirewall();
return;
}
var item = button.closest('.clicd-fw-rule');
if (!item) return;
var idx = parseInt(item.getAttribute('data-rule-index'), 10);
if (isNaN(idx) || !currentRules[idx]) return;
if (action === 'update-rule') {
updateRuleFromFields(item, idx);
saveFirewall();
return;
}
if (action === 'delete-rule') {
var desc = currentRules[idx].description || (currentRules[idx].protocol + '/' + (currentRules[idx].port || 'all'));
openDeleteModal(idx, desc);
return;
}
});
enabledCheckbox.addEventListener('change', function() {
saveFirewall();
});
if (deleteCancel) {
deleteCancel.addEventListener('click', closeDeleteModal);
}
if (deleteModal) {
deleteModal.addEventListener('click', function(event){
if (event.target === deleteModal) closeDeleteModal();
});
}
if (deleteConfirm) {
deleteConfirm.addEventListener('click', function(){
if (pendingDeleteRule === null) return;
var idx = pendingDeleteRule;
closeDeleteModal();
if (idx >= 0 && idx < currentRules.length) {
currentRules.splice(idx, 1);
saveFirewall();
}
});
}
loadFirewall();
})();
</script>
+1 -1
View File
@@ -1,4 +1,4 @@
<style>
<style>
.clicd-info{font-size:14px;color:#1f2937;background:#f6f8fb;padding:14px;border-radius:6px;max-width:100%;overflow:hidden}
.clicd-info *{box-sizing:border-box}
.clicd-head{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px;margin-bottom:12px}
+1 -1
View File
@@ -1,4 +1,4 @@
<style>
<style>
.clicd-nat-panel{font-size:14px;color:#1f2937}
.clicd-nat-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-bottom:16px}
.clicd-nat-card{border:1px solid #e5e7eb;border-radius:6px;padding:12px;background:#fff}