diff --git a/backend/alembic/versions/0007_policy_zones_nullable.py b/backend/alembic/versions/0007_policy_zones_nullable.py new file mode 100644 index 0000000..6fd9ac0 --- /dev/null +++ b/backend/alembic/versions/0007_policy_zones_nullable.py @@ -0,0 +1,23 @@ +"""make policy zone ids nullable (support 'all') + +Revision ID: 0007 +Revises: 0006 +Create Date: 2026-03-01 +""" +from alembic import op +import sqlalchemy as sa + +revision = "0007" +down_revision = "0006" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.alter_column("policies", "src_zone_id", existing_type=sa.Integer(), nullable=True) + op.alter_column("policies", "dst_zone_id", existing_type=sa.Integer(), nullable=True) + + +def downgrade() -> None: + op.alter_column("policies", "src_zone_id", existing_type=sa.Integer(), nullable=False) + op.alter_column("policies", "dst_zone_id", existing_type=sa.Integer(), nullable=False) diff --git a/backend/app/models.py b/backend/app/models.py index 293938a..06303a3 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -72,16 +72,16 @@ class Policy(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True) config_id: Mapped[int] = mapped_column(Integer, ForeignKey("configs.id"), nullable=False) - src_zone_id: Mapped[int] = mapped_column(Integer, ForeignKey("zones.id"), nullable=False) - dst_zone_id: Mapped[int] = mapped_column(Integer, ForeignKey("zones.id"), nullable=False) + src_zone_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("zones.id"), nullable=True) + dst_zone_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("zones.id"), nullable=True) policy: Mapped[str] = mapped_column(String(16), nullable=False) log_level: Mapped[str] = mapped_column(String(16), default="") comment: Mapped[str] = mapped_column(Text, default="") position: Mapped[int] = mapped_column(Integer, default=0) config: Mapped["Config"] = relationship("Config", back_populates="policies") - src_zone: Mapped["Zone"] = relationship("Zone", foreign_keys=[src_zone_id]) - dst_zone: Mapped["Zone"] = relationship("Zone", foreign_keys=[dst_zone_id]) + src_zone: Mapped["Zone | None"] = relationship("Zone", foreign_keys=[src_zone_id]) + dst_zone: Mapped["Zone | None"] = relationship("Zone", foreign_keys=[dst_zone_id]) class Rule(Base): diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 2281f2c..9d9b788 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -89,8 +89,8 @@ class InterfaceOut(BaseModel): # --- Policy --- class PolicyCreate(BaseModel): - src_zone_id: int - dst_zone_id: int + src_zone_id: Optional[int] = None + dst_zone_id: Optional[int] = None policy: str log_level: str = "" comment: str = "" @@ -109,8 +109,8 @@ class PolicyUpdate(BaseModel): class PolicyOut(BaseModel): id: int config_id: int - src_zone_id: int - dst_zone_id: int + src_zone_id: Optional[int] + dst_zone_id: Optional[int] policy: str log_level: str comment: str diff --git a/backend/app/shorewall_generator.py b/backend/app/shorewall_generator.py index 8843df8..84dea08 100644 --- a/backend/app/shorewall_generator.py +++ b/backend/app/shorewall_generator.py @@ -35,7 +35,9 @@ class ShorewallGenerator: def policy(self) -> str: lines = [self._header("policy"), "#SOURCE".ljust(16) + "DEST".ljust(16) + "POLICY".ljust(16) + "LOG LEVEL\n"] for p in sorted(self._config.policies, key=lambda x: x.position): - lines.append(self._col(p.src_zone.name, p.dst_zone.name, p.policy, p.log_level or "-")) + src = p.src_zone.name if p.src_zone else "all" + dst = p.dst_zone.name if p.dst_zone else "all" + lines.append(self._col(src, dst, p.policy, p.log_level or "-")) return "".join(lines) def rules(self) -> str: diff --git a/frontend/src/routes/ConfigDetail.tsx b/frontend/src/routes/ConfigDetail.tsx index 96e60f5..e731e53 100644 --- a/frontend/src/routes/ConfigDetail.tsx +++ b/frontend/src/routes/ConfigDetail.tsx @@ -104,20 +104,20 @@ export default function ConfigDetail() { { key: 'src_zone_id' as const, label: 'Source', - render: (r: AnyEntity) => zones.find((z) => z.id === r['src_zone_id'])?.name ?? String(r['src_zone_id']), + render: (r: AnyEntity) => r['src_zone_id'] == null ? 'all' : (zones.find((z) => z.id === r['src_zone_id'])?.name ?? String(r['src_zone_id'])), }, { key: 'dst_zone_id' as const, label: 'Destination', - render: (r: AnyEntity) => zones.find((z) => z.id === r['dst_zone_id'])?.name ?? String(r['dst_zone_id']), + render: (r: AnyEntity) => r['dst_zone_id'] == null ? 'all' : (zones.find((z) => z.id === r['dst_zone_id'])?.name ?? String(r['dst_zone_id'])), }, { key: 'policy' as const, label: 'Policy' }, { key: 'log_level' as const, label: 'Log Level' }, { key: 'position' as const, label: 'Position' }, ] as Column[], fields: [ - { name: 'src_zone_id', label: 'Source Zone', required: true, type: 'select' as const, options: zoneOptions }, - { name: 'dst_zone_id', label: 'Destination Zone', required: true, type: 'select' as const, options: zoneOptions }, + { name: 'src_zone_id', label: 'Source Zone', type: 'select' as const, options: [{ value: '', label: 'all' }, ...zoneOptions] }, + { name: 'dst_zone_id', label: 'Destination Zone', type: 'select' as const, options: [{ value: '', label: 'all' }, ...zoneOptions] }, { name: 'policy', label: 'Policy', required: true, type: 'select' as const, options: [{ value: 'ACCEPT', label: 'ACCEPT' }, { value: 'DROP', label: 'DROP' }, { value: 'REJECT', label: 'REJECT' }, { value: 'CONTINUE', label: 'CONTINUE' }] }, { name: 'log_level', label: 'Log Level' }, { name: 'comment', label: 'Comment' },