nft rule post json
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
This commit is contained in:
@@ -181,7 +181,7 @@ class NftManager:
|
||||
|
||||
|
||||
# ---------- FastAPI + Router ----------
|
||||
app = FastAPI(title="Unrestricted nftables API (textual only)")
|
||||
app = FastAPI(title="Unrestricted nftables API (json create)")
|
||||
router = APIRouter(prefix="/firewall", tags=["firewall"])
|
||||
mgr = NftManager()
|
||||
|
||||
@@ -208,40 +208,40 @@ class RuleOut(BaseModel):
|
||||
position: Optional[Any] = Field(None, description="Optional position metadata from nft if present")
|
||||
comment: Optional[str] = Field(None, description="Optional comment attached to the rule")
|
||||
|
||||
class Config:
|
||||
schema_extra = {
|
||||
"example": {
|
||||
"handle": 3,
|
||||
"expr": [{"match": {"left": {"payload": {"protocol": "ip", "field": "protocol"}}, "op": "==", "right": "icmp"}}, {"drop": None}],
|
||||
"text": "ip protocol icmp drop",
|
||||
"position": None,
|
||||
"comment": None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ChainOut(BaseModel):
|
||||
name: str = Field(..., description="Chain name", example="forward")
|
||||
rules: List[RuleOut] = Field(..., description="Rules in this chain (ordered)")
|
||||
|
||||
class Config:
|
||||
schema_extra = {"example": {"name": "forward", "rules": []}}
|
||||
|
||||
|
||||
class TableOut(BaseModel):
|
||||
family: str = Field(..., description="Table family (inet/bridge/ipv4/...)")
|
||||
name: str = Field(..., description="Table name", example="filter")
|
||||
chains: List[ChainOut] = Field(..., description="Chains in this table")
|
||||
|
||||
class Config:
|
||||
schema_extra = {"example": {"family": "bridge", "name": "filter", "chains": []}}
|
||||
|
||||
|
||||
class RulesetModel(BaseModel):
|
||||
tables: List[TableOut] = Field(..., description="Top-level tables list")
|
||||
|
||||
|
||||
# ---------- New: CreateRuleRequest (JSON, expr required) ----------
|
||||
class CreateRuleRequest(BaseModel):
|
||||
family: str = Field(..., description="Table family (e.g. inet, bridge, ip, ip6)", example="bridge")
|
||||
table: str = Field(..., description="Table name (e.g. filter)", example="filter")
|
||||
chain: str = Field(..., description="Chain name (e.g. forward)", example="forward")
|
||||
expr: Any = Field(..., description="nft JSON expression (machine-readable). This field is required for JSON rule creation.")
|
||||
position: Optional[Any] = Field(None, description="Optional position metadata (if you want to specify insertion position)")
|
||||
comment: Optional[str] = Field(None, description="Optional comment")
|
||||
|
||||
class Config:
|
||||
schema_extra = {"example": {"tables": []}}
|
||||
schema_extra = {
|
||||
"example": {
|
||||
"family": "bridge",
|
||||
"table": "filter",
|
||||
"chain": "forward",
|
||||
"expr": [{"match": {"left": {"payload": {"protocol": "ip", "field": "protocol"}}, "op": "==", "right": "icmp"}}, {"drop": None}],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ruleset may be typed RulesetModel or raw textual string (fallback)
|
||||
@@ -263,6 +263,7 @@ def rule_text_from_expr(expr: Any) -> str:
|
||||
"""
|
||||
Deterministic serializer to produce a compact UI-friendly string from expr list.
|
||||
Covers common constructs; falls back to JSON dump for unknown constructs.
|
||||
(Used for display in GET /rules).
|
||||
"""
|
||||
if expr is None:
|
||||
return ""
|
||||
@@ -373,6 +374,101 @@ def build_predictable_ruleset(nft_json: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return result
|
||||
|
||||
|
||||
# ---------- Helpers to render expr -> textual nft (best-effort) ----------
|
||||
def expr_to_text(expr: Any) -> Optional[str]:
|
||||
"""
|
||||
Best-effort renderer that converts a typical nft JSON expr (list) into a textual
|
||||
fragment suitable to append to 'add rule <family> <table> <chain> ...'.
|
||||
Returns None when it cannot deterministically render the provided expr.
|
||||
Supported cases (common):
|
||||
- [{'match': {'left': {'payload': {'protocol':'ip','field':'protocol'}}, 'op':'==', 'right':'icmp'}}, {'drop': None}]
|
||||
-> 'ip protocol icmp drop'
|
||||
- payload / tcp / udp / counter / accept
|
||||
- simple dicts where keys are 'drop' | 'accept' | 'counter'
|
||||
This intentionally does not attempt to support every nft JSON construct.
|
||||
"""
|
||||
if expr is None:
|
||||
return ""
|
||||
if isinstance(expr, str):
|
||||
return expr
|
||||
if not isinstance(expr, list):
|
||||
# unsupported top-level type
|
||||
return None
|
||||
|
||||
parts: List[str] = []
|
||||
for element in expr:
|
||||
if isinstance(element, dict):
|
||||
# handle drop/accept/counter directly
|
||||
if "drop" in element:
|
||||
parts.append("drop")
|
||||
continue
|
||||
if "accept" in element:
|
||||
parts.append("accept")
|
||||
continue
|
||||
if "counter" in element:
|
||||
parts.append("counter")
|
||||
continue
|
||||
|
||||
# match left/right payload equals -> ip protocol icmp, or ip saddr/daddr
|
||||
if "match" in element:
|
||||
m = element["match"]
|
||||
left = m.get("left")
|
||||
right = m.get("right")
|
||||
# payload matches
|
||||
if isinstance(left, dict) and "payload" in left and isinstance(right, (str, int)):
|
||||
p = left["payload"]
|
||||
prot = p.get("protocol")
|
||||
field = p.get("field")
|
||||
# common: protocol field match (protocol == icmp)
|
||||
if prot and field and isinstance(right, str):
|
||||
# ip vs ip6 decision is left to the frontend; here we render 'ip protocol icmp' (works for many setups)
|
||||
if field == "protocol":
|
||||
parts.append(f"{prot} {field} {right}")
|
||||
continue
|
||||
# payload might be l4 ports etc; produce generic payload(...) token
|
||||
parts.append(f"payload({prot}.{field}) {right}")
|
||||
continue
|
||||
# fallback for match: try to stringify right
|
||||
parts.append("match")
|
||||
continue
|
||||
|
||||
# payload shorthand
|
||||
if "payload" in element:
|
||||
p = element["payload"]
|
||||
prot = p.get("protocol")
|
||||
field = p.get("field")
|
||||
if prot and field:
|
||||
parts.append(f"payload({prot}.{field})")
|
||||
continue
|
||||
parts.append("payload")
|
||||
continue
|
||||
|
||||
# tcp/udp as nested dicts sometimes appear
|
||||
if "tcp" in element or "udp" in element:
|
||||
proto = "tcp" if "tcp" in element else "udp"
|
||||
val = element.get(proto)
|
||||
# attempt to detect dport/sport keys
|
||||
if isinstance(val, dict):
|
||||
if "dport" in val:
|
||||
parts.append(f"{proto} dport {val['dport']}")
|
||||
continue
|
||||
if "sport" in val:
|
||||
parts.append(f"{proto} sport {val['sport']}")
|
||||
continue
|
||||
parts.append(proto)
|
||||
continue
|
||||
|
||||
# cmp/binary operators etc — not supported deterministically
|
||||
# return None to indicate we can't safely render this expr
|
||||
return None
|
||||
else:
|
||||
# non-dict token (string/number)
|
||||
parts.append(str(element))
|
||||
|
||||
# join tokens
|
||||
return " ".join(parts).strip()
|
||||
|
||||
|
||||
# ---------- Routes ----------
|
||||
|
||||
@router.get("/rules", response_model=RulesetOut, summary="List ruleset")
|
||||
@@ -395,7 +491,6 @@ def list_rules():
|
||||
return RulesetOut(ruleset=text.strip() if text is not None else None)
|
||||
|
||||
custom = build_predictable_ruleset(nft_json)
|
||||
# Validate/construct Pydantic model so OpenAPI + client libs get accurate typing
|
||||
ruleset_model = RulesetModel.parse_obj(custom)
|
||||
return RulesetOut(ruleset=ruleset_model)
|
||||
except NftError as e:
|
||||
@@ -406,25 +501,45 @@ def list_rules():
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/rules", response_model=ExecResult, status_code=status.HTTP_201_CREATED, summary="Execute textual rule command")
|
||||
def create_rule_text(req: RawCmdRequest):
|
||||
@router.post("/rules", response_model=ExecResult, status_code=status.HTTP_201_CREATED, summary="Create rule (JSON, expr required)")
|
||||
def create_rule_json(req: CreateRuleRequest):
|
||||
"""
|
||||
Execute a textual nft command (convenience, returns structured exec result).
|
||||
Example: add rule inet filter input ip saddr 10.0.0.0/8 drop
|
||||
Create a rule from JSON.
|
||||
Preferred usage: provide `expr` (nft JSON expr). Server attempts to render it to textual nft.
|
||||
If `expr` can't be deterministically rendered, the server returns 400 instructing the client
|
||||
to use POST /firewall/raw for raw textual commands.
|
||||
"""
|
||||
try:
|
||||
res = mgr.cmd(req.cmd)
|
||||
family = req.family
|
||||
table = req.table
|
||||
chain = req.chain
|
||||
|
||||
if req.expr is None:
|
||||
raise NftError("field 'expr' is required for JSON rule creation")
|
||||
|
||||
rendered = expr_to_text(req.expr)
|
||||
if rendered is None:
|
||||
# cannot render - instruct client to use textual API
|
||||
raise NftError(
|
||||
"cannot render provided 'expr' to textual nft syntax. "
|
||||
"Please use POST /firewall/raw to execute the textual nft command."
|
||||
)
|
||||
expr_text = rendered
|
||||
|
||||
# construct final add rule command
|
||||
cmd = f"add rule {family} {table} {chain} {expr_text}"
|
||||
# execute
|
||||
res = mgr.cmd(cmd)
|
||||
rc = int(res.get("rc", -1) or -1)
|
||||
if rc != 0:
|
||||
# Bad request: command failed (client-provided textual command)
|
||||
# Return 400 with the stderr detail
|
||||
raise NftError(f"cmd failed rc={rc}: {res.get('stderr')}")
|
||||
# return 400 to indicate client-provided rule failed
|
||||
raise NftError(f"create rule failed rc={rc}: {res.get('stderr')}")
|
||||
return ExecResult(rc=rc, stdout=res.get("stdout"), stderr=res.get("stderr"))
|
||||
except NftError as e:
|
||||
logger.warning("create_rule_text failed: %s", e)
|
||||
logger.warning("create_rule_json failed: %s", e)
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.exception("create_rule_text internal error")
|
||||
logger.exception("create_rule_json internal error")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@@ -437,7 +552,6 @@ def delete_rule(handle: int, family: str = "inet", table: str = "filter", chain:
|
||||
"""
|
||||
try:
|
||||
mgr.delete_rule_by_handle_text(family=family, table=table, chain=chain, handle=handle)
|
||||
# 204 No Content — nothing to return
|
||||
except ValueError as e:
|
||||
logger.warning("delete_rule client error: %s", e)
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
Reference in New Issue
Block a user