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.rules).selectinload(models.Rule.src_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)
.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.middleware.cors import CORSMiddleware
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
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(policies.router, prefix="/configs", tags=["policies"])
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")

View File

@@ -34,7 +34,7 @@ class Config(Base):
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")
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):
@@ -102,8 +102,8 @@ class Rule(Base):
dst_zone: Mapped["Zone | None"] = relationship("Zone", foreign_keys=[dst_zone_id])
class Masq(Base):
__tablename__ = "masq"
class Snat(Base):
__tablename__ = "snat"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
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="")
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}
# --- Masq ---
class MasqCreate(BaseModel):
# --- Snat ---
class SnatCreate(BaseModel):
source_network: str
out_interface: str
to_address: str = ""
comment: str = ""
class MasqUpdate(BaseModel):
class SnatUpdate(BaseModel):
source_network: Optional[str] = None
out_interface: Optional[str] = None
to_address: Optional[str] = None
comment: Optional[str] = None
class MasqOut(BaseModel):
class SnatOut(BaseModel):
id: int
config_id: int
source_network: str
@@ -192,4 +192,4 @@ class GenerateOut(BaseModel):
interfaces: str
policy: 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))
return "".join(lines)
def masq(self) -> str:
lines = [self._header("masq"), "#INTERFACE".ljust(24) + "SOURCE".ljust(24) + "ADDRESS\n"]
for m in self._config.masq_entries:
lines.append(self._col(m.out_interface, m.source_network, m.to_address or "-", width=24))
def snat(self) -> str:
lines = [self._header("snat"), "#ACTION".ljust(24) + "SOURCE".ljust(24) + "DEST\n"]
for m in self._config.snat_entries:
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)
def as_json(self) -> dict:
@@ -60,7 +61,7 @@ class ShorewallGenerator:
"interfaces": self.interfaces(),
"policy": self.policy(),
"rules": self.rules(),
"masq": self.masq(),
"snat": self.snat(),
}
def as_zip(self) -> bytes:
@@ -70,5 +71,5 @@ class ShorewallGenerator:
zf.writestr("interfaces", self.interfaces())
zf.writestr("policy", self.policy())
zf.writestr("rules", self.rules())
zf.writestr("masq", self.masq())
zf.writestr("snat", self.snat())
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) => ({
list: (configId: number) => api.get(`/configs/${configId}/${resource}`),
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 policiesApi = nestedApi('policies')
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
policy: string
rules: string
masq: string
snat: string
}
interface Props {
@@ -28,7 +28,7 @@ interface Props {
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) {
const [tab, setTab] = useState(0)

View File

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