feat: rename masq to snat throughout, update generator to Shorewall 5 snat format

This commit is contained in:
2026-03-01 01:30:19 +01:00
parent 1b543ed44a
commit 686ce911bb
11 changed files with 115 additions and 94 deletions

View File

@@ -0,0 +1,20 @@
"""rename masq table to snat
Revision ID: 0003
Revises: 0002
Create Date: 2026-03-01
"""
from alembic import op
revision = "0003"
down_revision = "0002"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.rename_table("masq", "snat")
def downgrade() -> None:
op.rename_table("snat", "masq")

View File

@@ -92,7 +92,7 @@ def generate_config(
selectinload(models.Config.policies).selectinload(models.Policy.dst_zone), selectinload(models.Config.policies).selectinload(models.Policy.dst_zone),
selectinload(models.Config.rules).selectinload(models.Rule.src_zone), selectinload(models.Config.rules).selectinload(models.Rule.src_zone),
selectinload(models.Config.rules).selectinload(models.Rule.dst_zone), selectinload(models.Config.rules).selectinload(models.Rule.dst_zone),
selectinload(models.Config.masq_entries), selectinload(models.Config.snat_entries),
) )
.filter(models.Config.id == config_id, models.Config.owner_id == current_user.id) .filter(models.Config.id == config_id, models.Config.owner_id == current_user.id)
.first() .first()

View File

