mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-06 22:04:44 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da5eea5193 | |||
| 4de86c458f | |||
| baf213e769 | |||
| 819a79e00d | |||
| 18bee369c1 | |||
| 2463715e32 |
+355
-28
@@ -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,163 @@ 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);
|
||||
$defaultAction = strtoupper(trim((string)clicd_param_value($input, 'default_action', '')));
|
||||
$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,
|
||||
];
|
||||
if (in_array($defaultAction, ['ACCEPT', 'DROP'], true)) {
|
||||
$payload['default_action'] = $defaultAction;
|
||||
}
|
||||
|
||||
$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);
|
||||
$defaultAction = strtoupper(trim((string)clicd_param_value($input, 'default_action', '')));
|
||||
$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,
|
||||
];
|
||||
if (in_array($defaultAction, ['ACCEPT', 'DROP'], true)) {
|
||||
$payload['default_action'] = $defaultAction;
|
||||
}
|
||||
|
||||
$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 +1593,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 +1607,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 +1625,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 +1635,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 +1665,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 +1689,7 @@ function clicd_ClientAreaOutput($params, $key)
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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'] : '');
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
<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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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 dirRaw = String(rule.direction || 'in').toLowerCase();
|
||||
var protoRaw = String(rule.protocol || 'tcp').toLowerCase();
|
||||
var actionRaw = String(rule.action || 'ACCEPT').toUpperCase();
|
||||
var dir = (dirRaw === 'in' || dirRaw === 'out') ? dirRaw : 'in';
|
||||
var proto = (protoRaw === 'tcp' || protoRaw === 'udp' || protoRaw === 'icmp' || protoRaw === 'all') ? protoRaw : 'tcp';
|
||||
var action = (actionRaw === 'ACCEPT' || actionRaw === 'DROP' || actionRaw === 'REJECT') ? actionRaw : 'ACCEPT';
|
||||
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' ? '↑ 入站' : '↓ 出站') + '</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,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,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}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -29,8 +30,9 @@ func getFirewall(w http.ResponseWriter, r *http.Request, id int) {
|
||||
jsonResponse(w, http.StatusOK, APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]interface{}{
|
||||
"enabled": c.FirewallEnabled,
|
||||
"rules": c.FirewallRules,
|
||||
"enabled": c.FirewallEnabled,
|
||||
"default_action": normalizeFirewallDefaultAction(c.FirewallDefaultAction),
|
||||
"rules": c.FirewallRules,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -43,17 +45,32 @@ func updateFirewall(w http.ResponseWriter, r *http.Request, id int) {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
Rules *[]config.FirewallRule `json:"rules"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
DefaultAction *string `json:"default_action"`
|
||||
Rules *[]config.FirewallRule `json:"rules"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
oldEnabled := c.FirewallEnabled
|
||||
oldDefaultAction := c.FirewallDefaultAction
|
||||
oldRules := append([]config.FirewallRule(nil), c.FirewallRules...)
|
||||
|
||||
if req.Enabled != nil {
|
||||
c.FirewallEnabled = *req.Enabled
|
||||
}
|
||||
if req.DefaultAction != nil {
|
||||
action := normalizeFirewallDefaultAction(*req.DefaultAction)
|
||||
if action == "" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid default action"})
|
||||
return
|
||||
}
|
||||
c.FirewallDefaultAction = action
|
||||
} else if strings.TrimSpace(c.FirewallDefaultAction) == "" {
|
||||
c.FirewallDefaultAction = "DROP"
|
||||
}
|
||||
if req.Rules != nil {
|
||||
// Validate and assign IDs to new rules
|
||||
rules := *req.Rules
|
||||
@@ -61,9 +78,14 @@ func updateFirewall(w http.ResponseWriter, r *http.Request, id int) {
|
||||
rules[i].Direction = strings.ToLower(strings.TrimSpace(rules[i].Direction))
|
||||
rules[i].Protocol = strings.ToLower(strings.TrimSpace(rules[i].Protocol))
|
||||
rules[i].Action = strings.ToUpper(strings.TrimSpace(rules[i].Action))
|
||||
rules[i].Network = normalizeFirewallNetwork(rules[i].Network)
|
||||
rules[i].SourceIP = strings.TrimSpace(rules[i].SourceIP)
|
||||
rules[i].Port = strings.TrimSpace(rules[i].Port)
|
||||
|
||||
if rules[i].Network == "" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid network"})
|
||||
return
|
||||
}
|
||||
if rules[i].Direction != "in" && rules[i].Direction != "out" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid direction: " + rules[i].Direction})
|
||||
return
|
||||
@@ -76,11 +98,21 @@ func updateFirewall(w http.ResponseWriter, r *http.Request, id int) {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid action: " + rules[i].Action})
|
||||
return
|
||||
}
|
||||
if rules[i].ID == "" {
|
||||
if rules[i].SourceIP != "" {
|
||||
if err := validateFirewallIPSpec(rules[i].SourceIP, rules[i].Network); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid IP: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
if rules[i].ID == "" || strings.HasPrefix(rules[i].ID, "tmp-") {
|
||||
rules[i].ID = generateFirewallRuleID()
|
||||
}
|
||||
// Validate port spec
|
||||
if rules[i].Port != "" {
|
||||
if rules[i].Protocol != "tcp" && rules[i].Protocol != "udp" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Ports are only supported for TCP and UDP rules"})
|
||||
return
|
||||
}
|
||||
if err := validatePortSpec(rules[i].Port); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid port: " + err.Error()})
|
||||
return
|
||||
@@ -90,11 +122,14 @@ func updateFirewall(w http.ResponseWriter, r *http.Request, id int) {
|
||||
c.FirewallRules = rules
|
||||
}
|
||||
|
||||
config.SaveConfig()
|
||||
|
||||
// Apply firewall rules to iptables if container is running
|
||||
if c.Status == "running" {
|
||||
if err := lxc.ApplyFirewallRules(id); err != nil {
|
||||
c.FirewallEnabled = oldEnabled
|
||||
c.FirewallDefaultAction = oldDefaultAction
|
||||
c.FirewallRules = oldRules
|
||||
_ = lxc.ApplyFirewallRules(id)
|
||||
config.SaveConfig()
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to apply firewall rules: " + err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -102,28 +137,54 @@ func updateFirewall(w http.ResponseWriter, r *http.Request, id int) {
|
||||
// If disabled and not running, clean any lingering rules
|
||||
lxc.CleanFirewallRules(id)
|
||||
}
|
||||
config.SaveConfig()
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{
|
||||
Success: true,
|
||||
Message: "Firewall updated",
|
||||
Data: map[string]interface{}{
|
||||
"enabled": c.FirewallEnabled,
|
||||
"rules": c.FirewallRules,
|
||||
"enabled": c.FirewallEnabled,
|
||||
"default_action": normalizeFirewallDefaultAction(c.FirewallDefaultAction),
|
||||
"rules": c.FirewallRules,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeFirewallDefaultAction(action string) string {
|
||||
action = strings.ToUpper(strings.TrimSpace(action))
|
||||
if action == "ACCEPT" || action == "DROP" {
|
||||
return action
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeFirewallNetwork(network string) string {
|
||||
network = strings.ToLower(strings.TrimSpace(network))
|
||||
switch network {
|
||||
case "", "ipv4", "nat4":
|
||||
return "ipv4"
|
||||
case "ipv6":
|
||||
return "ipv6"
|
||||
case "all", "both":
|
||||
return "all"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func validatePortSpec(port string) error {
|
||||
port = strings.TrimSpace(port)
|
||||
if port == "" {
|
||||
return nil
|
||||
}
|
||||
// Support: "22", "80,443", "8000-9000", "80,443,8000-9000"
|
||||
partCount := 0
|
||||
for _, part := range strings.Split(port, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
return &portValidationError{port}
|
||||
}
|
||||
partCount++
|
||||
if strings.Contains(part, "-") {
|
||||
// Range
|
||||
bounds := strings.SplitN(part, "-", 2)
|
||||
@@ -135,6 +196,9 @@ func validatePortSpec(port string) error {
|
||||
if err != nil || hi < 1 || hi > 65535 {
|
||||
return &portValidationError{part}
|
||||
}
|
||||
if hi < lo {
|
||||
return &portValidationError{part}
|
||||
}
|
||||
} else {
|
||||
p, err := strconv.Atoi(part)
|
||||
if err != nil || p < 1 || p > 65535 {
|
||||
@@ -142,9 +206,48 @@ func validatePortSpec(port string) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
if partCount > 15 {
|
||||
return &portValidationError{"too many ports; maximum 15 items per rule"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateFirewallIPSpec(value string, network string) error {
|
||||
var addr netip.Addr
|
||||
if strings.Contains(value, "/") {
|
||||
prefix, err := netip.ParsePrefix(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
addr = prefix.Addr()
|
||||
} else {
|
||||
parsed, err := netip.ParseAddr(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
addr = parsed
|
||||
}
|
||||
switch network {
|
||||
case "ipv4":
|
||||
if !addr.Is4() {
|
||||
return &ipValidationError{"IPv4 rule requires an IPv4 address or CIDR: " + value}
|
||||
}
|
||||
case "ipv6":
|
||||
if !addr.Is6() || addr.Is4In6() {
|
||||
return &ipValidationError{"IPv6 rule requires an IPv6 address or CIDR: " + value}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ipValidationError struct {
|
||||
value string
|
||||
}
|
||||
|
||||
func (e *ipValidationError) Error() string {
|
||||
return e.value
|
||||
}
|
||||
|
||||
type portValidationError struct {
|
||||
port string
|
||||
}
|
||||
|
||||
@@ -24,11 +24,12 @@ type PortMapping struct {
|
||||
|
||||
type FirewallRule struct {
|
||||
ID string `json:"id"`
|
||||
Direction string `json:"direction"` // "in" or "out"
|
||||
Protocol string `json:"protocol"` // "tcp", "udp", "icmp", "all"
|
||||
Port string `json:"port"` // "" = all, "22", "80,443", "8000-9000"
|
||||
SourceIP string `json:"source_ip"` // "" = any
|
||||
Action string `json:"action"` // "ACCEPT" or "DROP"
|
||||
Network string `json:"network,omitempty"` // "ipv4", "ipv6", or "all"; empty defaults to "ipv4"
|
||||
Direction string `json:"direction"` // "in" or "out"
|
||||
Protocol string `json:"protocol"` // "tcp", "udp", "icmp", "all"
|
||||
Port string `json:"port"` // "" = all, "22", "80,443", "8000-9000"
|
||||
SourceIP string `json:"source_ip"` // "" = any
|
||||
Action string `json:"action"` // "ACCEPT" or "DROP"
|
||||
Description string `json:"description"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
@@ -136,7 +137,8 @@ type Container struct {
|
||||
PortMappings []PortMapping `json:"port_mappings"`
|
||||
PortMappingLimit int `json:"port_mapping_limit"`
|
||||
FirewallEnabled bool `json:"firewall_enabled"`
|
||||
FirewallRules []FirewallRule `json:"firewall_rules"`
|
||||
FirewallDefaultAction string `json:"firewall_default_action"`
|
||||
FirewallRules []FirewallRule `json:"firewall_rules"`
|
||||
SnapshotLimit int `json:"snapshot_limit"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
|
||||
@@ -365,6 +365,7 @@ func ensureSchemaMigrations() error {
|
||||
{"container_public_ipv4s", "prefix_len", "INTEGER"},
|
||||
{"container_public_ipv4s", "gateway", "TEXT"},
|
||||
{"containers", "firewall_enabled", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"containers", "firewall_default_action", "TEXT NOT NULL DEFAULT 'DROP'"},
|
||||
{"containers", "firewall_rules", "TEXT"},
|
||||
} {
|
||||
if err := ensureColumn(column.table, column.name, column.def); err != nil {
|
||||
@@ -586,8 +587,8 @@ func saveContainers(tx *sql.Tx) error {
|
||||
snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time,
|
||||
snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by,
|
||||
policy_blocked, policy_blocked_reason, policy_blocked_at,
|
||||
firewall_enabled, firewall_rules
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
firewall_enabled, firewall_default_action, firewall_rules
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
c.ID, c.UUID, c.Name, c.Virtualization, c.LXCName, c.KVMName, c.DiskImage, c.MACAddress, c.Template,
|
||||
c.VCPU, c.RAMMB, c.DiskGB, c.NetworkBWMbps, c.MonthlyTrafficGB, c.TrafficMode, c.TrafficInGB,
|
||||
c.TrafficOutGB, c.TrafficUsedRX, c.TrafficUsedTX, c.TrafficResetDate, c.IOSpeedMBps,
|
||||
@@ -596,7 +597,7 @@ func saveContainers(tx *sql.Tx) error {
|
||||
boolInt(c.SnapshotScheduleEnabled), c.SnapshotScheduleIntervalHours, c.SnapshotScheduleTime,
|
||||
c.SnapshotScheduleLastRun, c.SnapshotScheduleNextRun, c.SnapshotScheduleCreatedBy,
|
||||
boolInt(c.PolicyBlocked), c.PolicyBlockedReason, c.PolicyBlockedAt,
|
||||
boolInt(c.FirewallEnabled), marshalFirewallRules(c.FirewallRules),
|
||||
boolInt(c.FirewallEnabled), normalizeFirewallDefaultAction(c.FirewallDefaultAction), marshalFirewallRules(c.FirewallRules),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -794,7 +795,7 @@ func loadContainers() ([]Container, error) {
|
||||
snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time,
|
||||
snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by,
|
||||
policy_blocked, policy_blocked_reason, policy_blocked_at,
|
||||
firewall_enabled, firewall_rules
|
||||
firewall_enabled, firewall_default_action, firewall_rules
|
||||
FROM containers ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -805,6 +806,7 @@ func loadContainers() ([]Container, error) {
|
||||
for rows.Next() {
|
||||
var c Container
|
||||
var scheduleEnabled, policyBlocked, firewallEnabled int
|
||||
var firewallDefaultAction string
|
||||
var firewallRulesJSON sql.NullString
|
||||
if err := rows.Scan(
|
||||
&c.ID, &c.UUID, &c.Name, &c.Virtualization, &c.LXCName, &c.KVMName, &c.DiskImage, &c.MACAddress, &c.Template,
|
||||
@@ -815,13 +817,14 @@ func loadContainers() ([]Container, error) {
|
||||
&scheduleEnabled, &c.SnapshotScheduleIntervalHours, &c.SnapshotScheduleTime,
|
||||
&c.SnapshotScheduleLastRun, &c.SnapshotScheduleNextRun, &c.SnapshotScheduleCreatedBy,
|
||||
&policyBlocked, &c.PolicyBlockedReason, &c.PolicyBlockedAt,
|
||||
&firewallEnabled, &firewallRulesJSON,
|
||||
&firewallEnabled, &firewallDefaultAction, &firewallRulesJSON,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.SnapshotScheduleEnabled = scheduleEnabled != 0
|
||||
c.PolicyBlocked = policyBlocked != 0
|
||||
c.FirewallEnabled = firewallEnabled != 0
|
||||
c.FirewallDefaultAction = normalizeFirewallDefaultAction(firewallDefaultAction)
|
||||
if firewallRulesJSON.Valid && strings.TrimSpace(firewallRulesJSON.String) != "" {
|
||||
_ = json.Unmarshal([]byte(firewallRulesJSON.String), &c.FirewallRules)
|
||||
}
|
||||
@@ -1189,6 +1192,14 @@ func marshalFirewallRules(rules []FirewallRule) interface{} {
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func normalizeFirewallDefaultAction(action string) string {
|
||||
action = strings.ToUpper(strings.TrimSpace(action))
|
||||
if action == "ACCEPT" {
|
||||
return "ACCEPT"
|
||||
}
|
||||
return "DROP"
|
||||
}
|
||||
|
||||
func boolPtrInt(value *bool) interface{} {
|
||||
if value == nil {
|
||||
return nil
|
||||
|
||||
@@ -3231,6 +3231,11 @@ func (m *Manager) applyIPv6Runtime(c *config.Container) error {
|
||||
}
|
||||
ensureKVMIPv6NAT66(assignment.Address, uplink)
|
||||
}
|
||||
if c.Status == "running" {
|
||||
if err := lxc.ApplyFirewallRules(c.ID); err != nil {
|
||||
fmt.Printf("Warning: failed to re-apply firewall rules after KVM IPv6 setup for %s: %v\n", c.Name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3307,7 +3312,7 @@ func ensureKVMIPv6ForwardRules(ipv6 string, bridge string) {
|
||||
}
|
||||
for _, rule := range rules {
|
||||
check := append([]string{"-C"}, rule...)
|
||||
add := append([]string{"-I"}, append([]string{rule[0], "1"}, rule[1:]...)...)
|
||||
add := append([]string{"-A"}, rule...)
|
||||
if exec.Command("ip6tables", check...).Run() != nil {
|
||||
exec.Command("ip6tables", add...).Run()
|
||||
}
|
||||
|
||||
@@ -1610,6 +1610,9 @@ func (m *Manager) ApplyIPv6(id int) error {
|
||||
ensureIPv6NAT66(assignment.Address, uplink)
|
||||
}
|
||||
}
|
||||
if err := ApplyFirewallRules(c.ID); err != nil {
|
||||
fmt.Printf("Warning: failed to re-apply firewall rules after IPv6 setup for %s: %v\n", c.Name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+251
-49
@@ -67,6 +67,10 @@ func (m *Manager) ApplyPortMappings(id int) error {
|
||||
|
||||
applyIPv4EgressPolicy(c, bridge, subnet, tag)
|
||||
|
||||
if err := ApplyFirewallRules(id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -260,8 +264,8 @@ func EnsureForwardRules(bridge string) {
|
||||
break
|
||||
}
|
||||
}
|
||||
insertArgs := append([]string{"-I", "FORWARD", "1"}, args...)
|
||||
exec.Command("iptables", insertArgs...).Run()
|
||||
appendArgs := append([]string{"-A", "FORWARD"}, args...)
|
||||
exec.Command("iptables", appendArgs...).Run()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -576,6 +580,9 @@ func CleanFirewallRules(id int) {
|
||||
cmd := exec.Command("bash", "-c",
|
||||
fmt.Sprintf("iptables -S FORWARD 2>/dev/null | grep 'clicd-%s-fw-' | sed 's/^-A /-D /' | while read rule; do iptables $rule; done", tag))
|
||||
cmd.CombinedOutput()
|
||||
cmd = exec.Command("bash", "-c",
|
||||
fmt.Sprintf("ip6tables -S FORWARD 2>/dev/null | grep 'clicd-%s-fw-' | sed 's/^-A /-D /' | while read rule; do ip6tables $rule; done", tag))
|
||||
cmd.CombinedOutput()
|
||||
|
||||
// Also remove legacy default policy rules (without specific rule ID)
|
||||
for _, suffix := range []string{"default-in", "default-out"} {
|
||||
@@ -584,6 +591,9 @@ func CleanFirewallRules(id int) {
|
||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-%s-%s", tag, suffix, proto),
|
||||
).CombinedOutput()
|
||||
}
|
||||
exec.Command("ip6tables", "-D", "FORWARD",
|
||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-%s", tag, suffix),
|
||||
).CombinedOutput()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -606,42 +616,140 @@ func ApplyFirewallRules(id int) error {
|
||||
if c.IsKVM() {
|
||||
bridge = "virbr0"
|
||||
}
|
||||
containerIP := c.IP
|
||||
if containerIP == "" {
|
||||
containerIP := strings.TrimSpace(c.IP)
|
||||
containerIPv6s := firewallIPv6Addresses(c)
|
||||
if containerIP == "" && len(containerIPv6s) == 0 {
|
||||
return nil
|
||||
}
|
||||
tag := clicdTag(id)
|
||||
|
||||
// Apply default DROP policy first (inserted at position 1).
|
||||
// Then insert ACCEPT rules (also at position 1), which pushes the DROPs down.
|
||||
// Final order: ACCEPT rules on top, DROP defaults below, bridge ACCEPT rules at the bottom.
|
||||
applyDefaultFirewallPolicy(tag, bridge, containerIP)
|
||||
defaultAction := normalizeFirewallDefaultAction(c.FirewallDefaultAction)
|
||||
if defaultAction == "DROP" {
|
||||
if containerIP != "" {
|
||||
if err := applyDefaultFirewallPolicy(tag, bridge, containerIP); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := applyDefaultFirewallIPv6Policy(tag, bridge, containerIPv6s); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, rule := range c.FirewallRules {
|
||||
for i := len(c.FirewallRules) - 1; i >= 0; i-- {
|
||||
rule := c.FirewallRules[i]
|
||||
if !rule.Enabled {
|
||||
continue
|
||||
}
|
||||
if err := applyOneFirewallRule(tag, bridge, containerIP, rule); err != nil {
|
||||
fmt.Printf("Warning: failed to apply firewall rule %s for container %d: %v\n", rule.ID, id, err)
|
||||
if containerIP != "" && firewallRuleAppliesToFamily(rule, true) {
|
||||
if err := applyOneFirewallRule(tag, bridge, containerIP, rule); err != nil {
|
||||
return fmt.Errorf("failed to apply firewall rule %s for container %d: %w", rule.ID, id, err)
|
||||
}
|
||||
}
|
||||
if len(containerIPv6s) > 0 && firewallRuleAppliesToFamily(rule, false) {
|
||||
if err := applyOneFirewallIPv6Rule(tag, bridge, containerIPv6s, rule); err != nil {
|
||||
return fmt.Errorf("failed to apply IPv6 firewall rule %s for container %d: %w", rule.ID, id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeFirewallDefaultAction(action string) string {
|
||||
action = strings.ToUpper(strings.TrimSpace(action))
|
||||
if action == "ACCEPT" {
|
||||
return "ACCEPT"
|
||||
}
|
||||
return "DROP"
|
||||
}
|
||||
|
||||
func normalizeFirewallNetwork(network string) string {
|
||||
network = strings.ToLower(strings.TrimSpace(network))
|
||||
switch network {
|
||||
case "", "ipv4", "nat4":
|
||||
return "ipv4"
|
||||
case "ipv6":
|
||||
return "ipv6"
|
||||
case "all", "both":
|
||||
return "all"
|
||||
default:
|
||||
return "ipv4"
|
||||
}
|
||||
}
|
||||
|
||||
func firewallRuleAppliesToFamily(rule config.FirewallRule, ipv4 bool) bool {
|
||||
network := normalizeFirewallNetwork(rule.Network)
|
||||
if network == "ipv4" {
|
||||
return ipv4
|
||||
}
|
||||
if network == "ipv6" {
|
||||
return !ipv4
|
||||
}
|
||||
if rule.SourceIP == "" {
|
||||
return true
|
||||
}
|
||||
addr := firewallIPSpecAddr(rule.SourceIP)
|
||||
if !addr.IsValid() {
|
||||
return true
|
||||
}
|
||||
if ipv4 {
|
||||
return addr.Is4()
|
||||
}
|
||||
return addr.Is6() && !addr.Is4In6()
|
||||
}
|
||||
|
||||
func firewallIPSpecAddr(value string) netip.Addr {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return netip.Addr{}
|
||||
}
|
||||
if strings.Contains(value, "/") {
|
||||
prefix, err := netip.ParsePrefix(value)
|
||||
if err != nil {
|
||||
return netip.Addr{}
|
||||
}
|
||||
return prefix.Addr()
|
||||
}
|
||||
addr, err := netip.ParseAddr(value)
|
||||
if err != nil {
|
||||
return netip.Addr{}
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
func firewallIPv6Addresses(c *config.Container) []string {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
c.NormalizeNetworkAssignments()
|
||||
seen := map[string]bool{}
|
||||
result := []string{}
|
||||
for _, assignment := range c.IPv6Addresses {
|
||||
ip := strings.TrimSpace(assignment.Address)
|
||||
if ip == "" || seen[ip] {
|
||||
continue
|
||||
}
|
||||
if addr, err := netip.ParseAddr(ip); err == nil && addr.Is6() && !addr.Is4In6() {
|
||||
seen[ip] = true
|
||||
result = append(result, ip)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func applyOneFirewallRule(tag, bridge, containerIP string, rule config.FirewallRule) error {
|
||||
commentTag := fmt.Sprintf("clicd-%s-fw-%s", tag, rule.ID)
|
||||
|
||||
// Build base iptables args
|
||||
args := []string{"-I", "FORWARD", "1"}
|
||||
|
||||
// Direction: in = traffic arriving at container (-i bridge -d containerIP)
|
||||
// out = traffic leaving container (-o bridge -s containerIP)
|
||||
// Direction: in = traffic arriving at container (-o bridge -d containerIP)
|
||||
// out = traffic leaving container (-i bridge -s containerIP)
|
||||
switch rule.Direction {
|
||||
case "in":
|
||||
args = append(args, "-i", bridge, "-d", containerIP+"/32")
|
||||
args = append(args, "-o", bridge, "-d", containerIP+"/32")
|
||||
case "out":
|
||||
args = append(args, "-o", bridge, "-s", containerIP+"/32")
|
||||
args = append(args, "-i", bridge, "-s", containerIP+"/32")
|
||||
default:
|
||||
return fmt.Errorf("invalid direction: %s", rule.Direction)
|
||||
}
|
||||
@@ -662,7 +770,7 @@ func applyOneFirewallRule(tag, bridge, containerIP string, rule config.FirewallR
|
||||
if rule.Port != "" && (rule.Protocol == "tcp" || rule.Protocol == "udp") {
|
||||
// For "in" direction, traffic going TO the container uses --dport
|
||||
// For "out" direction, traffic going FROM the container uses --dport (destination port on remote)
|
||||
args = append(args, "--dport", normalizePortSpec(rule.Port))
|
||||
args = append(args, firewallPortArgs(rule.Port)...)
|
||||
}
|
||||
|
||||
// Source IP filter (for "out" direction, this matches the remote source; for "in", it matches the sender)
|
||||
@@ -693,51 +801,145 @@ func applyOneFirewallRule(tag, bridge, containerIP string, rule config.FirewallR
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyOneFirewallIPv6Rule(tag, bridge string, containerIPs []string, rule config.FirewallRule) error {
|
||||
for _, containerIP := range containerIPs {
|
||||
commentTag := fmt.Sprintf("clicd-%s-fw-%s-v6-%s", tag, rule.ID, firewallCommentIPTag(containerIP))
|
||||
args := []string{"-I", "FORWARD", "1"}
|
||||
|
||||
switch rule.Direction {
|
||||
case "in":
|
||||
args = append(args, "-o", bridge, "-d", containerIP+"/128")
|
||||
case "out":
|
||||
args = append(args, "-i", bridge, "-s", containerIP+"/128")
|
||||
default:
|
||||
return fmt.Errorf("invalid direction: %s", rule.Direction)
|
||||
}
|
||||
|
||||
switch rule.Protocol {
|
||||
case "tcp", "udp":
|
||||
args = append(args, "-p", rule.Protocol)
|
||||
case "icmp":
|
||||
args = append(args, "-p", "ipv6-icmp")
|
||||
case "all":
|
||||
default:
|
||||
return fmt.Errorf("invalid protocol: %s", rule.Protocol)
|
||||
}
|
||||
|
||||
if rule.Port != "" && (rule.Protocol == "tcp" || rule.Protocol == "udp") {
|
||||
args = append(args, firewallPortArgs(rule.Port)...)
|
||||
}
|
||||
|
||||
if rule.SourceIP != "" {
|
||||
switch rule.Direction {
|
||||
case "in":
|
||||
args = append(args, "-s", rule.SourceIP)
|
||||
case "out":
|
||||
args = append(args, "-d", rule.SourceIP)
|
||||
}
|
||||
}
|
||||
|
||||
action := "DROP"
|
||||
if rule.Action == "ACCEPT" {
|
||||
action = "ACCEPT"
|
||||
}
|
||||
args = append(args, "-j", action)
|
||||
args = append(args, "-m", "comment", "--comment", commentTag)
|
||||
|
||||
cmd := exec.Command("ip6tables", args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ip6tables error: %s", string(output))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firewallPortArgs(port string) []string {
|
||||
spec := normalizePortSpec(port)
|
||||
if strings.Contains(spec, ",") {
|
||||
return []string{"-m", "multiport", "--dports", spec}
|
||||
}
|
||||
return []string{"--dport", spec}
|
||||
}
|
||||
|
||||
// normalizePortSpec converts user port input to iptables-compatible port spec.
|
||||
// "80,443" -> "80,443", "8000-9000" -> "8000:9000", "22" -> "22"
|
||||
// "80,443" -> "80,443", "8000-9000" -> "8000:9000", "80,443,8000-9000" -> "80,443,8000:9000"
|
||||
func normalizePortSpec(port string) string {
|
||||
port = strings.TrimSpace(port)
|
||||
if port == "" {
|
||||
return ""
|
||||
}
|
||||
// Convert comma-separated to iptables format (already valid)
|
||||
// Convert dash range to colon range: "8000-9000" -> "8000:9000"
|
||||
if strings.Contains(port, "-") && !strings.Contains(port, ":") {
|
||||
parts := strings.SplitN(port, "-", 2)
|
||||
if len(parts) == 2 {
|
||||
return strings.TrimSpace(parts[0]) + ":" + strings.TrimSpace(parts[1])
|
||||
parts := strings.Split(port, ",")
|
||||
for i, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if strings.Contains(part, "-") && !strings.Contains(part, ":") {
|
||||
bounds := strings.SplitN(part, "-", 2)
|
||||
if len(bounds) == 2 {
|
||||
part = strings.TrimSpace(bounds[0]) + ":" + strings.TrimSpace(bounds[1])
|
||||
}
|
||||
}
|
||||
parts[i] = part
|
||||
}
|
||||
return port
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func applyDefaultFirewallPolicy(tag, bridge, containerIP string) {
|
||||
// Default DROP: inserted at position 1 so they sit above bridge ACCEPT rules.
|
||||
// The user-defined ACCEPT rules (also at position 1) were inserted first,
|
||||
// so they end up above these DROP defaults after the position-1 insertions.
|
||||
for _, proto := range []string{"tcp", "udp"} {
|
||||
args := []string{
|
||||
"-I", "FORWARD", "1",
|
||||
"-i", bridge,
|
||||
"-d", containerIP + "/32",
|
||||
"-p", proto,
|
||||
"-j", "DROP",
|
||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-default-in-%s", tag, proto),
|
||||
}
|
||||
cmd := exec.Command("iptables", args...)
|
||||
cmd.CombinedOutput()
|
||||
}
|
||||
|
||||
for _, proto := range []string{"tcp", "udp"} {
|
||||
args := []string{
|
||||
func applyDefaultFirewallPolicy(tag, bridge, containerIP string) error {
|
||||
defaults := [][]string{
|
||||
{
|
||||
"-I", "FORWARD", "1",
|
||||
"-o", bridge,
|
||||
"-s", containerIP + "/32",
|
||||
"-p", proto,
|
||||
"-d", containerIP + "/32",
|
||||
"-j", "DROP",
|
||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-default-out-%s", tag, proto),
|
||||
}
|
||||
cmd := exec.Command("iptables", args...)
|
||||
cmd.CombinedOutput()
|
||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-default-in", tag),
|
||||
},
|
||||
{
|
||||
"-I", "FORWARD", "1",
|
||||
"-i", bridge,
|
||||
"-s", containerIP + "/32",
|
||||
"-j", "DROP",
|
||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-default-out", tag),
|
||||
},
|
||||
}
|
||||
for _, args := range defaults {
|
||||
cmd := exec.Command("iptables", args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("iptables default firewall error: %s", string(output))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyDefaultFirewallIPv6Policy(tag, bridge string, containerIPs []string) error {
|
||||
for _, containerIP := range containerIPs {
|
||||
defaults := [][]string{
|
||||
{
|
||||
"-I", "FORWARD", "1",
|
||||
"-o", bridge,
|
||||
"-d", containerIP + "/128",
|
||||
"-j", "DROP",
|
||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-default-in-v6-%s", tag, firewallCommentIPTag(containerIP)),
|
||||
},
|
||||
{
|
||||
"-I", "FORWARD", "1",
|
||||
"-i", bridge,
|
||||
"-s", containerIP + "/128",
|
||||
"-j", "DROP",
|
||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-default-out-v6-%s", tag, firewallCommentIPTag(containerIP)),
|
||||
},
|
||||
}
|
||||
for _, args := range defaults {
|
||||
cmd := exec.Command("ip6tables", args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ip6tables default firewall error: %s", string(output))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firewallCommentIPTag(ip string) string {
|
||||
replacer := strings.NewReplacer(":", "_", ".", "_", "/", "_")
|
||||
return replacer.Replace(ip)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package version
|
||||
|
||||
var (
|
||||
Version = "1.1.17"
|
||||
Version = "1.1.18"
|
||||
Repo = "MengMengCode/CLICD"
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "clicd-frontend",
|
||||
"private": true,
|
||||
"version": "1.1.17",
|
||||
"version": "1.1.18",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -821,10 +821,11 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
|
||||
'POST /api/v1/security/check': { container_name: 'example-vm' },
|
||||
'PUT /api/v1/containers/{id}/firewall': {
|
||||
enabled: true,
|
||||
default_action: 'DROP',
|
||||
rules: [
|
||||
{ id: '', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH', enabled: true },
|
||||
{ id: '', direction: 'in', protocol: 'tcp', port: '80,443', source_ip: '', action: 'ACCEPT', description: 'Allow HTTP/HTTPS', enabled: true },
|
||||
{ id: '', direction: 'out', protocol: 'tcp', port: '', source_ip: '', action: 'ACCEPT', description: 'Allow all outbound TCP', enabled: true },
|
||||
{ id: '', network: 'ipv4', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH over IPv4/NAT4', enabled: true },
|
||||
{ id: '', network: 'ipv6', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH over IPv6', enabled: true },
|
||||
{ id: '', network: 'all', direction: 'out', protocol: 'tcp', port: '', source_ip: '', action: 'ACCEPT', description: 'Allow outbound TCP', enabled: true },
|
||||
],
|
||||
},
|
||||
'PUT /api/v1/security/settings': { auto_shutdown: false },
|
||||
@@ -1039,10 +1040,11 @@ const responseSamples: Record<string, unknown> = {
|
||||
success: true,
|
||||
data: {
|
||||
enabled: true,
|
||||
default_action: 'DROP',
|
||||
rules: [
|
||||
{ id: 'a1b2c3d4', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH', enabled: true },
|
||||
{ id: 'e5f6g7h8', direction: 'in', protocol: 'tcp', port: '80,443', source_ip: '', action: 'ACCEPT', description: 'Allow HTTP/HTTPS', enabled: true },
|
||||
{ id: 'i9j0k1l2', direction: 'out', protocol: 'tcp', port: '', source_ip: '', action: 'ACCEPT', description: 'Allow all outbound TCP', enabled: true },
|
||||
{ id: 'a1b2c3d4', network: 'ipv4', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH over IPv4/NAT4', enabled: true },
|
||||
{ id: 'e5f6g7h8', network: 'ipv6', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH over IPv6', enabled: true },
|
||||
{ id: 'i9j0k1l2', network: 'all', direction: 'out', protocol: 'tcp', port: '', source_ip: '', action: 'ACCEPT', description: 'Allow outbound TCP', enabled: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -1051,10 +1053,11 @@ const responseSamples: Record<string, unknown> = {
|
||||
message: 'Firewall updated',
|
||||
data: {
|
||||
enabled: true,
|
||||
default_action: 'DROP',
|
||||
rules: [
|
||||
{ id: 'a1b2c3d4', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH', enabled: true },
|
||||
{ id: 'e5f6g7h8', direction: 'in', protocol: 'tcp', port: '80,443', source_ip: '', action: 'ACCEPT', description: 'Allow HTTP/HTTPS', enabled: true },
|
||||
{ id: 'i9j0k1l2', direction: 'out', protocol: 'tcp', port: '', source_ip: '', action: 'ACCEPT', description: 'Allow all outbound TCP', enabled: true },
|
||||
{ id: 'a1b2c3d4', network: 'ipv4', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH over IPv4/NAT4', enabled: true },
|
||||
{ id: 'e5f6g7h8', network: 'ipv6', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH over IPv6', enabled: true },
|
||||
{ id: 'i9j0k1l2', network: 'all', direction: 'out', protocol: 'tcp', port: '', source_ip: '', action: 'ACCEPT', description: 'Allow outbound TCP', enabled: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -1173,7 +1176,7 @@ function endpointNoteFor(key: string) {
|
||||
notes.push('批量创建的单个 containers[] 项支持与 POST /api/v1/containers 相同的网络和 SSH 认证字段。')
|
||||
}
|
||||
if (key === 'PUT /api/v1/containers/{id}/firewall') {
|
||||
notes.push('启用防火墙后默认拒绝所有 TCP/UDP 入站和出站流量,仅放行 rules 中定义的规则。direction: in=入站, out=出站。action: ACCEPT=放行, DROP=拒绝。port 支持单端口(22)、多端口(80,443)、范围(8000-9000)。')
|
||||
notes.push('兼容旧请求:default_action 可不传,不传时保留现有策略;rule.network 可不传,不传按 ipv4 处理。default_action: DROP=未命中规则时拒绝, ACCEPT=未命中规则时放行。network: ipv4=IPv4 NAT/公网 IPv4, ipv6=IPv6, all=同时应用到 IPv4 和 IPv6。NAT 入站规则的 port 填容器内端口,不是宿主机公网端口。')
|
||||
}
|
||||
if (key === 'POST /api/v1/batch-action') {
|
||||
notes.push('action=reinstall 时可追加 template_id、ssh_auth_mode、ssh_password、ssh_public_key;其他 action 会忽略这些重装字段。')
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
HostInfo,
|
||||
TrafficInfo,
|
||||
getEnabledImages,
|
||||
getFirewall,
|
||||
PortMapping,
|
||||
FirewallRule,
|
||||
reinstallContainer,
|
||||
@@ -166,8 +167,10 @@ export default function ContainerDetail() {
|
||||
const [snapshotScheduleDraft, setSnapshotScheduleDraft] = useState({ intervalHours: 24, time: '03:00' })
|
||||
const [showFirewall, setShowFirewall] = useState(false)
|
||||
const [firewallEnabled, setFirewallEnabled] = useState(false)
|
||||
const [firewallDefaultAction, setFirewallDefaultAction] = useState<'ACCEPT' | 'DROP'>('DROP')
|
||||
const [firewallRules, setFirewallRules] = useState<FirewallRule[]>([])
|
||||
const [firewallSaving, setFirewallSaving] = useState(false)
|
||||
const [firewallMessage, setFirewallMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||
const [editingFirewallRule, setEditingFirewallRule] = useState<FirewallRule | null>(null)
|
||||
const [showFirewallEditor, setShowFirewallEditor] = useState(false)
|
||||
|
||||
@@ -417,29 +420,59 @@ export default function ContainerDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
const openFirewall = () => {
|
||||
const syncFirewallState = (enabled: boolean, defaultAction: 'ACCEPT' | 'DROP', rules: FirewallRule[]) => {
|
||||
const nextRules = rules.map(r => ({ ...r }))
|
||||
setFirewallEnabled(enabled)
|
||||
setFirewallDefaultAction(defaultAction)
|
||||
setFirewallRules(nextRules)
|
||||
setContainer(prev => prev ? {
|
||||
...prev,
|
||||
firewall_enabled: enabled,
|
||||
firewall_default_action: defaultAction,
|
||||
firewall_rules: nextRules.map(r => ({ ...r })),
|
||||
} : prev)
|
||||
}
|
||||
|
||||
const openFirewall = async () => {
|
||||
if (!container) return
|
||||
setFirewallEnabled(container.firewall_enabled || false)
|
||||
setFirewallRules(container.firewall_rules ? [...container.firewall_rules.map(r => ({ ...r }))] : [])
|
||||
syncFirewallState(container.firewall_enabled || false, container.firewall_default_action || 'DROP', container.firewall_rules || [])
|
||||
setFirewallMessage(null)
|
||||
setShowFirewall(true)
|
||||
try {
|
||||
const res = await getFirewall(container.id)
|
||||
const data = res.data.data
|
||||
if (data) syncFirewallState(data.enabled, data.default_action || 'DROP', data.rules || [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load firewall:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const saveFirewall = async () => {
|
||||
if (!container) return
|
||||
setFirewallSaving(true)
|
||||
try {
|
||||
await updateFirewall(container.id, { enabled: firewallEnabled, rules: firewallRules })
|
||||
const res = await updateFirewall(container.id, { enabled: firewallEnabled, default_action: firewallDefaultAction, rules: firewallRules })
|
||||
const data = res.data.data
|
||||
if (data) {
|
||||
syncFirewallState(data.enabled, data.default_action || 'DROP', data.rules || [])
|
||||
}
|
||||
setFirewallMessage({ type: 'success', text: '防火墙设置已保存并应用' })
|
||||
fetchContainer()
|
||||
} catch (err: any) {
|
||||
dialog.alert('错误', err?.response?.data?.message || '保存防火墙设置失败')
|
||||
const message = err?.response?.data?.message || '保存防火墙设置失败'
|
||||
setFirewallMessage({ type: 'error', text: message })
|
||||
dialog.alert('错误', message)
|
||||
} finally {
|
||||
setFirewallSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const addFirewallRule = () => {
|
||||
const hasIPv4Firewall = (container?.public_ipv4s?.length || 0) > 0 || Math.max(container?.port_mapping_limit || 0, container?.port_mappings?.length || 0) > 0
|
||||
const hasIPv6Firewall = !!container?.ipv6 || (container?.ipv6_addresses?.length || 0) > 0
|
||||
setEditingFirewallRule({
|
||||
id: '',
|
||||
network: hasIPv4Firewall ? 'ipv4' : hasIPv6Firewall ? 'ipv6' : 'ipv4',
|
||||
direction: 'in',
|
||||
protocol: 'tcp',
|
||||
port: '',
|
||||
@@ -876,6 +909,20 @@ export default function ContainerDetail() {
|
||||
const mappingLimit = Math.max(container.port_mapping_limit || 0, mappingCount)
|
||||
const hasNATQuota = mappingLimit > 0
|
||||
const canAddMapping = hasNATQuota && mappingCount < mappingLimit && !isSubUserPolicyBlocked
|
||||
const hasFirewallIPv4 = hasIndependentIPv4 || hasNATQuota
|
||||
const firewallNetworkOptions: Array<{ value: NonNullable<FirewallRule['network']>; label: string }> = []
|
||||
if (hasFirewallIPv4) {
|
||||
firewallNetworkOptions.push({
|
||||
value: 'ipv4',
|
||||
label: hasIndependentIPv4 ? 'IPv4(公网 IPv4)' : 'IPv4(NAT)',
|
||||
})
|
||||
}
|
||||
if (hasIndependentIPv6) {
|
||||
firewallNetworkOptions.push({ value: 'ipv6', label: 'IPv6' })
|
||||
}
|
||||
if (hasFirewallIPv4 && hasIndependentIPv6) {
|
||||
firewallNetworkOptions.push({ value: 'all', label: '全部网络' })
|
||||
}
|
||||
const managementUrl = subUser?.access_code
|
||||
? `${window.location.origin}/login?code=${encodeURIComponent(subUser.access_code)}`
|
||||
: ''
|
||||
@@ -997,7 +1044,7 @@ export default function ContainerDetail() {
|
||||
IPv4 NAT 管理
|
||||
</ActionButton>
|
||||
)}
|
||||
<ActionButton onClick={() => setShowFirewall(true)} disabled={isSubUserPolicyBlocked}>
|
||||
<ActionButton onClick={openFirewall} disabled={isSubUserPolicyBlocked}>
|
||||
<FirewallIcon className="w-3.5 h-3.5" />
|
||||
防火墙
|
||||
</ActionButton>
|
||||
@@ -1511,7 +1558,12 @@ export default function ContainerDetail() {
|
||||
{showFirewall && (
|
||||
<Modal title="防火墙设置" onClose={() => { setShowFirewall(false); setShowFirewallEditor(false); setEditingFirewallRule(null) }} wide extra={
|
||||
!isSubUser && (
|
||||
<button onClick={addFirewallRule} className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-black text-white rounded-md text-xs hover:bg-gray-800">
|
||||
<button
|
||||
onClick={addFirewallRule}
|
||||
disabled={firewallNetworkOptions.length === 0}
|
||||
title={firewallNetworkOptions.length === 0 ? '当前容器没有可配置的 NAT、公网 IPv4 或 IPv6' : undefined}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-black text-white rounded-md text-xs hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />添加规则
|
||||
</button>
|
||||
)
|
||||
@@ -1521,7 +1573,11 @@ export default function ContainerDetail() {
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-800">防火墙</div>
|
||||
<div className="text-xs text-gray-500">启用后默认拒绝所有入站和出站流量,仅放行下方规则</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{firewallEnabled
|
||||
? (firewallDefaultAction === 'DROP' ? '已启用,未匹配规则的流量将被拒绝' : '已启用,未匹配规则的流量将被放行')
|
||||
: '未启用时不接管该容器流量'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setFirewallEnabled(!firewallEnabled)}
|
||||
@@ -1531,12 +1587,47 @@ export default function ContainerDetail() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 rounded-md border border-gray-200 px-3 py-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-800">默认动作</div>
|
||||
<div className="text-xs text-gray-500">没有命中下方规则时如何处理</div>
|
||||
</div>
|
||||
<select
|
||||
value={firewallDefaultAction}
|
||||
onChange={(e) => setFirewallDefaultAction(e.target.value as 'ACCEPT' | 'DROP')}
|
||||
disabled={isSubUser}
|
||||
className="rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-xs text-gray-800 focus:border-black focus:outline-none focus:ring-2 focus:ring-black disabled:opacity-60"
|
||||
>
|
||||
<option value="DROP">未匹配拒绝</option>
|
||||
<option value="ACCEPT">未匹配放行</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-blue-100 bg-blue-50 px-3 py-2 text-xs text-blue-800">
|
||||
<div className="font-medium text-blue-900">网络范围</div>
|
||||
<div className="mt-1">
|
||||
{firewallNetworkOptions.length > 0
|
||||
? `可配置:${firewallNetworkOptions.filter((option) => option.value !== 'all').map((option) => option.label).join('、')}。`
|
||||
: '当前容器未分配 IPv4 NAT、独立公网 IPv4 或 IPv6,暂无可配置网络。'}
|
||||
{hasFirewallIPv4 ? ` IPv4 规则覆盖${hasIndependentIPv4 ? '独立公网 IPv4' : 'IPv4 NAT 端口映射'}。` : ''}
|
||||
{hasNATQuota && !hasIndependentIPv4 ? ' NAT 入站端口按容器内部端口匹配,不是宿主机公网端口。' : ''}
|
||||
{hasIndependentIPv6 ? ' IPv6 规则覆盖该容器已分配的 IPv6 地址。' : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{firewallMessage && (
|
||||
<div className={`rounded-md px-3 py-2 text-xs ${firewallMessage.type === 'success' ? 'border border-emerald-100 bg-emerald-50 text-emerald-700' : 'border border-red-100 bg-red-50 text-red-700'}`}>
|
||||
{firewallMessage.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rules table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium">状态</th>
|
||||
<th className="px-3 py-2 text-left font-medium">网络</th>
|
||||
<th className="px-3 py-2 text-left font-medium">方向</th>
|
||||
<th className="px-3 py-2 text-left font-medium">协议</th>
|
||||
<th className="px-3 py-2 text-left font-medium">端口</th>
|
||||
@@ -1554,6 +1645,11 @@ export default function ContainerDetail() {
|
||||
<span className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${rule.enabled ? 'translate-x-3.5' : 'translate-x-0.5'}`} />
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className="inline-flex rounded bg-gray-100 px-1.5 py-0.5 text-xs font-medium text-gray-700">
|
||||
{(rule.network || 'ipv4') === 'ipv6' ? 'IPv6' : (rule.network || 'ipv4') === 'all' ? '全部' : 'IPv4'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`inline-flex px-1.5 py-0.5 rounded text-xs font-medium ${rule.direction === 'in' ? 'bg-blue-50 text-blue-700' : 'bg-orange-50 text-orange-700'}`}>
|
||||
{rule.direction === 'in' ? '入站' : '出站'}
|
||||
@@ -1571,7 +1667,14 @@ export default function ContainerDetail() {
|
||||
{!isSubUser && (
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<button onClick={() => { setEditingFirewallRule({ ...rule }); setShowFirewallEditor(true) }} className="p-1.5 text-gray-400 hover:text-gray-700 rounded hover:bg-gray-100">
|
||||
<button onClick={() => {
|
||||
const currentNetwork = (rule.network || 'ipv4') as NonNullable<FirewallRule['network']>
|
||||
const network = firewallNetworkOptions.some((option) => option.value === currentNetwork)
|
||||
? currentNetwork
|
||||
: (firewallNetworkOptions[0]?.value || currentNetwork)
|
||||
setEditingFirewallRule({ ...rule, network })
|
||||
setShowFirewallEditor(true)
|
||||
}} className="p-1.5 text-gray-400 hover:text-gray-700 rounded hover:bg-gray-100">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button onClick={() => deleteFirewallRule(rule.id)} className="p-1.5 text-gray-400 hover:text-red-600 rounded hover:bg-red-50">
|
||||
@@ -1583,7 +1686,7 @@ export default function ContainerDetail() {
|
||||
</tr>
|
||||
))}
|
||||
{firewallRules.length === 0 && (
|
||||
<tr><td colSpan={isSubUser ? 7 : 8} className="px-3 py-6 text-center text-xs text-gray-400">暂无防火墙规则</td></tr>
|
||||
<tr><td colSpan={isSubUser ? 8 : 9} className="px-3 py-6 text-center text-xs text-gray-400">暂无防火墙规则</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -1605,6 +1708,17 @@ export default function ContainerDetail() {
|
||||
{showFirewallEditor && editingFirewallRule && (
|
||||
<Modal title={editingFirewallRule.id ? '编辑规则' : '添加规则'} onClose={() => { setShowFirewallEditor(false); setEditingFirewallRule(null) }}>
|
||||
<div className="space-y-4">
|
||||
<Field label="网络">
|
||||
{firewallNetworkOptions.length > 0 ? (
|
||||
<select value={editingFirewallRule.network || firewallNetworkOptions[0].value} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, network: e.target.value as FirewallRule['network'] })} className={inputClass}>
|
||||
{firewallNetworkOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input value="当前容器没有可配置网络" disabled className={`${inputClass} bg-gray-100 text-gray-400`} />
|
||||
)}
|
||||
</Field>
|
||||
<Field label="方向">
|
||||
<select value={editingFirewallRule.direction} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, direction: e.target.value as 'in' | 'out' })} className={inputClass}>
|
||||
<option value="in">入站 (Inbound)</option>
|
||||
@@ -1612,18 +1726,52 @@ export default function ContainerDetail() {
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="协议">
|
||||
<select value={editingFirewallRule.protocol} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, protocol: e.target.value as any })} className={inputClass}>
|
||||
<select
|
||||
value={editingFirewallRule.protocol}
|
||||
onChange={(e) => {
|
||||
const protocol = e.target.value as FirewallRule['protocol']
|
||||
setEditingFirewallRule({
|
||||
...editingFirewallRule,
|
||||
protocol,
|
||||
port: protocol === 'tcp' || protocol === 'udp' ? editingFirewallRule.port : '',
|
||||
})
|
||||
}}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="tcp">TCP</option>
|
||||
<option value="udp">UDP</option>
|
||||
<option value="icmp">ICMP</option>
|
||||
<option value="all">全部</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="端口" hint="留空为全部端口,支持: 22 | 80,443 | 8000-9000">
|
||||
<input value={editingFirewallRule.port} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, port: e.target.value })} placeholder="如: 22 或 80,443 或 8000-9000" className={inputClass} />
|
||||
<Field
|
||||
label="端口"
|
||||
hint={editingFirewallRule.protocol === 'tcp' || editingFirewallRule.protocol === 'udp'
|
||||
? (editingFirewallRule.direction === 'in'
|
||||
? ((editingFirewallRule.network || 'ipv4') === 'ipv4' && hasNATQuota && !hasIndependentIPv4
|
||||
? 'NAT 入站填容器内部端口,例如公网 22023 -> 容器 22,这里填 22'
|
||||
: '入站填容器服务端口;留空为全部端口,支持: 22 | 80,443 | 8000-9000')
|
||||
: '出站填远端目标端口;留空为全部端口,支持: 22 | 80,443 | 8000-9000')
|
||||
: '端口仅适用于 TCP/UDP'}
|
||||
>
|
||||
<input
|
||||
value={editingFirewallRule.port}
|
||||
onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, port: e.target.value })}
|
||||
placeholder={editingFirewallRule.protocol === 'tcp' || editingFirewallRule.protocol === 'udp' ? '如: 22 或 80,443 或 8000-9000' : '当前协议不使用端口'}
|
||||
disabled={editingFirewallRule.protocol !== 'tcp' && editingFirewallRule.protocol !== 'udp'}
|
||||
className={`${inputClass} disabled:bg-gray-100 disabled:text-gray-400`}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={editingFirewallRule.direction === 'in' ? '来源 IP' : '目标 IP'} hint="留空为任意 IP,支持 CIDR: 192.168.1.0/24">
|
||||
<input value={editingFirewallRule.source_ip} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, source_ip: e.target.value })} placeholder="如: 192.168.1.0/24" className={inputClass} />
|
||||
<Field
|
||||
label={editingFirewallRule.direction === 'in' ? '来源 IP' : '目标 IP'}
|
||||
hint={(editingFirewallRule.network || 'ipv4') === 'ipv6' ? '留空为任意 IPv6,支持 CIDR: 2001:db8::/64' : (editingFirewallRule.network || 'ipv4') === 'all' ? '留空为任意 IP,支持 IPv4/IPv6 CIDR' : '留空为任意 IPv4,支持 CIDR: 192.168.1.0/24'}
|
||||
>
|
||||
<input
|
||||
value={editingFirewallRule.source_ip}
|
||||
onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, source_ip: e.target.value })}
|
||||
placeholder={(editingFirewallRule.network || 'ipv4') === 'ipv6' ? '如: 2001:db8::/64' : (editingFirewallRule.network || 'ipv4') === 'all' ? '如: 192.168.1.0/24 或 2001:db8::/64' : '如: 192.168.1.0/24'}
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="动作">
|
||||
<select value={editingFirewallRule.action} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, action: e.target.value as 'ACCEPT' | 'DROP' })} className={inputClass}>
|
||||
|
||||
@@ -725,6 +725,7 @@ function toPlaceholder(cfg: CreateContainerRequest): DisplayContainer {
|
||||
port_mappings: [],
|
||||
port_mapping_limit: cfg.assign_nat === false ? 0 : (cfg.port_mapping_count || 0),
|
||||
firewall_enabled: false,
|
||||
firewall_default_action: 'DROP',
|
||||
firewall_rules: [],
|
||||
snapshot_limit: cfg.snapshot_limit || 3,
|
||||
created_at: '',
|
||||
|
||||
@@ -128,7 +128,7 @@ export default function Login() {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.17</p>
|
||||
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.18</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface PortMapping {
|
||||
|
||||
export interface FirewallRule {
|
||||
id: string
|
||||
network?: 'ipv4' | 'ipv6' | 'all'
|
||||
direction: 'in' | 'out'
|
||||
protocol: 'tcp' | 'udp' | 'icmp' | 'all'
|
||||
port: string
|
||||
@@ -100,6 +101,7 @@ export interface Container {
|
||||
port_mappings: PortMapping[]
|
||||
port_mapping_limit: number
|
||||
firewall_enabled: boolean
|
||||
firewall_default_action: 'ACCEPT' | 'DROP'
|
||||
firewall_rules: FirewallRule[]
|
||||
snapshot_limit: number
|
||||
created_at: string
|
||||
@@ -501,10 +503,10 @@ export const deletePortMapping = (id: ContainerIdentifier, index: number) =>
|
||||
api.delete<APIResponse<PortMapping[]>>(`/containers/${id}/port-mappings/${index}`)
|
||||
|
||||
export const getFirewall = (id: ContainerIdentifier) =>
|
||||
api.get<APIResponse<{ enabled: boolean; rules: FirewallRule[] }>>(`/containers/${id}/firewall`)
|
||||
api.get<APIResponse<{ enabled: boolean; default_action: 'ACCEPT' | 'DROP'; rules: FirewallRule[] }>>(`/containers/${id}/firewall`)
|
||||
|
||||
export const updateFirewall = (id: ContainerIdentifier, data: { enabled?: boolean; rules?: FirewallRule[] }) =>
|
||||
api.put<APIResponse<{ enabled: boolean; rules: FirewallRule[] }>>(`/containers/${id}/firewall`, data)
|
||||
export const updateFirewall = (id: ContainerIdentifier, data: { enabled?: boolean; default_action?: 'ACCEPT' | 'DROP'; rules?: FirewallRule[] }) =>
|
||||
api.put<APIResponse<{ enabled: boolean; default_action: 'ACCEPT' | 'DROP'; rules: FirewallRule[] }>>(`/containers/${id}/firewall`, data)
|
||||
|
||||
export const updateContainerExpiry = (id: ContainerIdentifier, expiresAt: string) =>
|
||||
api.put<APIResponse>(`/containers/${id}/expiry`, { expires_at: expiresAt })
|
||||
|
||||
Reference in New Issue
Block a user