move rule
This commit is contained in:
@@ -131,7 +131,7 @@ class NftManager:
|
|||||||
if isinstance(expr, list):
|
if isinstance(expr, list):
|
||||||
tokens: List[str] = []
|
tokens: List[str] = []
|
||||||
for part in expr:
|
for part in expr:
|
||||||
if "match" in part:
|
if isinstance(part, dict) and "match" in part:
|
||||||
m = part["match"]
|
m = part["match"]
|
||||||
left = m.get("left")
|
left = m.get("left")
|
||||||
right = m.get("right")
|
right = m.get("right")
|
||||||
@@ -143,18 +143,18 @@ class NftManager:
|
|||||||
tokens.append(f"{prot} {field} {right}")
|
tokens.append(f"{prot} {field} {right}")
|
||||||
continue
|
continue
|
||||||
tokens.append("match")
|
tokens.append("match")
|
||||||
elif "payload" in part:
|
elif isinstance(part, dict) and "payload" in part:
|
||||||
p = part["payload"]
|
p = part["payload"]
|
||||||
prot = p.get("protocol")
|
prot = p.get("protocol")
|
||||||
field = p.get("field")
|
field = p.get("field")
|
||||||
tokens.append(f"payload({prot}.{field})")
|
tokens.append(f"payload({prot}.{field})")
|
||||||
elif "drop" in part:
|
elif isinstance(part, dict) and "drop" in part:
|
||||||
tokens.append("drop")
|
tokens.append("drop")
|
||||||
elif "accept" in part:
|
elif isinstance(part, dict) and "accept" in part:
|
||||||
tokens.append("accept")
|
tokens.append("accept")
|
||||||
elif "counter" in part:
|
elif isinstance(part, dict) and "counter" in part:
|
||||||
tokens.append("counter")
|
tokens.append("counter")
|
||||||
elif "queue" in part:
|
elif isinstance(part, dict) and "queue" in part:
|
||||||
# handle fallback queue textualization
|
# handle fallback queue textualization
|
||||||
q = part["queue"]
|
q = part["queue"]
|
||||||
if isinstance(q, dict):
|
if isinstance(q, dict):
|
||||||
@@ -172,7 +172,11 @@ class NftManager:
|
|||||||
else:
|
else:
|
||||||
tokens.append(f"queue num {q}")
|
tokens.append(f"queue num {q}")
|
||||||
else:
|
else:
|
||||||
|
# fallback for unknown dict token
|
||||||
|
if isinstance(part, dict):
|
||||||
tokens.append("+".join(part.keys()))
|
tokens.append("+".join(part.keys()))
|
||||||
|
else:
|
||||||
|
tokens.append(str(part))
|
||||||
rule_lines.append(" ".join(tokens))
|
rule_lines.append(" ".join(tokens))
|
||||||
else:
|
else:
|
||||||
rule_lines.append(json.dumps(r))
|
rule_lines.append(json.dumps(r))
|
||||||
@@ -283,11 +287,13 @@ class MoveRequest(BaseModel):
|
|||||||
to_top: Optional[bool] = Field(False, description="Move to top (equivalent to position=0)")
|
to_top: Optional[bool] = Field(False, description="Move to top (equivalent to position=0)")
|
||||||
to_bottom: Optional[bool] = Field(False, description="Move to bottom (append)")
|
to_bottom: Optional[bool] = Field(False, description="Move to bottom (append)")
|
||||||
|
|
||||||
|
|
||||||
class MoveSubResult(BaseModel):
|
class MoveSubResult(BaseModel):
|
||||||
cmd: str
|
cmd: str
|
||||||
out: Optional[ExecResult] = None
|
out: Optional[ExecResult] = None
|
||||||
err: Optional[str] = None
|
err: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class MoveResult(BaseModel):
|
class MoveResult(BaseModel):
|
||||||
added: MoveSubResult
|
added: MoveSubResult
|
||||||
deleted: MoveSubResult
|
deleted: MoveSubResult
|
||||||
@@ -325,22 +331,22 @@ def parse_priority(val: Any) -> Optional[int]:
|
|||||||
# numeric string
|
# numeric string
|
||||||
if isinstance(val, str):
|
if isinstance(val, str):
|
||||||
s = val.strip()
|
s = val.strip()
|
||||||
if s.isdigit() or (s.startswith("-") and s[1:].isdigit()):
|
# try integer parse
|
||||||
try:
|
try:
|
||||||
return int(s)
|
return int(s)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
|
||||||
# sometimes nft uses "0" etc with whitespace
|
|
||||||
try:
|
try:
|
||||||
|
# sometimes it's "0.0" or similar
|
||||||
return int(float(s))
|
return int(float(s))
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
# nested dicts sometimes appear
|
# nested dicts sometimes appear
|
||||||
if isinstance(val, dict):
|
if isinstance(val, dict):
|
||||||
|
# look for common keys
|
||||||
for key in ("priority", "prio"):
|
for key in ("priority", "prio"):
|
||||||
if key in val:
|
if key in val:
|
||||||
return parse_priority(val.get(key))
|
return parse_priority(val.get(key))
|
||||||
# sometimes structure like {'hook': {'priority': 0}} - try to dive in
|
# try nested dict values
|
||||||
for v in val.values():
|
for v in val.values():
|
||||||
p = parse_priority(v)
|
p = parse_priority(v)
|
||||||
if p is not None:
|
if p is not None:
|
||||||
@@ -594,6 +600,7 @@ def expr_to_text(expr: Any) -> Optional[str]:
|
|||||||
elif isinstance(q, (int, float)):
|
elif isinstance(q, (int, float)):
|
||||||
token += f" num {int(q)}"
|
token += f" num {int(q)}"
|
||||||
elif isinstance(q, str):
|
elif isinstance(q, str):
|
||||||
|
# preserve string but ensure spacing: client must supply numeric if nft expects it
|
||||||
token += f" num {q}"
|
token += f" num {q}"
|
||||||
parts.append(token)
|
parts.append(token)
|
||||||
continue
|
continue
|
||||||
@@ -855,15 +862,19 @@ def move_rule(handle: int, req: MoveRequest):
|
|||||||
chain_rules = ch.get("rules", [])
|
chain_rules = ch.get("rules", [])
|
||||||
break
|
break
|
||||||
|
|
||||||
if not chain_rules:
|
if chain_rules is None or len(chain_rules) == 0:
|
||||||
raise HTTPException(status_code=404, detail="Chain not found or chain contains no rules")
|
raise HTTPException(status_code=404, detail="Chain not found or chain contains no rules")
|
||||||
|
|
||||||
# find the rule by handle
|
# find the rule by handle
|
||||||
source_rule = None
|
source_rule = None
|
||||||
for r in chain_rules:
|
for r in chain_rules:
|
||||||
if r.get("handle") == handle:
|
# handle may be int or convertible; do tolerant compare
|
||||||
|
try:
|
||||||
|
if r.get("handle") is not None and int(r.get("handle")) == int(handle):
|
||||||
source_rule = r
|
source_rule = r
|
||||||
break
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
if source_rule is None:
|
if source_rule is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Rule with handle {handle} not found in chain")
|
raise HTTPException(status_code=404, detail=f"Rule with handle {handle} not found in chain")
|
||||||
|
|
||||||
@@ -881,7 +892,7 @@ def move_rule(handle: int, req: MoveRequest):
|
|||||||
target_pos = len(chain_rules) # append
|
target_pos = len(chain_rules) # append
|
||||||
elif req.before_handle is not None:
|
elif req.before_handle is not None:
|
||||||
# find index of before_handle
|
# find index of before_handle
|
||||||
idx = next((i for i, rr in enumerate(chain_rules) if rr.get("handle") == req.before_handle), None)
|
idx = next((i for i, rr in enumerate(chain_rules) if rr.get("handle") is not None and int(rr.get("handle")) == int(req.before_handle)), None)
|
||||||
if idx is None:
|
if idx is None:
|
||||||
raise HTTPException(status_code=404, detail=f"before_handle {req.before_handle} not found in chain")
|
raise HTTPException(status_code=404, detail=f"before_handle {req.before_handle} not found in chain")
|
||||||
target_pos = idx
|
target_pos = idx
|
||||||
@@ -932,20 +943,21 @@ def move_rule(handle: int, req: MoveRequest):
|
|||||||
|
|
||||||
# Now delete the original rule by handle
|
# Now delete the original rule by handle
|
||||||
try:
|
try:
|
||||||
mgr.delete_rule_by_handle_text(family=family, table=table, chain=chain, handle=handle)
|
mgr.delete_rule_by_handle_text(family=family, table=table, chain=chain, handle=int(handle))
|
||||||
del_exec = MoveSubResult(cmd=f"delete rule {family} {table} {chain} handle {handle}", out=ExecResult(rc=0, stdout="", stderr=""), err=None)
|
del_exec = MoveSubResult(cmd=f"delete rule {family} {table} {chain} handle {handle}", out=ExecResult(rc=0, stdout="", stderr=""), err=None)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# We succeeded adding but failed deleting - report both
|
# We succeeded adding but failed deleting - report both
|
||||||
err_msg = f"added new rule but deleting original handle {handle} failed: {e}"
|
err_msg = f"added new rule but deleting original handle {handle} failed: {e}"
|
||||||
logger.exception(err_msg)
|
logger.exception(err_msg)
|
||||||
del_exec = MoveSubResult(cmd=f"delete rule {family} {table} {chain} handle {handle}", out=None, err=str(e))
|
del_exec = MoveSubResult(cmd=f"delete rule {family} {table} {chain} handle {handle}", out=None, err=str(e))
|
||||||
# Return 500 to indicate partial failure
|
# Return a 500 (partial success)
|
||||||
return MoveResult(added=add_exec, deleted=del_exec)
|
raise HTTPException(status_code=500, detail={"added": add_exec, "deleted": del_exec})
|
||||||
|
|
||||||
# success
|
# success
|
||||||
return MoveResult(added=add_exec, deleted=del_exec)
|
return MoveResult(added=add_exec, deleted=del_exec)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
# re-raise so FastAPI handles it
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("move_rule internal error")
|
logger.exception("move_rule internal error")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// src/apiClient.ts
|
// src/apiClient.ts
|
||||||
import axios from "axios";
|
import axios from 'axios';
|
||||||
import { CreateRuleRequest, ExecResult, RulesetModel } from "../types/firewall";
|
import { CreateRuleRequest, ExecResult, MoveRequest, MoveResult, RulesetModel } from '../types/firewall';
|
||||||
import {
|
import {
|
||||||
BridgeCreateRequest,
|
BridgeCreateRequest,
|
||||||
BridgeInfo,
|
BridgeInfo,
|
||||||
@@ -8,17 +8,15 @@ import {
|
|||||||
FullState,
|
FullState,
|
||||||
InterfaceInfo,
|
InterfaceInfo,
|
||||||
RouteInfo,
|
RouteInfo,
|
||||||
} from "../types/network";
|
} from '../types/network';
|
||||||
import { EnableRequest, ScriptInfo } from "../types/scripting";
|
import { EnableRequest, ScriptInfo } from '../types/scripting';
|
||||||
import {
|
import { SnifferStatusResponse } from '../types/sniffer';
|
||||||
SnifferStatusResponse,
|
|
||||||
} from "../types/sniffer";
|
|
||||||
|
|
||||||
const BASE = "http://mitm.lan/api";
|
const BASE = 'http://mitm.lan/api';
|
||||||
|
|
||||||
export const api = axios.create({
|
export const api = axios.create({
|
||||||
baseURL: BASE,
|
baseURL: BASE,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -27,29 +25,24 @@ api.interceptors.response.use(
|
|||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
(error) => {
|
||||||
// FastAPI HTTPException format
|
// FastAPI HTTPException format
|
||||||
const detail =
|
const detail = error?.response?.data?.detail ?? error?.response?.data?.message ?? error.message ?? 'Unknown error';
|
||||||
error?.response?.data?.detail ??
|
|
||||||
error?.response?.data?.message ??
|
|
||||||
error.message ??
|
|
||||||
"Unknown error";
|
|
||||||
|
|
||||||
// Always reject with a standard Error
|
// Always reject with a standard Error
|
||||||
return Promise.reject(new Error(detail));
|
return Promise.reject(new Error(detail));
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
/* -------------------------
|
/* -------------------------
|
||||||
Basic endpoints
|
Basic endpoints
|
||||||
------------------------- */
|
------------------------- */
|
||||||
|
|
||||||
export const fetchHello = async (): Promise<any> => {
|
export const fetchHello = async (): Promise<any> => {
|
||||||
const res = await api.get("/hello");
|
const res = await api.get('/hello');
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchVersions = async (): Promise<any> => {
|
export const fetchVersions = async (): Promise<any> => {
|
||||||
const res = await api.get("/versions");
|
const res = await api.get('/versions');
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -58,47 +51,46 @@ export const fetchVersions = async (): Promise<any> => {
|
|||||||
------------------------- */
|
------------------------- */
|
||||||
|
|
||||||
export const fetchInterfaces = async (): Promise<InterfaceInfo[]> => {
|
export const fetchInterfaces = async (): Promise<InterfaceInfo[]> => {
|
||||||
const res = await api.get<InterfaceInfo[]>("/network/interfaces");
|
const res = await api.get<InterfaceInfo[]>('/network/interfaces');
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchLinks = async (): Promise<InterfaceInfo[]> => {
|
export const fetchLinks = async (): Promise<InterfaceInfo[]> => {
|
||||||
const res = await api.get<InterfaceInfo[]>("/network/links");
|
const res = await api.get<InterfaceInfo[]>('/network/links');
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchRoutes = async (): Promise<RouteInfo[]> => {
|
export const fetchRoutes = async (): Promise<RouteInfo[]> => {
|
||||||
const res = await api.get<RouteInfo[]>("/network/routes");
|
const res = await api.get<RouteInfo[]>('/network/routes');
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchBridges = async (): Promise<BridgeInfo[]> => {
|
export const fetchBridges = async (): Promise<BridgeInfo[]> => {
|
||||||
const res = await api.get<BridgeInfo[]>("/network/bridges");
|
const res = await api.get<BridgeInfo[]>('/network/bridges');
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchFullState = async (): Promise<FullState> => {
|
export const fetchFullState = async (): Promise<FullState> => {
|
||||||
const res = await api.get<FullState>("/network/full-state");
|
const res = await api.get<FullState>('/network/full-state');
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createBridge = async (req: BridgeCreateRequest) => {
|
export const createBridge = async (req: BridgeCreateRequest) => {
|
||||||
const res = await api.post("/network/bridge/create", req);
|
const res = await api.post('/network/bridge/create', req);
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const removeBridge = async (req: BridgeRemoveRequest) => {
|
export const removeBridge = async (req: BridgeRemoveRequest) => {
|
||||||
const res = await api.post("/network/bridge/remove", req);
|
const res = await api.post('/network/bridge/remove', req);
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/* -------------------------
|
/* -------------------------
|
||||||
Sniffer
|
Sniffer
|
||||||
------------------------- */
|
------------------------- */
|
||||||
|
|
||||||
export const fetchSnifferStatus = async (): Promise<SnifferStatusResponse> => {
|
export const fetchSnifferStatus = async (): Promise<SnifferStatusResponse> => {
|
||||||
const res = await api.get<SnifferStatusResponse>("/sniffer/status");
|
const res = await api.get<SnifferStatusResponse>('/sniffer/status');
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -120,7 +112,7 @@ export const snifferStop = async (): Promise<SnifferStopResponse> => {
|
|||||||
|
|
||||||
export const fetchPackets = async (limit = 100): Promise<any> => {
|
export const fetchPackets = async (limit = 100): Promise<any> => {
|
||||||
// limit default mirrors OpenAPI default
|
// limit default mirrors OpenAPI default
|
||||||
const res = await api.get("/packets/packets", { params: { limit } });
|
const res = await api.get('/packets/packets', { params: { limit } });
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -134,7 +126,7 @@ export const fetchPackets = async (limit = 100): Promise<any> => {
|
|||||||
* - If the server returns a raw textual fallback (string), the caller should handle it.
|
* - If the server returns a raw textual fallback (string), the caller should handle it.
|
||||||
*/
|
*/
|
||||||
export const fetchRuleset = async (): Promise<{ ruleset: RulesetModel | string | null }> => {
|
export const fetchRuleset = async (): Promise<{ ruleset: RulesetModel | string | null }> => {
|
||||||
const res = await api.get<{ ruleset: RulesetModel | string | null }>("/firewall/rules");
|
const res = await api.get<{ ruleset: RulesetModel | string | null }>('/firewall/rules');
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -142,12 +134,7 @@ export const fetchRuleset = async (): Promise<{ ruleset: RulesetModel | string |
|
|||||||
* DELETE /firewall/rules/{handle}?family=...&table=...&chain=...
|
* DELETE /firewall/rules/{handle}?family=...&table=...&chain=...
|
||||||
* On success the backend returns 204 No Content. This function resolves to void.
|
* On success the backend returns 204 No Content. This function resolves to void.
|
||||||
*/
|
*/
|
||||||
export const deleteRule = async (
|
export const deleteRule = async (handle: number, family: string, table: string, chain: string): Promise<void> => {
|
||||||
handle: number,
|
|
||||||
family: string,
|
|
||||||
table: string,
|
|
||||||
chain: string
|
|
||||||
): Promise<void> => {
|
|
||||||
const res = await api.delete(`/firewall/rules/${encodeURIComponent(String(handle))}`, {
|
const res = await api.delete(`/firewall/rules/${encodeURIComponent(String(handle))}`, {
|
||||||
params: { family, table, chain },
|
params: { family, table, chain },
|
||||||
});
|
});
|
||||||
@@ -155,26 +142,31 @@ export const deleteRule = async (
|
|||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const moveRule = async (req: MoveRequest): Promise<MoveResult> => {
|
||||||
|
const res = await api.post<MoveResult>(`/firewall/rules/${encodeURIComponent(String(req.before_handle))}/move`, req);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* createRuleJson - POST /firewall/rules
|
* createRuleJson - POST /firewall/rules
|
||||||
* Body: CreateRuleRequest (must include expr)
|
* Body: CreateRuleRequest (must include expr)
|
||||||
*/
|
*/
|
||||||
export const createRuleJson = async (req: CreateRuleRequest): Promise<ExecResult> => {
|
export const createRuleJson = async (req: CreateRuleRequest): Promise<ExecResult> => {
|
||||||
const res = await api.post<ExecResult>("/firewall/rules", req);
|
const res = await api.post<ExecResult>('/firewall/rules', req);
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const execFirewallRaw = async (cmd: string): Promise<ExecResult> => {
|
export const execFirewallRaw = async (cmd: string): Promise<ExecResult> => {
|
||||||
const res = await api.post<ExecResult>("/firewall/raw", { "cmd": cmd });
|
const res = await api.post<ExecResult>('/firewall/raw', { cmd: cmd });
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
};
|
||||||
|
|
||||||
/* -------------------------
|
/* -------------------------
|
||||||
Scripts
|
Scripts
|
||||||
------------------------- */
|
------------------------- */
|
||||||
|
|
||||||
export const fetchScriptsStatusAll = async (): Promise<any> => {
|
export const fetchScriptsStatusAll = async (): Promise<any> => {
|
||||||
const res = await api.get("/scripts/scripts/status");
|
const res = await api.get('/scripts/scripts/status');
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -184,24 +176,23 @@ export const fetchScriptStatusForName = async (name: string): Promise<any> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const listScripts = async (): Promise<ScriptInfo[]> => {
|
export const listScripts = async (): Promise<ScriptInfo[]> => {
|
||||||
const res = await api.get<ScriptInfo[]>("/scripts/scripts");
|
const res = await api.get<ScriptInfo[]>('/scripts/scripts');
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export const uploadScript = async (opts: {
|
export const uploadScript = async (opts: {
|
||||||
name: string;
|
name: string;
|
||||||
script: File | Blob;
|
script: File | Blob;
|
||||||
requirements?: File | Blob | null;
|
requirements?: File | Blob | null;
|
||||||
}): Promise<ScriptInfo> => {
|
}): Promise<ScriptInfo> => {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append("name", opts.name);
|
fd.append('name', opts.name);
|
||||||
fd.append("script", opts.script);
|
fd.append('script', opts.script);
|
||||||
if (opts.requirements) fd.append("requirements", opts.requirements as Blob);
|
if (opts.requirements) fd.append('requirements', opts.requirements as Blob);
|
||||||
|
|
||||||
// axios will set multipart/form-data boundary automatically when FormData passed
|
// axios will set multipart/form-data boundary automatically when FormData passed
|
||||||
const res = await api.post<ScriptInfo>("/scripts/scripts", fd, {
|
const res = await api.post<ScriptInfo>('/scripts/scripts', fd, {
|
||||||
headers: { "Content-Type": "multipart/form-data" },
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
});
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
@@ -213,7 +204,7 @@ export const downloadScript = async (name: string): Promise<any> => {
|
|||||||
|
|
||||||
export const deleteScript = async (name: string, qnum?: number | null): Promise<any> => {
|
export const deleteScript = async (name: string, qnum?: number | null): Promise<any> => {
|
||||||
const params: Record<string, any> = {};
|
const params: Record<string, any> = {};
|
||||||
if (typeof qnum !== "undefined") params.qnum = qnum;
|
if (typeof qnum !== 'undefined') params.qnum = qnum;
|
||||||
const res = await api.delete(`/scripts/scripts/${encodeURIComponent(name)}`, { params });
|
const res = await api.delete(`/scripts/scripts/${encodeURIComponent(name)}`, { params });
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import { Alert, Button, Card, Modal, Space, Spin, Table, Typography, message } from 'antd';
|
// src/components/FirewallTables.tsx
|
||||||
|
import { ArrowDownOutlined, ArrowUpOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||||
|
import { Alert, Button, Card, Divider, message, Modal, Space, Spin, Table, Typography } from 'antd';
|
||||||
import { ColumnsType } from 'antd/lib/table';
|
import { ColumnsType } from 'antd/lib/table';
|
||||||
import { ReactElement, useEffect, useState } from 'react';
|
import { ReactElement, useEffect, useState } from 'react';
|
||||||
import { execFirewallRaw, fetchRuleset } from '../api/apiClient';
|
import { execFirewallRaw, fetchRuleset, moveRule } from '../api/apiClient';
|
||||||
import { CmdResult, ExecResult } from '../types/firewall';
|
import type { CmdResult, ExecResult } from '../types/firewall';
|
||||||
import FirewallAddChainModal from './FireWallAddChainModal';
|
import FirewallAddChainModal from './FireWallAddChainModal';
|
||||||
import FirewallAddTableModal from './FireWallAddTableModal';
|
import FirewallAddTableModal from './FireWallAddTableModal';
|
||||||
|
|
||||||
const { Paragraph, Text, Title } = Typography;
|
const { Paragraph, Text, Title } = Typography;
|
||||||
|
|
||||||
|
/* Types */
|
||||||
type NFTRule = {
|
type NFTRule = {
|
||||||
handle?: number | string;
|
handle?: number | string;
|
||||||
expr?: any;
|
expr?: any;
|
||||||
|
rule?: any;
|
||||||
|
text?: string;
|
||||||
[k: string]: any;
|
[k: string]: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -29,9 +34,7 @@ type NFTTable = {
|
|||||||
chains: NFTChain[];
|
chains: NFTChain[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/* -------------------------
|
/* Extract ruleset -> tables (unchanged) */
|
||||||
Extract NFT structure
|
|
||||||
------------------------- */
|
|
||||||
function extractTablesFromParsed(parsed: any): NFTTable[] {
|
function extractTablesFromParsed(parsed: any): NFTTable[] {
|
||||||
if (!parsed) return [];
|
if (!parsed) return [];
|
||||||
|
|
||||||
@@ -108,14 +111,102 @@ function extractTablesFromParsed(parsed: any): NFTTable[] {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -------------------------
|
/* Friendly rule renderer (keeps previous logic) */
|
||||||
Rule preview
|
function renderRuleFriendly(rule: NFTRule): string {
|
||||||
------------------------- */
|
if (rule.text && typeof rule.text === 'string' && rule.text.trim() !== '') return rule.text;
|
||||||
function rulePreview(rule: NFTRule): string {
|
if (typeof rule.rule === 'string') return rule.rule;
|
||||||
if (typeof rule === 'string') return rule;
|
|
||||||
if (rule.expr && typeof rule.expr === 'string') return rule.expr;
|
if (rule.expr && typeof rule.expr === 'string') return rule.expr;
|
||||||
if (rule.rule && typeof rule.rule === 'string') return rule.rule;
|
|
||||||
if (rule.handle && Object.keys(rule).length === 1) return `handle ${rule.handle}`;
|
const expr = rule.expr ?? rule;
|
||||||
|
if (Array.isArray(expr)) {
|
||||||
|
const tokens: string[] = [];
|
||||||
|
for (const part of expr) {
|
||||||
|
if (part == null) continue;
|
||||||
|
if (typeof part === 'string' || typeof part === 'number') {
|
||||||
|
tokens.push(String(part));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (typeof part === 'object') {
|
||||||
|
if ('match' in part) {
|
||||||
|
const m = (part as any).match;
|
||||||
|
const left = m?.left;
|
||||||
|
const right = m?.right;
|
||||||
|
if (left && left.payload && (typeof right === 'string' || typeof right === 'number')) {
|
||||||
|
const p = left.payload;
|
||||||
|
const prot = p.protocol;
|
||||||
|
const field = p.field;
|
||||||
|
if (prot && field) {
|
||||||
|
tokens.push(`${prot} ${field} ${right}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokens.push('match');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ('payload' in part) {
|
||||||
|
const p = (part as any).payload;
|
||||||
|
if (p?.protocol && p?.field) {
|
||||||
|
tokens.push(`payload(${p.protocol}.${p.field})`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
tokens.push('payload');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ('tcp' in part) {
|
||||||
|
const v = (part as any).tcp;
|
||||||
|
if (v && v.dport) tokens.push(`tcp dport ${v.dport}`);
|
||||||
|
else if (v && v.sport) tokens.push(`tcp sport ${v.sport}`);
|
||||||
|
else tokens.push('tcp');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ('udp' in part) {
|
||||||
|
const v = (part as any).udp;
|
||||||
|
if (v && v.dport) tokens.push(`udp dport ${v.dport}`);
|
||||||
|
else if (v && v.sport) tokens.push(`udp sport ${v.sport}`);
|
||||||
|
else tokens.push('udp');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ('drop' in part) {
|
||||||
|
tokens.push('drop');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ('accept' in part) {
|
||||||
|
tokens.push('accept');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ('counter' in part) {
|
||||||
|
tokens.push('counter');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ('queue' in part) {
|
||||||
|
const q = (part as any).queue;
|
||||||
|
let tok = 'queue';
|
||||||
|
if (typeof q === 'object' && q !== null) {
|
||||||
|
const num = q.num ?? q.number ?? q.queue_number ?? q.from ?? q.range;
|
||||||
|
if (num !== undefined) tok += ` num ${num}`;
|
||||||
|
if (q.bypass) tok += ' bypass';
|
||||||
|
} else if (typeof q === 'number') {
|
||||||
|
tok += ` num ${q}`;
|
||||||
|
} else if (typeof q === 'string') {
|
||||||
|
tok += ` num ${q}`;
|
||||||
|
}
|
||||||
|
tokens.push(tok);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
tokens.push(Object.keys(part).sort().join('+'));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tokens.length > 0) return tokens.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.expr && typeof rule.expr === 'object') {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(rule.expr, (_k, v) => (v === undefined ? null : v)).slice(0, 500);
|
||||||
|
} catch {
|
||||||
|
// fallthrough
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(rule, null, 2);
|
return JSON.stringify(rule, null, 2);
|
||||||
@@ -124,17 +215,13 @@ function rulePreview(rule: NFTRule): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Helper: determine success by rc
|
/* Helper: success RC */
|
||||||
Some endpoints return rc === -1 on success in your environment,
|
|
||||||
so treat rc === 0 or rc === -1 as success. */
|
|
||||||
function isSuccessRc(out?: ExecResult | null): boolean {
|
function isSuccessRc(out?: ExecResult | null): boolean {
|
||||||
if (!out) return false;
|
if (!out) return false;
|
||||||
return out.rc === 0 || out.rc === -1;
|
return out.rc === 0 || out.rc === -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -------------------------
|
/* Component */
|
||||||
Component
|
|
||||||
------------------------- */
|
|
||||||
export default function FirewallTables(): ReactElement {
|
export default function FirewallTables(): ReactElement {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
@@ -163,27 +250,22 @@ export default function FirewallTables(): ReactElement {
|
|||||||
loadRuleset();
|
loadRuleset();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// helper to run multiple commands sequentially and collect results
|
|
||||||
async function runCommands(cmds: string[]) {
|
async function runCommands(cmds: string[]) {
|
||||||
const acc: CmdResult[] = [];
|
const acc: CmdResult[] = [];
|
||||||
for (const cmd of cmds) {
|
for (const cmd of cmds) {
|
||||||
try {
|
try {
|
||||||
const out = (await execFirewallRaw(cmd)) as ExecResult;
|
const out = (await execFirewallRaw(cmd)) as ExecResult;
|
||||||
if (isSuccessRc(out)) {
|
if (isSuccessRc(out)) acc.push({ cmd, out });
|
||||||
acc.push({ cmd, out });
|
else acc.push({ cmd, out, err: out ? `stderr: ${out.stderr ?? ''} rc: ${out.rc}` : 'Unknown error' });
|
||||||
} else {
|
|
||||||
acc.push({ cmd, out, err: out ? `stderr: ${out.stderr ?? ''} rc: ${out.rc}` : 'Unknown error' });
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
acc.push({ cmd, err: err?.message ?? String(err) });
|
acc.push({ cmd, err: err?.message ?? String(err) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// prepend new results so the latest are visible first
|
|
||||||
setResults((prev) => [...acc, ...prev]);
|
setResults((prev) => [...acc, ...prev]);
|
||||||
return acc;
|
return acc;
|
||||||
}
|
}
|
||||||
|
|
||||||
// delete a single rule
|
// Delete helpers (unchanged behavior)
|
||||||
async function handleDeleteRule(
|
async function handleDeleteRule(
|
||||||
family: string | null | undefined,
|
family: string | null | undefined,
|
||||||
table: string,
|
table: string,
|
||||||
@@ -191,24 +273,21 @@ export default function FirewallTables(): ReactElement {
|
|||||||
handle: number | string,
|
handle: number | string,
|
||||||
) {
|
) {
|
||||||
const cmd = `delete rule ${family ?? 'inet'} ${table} ${chain} handle ${handle}`;
|
const cmd = `delete rule ${family ?? 'inet'} ${table} ${chain} handle ${handle}`;
|
||||||
|
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: 'Delete Rule',
|
title: 'Delete Rule',
|
||||||
content: (
|
content: (
|
||||||
<>
|
<>
|
||||||
<Paragraph>Are you sure you want to delete this rule?</Paragraph>
|
<Paragraph>Are you sure you want to delete this rule?</Paragraph>
|
||||||
<pre style={{ whiteSpace: 'pre-wrap' }}>{cmd}</pre>
|
<Divider />
|
||||||
|
<Paragraph copyable>{cmd}</Paragraph>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
try {
|
try {
|
||||||
const res = await runCommands([cmd]);
|
const res = await runCommands([cmd]);
|
||||||
const first = res[0];
|
const first = res[0];
|
||||||
if (!first.err) {
|
if (!first.err) message.success('Rule deleted');
|
||||||
message.success('Rule deleted');
|
else message.error('Delete returned error — check results panel');
|
||||||
} else {
|
|
||||||
message.error('Delete returned error — check results panel');
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
message.error('Delete failed: ' + (err?.message ?? String(err)));
|
message.error('Delete failed: ' + (err?.message ?? String(err)));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -218,10 +297,8 @@ export default function FirewallTables(): ReactElement {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// delete a chain (non-force)
|
|
||||||
async function handleDeleteChain(family: string | null | undefined, table: string, chain: string) {
|
async function handleDeleteChain(family: string | null | undefined, table: string, chain: string) {
|
||||||
const cmd = `delete chain ${family ?? 'inet'} ${table} ${chain}`;
|
const cmd = `delete chain ${family ?? 'inet'} ${table} ${chain}`;
|
||||||
|
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: 'Delete Chain',
|
title: 'Delete Chain',
|
||||||
content: (
|
content: (
|
||||||
@@ -229,18 +306,16 @@ export default function FirewallTables(): ReactElement {
|
|||||||
<Paragraph>
|
<Paragraph>
|
||||||
This will delete the chain <i>{chain}</i> in table <i>{table}</i> unrevertably.
|
This will delete the chain <i>{chain}</i> in table <i>{table}</i> unrevertably.
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
<pre style={{ whiteSpace: 'pre-wrap' }}>{cmd}</pre>
|
<Divider />
|
||||||
|
<Paragraph copyable>{cmd}</Paragraph>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
try {
|
try {
|
||||||
const res = await runCommands([cmd]);
|
const res = await runCommands([cmd]);
|
||||||
const first = res[0];
|
const first = res[0];
|
||||||
if (!first.err) {
|
if (!first.err) message.success(`Chain ${chain} deleted`);
|
||||||
message.success(`Chain ${chain} deleted`);
|
else message.error(`Chain deletion returned error — check results panel`);
|
||||||
} else {
|
|
||||||
message.error(`Chain deletion returned error — check results panel`);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
message.error('Chain deletion failed: ' + (err?.message ?? String(err)));
|
message.error('Chain deletion failed: ' + (err?.message ?? String(err)));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -250,10 +325,8 @@ export default function FirewallTables(): ReactElement {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// delete a table (non-force)
|
|
||||||
async function handleDeleteTable(family: string | null | undefined, table: string) {
|
async function handleDeleteTable(family: string | null | undefined, table: string) {
|
||||||
const cmd = `delete table ${family ?? 'inet'} ${table}`;
|
const cmd = `delete table ${family ?? 'inet'} ${table}`;
|
||||||
|
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: 'Delete Table',
|
title: 'Delete Table',
|
||||||
content: (
|
content: (
|
||||||
@@ -261,18 +334,16 @@ export default function FirewallTables(): ReactElement {
|
|||||||
<Paragraph>
|
<Paragraph>
|
||||||
This will delete the table <i>{table}</i> including all its chains and rules unrevertably.
|
This will delete the table <i>{table}</i> including all its chains and rules unrevertably.
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
<pre style={{ whiteSpace: 'pre-wrap' }}>{cmd}</pre>
|
<Divider />
|
||||||
|
<Paragraph copyable>{cmd}</Paragraph>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
try {
|
try {
|
||||||
const res = await runCommands([cmd]);
|
const res = await runCommands([cmd]);
|
||||||
const first = res[0];
|
const first = res[0];
|
||||||
if (!first.err) {
|
if (!first.err) message.success(`Table ${table} deleted`);
|
||||||
message.success(`Table ${table} deleted`);
|
else message.error(`Table deletion returned error — check results panel`);
|
||||||
} else {
|
|
||||||
message.error(`Table deletion returned error — check results panel`);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
message.error('Table deletion failed: ' + (err?.message ?? String(err)));
|
message.error('Table deletion failed: ' + (err?.message ?? String(err)));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -282,19 +353,135 @@ export default function FirewallTables(): ReactElement {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh handler
|
|
||||||
async function handleRefresh() {
|
async function handleRefresh() {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
try {
|
try {
|
||||||
await loadRuleset();
|
await loadRuleset();
|
||||||
message.success('Ruleset refreshed');
|
message.success('Ruleset refreshed');
|
||||||
} catch {
|
} catch {
|
||||||
// loadRuleset sets error state
|
// ignore
|
||||||
} finally {
|
} finally {
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Move using position (0-based). We still send source handle in the URL by setting before_handle =
|
||||||
|
// sourceHandle so your helper constructs the correct path. The body contains "position".
|
||||||
|
async function performMoveInline(
|
||||||
|
family: string | null | undefined,
|
||||||
|
tableName: string,
|
||||||
|
chainName: string,
|
||||||
|
handle: number | string | undefined,
|
||||||
|
idx: number,
|
||||||
|
) {
|
||||||
|
const t = tables.find((x) => x.family === family && x.name === tableName);
|
||||||
|
if (!t) {
|
||||||
|
message.error('Table not found in local state');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ch = t.chains.find((c) => c.name === chainName);
|
||||||
|
if (!ch) {
|
||||||
|
message.error('Chain not found in local state');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastIndex = ch.rules.length - 1;
|
||||||
|
|
||||||
|
// if no handle -> disallow
|
||||||
|
if (handle === undefined || handle === null) {
|
||||||
|
message.error('Rule has no handle; cannot perform server-side move.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// compute target positions for up/down actions invoked from UI; we assume caller computed newPos already
|
||||||
|
// but here we'll ask the user which direction via a small confirm for safety.
|
||||||
|
const confirm = await new Promise<boolean>((resolve) => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: 'Move rule',
|
||||||
|
content: `Move rule #${handle} (position ${idx + 1} of ${lastIndex + 1})?`,
|
||||||
|
okText: 'Move up one',
|
||||||
|
cancelText: 'Move down one',
|
||||||
|
onOk: () => resolve(true),
|
||||||
|
onCancel: () => resolve(false),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// But above confirm uses ok/cancel as a quick choice. For inline buttons we call this function separately
|
||||||
|
// by passing the desired direction; however to keep this function generic we will not rely on confirm.
|
||||||
|
// For clarity: update callers to call with desired target position directly. (We handle here both.)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline-specific wrappers: moveUp / moveDown (these compute position and call moveRule)
|
||||||
|
async function moveUp(family: string, tableName: string, chainName: string, handle: number | string, idx: number) {
|
||||||
|
if (idx <= 0) {
|
||||||
|
message.warning('Already at top');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// target position: idx - 1
|
||||||
|
const targetPos = idx - 1;
|
||||||
|
await callMoveApi(family, tableName, chainName, handle, targetPos);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveDown(family: string, tableName: string, chainName: string, handle: number | string, idx: number) {
|
||||||
|
const t = tables.find((x) => x.family === family && x.name === tableName);
|
||||||
|
if (!t) return;
|
||||||
|
const ch = t.chains.find((c) => c.name === chainName);
|
||||||
|
if (!ch) return;
|
||||||
|
const lastIndex = ch.rules.length - 1;
|
||||||
|
if (idx >= lastIndex) {
|
||||||
|
message.warning('Already at bottom');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// target position: idx + 1
|
||||||
|
const targetPos = idx + 1;
|
||||||
|
await callMoveApi(family, tableName, chainName, handle, targetPos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// call your moveRule helper; we must supply before_handle as the source handle so the helper puts that in the URL
|
||||||
|
// while the server will use 'position' from the body to place the rule.
|
||||||
|
async function callMoveApi(
|
||||||
|
family: string | null,
|
||||||
|
tableName: string,
|
||||||
|
chainName: string,
|
||||||
|
sourceHandle: number | string,
|
||||||
|
position: number,
|
||||||
|
) {
|
||||||
|
if (sourceHandle === undefined || sourceHandle === null) {
|
||||||
|
message.error('Cannot move rule without a source handle.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reqBody = {
|
||||||
|
family: family,
|
||||||
|
table: tableName,
|
||||||
|
chain: chainName,
|
||||||
|
position: Number(position),
|
||||||
|
before_handle: Number(sourceHandle),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
message.loading({ content: 'Moving rule…', key: 'move' });
|
||||||
|
try {
|
||||||
|
const res = await moveRule(reqBody); // uses your helper
|
||||||
|
message.success({ content: 'Rule moved', key: 'move' });
|
||||||
|
} catch (err: any) {
|
||||||
|
const detail = err?.response?.data ?? err?.message ?? String(err);
|
||||||
|
message.error({
|
||||||
|
content: 'Move failed: ' + (typeof detail === 'string' ? detail : JSON.stringify(detail)),
|
||||||
|
key: 'move',
|
||||||
|
});
|
||||||
|
setResults((prev) => [
|
||||||
|
{
|
||||||
|
cmd: `MOVE ${String(sourceHandle)} -> pos=${position}`,
|
||||||
|
err: typeof detail === 'string' ? detail : JSON.stringify(detail),
|
||||||
|
},
|
||||||
|
...prev,
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
await loadRuleset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) return <Spin size="large" style={{ display: 'block', margin: '40px auto' }} />;
|
if (loading) return <Spin size="large" style={{ display: 'block', margin: '40px auto' }} />;
|
||||||
|
|
||||||
if (error) return <Alert type="error" message="Failed to load firewall rules" description={error} />;
|
if (error) return <Alert type="error" message="Failed to load firewall rules" description={error} />;
|
||||||
@@ -342,61 +529,90 @@ export default function FirewallTables(): ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Space>
|
<Space>
|
||||||
<Button danger size="small" onClick={() => handleDeleteTable(table.family, table.name)}>
|
<Button
|
||||||
Delete Table
|
danger
|
||||||
|
size="small"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={() => handleDeleteTable(table.family, table.name)}
|
||||||
|
/>
|
||||||
|
<Button size="small" onClick={() => setIsOpenChainCreatorModal(true)}>
|
||||||
|
Add Chain
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
extra={<Button onClick={() => setIsOpenChainCreatorModal(true)}>Add Chain</Button>}
|
|
||||||
>
|
>
|
||||||
<FirewallAddChainModal
|
<FirewallAddChainModal
|
||||||
open={isOpenChainCreatorModal}
|
open={isOpenChainCreatorModal}
|
||||||
onClose={() => setIsOpenChainCreatorModal(false)}
|
onClose={() => setIsOpenChainCreatorModal(false)}
|
||||||
table={{ family: table.family, name: table.name }}
|
table={{ family: table.family ?? '', name: table.name }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Space direction="vertical" style={{ width: '100%' }}>
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
{table.chains.map((chain) => {
|
{table.chains.map((chain) => {
|
||||||
|
const lastIndex = chain.rules.length - 1;
|
||||||
const columns: ColumnsType<any> = [
|
const columns: ColumnsType<any> = [
|
||||||
{ title: 'Rule #', dataIndex: 'idx', width: 80 },
|
{ title: 'Rule #', dataIndex: 'idx', width: 80 },
|
||||||
{
|
{
|
||||||
title: 'Handle',
|
title: 'Handle',
|
||||||
dataIndex: 'handle',
|
dataIndex: 'handle',
|
||||||
|
width: 120,
|
||||||
render: (v) => v ?? '-',
|
render: (v) => v ?? '-',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Rule Content',
|
title: 'Rule',
|
||||||
dataIndex: 'raw',
|
dataIndex: 'raw',
|
||||||
render: (v) => <Paragraph copyable>{v}</Paragraph>,
|
render: (v) => (
|
||||||
|
<Paragraph ellipsis={{ rows: 2, expandable: false }} copyable>
|
||||||
|
{v}
|
||||||
|
</Paragraph>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Actions',
|
title: 'Actions',
|
||||||
|
dataIndex: 'actions',
|
||||||
|
width: 160,
|
||||||
render: (_: any, rec: any) =>
|
render: (_: any, rec: any) =>
|
||||||
rec.handle ? (
|
rec.handle ? (
|
||||||
<Space>
|
<Space>
|
||||||
<Button
|
<Button
|
||||||
danger
|
danger
|
||||||
size="small"
|
size="small"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
onClick={() => handleDeleteRule(table.family, table.name, chain.name, rec.handle)}
|
onClick={() => handleDeleteRule(table.family, table.name, chain.name, rec.handle)}
|
||||||
>
|
/>
|
||||||
Delete Rule
|
<Button
|
||||||
</Button>
|
size="small"
|
||||||
|
icon={<ArrowUpOutlined />}
|
||||||
|
disabled={rec.idx <= 1 || rec.handle === undefined || rec.handle === null}
|
||||||
|
onClick={() =>
|
||||||
|
moveUp(table.family ?? '', table.name, chain.name, rec.handle, rec.idx - 1)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<ArrowDownOutlined />}
|
||||||
|
disabled={rec.idx - 1 >= lastIndex || rec.handle === undefined || rec.handle === null}
|
||||||
|
onClick={() =>
|
||||||
|
moveDown(table.family ?? '', table.name, chain.name, rec.handle, rec.idx - 1)
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
) : (
|
) : (
|
||||||
<Space>
|
<Space>
|
||||||
<Button size="small" disabled>
|
<Button size="small" disabled icon={<DeleteOutlined />} />
|
||||||
Delete Rule
|
<Button size="small" disabled icon={<ArrowUpOutlined />} />
|
||||||
</Button>
|
<Button size="small" disabled icon={<ArrowDownOutlined />} />
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const dataSource = chain.rules.map((r, idx) => ({
|
const dataSource = chain.rules.map((r, idx) => ({
|
||||||
key: idx,
|
key: `${chain.name}:${idx}`,
|
||||||
idx: idx + 1,
|
idx: idx + 1,
|
||||||
handle: r.handle ?? null,
|
handle: r.handle ?? null,
|
||||||
raw: rulePreview(r),
|
raw: renderRuleFriendly(r),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -424,10 +640,9 @@ export default function FirewallTables(): ReactElement {
|
|||||||
<Space>
|
<Space>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
onClick={() => handleDeleteChain(table.family, table.name, chain.name)}
|
onClick={() => handleDeleteChain(table.family, table.name, chain.name)}
|
||||||
>
|
/>
|
||||||
Delete Chain
|
|
||||||
</Button>
|
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -447,6 +662,40 @@ export default function FirewallTables(): ReactElement {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Results panel */}
|
||||||
|
{results.length > 0 && (
|
||||||
|
<Card title="Command Results" style={{ marginTop: 20 }}>
|
||||||
|
{results.map((r, i) => (
|
||||||
|
<div key={i} style={{ marginBottom: 12 }}>
|
||||||
|
<Text strong>{r.cmd}</Text>
|
||||||
|
{r.err ? (
|
||||||
|
<Paragraph type="danger" style={{ marginTop: 6 }}>
|
||||||
|
{r.err}
|
||||||
|
</Paragraph>
|
||||||
|
) : r.out ? (
|
||||||
|
<>
|
||||||
|
<Paragraph>
|
||||||
|
<Text type="secondary">rc:</Text> {r.out.rc}
|
||||||
|
</Paragraph>
|
||||||
|
{r.out.stdout ? (
|
||||||
|
<>
|
||||||
|
<Text type="secondary">stdout:</Text>
|
||||||
|
<pre style={{ whiteSpace: 'pre-wrap', background: '#fff', padding: 8 }}>{r.out.stdout}</pre>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{r.out.stderr ? (
|
||||||
|
<>
|
||||||
|
<Text type="secondary">stderr:</Text>
|
||||||
|
<pre style={{ whiteSpace: 'pre-wrap', background: '#fff', padding: 8 }}>{r.out.stderr}</pre>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,9 +45,83 @@ export interface CmdResult {
|
|||||||
cmd: string;
|
cmd: string;
|
||||||
out?: ExecResult;
|
out?: ExecResult;
|
||||||
err?: string;
|
err?: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
export interface TableProp {
|
export interface TableProp {
|
||||||
family: string;
|
family: string;
|
||||||
name: string;
|
name: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Types derived from Python Pydantic models:
|
||||||
|
* - MoveRequest
|
||||||
|
* - MoveSubResult
|
||||||
|
* - MoveResult
|
||||||
|
*
|
||||||
|
* Field names intentionally match the Python/JSON names (snake_case) so they work
|
||||||
|
* directly with your backend API.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ExecResult mirrors the ExecResult Pydantic model returned by /firewall/raw and other endpoints.
|
||||||
|
*/
|
||||||
|
export interface ExecResult {
|
||||||
|
/** Return code from nft execution (example: 0 or -1). */
|
||||||
|
rc: number;
|
||||||
|
/** Standard output from nft (may be null/undefined). */
|
||||||
|
stdout?: string | null;
|
||||||
|
/** Standard error from nft (may be null/undefined). */
|
||||||
|
stderr?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request body for moving a rule.
|
||||||
|
* Exactly one of position / before_handle / to_top / to_bottom is typically provided.
|
||||||
|
*
|
||||||
|
* Examples:
|
||||||
|
* - Move to top: { family: "bridge", table: "filter", chain: "forward", to_top: true }
|
||||||
|
* - Move before handle: { family: "inet", table: "filter", chain: "input", before_handle: 42 }
|
||||||
|
* - Insert at position: { family: "ip", table: "filter", chain: "forward", position: 0 }
|
||||||
|
*/
|
||||||
|
export interface MoveRequest {
|
||||||
|
/** Table family, e.g. "bridge", "inet", "ip", "ip6". */
|
||||||
|
family: string;
|
||||||
|
/** Table name, e.g. "filter". */
|
||||||
|
table: string;
|
||||||
|
/** Chain name, e.g. "forward". */
|
||||||
|
chain: string;
|
||||||
|
|
||||||
|
/** Zero-based position to insert at (0 = top). Optional. */
|
||||||
|
position?: number | null;
|
||||||
|
|
||||||
|
/** Insert before this existing handle in the same chain. Optional. */
|
||||||
|
before_handle?: number | null;
|
||||||
|
|
||||||
|
/** Move to top (equivalent to position=0). Optional; default false on server. */
|
||||||
|
to_top?: boolean;
|
||||||
|
|
||||||
|
/** Move to bottom (append). Optional; default false on server. */
|
||||||
|
to_bottom?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sub-result for the move action (what was added / what was deleted).
|
||||||
|
* Mirrors MoveSubResult(BaseModel).
|
||||||
|
*/
|
||||||
|
export interface MoveSubResult {
|
||||||
|
/** Executed command as text (e.g. "add rule ..."). */
|
||||||
|
cmd: string;
|
||||||
|
/** Exec result if command was executed (may be null/undefined). */
|
||||||
|
out?: ExecResult | null;
|
||||||
|
/** Error string if the operation failed (may be null/undefined). */
|
||||||
|
err?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overall move result: what was added and what was deleted.
|
||||||
|
* Mirrors MoveResult(BaseModel).
|
||||||
|
*/
|
||||||
|
export interface MoveResult {
|
||||||
|
added: MoveSubResult;
|
||||||
|
deleted: MoveSubResult;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user