@@ -1,64 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app import models, schemas
from app.auth import get_current_user
from app.database import get_db
router = APIRouter()
def _owner_config(config_id: int, db: Session, user: models.User) -> models.Config:
config = db.query(models.Config).filter(
models.Config.id == config_id, models.Config.owner_id == user.id
).first()
if not config:
raise HTTPException(status_code=404, detail="Config not found")
return config
@router.get("/{config_id}/masq", response_model=list[schemas.MasqOut])
def list_masq(config_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
return db.query(models.Masq).filter(models.Masq.config_id == config_id).all()
@router.post("/{config_id}/masq", response_model=schemas.MasqOut, status_code=201)
def create_masq(config_id: int, body: schemas.MasqCreate, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
masq = models.Masq(**body.model_dump(), config_id=config_id)
db.add(masq)
db.commit()
db.refresh(masq)
return masq
@router.get("/{config_id}/masq/{masq_id}", response_model=schemas.MasqOut)
def get_masq(config_id: int, masq_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
masq = db.query(models.Masq).filter(models.Masq.id == masq_id, models.Masq.config_id == config_id).first()
if not masq:
raise HTTPException(status_code=404, detail="Masq entry not found")
return masq
@router.put("/{config_id}/masq/{masq_id}", response_model=schemas.MasqOut)
def update_masq(config_id: int, masq_id: int, body: schemas.MasqUpdate, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
masq = db.query(models.Masq).filter(models.Masq.id == masq_id, models.Masq.config_id == config_id).first()
if not masq:
raise HTTPException(status_code=404, detail="Masq entry not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(masq, field, value)
db.commit()
db.refresh(masq)
return masq
@router.delete("/{config_id}/masq/{masq_id}", status_code=204)
def delete_masq(config_id: int, masq_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
masq = db.query(models.Masq).filter(models.Masq.id == masq_id, models.Masq.config_id == config_id).first()
if not masq:
raise HTTPException(status_code=404, detail="Masq entry not found")
db.delete(masq)
db.commit()

64
backend/app/api/snat.py Normal file
View File

@@ -0,0 +1,64 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app import models, schemas
from app.auth import get_current_user
from app.database import get_db
router = APIRouter()
def _owner_config(config_id: int, db: Session, user: models.User) -> models.Config:
config = db.query(models.Config).filter(
models.Config.id == config_id, models.Config.owner_id == user.id
).first()
if not config:
raise HTTPException(status_code=404, detail="Config not found")
return config
@router.get("/{config_id}/snat", response_model=list[schemas.SnatOut])
def list_snat(config_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
return db.query(models.Snat).filter(models.Snat.config_id == config_id).all()
@router.post("/{config_id}/snat", response_model=schemas.SnatOut, status_code=201)
def create_snat(config_id: int, body: schemas.SnatCreate, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
snat = models.Snat(**body.model_dump(), config_id=config_id)
db.add(snat)
db.commit()
db.refresh(snat)
return snat
@router.get("/{config_id}/snat/{snat_id}", response_model=schemas.SnatOut)
def get_snat(config_id: int, snat_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
snat = db.query(models.Snat).filter(models.Snat.id == snat_id, models.Snat.config_id == config_id).first()
if not snat:
raise HTTPException(status_code=404, detail="SNAT entry not found")
return snat
@router.put("/{config_id}/snat/{snat_id}", response_model=schemas.SnatOut)
def update_snat(config_id: int, snat_id: int, body: schemas.SnatUpdate, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
snat = db.query(models.Snat).filter(models.Snat.id == snat_id, models.Snat.config_id == config_id).first()
if not snat:
raise HTTPException(status_code=404, detail="SNAT entry not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(snat, field, value)
db.commit()
db.refresh(snat)
return snat
@router.delete("/{config_id}/snat/{snat_id}", status_code=204)
def delete_snat(config_id: int, snat_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
_owner_config(config_id, db, user)
snat = db.query(models.Snat).filter(models.Snat.id == snat_id, models.Snat.config_id == config_id).first()
if not snat:
raise HTTPException(status_code=404, detail="SNAT entry not found")
db.delete(snat)
db.commit()

View File

@@ -1,7 +1,7 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from app.api import auth, configs, zones, interfaces, policies, rules, masq from app.api import auth, configs, zones, interfaces, policies, rules, snat
from app.database import settings from app.database import settings
app = FastAPI(title="Shorefront", version="0.1.0") app = FastAPI(title="Shorefront", version="0.1.0")
@@ -21,7 +21,7 @@ app.include_router(zones.router, prefix="/configs", tags=["zones"])
app.include_router(interfaces.router, prefix="/configs", tags=["interfaces"]) app.include_router(interfaces.router, prefix="/configs", tags=["interfaces"])
app.include_router(policies.router, prefix="/configs", tags=["policies"]) app.include_router(policies.router, prefix="/configs", tags=["policies"])
app.include_router(rules.router, prefix="/configs", tags=["rules"]) app.include_router(rules.router, prefix="/configs", tags=["rules"])
app.include_router(masq.router, prefix="/configs", tags=["masq"]) app.include_router(snat.router, prefix="/configs", tags=["snat"])
@app.get("/health") @app.get("/health")

View File

@@ -34,7 +34,7 @@ class Config(Base):
interfaces: Mapped[list["Interface"]] = relationship("Interface", back_populates="config", cascade="all, delete-orphan") interfaces: Mapped[list["Interface"]] = relationship("Interface", back_populates="config", cascade="all, delete-orphan")
policies: Mapped[list["Policy"]] = relationship("Policy", back_populates="config", cascade="all, delete-orphan", order_by="Policy.position") policies: Mapped[list["Policy"]] = relationship("Policy", back_populates="config", cascade="all, delete-orphan", order_by="Policy.position")
rules: Mapped[list["Rule"]] = relationship("Rule", back_populates="config", cascade="all, delete-orphan", order_by="Rule.position") rules: Mapped[list["Rule"]] = relationship("Rule", back_populates="config", cascade="all, delete-orphan", order_by="Rule.position")
masq_entries: Mapped[list["Masq"]] = relationship("Masq", back_populates="config", cascade="all, delete-orphan") snat_entries: Mapped[list["Snat"]] = relationship("Snat", back_populates="config", cascade="all, delete-orphan")
class Zone(Base): class Zone(Base):
@@ -102,8 +102,8 @@ class Rule(Base):
dst_zone: Mapped["Zone | None"] = relationship("Zone", foreign_keys=[dst_zone_id]) dst_zone: Mapped["Zone | None"] = relationship("Zone", foreign_keys=[dst_zone_id])
class Masq(Base): class Snat(Base):
__tablename__ = "masq" __tablename__ = "snat"
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
config_id: Mapped[int] = mapped_column(Integer, ForeignKey("configs.id"), nullable=False) config_id: Mapped[int] = mapped_column(Integer, ForeignKey("configs.id"), nullable=False)
@@ -112,4 +112,4 @@ class Masq(Base):
to_address: Mapped[str] = mapped_column(String(64), default="") to_address: Mapped[str] = mapped_column(String(64), default="")
comment: Mapped[str] = mapped_column(Text, default="") comment: Mapped[str] = mapped_column(Text, default="")
config: Mapped["Config"] = relationship("Config", back_populates="masq_entries") config: Mapped["Config"] = relationship("Config", back_populates="snat_entries")

View File

@@ -160,22 +160,22 @@ class RuleOut(BaseModel):
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
# --- Masq --- # --- Snat ---
class MasqCreate(BaseModel): class SnatCreate(BaseModel):
source_network: str source_network: str
out_interface: str out_interface: str
to_address: str = "" to_address: str = ""
comment: str = "" comment: str = ""
class MasqUpdate(BaseModel): class SnatUpdate(BaseModel):
source_network: Optional[str] = None source_network: Optional[str] = None
out_interface: Optional[str] = None out_interface: Optional[str] = None
to_address: Optional[str] = None to_address: Optional[str] = None
comment: Optional[str] = None comment: Optional[str] = None
class MasqOut(BaseModel): class SnatOut(BaseModel):
id: int id: int
config_id: int config_id: int
source_network: str source_network: str
@@ -192,4 +192,4 @@ class GenerateOut(BaseModel):
interfaces: str interfaces: str
policy: str policy: str
rules: str rules: str
masq: str snat: str

View File

@@ -48,10 +48,11 @@ class ShorewallGenerator:
lines.append(self._col(r.action, src, dst, r.proto or "-", r.dport or "-", r.sport or "-", width=16)) lines.append(self._col(r.action, src, dst, r.proto or "-", r.dport or "-", r.sport or "-", width=16))
return "".join(lines) return "".join(lines)
def masq(self) -> str: def snat(self) -> str:
lines = [self._header("masq"), "#INTERFACE".ljust(24) + "SOURCE".ljust(24) + "ADDRESS\n"] lines = [self._header("snat"), "#ACTION".ljust(24) + "SOURCE".ljust(24) + "DEST\n"]
for m in self._config.masq_entries: for m in self._config.snat_entries:
lines.append(self._col(m.out_interface, m.source_network, m.to_address or "-", width=24)) action = f"SNAT:{m.to_address}" if m.to_address else "MASQUERADE"
lines.append(self._col(action, m.source_network, m.out_interface, width=24))
return "".join(lines) return "".join(lines)
def as_json(self) -> dict: def as_json(self) -> dict:
@@ -60,7 +61,7 @@ class ShorewallGenerator:
"interfaces": self.interfaces(), "interfaces": self.interfaces(),
"policy": self.policy(), "policy": self.policy(),
"rules": self.rules(), "rules": self.rules(),
"masq": self.masq(), "snat": self.snat(),
} }
def as_zip(self) -> bytes: def as_zip(self) -> bytes:
@@ -70,5 +71,5 @@ class ShorewallGenerator:
zf.writestr("interfaces", self.interfaces()) zf.writestr("interfaces", self.interfaces())
zf.writestr("policy", self.policy()) zf.writestr("policy", self.policy())
zf.writestr("rules", self.rules()) zf.writestr("rules", self.rules())
zf.writestr("masq", self.masq()) zf.writestr("snat", self.snat())
return buf.getvalue() return buf.getvalue()

View File

@@ -40,7 +40,7 @@ export const configsApi = {
}), }),
} }
// --- Nested resources (zones, interfaces, policies, rules, masq) --- // --- Nested resources (zones, interfaces, policies, rules, snat) ---
const nestedApi = (resource: string) => ({ const nestedApi = (resource: string) => ({
list: (configId: number) => api.get(`/configs/${configId}/${resource}`), list: (configId: number) => api.get(`/configs/${configId}/${resource}`),
create: (configId: number, data: object) => api.post(`/configs/${configId}/${resource}`, data), create: (configId: number, data: object) => api.post(`/configs/${configId}/${resource}`, data),
@@ -54,4 +54,4 @@ export const zonesApi = nestedApi('zones')
export const interfacesApi = nestedApi('interfaces') export const interfacesApi = nestedApi('interfaces')
export const policiesApi = nestedApi('policies') export const policiesApi = nestedApi('policies')
export const rulesApi = nestedApi('rules') export const rulesApi = nestedApi('rules')
export const masqApi = nestedApi('masq') export const snatApi = nestedApi('snat')

View File

@@ -18,7 +18,7 @@ interface GeneratedFiles {
interfaces: string interfaces: string
policy: string policy: string
rules: string rules: string
masq: string snat: string
} }
interface Props { interface Props {
@@ -28,7 +28,7 @@ interface Props {
onClose: () => void onClose: () => void
} }
const TABS = ['zones', 'interfaces', 'policy', 'rules', 'masq'] as const const TABS = ['zones', 'interfaces', 'policy', 'rules', 'snat'] as const
export default function GenerateModal({ open, configId, configName, onClose }: Props) { export default function GenerateModal({ open, configId, configName, onClose }: Props) {
const [tab, setTab] = useState(0) const [tab, setTab] = useState(0)

View File

@@ -12,14 +12,14 @@ import Typography from '@mui/material/Typography'
import Breadcrumbs from '@mui/material/Breadcrumbs' import Breadcrumbs from '@mui/material/Breadcrumbs'
import AddIcon from '@mui/icons-material/Add' import AddIcon from '@mui/icons-material/Add'
import BuildIcon from '@mui/icons-material/Build' import BuildIcon from '@mui/icons-material/Build'
import { zonesApi, interfacesApi, policiesApi, rulesApi, masqApi, configsApi } from '../api' import { zonesApi, interfacesApi, policiesApi, rulesApi, snatApi, configsApi } from '../api'
// ---- Types ---- // ---- Types ----
interface Zone { id: number; name: string; type: string; options: string } interface Zone { id: number; name: string; type: string; options: string }
interface Iface { id: number; name: string; zone_id: number; options: string } interface Iface { id: number; name: string; zone_id: number; options: string }
interface Policy { id: number; src_zone_id: number; dst_zone_id: number; policy: string; log_level: string; comment: string; position: number } interface Policy { id: number; src_zone_id: number; dst_zone_id: number; policy: string; log_level: string; comment: string; position: number }
interface Rule { id: number; action: string; src_zone_id: number | null; dst_zone_id: number | null; src_ip: string; dst_ip: string; proto: string; dport: string; sport: string; comment: string; position: number } interface Rule { id: number; action: string; src_zone_id: number | null; dst_zone_id: number | null; src_ip: string; dst_ip: string; proto: string; dport: string; sport: string; comment: string; position: number }
interface Masq { id: number; source_network: string; out_interface: string; to_address: string; comment: string } interface Snat { id: number; source_network: string; out_interface: string; to_address: string; comment: string }
type AnyEntity = { id: number } & Record<string, unknown> type AnyEntity = { id: number } & Record<string, unknown>
@@ -33,7 +33,7 @@ export default function ConfigDetail() {
const [interfaces, setInterfaces] = useState<Iface[]>([]) const [interfaces, setInterfaces] = useState<Iface[]>([])
const [policies, setPolicies] = useState<Policy[]>([]) const [policies, setPolicies] = useState<Policy[]>([])
const [rules, setRules] = useState<Rule[]>([]) const [rules, setRules] = useState<Rule[]>([])
const [masq, setMasq] = useState<Masq[]>([]) const [snat, setSnat] = useState<Snat[]>([])
const [formOpen, setFormOpen] = useState(false) const [formOpen, setFormOpen] = useState(false)
const [editing, setEditing] = useState<AnyEntity | null>(null) const [editing, setEditing] = useState<AnyEntity | null>(null)
const [generateOpen, setGenerateOpen] = useState(false) const [generateOpen, setGenerateOpen] = useState(false)
@@ -44,7 +44,7 @@ export default function ConfigDetail() {
interfacesApi.list(configId).then((r) => setInterfaces(r.data)) interfacesApi.list(configId).then((r) => setInterfaces(r.data))
policiesApi.list(configId).then((r) => setPolicies(r.data)) policiesApi.list(configId).then((r) => setPolicies(r.data))
rulesApi.list(configId).then((r) => setRules(r.data)) rulesApi.list(configId).then((r) => setRules(r.data))
masqApi.list(configId).then((r) => setMasq(r.data)) snatApi.list(configId).then((r) => setSnat(r.data))
}, [configId]) }, [configId])
const zoneOptions = zones.map((z) => ({ value: z.id, label: z.name })) const zoneOptions = zones.map((z) => ({ value: z.id, label: z.name }))
@@ -151,10 +151,10 @@ export default function ConfigDetail() {
] as FieldDef[], ] as FieldDef[],
}, },
{ {
label: 'Masq/NAT', label: 'SNAT',
rows: masq as unknown as AnyEntity[], rows: snat as unknown as AnyEntity[],
setRows: setMasq as unknown as Dispatch<SetStateAction<AnyEntity[]>>, setRows: setSnat as unknown as Dispatch<SetStateAction<AnyEntity[]>>,
api: masqApi, api: snatApi,
columns: [ columns: [
{ key: 'out_interface' as const, label: 'Out Interface' }, { key: 'out_interface' as const, label: 'Out Interface' },
{ key: 'source_network' as const, label: 'Source Network' }, { key: 'source_network' as const, label: 'Source Network' },