Compare commits

..

16 Commits

Author SHA1 Message Date
mteehan b673e87c9b refactor: introduce model layer for centralized data synchronization
Add hoover model.js as a central reactive store per subsystem, replacing
per-component data fetching with a single source of truth.

- Add hoover/model.js with modelRegister, modelFetch, and WS invalidation
- Refactor websocket.js to route messages to model refresh (drop per-component
  subscribe/unsubscribe)
- Simplify component.js by removing WS subscription management
- Add refresh option to apiSubmit, deprecate refactorLoad and checkAbort
- Rewrite all pages to use getModel() instead of inline data fetching
- Bootstrap model registrations in app.js
- Add GET /api/firewall/state endpoint
- Fix restart-services.sh restart order and add service health verification
- Update hoover.md docs with model layer architecture
2026-06-22 22:54:29 +00:00
mteehan 633505e7dc refactor: modernize frontend with hoover framework components and docs
- Add quick modal, table, service status, and confirmation dialog components
- Refactor all pages (certs, dhcp, proxy, etc.) to use new component patterns
- Introduce refactor load utility and render guard for consistent UX
- Add hoover documentation and update AGENTS.md, architecture, overview
2026-06-21 04:29:27 +00:00
mteehan b8f20e99d9 agent update 2026-06-17 03:59:37 +00:00
mteehan 687fa8f52f pre-refactor 2026-06-17 03:41:08 +00:00
mteehan 318d7169f7 Switch networkctl parsing from text to JSON output
Replace fragile text-based parsing of ● 1: lo
                   Link File: n/a
                Network File: n/a
                       State: carrier (unmanaged)
                Online state: unknown
                        Type: loopback
            Hardware Address: 00:00:00:00:00:00
                         MTU: 65536
                       QDisc: noqueue
IPv6 Address Generation Mode: eui64
    Number of Queues (Tx/Rx): 1/1
                     Address: 127.0.0.1
                              ::1

May 30 22:43:27 vacuum-wall systemd-networkd[315]: lo: Link UP
May 30 22:43:27 vacuum-wall systemd-networkd[315]: lo: Gained carrier

● 77: eth0
                   Link File: /usr/lib/systemd/network/99-default.link
                Network File: /etc/systemd/network/eth0.network
                       State: routable (configured)
                Online state: online
                        Type: ether
                        Kind: veth
                      Driver: veth
            Hardware Address: 8e:63:52:6b:ea:e8
                         MTU: 1500 (min: 68, max: 65535)
                       QDisc: noqueue
IPv6 Address Generation Mode: eui64
    Number of Queues (Tx/Rx): 8/8
            Auto negotiation: no
                       Speed: 10Gbps
                      Duplex: full
                        Port: tp
                     Address: 192.168.1.5 (DHCPv4 via 192.168.1.1)
                              2600:4040:a6c1:4a00:8c63:52ff:fe6b:eae8
                              fe80::8c63:52ff:fe6b:eae8
                     Gateway: 192.168.1.1
                              fe80::3ebd:c5ff:fe2b:bd99
                         DNS: 192.168.1.1
                              2600:4040:a6c1:4a00::1
              Search Domains: myfiosgateway.com
           Activation Policy: up
         Required For Online: yes
            DHCPv4 Client ID: 8e:63:52:6b:ea:e8
          DHCPv6 Client IAID: 0xf3d61521
          DHCPv6 Client DUID: DUID-EN/Vendor:0000ab11b94215a519e8ca54

May 30 22:43:27 vacuum-wall systemd-networkd[315]: eth0: Link UP
May 30 22:43:27 vacuum-wall systemd-networkd[315]: eth0: Gained carrier
May 30 22:43:27 vacuum-wall systemd-networkd[315]: eth0: Configuring with /etc/systemd/network/eth0.network.
May 30 22:43:27 vacuum-wall systemd-networkd[315]: eth0: Gained IPv6LL
May 30 22:43:27 vacuum-wall systemd-networkd[315]: eth0: DHCPv4 address 192.168.1.5/24, gateway 192.168.1.1 acquired from 192.168.1.1

● 79: eth1
                   Link File: /usr/lib/systemd/network/99-default.link
                Network File: /etc/systemd/network/50-eth1.network
                       State: routable (configured)
                Online state: online
                        Type: ether
                        Kind: veth
                      Driver: veth
            Hardware Address: 8e:63:52:6b:ea:8e
                         MTU: 1500 (min: 68, max: 65535)
                       QDisc: noqueue
IPv6 Address Generation Mode: eui64
    Number of Queues (Tx/Rx): 8/8
            Auto negotiation: no
                       Speed: 10Gbps
                      Duplex: full
                        Port: tp
                     Address: 10.4.20.1
                              fd42:a304:c836:2a7f:8c63:52ff:fe6b:ea8e
                              fe80::8c63:52ff:fe6b:ea8e
                     Gateway: fe80::1266:6aff:fe76:bc5b
                         DNS: fd42:a304:c836:2a7f::1
           Activation Policy: up
         Required For Online: yes
          DHCPv6 Client IAID: 0x1da7c7a5
          DHCPv6 Client DUID: DUID-EN/Vendor:0000ab11b94215a519e8ca54

Jun 01 03:48:43 vacuum-wall systemd-networkd[315]: eth1: Reconfiguring with /etc/systemd/network/50-eth1.network.
Jun 01 04:05:47 vacuum-wall systemd-networkd[315]: eth1: Reconfiguring with /etc/systemd/network/99-eth1.network.
Jun 01 04:05:47 vacuum-wall systemd-networkd[315]: eth1: DHCPv6 lease lost
Jun 01 04:05:47 vacuum-wall systemd-networkd[315]: eth1: Reconfiguring with /etc/systemd/network/99-eth1.network.
Jun 01 04:06:51 vacuum-wall systemd-networkd[315]: eth1: Reconfiguring with /etc/systemd/network/99-eth1.network.
Jun 01 04:06:51 vacuum-wall systemd-networkd[315]: eth1: DHCPv6 lease lost
Jun 01 04:06:51 vacuum-wall systemd-networkd[315]: eth1: Reconfiguring with /etc/systemd/network/99-eth1.network.
Jun 01 04:09:35 vacuum-wall systemd-networkd[315]: eth1: Reconfiguring with /etc/systemd/network/50-eth1.network.
Jun 01 04:09:35 vacuum-wall systemd-networkd[315]: eth1: DHCPv6 lease lost
Jun 01 04:09:35 vacuum-wall systemd-networkd[315]: eth1: Reconfiguring with /etc/systemd/network/50-eth1.network. with structured JSON parsing using {"Interfaces":[{"Index":1,"Name":"lo","Type":"loopback","Flags":65609,"FlagsString":"up,loopback,running,lower-up","KernelOperationalState":0,"KernelOperationalStateString":"unknown","MTU":65536,"MinimumMTU":0,"MaximumMTU":4294967295,"AdministrativeState":"unmanaged","OperationalState":"carrier","CarrierState":"carrier","AddressState":"off","IPv4AddressState":"off","IPv6AddressState":"off","OnlineState":null,"Addresses":[{"Family":2,"Address":[127,0,0,1],"PrefixLength":8,"ConfigSource":"foreign","Scope":254,"ScopeString":"host","Flags":128,"FlagsString":"permanent","ConfigState":"configured"},{"Family":10,"Address":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],"PrefixLength":128,"ConfigSource":"foreign","Scope":254,"ScopeString":"host","Flags":128,"FlagsString":"permanent","ConfigState":"configured"}],"Routes":[{"Family":10,"Destination":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],"DestinationPrefixLength":128,"TOS":0,"Scope":0,"Protocol":2,"Type":2,"Priority":0,"Table":255,"Flags":0,"ConfigSource":"foreign","ScopeString":"global","ProtocolString":"kernel","TypeString":"local","TableString":"local","Preference":0,"FlagsString":"","ConfigState":"configured"},{"Family":2,"Destination":[127,255,255,255],"DestinationPrefixLength":32,"PreferredSource":[127,0,0,1],"TOS":0,"Scope":253,"Protocol":2,"Type":3,"Priority":0,"Table":255,"Flags":0,"ConfigSource":"foreign","ScopeString":"link","ProtocolString":"kernel","TypeString":"broadcast","TableString":"local","Preference":0,"FlagsString":"","ConfigState":"configured"},{"Family":2,"Destination":[127,0,0,1],"DestinationPrefixLength":32,"PreferredSource":[127,0,0,1],"TOS":0,"Scope":254,"Protocol":2,"Type":2,"Priority":0,"Table":255,"Flags":0,"ConfigSource":"foreign","ScopeString":"host","ProtocolString":"kernel","TypeString":"local","TableString":"local","Preference":0,"FlagsString":"","ConfigState":"configured"},{"Family":2,"Destination":[127,0,0,0],"DestinationPrefixLength":8,"PreferredSource":[127,0,0,1],"TOS":0,"Scope":254,"Protocol":2,"Type":2,"Priority":0,"Table":255,"Flags":0,"ConfigSource":"foreign","ScopeString":"host","ProtocolString":"kernel","TypeString":"local","TableString":"local","Preference":0,"FlagsString":"","ConfigState":"configured"}]},{"Index":77,"Name":"eth0","Kind":"veth","Type":"ether","Driver":"veth","Flags":69699,"FlagsString":"up,broadcast,running,multicast,lower-up","KernelOperationalState":6,"KernelOperationalStateString":"up","MTU":1500,"MinimumMTU":68,"MaximumMTU":65535,"HardwareAddress":[142,99,82,107,234,232],"BroadcastAddress":[255,255,255,255,255,255],"IPv6LinkLocalAddress":[254,128,0,0,0,0,0,0,140,99,82,255,254,107,234,232],"AdministrativeState":"configured","OperationalState":"routable","CarrierState":"carrier","AddressState":"routable","IPv4AddressState":"routable","IPv6AddressState":"routable","OnlineState":"online","NetworkFile":"/etc/systemd/network/eth0.network","NetworkFileDropins":[],"RequiredForOnline":true,"RequiredOperationalStateForOnline":[null,null],"RequiredFamilyForOnline":"any","ActivationPolicy":"up","DNS":[{"Family":2,"Address":[192,168,1,1],"ConfigSource":"DHCPv4","ConfigProvider":[192,168,1,1]},{"Family":10,"Address":[38,0,64,64,166,193,74,0,0,0,0,0,0,0,0,1],"ConfigSource":"NDisc","ConfigProvider":[254,128,0,0,0,0,0,0,62,189,197,255,254,43,189,153]}],"SearchDomains":[{"Domain":"myfiosgateway.com","ConfigSource":"DHCPv4","ConfigProvider":[192,168,1,1]}],"DNSSettings":[{"LLMNR":"yes","ConfigSource":"static"},{"MDNS":"no","ConfigSource":"static"}],"Addresses":[{"Family":2,"Address":[192,168,1,5],"PrefixLength":24,"ConfigSource":"DHCPv4","ConfigProvider":[192,168,1,1],"Broadcast":[192,168,1,255],"Scope":0,"ScopeString":"global","Flags":0,"FlagsString":null,"PreferredLifetimeUSec":1734964293205,"PreferredLifetimeUsec":1734964293205,"ValidLifetimeUSec":1734964293205,"ValidLifetimeUsec":1734964293205,"ConfigState":"configured"},{"Family":10,"Address":[254,128,0,0,0,0,0,0,140,99,82,255,254,107,234,232],"PrefixLength":64,"ConfigSource":"foreign","Scope":253,"ScopeString":"link","Flags":128,"FlagsString":"permanent","ConfigState":"configured"},{"Family":10,"Address":[38,0,64,64,166,193,74,0,140,99,82,255,254,107,234,232],"PrefixLength":64,"ConfigSource":"NDisc","ConfigProvider":[254,128,0,0,0,0,0,0,62,189,197,255,254,43,189,153],"Scope":0,"ScopeString":"global","Flags":768,"FlagsString":"manage-temporary-address,no-prefixroute","PreferredLifetimeUSec":1677495569859,"PreferredLifetimeUsec":1677495569859,"ValidLifetimeUSec":1677495569859,"ValidLifetimeUsec":1677495569859,"ConfigState":"configured"}],"NextHops":[{"ID":1635324079,"Family":10,"ConfigSource":"NDisc","ConfigProvider":[254,128,0,0,0,0,0,0,62,189,197,255,254,43,189,153],"Gateway":[254,128,0,0,0,0,0,0,62,189,197,255,254,43,189,153],"Flags":0,"FlagsString":"","Protocol":9,"ProtocolString":"9","Blackhole":false,"ConfigState":"configured"}],"Routes":[{"Family":2,"Destination":[192,168,1,0],"DestinationPrefixLength":24,"PreferredSource":[192,168,1,5],"TOS":0,"Scope":253,"Protocol":2,"Type":1,"Priority":1024,"Table":254,"Flags":0,"ConfigSource":"foreign","ScopeString":"link","ProtocolString":"kernel","TypeString":"unicast","TableString":"main","Preference":0,"FlagsString":"","ConfigState":"configured"},{"Family":2,"Destination":[192,168,1,255],"DestinationPrefixLength":32,"PreferredSource":[192,168,1,5],"TOS":0,"Scope":253,"Protocol":2,"Type":3,"Priority":0,"Table":255,"Flags":0,"ConfigSource":"foreign","ScopeString":"link","ProtocolString":"kernel","TypeString":"broadcast","TableString":"local","Preference":0,"FlagsString":"","ConfigState":"configured"},{"Family":10,"Destination":[38,0,64,64,166,193,74,0,140,99,82,255,254,107,234,232],"DestinationPrefixLength":128,"TOS":0,"Scope":0,"Protocol":2,"Type":2,"Priority":0,"Table":255,"Flags":0,"ConfigSource":"foreign","ScopeString":"global","ProtocolString":"kernel","TypeString":"local","TableString":"local","Preference":0,"FlagsString":"","ConfigState":"configured"},{"Family":10,"Destination":[38,0,64,64,166,193,74,0,0,0,0,0,0,0,0,0],"DestinationPrefixLength":64,"TOS":0,"Scope":0,"Protocol":9,"Type":1,"Priority":1024,"Table":254,"Flags":0,"ConfigSource":"NDisc","ConfigProvider":[254,128,0,0,0,0,0,0,62,189,197,255,254,43,189,153],"ScopeString":"global","ProtocolString":"9","TypeString":"unicast","TableString":"main","Preference":0,"FlagsString":"","LifetimeUSec":1677495568830,"ConfigState":"configured"},{"Family":10,"Destination":[254,128,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"DestinationPrefixLength":64,"TOS":0,"Scope":0,"Protocol":2,"Type":1,"Priority":256,"Table":254,"Flags":0,"ConfigSource":"foreign","ScopeString":"global","ProtocolString":"kernel","TypeString":"unicast","TableString":"main","Preference":0,"FlagsString":"","ConfigState":"configured"},{"Family":2,"Destination":[0,0,0,0],"DestinationPrefixLength":0,"Gateway":[192,168,1,1],"PreferredSource":[192,168,1,5],"TOS":0,"Scope":0,"Protocol":16,"Type":1,"Priority":1024,"Table":254,"Flags":0,"ConfigSource":"DHCPv4","ConfigProvider":[192,168,1,1],"ScopeString":"global","ProtocolString":"16","TypeString":"unicast","TableString":"main","Preference":0,"FlagsString":"","ConfigState":"configured"},{"Family":2,"Destination":[192,168,1,1],"DestinationPrefixLength":32,"PreferredSource":[192,168,1,5],"TOS":0,"Scope":253,"Protocol":16,"Type":1,"Priority":1024,"Table":254,"Flags":0,"ConfigSource":"DHCPv4","ConfigProvider":[192,168,1,1],"ScopeString":"link","ProtocolString":"16","TypeString":"unicast","TableString":"main","Preference":0,"FlagsString":"","ConfigState":"configured"},{"Family":2,"Destination":[192,168,1,5],"DestinationPrefixLength":32,"PreferredSource":[192,168,1,5],"TOS":0,"Scope":254,"Protocol":2,"Type":2,"Priority":0,"Table":255,"Flags":0,"ConfigSource":"foreign","ScopeString":"host","ProtocolString":"kernel","TypeString":"local","TableString":"local","Preference":0,"FlagsString":"","ConfigState":"configured"},{"Family":10,"Destination":[255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"DestinationPrefixLength":8,"TOS":0,"Scope":0,"Protocol":2,"Type":5,"Priority":256,"Table":255,"Flags":0,"ConfigSource":"foreign","ScopeString":"global","ProtocolString":"kernel","TypeString":"multicast","TableString":"local","Preference":0,"FlagsString":"","ConfigState":"configured"},{"Family":10,"Destination":[254,128,0,0,0,0,0,0,140,99,82,255,254,107,234,232],"DestinationPrefixLength":128,"TOS":0,"Scope":0,"Protocol":2,"Type":2,"Priority":0,"Table":255,"Flags":0,"ConfigSource":"foreign","ScopeString":"global","ProtocolString":"kernel","TypeString":"local","TableString":"local","Preference":0,"FlagsString":"","ConfigState":"configured"}],"DHCPv4Client":{"Lease":{"LeaseTimestampUSec":1648564292241,"Timeout1USec":1691764292241,"Timeout2USec":1724164292241},"ClientIdentifier":[1,142,99,82,107,234,232]},"DHCPv6Client":{"Lease":{"LeaseTimestampUSec":958440922609},"DUID":[0,2,0,0,171,17,185,66,21,165,25,232,202,84]}},{"Index":79,"Name":"eth1","Kind":"veth","Type":"ether","Driver":"veth","Flags":69699,"FlagsString":"up,broadcast,running,multicast,lower-up","KernelOperationalState":6,"KernelOperationalStateString":"up","MTU":1500,"MinimumMTU":68,"MaximumMTU":65535,"HardwareAddress":[142,99,82,107,234,142],"BroadcastAddress":[255,255,255,255,255,255],"IPv6LinkLocalAddress":[254,128,0,0,0,0,0,0,140,99,82,255,254,107,234,142],"AdministrativeState":"configured","OperationalState":"routable","CarrierState":"carrier","AddressState":"routable","IPv4AddressState":"routable","IPv6AddressState":"routable","OnlineState":"online","NetworkFile":"/etc/systemd/network/50-eth1.network","NetworkFileDropins":[],"RequiredForOnline":true,"RequiredOperationalStateForOnline":[null,null],"RequiredFamilyForOnline":"any","ActivationPolicy":"up","DNS":[{"Family":10,"Address":[253,66,163,4,200,54,42,127,0,0,0,0,0,0,0,1],"ConfigSource":"DHCPv6","ConfigProvider":[254,128,0,0,0,0,0,0,18,102,106,255,254,118,188,91]},{"Family":10,"Address":[253,66,163,4,200,54,42,127,0,0,0,0,0,0,0,1],"ConfigSource":"NDisc","ConfigProvider":[254,128,0,0,0,0,0,0,18,102,106,255,254,118,188,91]}],"DNSSettings":[{"LLMNR":"yes","ConfigSource":"static"},{"MDNS":"no","ConfigSource":"static"}],"Addresses":[{"Family":10,"Address":[254,128,0,0,0,0,0,0,140,99,82,255,254,107,234,142],"PrefixLength":64,"ConfigSource":"foreign","Scope":253,"ScopeString":"link","Flags":128,"FlagsString":"permanent","ConfigState":"configured"},{"Family":2,"Address":[10,4,20,1],"PrefixLength":24,"ConfigSource":"static","Broadcast":[10,4,20,255],"Scope":0,"ScopeString":"global","Flags":128,"FlagsString":"permanent","ConfigState":"configured"},{"Family":10,"Address":[253,66,163,4,200,54,42,127,140,99,82,255,254,107,234,142],"PrefixLength":64,"ConfigSource":"NDisc","ConfigProvider":[254,128,0,0,0,0,0,0,18,102,106,255,254,118,188,91],"Scope":0,"ScopeString":"global","Flags":896,"FlagsString":"permanent,manage-temporary-address,no-prefixroute","ConfigState":"configured"}],"NextHops":[{"ID":3144860678,"Family":10,"ConfigSource":"NDisc","ConfigProvider":[254,128,0,0,0,0,0,0,18,102,106,255,254,118,188,91],"Gateway":[254,128,0,0,0,0,0,0,18,102,106,255,254,118,188,91],"Flags":0,"FlagsString":"","Protocol":9,"ProtocolString":"9","Blackhole":false,"ConfigState":"configured"}],"Routes":[{"Family":2,"Destination":[10,4,20,255],"DestinationPrefixLength":32,"PreferredSource":[10,4,20,1],"TOS":0,"Scope":253,"Protocol":2,"Type":3,"Priority":0,"Table":255,"Flags":0,"ConfigSource":"foreign","ScopeString":"link","ProtocolString":"kernel","TypeString":"broadcast","TableString":"local","Preference":0,"FlagsString":"","ConfigState":"configured"},{"Family":10,"Destination":[254,128,0,0,0,0,0,0,140,99,82,255,254,107,234,142],"DestinationPrefixLength":128,"TOS":0,"Scope":0,"Protocol":2,"Type":2,"Priority":0,"Table":255,"Flags":0,"ConfigSource":"foreign","ScopeString":"global","ProtocolString":"kernel","TypeString":"local","TableString":"local","Preference":0,"FlagsString":"","ConfigState":"configured"},{"Family":10,"Destination":[253,66,163,4,200,54,42,127,0,0,0,0,0,0,0,0],"DestinationPrefixLength":64,"TOS":0,"Scope":0,"Protocol":9,"Type":1,"Priority":1024,"Table":254,"Flags":0,"ConfigSource":"NDisc","ConfigProvider":[254,128,0,0,0,0,0,0,18,102,106,255,254,118,188,91],"ScopeString":"global","ProtocolString":"9","TypeString":"unicast","TableString":"main","Preference":0,"FlagsString":"","ConfigState":"configured"},{"Family":10,"Destination":[253,66,163,4,200,54,42,127,140,99,82,255,254,107,234,142],"DestinationPrefixLength":128,"TOS":0,"Scope":0,"Protocol":2,"Type":2,"Priority":0,"Table":255,"Flags":0,"ConfigSource":"foreign","ScopeString":"global","ProtocolString":"kernel","TypeString":"local","TableString":"local","Preference":0,"FlagsString":"","ConfigState":"configured"},{"Family":2,"Destination":[10,4,20,1],"DestinationPrefixLength":32,"PreferredSource":[10,4,20,1],"TOS":0,"Scope":254,"Protocol":2,"Type":2,"Priority":0,"Table":255,"Flags":0,"ConfigSource":"foreign","ScopeString":"host","ProtocolString":"kernel","TypeString":"local","TableString":"local","Preference":0,"FlagsString":"","ConfigState":"configured"},{"Family":2,"Destination":[10,4,20,0],"DestinationPrefixLength":24,"PreferredSource":[10,4,20,1],"TOS":0,"Scope":253,"Protocol":2,"Type":1,"Priority":0,"Table":254,"Flags":0,"ConfigSource":"foreign","ScopeString":"link","ProtocolString":"kernel","TypeString":"unicast","TableString":"main","Preference":0,"FlagsString":"","ConfigState":"configured"}],"DHCPv6Client":{"Lease":{"LeaseTimestampUSec":1585104388996},"DUID":[0,2,0,0,171,17,185,66,21,165,25,232,202,84]}}],"Routes":[{"Family":10,"Destination":[38,0,64,64,166,193,74,0,0,0,0,0,0,0,0,0],"DestinationPrefixLength":56,"TOS":0,"Scope":0,"Protocol":9,"Type":1,"Priority":512,"Table":254,"Flags":0,"NextHopID":1635324079,"ConfigSource":"NDisc","ConfigProvider":[254,128,0,0,0,0,0,0,62,189,197,255,254,43,189,153],"ScopeString":"global","ProtocolString":"9","TypeString":"unicast","TableString":"main","Preference":1,"FlagsString":"","LifetimeUSec":1677495568830,"ConfigState":"configured"},{"Family":10,"Destination":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"DestinationPrefixLength":0,"TOS":0,"Scope":0,"Protocol":9,"Type":1,"Priority":1024,"Table":254,"Flags":0,"NextHopID":3144860678,"ConfigSource":"NDisc","ConfigProvider":[254,128,0,0,0,0,0,0,18,102,106,255,254,118,188,91],"ScopeString":"global","ProtocolString":"9","TypeString":"unicast","TableString":"main","Preference":0,"FlagsString":"","LifetimeUSec":1671685709586,"ConfigState":"configured"},{"Family":10,"Destination":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"DestinationPrefixLength":0,"TOS":0,"Scope":0,"Protocol":9,"Type":1,"Priority":1024,"Table":254,"Flags":0,"NextHopID":1635324079,"ConfigSource":"NDisc","ConfigProvider":[254,128,0,0,0,0,0,0,62,189,197,255,254,43,189,153],"ScopeString":"global","ProtocolString":"9","TypeString":"unicast","TableString":"main","Preference":0,"FlagsString":"","LifetimeUSec":1671195568830,"ConfigState":"configured"}],"RoutingPolicyRules":[{"Family":10,"Protocol":2,"ProtocolString":"kernel","TOS":0,"Type":1,"TypeString":"table","IPProtocol":0,"IPProtocolString":"ip","Priority":0,"FirewallMark":0,"FirewallMask":0,"Table":255,"TableString":"local","Invert":false,"ConfigSource":"foreign","ConfigState":"configured"},{"Family":10,"Protocol":2,"ProtocolString":"kernel","TOS":0,"Type":1,"TypeString":"table","IPProtocol":0,"IPProtocolString":"ip","Priority":32766,"FirewallMark":0,"FirewallMask":0,"Table":254,"TableString":"main","Invert":false,"ConfigSource":"foreign","ConfigState":"configured"},{"Family":2,"Protocol":2,"ProtocolString":"kernel","TOS":0,"Type":1,"TypeString":"table","IPProtocol":0,"IPProtocolString":"ip","Priority":32767,"FirewallMark":0,"FirewallMask":0,"Table":253,"TableString":"default","Invert":false,"ConfigSource":"foreign","ConfigState":"configured"},{"Family":2,"Protocol":2,"ProtocolString":"kernel","TOS":0,"Type":1,"TypeString":"table","IPProtocol":0,"IPProtocolString":"ip","Priority":0,"FirewallMark":0,"FirewallMask":0,"Table":255,"TableString":"local","Invert":false,"ConfigSource":"foreign","ConfigState":"configured"},{"Family":2,"Protocol":2,"ProtocolString":"kernel","TOS":0,"Type":1,"TypeString":"table","IPProtocol":0,"IPProtocolString":"ip","Priority":32766,"FirewallMark":0,"FirewallMask":0,"Table":254,"TableString":"main","Invert":false,"ConfigSource":"foreign","ConfigState":"configured"}]}. This provides more reliable and maintainable runtime state extraction.

lib/network.py:
- Rewrite parse_networkctl_status() to parse JSON instead of text lines
- Add _bytes_to_ip() helper for converting address byte arrays to IP strings
- Extract addresses, gateway (from Routes), DNS, MAC, and state from structured JSON
- Add proper error handling for malformed JSON input

daemon/handlers/network.py:
- Update all 3 callers (get_interfaces, get_interface, save_interface) to use --json=short
- Fix get_interfaces to include runtime-only interfaces by unioning config and runtime names (minus lo), rather than only iterating config-defined interfaces

lib/state.py:
- Update _collect_networkd to use --json=short flag

tests/test_network.py, tests/test_network_integration.py:
- Update all test fixtures from text output to matching JSON structure
2026-06-16 04:45:59 +00:00
mteehan 2f78102090 chore: cleanup pyproject, vendor script, and add restart-services.sh 2026-06-16 03:50:00 +00:00
mteehan 7abe7700e9 test: update and add tests for all updated subsystems 2026-06-16 03:50:00 +00:00
mteehan 6e814d2827 refactor: update system config, sudoers, and install script 2026-06-16 03:50:00 +00:00
mteehan 708b8b5d15 refactor: update all API blueprints (certs, dhcp, firewall, logs, network, proxy, wireguard) 2026-06-16 03:50:00 +00:00
mteehan 4fc0fb3f72 refactor: overhaul daemon server, client, and handlers 2026-06-16 03:50:00 +00:00
mteehan c5813d68b3 refactor: update lib modules (common, dnsmasq, logging, network, state, nginx) 2026-06-16 03:50:00 +00:00
mteehan 593dece92b refactor: replace Jinja templates with static frontend pages 2026-06-16 03:50:00 +00:00
mteehan 2874680ffa chore: add node_modules to .gitignore (debugging only, never committed) 2026-06-16 03:49:15 +00:00
mteehan b8c2fa2f24 docs: update all documentation and AGENTS.md 2026-06-16 03:48:04 +00:00
mteehan bc72db903c feat: add networkd subsystem and fix code review issues
Phase 1-4: Networkd subsystem
- lib/network.py: systemd-networkd config renderer (.network INI files)
  with full schema support: [Match], [Link], [Network], [Address], [Route],
  [DHCPv4], [DHCPv6] sections. One Address/=DNS= line per value per spec.
  Route sections use #N suffix per systemd.syntax(7).
- lib/network.py: generate_network_files() with 50-<name>.network prefix
  and stale file cleanup
- lib/network.py: collect_upstream_dns() filters local/private DNS
- lib/network.py: infer_dhcp_ranges() and infer_zones() helpers
- daemon/handlers/network.py: routes for GET/POST /network/interfaces
  and full apply with DNS upstream sync to dnsmasq
- webui/api/network.py: Flask blueprint for /api/network/* endpoints
- webui/api: interfaces page updated with IP config inline editing
- lib/state.py: networkd collector using parse_networkctl_status()
- system/sudoers.d/vacuum-walld: networkctl + systemd-network rules
- system/systemd/vacuum-walld.service: ReadWritePaths for /etc/systemd/network
- install.sh: ACME email now optional, configured from WebUI
- lib/acme.py: get_email() falls back to declarative config

Phase 5: Code review fixes
- daemon/server.py: path params now win over JSON body and query params
  in request body merge (prevents config save name override)
- daemon/server.py: remove dead 'import re'
- daemon/handlers/network.py: replace Path.mkdir() with sudo mkdir
  for /etc/systemd/network (ProtectSystem=strict compatibility)
- system/sudoers.d/vacuum-walld: pin systemctl to specific commands
  (reload/is-active dnsmasq instead of wildcard)
- system/sudoers.d/vacuum-walld: restore !requiretty and section comment
- lib/network.py: remove unused _MANAGEMENT_PORTS constant
- webui/api/network.py: remove redundant body[\name\] = name in save_interface

Tests: 332 passing (110 new/updated), ruff clean
2026-06-01 03:15:50 +00:00
mteehan 2f215793e9 docs: add docstrings to all API endpoints and daemon handlers
Add comprehensive docstrings to firewall, DHCP, proxy, wireguard, certs,
and logs API endpoints. Document parameters, return values, and error cases
for the documentation system.
2026-05-30 16:15:45 +00:00
93 changed files with 11502 additions and 3346 deletions
+6
View File
@@ -15,10 +15,16 @@ __pycache__/
# Local AI tool config (contains internal hostnames)
opencode.json
opencode.json.pwenv
PLAN.md
# Playwright MCP artifacts
.playwright-mcp/
# Node dependencies (debugging only, never committed)
node_modules/
package.json
package-lock.json
# Runtime artifacts
build/
config/
+55 -30
View File
@@ -2,7 +2,7 @@
## What This Is
SSL proxy / firewall appliance. Python 3 Flask WebUI behind nginx reverse proxy.
SSL proxy / firewall appliance. Python 3.13+ Flask SPA behind nginx reverse proxy.
Deploys on Debian 13 (trixie). Serves from repo root by default.
## Architecture
@@ -15,39 +15,65 @@ vacuum-walld ──→ daemon/handlers/*.py ──→ sudo <cmd> ──→ syste
### Two-User Model with Shared Group
- **`vacuum-walld`** (daemon user): runs the privileged background daemon with `NOPASSWD sudo` whitelist (`/etc/sudoers.d/vacuum-walld`). Owns project directory and socket. Primary group is the WebUI user's primary group.
- **`vacuum-walld`** (daemon user): runs the privileged background daemon with `NOPASSWD sudo` whitelist (`/etc/sudoers.d/vacuum-walld`). Owns socket. Primary group is the WebUI user's primary group. Daemon user name is derived: `USER_NAME` + `d`.
- **WebUI user** (default: repo owner in `--dev` mode): runs the Flask process with **zero sudo** access. Communicates with the daemon via Unix socket.
- **Shared group**: both users share the WebUI user's primary group. Socket is `vacuum-walld:<group>` with mode `0660`. Project dir is owned by the WebUI user with group-read+execute.
- **Shared group**: both users share the WebUI user's primary group. Socket is `vacuum-walld:<group>` with mode `0660`.
### Code Layout
- `webui/server.py` — Flask app entry point. **Only** file that creates the `app`.
- `webui/server.py` — Flask app entry point. **Only** file that creates the `app`. SPA catch-all renders `index.html` with server-side `__WS_URL_PLACEHOLDER__` substitution (no Jinja).
- `webui/api/*.py` — Flask blueprints, one per subsystem. Routes prefix `/api/<subsystem>/`. All call `daemon.client` instead of `lib/` directly.
- `webui/api/common.py` — Shared `_ok()` / `_error()` response helpers used by all blueprints.
- `daemon/server.py` — aiohttp server, cache engine, batch routing, handler registry.
- `daemon/server.py` — aiohttp server, route registry, batch routing, WebSocket broadcast, state refresh.
- `daemon/client.py` — Sync HTTP client over Unix socket using `requests_unixsocket.Session`.
- `daemon/iface.py`**Single source of truth** for all daemon API endpoints. Every endpoint is a frozen `(method, path)` tuple. Renaming an endpoint here auto-updates both server registry and client calls. All blueprints and handlers import from here.
- `daemon/handlers/*.py` — Privileged operation handlers (all `sudo` calls live here).
- `lib/common.py`Shared utilities: `run()`, `run_proc()`, `load_json()`, `save_json()`, `deep_merge()`, `ensure_dirs()`. All `lib/` modules use these instead of defining local helpers.
- `lib/*.py`Backend modules (parsing, config, shared logic). All have full type hints and `__all__` exports. No sudo calls — privilege escalation is handled by `daemon/handlers/*.py`.
- `lib/state.py`In-memory state store with per-subsystem collectors. Populated at daemon startup, refreshed on request. Backs WebSocket versioning/broadcast.
- `lib/common.py`Shared utilities: `run()`, `run_proc()`, `load_json()`, `save_json()`, `deep_merge()`, `ensure_dirs()`, `validate_interface_name()`. All `lib/` modules use these instead of defining local helpers.
- `lib/logging.py` — Logging setup used by both webui and daemon.
- `lib/*.py` — Backend modules (parsing, config, shared logic). Full type hints and `__all__` exports. No sudo calls.
- `vendor/` — Vendored scripts and JS libraries (`acme.sh`, `htmx`, `json-enc`).
- `data/` — Runtime artifacts (generated .confs, `.htpasswd`, ACME certs, firewall backup, dnsmasq fragments).
- `config/<subsystem>/config.json` — Declarative JSON configs (source of truth). Generated `.conf` in `data/nginx/sites-enabled/`. Certs in `data/acme/`.
- `system/` — System file templates. `systemd/` (service units installed to `/etc/systemd/system/`), `sudoers.d/`, `nginx/`.
- `webui/static/` — Vendored frontend libraries (JS + CSS). Flask auto-serves at `/static/`.
- `system/` — System file templates. `systemd/` (units installed to `/etc/systemd/system/`), `sudoers.d/`, `nginx/`.
Project uses `.venv`. Install deps with `pip install -e .` (from `pyproject.toml`). `__init__.py` files in `webui/` and `lib/` are intentionally empty — no `sys.path` boilerplate needed.
Project uses `.venv`. Install deps with `pip install -e .` (from `pyproject.toml`). `__init__.py` files in `webui/` and `lib/` are intentionally empty.
**No CDN packages.** All frontend libraries (JS and CSS) must be vendored in `webui/static/`. Never reference `unpkg.com`, `cdn.jsdelivr.net`, or similar. To add/update a library, edit the version in `scripts/update-vendor.sh` and run it.
### Frontend (hoover)
| Library | Version | Vendor file | Symlink (active) | CDN source |
| ------- | ------- | ------------------------------------|--------------------------------| ---------- |
| htmx | 2.0.4 | `vendor/htmx-2.0.4.min.js` | `webui/static/htmx.min.js` | `npm:htmx.org@2.0.4` |
| htmx-ext-json-enc | 2.0.0 | `vendor/json-enc-2.0.0.js` | `webui/static/json-enc.js` | `npm:htmx-ext-json-enc@2.0.0` |
Custom reactive SPA framework at `webui/static/hoover/`. See `docs/hoover.md` for full API reference.
Conventions:
- All imports from `/static/hoover/index.js` (barrel export of reactivity, VDOM, router, API, components).
- Pages in `webui/static/pages/` export `definePage({ init, subscribe, load, render })` as default.
- Bootstrap: `webui/static/app.js` mounts two render roots (`#sidebar`, `#main`), then `connect()` for WS.
- `h()` builds VNodes; `#comp` + `hComp()` for component lifecycle; `key` for keyed diff.
- Events use `on:` prefix (`on:click`, `on:submit`). `class` prop accepts object.
- State always has `loading`, `refreshing`, `error` plus data. `load()` receives `(state, abortController, entry)`.
- `openModal` + `formModal` for dialogs; `apiSubmit()` for form submission. `ToastContainer()` in main root.
- No build step — ES modules served raw. Assets versioned via `?v=N` query string.
### Daemon Endpoints
- Unix socket at `data/daemon.sock` (configurable via `VACUUM_WALLD_SOCKET` env var)
- WebSocket at `127.0.0.1:9091` (configurable via `VACUUM_WALLD_WS_PORT`) for real-time state change notifications
- Can also be started as `python -m daemon` or via the `vacuum-walld` console script
## Environment Variables
- `VACUUM_WALL_DEV` — dev mode flag; when set, disables aggressive static asset caching
- `VACUUM_WALLD_SOCKET` — override daemon socket path
- `VACUUM_WALLD_WS_PORT` — override WebSocket port (default `9091`)
## Deployment
`install.sh` installs only system components and configures them; the project serves from the repo root by default. All options can be set via env vars or CLI flags (CLI takes precedence). Set `INSTALL_DIR` or `--path` to override install directory. Use `--dev` to auto-detect repo owner as service user (non-dev mode requires `--user`).
`install.sh` installs only system components and configures them; the project serves from the repo root by default. Options via env vars or CLI flags (CLI takes precedence). Set `INSTALL_DIR` or `--path` to override. Use `--dev` to auto-detect repo owner as service user; non-dev requires `--user`.
All Python modules use `Path(__file__).resolve().parent.parent` for `PROJECT_DIR` — no hardcoded paths. ACME certs live at `PROJECT_DIR/data/acme/`.
All Python modules use `Path(__file__).resolve().parent.parent` for `PROJECT_DIR` — no hardcoded paths. ACME certs at `PROJECT_DIR/data/acme/`.
### Service Start Order
`firewalld``avahi-daemon``dnsmasq``vacuum-walld``vacuum-wall`
## Local Dev
@@ -55,11 +81,11 @@ All Python modules use `Path(__file__).resolve().parent.parent` for `PROJECT_DIR
.venv/bin/python webui/server.py # binds 127.0.0.1:9090
```
In production the systemd unit runs as the configured service user (`NoNewPrivileges`, `ProtectSystem=strict`, loopback-only networking).
Reload running Flask via SIGHUP (auto-reloads `webui.*` and `lib.*` modules, then SIGTERM restart).
When `install.sh --dev` is used, the repo owner gets NOPASSWD sudo for system service commands (`nginx -t`, `nginx -s reload`, `firewall-cmd`, `wg`, `systemctl reload dnsmasq`, etc.). This allows invoking those commands directly in bash to inspect or test live system state during debugging, without relying on the mocked test suite.
In production: systemd units run with `NoNewPrivileges`, `ProtectSystem=strict`, loopback-only networking.
## Blueprint ↔ lib Mapping (Naming Is Not 1:1)
## Blueprint ↔ lib Mapping
| Blueprint | URL prefix | Backend module |
|-----------------------|-------------------|------------------|
@@ -68,6 +94,8 @@ When `install.sh --dev` is used, the repo owner gets NOPASSWD sudo for system se
| `webui/api/proxy` | `/api/proxy/` | `lib.nginx` |
| `webui/api/certs` | `/api/certs/` | `lib.acme` |
| `webui/api/wireguard` | `/api/wireguard/` | `lib.wireguard` |
| `webui/api/network` | `/api/network/` | `lib.network` |
| `webui/api/logs` | `/api/logs/` | `lib.logging` |
## Privileged Operations
@@ -84,13 +112,10 @@ Adding a new privileged command requires a sudoers entry **and** the `daemon/han
- Error: `{"ok": false, "error": "msg"}` — helper `_error(msg, code=400)` from `webui.api.common`
- `acme.issue()` / `acme.renew()` raise `RuntimeError` on failure — API layer wraps in try/except
- HTTP codes: `400` bad request, `404` not found, `500` internal failure
## Page Routes vs API
`server.py` serves HTML pages with Jinja templates. All data is wrapped in `_safely(fn, default)` so page routes never 500 — they render with fallback values instead.
## Deploy
`install.sh` is the single deploy script. Run as root, requires `MGMT_DOMAIN`, `MGMT_PASS`, `ACME_EMAIL` env vars.
`install.sh` is the single deploy script. Run as root. Only `MGMT_PASS` is strictly required; `MGMT_DOMAIN` is auto-detected from hostname.
## Lint and Tests
@@ -101,14 +126,14 @@ Adding a new privileged command requires a sudoers entry **and** the `daemon/han
```bash
.venv/bin/ruff check lib/ webui/ tests/ # lint
.venv/bin/ruff format lib/ webui/ tests/ # format
.venv/bin/python -m pytest tests/ -v # test (212 tests)
.venv/bin/python -m pytest tests/ -v # test
```
Install dev tooling with `pip install -e ".[dev]"`.
## Docs
`docs/` contains the authoritative reference for each subsystem.
`docs/` contains the authoritative reference for each subsystem. **Before reasoning about any subsystem**, read the relevant doc(s) below.
| Doc | Contents |
|-----|----------|
@@ -120,7 +145,7 @@ Install dev tooling with `pip install -e ".[dev]"`.
| `docs/overview.md` | Subsystem summaries, tech stack, complete project directory tree |
## Important Rules
1. Ask, don't assume. If something is unclear, ask before writing a single line. Never make silent assumptions about intent, architecture, or requirements.
2. Simplest solution first. Always implement the simplest thing that could work. Do not add abstractions or flexibility that weren't explicitly requested.
3. Don't touch unrelated code. If a file or function is not directly part of the current task, do not modify it, even if you think it could be improved.
4. Flag uncertainty explicitly. If you are not confident about an approach or technical detail, say so before proceeding. Confidence without certainty causes more damage than admitting a gap.
1. Ask, don't assume. If something is unclear, ask before writing a single line.
2. Simplest solution first. Always implement the simplest thing that could work.
3. Don't touch unrelated code. If a file or function is not directly part of the current task, do not modify it.
4. Flag uncertainty explicitly. If you are not confident about an approach or technical detail, say so.
-1
View File
@@ -8,7 +8,6 @@ A zone-based firewall appliance with a built-in SSL reverse proxy. Combines fire
- Python 3.13+, Flask 3.x web UI
- firewalld (nftables backend), dnsmasq, nginx, WireGuard
- acme.sh for ACME certificates (ZeroSSL)
- HTMX + Jinja2 templates
---
+108 -20
View File
@@ -5,14 +5,19 @@ Communicates with vacuum-walld over a Unix socket using requests-unixsocket.
import json
import logging
import re
import urllib.parse
from typing import Any
import requests
import requests_unixsocket
from daemon.iface import PathLike
logger = logging.getLogger(__name__)
_param_re = re.compile(r"<(\w+)>")
class NotFound(Exception):
"""Raised when the daemon returns HTTP 404."""
@@ -21,7 +26,11 @@ class NotFound(Exception):
class BadRequest(Exception):
"""Raised when the daemon returns HTTP 400."""
"""Exception raised when the daemon returns HTTP 400.
Used to signal client-side input or formatting errors from the
daemon for distinction from general failures.
"""
pass
@@ -30,6 +39,15 @@ _DEFAULT_SOCKET = None
def _get_socket_path() -> str:
"""Return the daemon Unix socket path.
Lazily resolves the path from the VACUUM_WALLD_SOCKET environment
variable or falls back to data/daemon.sock under the project
directory. The result is cached in _DEFAULT_SOCKET.
Returns:
Absolute path string for the daemon socket.
"""
global _DEFAULT_SOCKET
if _DEFAULT_SOCKET is None:
import os
@@ -44,13 +62,52 @@ def _get_socket_path() -> str:
def set_socket_path(path: str) -> None:
"""Override the default daemon socket path.
Useful for tests that need an alternate socket.
Args:
path: Absolute path to the Unix socket file.
"""
global _DEFAULT_SOCKET
_DEFAULT_SOCKET = path
def _format_path(path: str, params: dict[str, Any] | None) -> str:
"""Replace ``<param>`` path segments with URL-encoded values from *params*.
Args:
path: URL path that may contain ``<key>`` placeholders.
params: Dict of parameter values to substitute.
Returns:
Path with all ``<key>`` segments replaced by their URL-encoded
values. Unmatched placeholders are left unchanged.
"""
if params is None:
return path
def _replace(m: re.Match[str]) -> str:
key = m.group(1)
if key in params:
return urllib.parse.quote(str(params[key]), safe="")
return m.group(0)
return _param_re.sub(_replace, path)
def _resolve_path(method_or_ep: PathLike, path: str | None = None) -> tuple[str, str]:
"""Resolve method/path from an Endpoint tuple or two separate arguments."""
if isinstance(method_or_ep, tuple):
return (method_or_ep[0], method_or_ep[1])
if path is None:
raise ValueError("path is required when method is a string")
return (method_or_ep, path)
def request(
method: str,
path: str,
method: PathLike,
path: str | None = None,
json_body: dict[str, Any] | None = None,
query_params: dict[str, Any] | None = None,
socket_path: str | None = None,
@@ -58,27 +115,40 @@ def request(
) -> dict[str, Any]:
"""Make a request to the daemon and return the parsed response body.
*method* can be an :class:`Endpoint` tuple from :mod:`daemon.iface`,
in which case *path* should be omitted.
For GET requests, query_params are sent as URL query parameters instead
of a JSON body. For other methods, json_body is sent as JSON.
Raises RuntimeError on non-2xx responses or connection errors.
Raises NotFound on HTTP 404. Raises BadRequest on HTTP 400.
"""
resolved_method, resolved_path = _resolve_path(method, path)
# Substitute <param> segments from body/query params so the daemon
# receives a concrete path instead of a template.
# Merge body and query params for <param> substitution. query_params
# takes precedence on key conflicts, so callers should avoid passing
# the same key in both dicts.
combined = {**(json_body or {}), **(query_params or {})}
formatted_path = _format_path(resolved_path, combined)
sp = socket_path or _get_socket_path()
url = f"http+unix://{urllib.parse.quote(sp, safe='')}{path}"
url = f"http+unix://{urllib.parse.quote(sp, safe='')}{formatted_path}"
sess = requests_unixsocket.Session()
try:
kwargs: dict[str, Any] = {
"timeout": timeout,
}
if method == "GET":
if resolved_method == "GET":
if query_params:
kwargs["params"] = query_params
else:
if json_body is not None:
kwargs["json"] = json_body
resp = sess.request(
method,
resolved_method,
url,
**kwargs,
)
@@ -92,7 +162,9 @@ def request(
except requests.ConnectionError as exc:
raise RuntimeError(f"Cannot connect to daemon at {sp}: {exc}") from exc
except requests.Timeout as exc:
raise RuntimeError(f"Daemon request timed out: {method} {path}") from exc
raise RuntimeError(
f"Daemon request timed out: {resolved_method} {resolved_path}"
) from exc
except requests.HTTPError as exc:
try:
data = resp.json()
@@ -109,24 +181,40 @@ def request(
return data.get("data")
def get(path: str, params: dict[str, Any] | None = None, **kwargs: Any) -> Any:
"""GET request to daemon. Params are sent as URL query parameters."""
return request("GET", path, query_params=params, **kwargs)
def get(path: PathLike, params: dict[str, Any] | None = None, **kwargs: Any) -> Any:
"""Send a GET request to the daemon.
*path* can be a :class:`Endpoint` tuple from :mod:`daemon.iface`
(e.g., ``GET_FIREWALL_ZONES``), or a plain string path.
"""
return request(path, query_params=params, **kwargs)
def post(path: str, body: dict[str, Any] | None = None, **kwargs: Any) -> Any:
"""POST request to daemon."""
return request("POST", path, json_body=body, **kwargs)
def post(path: PathLike, body: dict[str, Any] | None = None, **kwargs: Any) -> Any:
"""Send a POST request to the daemon.
The body is transmitted as a JSON payload. Extra keyword arguments
are forwarded to request(). *path* can be an :class:`Endpoint` tuple.
"""
return request(path, json_body=body, **kwargs)
def patch(path: str, body: dict[str, Any] | None = None, **kwargs: Any) -> Any:
"""PATCH request to daemon."""
return request("PATCH", path, json_body=body, **kwargs)
def patch(path: PathLike, body: dict[str, Any] | None = None, **kwargs: Any) -> Any:
"""Send a PATCH request to the daemon.
The body is transmitted as a JSON payload. Extra keyword arguments
are forwarded to request(). *path* can be an :class:`Endpoint` tuple.
"""
return request(path, json_body=body, **kwargs)
def delete(path: str, body: dict[str, Any] | None = None, **kwargs: Any) -> Any:
"""DELETE request to daemon."""
return request("DELETE", path, json_body=body, **kwargs)
def delete(path: PathLike, body: dict[str, Any] | None = None, **kwargs: Any) -> Any:
"""Send a DELETE request to the daemon.
The body is transmitted as a JSON payload. Extra keyword arguments
are forwarded to request(). *path* can be an :class:`Endpoint` tuple.
"""
return request(path, json_body=body, **kwargs)
def batch(ops: list[dict[str, Any]], **kwargs: Any) -> dict[str, Any]:
@@ -135,4 +223,4 @@ def batch(ops: list[dict[str, Any]], **kwargs: Any) -> dict[str, Any]:
Each op is a dict with 'id', 'method', 'path', and optionally 'body'.
Returns a dict mapping each id to its result.
"""
return post("/batch", {"ops": ops}, **kwargs)
return request("POST", "/batch", json_body={"ops": ops}, **kwargs)
+188 -58
View File
@@ -3,7 +3,6 @@
import asyncio
import logging
import os
import re
import socket
import subprocess
from contextlib import suppress
@@ -13,7 +12,21 @@ from pathlib import Path
from typing import Any
from uuid import uuid4
from daemon.iface import (
DELETE_ACME_REMOVE,
GET_ACME_EMAIL,
GET_ACME_INFO,
GET_ACME_ISSUE_STATUS,
GET_ACME_LIST,
GET_ACME_PATHS,
POST_ACME_EMAIL,
POST_ACME_ISSUE,
POST_ACME_RENEW,
POST_ACME_SELF_SIGNED,
POST_ACME_VALIDATE,
)
from daemon.server import NotFoundError, refresh_state, registry
from lib.state import _run_acme
logger = logging.getLogger(__name__)
@@ -37,6 +50,15 @@ _ISSUANCE_TTL = 300 # seconds to keep completed requests
@dataclass
class IssueStep:
"""Single step in a certificate issuance workflow.
Attributes:
name: Machine-readable step identifier (e.g. "issue").
label: Human-readable description shown to the user.
status: Current state: "pending", "running", "done", or "error".
message: Optional detail or error message for the step.
"""
name: str
label: str
status: str = "pending"
@@ -45,6 +67,19 @@ class IssueStep:
@dataclass
class IssueRequest:
"""Tracked certificate issuance request.
Attributes:
request_id: Unique hex identifier for polling.
domain: Target domain for the certificate.
email: Optional ACME contact email.
webroot: Optional custom webroot path.
steps: Ordered list of issuance steps.
status: Overall status: "running", "completed", or "failed".
created_at: Unix timestamp when request was created.
expires_at: Unix timestamp when entry expires from store.
"""
request_id: str
domain: str
email: str | None = None
@@ -55,6 +90,7 @@ class IssueRequest:
expires_at: float | None = None
def to_dict(self) -> dict[str, Any]:
"""Serialize request to a JSON-compatible dictionary."""
return {
"request_id": self.request_id,
"domain": self.domain,
@@ -77,57 +113,29 @@ class IssueRequest:
# Internal helpers
def _run_acme(args: list[str]) -> str:
from lib.state import _find_acme
acme_bin = _find_acme()
acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME))
cmd = [acme_bin, "--home", acme_home_env, "--config-home", acme_home_env, *args]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=120,
env={**os.environ, **_ACME_ENVIRON},
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(f"acme.sh timed out: {' '.join(cmd)}") from exc
output = result.stdout
if result.stderr:
output = output + result.stderr if output else result.stderr
if result.returncode != 0:
raise RuntimeError(f"acme.sh failed (rc={result.returncode}): {output.strip()}")
return output
def _find_acme_bin() -> str:
"""Return the path to the acme.sh binary."""
from lib.state import _find_acme
return _find_acme()
def _get_acme_email() -> str:
try:
acme_home = Path(os.environ.get("ACME_HOME", str(_ACME_HOME)))
account_conf = acme_home / "account.conf"
if account_conf.is_file():
text = account_conf.read_text()
match = re.search(r"^ACME_LEEMAIL=(.+)$", text, re.MULTILINE)
if match:
return match.group(1).strip().strip("'\"")
except OSError:
pass
return ""
"""Read registered contact email from ACME account config."""
from lib.acme import _read_acme_email
return _read_acme_email()
def _get_state() -> dict[str, Any] | None:
"""Return the raw ACME entry from the shared state store."""
from lib.state import state as state_store
return state_store.get("acme")
def _get_acme_state() -> dict[str, Any]:
"""Return the ACME state or empty dict when missing."""
ac = _get_state()
if ac is None:
return {}
@@ -213,6 +221,7 @@ def _check_dns_resolves(domain: str) -> tuple[bool, str]:
def _check_acme_installed() -> tuple[bool, str]:
"""Verify acme.sh binary is installed and executable."""
try:
_find_acme_bin()
return True, "acme.sh found"
@@ -221,6 +230,7 @@ def _check_acme_installed() -> tuple[bool, str]:
def _check_email_configured() -> tuple[bool, str]:
"""Check whether an ACME contact email has been configured."""
email = _get_acme_email() or ""
if email:
return True, f"Contact email configured: {email}"
@@ -228,12 +238,14 @@ def _check_email_configured() -> tuple[bool, str]:
def _check_webroot() -> tuple[bool, str]:
"""Verify the ACME webroot directory exists and is writable."""
if _WEBROOT.is_dir() and os.access(str(_WEBROOT), os.W_OK):
return True, "ACME webroot ready"
return False, "ACME webroot not ready or not writable"
def _check_challenge_config() -> tuple[bool, str]:
"""Check for the ACME HTTP-01 challenge nginx config file."""
from lib.nginx import SITES_DIR
site_conf = SITES_DIR / "_acme-challenge.conf" if SITES_DIR else None
@@ -296,14 +308,21 @@ def _validate(domain: str) -> dict[str, Any]:
# Routes — status reads from state, mutations call refresh_state
@registry.register("GET", "/acme/list")
@registry.register(GET_ACME_LIST)
def list_certs(_request: Any, _body: Any) -> list[dict]:
"""GET /acme/list — return managed certificates."""
ac = _get_acme_state()
return ac.get("certs", [])
@registry.register("GET", "/acme/info")
@registry.register(GET_ACME_INFO)
def get_cert_info(_request: Any, body: dict[str, Any] | None) -> dict:
"""GET /acme/info — return details for a single domain certificate.
Raises:
ValueError: When domain is missing.
NotFoundError: When no certificate exists for domain.
"""
if not body or "domain" not in body:
raise ValueError("'domain' is required")
domain = body["domain"]
@@ -314,8 +333,13 @@ def get_cert_info(_request: Any, body: dict[str, Any] | None) -> dict:
raise NotFoundError(f"No certificate found for domain: {domain}")
@registry.register("POST", "/acme/validate")
@registry.register(POST_ACME_VALIDATE)
def validate_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /acme/validate — run pre-flight checks for a domain.
Raises:
ValueError: When domain is missing.
"""
if not body:
raise ValueError("Request body required")
domain = body.get("domain", "").strip()
@@ -324,8 +348,16 @@ def validate_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
return _validate(domain)
@registry.register("POST", "/acme/issue")
@registry.register(POST_ACME_ISSUE)
async def issue_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /acme/issue — create a new certificate issuance request.
Deduplicates in-progress requests. Spawns background task for actual issuance.
Raises:
ValueError: When domain is missing.
RuntimeError: When pre-flight checks fail.
"""
if not body:
raise ValueError("Request body required")
domain = body.get("domain", "").strip()
@@ -379,8 +411,14 @@ async def issue_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, An
return {"request_id": request_id, "domain": domain}
@registry.register("GET", "/acme/issue/status")
@registry.register(GET_ACME_ISSUE_STATUS)
def get_issuance_status(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""GET /acme/issue/status — poll status of an issuance request.
Raises:
ValueError: When id is missing.
NotFoundError: When request_id is unknown.
"""
request_id = (body or {}).get("id", "").strip()
if not request_id:
raise ValueError("'id' is required")
@@ -442,8 +480,16 @@ async def _run_issue(req: IssueRequest) -> None:
logger.error("Cert issuance for %s failed: %s", req.domain, exc)
@registry.register("POST", "/acme/renew")
@registry.register(POST_ACME_RENEW)
def renew_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /acme/renew — renew a certificate for the given domain.
Args:
force: Force renewal regardless of expiry.
Raises:
ValueError: When domain is missing.
"""
if not body:
raise ValueError("Request body required")
domain = body.get("domain", "").strip()
@@ -460,8 +506,13 @@ def renew_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
return {"domain": domain, "output": output.strip()}
@registry.register("DELETE", "/acme/remove")
@registry.register(DELETE_ACME_REMOVE)
def remove_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""DELETE /acme/remove — remove a certificate from ACME management.
Raises:
ValueError: When domain is missing.
"""
if not body:
raise ValueError("Request body required")
domain = body.get("domain", "").strip()
@@ -473,39 +524,53 @@ def remove_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
return {"domain": domain}
@registry.register("POST", "/acme/email")
@registry.register(POST_ACME_EMAIL)
def set_email(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /acme/email — set the ACME contact email via account registration.
Raises:
ValueError: When email is missing.
"""
if not body:
raise ValueError("Request body required")
email = body.get("email", "").strip()
if not email:
raise ValueError("'email' is required")
_run_acme(["--register-account", "-m", email])
# Persist to declarative ACME config
acme_cfg = PROJECT_DIR / "config" / "acme" / "config.json"
acme_cfg.parent.mkdir(parents=True, exist_ok=True)
import json as _json
_acme_data: dict[str, str] = {}
if acme_cfg.is_file():
_acme_data = _json.loads(acme_cfg.read_text())
_acme_data["email"] = email
acme_cfg.write_text(_json.dumps(_acme_data, indent=4) + "\n")
logger.info("ACME email set to %s", email)
refresh_state(["acme"])
return {"email": email}
@registry.register("GET", "/acme/email")
@registry.register(GET_ACME_EMAIL)
def get_email(_request: Any, _body: Any) -> dict[str, Any]:
"""GET /acme/email — return the currently configured ACME contact email."""
ac = _get_acme_state()
email = ""
if ac:
return {"email": ac.get("email", "")}
try:
acme_home = Path(os.environ.get("ACME_HOME", str(_ACME_HOME)))
account_conf = acme_home / "account.conf"
if account_conf.is_file():
text = account_conf.read_text()
match = re.search(r"^ACME_LEEMAIL=(.+)$", text, re.MULTILINE)
if match:
return {"email": match.group(1).strip().strip("'\"")}
except OSError:
pass
return {"email": ""}
email = ac.get("email", "")
if not email:
email = _get_acme_email()
return {"email": email}
@registry.register("GET", "/acme/paths")
@registry.register(GET_ACME_PATHS)
def get_cert_paths(_request: Any, body: dict[str, Any] | None) -> dict[str, str]:
"""GET /acme/paths — return filesystem paths for a domain's certificate files.
Raises:
ValueError: When domain is missing.
"""
if not body or "domain" not in body:
raise ValueError("'domain' is required")
domain = body["domain"]
@@ -517,3 +582,68 @@ def get_cert_paths(_request: Any, body: dict[str, Any] | None) -> dict[str, str]
"ca": f"{acme_home}/ca.cer",
"fullchain": f"{acme_home}/fullchain.cer",
}
@registry.register(POST_ACME_SELF_SIGNED)
def generate_self_signed(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /acme/self-signed — generate a self-signed certificate for a domain.
Idempotent: skips generation if cert and key already exist.
Raises:
ValueError: When domain is missing.
"""
if not body:
raise ValueError("Request body required")
domain = body.get("domain", "").strip()
if not domain:
raise ValueError("'domain' is required")
days = body.get("days", 365)
cert_dir = _ACME_HOME / domain
cert_dir.mkdir(parents=True, exist_ok=True)
cert_file = cert_dir / "fullchain.cer"
key_file = cert_dir / f"{domain}.key"
if cert_file.is_file() and key_file.is_file():
logger.info("Self-signed cert for %s already exists, skipping", domain)
return {
"domain": domain,
"cert": str(cert_file),
"key": str(key_file),
"generated": False,
}
subprocess.run(
[
"openssl",
"req",
"-x509",
"-newkey",
"rsa:2048",
"-keyout",
str(key_file),
"-out",
str(cert_file),
"-days",
str(days),
"-nodes",
"-subj",
f"/CN={domain}",
],
capture_output=True,
text=True,
check=True,
timeout=30,
)
cert_file.chmod(0o644)
key_file.chmod(0o600)
logger.info("Self-signed cert for %s generated (%d days)", domain, days)
return {
"domain": domain,
"cert": str(cert_file),
"key": str(key_file),
"generated": True,
}
+122 -15
View File
@@ -8,6 +8,22 @@ from typing import Any
from jinja2 import Environment, FileSystemLoader
from daemon.iface import (
DELETE_DNSMASQ_DNS_RECORD_REMOVE,
DELETE_DNSMASQ_RANGES_REMOVE,
DELETE_DNSMASQ_STATIC_LEASE_REMOVE,
GET_DNSMASQ_CONFIG,
GET_DNSMASQ_LEASES,
GET_DNSMASQ_STATUS,
PATCH_DNSMASQ_CONFIG,
POST_DNSMASQ_APPLY,
POST_DNSMASQ_CONFIG,
POST_DNSMASQ_DNS_RECORD_ADD,
POST_DNSMASQ_DOMAIN,
POST_DNSMASQ_RANGES_ADD,
POST_DNSMASQ_STATIC_LEASE_ADD,
POST_DNSMASQ_UPSTREAMS,
)
from daemon.server import NotFoundError, refresh_state, registry
from lib.common import deep_merge, ensure_dirs, load_json, run, run_proc, save_json
@@ -35,12 +51,14 @@ DEFAULT_CFG: dict[str, Any] = {
def _get_state() -> dict[str, Any] | None:
"""Retrieve cached dnsmasq state from the state store."""
from lib.state import state as state_store
return state_store.get("dnsmasq")
def _get_config() -> dict[str, Any]:
"""Load dnsmasq config from JSON, merging with defaults."""
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
raw = load_json(CONFIG_PATH)
if not raw:
@@ -49,21 +67,39 @@ def _get_config() -> dict[str, Any]:
def _save_config(cfg: dict[str, Any]) -> None:
"""Persist dnsmasq config to JSON after merging with defaults."""
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
merged = deep_merge(deepcopy(DEFAULT_CFG), cfg)
save_json(CONFIG_PATH, merged)
def _generate_conf(cfg: dict[str, Any]) -> str:
"""Render dnsmasq.conf from Jinja template and config dict."""
dhcp_cfg = cfg.get("dhcp", {})
dns_cfg = cfg.get("dns", {})
interfaces = [
r["interface"] for r in dhcp_cfg.get("ranges", []) if "interface" in r
]
# Fallback: use network-managed interface addresses for listen-address
listen_addresses = []
try:
from lib.network import get_config as _get_net_config
net_cfg = _get_net_config()
for _iface, info in net_cfg.get("interfaces", {}).items():
for addr_str in info.get("addresses", []):
if "/" in addr_str:
addr_str = addr_str.split("/")[0]
listen_addresses.append(addr_str)
except Exception:
pass
tmpl = ENV.get_template("dnsmasq.conf")
return tmpl.render(
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
interfaces=interfaces,
interfaces=interfaces or None,
listen_addresses=listen_addresses if listen_addresses else None,
dhcp=dhcp_cfg,
dns=dns_cfg,
fragments_dir=str(FRAGMENTS_DIR) if FRAGMENTS_DIR.exists() else None,
@@ -71,6 +107,7 @@ def _generate_conf(cfg: dict[str, Any]) -> str:
def _get_dnsmasq_state() -> dict[str, Any]:
"""Return cached dnsmasq state, or empty dict if unset."""
dm = _get_state()
if dm is None:
return {}
@@ -81,16 +118,26 @@ def _get_dnsmasq_state() -> dict[str, Any]:
# Routes
@registry.register("GET", "/dnsmasq/config")
@registry.register(GET_DNSMASQ_CONFIG)
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
"""\
Endpoint: GET /dnsmasq/config
Returns cached config if available, otherwise loads from disk.
"""
dm = _get_dnsmasq_state()
if dm:
return dm.get("config", {})
return _get_config()
@registry.register("POST", "/dnsmasq/config")
@registry.register(POST_DNSMASQ_CONFIG)
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: POST /dnsmasq/config
Save full config. Raises ValueError on missing body.
"""
if not body:
raise ValueError("Request body required")
_save_config(body)
@@ -98,8 +145,13 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
return {"config_saved": True}
@registry.register("PATCH", "/dnsmasq/config")
@registry.register(PATCH_DNSMASQ_CONFIG)
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: PATCH /dnsmasq/config
Merge partial update into existing config. Raises ValueError on missing body.
"""
if not body:
raise ValueError("Request body required")
current = _get_config()
@@ -109,8 +161,13 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
return {"config_saved": True}
@registry.register("POST", "/dnsmasq/apply")
@registry.register(POST_DNSMASQ_APPLY)
def apply_config(_request: Any, _body: Any) -> dict[str, Any]:
"""\
Endpoint: POST /dnsmasq/apply
Render config to dnsmasq.conf, write to disk, and reload dnsmasq service via sudo.
"""
cfg = _get_config()
conf_text = _generate_conf(cfg)
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
@@ -127,16 +184,26 @@ def apply_config(_request: Any, _body: Any) -> dict[str, Any]:
return {"applied": True}
@registry.register("GET", "/dnsmasq/status")
@registry.register(GET_DNSMASQ_STATUS)
def get_status(_request: Any, _body: Any) -> dict[str, Any]:
"""\
Endpoint: GET /dnsmasq/status
Returns cached dnsmasq status object, or empty dict if unavailable.
"""
dm = _get_dnsmasq_state()
if dm and "status" in dm:
return dm["status"]
return {}
@registry.register("POST", "/dnsmasq/ranges/add")
@registry.register(POST_DNSMASQ_RANGES_ADD)
def set_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: POST /dnsmasq/ranges/add
Add or update a DHCP pool range by interface. Raises ValueError on invalid input.
"""
if not body:
raise ValueError("Request body required")
iface = body.get("interface", "").strip() or ""
@@ -179,8 +246,13 @@ def set_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
return {"interface": iface, "start": start, "end": end}
@registry.register("DELETE", "/dnsmasq/ranges/remove")
@registry.register(DELETE_DNSMASQ_RANGES_REMOVE)
def remove_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: DELETE /dnsmasq/ranges/remove
Remove DHCP range matching interface + start + end. Raises NotFoundError if missing.
"""
if not body:
raise ValueError("Request body required")
iface = body.get("interface", "").strip() or ""
@@ -209,16 +281,26 @@ def remove_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, A
return {"interface": iface, "start": start, "end": end}
@registry.register("GET", "/dnsmasq/leases")
@registry.register(GET_DNSMASQ_LEASES)
def get_leases(_request: Any, _body: Any) -> list[dict[str, Any]]:
"""\
Endpoint: GET /dnsmasq/leases
Returns cached DHCP lease list from state, or empty list if unavailable.
"""
dm = _get_dnsmasq_state()
if dm:
return dm.get("leases", [])
return []
@registry.register("POST", "/dnsmasq/static-lease/add")
@registry.register(POST_DNSMASQ_STATIC_LEASE_ADD)
def add_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: POST /dnsmasq/static-lease/add
Add or update a static DHCP lease by MAC address. Raises ValueError on invalid input.
"""
if not body:
raise ValueError("Request body required")
mac = body.get("mac", "").strip()
@@ -245,8 +327,13 @@ def add_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, An
return {"mac": mac, "ip": ip, "hostname": hostname}
@registry.register("DELETE", "/dnsmasq/static-lease/remove")
@registry.register(DELETE_DNSMASQ_STATIC_LEASE_REMOVE)
def remove_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: DELETE /dnsmasq/static-lease/remove
Remove static lease by MAC. Raises NotFoundError if no match.
"""
if not body:
raise ValueError("Request body required")
mac = body.get("mac", "").strip()
@@ -265,8 +352,13 @@ def remove_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str,
return {"mac": mac}
@registry.register("POST", "/dnsmasq/dns-record/add")
@registry.register(POST_DNSMASQ_DNS_RECORD_ADD)
def add_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: POST /dnsmasq/dns-record/add
Add or update a custom DNS record by name. Raises ValueError on invalid input.
"""
if not body:
raise ValueError("Request body required")
name = body.get("name", "").strip()
@@ -293,8 +385,13 @@ def add_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
return {"name": name, "address": address, "hostname": hostname}
@registry.register("DELETE", "/dnsmasq/dns-record/remove")
@registry.register(DELETE_DNSMASQ_DNS_RECORD_REMOVE)
def remove_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: DELETE /dnsmasq/dns-record/remove
Remove custom DNS record by name. Raises NotFoundError if no match.
"""
if not body:
raise ValueError("Request body required")
name = body.get("name", "").strip()
@@ -311,8 +408,13 @@ def remove_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, A
return {"name": name}
@registry.register("POST", "/dnsmasq/upstreams")
@registry.register(POST_DNSMASQ_UPSTREAMS)
def set_upstreams(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: POST /dnsmasq/upstreams
Replace DNS upstream servers list. Raises ValueError if servers field missing.
"""
if not body or "servers" not in body:
raise ValueError("'servers' is required")
cfg = _get_config()
@@ -322,8 +424,13 @@ def set_upstreams(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
return {"upstreams": cfg["dns"]["upstreams"]}
@registry.register("POST", "/dnsmasq/domain")
@registry.register(POST_DNSMASQ_DOMAIN)
def set_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: POST /dnsmasq/domain
Set or clear the local DNS domain. Raises ValueError on missing body.
"""
if not body:
raise ValueError("Request body required")
domain = body.get("domain")
+97 -38
View File
@@ -10,10 +10,34 @@ from pathlib import Path
from typing import Any
from uuid import uuid4
from daemon.iface import (
DELETE_FIREWALL_FORWARD_PORT_REMOVE,
DELETE_FIREWALL_RICH_RULES_REMOVE,
DELETE_FIREWALL_ZONES_DELETE,
GET_FIREWALL_CONFIG,
GET_FIREWALL_CONFIG_PENDING,
GET_FIREWALL_INTERFACES,
GET_FIREWALL_RICH_RULES,
GET_FIREWALL_SERVICES,
GET_FIREWALL_STATE,
GET_FIREWALL_ZONES,
GET_FIREWALL_ZONES_ALL,
GET_FIREWALL_ZONES_INFO,
PATCH_FIREWALL_CONFIG,
POST_FIREWALL_CONFIG,
POST_FIREWALL_CONFIG_APPLY,
POST_FIREWALL_FORWARD_PORT_ADD,
POST_FIREWALL_MASQUERADE,
POST_FIREWALL_RICH_RULES_ADD,
POST_FIREWALL_ZONES_CREATE,
POST_FIREWALL_ZONES_INTERFACES,
POST_FIREWALL_ZONES_SERVICES,
)
from daemon.server import NotFoundError, refresh_state, registry
from lib.common import load_json, run, save_json
from lib.firewall import (
_normalize_target,
_parse_active_zones,
_parse_zone_output,
)
from lib.firewall import (
@@ -36,26 +60,31 @@ def _get_state() -> dict[str, Any] | None:
def _ensure_config_file() -> None:
"""Initialize config file with defaults if missing."""
if not CONFIG_FILE.exists():
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
save_json(CONFIG_FILE, DEFAULT_CONFIG, indent=2)
def _get_config() -> dict[str, Any]:
"""Load the firewall config file."""
_ensure_config_file()
return load_json(CONFIG_FILE)
def _save_config(cfg: dict[str, Any]) -> None:
"""Persist firewall config to disk."""
_ensure_config_file()
save_json(CONFIG_FILE, cfg, indent=2)
def _reload() -> None:
"""Reload firewalld to apply permanent changes."""
run(["firewall-cmd", "--reload"], sudo=True)
def _fp_to_str(fp: dict[str, Any]) -> str:
"""Convert a forward-port dict to firewall-cmd CLI argument string."""
parts = [f"port={fp['port']}", f"proto={fp['proto']}"]
if "toaddr" in fp:
parts.append(f"toaddr={fp['toaddr']}")
@@ -65,6 +94,7 @@ def _fp_to_str(fp: dict[str, Any]) -> str:
def _get_forward_ports(zone_name: str) -> list[str]:
"""Return forward-port entries for a zone as CLI-style strings."""
with suppress(Exception):
fps = _parse_zone_output(
zone_name,
@@ -255,19 +285,21 @@ def _config_apply() -> dict[str, Any]:
def _get_fw_state() -> dict[str, Any]:
"""Return firewall state from the state store, or empty dict if absent."""
fw = _get_state()
if fw is None:
return {}
return fw
@registry.register("GET", "/firewall/interfaces")
@registry.register(GET_FIREWALL_INTERFACES)
def get_interfaces(_request: Any, _body: Any) -> list[dict[str, Any]]:
"""GET /firewall/interfaces — return active interfaces from state."""
fw = _get_fw_state()
return fw.get("interfaces", [])
@registry.register("GET", "/firewall/zones")
@registry.register(GET_FIREWALL_ZONES)
def get_zones(_request: Any, _body: Any) -> dict[str, Any]:
fw = _get_fw_state()
active = fw.get("active_zones", {})
@@ -275,7 +307,7 @@ def get_zones(_request: Any, _body: Any) -> dict[str, Any]:
return {"active": active, "available": list(zones.keys())}
@registry.register("GET", "/firewall/zones/info")
@registry.register(GET_FIREWALL_ZONES_INFO)
def get_zone_info(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body or "zone" not in body:
raise ValueError("'zone' is required")
@@ -287,7 +319,7 @@ def get_zone_info(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
return zones[zone]
@registry.register("GET", "/firewall/zones/all")
@registry.register(GET_FIREWALL_ZONES_ALL)
def get_all_zones_info(_request: Any, _body: Any) -> list[dict[str, Any]]:
fw = _get_fw_state()
active = fw.get("active_zones", {})
@@ -299,18 +331,18 @@ def get_all_zones_info(_request: Any, _body: Any) -> list[dict[str, Any]]:
return result
@registry.register("GET", "/firewall/services")
@registry.register(GET_FIREWALL_SERVICES)
def get_services(_request: Any, _body: Any) -> list[str]:
fw = _get_fw_state()
return fw.get("available_services", [])
@registry.register("GET", "/firewall/config")
@registry.register(GET_FIREWALL_CONFIG)
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
return _get_config()
@registry.register("POST", "/firewall/config")
@registry.register(POST_FIREWALL_CONFIG)
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body or "zones" not in body:
raise ValueError("'zones' key is required")
@@ -322,7 +354,7 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
return {"config_saved": True}
@registry.register("PATCH", "/firewall/config")
@registry.register(PATCH_FIREWALL_CONFIG)
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body:
raise ValueError("Request body must be a JSON object")
@@ -336,13 +368,13 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
return {"config_saved": True}
@registry.register("GET", "/firewall/config/pending")
@registry.register(GET_FIREWALL_CONFIG_PENDING)
def config_pending_handler(_request: Any, _body: Any) -> dict[str, Any]:
fw = _get_fw_state()
return fw.get("pending", {})
@registry.register("POST", "/firewall/config/apply")
@registry.register(POST_FIREWALL_CONFIG_APPLY)
def config_apply(_request: Any, _body: Any) -> dict[str, Any]:
result = _config_apply()
logger.info("Firewall config applied: %s", result.get("applied_zones", []))
@@ -350,7 +382,7 @@ def config_apply(_request: Any, _body: Any) -> dict[str, Any]:
return result
@registry.register("POST", "/firewall/zones/create")
@registry.register(POST_FIREWALL_ZONES_CREATE)
def create_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body:
raise ValueError("Request body required")
@@ -376,7 +408,7 @@ def create_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
return {"zone": zone_name}
@registry.register("DELETE", "/firewall/zones/delete")
@registry.register(DELETE_FIREWALL_ZONES_DELETE)
def delete_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body or "zone" not in body:
raise ValueError("'zone' is required")
@@ -391,7 +423,7 @@ def delete_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
return {"zone": zone}
@registry.register("POST", "/firewall/zones/interfaces")
@registry.register(POST_FIREWALL_ZONES_INTERFACES)
def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body:
raise ValueError("Request body required")
@@ -401,24 +433,31 @@ def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str,
raise ValueError("'zone' is required")
if zone not in run(["firewall-cmd", "--get-zones"], sudo=True).split():
raise NotFoundError(f"Zone '{zone}' does not exist")
try:
current = _parse_zone_output(
zone, run(["firewall-cmd", f"--zone={zone}", "--list-all"], sudo=True)
).get("interfaces", [])
except Exception:
current = []
for iface in current:
run(
[
"firewall-cmd",
f"--zone={zone}",
"--remove-interface=" + iface,
"--permanent",
],
sudo=True,
check=False,
)
# Determine old zone for each interface being reassigned
active_raw = run(["firewall-cmd", "--get-active-zones"], sudo=True)
active = _parse_active_zones(active_raw)
for iface in interfaces:
# Find which zone currently owns this interface
old_zone = None
for az, az_ifaces in active.items():
if iface in az_ifaces:
old_zone = az
break
# Remove from old zone (if different from target)
if old_zone and old_zone != zone:
run(
[
"firewall-cmd",
f"--zone={old_zone}",
"--remove-interface=" + iface,
"--permanent",
],
sudo=True,
check=False,
)
# Add to target zone
run(
[
"firewall-cmd",
@@ -428,13 +467,33 @@ def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str,
],
sudo=True,
)
_reload()
# Update config
cfg = _get_config()
cfg.setdefault("zones", {})
cfg["zones"].setdefault(zone, {})
cfg["zones"][zone]["interfaces"] = list(interfaces)
# Remove interface from any old zone in config
for old_zone_name, old_zone_cfg in cfg["zones"].items():
if old_zone_name == zone:
continue
old_ifaces = old_zone_cfg.get("interfaces", [])
new_ifaces = [i for i in old_ifaces if i not in interfaces]
if len(new_ifaces) < len(old_ifaces):
if new_ifaces:
old_zone_cfg["interfaces"] = new_ifaces
elif "interfaces" in old_zone_cfg:
del old_zone_cfg["interfaces"]
_save_config(cfg)
logger.info("Zone '%s' interfaces set to %s", zone, interfaces)
refresh_state(["firewall"])
return {"zone": zone, "interfaces": interfaces}
@registry.register("POST", "/firewall/zones/services")
@registry.register(POST_FIREWALL_ZONES_SERVICES)
def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body:
raise ValueError("Request body required")
@@ -473,7 +532,7 @@ def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, A
return {"zone": zone, "services": services}
@registry.register("POST", "/firewall/rich-rules/add")
@registry.register(POST_FIREWALL_RICH_RULES_ADD)
def add_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body:
raise ValueError("Request body required")
@@ -504,7 +563,7 @@ def add_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
return {"zone": zone, "id": rule_id, "rule": rule}
@registry.register("DELETE", "/firewall/rich-rules/remove")
@registry.register(DELETE_FIREWALL_RICH_RULES_REMOVE)
def remove_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body:
raise ValueError("Request body required")
@@ -542,7 +601,7 @@ def remove_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, An
return {"zone": zone, "id": rule_id}
@registry.register("GET", "/firewall/rich-rules")
@registry.register(GET_FIREWALL_RICH_RULES)
def list_rich_rules(_request: Any, body: dict[str, Any] | None) -> list[dict[str, Any]]:
if not body or "zone" not in body:
raise ValueError("'zone' is required")
@@ -562,7 +621,7 @@ def list_rich_rules(_request: Any, body: dict[str, Any] | None) -> list[dict[str
return result
@registry.register("POST", "/firewall/masquerade")
@registry.register(POST_FIREWALL_MASQUERADE)
def set_masquerade(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body:
raise ValueError("Request body required")
@@ -577,7 +636,7 @@ def set_masquerade(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
return {"zone": zone, "masquerade": bool(enable)}
@registry.register("POST", "/firewall/forward-port/add")
@registry.register(POST_FIREWALL_FORWARD_PORT_ADD)
def add_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body:
raise ValueError("Request body required")
@@ -620,7 +679,7 @@ def add_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, An
return {"zone": zone, "id": fp_id, "port": int(port), "proto": proto}
@registry.register("DELETE", "/firewall/forward-port/remove")
@registry.register(DELETE_FIREWALL_FORWARD_PORT_REMOVE)
def remove_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body:
raise ValueError("Request body required")
@@ -667,7 +726,7 @@ def remove_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str,
return {"zone": zone, "port": int(port), "proto": proto}
@registry.register("GET", "/firewall/state")
@registry.register(GET_FIREWALL_STATE)
def get_state(_request: Any, _body: Any) -> dict[str, Any]:
fw = _get_state()
if fw is None:
+40 -9
View File
@@ -6,7 +6,14 @@ Reads system logs and journal entries.
import logging
from pathlib import Path
from daemon.server import registry
from daemon.iface import (
GET_LOGS_APP,
GET_LOGS_DNSMASQ,
GET_LOGS_JOURNAL,
GET_LOGS_NGINX_ACCESS,
GET_LOGS_NGINX_ERROR,
)
from daemon.server import NotFoundError, registry
from lib.common import run_proc
logger = logging.getLogger(__name__)
@@ -17,6 +24,16 @@ _MAX_LINES = 200
def _tail_file(path: str, n: int = _MAX_LINES, sudo: bool = False) -> str:
"""Return the last N lines of a file, optionally via sudo.
Args:
path: Absolute path to the file to read.
n: Number of trailing lines to return.
sudo: Whether to use sudo to access the file.
Returns:
Truncated file content or an error message string.
"""
try:
if sudo:
result = run_proc(["cat", path], sudo=True)
@@ -26,12 +43,21 @@ def _tail_file(path: str, n: int = _MAX_LINES, sudo: bool = False) -> str:
lines = f.readlines()
return "".join(lines[-n:])
except FileNotFoundError:
return "(log file not found)\n"
raise NotFoundError("log file not found") from None
except PermissionError:
return "(permission denied)\n"
raise RuntimeError("permission denied") from None
def _sudo_journalctl(unit: str, n: int = _MAX_LINES) -> str:
"""Return recent journalctl output for a systemd unit via sudo.
Args:
unit: Systemd unit name to query.
n: Number of journal lines to return.
Returns:
Journal output or an error message string.
"""
try:
result = run_proc(
["journalctl", "--unit=" + unit, "-n", str(n)],
@@ -42,29 +68,34 @@ def _sudo_journalctl(unit: str, n: int = _MAX_LINES) -> str:
output = result.stdout.strip()
return output if output else f"(no journal entries for {unit})\n"
except Exception as exc:
return f"(error reading journal: {exc})\n"
raise RuntimeError(f"error reading journal: {exc}") from exc
@registry.register("GET", "/logs/journal")
@registry.register(GET_LOGS_JOURNAL)
def journal(_request, _body) -> str:
"""GET /logs/journal — return vacuum-wall daemon journal entries."""
return _sudo_journalctl("vacuum-wall")
@registry.register("GET", "/logs/nginx/access")
@registry.register(GET_LOGS_NGINX_ACCESS)
def nginx_access(_request, _body) -> str:
"""GET /logs/nginx/access — return recent nginx access log lines."""
return _tail_file("/var/log/nginx/access.log", sudo=True)
@registry.register("GET", "/logs/nginx/error")
@registry.register(GET_LOGS_NGINX_ERROR)
def nginx_error(_request, _body) -> str:
"""GET /logs/nginx/error — return recent nginx error log lines."""
return _tail_file("/var/log/nginx/error.log", sudo=True)
@registry.register("GET", "/logs/dnsmasq")
@registry.register(GET_LOGS_DNSMASQ)
def dnsmasq_log(_request, _body) -> str:
"""GET /logs/dnsmasq — return recent dnsmasq journal entries."""
return _sudo_journalctl("dnsmasq")
@registry.register("GET", "/logs/app")
@registry.register(GET_LOGS_APP)
def app_log(_request, _body) -> str:
"""GET /logs/app — return recent application log lines."""
return _tail_file(str(_APP_LOG_FILE))
+314
View File
@@ -0,0 +1,314 @@
"""Networkd daemon handler.
Registers routes for managing systemd-networkd interface configuration
via config/network/config.json and generated .network files.
"""
import contextlib
import logging
import re
from pathlib import Path
from typing import Any
from daemon.iface import (
GET_NETWORK_INFER_DHCP_RANGES,
GET_NETWORK_INFER_ZONES,
GET_NETWORK_INTERFACE_NAME,
GET_NETWORK_INTERFACES,
POST_NETWORK_APPLY,
POST_NETWORK_INTERFACE_NAME,
POST_NETWORK_INTERFACE_RELOAD,
POST_NETWORK_SYSCTL_SET,
)
from daemon.server import NotFoundError, registry
from lib.common import run, validate_interface_name
from lib.dnsmasq import set_upstreams
from lib.network import (
KNOWN_INTERFACE_KEYS,
collect_upstream_dns,
generate_network_files,
get_config,
infer_dhcp_ranges,
infer_zones,
parse_networkctl_status,
render_network_file,
save_config,
)
logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent.parent
CONFIG_DIR = PROJECT_DIR / "config" / "network"
DATA_DIR = PROJECT_DIR / "data" / "networkd"
def _copy_and_reload(iface_name: str) -> None:
"""Copy generated 99-<name>.network file to /etc/systemd/network/ and reload."""
validate_interface_name(iface_name)
src = DATA_DIR / f"99-{iface_name}.network"
dst_dir = Path("/etc/systemd/network")
run(["mkdir", "-p", str(dst_dir)], sudo=True)
dst = dst_dir / f"99-{iface_name}.network"
run(["cp", str(src), str(dst)], sudo=True)
# Remove lower-priority .network files that match this interface
# (they would override our config due to higher systemd priority)
if dst_dir.exists():
for f in dst_dir.iterdir():
if (
f.name.endswith(".network")
and f.name != dst.name
and _matches_interface(f.name, iface_name)
):
with contextlib.suppress(Exception):
run(["rm", str(f)], sudo=True)
logger.info("Removed conflicting file: %s", f.name)
run(["networkctl", "reload"], sudo=True)
run(["networkctl", "reconfigure", iface_name], sudo=True)
def _matches_interface(filename: str, iface_name: str) -> bool:
"""Check if a .network filename would match the given interface."""
base = filename.replace(".network", "")
# Strip numeric priority prefix (e.g. "50-eth1" → "eth1")
if "-" in base and base.split("-", 1)[0].isdigit():
base = base.split("-", 1)[1]
return base == iface_name
def _extract_iface_from_filename(filename: str) -> str | None:
"""Extract interface name from a .network filename (e.g. '50-eth1.network''eth1')."""
base = filename.replace(".network", "")
if "-" in base and base.split("-", 1)[0].isdigit():
return base.split("-", 1)[1]
return base if base else None
def _full_reload() -> None:
"""Reload networkd for all interfaces."""
run(["networkctl", "reload"], sudo=True)
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@registry.register(GET_NETWORK_INTERFACES)
def get_interfaces(_request: Any, _body: Any) -> dict[str, Any]:
"""GET /network/interfaces — return all interface config + runtime state."""
cfg = get_config()
ifaces_cfg = cfg.get("interfaces", {})
runtime: dict[str, Any] = {}
with contextlib.suppress(Exception):
raw = run(["networkctl", "status", "--json=short", "--all"], sudo=True)
runtime = parse_networkctl_status(raw)
merged: dict[str, Any] = {}
all_names = set(ifaces_cfg.keys()) | set(runtime.keys()) - {"lo"}
for name in sorted(all_names):
merged[name] = {
"config": ifaces_cfg.get(name, {}),
"runtime": runtime.get(name, {}),
}
from lib.state import _now_iso
return {"interfaces": merged, "timestamp": _now_iso()}
@registry.register(GET_NETWORK_INTERFACE_NAME)
def get_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""GET /network/interfaces/<name> — return config for one interface."""
if not body or "name" not in body:
raise ValueError("Interface name is required")
name = validate_interface_name(body["name"])
cfg = get_config()
ifaces = cfg.get("interfaces", {})
if name not in ifaces:
raise NotFoundError(f"Interface '{name}' not found in config")
runtime: dict[str, Any] = {}
with contextlib.suppress(Exception):
raw = run(["networkctl", "status", "--json=short", "--all"], sudo=True)
runtime = parse_networkctl_status(raw)
return {
"name": name,
"config": ifaces[name],
"runtime": runtime.get(name, {}),
}
@registry.register(POST_NETWORK_INTERFACE_NAME)
def save_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /network/interfaces/<name> — save config, render, apply."""
if not body:
raise ValueError("Request body required")
name = validate_interface_name(body.get("name", ""))
iface_cfg = {k: v for k, v in body.items() if k not in ("name",)}
unknown = set(iface_cfg.keys()) - KNOWN_INTERFACE_KEYS
if unknown:
logger.warning(
"Interface '%s': unexpected config keys %s — these will be "
"saved but not rendered to .network files",
name,
sorted(unknown),
)
with contextlib.suppress(Exception):
raw = run(["networkctl", "status", "--json=short", "--all"], sudo=True)
runtime = parse_networkctl_status(raw)
if name not in runtime:
logger.warning(
"Interface '%s' not found in networkctl "
"(config saved but networkd will ignore it)",
name,
)
cfg = get_config()
cfg.setdefault("interfaces", {})
cfg["interfaces"][name] = iface_cfg
save_config(cfg)
content = render_network_file(name, iface_cfg)
DATA_DIR.mkdir(parents=True, exist_ok=True)
(DATA_DIR / f"99-{name}.network").write_text(content)
# Deploy to system. In containerized environments this may fail
# (e.g. read-only /run/sudo timestamps) — don't let that block the save.
deployed = True
try:
_copy_and_reload(name)
except Exception:
deployed = False
logger.warning(
"Interface '%s' config saved but failed to deploy to "
"systemd-networkd (sudo/system unavailable)",
name,
exc_info=True,
)
logger.info("Interface '%s' config saved (applied=%s)", name, deployed)
return {"name": name, "applied": deployed}
@registry.register(POST_NETWORK_INTERFACE_RELOAD)
def reload_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /network/interfaces/<name>/reload — reload networkd for interface."""
if not body or "name" not in body:
raise ValueError("'name' is required in request body")
name = validate_interface_name(body["name"])
with contextlib.suppress(Exception):
run(["networkctl", "reconfigure", name], sudo=True)
logger.info("Interface '%s' reloaded", name)
return {"name": name, "reloaded": True}
@registry.register(POST_NETWORK_APPLY)
def apply_all(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /network/apply — apply ALL interfaces (full sync)."""
cfg = get_config()
result = generate_network_files(cfg)
generated = result.get("generated", [])
cleaned = result.get("cleaned", [])
# Remove stale/conflicting files from system dir
expected_names = {f.name for f in generated}
managed_ifaces = {
f.name.replace("99-", "").replace(".network", "") for f in generated
}
sys_dir = Path("/etc/systemd/network")
if sys_dir.exists():
for f in sys_dir.iterdir():
if f.name.endswith(".network") and f.name not in expected_names:
iface_from_file = _extract_iface_from_filename(f.name)
if iface_from_file and iface_from_file in managed_ifaces:
# Remove conflicting external configs for managed interfaces
with contextlib.suppress(Exception):
run(["rm", str(f)], sudo=True)
cleaned.append(f)
for f in generated:
dst = sys_dir / f.name
run(["mkdir", "-p", str(sys_dir)], sudo=True)
run(["cp", str(f), str(dst)], sudo=True)
_full_reload()
# TF-8: sync DNS upstreams to dnsmasq
try:
upstreams = collect_upstream_dns(cfg)
if upstreams:
set_upstreams(upstreams)
logger.info("Synced %d DNS upstreams to dnsmasq", len(upstreams))
except Exception:
logger.warning("Failed to sync DNS upstreams to dnsmasq", exc_info=True)
logger.info(
"Network config applied: %d interfaces, %d stale cleaned",
len(generated),
len(cleaned),
)
return {
"applied": len(generated),
"files": [str(p) for p in generated],
"cleaned": [str(p) for p in cleaned],
}
@registry.register(GET_NETWORK_INFER_DHCP_RANGES)
def get_infer_dhcp_ranges(_request: Any, _body: Any) -> dict[str, Any]:
"""GET /network/infer-dhcp-ranges — suggest DHCP ranges from static IPs."""
cfg = get_config()
ranges = infer_dhcp_ranges(cfg)
return {"ranges": ranges}
@registry.register(GET_NETWORK_INFER_ZONES)
def get_infer_zones(_request: Any, _body: Any) -> dict[str, Any]:
"""GET /network/infer-zones — suggest firewalld zones from interface config."""
cfg = get_config()
zones = infer_zones(cfg)
return {"zones": zones}
@registry.register(POST_NETWORK_SYSCTL_SET)
def set_sysctl(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /sysctl/set — set a sysctl kernel parameter value.
Writes the value via `sysctl -w`, then verifies by reading it back.
Raises:
ValueError: When name or value is missing.
"""
if not body:
raise ValueError("Request body required")
name = body.get("name", "").strip()
if not name:
raise ValueError("'name' is required")
if not re.match(r"^[a-zA-Z0-9_]+(\.[a-zA-Z0-9_]+)*$", name):
raise ValueError("'name' is not a valid sysctl key")
value = str(body.get("value", "")).strip()
if not value:
raise ValueError("'value' is required")
run(["sysctl", "-w", f"{name}={value}"], sudo=True)
# Verify by reading back via /proc/sys (no sudo needed for reads, avoid
# triggering sudoers for read-only sysctl which is not whitelisted)
proc_path = Path(f"/proc/sys/{name.replace('.', '/')}")
read_value = proc_path.read_text().strip()
if read_value != value:
raise RuntimeError(
f"sysctl verify failed: set {name}={value} but read back {read_value}"
)
logger.info("sysctl %s set to %s", name, value)
return {"name": name, "value": value}
+146 -19
View File
@@ -8,6 +8,20 @@ from typing import Any
from jinja2 import Environment, FileSystemLoader
from daemon.iface import (
DELETE_NGINX_DOMAINS_REMOVE,
GET_NGINX_CONFIG,
GET_NGINX_DOMAINS,
PATCH_NGINX_CONFIG,
POST_NGINX_APPLY,
POST_NGINX_CONFIG,
POST_NGINX_DOMAINS_ADD,
POST_NGINX_DOMAINS_UPDATE,
POST_NGINX_MANAGEMENT,
POST_NGINX_RELOAD,
POST_NGINX_SSL_APPLY,
POST_NGINX_TEST,
)
from daemon.server import NotFoundError, refresh_state, registry
from lib.common import ensure_dirs, load_json, run, run_proc, save_json
@@ -50,12 +64,18 @@ DEFAULT_CONFIG: dict[str, Any] = {
def _get_state() -> dict[str, Any] | None:
"""Retrieve cached nginx state from the state store."""
from lib.state import state as state_store
return state_store.get("nginx")
def _get_config() -> dict[str, Any]:
"""Load the nginx config JSON, applying defaults for missing fields.
Returns:
The parsed config dict with ssl defaults filled in.
"""
ensure_dirs(CONFIG_DIR, SITES_DIR)
raw = load_json(CONFIG_FILE)
if not raw:
@@ -66,10 +86,23 @@ def _get_config() -> dict[str, Any]:
def _save_config(cfg: dict[str, Any]) -> None:
"""Persist the nginx config dict to disk.
Args:
cfg: The config dictionary to save.
"""
save_json(CONFIG_FILE, cfg)
def _generate_server_conf(domain_cfg: dict[str, Any]) -> str:
"""Render an nginx server block config from a domain entry via Jinja.
Args:
domain_cfg: Domain config dict containing domain name, backend, headers, etc.
Returns:
Rendered server block as a string.
"""
tmpl = ENV.get_template("nginx/server_block.conf")
return tmpl.render(
domain=domain_cfg["domain"],
@@ -86,6 +119,12 @@ def _generate_server_conf(domain_cfg: dict[str, Any]) -> str:
def _write_site(domain: str, conf_text: str) -> None:
"""Atomically write a single site config file into sites-enabled.
Args:
domain: Site name used as the filename.
conf_text: Rendered nginx server block content.
"""
ensure_dirs(SITES_DIR)
path = SITES_DIR / f"{domain}.conf"
tmp = path.with_suffix(".tmp")
@@ -97,9 +136,10 @@ def _write_site(domain: str, conf_text: str) -> None:
def _write_include_file() -> None:
"""Write the system include file that references all per-site configs."""
tmpl = ENV.get_template("nginx/include.conf")
content = tmpl.render(sites_glob=str(SITES_DIR / "*.conf"))
tmp = INCLUDE_FILE.with_suffix(".tmp")
tmp = Path("/tmp") / "vacuum-wall-include.tmp"
with open(tmp, "w") as f:
f.write(content)
os.chmod(tmp, 0o644)
@@ -109,6 +149,7 @@ def _write_include_file() -> None:
def _write_ssl_snippet() -> None:
"""Render and install the shared SSL snippet to /etc/nginx/snippets/."""
cfg = _get_config()
ssl_cfg = cfg.get("ssl", {})
ssl_cfg.setdefault("prefer_server_ciphers", DEFAULT_SSL["prefer_server_ciphers"])
@@ -116,7 +157,7 @@ def _write_ssl_snippet() -> None:
ssl_cfg.setdefault("ciphers", DEFAULT_SSL["ciphers"])
tmpl = ENV.get_template("nginx/ssl_snippet.conf")
content = tmpl.render(ssl=ssl_cfg)
tmp = SSL_SNIPPET.with_suffix(".tmp")
tmp = Path("/tmp") / "vacuum-wall-ssl-snippet.tmp"
with open(tmp, "w") as f:
f.write(content)
os.chmod(tmp, 0o644)
@@ -126,6 +167,11 @@ def _write_ssl_snippet() -> None:
def _test_config() -> tuple[bool, str]:
"""Run `nginx -t` to validate the current config.
Returns:
Tuple of (passed, message).
"""
result = run_proc(["nginx", "-t"], sudo=True, check=False)
ok = result.returncode == 0
output = (result.stderr or result.stdout or "").strip()
@@ -135,6 +181,10 @@ def _test_config() -> tuple[bool, str]:
def _reload_nginx() -> None:
"""Send SIGHUP to nginx to reload its configuration.
Logs an error if the reload fails.
"""
result = run_proc(["nginx", "-s", "reload"], sudo=True, check=False)
if result.returncode != 0:
logger.error("nginx reload failed: %s", result.stderr.strip())
@@ -143,6 +193,10 @@ def _reload_nginx() -> None:
def _write_all_sites() -> None:
"""Regenerate all site configs, management proxy, and ACME challenge site.
Removes stale .conf files that are no longer in config.
"""
ensure_dirs(SITES_DIR)
cfg = _get_config()
existing = set(SITES_DIR.iterdir()) if SITES_DIR.exists() else set()
@@ -185,12 +239,29 @@ def _write_all_sites() -> None:
os.replace(tmp, site)
def _write_htpasswd(user: str, password: str) -> None:
ensure_dirs(DATA_DIR)
import crypt
def _hash_password(password: str) -> str:
"""Hash *password* using SHA-256 crypt via passlib.
salt = os.urandom(16).hex()[:16]
hashed = crypt.crypt(password, f"$5${salt}")
Args:
password: Plain-text password to hash.
Returns:
The hashed password string suitable for ``.htpasswd``.
"""
from passlib.hash import sha256_crypt
return sha256_crypt.hash(password)
def _write_htpasswd(user: str, password: str) -> None:
"""Add or update a user entry in the .htpasswd file using SHA-256 hashing.
Args:
user: The username to add or update.
password: Plain-text password to hash.
"""
ensure_dirs(DATA_DIR)
hashed = _hash_password(password)
existing: dict[str, str] = {}
if HTPASSWD_FILE.exists():
with open(HTPASSWD_FILE) as f:
@@ -211,6 +282,7 @@ def _write_htpasswd(user: str, password: str) -> None:
def _get_nginx_state() -> dict[str, Any]:
"""Return a shallow copy of the cached nginx state, or empty dict if unset."""
ng = _get_state()
if ng is None:
return {}
@@ -221,16 +293,26 @@ def _get_nginx_state() -> dict[str, Any]:
# Routes
@registry.register("GET", "/nginx/config")
@registry.register(GET_NGINX_CONFIG)
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
"""GET /nginx/config — return current nginx config.
Returns:
Full config dict from state cache, or fallback to file.
"""
ng = _get_nginx_state()
if ng:
return ng.get("config", {})
return _get_config()
@registry.register("POST", "/nginx/config")
@registry.register(POST_NGINX_CONFIG)
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /nginx/config — replace the entire nginx config and refresh state.
Raises:
ValueError: When request body is missing.
"""
if not body:
raise ValueError("Request body required")
_save_config(body)
@@ -238,8 +320,13 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
return {"config_saved": True}
@registry.register("PATCH", "/nginx/config")
@registry.register(PATCH_NGINX_CONFIG)
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""PATCH /nginx/config — deep-merge partial updates into current config.
Raises:
ValueError: When request body is missing.
"""
if not body:
raise ValueError("Request body required")
from lib.common import deep_merge
@@ -251,16 +338,27 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
return {"config_saved": True}
@registry.register("GET", "/nginx/domains")
@registry.register(GET_NGINX_DOMAINS)
def get_domains(_request: Any, _body: Any) -> list[dict[str, Any]]:
"""GET /nginx/domains — return the list of configured proxy domains.
Returns:
Domains list from state cache, or empty list.
"""
ng = _get_nginx_state()
if ng:
return ng.get("domains", [])
return []
@registry.register("POST", "/nginx/domains/add")
@registry.register(POST_NGINX_DOMAINS_ADD)
def add_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /nginx/domains/add — add a new reverse-proxy domain entry.
Raises:
ValueError: When required fields (domain, backend_host, backend_port) are missing.
ValueError: When the domain already exists.
"""
if not body:
raise ValueError("Request body required")
domain = body.get("domain", "").strip()
@@ -296,8 +394,14 @@ def add_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
return {"domain": domain}
@registry.register("DELETE", "/nginx/domains/remove")
@registry.register(DELETE_NGINX_DOMAINS_REMOVE)
def remove_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""DELETE /nginx/domains/remove — remove a domain from the proxy config.
Raises:
ValueError: When request body or domain field is missing.
NotFoundError: When the domain is not configured.
"""
if not body:
raise ValueError("Request body required")
domain = body.get("domain", "").strip()
@@ -315,8 +419,14 @@ def remove_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
return {"domain": domain}
@registry.register("POST", "/nginx/domains/update")
@registry.register(POST_NGINX_DOMAINS_UPDATE)
def update_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /nginx/domains/update — patch fields of an existing domain entry.
Raises:
ValueError: When request body or domain field is missing.
NotFoundError: When the domain is not configured.
"""
if not body:
raise ValueError("Request body required")
domain = body.get("domain", "").strip()
@@ -337,8 +447,13 @@ def update_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
return {"domain": domain}
@registry.register("POST", "/nginx/apply")
@registry.register(POST_NGINX_APPLY)
def apply(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /nginx/apply — render all configs, test, and reload nginx.
Raises:
RuntimeError: When the nginx config test fails.
"""
_write_ssl_snippet()
_write_all_sites()
_write_include_file()
@@ -350,21 +465,32 @@ def apply(_request: Any, _body: Any) -> dict[str, Any]:
return {"applied": True}
@registry.register("POST", "/nginx/test")
@registry.register(POST_NGINX_TEST)
def test(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /nginx/test — dry-run validate the live nginx config without applying.
Returns:
Dict with valid (bool) and output (str) from `nginx -t`.
"""
valid, output = _test_config()
return {"valid": valid, "output": output}
@registry.register("POST", "/nginx/ssl-apply")
@registry.register(POST_NGINX_SSL_APPLY)
def ssl_apply(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /nginx/ssl-apply — re-render and install only the SSL snippet."""
_write_ssl_snippet()
refresh_state(["nginx"])
return {"applied": True}
@registry.register("POST", "/nginx/management")
@registry.register(POST_NGINX_MANAGEMENT)
def set_management_proxy(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /nginx/management — configure the management UI reverse proxy.
Raises:
ValueError: When request body or domain field is missing.
"""
if not body:
raise ValueError("Request body required")
domain = body.get("domain", "").strip()
@@ -389,7 +515,8 @@ def set_management_proxy(_request: Any, body: dict[str, Any] | None) -> dict[str
return {"domain": domain}
@registry.register("POST", "/nginx/reload")
@registry.register(POST_NGINX_RELOAD)
def reload_nginx(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /nginx/reload — trigger an nginx reload (SIGHUP)."""
_reload_nginx()
return {"reloaded": True}
+65 -12
View File
@@ -9,6 +9,20 @@ from typing import Any
from jinja2 import Environment, FileSystemLoader
from daemon.iface import (
DELETE_WIREGUARD_PEERS_REMOVE,
GET_WIREGUARD_CONFIG,
GET_WIREGUARD_PEER_STATUS,
GET_WIREGUARD_PEERS,
GET_WIREGUARD_STATUS,
PATCH_WIREGUARD_CONFIG,
POST_WIREGUARD_APPLY,
POST_WIREGUARD_CONFIG,
POST_WIREGUARD_DOWN,
POST_WIREGUARD_GENERATE_CLIENT,
POST_WIREGUARD_INITIALIZE,
POST_WIREGUARD_PEERS_ADD,
)
from daemon.server import NotFoundError, refresh_state, registry
from lib.common import deep_merge, load_json, run, run_proc, save_json
@@ -42,20 +56,24 @@ DEFAULT_CONFIG: dict[str, Any] = {
def _get_state() -> dict[str, Any] | None:
"""Retrieve cached WireGuard state from the global state store."""
from lib.state import state as state_store
return state_store.get("wireguard")
def _get_config() -> dict[str, Any]:
"""Load and merge the WireGuard config with defaults."""
return deep_merge(deepcopy(DEFAULT_CONFIG), load_json(CONFIG_PATH))
def _save_config(cfg: dict[str, Any]) -> None:
"""Persist the WireGuard config to disk."""
save_json(CONFIG_PATH, cfg)
def _generate_conf(cfg: dict[str, Any]) -> str:
"""Render the WireGuard server config file from Jinja template."""
tmpl = ENV.get_template("wireguard.conf")
return tmpl.render(
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
@@ -65,6 +83,7 @@ def _generate_conf(cfg: dict[str, Any]) -> str:
def _get_wg_state() -> dict[str, Any]:
"""Return cached WireGuard state, or empty dict if not yet loaded."""
wg = _get_state()
if wg is None:
return {}
@@ -75,8 +94,9 @@ def _get_wg_state() -> dict[str, Any]:
# Routes
@registry.register("GET", "/wireguard/config")
@registry.register(GET_WIREGUARD_CONFIG)
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
"""GET /wireguard/config — return WireGuard config with private key stripped."""
wg = _get_wg_state()
if wg:
return wg.get("config", {})
@@ -88,8 +108,13 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
return safe
@registry.register("POST", "/wireguard/config")
@registry.register(POST_WIREGUARD_CONFIG)
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /wireguard/config — replace config, preserving existing private key.
Raises:
ValueError: When request body is missing.
"""
if not body:
raise ValueError("Request body required")
current = _get_config()
@@ -105,8 +130,13 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
return {"config_saved": True}
@registry.register("PATCH", "/wireguard/config")
@registry.register(PATCH_WIREGUARD_CONFIG)
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""PATCH /wireguard/config — deep-merge patch into existing config.
Raises:
ValueError: When request body is missing.
"""
if not body:
raise ValueError("Request body required")
if "interface" in body:
@@ -120,8 +150,9 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
return {"config_saved": True}
@registry.register("POST", "/wireguard/apply")
@registry.register(POST_WIREGUARD_APPLY)
def apply(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /wireguard/apply — render config, write to disk, bring up tunnel via sudo."""
cfg = _get_config()
conf_text = _generate_conf(cfg)
_save_config(cfg)
@@ -140,8 +171,9 @@ def apply(_request: Any, _body: Any) -> dict[str, Any]:
return {"applied": True}
@registry.register("POST", "/wireguard/down")
@registry.register(POST_WIREGUARD_DOWN)
def down(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /wireguard/down — bring down the WireGuard tunnel via sudo."""
cfg = _get_config()
name = cfg["interface"]["name"]
run([WG_QUICK_BIN, "down", name], sudo=True)
@@ -150,16 +182,18 @@ def down(_request: Any, _body: Any) -> dict[str, Any]:
return {"down": True}
@registry.register("GET", "/wireguard/status")
@registry.register(GET_WIREGUARD_STATUS)
def status(_request: Any, _body: Any) -> dict[str, Any]:
"""GET /wireguard/status — return current WireGuard status from cache."""
wg = _get_wg_state()
if wg:
return wg.get("status", {"up": False, "interface": {}, "peers": []})
return {"up": False, "interface": {}, "peers": []}
@registry.register("POST", "/wireguard/initialize")
@registry.register(POST_WIREGUARD_INITIALIZE)
def initialize(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /wireguard/initialize — generate keypair and store in config (idempotent)."""
cfg = _get_config()
if cfg["interface"].get("private_key"):
return {"initialized": False, "reason": "already initialized"}
@@ -178,8 +212,13 @@ def initialize(_request: Any, _body: Any) -> dict[str, Any]:
return {"initialized": True, "config": safe}
@registry.register("POST", "/wireguard/peers/add")
@registry.register(POST_WIREGUARD_PEERS_ADD)
def add_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /wireguard/peers/add — add new peer or update existing one.
Raises:
ValueError: When body is missing or name is empty.
"""
if not body:
raise ValueError("Request body required")
name = body.get("name", "").strip()
@@ -217,8 +256,14 @@ def add_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
return peer_out
@registry.register("DELETE", "/wireguard/peers/remove")
@registry.register(DELETE_WIREGUARD_PEERS_REMOVE)
def remove_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""DELETE /wireguard/peers/remove — remove a peer by name.
Raises:
ValueError: When body is missing or name is empty.
NotFoundError: When peer does not exist.
"""
if not body:
raise ValueError("Request body required")
name = body.get("name", "").strip()
@@ -235,8 +280,9 @@ def remove_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
return {"name": name}
@registry.register("GET", "/wireguard/peers")
@registry.register(GET_WIREGUARD_PEERS)
def list_peers(_request: Any, _body: Any) -> list[dict[str, Any]]:
"""GET /wireguard/peers — return configured peers with private keys stripped."""
wg = _get_wg_state()
if wg:
return wg.get("peers", [])
@@ -250,16 +296,23 @@ def list_peers(_request: Any, _body: Any) -> list[dict[str, Any]]:
return result
@registry.register("GET", "/wireguard/peer-status")
@registry.register(GET_WIREGUARD_PEER_STATUS)
def get_peer_status(_request: Any, _body: Any) -> list[dict[str, Any]]:
"""GET /wireguard/peer-status — return runtime peer status from cache."""
wg = _get_wg_state()
if wg:
return wg.get("status", {}).get("peers", [])
return []
@registry.register("POST", "/wireguard/generate-client")
@registry.register(POST_WIREGUARD_GENERATE_CLIENT)
def generate_client_conf(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /wireguard/generate-client — render client-side WireGuard config for a peer.
Raises:
ValueError: When body, name, or server_endpoint is missing.
NotFoundError: When peer does not exist or has no private key.
"""
if not body:
raise ValueError("Request body required")
name = body.get("name", "").strip()
+153
View File
@@ -0,0 +1,153 @@
"""Shared walld interface definitions.
This module is the **single source of truth** for all daemon API endpoints.
Every endpoint is a frozen tuple of (method, path). Both the server's
registry.register() and the client's request/get/post/patch/delete() accept
an Endpoint in addition to a plain string path, so renaming an endpoint here
automatically updates both sides.
Usage:
# Server (daemon/handlers/)
from daemon.iface import GET_FIREWALL_ZONES
@registry.register(GET_FIREWALL_ZONES)
def get_zones(_request, _body):
...
# Client (webui/api/)
from daemon.iface import GET_FIREWALL_ZONES
from daemon.client import get
data = get(GET_FIREWALL_ZONES)
"""
from __future__ import annotations
Endpoint = tuple[str, str]
PathLike = str | Endpoint
def _ep(method: str, path: str) -> Endpoint:
return (method, path)
# ---- Nginx / Proxy ----
GET_NGINX_CONFIG: Endpoint = _ep("GET", "/nginx/config")
POST_NGINX_CONFIG: Endpoint = _ep("POST", "/nginx/config")
PATCH_NGINX_CONFIG: Endpoint = _ep("PATCH", "/nginx/config")
GET_NGINX_DOMAINS: Endpoint = _ep("GET", "/nginx/domains")
POST_NGINX_DOMAINS_ADD: Endpoint = _ep("POST", "/nginx/domains/add")
DELETE_NGINX_DOMAINS_REMOVE: Endpoint = _ep("DELETE", "/nginx/domains/remove")
POST_NGINX_DOMAINS_UPDATE: Endpoint = _ep("POST", "/nginx/domains/update")
POST_NGINX_APPLY: Endpoint = _ep("POST", "/nginx/apply")
POST_NGINX_TEST: Endpoint = _ep("POST", "/nginx/test")
POST_NGINX_SSL_APPLY: Endpoint = _ep("POST", "/nginx/ssl-apply")
POST_NGINX_MANAGEMENT: Endpoint = _ep("POST", "/nginx/management")
POST_NGINX_RELOAD: Endpoint = _ep("POST", "/nginx/reload")
# ---- Firewall ----
GET_FIREWALL_INTERFACES: Endpoint = _ep("GET", "/firewall/interfaces")
GET_FIREWALL_ZONES: Endpoint = _ep("GET", "/firewall/zones")
GET_FIREWALL_ZONES_INFO: Endpoint = _ep("GET", "/firewall/zones/info")
GET_FIREWALL_ZONES_ALL: Endpoint = _ep("GET", "/firewall/zones/all")
GET_FIREWALL_SERVICES: Endpoint = _ep("GET", "/firewall/services")
GET_FIREWALL_CONFIG: Endpoint = _ep("GET", "/firewall/config")
POST_FIREWALL_CONFIG: Endpoint = _ep("POST", "/firewall/config")
PATCH_FIREWALL_CONFIG: Endpoint = _ep("PATCH", "/firewall/config")
GET_FIREWALL_CONFIG_PENDING: Endpoint = _ep("GET", "/firewall/config/pending")
POST_FIREWALL_CONFIG_APPLY: Endpoint = _ep("POST", "/firewall/config/apply")
POST_FIREWALL_ZONES_CREATE: Endpoint = _ep("POST", "/firewall/zones/create")
DELETE_FIREWALL_ZONES_DELETE: Endpoint = _ep("DELETE", "/firewall/zones/delete")
POST_FIREWALL_ZONES_INTERFACES: Endpoint = _ep("POST", "/firewall/zones/interfaces")
POST_FIREWALL_ZONES_SERVICES: Endpoint = _ep("POST", "/firewall/zones/services")
POST_FIREWALL_RICH_RULES_ADD: Endpoint = _ep("POST", "/firewall/rich-rules/add")
DELETE_FIREWALL_RICH_RULES_REMOVE: Endpoint = _ep(
"DELETE", "/firewall/rich-rules/remove"
)
GET_FIREWALL_RICH_RULES: Endpoint = _ep("GET", "/firewall/rich-rules")
POST_FIREWALL_MASQUERADE: Endpoint = _ep("POST", "/firewall/masquerade")
POST_FIREWALL_FORWARD_PORT_ADD: Endpoint = _ep("POST", "/firewall/forward-port/add")
DELETE_FIREWALL_FORWARD_PORT_REMOVE: Endpoint = _ep(
"DELETE", "/firewall/forward-port/remove"
)
GET_FIREWALL_STATE: Endpoint = _ep("GET", "/firewall/state")
# ---- WireGuard ----
GET_WIREGUARD_CONFIG: Endpoint = _ep("GET", "/wireguard/config")
POST_WIREGUARD_CONFIG: Endpoint = _ep("POST", "/wireguard/config")
PATCH_WIREGUARD_CONFIG: Endpoint = _ep("PATCH", "/wireguard/config")
POST_WIREGUARD_APPLY: Endpoint = _ep("POST", "/wireguard/apply")
POST_WIREGUARD_DOWN: Endpoint = _ep("POST", "/wireguard/down")
GET_WIREGUARD_STATUS: Endpoint = _ep("GET", "/wireguard/status")
POST_WIREGUARD_INITIALIZE: Endpoint = _ep("POST", "/wireguard/initialize")
POST_WIREGUARD_PEERS_ADD: Endpoint = _ep("POST", "/wireguard/peers/add")
DELETE_WIREGUARD_PEERS_REMOVE: Endpoint = _ep("DELETE", "/wireguard/peers/remove")
GET_WIREGUARD_PEERS: Endpoint = _ep("GET", "/wireguard/peers")
GET_WIREGUARD_PEER_STATUS: Endpoint = _ep("GET", "/wireguard/peer-status")
POST_WIREGUARD_GENERATE_CLIENT: Endpoint = _ep("POST", "/wireguard/generate-client")
# ---- ACME / Certs ----
GET_ACME_LIST: Endpoint = _ep("GET", "/acme/list")
GET_ACME_INFO: Endpoint = _ep("GET", "/acme/info")
POST_ACME_VALIDATE: Endpoint = _ep("POST", "/acme/validate")
POST_ACME_ISSUE: Endpoint = _ep("POST", "/acme/issue")
GET_ACME_ISSUE_STATUS: Endpoint = _ep("GET", "/acme/issue/status")
POST_ACME_RENEW: Endpoint = _ep("POST", "/acme/renew")
DELETE_ACME_REMOVE: Endpoint = _ep("DELETE", "/acme/remove")
POST_ACME_EMAIL: Endpoint = _ep("POST", "/acme/email")
GET_ACME_EMAIL: Endpoint = _ep("GET", "/acme/email")
GET_ACME_PATHS: Endpoint = _ep("GET", "/acme/paths")
POST_ACME_SELF_SIGNED: Endpoint = _ep("POST", "/acme/self-signed")
# ---- Dnsmasq / DHCP ----
GET_DNSMASQ_CONFIG: Endpoint = _ep("GET", "/dnsmasq/config")
POST_DNSMASQ_CONFIG: Endpoint = _ep("POST", "/dnsmasq/config")
PATCH_DNSMASQ_CONFIG: Endpoint = _ep("PATCH", "/dnsmasq/config")
POST_DNSMASQ_APPLY: Endpoint = _ep("POST", "/dnsmasq/apply")
GET_DNSMASQ_STATUS: Endpoint = _ep("GET", "/dnsmasq/status")
POST_DNSMASQ_RANGES_ADD: Endpoint = _ep("POST", "/dnsmasq/ranges/add")
DELETE_DNSMASQ_RANGES_REMOVE: Endpoint = _ep("DELETE", "/dnsmasq/ranges/remove")
GET_DNSMASQ_LEASES: Endpoint = _ep("GET", "/dnsmasq/leases")
POST_DNSMASQ_STATIC_LEASE_ADD: Endpoint = _ep("POST", "/dnsmasq/static-lease/add")
DELETE_DNSMASQ_STATIC_LEASE_REMOVE: Endpoint = _ep(
"DELETE", "/dnsmasq/static-lease/remove"
)
POST_DNSMASQ_DNS_RECORD_ADD: Endpoint = _ep("POST", "/dnsmasq/dns-record/add")
DELETE_DNSMASQ_DNS_RECORD_REMOVE: Endpoint = _ep("DELETE", "/dnsmasq/dns-record/remove")
POST_DNSMASQ_UPSTREAMS: Endpoint = _ep("POST", "/dnsmasq/upstreams")
POST_DNSMASQ_DOMAIN: Endpoint = _ep("POST", "/dnsmasq/domain")
# ---- Network ----
GET_NETWORK_INTERFACES: Endpoint = _ep("GET", "/network/interfaces")
GET_NETWORK_INTERFACE_NAME: Endpoint = _ep("GET", "/network/interfaces/<name>")
POST_NETWORK_INTERFACE_NAME: Endpoint = _ep("POST", "/network/interfaces/<name>")
POST_NETWORK_INTERFACE_RELOAD: Endpoint = _ep(
"POST", "/network/interfaces/<name>/reload"
)
POST_NETWORK_APPLY: Endpoint = _ep("POST", "/network/apply")
GET_NETWORK_INFER_DHCP_RANGES: Endpoint = _ep("GET", "/network/infer-dhcp-ranges")
GET_NETWORK_INFER_ZONES: Endpoint = _ep("GET", "/network/infer-zones")
POST_NETWORK_SYSCTL_SET: Endpoint = _ep("POST", "/network/sysctl/set")
# ---- Logs ----
GET_LOGS_JOURNAL: Endpoint = _ep("GET", "/logs/journal")
GET_LOGS_NGINX_ACCESS: Endpoint = _ep("GET", "/logs/nginx/access")
GET_LOGS_NGINX_ERROR: Endpoint = _ep("GET", "/logs/nginx/error")
GET_LOGS_DNSMASQ: Endpoint = _ep("GET", "/logs/dnsmasq")
GET_LOGS_APP: Endpoint = _ep("GET", "/logs/app")
# ---- Server infra (not going through client) ----
GET_HEALTH: Endpoint = _ep("GET", "/health")
GET_STATUS_ALL: Endpoint = _ep("GET", "/status/all")
POST_STATUS_REFRESH: Endpoint = _ep("POST", "/status/refresh")
GET_WS: Endpoint = _ep("GET", "/ws")
POST_BATCH: Endpoint = _ep("POST", "/batch")
# Collect all endpoint module-level constants for __all__ verification
_all_endpoints = [
name
for name, val in globals().items()
if isinstance(val, tuple) and len(val) == 2 and all(isinstance(x, str) for x in val)
]
__all__ = ["Endpoint", "PathLike", *sorted(_all_endpoints)]
+242 -24
View File
@@ -15,46 +15,123 @@ from typing import Any
from aiohttp import web
from daemon.iface import PathLike
from lib.state import state as state_store
logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent
SOCKET_PATH = PROJECT_DIR / "data" / "daemon.sock"
_WS_PORT = int(os.environ.get("VACUUM_WALLD_WS_PORT", "9091"))
class Handler:
"""Wrapper for a daemon handler function."""
"""Wrapper for a daemon handler function.
Attributes:
method: HTTP method (e.g. "GET", "POST").
path: URL path pattern.
"""
def __init__(self, method: str, path: str) -> None:
"""Initialize the handler metadata.
Args:
method: HTTP method.
path: URL path pattern.
"""
self.method = method.upper()
self.path = path
class Registry:
"""Route registry for daemon handlers."""
"""Route registry for daemon handlers.
Attributes:
_routes: Mapping of (method, path) tuples to handler callables.
"""
def __init__(self) -> None:
"""Initialize an empty route registry."""
self._routes: dict[tuple[str, str], Callable] = {}
def register(self, method: str, path: str):
def register(self, method: PathLike, path: str | None = None):
"""Decorator that registers a handler for the given method and path.
Accepts either two separate arguments (``method``, ``path``) or a
single :class:`daemon.iface.Endpoint` tuple.
Args:
method: HTTP method string, or an :class:`Endpoint` tuple.
path: URL path (omit when passing an :class:`Endpoint`).
Returns:
Decorator function wrapping the handler.
"""
if isinstance(method, tuple):
ep_method, ep_path = method
path = ep_path
method = ep_method
def decorator(fn: Callable) -> Callable:
self._routes[(method.upper(), path)] = fn
fn._handler = Handler(method, path) # type: ignore[attr-defined]
self._routes[(method.upper(), path)] = fn # type: ignore[arg-type]
fn._handler = Handler(method, path) # type: ignore[attr-defined,reportArgumentType]
return fn
return decorator
def get(self, method: str, path: str) -> Callable | None:
"""Look up a registered handler by method and path.
Args:
method: HTTP method to match.
path: URL path to match.
Returns:
The handler callable, or None if not registered.
"""
return self._routes.get((method.upper(), path))
def match(self, method: str, path: str):
"""Match *path* against registered patterns, returning handler + params.
Patterns may contain ``<param>`` segments (e.g. ``/foo/<name>``).
Matching segments are captured into a dict and merged into *body*.
Returns:
Tuple of (handler_fn, params_dict) or (None, None) if no match.
"""
for (reg_method, reg_path), fn in self._routes.items():
if reg_method != method.upper():
continue
pat_params = _match_path(reg_path, path)
if pat_params is not None:
return fn, pat_params
return None, None
registry = Registry()
def refresh_state(subsystems: list[str] | None = None) -> None:
"""Refresh the pre-computed state for the given subsystems (or all)."""
"""Refresh the pre-computed state for the given subsystems (or all).
Args:
subsystems: List of subsystem names to refresh. If None, all subsystems are refreshed.
"""
state_store.populate(subsystems)
targets = subsystems or state_store.SUBSYSTEMS
for name in targets:
state_store.bump(name)
try:
asyncio.get_running_loop()
except RuntimeError:
pass
else:
task = asyncio.create_task(broadcast_versions())
task.add_done_callback(_ws_tasks.discard)
_ws_tasks.add(task)
class NotFoundError(Exception):
@@ -64,36 +141,81 @@ class NotFoundError(Exception):
def ok(data: Any = None) -> web.Response:
"""Create a success JSON response.
Args:
data: Response payload. Defaults to None.
Returns:
A JSON Response with `ok` set to True.
"""
return web.json_response({"ok": True, "data": data})
def error(msg: str, code: int = 400) -> web.Response:
"""Create an error JSON response.
Args:
msg: Error message.
code: HTTP status code. Defaults to 400.
Returns:
A JSON Response with `ok` set to False.
"""
return web.json_response({"ok": False, "error": msg}, status=code)
def _match_path(pattern: str, path: str) -> dict[str, str] | None:
"""Match *path* against a URL pattern containing ``<param>`` segments.
Args:
pattern: URL pattern like ``/network/interfaces/<name>``.
path: Actual request path like ``/network/interfaces/eth1``.
Returns:
Dict mapping param names to their matched values, or ``None`` if no match.
"""
p_parts = pattern.strip("/").split("/")
r_parts = path.strip("/").split("/")
if len(p_parts) != len(r_parts):
return None
params: dict[str, str] = {}
for p_seg, r_seg in zip(p_parts, r_parts, strict=True):
if p_seg.startswith("<") and p_seg.endswith(">"):
params[p_seg[1:-1]] = r_seg
elif p_seg != r_seg:
return None
return params
async def _handle_request(request: web.Request) -> web.Response:
"""Dispatch a request to the appropriate handler."""
handler_fn = registry.get(request.method, request.path)
"""Dispatch a request to the appropriate handler.
Args:
request: The incoming HTTP request.
Returns:
The handler's response.
"""
handler_fn, pat_params = registry.match(request.method, request.path)
if handler_fn is None:
return error(f"Method {request.method} not allowed for {request.path}", 404)
# Build body from JSON and merge query params. GET requests send params
# as URL query string, so they need to be treated as body for handlers.
body: dict[str, Any] | None = None
# Build body — merge order (highest wins): path params > JSON body > query params.
# Path params come from the URL path (e.g. /interfaces/eth0) and should not
# be overridable by body or query parameters.
body: dict[str, Any] | None = pat_params if pat_params else None
if request.content_type == "application/json":
try:
body = await request.json()
json_body = await request.json()
body = {**json_body, **body} if body is not None else json_body
except json.JSONDecodeError:
return error("Invalid JSON body", 400)
query_dict = dict(request.query)
if query_dict:
query_body = {k: v[0] if len(v) == 1 else v for k, v in query_dict.items()}
if body is not None:
merged = {**query_body, **body}
body = merged
else:
body = query_body
body = {**query_body, **body} if body is not None else query_body
try:
if body is not None:
@@ -126,7 +248,14 @@ async def _handle_request(request: web.Request) -> web.Response:
async def _handle_batch(request: web.Request) -> web.Response:
"""Handle batch requests: execute operations in order, return keyed results."""
"""Handle batch requests: execute operations in order, return keyed results.
Args:
request: The incoming HTTP request containing operations.
Returns:
A JSON response keyed by operation IDs.
"""
try:
body = await request.json()
except json.JSONDecodeError:
@@ -146,7 +275,7 @@ async def _handle_batch(request: web.Request) -> web.Response:
results[op_id] = {"ok": False, "error": "'id' and 'path' are required"}
continue
handler_fn = registry.get(method, path)
handler_fn, pat_params = registry.match(method, path)
if handler_fn is None:
results[op_id] = {
"ok": False,
@@ -155,6 +284,8 @@ async def _handle_batch(request: web.Request) -> web.Response:
continue
op_body = op.get("body")
if pat_params:
op_body = {**(op_body or {}), **pat_params}
try:
result = handler_fn(None, op_body)
@@ -175,26 +306,94 @@ async def _handle_batch(request: web.Request) -> web.Response:
def create_app() -> web.Application:
"""Create and configure the aiohttp application.
Returns:
A configured web.Application instance.
"""
app = web.Application()
app.router.add_route("GET", "/health", _health)
app.router.add_route("GET", "/status/all", get_status_all)
app.router.add_route("POST", "/status/refresh", refresh_status)
app.router.add_route("POST", "/batch", _handle_batch)
app.router.add_route("GET", "/ws", _handle_ws)
app.router.add_route("*", "/{tail:.*}", _catch_all)
return app
# WebSocket subscribers
_ws_subscribers: set[web.WebSocketResponse] = set()
_ws_tasks: set[asyncio.Task[None]] = set()
async def _handle_ws(request: web.Request) -> web.Response:
"""WebSocket endpoint for real-time state change notifications.
On connect: sends current versions. On state change: broadcasts
updated subsystem versions. Clients disconnect to unsubscribe.
"""
ws = web.WebSocketResponse()
await ws.prepare(request)
_ws_subscribers.add(ws)
await ws.send_json({"type": "init", "versions": state_store.get_versions()})
try:
async for msg in ws:
if msg.type == web.WSMsgType.ERROR:
break
if msg.type == web.WSMsgType.CLOSE:
break
finally:
_ws_subscribers.discard(ws)
return ws
async def broadcast_versions() -> None:
"""Broadcast updated subsystem versions to all WebSocket clients."""
updated = state_store.get_updated_versions()
if not updated or not _ws_subscribers:
return
data = json.dumps({"type": "versions", "updated": updated})
dead: set[web.WebSocketResponse] = set()
for ws in _ws_subscribers:
try:
await ws.send_str(data)
except Exception:
dead.add(ws)
_ws_subscribers.difference_update(dead)
if dead:
logger.warning("Removed %d dead WS subscribers", len(dead))
async def _health(_request: web.Request) -> web.Response:
"""Return the health check response.
Returns:
JSON response with process ID and socket path.
"""
return ok({"pid": os.getpid(), "socket": str(SOCKET_PATH)})
async def get_status_all(_request: web.Request) -> web.Response:
"""Return the entire state snapshot in one call."""
"""Return the entire state snapshot in one call.
Returns:
JSON response containing state data for all subsystems.
"""
return ok({name: state_store.get(name) for name in state_store.SUBSYSTEMS})
async def refresh_status(_request: web.Request) -> web.Response:
"""Re-collect all state from system."""
"""Re-collect all state from system.
Args:
_request: The incoming request. JSON body may contain a "subsystems" list.
Returns:
JSON response with the updated state snapshot.
"""
try:
body = await _request.json()
except (json.JSONDecodeError, ValueError):
@@ -207,24 +406,40 @@ async def refresh_status(_request: web.Request) -> web.Response:
async def _catch_all(request: web.Request) -> web.Response:
"""Catch-all for registered routes."""
"""Catch-all for registered routes.
Args:
request: The incoming HTTP request.
Returns:
The dispatched handler's response.
"""
return await _handle_request(request)
def _register_routes() -> None:
"""Import all handler modules to register routes."""
"""Import all handler modules to register routes.
Side effect: each imported module's `@registry.register` calls populate the
route registry with their handler endpoints.
"""
from daemon.handlers import (
acme, # noqa: F401
dnsmasq, # noqa: F401
firewall, # noqa: F401
logs, # noqa: F401
network, # noqa: F401
nginx, # noqa: F401
wireguard, # noqa: F401
)
def main() -> None:
"""Entry point for vacuum-walld."""
"""Entry point for vacuum-walld.
Sets up logging, registers routes, creates the aiohttp application,
and starts listening on the Unix socket.
"""
from lib.logging import setup_logging
setup_logging()
@@ -252,6 +467,8 @@ def main() -> None:
loop.run_until_complete(runner.setup())
site = web.UnixSite(runner, socket_path)
loop.run_until_complete(site.start())
tcp_site = web.TCPSite(runner, "127.0.0.1", _WS_PORT)
loop.run_until_complete(tcp_site.start())
os.chmod(socket_path, 0o660)
@@ -259,6 +476,7 @@ def main() -> None:
logger.info("Populating system state...")
state_store.populate()
logger.info("vacuum-walld listening on %s", socket_path)
logger.info("WebSocket on 127.0.0.1:%d", _WS_PORT)
try:
loop.run_forever()
+187 -2
View File
@@ -965,7 +965,29 @@ Set or update the ACME account contact email.
**Response (`data`):** Returns the set `email` field.
---
#### Generate Self-Signed Certificate
```
POST /api/certs/self-signed
```
Generate a self-signed certificate for a domain. Idempotent — skips if `fullchain.cer` and `<domain>.key` already exist at `data/acme/<domain>/`.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `domain` | `string` | Yes | Domain name for the certificate CN |
| `days` | `number` | No | Validity in days; defaults to `365` |
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `domain` | `string` | Domain name |
| `cert` | `string` | Path to `fullchain.cer` |
| `key` | `string` | Path to `<domain>.key` |
| `generated` | `boolean` | `true` if a new cert was created, `false` if existing cert was reused |
## WireGuard API
@@ -1190,9 +1212,172 @@ Returns HTTP `404` if the peer is not found.
---
## Network API
Endpoints prefixed with `/api/network/...`. Manage systemd-networkd interface configuration including static addresses, routes, DNS, DHCP client settings, and link parameters.
### Interface Management
#### List All Interfaces
```
GET /api/network/interfaces
```
Return all configured interfaces with their network config and runtime state from `networkctl`.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data.interfaces` | `object` | Map of interface name to `{config, runtime}` |
| `data.timestamp` | `string` | Timestamp of runtime data collection |
---
#### Get Interface Details
```
GET /api/network/interfaces/<name>
```
Return config and runtime state for a specific interface.
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `name` | `string` | Interface name |
| `config` | `object` | Full networkd config entry for this interface |
| `runtime` | `object` | Runtime state from `networkctl` (addresses, gateway, DNS, state) |
Returns HTTP `400` if the interface name is invalid (contains path components, spaces, or characters outside `^[a-zA-Z0-9][a-zA-Z0-9._-]*$`). Returns HTTP `404` if the interface is not found in config.
---
#### Save and Apply Interface
```
POST /api/network/interfaces/<name>
```
Save network config for an interface, render the `.network` file, copy it to `/etc/systemd/network/`, and reload networkd for that interface.
**Request Body:** Any networkd config keys (e.g., `addresses`, `gateway`, `dns`, `routes`, `dhcp`, `link`, `dhcp_client`).
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `name` | `string` | Interface name |
| `applied` | `boolean` | Always `true` on success |
Returns HTTP `400` if the interface name is invalid.
---
#### Reload Interface
```
POST /api/network/interfaces/<name>/reload
```
Reload networkd for a single interface (runs `networkctl reload <name>`).
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `name` | `string` | Interface name |
| `reloaded` | `boolean` | Always `true` on success |
Returns HTTP `400` if the interface name is invalid.
### Full Sync
#### Apply All Interfaces
```
POST /api/network/apply
```
Full sync: generate all `.network` files, remove stale files, copy to `/etc/systemd/network/`, reload all interfaces, and sync DNS upstreams to dnsmasq.
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `applied` | `number` | Number of interfaces applied |
| `files` | `[string, ...]` | Paths of generated files |
| `cleaned` | `[string, ...]` | Paths of removed stale files |
### Helpers
#### Infer DHCP Ranges
```
GET /api/network/infer-dhcp-ranges
```
Suggest candidate DHCP ranges based on static interface IPs. For each interface with a static IPv4 address, calculates a usable address range in the subnet.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data.ranges` | `object` | Map of interface name to `{subnet, prefix, start, end}` |
---
#### Infer Firewall Zones
```
GET /api/network/infer-zones
```
Suggest firewalld zone assignments for configured interfaces based on heuristics:
- Interface name contains `wg``wan`
- DHCP-enabled or public-facing IP → `wan`
- Has explicit routes → `management`
- Everything else → `lan`
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data.zones` | `object` | Map of interface name to suggested zone (`"lan"`, `"wan"`, `"management"`) |
### Sysctl
#### Set Kernel Parameter
```
POST /api/sysctl/set
```
Set a sysctl kernel parameter value via `sysctl -w`, then verify by reading it back.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | `string` | Yes | Kernel parameter name (e.g., `"net.ipv4.ip_forward"`) |
| `value` | `string` | Yes | Value to set |
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `name` | `string` | Parameter name |
| `value` | `string` | Value set |
Returns HTTP `500` if the value cannot be verified after write.
---
## Logs API
Endpoints prefixed with `/api/logs/...`. Serve rendered HTML log line fragments for HTMX consumption. These endpoints **do not** follow the standard JSON `{"ok": true, "data": ...}` response contract — they return HTML `<div>` elements directly. Errors are rendered inline as `(error reading ...)` text rather than returning JSON error responses.
Endpoints prefixed with `/api/logs/...`. These endpoints **do not** follow the standard JSON `{"ok": true, "data": ...}` response contract — they return HTML `<div>` elements directly. Errors are rendered inline as `(error reading ...)` text rather than returning JSON error responses.
### System Journal
+34 -1
View File
@@ -34,12 +34,13 @@ The following diagram summarizes how the Flask WebUI communicates with each mana
```
External Client ──→ nginx (SSL termination) ──→ Flask WebUI (127.0.0.1:9090)
Flask WebUI ──→ daemon/client.py (Unix socket) ──→ vacuum-walld (aiohttp server)
Flask WebUI ──→ daemon/client.py (path resolution, Unix socket) ──→ vacuum-walld (aiohttp server)
vacuum-walld ──→ daemon/handlers/firewall.py ──→ sudo firewall-cmd ──→ firewalld / D-Bus ──→ nftables
vacuum-walld ──→ daemon/handlers/nginx.py ──→ write local .conf files ──→ sudo cp to /etc/nginx/ ──→ sudo nginx -t && sudo nginx -s reload
vacuum-walld ──→ daemon/handlers/dnsmasq.py ──→ render config ──→ sudo tee /etc/dnsmasq.d/vacuum-wall.conf ──→ sudo systemctl reload dnsmasq
vacuum-walld ──→ daemon/handlers/acme.py ──→ acme.sh (subprocess) ──→ deploy hook (daemon API) ──→ ZeroSSL ACME
vacuum-walld ──→ daemon/handlers/wireguard.py ──→ render data/wireguard/wg0.conf ──→ sudo cp to /etc/wireguard/ ──→ sudo wg-quick up wg0
vacuum-walld ──→ daemon/handlers/network.py ──→ render 50-<name>.network ──→ sudo cp to /etc/systemd/network/ ──→ sudo networkctl reload
vacuum-walld ──→ daemon/handlers/logs.py ──→ sudo journalctl ──→ systemd journal
```
@@ -77,6 +78,7 @@ Vacuum Wall uses a declarative configuration model. Persistent user-facing confi
| dnsmasq | `config/dnsmasq/config.json` | `data/dnsmasq/fragments/` | `/etc/dnsmasq.d/vacuum-wall.conf` | The JSON file is the source of truth. The rendered `.conf` file is overwritten on each apply. |
| nginx | `config/nginx/config.json` | `data/nginx/.htpasswd`, `data/nginx/sites-enabled/` | `data/nginx/sites-enabled/<domain>.conf` + `/etc/nginx/conf.d/vacuum-wall.conf` | All proxy and management domain definitions are derived from the JSON config. Generated `.conf` files are overwritten on each apply. |
| WireGuard | `config/wireguard/config.json` | `data/wireguard/` | `/etc/wireguard/wg0.conf` | The JSON file defines the interface and all peers. The rendered WireGuard config is overwritten on each apply. |
| networkd | `config/network/config.json` | `data/networkd/` | `/etc/systemd/network/50-<name>.network` | The JSON file defines per-interface static addresses, routes, DNS, DHCP, and link settings. Each entry renders to a `50-<name>.network` INI file. Stale files are cleaned on apply. Public DNS servers are auto-synced to dnsmasq upstreams. |
| ACME | N/A (`~/.acme.sh/` managed by acme.sh) | `data/acme/` | Certificate and key files | acme.sh manages its own state, renewal scheduling, and account keys. Vacuum Wall triggers issuance and renewal but does not maintain independent ACME state. |
## Directory Structure
@@ -95,6 +97,8 @@ config/
│ └── config.json # Proxy domain definitions, management domain, SSL settings
└── wireguard/
└── config.json # WireGuard interface and peer configuration
├── network/
│ └── config.json # Per-interface static IP, routes, DNS, DHCP settings
```
### Data — Runtime Artifacts
@@ -114,6 +118,7 @@ data/
├── logs/
│ └── vacuum-wall.log # Application log file
└── wireguard/ # WireGuard runtime artifacts
├── networkd/ # Generated 50-<name>.network files
```
Both `config/` and `data/` reside within the project directory. The systemd service unit's `ReadWritePaths` directive grants the Flask process write access to both directories, while keeping the rest of the filesystem read-only. The `INSTALL_DIR` value is templated into the service unit at install time.
@@ -128,10 +133,38 @@ The following file system locations are used for integration with system service
| `/etc/nginx/snippets/vacuum-wall-ssl.conf` | Shared SSL configuration snippet (protocols, ciphers, DH parameters, OCSP). Included by all HTTPS server blocks. | Vacuum Wall (lib/nginx.py) |
| `/etc/dnsmasq.d/vacuum-wall.conf` | Generated dnsmasq configuration file. Written from `config/dnsmasq/config.json`. | Vacuum Wall (lib/dnsmasq.py) |
| `/etc/wireguard/wg0.conf` | Generated WireGuard interface configuration. Written from `config/wireguard/config.json`. | Vacuum Wall (lib/wireguard.py) |
| `/etc/systemd/network/50-<name>.network` | Generated systemd-networkd drop-in files. Written from `config/network/config.json`, one per interface. | Vacuum Wall (lib/network.py) |
| `/etc/sudoers.d/vacuum-walld` | Sudo whitelist for the daemon user. Defines all permitted privilege escalations. | Install script (rendered from Jinja2 template) |
The `/etc/nginx/conf.d/vacuum-wall.conf` include file ensures that all domain-specific configurations in `sites-enabled/` are loaded by nginx without modifying the main `nginx.conf`. The SSL snippet keeps TLS settings consistent across all managed domains and allows global updates from a single location.
## Frontend Architecture
The web UI is a single-page application built on **Hoover**, a custom lightweight VDOM framework. See [Hoover Framework Reference](hoover.md) for the complete API.
### Request Flow (Frontend)
```
Client requests index.html ──→ nginx ──→ Flask (server-side __WS_URL_PLACEHOLDER__ substitution)
Client loads app.js ──→ Hoover initializes, mounts #sidebar and #main render roots
Hoover connects WebSocket ──→ daemon/ws (127.0.0.1:9091)
Page navigate (hash change) ──→ reactive router state updates ──→ render engine re-executes ──→ VDOM diff patches DOM
User action (form submit) ──→ apiFetch() ──→ Flask REST API ──→ daemon/client.py ──→ vacuum-walld
WebSocket message (versions) ──→ topic match ──→ page load() re-executed ──→ state updated ──→ render engine patches DOM
```
### Component Model
Each route is a `definePage()` component with reactive state, async data loading, and WebSocket auto-refresh. Pages are mounted using `hComp(page, key)` in the router, where the key determines lifecycle boundaries. The same key reuses the component instance (preserving state); a different key unmounts the old page and mounts the new one.
### No Build Step
All JavaScript is served as ES modules. The `?v=N` query string param version-pins asset imports for cache invalidation. Dev mode (`VACUUM_WALL_DEV`) disables aggressive static asset caching.
### WebSocket Broadcast
The daemon broadcasts state-change notifications via WebSocket. Hoover's `subscribe` mechanism maps page-level topic subscriptions to automatic `load()` re-executions. Messages are debounced (300ms) and in-flight loads are aborted before re-loading, ensuring the UI always displays the latest available data.
## Zone Model
The firewalld zone layout in Vacuum Wall follows a defense-in-depth approach, segmenting traffic based on trust level:
+91 -1
View File
@@ -291,4 +291,94 @@ The `zones` object maps zone names (keys) to zone configurations. Each zone corr
### Applying Firewall Configuration
The `config_pending()` function compares the declarative config in `config/firewall/config.json` against the live firewalld state returned by the daemon (via `daemon.handlers.firewall.get_state()`). It returns a diff indicating which zones have pending changes for interfaces, services, target, masquerade, forward ports, and rich rules. Zones that exist live but not in config are reported as `unmanaged_zones`.
The `config_pending()` function compares the declarative config in `config/firewall/config.json` against the live firewalld state returned by the daemon (via `daemon.handlers.firewall.get_state()`). It returns a diff indicating which zones have pending changes for interfaces, services, target, masquerade, forward ports, and rich rules. Zones that exist live but not in config are reported as `unmanaged_zones`.
## Networkd (IP Configuration)
**File**: `config/network/config.json`
This file defines static IP configuration for network interfaces managed by systemd-networkd. The application renders each interface entry into a `50-<name>.network` INI file in `data/networkd/`, which the handler copies to `/etc/systemd/network/`.
```json
{
"interfaces": {
"eth0": {
"addresses": ["192.168.1.1/24"],
"gateway": "192.168.1.254",
"dns": ["8.8.8.8", "1.1.1.1"],
"dhcp": "no"
},
"eth1": {
"dhcp": "ipv4",
"dns_default_route": true,
"dhcp_client": {
"hostname": "router",
"use_dns": true
}
},
"wg0": {
"addresses": [{"address": "10.137.0.1/24"}],
"routes": [
{
"destination": "10.0.0.0/8",
"gateway": "10.137.0.2"
}
]
}
}
}
```
### Interface Entry Fields
Each key in the `interfaces` object is an interface name (e.g., `eth0`, `eth1`, `wg0`). The value is a dict with the following keys:
| Field | Type | Description |
|---|---|---|
| `addresses` | `array` | IPv4 addresses. Each item is either a bare CIDR string (`"192.168.1.1/24"`) or a dict with `address`, `label`, `scope`, `route_metric`, `duplicate_address_detection`, `manage_temporary_address`, `add_prefix_route`. Renders to `[Address]` sections. |
| `ipv6_addresses` | `array` | Same as `addresses`, but for IPv6. |
| `gateway` | `string` | Default IPv4 gateway (`[Network] Gateway=`). |
| `ipv6_gateway` | `string` | Default IPv6 gateway (`[Network] IPv6Gateway=`). |
| `dns` | `array` | IPv4 DNS servers (`[Network] DNS=`, one per line). |
| `ipv6_dns` | `array` | IPv6 DNS servers (`[Network] IPv6DNS=`). |
| `domains` | `array` | Search domains (`[Network] Domains=`). |
| `ipv6_domains` | `array` | IPv6 search domains (`[Network] IPv6Domains=`). |
| `dns_default_route` | `boolean` | Whether DNS is the default route for resolution (`[Network] DNSDefaultRoute=`). |
| `dhcp` | `string` | DHCP mode: `"yes"`, `"ipv4"`, `"ipv6"`, `"no"`. Controls `[Network] DHCP=` and whether `[DHCPv4]`/`[DHCPv6]` sections are rendered. |
| `routes` | `array` | Static routes. Each dict has `destination`, `gateway`, `metric`, `table`, `type`, `scope`, `gateway_on_link`, `ipv6_preference`, `initial_congestion_window`, `initial_advertised_receive_window`, `quick_ack`, `fast_open_no_cookie`, `mtu_bytes`, `protocol`, `next_hop`, `multi_path_route`. Renders to `[Route#N]` sections. |
| `link` | `object` | Link settings: `mtu_bytes`, `mac_address`, `arp`, `multicast`, `all_multicast`, `promiscuous`, `unmanaged`, `activation_policy`, `required_for_online`. Renders to `[Link]` section. |
| `dhcp_client` | `object` | DHCP client settings. Shared keys for both `[DHCPv4]` and `[DHCPv6]`: `hostname`, `duid_type`, `duid_raw_data`, `iaid`, `client_identifier`, `rapid_commit`, `anonymize`, `use_dns`, `use_ntp`, `use_sip`, `use_captive_portal`, `use_mtu`, `use_hostname`, `use_domains`, `use_routes`, `route_metric`, `send_decline`, `net_label`, `nft_set`, `ip_service_type`, `socket_priority`, `bootp`, `label`, `max_attempts`, `listen_port`, `server_port`, `mud_url`, `boot_filename`, `send_option`, `send_vendor_option`, `user_class`, `vendor_class_identifier`, `request_options`. |
| `bind_carrier` | `array` | Carrier interfaces to bind to. |
| `ignore_carrier_loss` | `boolean` | Ignore carrier loss events. |
| `keep_configuration` | `boolean` | Keep configuration on stop. |
| `configure_without_carrier` | `boolean` | Configure even without carrier. |
| `link_local_addressing` | `string` | Link-local addressing mode. |
| `ipv6_link_local_address_generation_mode` | `string` | IPv6 link-local address generation mode. |
| `ipv6_stable_secret_address` | `string` | Stable secret for IPv6 address generation. |
| `ipv4_ll_start_address` | `string` | Link-local IPv4 start address. |
| `ipv4_ll_route` | `boolean` | Add route to link-local IPv4 address. |
| `default_route_on_device` | `boolean` | Always add default route via this device. |
| `ipv6_hop_limit` | `int` | IPv6 hop limit. |
| `ipv6_retransmission_time_sec` | `string` | IPv6 retransmission timeout. |
| `ipv4_duplicate_address_detection_timeout_sec` | `string` | IPv4 DAD timeout. |
| `ipv4_reverse_path_filter` | `string` | IPv4 reverse path filtering mode. |
| `ipv4_accept_local` | `boolean` | Accept packets to local addresses as non-local. |
| `ipv4_route_localnet` | `boolean` | Route local network traffic. |
| `ipv4_proxy_arp` | `boolean` | Enable proxy ARP. |
| `ipv4_proxy_arp_private_vlan` | `boolean` | Private VLAN proxy ARP. |
| `ipv6_proxy_ndp` | `boolean` | Enable IPv6 proxy NDP. |
| `ipv6_proxy_ndp_address` | `string` | IPv6 proxy NDP address. |
| `ipv6_send_ra` | `boolean` | Send IPv6 Router Advertisements. |
| `m_pls_routing` | `boolean` | Enable MPLS routing. |
| `keep_master` | `boolean` | Keep master on stop. |
| `ip_family` | `string` | IP family to use. |
Keys not in the recognized set will be saved to `config.json` but won't be rendered to `.network` files. A warning is logged identifying any unrecognized keys.
### DNS Upstream Sync
When `POST /api/network/apply` is called, the handler automatically collects public DNS servers from all networkd interface configs (via `collect_upstream_dns()`), filters out local/private-range addresses, and syncs the deduplicated list to dnsmasq's upstream DNS configuration. This keeps dnsmasq's upstream resolvers in sync with whatever DNS the WAN interface receives (whether statically configured or via DHCP).
### Generated Files
Each interface config entry produces a `50-<name>.network` file in `data/networkd/`. During apply, these are copied to `/etc/systemd/network/` and stale files (not matching any config entry) are removed. File generation uses the `systemd.syntax(7)` naming convention: first section is bare (`[Address]`, `[Route]`), subsequent sections use `#` suffix (`[Address#1]`, `[Route#2]`).
+1115
View File
File diff suppressed because it is too large Load Diff
+30 -8
View File
@@ -2,11 +2,11 @@
## What is Vacuum Wall?
Vacuum Wall is a zone-based firewall appliance with a built-in SSL reverse proxy, providing a unified platform for network security and traffic management. It combines firewalld policy control, DHCP/DNS services, WireGuard VPN tunnels, and automated certificate provisioning into a single device. A single web UI controls everything, making enterprise-grade network infrastructure manageable from one place.
Vacuum Wall is a zone-based firewall appliance with a built-in SSL reverse proxy, providing a unified platform for network security and traffic management. It combines firewalld policy control, DHCP/DNS services, systemd-networkd for static IP management, WireGuard VPN tunnels, and automated certificate provisioning into a single device. A single web UI controls everything, making enterprise-grade network infrastructure manageable from one place.
## Architecture Overview
Vacuum Wall is built around four integrated subsystems managed through a two-layer architecture: a non-privileged Flask web UI and a privileged background daemon (`vacuum-walld`). The web UI communicates with the daemon via a Unix socket. The daemon handles all privileged operations (sudo) for the subsystems: the traffic plane uses firewalld with its nftables backend (zone-based policies, source NAT, destination NAT); the DNS/DHCP plane serves private subnets via dnsmasq; the proxy plane runs nginx with automatic ACME certificates through acme.sh; and the VPN plane uses WireGuard (wg-quick) for encrypted tunnel management. All subsystems are configured and monitored through the Flask web UI, which is itself proxied through nginx with basic HTTP authentication.
Vacuum Wall is built around five integrated subsystems managed through a two-layer architecture: a non-privileged Flask web UI and a privileged background daemon (`vacuum-walld`). The web UI communicates with the daemon via a Unix socket. The daemon handles all privileged operations (sudo) for the subsystems: the traffic plane uses firewalld with its nftables backend (zone-based policies, source NAT, destination NAT); the DNS/DHCP plane serves private subnets via dnsmasq; the network plane uses systemd-networkd for static IP management; the proxy plane runs nginx with automatic ACME certificates through acme.sh; and the VPN plane uses WireGuard (wg-quick) for encrypted tunnel management. All subsystems are configured and monitored through the Flask web UI, which is itself proxied through nginx with basic HTTP authentication.
## Subsystems
@@ -22,6 +22,10 @@ dnsmasq serves as both the DHCP server and local DNS resolver. It is configured
The nginx reverse proxy handles HTTPS termination for user-defined domains, with certificates automatically provisioned and renewed via acme.sh and an ACME provider (ZeroSSL by default). Each proxy domain is configured with an HTTP-to-HTTPS redirect, modern TLS settings, and a configurable backend target. New proxy domains are added through the web UI, and the configuration is applied without manual intervention.
### Network (systemd-networkd)
The networkd subsystem manages static IP configuration for network interfaces via systemd-networkd. It renders declarative JSON configuration into per-interface `.network` INI files (`50-<name>.network`), supporting static addresses, routes, DNS, DHCP clients, link settings, and all `[Address]`, `[Route]`, `[DHCPv4]`, `[DHCPv6]`, and `[Link]` section keys. When the full apply runs, public DNS servers from networkd configs are auto-synced to dnsmasq's upstream resolvers. Helper endpoints can infer candidate DHCP ranges from static IPs and suggest firewalld zone assignments based on interface role.
### WireGuard
WireGuard support provides server-side VPN tunnel management. Peers are added through the web UI, with the system generating client configuration files that can be downloaded and applied on remote devices. The dashboard displays active connections and transfer statistics for each peer, allowing operators to monitor tunnel health and usage.
@@ -31,12 +35,11 @@ WireGuard support provides server-side VPN tunnel management. Peers are added th
- Debian 13 (trixie) target platform
- Python 3.13+, Flask 3.x for web management
- firewalld (nftables backend)
- systemd-networkd (ip-lladdr, networkctl)
- nginx 1.26+
- dnsmasq
- WireGuard tools (wireguard-tools)
- acme.sh for ACME certificate management (ZeroSSL by default)
- HTMX for dynamic UI updates
- Jinja2 for server-side templating
## Quick Start
@@ -60,6 +63,7 @@ After installation, access the management interface at `https://<hostname>.local
├── .venv/ # Python virtual environment
├── config/ # Declarative JSON configuration (source of truth)
│ ├── dnsmasq/ # DHCP/DNS config
│ ├── network/ # systemd-networkd per-interface config
│ ├── firewall/ # Firewall zone & rule config
│ ├── nginx/ # Proxy domain & SSL config
│ └── wireguard/ # VPN interface & peer config
@@ -69,11 +73,13 @@ After installation, access the management interface at `https://<hostname>.local
│ ├── acme/ # ACME certificates
│ ├── firewall/ # Firewall rule backup
│ ├── logs/ # Application logs
│ ├── networkd/ # Generated 50-<name>.network files
│ └── wireguard/ # Generated WireGuard configs
├── daemon/ # Privileged background daemon
│ ├── server.py # aiohttp server, cache, batch routing, handler registry
│ ├── client.py # Sync HTTP client over Unix socket
── handlers/ # Privileged operation handlers (all sudo calls)
── handlers/ # Privileged operation handlers (all sudo calls)
│ │ └── network.py # networkd handler (generate + apply)
├── system/ # System file templates (all Jinja2)
│ ├── systemd/ # Service and timer unit files
│ │ ├── vacuum-wall.service # Web UI service (rendered at install)
@@ -87,8 +93,10 @@ After installation, access the management interface at `https://<hostname>.local
│ ├── common.py # Shared utilities (run, run_proc, load_json, save_json, deep_merge, ensure_dirs)
│ ├── logging.py # Logging setup
│ ├── firewall.py # firewalld bindings
│ ├── network.py # systemd-networkd rendering & parsing
│ ├── dnsmasq.py # DHCP/DNS configuration
│ ├── nginx.py # Reverse proxy configuration
│ ├── state.py # State collector (uses lib.network.parse_networkctl_status)
│ ├── acme.py # Certificate management
│ └── wireguard.py # VPN tunnel management
├── webui/ # Flask web application
@@ -100,16 +108,29 @@ After installation, access the management interface at `https://<hostname>.local
│ │ ├── proxy.py # Nginx proxy API
│ │ ├── certs.py # Certificate API
│ │ ├── wireguard.py # WireGuard API
│ │ ├── network.py # Networkd API
│ │ └── logs.py # Logs API
── templates/ # Jinja2/HTMX templates
└── static/ # CSS and client-side JS
── static/ # SPA (index.html, app.js, style.css)
├── hoover/ # Hoover SPA framework (VDOM, reactivity, router, components)
│ │ ├── index.js # Barrel export of all public APIs
│ │ ├── reactivity.js
│ │ ├── vdom.js
│ │ ├── render.js
│ │ ├── component.js
│ │ ├── router.js
│ │ ├── websocket.js
│ │ ├── api.js
│ │ ├── helpers.js
│ │ └── components/ # Layout, data display, modal, toast
│ └── pages/ # Page modules (each defines a route via definePage)
├── docs/ # Documentation
│ ├── overview.md # This file
│ ├── deployment.md
│ ├── api.md
│ ├── security.md
│ ├── architecture.md
── config.md
── config.md
│ └── hoover.md # Hoover SPA framework
└── scripts/ # Utility scripts
└── update-vendor.sh # Vendor frontend library updates
```
@@ -121,3 +142,4 @@ After installation, access the management interface at `https://<hostname>.local
- [Security Model](security.md) - Privilege model and sudo whitelist
- [Architecture](architecture.md) - Detailed subsystem design
- [Configuration](config.md) - Config file formats and locations
- [Hoover Framework](hoover.md) - Frontend SPA framework reference
+16
View File
@@ -40,6 +40,9 @@ The file `/etc/sudoers.d/vacuum-walld` grants the daemon user (`vacuum-walld`) p
| Certificates | (none) | acme.sh runs as the non-root service user directly; no sudo escalation is needed (webroot validation is used) |
| Network queries | `ip -o link show` | List network interfaces |
| Network queries | `ip -o addr show` | List IP addresses on interfaces |
| Networkd | `networkctl status *` | Query interface status from networkd |
| Networkd | `networkctl reload *` | Reload networkd for a specific interface |
| Networkd | `networkctl reload` | Reload networkd for all interfaces |
| Logs | `journalctl --unit=* -n *` | Query systemd journal for managed services |
| Logs | `cat /var/log/nginx/*` | Read nginx access and error logs |
@@ -50,6 +53,10 @@ Key safety properties:
- `NOPASSWD` is used so the application never prompts for a password. `Defaults:<user>` restricts the secure path and disables TTY requirement.
- The sudoers file is rendered from a Jinja2 template at install time, substituting the configured user name.
## Daemon Client Path Resolution
The `daemon/client.py` module resolves `<param>` placeholders in URL paths before sending requests over the Unix socket. For example, a request to `/network/interfaces/<name>` with a body containing `{"name": "eth0"}` is rewritten to `/network/interfaces/eth0` before transmission. Parameter values are URL-encoded to handle special characters safely. This eliminates the need for the API layer to construct literal paths and ensures the daemon always receives concrete paths for routing.
## Web Security
### Management Interface
@@ -132,6 +139,15 @@ The `lib/firewall` module is a generic firewalld parser with no hardcoded zone d
IP forwarding (`net.ipv4.ip_forward = 1`) is enabled system-wide to allow routing between zones (LAN to Internet, VPN to LAN). However, actual traffic flow is controlled by firewalld rules. Masquerade is enabled on the `internal` zone so that LAN clients get NAT translation when accessing the Internet through the Vacuum Wall router.
## Input Validation
Interface names provided via the API are validated at two layers before any file system access or subprocess invocation:
- **API layer** (`webui/api/network.py`): The Flask route calls `validate_interface_name()` from `lib/common.py`, rejecting any name that doesn't match `^[a-zA-Z0-9][a-zA-Z0-9._-]*$`. Names containing `/`, `..`, spaces, or other disallowed characters return HTTP `400`.
- **Daemon handler layer** (`daemon/handlers/network.py`): Each handler re-validates the name from the request body using the same function. An invalid name raises `ValueError`, which the daemon converts to an error response before any `sudo` call.
This defense-in-depth approach ensures that even if a request bypasses the API layer, the daemon will still reject malicious interface names.
## Certificate Security
### acme.sh Integration
+135 -297
View File
@@ -45,22 +45,22 @@ while [[ $# -gt 0 ]]; do
" --dev Dev mode: auto-detect repo owner, skip safety warning" \
" --mgmt-pass PASS WebUI basic auth password (required)" \
" --mgmt-user USER WebUI basic auth username (default: admin)" \
" --mgmt-domain DOMAIN Management domain (auto-detected)" \
" --acme-email EMAIL ACME registration email (required)" \
" --wan-iface IFACE WAN interface name (auto-detected)" \
" --mgmt-domain DOMAIN Management domain (auto-detected)" \
" --acme-email EMAIL ACME contact email (optional, deprecated — use WebUI)" \
" --wan-iface IFACE WAN interface name (auto-detected)" \
" --lan-ifaces IFC,... LAN interface names, comma-separated (auto-detected)" \
" -h, --help Show this help" \
"" \
"All options also have environment variable equivalents:" \
" USER_NAME, INSTALL_DIR, MGMT_PASS, MGMT_USER," \
" MGMT_DOMAIN, ACME_EMAIL, WAN_IFACE, LAN_IFACES." \
" USER_NAME, INSTALL_DIR, MGMT_PASS, MGMT_USER," \
" MGMT_DOMAIN, WAN_IFACE, LAN_IFACES." \
" CLI flags take precedence over env vars." \
"" \
"Example (dev):" \
" ./install.sh --dev --mgmt-pass pass --acme-email me@example.com" \
"Example (dev):" \
" ./install.sh --dev --mgmt-pass pass" \
"" \
"Example (prod):" \
" MGMT_PASS=pass ACME_EMAIL=me@example.com ./install.sh --user vacuum-wall"
"Example (prod):" \
" MGMT_PASS=pass ./install.sh --user vacuum-wall"
exit 0
;;
*)
@@ -74,6 +74,7 @@ REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
# Required settings (no defaults — must be provided)
MGMT_PASS="${_cli_mgmt_pass:-${MGMT_PASS:-}}"
# ACME_EMAIL is optional — will be configured from the WebUI
ACME_EMAIL="${_cli_acme_email:-${ACME_EMAIL:-}}"
# Optional settings with defaults
@@ -110,18 +111,16 @@ LAN_IFACES="${_cli_lan_ifaces:-${LAN_IFACES:-}}"
# --- Validate required settings ---
missing=()
[[ -z "$MGMT_PASS" ]] && missing+=("MGMT_PASS (--mgmt-pass)")
[[ -z "$ACME_EMAIL" ]] && missing+=("ACME_EMAIL (--acme-email)")
[[ -z "$MGMT_PASS" ]] && missing+=("MGMT_PASS (--mgmt-pass)")
if (( ${#missing[@]} )); then
echo -e "${RED}[!!]${NC} Missing required settings:"
for v in "${missing[@]}"; do
case "$v" in
"MGMT_PASS (--mgmt-pass)") echo ' export MGMT_PASS="your-password" # or --mgmt-pass';;
"ACME_EMAIL (--acme-email)") echo " export ACME_EMAIL=\"you@example.com\" # or --acme-email";;
"MGMT_PASS (--mgmt-pass)") echo ' export MGMT_PASS="your-password" # or --mgmt-pass';;
esac
done
printf '\nTo run: MGMT_PASS=pass ACME_EMAIL=you@example.com ./install.sh\n'
printf '\nTo run: MGMT_PASS=pass ./install.sh\n'
exit 1
fi
ACME_HOME="$PROJECT_DIR/data/acme"
@@ -287,171 +286,13 @@ render_template "${PROJECT_DIR}/system/systemd/vacuum-wall-acme.service" \
install -m 0644 "${PROJECT_DIR}/system/systemd/vacuum-wall-acme.timer" /etc/systemd/system/vacuum-wall-acme.timer
systemctl daemon-reload
# --- 7. Enable IP forwarding ---
# --- 7. Enable IP forwarding (persistent via sysctl.conf) ---
log "Enabling IP forwarding..."
if ! grep -q "^net.ipv4.ip_forward=1" /etc/sysctl.conf 2>/dev/null; then
echo "net.ipv4.ip_forward=1" >> /etc/sysctl.conf
fi
sysctl -w net.ipv4.ip_forward=1 2>/dev/null || warn "Could not enable IP forwarding (may need kernel access)"
# --- 8. Start and configure firewalld ---
log "Enabling firewalld..."
systemctl enable firewalld >/dev/null 2>&1 || warn "Could not enable firewalld (already running?)"
systemctl start firewalld >/dev/null 2>&1 || warn "Could not start firewalld (may need D-Bus)"
firewall-cmd --permanent --add-service=http >/dev/null 2>&1 && \
log "Added service http to public zone" || \
warn "Could not add service http to public zone (already exists?)"
firewall-cmd --permanent --add-service=https >/dev/null 2>&1 && \
log "Added service https to public zone" || \
warn "Could not add service https to public zone (already exists?)"
firewall-cmd --permanent --add-service=ssh >/dev/null 2>&1 && \
log "Added service ssh to public zone" || \
warn "Could not add service ssh to public zone (already exists?)"
firewall-cmd --reload >/dev/null 2>&1 && \
log "Firewalld rules reloaded" || \
warn "Could not reload firewalld rules"
# --- 9. Configure dnsmasq ---
log "Configuring dnsmasq..."
systemctl enable dnsmasq >/dev/null 2>&1 && log "Enabled dnsmasq" || warn "Could not enable dnsmasq"
systemctl start dnsmasq >/dev/null 2>&1 || warn "Could not start dnsmasq (no interfaces configured yet)"
log "dnsmasq configured (will fully start after DHCP ranges are set)"
# --- 10. Setup nginx management proxy ---
mkdir -p "$ACME_HOME/$DOMAIN"
if [[ -f "$ACME_HOME/$DOMAIN/$DOMAIN.key" ]]; then
log "SSL certificate already exists for $DOMAIN, skipping."
else
log "Generating self-signed certificate for management domain..."
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout "$ACME_HOME/$DOMAIN/$DOMAIN.key" \
-out "$ACME_HOME/$DOMAIN/fullchain.cer" \
-subj "/CN=$DOMAIN" \
-addext "subjectAltName=DNS:$DOMAIN"
fi
chown -R "$USER_DAEMON_NAME:$USER_GROUP" "$ACME_HOME"
# Generate/update htpasswd directly in data/nginx/
HTPASSWD_FILE="${PROJECT_DIR}/data/nginx/.htpasswd"
if [[ -f "$HTPASSWD_FILE" ]]; then
htpasswd -b "$HTPASSWD_FILE" "$MGMT_USER" "$MGMT_PASS" 2>/dev/null || \
warn "Could not update htpasswd (install apache2-utils)"
else
htpasswd -cb "$HTPASSWD_FILE" "$MGMT_USER" "$MGMT_PASS" 2>/dev/null || \
MGMT_USER="$MGMT_USER" MGMT_PASS="$MGMT_PASS" HTFILE="$HTPASSWD_FILE" python3 -c "
import os, crypt, base64
password = os.environ['MGMT_PASS']
user = os.environ['MGMT_USER']
salt = '\$6\$' + base64.b64encode(os.urandom(16)).decode().rstrip('=')[:16]
hashed = crypt.crypt(password, salt)
with open(os.environ['HTFILE'], 'w') as f:
f.write(user + ':' + hashed + '\n')
" 2>/dev/null || \
warn "Could not generate htpasswd (install apache2-utils or python3-crypt)"
fi
chown "$USER_NAME:$USER_GROUP" "${PROJECT_DIR}/data/nginx/.htpasswd"
# Remove default nginx site so vacuum-wall management config takes precedence
rm -f /etc/nginx/sites-enabled/default
# Write initial management proxy config directly to /etc/nginx/conf.d/.
# This bootstrap config is needed before the WebUI is running. Once the
# WebUI is up, it manages proxy configs from config/nginx/config.json
# and renders them to data/nginx/sites-enabled/.
# Write WebSocket upgrade map (nginx conf.d/ is already inside http {} context)
cat > /etc/nginx/conf.d/vacuum-wall-map.conf <<'MAPEOF'
# Vacuum Wall - WebSocket upgrade map
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
MAPEOF
# Write the management site block (conf.d/ is inside http {}, no extra http {} needed)
cat > /etc/nginx/conf.d/vacuum-wall-mgmt.conf <<MGMTSITEEOF
# Vacuum Wall - Management Proxy
# Auto-generated by install.sh
server {
listen 80;
server_name $DOMAIN;
return 301 https://\$host\$request_uri;
}
server {
listen 443 ssl;
server_name $DOMAIN;
ssl_certificate $ACME_HOME/$DOMAIN/fullchain.cer;
ssl_certificate_key $ACME_HOME/$DOMAIN/$DOMAIN.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
auth_basic "Vacuum Wall";
auth_basic_user_file ${PROJECT_DIR}/data/nginx/.htpasswd;
location / {
proxy_pass http://127.0.0.1:9090;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection \$connection_upgrade;
}
}
MGMTSITEEOF
# --- 11. Write initial nginx config.json (skip if user has customized it) ---
NGINX_CFG="${PROJECT_DIR}/config/nginx/config.json"
if [[ -f "$NGINX_CFG" ]]; then
log "Nginx config already exists, skipping initial write."
else
log "Writing initial nginx configuration..."
MGMT_DOMAIN="$DOMAIN" \
MGMT_USER="$MGMT_USER" \
INSTALL_DIR="$PROJECT_DIR" \
"${PROJECT_DIR}/.venv/bin/python3" -c "
import json, os
d = os.environ['MGMT_DOMAIN']
u = os.environ['MGMT_USER']
p = os.environ['INSTALL_DIR']
cfg = {
'domains': {},
'management': {
'domain': d,
'backend': {
'host': '127.0.0.1',
'port': 9090,
'proto': 'http'
},
'auth': {
'user': u,
'htpasswd': p + '/data/nginx/.htpasswd'
}
},
'ssl': {
'protocols': 'TLSv1.2 TLSv1.3',
'ciphers': 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305',
'prefer_server_ciphers': False
}
}
with open(os.path.join(p, 'config/nginx/config.json'), 'w') as f:
json.dump(cfg, f, indent=4)
f.write('\n')
"
fi
# --- 12. Auto-detect interfaces and setup initial firewalld zones ---
# --- 8. Detect network interfaces ---
log "Detecting network interfaces..."
# Auto-detect WAN (interface with default gateway)
@@ -480,134 +321,146 @@ if [[ -z "$LAN_IFACES" ]]; then
fi
fi
# Generate config/firewall/config.json (skip if user has customized it)
FIREWALL_CFG="${PROJECT_DIR}/config/firewall/config.json"
if [[ -f "$FIREWALL_CFG" ]]; then
log "Firewall config already exists, skipping initial write."
# --- 9. Enable and start core services ---
log "Enabling services..."
systemctl enable firewalld >/dev/null 2>&1 && log "Enabled firewalld" || warn "Could not enable firewalld"
systemctl enable nginx >/dev/null 2>&1 && log "Enabled nginx" || warn "Could not enable nginx"
systemctl enable dnsmasq >/dev/null 2>&1 && log "Enabled dnsmasq" || warn "Could not enable dnsmasq"
systemctl enable vacuum-walld >/dev/null 2>&1 && log "Enabled vacuum-walld" || warn "Could not enable vacuum-walld"
systemctl enable vacuum-wall >/dev/null 2>&1 && log "Enabled vacuum-wall" || warn "Could not enable vacuum-wall"
systemctl enable vacuum-wall-acme.timer >/dev/null 2>&1 && log "Enabled vacuum-wall-acme.timer" || warn "Could not enable vacuum-wall-acme.timer"
systemctl enable avahi-daemon >/dev/null 2>&1 && log "Enabled avahi-daemon" || warn "Could not enable avahi-daemon"
# Clean up old nginx bootstrap configs (replaced by daemon-generated config)
rm -f /etc/nginx/conf.d/vacuum-wall-map.conf /etc/nginx/conf.d/vacuum-wall-mgmt.conf
# Stop all services to ensure clean start order
systemctl stop vacuum-wall >/dev/null 2>&1 || true
systemctl stop vacuum-walld >/dev/null 2>&1 || true
# Start services in dependency order
systemctl start firewalld >/dev/null 2>&1 && log "Started firewalld" || warn "Could not start firewalld"
systemctl start avahi-daemon >/dev/null 2>&1 && log "Started avahi-daemon" || warn "Could not start avahi-daemon"
systemctl start dnsmasq >/dev/null 2>&1 || warn "Could not start dnsmasq (no interfaces configured yet)"
# Start daemon and wait for socket
systemctl start vacuum-walld >/dev/null 2>&1 && log "Started vacuum-walld daemon" || warn "Could not start vacuum-walld daemon"
_SOCKET="$PROJECT_DIR/data/daemon.sock"
for _i in $(seq 1 30); do
[[ -S "$_SOCKET" ]] && break
sleep 0.5
done
if [[ ! -S "$_SOCKET" ]]; then
warn "Daemon socket not found at $_SOCKET — skipping API configuration"
else
log "Writing initial firewall configuration..."
chown "$USER_DAEMON_NAME:$USER_GROUP" "$_SOCKET" 2>/dev/null || true
chmod 0660 "$_SOCKET" 2>/dev/null || true
# --- 10. Configure subsystems via daemon API ---
log "Configuring subsystems via daemon API..."
WAN_IFACE="$WAN_IFACE" \
LAN_IFACES="$LAN_IFACES" \
INSTALL_DIR="$PROJECT_DIR" \
MGMT_DOMAIN="$DOMAIN" \
MGMT_USER="$MGMT_USER" \
MGMT_PASS="$MGMT_PASS" \
ACME_EMAIL="$ACME_EMAIL" \
"${PROJECT_DIR}/.venv/bin/python3" -c "
import json, os
import daemon.client as c
from daemon.iface import (
POST_ACME_SELF_SIGNED, POST_NGINX_MANAGEMENT, POST_NGINX_APPLY,
POST_FIREWALL_CONFIG, POST_FIREWALL_CONFIG_APPLY, POST_NETWORK_SYSCTL_SET,
POST_ACME_EMAIL, GET_NETWORK_INFER_DHCP_RANGES,
)
import sys
wan = os.environ.get('WAN_IFACE', '').strip() or None
lan = os.environ.get('LAN_IFACES', '').strip() or None
p = os.environ['INSTALL_DIR']
domain = '${DOMAIN}'
mgmt_user = '${MGMT_USER}'
mgmt_pass = '${MGMT_PASS}'
acme_email = '${ACME_EMAIL}'
wan_iface = '${WAN_IFACE}'
lan_ifaces = '${LAN_IFACES}'
cfg = {'zones': {}}
# Self-signed cert for management domain
try:
res = c.post(POST_ACME_SELF_SIGNED, {'domain': domain, 'days': 365})
print(f' [cert] Self-signed: {\"generated\" if res.get(\"generated\") else \"exists\"}')
except Exception as e:
print(f' [cert] Warning: {e}', file=sys.stderr)
if wan:
cfg['zones']['public'] = {
# Management proxy + htpasswd
try:
c.post(POST_NGINX_MANAGEMENT, {
'domain': domain,
'flask_host': '127.0.0.1',
'flask_port': 9090,
'auth_user': mgmt_user,
'auth_pass': mgmt_pass,
})
c.post(POST_NGINX_APPLY)
print(f' [proxy] Management proxy configured for {domain}')
except Exception as e:
print(f' [proxy] Warning: {e}', file=sys.stderr)
# Firewall config (interface detection done in bash above)
import json as _json
zones = {}
if wan_iface:
zones['public'] = {
'target': 'DEFAULT',
'interfaces': [i for i in wan.split(',') if i],
'interfaces': [i for i in wan_iface.split(',') if i],
'services': ['http', 'https', 'ssh'],
'masquerade': True,
}
if lan:
cfg['zones']['internal'] = {
if lan_ifaces:
zones['internal'] = {
'target': 'ACCEPT',
'interfaces': [i for i in lan.split(',') if i],
'interfaces': [i for i in lan_ifaces.split(',') if i],
'services': ['dhcp', 'dns', 'ntp'],
'masquerade': False,
}
# Always create vpn zone skeleton for later WireGuard setup
cfg['zones']['vpn'] = {
zones['vpn'] = {
'target': 'ACCEPT',
'interfaces': [],
'services': [],
'masquerade': False,
}
with open(os.path.join(p, 'config/firewall/config.json'), 'w') as f:
json.dump(cfg, f, indent=2)
f.write('\n')
try:
c.post(POST_FIREWALL_CONFIG, {'zones': zones})
c.post(POST_FIREWALL_CONFIG_APPLY)
print(' [firewall] Zones configured and applied')
except Exception as e:
print(f' [firewall] Warning: {e}', file=sys.stderr)
# IP forwarding
try:
c.post(POST_NETWORK_SYSCTL_SET, {'name': 'net.ipv4.ip_forward', 'value': '1'})
print(' [network] IP forwarding enabled')
except Exception as e:
print(f' [network] Warning: {e}', file=sys.stderr)
# ACME email (optional)
if acme_email:
try:
c.post(POST_ACME_EMAIL, {'email': acme_email})
print(f' [acme] Email set to {acme_email}')
except Exception as e:
print(f' [acme] Warning: {e}', file=sys.stderr)
# Infer DHCP ranges (logged for user reference)
try:
ranges = c.get(GET_NETWORK_INFER_DHCP_RANGES)
for iface, rng in ranges.get('ranges', {}).items():
print(f' [suggestion] DHCP range for {iface}: {rng.get(\"start\")}-{rng.get(\"end\")}')
except Exception:
pass
"
fi
# Apply zones via firewall-cmd (Python venv not yet fully available for apply_config)
firewall-cmd --permanent --new-zone=internal >/dev/null 2>&1 && \
log "Created firewalld zone: internal" || \
warn "firewalld zone 'internal' may already exist"
firewall-cmd --permanent --zone=internal --set-target=ACCEPT >/dev/null 2>&1 || \
warn "Could not set target ACCEPT on internal zone"
firewall-cmd --permanent --zone=internal --add-service=dhcp >/dev/null 2>&1 && \
log "Added service dhcp to internal zone" || \
warn "Could not add service dhcp to internal zone"
firewall-cmd --permanent --zone=internal --add-service=dns >/dev/null 2>&1 && \
log "Added service dns to internal zone" || \
warn "Could not add service dns to internal zone"
firewall-cmd --permanent --zone=internal --add-service=ntp >/dev/null 2>&1 && \
log "Added service ntp to internal zone" || \
warn "Could not add service ntp to internal zone"
firewall-cmd --permanent --new-zone=vpn >/dev/null 2>&1 && \
log "Created firewalld zone: vpn" || \
warn "firewalld zone 'vpn' may already exist"
firewall-cmd --permanent --zone=vpn --set-target=ACCEPT >/dev/null 2>&1 || \
warn "Could not set target ACCEPT on vpn zone"
# Apply masquerade on public/WAN
if [[ -n "$WAN_IFACE" ]]; then
firewall-cmd --permanent --zone=public --add-masquerade >/dev/null 2>&1 && \
log "Enabled masquerade on public zone ($WAN_IFACE)" || \
warn "Could not enable masquerade on public zone"
firewall-cmd --permanent --zone=public --add-interface="$WAN_IFACE" >/dev/null 2>&1 && \
log "Assigned $WAN_IFACE to public zone" || \
warn "Could not assign $WAN_IFACE to public zone"
fi
# Assign LAN interfaces to internal zone
if [[ -n "$LAN_IFACES" ]]; then
IFS=',' read -ra LAN_ARRAY <<< "$LAN_IFACES"
for iface in "${LAN_ARRAY[@]}"; do
iface=$(echo "$iface" | xargs)
[[ -z "$iface" ]] && continue
firewall-cmd --permanent --zone=internal --add-interface="$iface" >/dev/null 2>&1 && \
log "Assigned $iface to internal zone" || \
warn "Could not assign $iface to internal zone"
done
fi
firewall-cmd --reload >/dev/null 2>&1 && \
log "Firewalld rules reloaded" || \
warn "Could not reload firewalld rules"
# --- 13. Enable and start services ---
log "Enabling services..."
systemctl enable nginx >/dev/null 2>&1 && log "Enabled nginx" || warn "Could not enable nginx"
systemctl enable vacuum-walld >/dev/null 2>&1 && log "Enabled vacuum-walld" || warn "Could not enable vacuum-walld"
systemctl enable vacuum-wall >/dev/null 2>&1 && log "Enabled vacuum-wall" || warn "Could not enable vacuum-wall"
systemctl enable vacuum-wall-acme.timer >/dev/null 2>&1 && log "Enabled vacuum-wall-acme.timer" || warn "Could not enable vacuum-wall-acme.timer"
systemctl enable avahi-daemon >/dev/null 2>&1 && log "Enabled avahi-daemon" || warn "Could not enable avahi-daemon"
systemctl start avahi-daemon >/dev/null 2>&1 && log "Started avahi-daemon" || warn "Could not start avahi-daemon"
# Start daemon first, then web UI
# Stop vacuum-wall first. vacuum-wall.service Requires=vacuum-walld.service,
# so stopping vacuum-wall triggers a cascade stop of vacuum-walld. The explicit
# stop of vacuum-walld below is redundant but ensures clean teardown.
systemctl stop vacuum-wall >/dev/null 2>&1 || true
systemctl stop vacuum-walld >/dev/null 2>&1 || true
systemctl start vacuum-walld >/dev/null 2>&1 && log "Started vacuum-walld daemon" || warn "Could not start vacuum-walld daemon"
# Wait for daemon socket
_SOCKET="$PROJECT_DIR/data/daemon.sock"
for _i in $(seq 1 10); do
[[ -S "$_SOCKET" ]] && break
sleep 0.5
done
if [[ ! -S "$_SOCKET" ]]; then
warn "Daemon socket not found at $_SOCKET"
fi
# Set socket ownership so web UI user can connect
if [[ -S "$_SOCKET" ]]; then
chown "$USER_DAEMON_NAME:$USER_GROUP" "$_SOCKET" 2>/dev/null || true
chmod 0660 "$_SOCKET" 2>/dev/null || true
log "Subsystem configuration complete"
fi
systemctl start vacuum-wall >/dev/null 2>&1 && log "Started vacuum-wall WebUI" || warn "Could not start vacuum-wall WebUI"
@@ -616,22 +469,6 @@ nginx -t 2>/dev/null && nginx -s reload 2>/dev/null && log "Reloaded nginx" || \
systemctl restart nginx >/dev/null 2>&1 && log "Restarted nginx" || \
warn "Could not restart nginx (check config)"
# --- 14. Configure acme.sh default email ---
if [[ -f "$ACME_HOME/account.conf" ]] && grep -q '^ACME_LEEMAIL=' "$ACME_HOME/account.conf" 2>/dev/null; then
log "acme.sh account already registered, skipping."
else
# acme.sh must never run as root — always as the service user via sudo -u.
# This prevents acme.sh from running any command as root and limits its
# ability to modify system files.
log "Registering acme.sh account with email $ACME_EMAIL..."
mkdir -p "$ACME_HOME/www"
chown "$USER_DAEMON_NAME:$USER_GROUP" "$ACME_HOME/www"
sudo -u "$USER_DAEMON_NAME" env ACME_HOME="$ACME_HOME" HOME="$PROJECT_DIR" \
"$ACME_HOME/acme.sh" --home "$ACME_HOME" --config-home "$ACME_HOME" \
--register-account -m "$ACME_EMAIL" 2>/dev/null || \
warn "Could not register acme.sh account (will be done from WebUI)"
fi
# --- Done ---
echo ""
echo "============================================"
@@ -657,10 +494,11 @@ else
fi
echo ""
echo " Next steps:"
echo " 1. Verify zone assignments at https://$DOMAIN/interfaces"
echo " 2. Configure DHCP ranges for your LAN"
echo " 3. Add proxy domains with ACME certificates"
echo " 4. Set up WireGuard tunnel (optional)"
echo " 1. Set ACME contact email at https://$DOMAIN/certs/settings"
echo " 2. Verify zone assignments at https://$DOMAIN/interfaces"
echo " 3. Configure DHCP ranges for your LAN"
echo " 4. Add proxy domains with ACME certificates"
echo " 5. Set up WireGuard tunnel (optional)"
echo ""
echo " NOTE: A self-signed certificate was generated."
echo " From the WebUI, issue a real certificate for $DOMAIN"
+20 -1
View File
@@ -135,7 +135,16 @@ def set_email(email: str) -> None:
def get_email() -> str:
"""Return the ACME contact email, or '' if none is configured."""
"""Return the ACME contact email, or '' if none is configured.
Checks account.conf first (acme.sh registered account), then falls
back to the declarative acme config.
"""
return _read_acme_email()
def _read_acme_email() -> str:
"""Read ACME email from account.conf, falling back to declarative config."""
try:
acme_home = Path(os.environ.get("ACME_HOME", str(_ACME_HOME)))
account_conf = acme_home / "account.conf"
@@ -146,6 +155,16 @@ def get_email() -> str:
return match.group(1).strip().strip("'\"")
except OSError as exc:
logger.warning("Could not read account.conf: %s", exc)
# Fallback: read from declarative ACME config
try:
from lib.common import load_json
acme_cfg = PROJECT_DIR / "config" / "acme" / "config.json"
conf = load_json(acme_cfg)
if conf and "email" in conf:
return conf["email"]
except (OSError, ValueError, KeyError):
pass
return ""
+27
View File
@@ -6,12 +6,38 @@ deep merging, and directory creation used across all subsystem modules.
import json
import os
import re
import subprocess
from copy import deepcopy
from pathlib import Path
from typing import Any
def validate_interface_name(name: str) -> str:
"""Validate a Linux network interface name.
Args:
name: Interface name to validate.
Returns:
The validated (stripped) name.
Raises:
ValueError: When the name is empty, contains path components,
or does not match Linux interface naming rules.
"""
if not name or not isinstance(name, str):
raise ValueError("Interface name must be a non-empty string")
name = name.strip()
if not name:
raise ValueError("Interface name must not be blank")
if "/" in name or ".." in name or " " in name:
raise ValueError(f"Invalid interface name: {name!r}")
if not re.match(r"^[a-zA-Z0-9][a-zA-Z0-9._-]*$", name):
raise ValueError(f"Invalid interface name: {name!r}")
return name
def run(
cmd: list[str],
check: bool = True,
@@ -134,4 +160,5 @@ __all__ = [
"run",
"run_proc",
"save_json",
"validate_interface_name",
]
+20 -5
View File
@@ -101,10 +101,25 @@ def generate_conf(cfg: dict[str, Any]) -> str:
r["interface"] for r in dhcp_cfg.get("ranges", []) if "interface" in r
]
# Fallback: use network-managed interface addresses for listen-address
listen_addresses = []
try:
from lib.network import get_config as _get_net_config
net_cfg = _get_net_config()
for _iface, info in net_cfg.get("interfaces", {}).items():
for addr_str in info.get("addresses", []):
if "/" in addr_str:
addr_str = addr_str.split("/")[0]
listen_addresses.append(addr_str)
except Exception:
pass
tmpl = ENV.get_template("dnsmasq.conf")
return tmpl.render(
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
interfaces=interfaces,
interfaces=interfaces or None,
listen_addresses=listen_addresses if listen_addresses else None,
dhcp=dhcp_cfg,
dns=dns_cfg,
fragments_dir=str(FRAGMENTS_DIR) if FRAGMENTS_DIR.exists() else None,
@@ -182,8 +197,8 @@ def add_static_lease(mac: str, ip: str, hostname: str | None = None) -> None:
for i, lease in enumerate(leases):
if lease["mac"].lower() == mac.lower():
leases[i] = {"mac": mac, "ip": ip}
if hostname:
leases[i].update({"mac": mac, "ip": ip})
if hostname is not None:
leases[i]["hostname"] = hostname
save_config(cfg)
logger.info("Static DHCP lease updated: %s -> %s", mac, ip)
@@ -219,8 +234,8 @@ def add_dns_record(name: str, address: str, hostname: str | None = None) -> None
for i, r in enumerate(records):
if r["name"] == name:
records[i] = {"name": name, "address": address}
if hostname:
records[i].update({"name": name, "address": address})
if hostname is not None:
records[i]["hostname"] = hostname
save_config(cfg)
logger.info("DNS record updated: %s -> %s", name, address)
+53 -8
View File
@@ -10,6 +10,7 @@ Output:
viewing via the WebUI ``/logs`` page.
"""
import contextlib
import logging
import os
import sys
@@ -66,15 +67,59 @@ def setup_logging(level: str | None = None) -> None:
sh.setFormatter(fmt)
root.addHandler(sh)
# rotating file handler
class GroupWriteHandler(RotatingFileHandler):
"""RotatingFileHandler that always opens files with group-write mode.
Ensures the log file is group-writable so both the WebUI process
(vacuum-wall user) and daemon process (vacuum-walld user) can write
to it when they share a group.
"""
def _open(self):
# Ensure group-write on an existing stale file (e.g. left by the
# other process with a stricter umask at creation time).
with contextlib.suppress(OSError):
os.chmod(self.baseFilename, 0o664)
# Temporarily clear group-write umask bits so os.open's mode is
# not masked away.
old = os.umask(0o002)
try:
fd = os.open(
self.baseFilename, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o664
)
finally:
os.umask(old)
return os.fdopen(fd, "a", errors="backslashreplace")
def doRollover(self):
"""Override to enforce group-write on rotated files."""
super().doRollover()
# Set group-write on all log files (current + backups)
base = Path(self.baseFilename)
for suffix in ("", ".1", ".2", ".3"):
fp = str(base.parent / base.name + suffix)
with contextlib.suppress(OSError):
os.chmod(fp, 0o664)
# rotating file handler with group-write permissions
_LOG_DIR.mkdir(parents=True, exist_ok=True)
fh = RotatingFileHandler(
str(_LOG_FILE),
maxBytes=_MAX_BYTES,
backupCount=_BACKUP_COUNT,
)
fh.setFormatter(fmt)
root.addHandler(fh)
try:
fh = GroupWriteHandler(
str(_LOG_FILE),
maxBytes=_MAX_BYTES,
backupCount=_BACKUP_COUNT,
)
fh.setFormatter(fmt)
root.addHandler(fh)
except PermissionError:
# Log file exists but is not writable (e.g. stale file from the other
# process created with a stricter umask). Fall back to stderr-only.
print(
f"WARNING: cannot open log file {_LOG_FILE}, "
"logging to stderr only. Fix with: chmod g+w "
f"{_LOG_FILE}",
file=sys.stderr,
)
# Silence noisy third-party loggers in production
for name in ("werkzeug", "urllib3"):
+720
View File
@@ -0,0 +1,720 @@
"""Networkd/IP configuration module.
Reads/writes config/network/config.json, renders .network INI files,
and parses networkctl JSON output for runtime state.
"""
import contextlib
import ipaddress
import json
import logging
from pathlib import Path
from typing import Any
from lib.common import load_json, save_json
logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent
CONFIG_DIR = PROJECT_DIR / "config" / "network"
CONFIG_FILE = CONFIG_DIR / "config.json"
DATA_DIR = PROJECT_DIR / "data" / "networkd"
DEFAULT_CONFIG: dict[str, Any] = {"interfaces": {}}
KNOWN_INTERFACE_KEYS: set[str] = {
"addresses",
"ipv6_addresses",
"gateway",
"ipv6_gateway",
"dns",
"ipv6_dns",
"domains",
"ipv6_domains",
"dns_default_route",
"dhcp",
"routes",
"bind_carrier",
"ignore_carrier_loss",
"keep_configuration",
"configure_without_carrier",
"link_local_addressing",
"ipv6_link_local_address_generation_mode",
"ipv6_stable_secret_address",
"ipv4_ll_start_address",
"ipv4_ll_route",
"default_route_on_device",
"ipv6_hop_limit",
"ipv6_retransmission_time_sec",
"ipv4_duplicate_address_detection_timeout_sec",
"ipv4_reverse_path_filter",
"ipv4_accept_local",
"ipv4_route_localnet",
"ipv4_proxy_arp",
"ipv4_proxy_arp_private_vlan",
"ipv6_proxy_ndp",
"ipv6_proxy_ndp_address",
"ipv6_send_ra",
"m_pls_routing",
"keep_master",
"ip_family",
"link",
"dhcp_client",
}
__all__ = [
"KNOWN_INTERFACE_KEYS",
"collect_upstream_dns",
"generate_network_files",
"get_config",
"infer_dhcp_ranges",
"infer_zones",
"parse_networkctl_status",
"render_network_file",
"save_config",
]
def get_config() -> dict[str, Any]:
"""Read network config from config/network/config.json.
Returns:
Dict with ``interfaces`` mapping interface names to config entries.
"""
if not CONFIG_FILE.exists():
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
save_json(CONFIG_FILE, DEFAULT_CONFIG, indent=2)
return load_json(CONFIG_FILE)
def save_config(cfg: dict[str, Any]) -> None:
"""Persist network config to disk.
Args:
cfg: Full config dict to write.
"""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
save_json(CONFIG_FILE, cfg, indent=2)
# -----------------------------------------------------------------------
# Inline key-value emitters — append to a lines list
# -----------------------------------------------------------------------
def _emit_str(lines: list[str], key: str, py_key: str, d: dict[str, Any]) -> None:
v = d.get(py_key)
if v is not None:
lines.append(f"{key}={v}")
def _emit_int(lines: list[str], key: str, py_key: str, d: dict[str, Any]) -> None:
v = d.get(py_key)
if v is not None:
lines.append(f"{key}={v}")
def _emit_bool(lines: list[str], key: str, py_key: str, d: dict[str, Any]) -> None:
v = d.get(py_key)
if v is not None:
lines.append(f"{key}={'yes' if v else 'no'}")
def _emit_bool_opt(lines: list[str], key: str, py_key: str, d: dict[str, Any]) -> None:
v = d.get(py_key)
if v is not None:
lines.append(f"{key}={'yes' if v else 'no'}")
def _emit_any(lines: list[str], key: str, py_key: str, d: dict[str, Any]) -> None:
v = d.get(py_key)
if v is not None:
if isinstance(v, bool):
lines.append(f"{key}={'yes' if v else 'no'}")
else:
lines.append(f"{key}={v}")
def render_network_file(iface_name: str, cfg_entry: dict[str, Any]) -> str:
"""Render a .network INI file for an interface.
Args:
iface_name: Interface name (e.g. "eth0").
cfg_entry: Dict with networkd config keys per the schema in
todo.md (addresses, gateway, dns, routes, link, dhcp_client, etc.).
Returns:
INI content string ready to write as 50-<name>.network file.
"""
lines: list[str] = []
d = cfg_entry
# ------------------------------------------------------------------
# [Match]
# ------------------------------------------------------------------
lines.append("[Match]")
lines.append(f"Name={iface_name}")
lines.append("")
# ------------------------------------------------------------------
# [Link]
# ------------------------------------------------------------------
link = d.get("link", {})
if link:
lines.append("[Link]")
_emit_int(lines, "MTUBytes", "mtu_bytes", link)
_emit_str(lines, "MACAddress", "mac_address", link)
_emit_bool_opt(lines, "ARP", "arp", link)
_emit_bool_opt(lines, "Multicast", "multicast", link)
_emit_bool_opt(lines, "AllMulticast", "all_multicast", link)
_emit_bool_opt(lines, "Promiscuous", "promiscuous", link)
_emit_bool(lines, "Unmanaged", "unmanaged", link)
_emit_str(lines, "ActivationPolicy", "activation_policy", link)
_emit_any(lines, "RequiredForOnline", "required_for_online", link)
lines.append("")
# ------------------------------------------------------------------
# [Network]
# ------------------------------------------------------------------
lines.append("[Network]")
_emit_str(lines, "DHCP", "dhcp", d)
_emit_str(lines, "Gateway", "gateway", d)
_emit_str(lines, "IPv6Gateway", "ipv6_gateway", d)
for dns in d.get("dns", []):
lines.append(f"DNS={dns}")
for dns in d.get("ipv6_dns", []):
lines.append(f"IPv6DNS={dns}")
for dm in d.get("domains", []):
lines.append(f"Domains={dm}")
for dm in d.get("ipv6_domains", []):
lines.append(f"IPv6Domains={dm}")
_emit_bool(lines, "DNSDefaultRoute", "dns_default_route", d)
for bc in d.get("bind_carrier", []):
lines.append(f"BindCarrier={bc}")
_emit_any(lines, "IgnoreCarrierLoss", "ignore_carrier_loss", d)
_emit_any(lines, "KeepConfiguration", "keep_configuration", d)
_emit_bool(lines, "ConfigureWithoutCarrier", "configure_without_carrier", d)
_emit_str(lines, "LinkLocalAddressing", "link_local_addressing", d)
_emit_str(
lines,
"IPv6LinkLocalAddressGenerationMode",
"ipv6_link_local_address_generation_mode",
d,
)
_emit_str(lines, "IPv6StableSecretAddress", "ipv6_stable_secret_address", d)
_emit_str(lines, "IPv4LLStartAddress", "ipv4_ll_start_address", d)
_emit_bool(lines, "IPv4LLRoute", "ipv4_ll_route", d)
_emit_bool(lines, "DefaultRouteOnDevice", "default_route_on_device", d)
_emit_int(lines, "IPv6HopLimit", "ipv6_hop_limit", d)
_emit_str(lines, "IPv6RetransmissionTimeSec", "ipv6_retransmission_time_sec", d)
_emit_str(
lines,
"IPv4DuplicateAddressDetectionTimeoutSec",
"ipv4_duplicate_address_detection_timeout_sec",
d,
)
_emit_str(lines, "IPv4ReversePathFilter", "ipv4_reverse_path_filter", d)
_emit_bool(lines, "IPv4AcceptLocal", "ipv4_accept_local", d)
_emit_bool(lines, "IPv4RouteLocalnet", "ipv4_route_localnet", d)
_emit_bool(lines, "IPv4ProxyARP", "ipv4_proxy_arp", d)
_emit_bool(lines, "IPv4ProxyARPPrivateVLAN", "ipv4_proxy_arp_private_vlan", d)
_emit_bool(lines, "IPv6ProxyNDP", "ipv6_proxy_ndp", d)
_emit_str(lines, "IPv6ProxyNDPAddress", "ipv6_proxy_ndp_address", d)
_emit_bool(lines, "IPv6SendRA", "ipv6_send_ra", d)
_emit_bool(lines, "MPLSRouting", "m_pls_routing", d)
_emit_bool(lines, "KeepMaster", "keep_master", d)
_emit_str(lines, "IPFamily", "ip_family", d)
lines.append("")
# ------------------------------------------------------------------
# [Address] sections — one per entry
# ------------------------------------------------------------------
addresses = d.get("addresses", [])
for i, addr in enumerate(addresses):
if not isinstance(addr, dict):
lines.append("[Address]" if i == 0 else f"[Address#{i}]")
lines.append(f"Address={addr}")
lines.append("")
continue
lines.append("[Address]" if i == 0 else f"[Address#{i}]")
_emit_str(lines, "Address", "address", addr)
_emit_str(lines, "Label", "label", addr)
_emit_str(lines, "Scope", "scope", addr)
_emit_int(lines, "RouteMetric", "route_metric", addr)
_emit_str(
lines, "DuplicateAddressDetection", "duplicate_address_detection", addr
)
_emit_bool(lines, "ManageTemporaryAddress", "manage_temporary_address", addr)
_emit_bool(lines, "AddPrefixRoute", "add_prefix_route", addr)
lines.append("")
# ------------------------------------------------------------------
# [Address] sections — IPv6
# ------------------------------------------------------------------
ipv6_addrs = d.get("ipv6_addresses", [])
offset = len(addresses)
for i, addr in enumerate(ipv6_addrs):
if not isinstance(addr, dict):
lines.append(f"[Address#{offset + i}]")
lines.append(f"Address={addr}")
lines.append("")
continue
lines.append(f"[Address#{offset + i}]")
_emit_str(lines, "Address", "address", addr)
_emit_str(lines, "Label", "label", addr)
_emit_str(lines, "Scope", "scope", addr)
_emit_int(lines, "RouteMetric", "route_metric", addr)
_emit_str(
lines, "DuplicateAddressDetection", "duplicate_address_detection", addr
)
_emit_bool(lines, "ManageTemporaryAddress", "manage_temporary_address", addr)
_emit_bool(lines, "AddPrefixRoute", "add_prefix_route", addr)
lines.append("")
# ------------------------------------------------------------------
# [Route#N] sections
# ------------------------------------------------------------------
routes = d.get("routes", [])
for i, route in enumerate(routes):
if not isinstance(route, dict):
continue
lines.append("[Route]" if i == 0 else f"[Route#{i}]")
_emit_str(lines, "Destination", "destination", route)
_emit_str(lines, "Gateway", "gateway", route)
_emit_int(lines, "Metric", "metric", route)
_emit_any(lines, "Table", "table", route)
_emit_str(lines, "Type", "type", route)
_emit_str(lines, "Scope", "scope", route)
_emit_bool(lines, "GatewayOnLink", "gateway_on_link", route)
_emit_str(lines, "IPv6Preference", "ipv6_preference", route)
_emit_int(lines, "InitialCongestionWindow", "initial_congestion_window", route)
_emit_int(
lines,
"InitialAdvertisedReceiveWindow",
"initial_advertised_receive_window",
route,
)
_emit_bool(lines, "QuickAck", "quick_ack", route)
_emit_bool(lines, "FastOpenNoCookie", "fast_open_no_cookie", route)
_emit_int(lines, "MTUBytes", "mtu_bytes", route)
_emit_any(lines, "Protocol", "protocol", route)
_emit_int(lines, "NextHop", "next_hop", route)
for mpr in route.get("multi_path_route", []):
lines.append(f"MultiPathRoute={mpr}")
lines.append("")
# ------------------------------------------------------------------
# [DHCPv4] / [DHCPv6]
# ------------------------------------------------------------------
dhcp_client = d.get("dhcp_client", {})
if dhcp_client:
dhcp_mode = d.get("dhcp", "no")
render_v4 = dhcp_mode in ("yes", "ipv4")
render_v6 = dhcp_mode in ("yes", "ipv6")
if not render_v4 and not render_v6:
render_v4 = True
render_v6 = True
if render_v4:
lines.append("[DHCPv4]")
_emit_str(lines, "Hostname", "hostname", dhcp_client)
_emit_any(lines, "DUID", "duid", dhcp_client)
_emit_str(lines, "DUIDType", "duid_type", dhcp_client)
_emit_any(lines, "DUIDRawData", "duid_raw_data", dhcp_client)
_emit_str(lines, "IAID", "iaid", dhcp_client)
_emit_any(lines, "ClientIdentifier", "client_identifier", dhcp_client)
_emit_bool(lines, "RapidCommit", "rapid_commit", dhcp_client)
_emit_bool(lines, "Anonymize", "anonymize", dhcp_client)
_emit_bool(lines, "UseDNS", "use_dns", dhcp_client)
_emit_bool(lines, "UseNTP", "use_ntp", dhcp_client)
_emit_bool(lines, "UseSIP", "use_sip", dhcp_client)
_emit_bool(lines, "UseCaptivePortal", "use_captive_portal", dhcp_client)
_emit_bool(lines, "UseMTU", "use_mtu", dhcp_client)
_emit_bool(lines, "UseHostname", "use_hostname", dhcp_client)
_emit_any(lines, "UseDomains", "use_domains", dhcp_client)
_emit_bool(lines, "UseRoutes", "use_routes", dhcp_client)
_emit_int(lines, "RouteMetric", "route_metric", dhcp_client)
_emit_bool(lines, "SendDecline", "send_decline", dhcp_client)
_emit_str(lines, "NetLabel", "net_label", dhcp_client)
_emit_str(lines, "NFTSet", "nft_set", dhcp_client)
_emit_str(lines, "IPServiceType", "ip_service_type", dhcp_client)
_emit_int(lines, "SocketPriority", "socket_priority", dhcp_client)
_emit_bool(lines, "BOOTP", "bootp", dhcp_client)
_emit_str(lines, "Label", "label", dhcp_client)
_emit_int(lines, "MaxAttempts", "max_attempts", dhcp_client)
_emit_int(lines, "ListenPort", "listen_port", dhcp_client)
_emit_int(lines, "ServerPort", "server_port", dhcp_client)
_emit_str(lines, "MUDURL", "mud_url", dhcp_client)
_emit_str(lines, "BootFilename", "boot_filename", dhcp_client)
for opt in dhcp_client.get("send_option", []):
if isinstance(opt, dict):
lines.append(
f"SendOption={opt.get('code', '-')} {opt.get('value', '')}"
)
else:
lines.append(f"SendOption={opt}")
for opt in dhcp_client.get("send_vendor_option", []):
if isinstance(opt, dict):
lines.append(
f"SendVendorOption={opt.get('code', '-')}"
f" {opt.get('vendor_code', '')}"
f" {opt.get('value', '')}"
)
else:
lines.append(f"SendVendorOption={opt}")
for uc in dhcp_client.get("user_class", []):
lines.append(f"UserClass={uc}")
_emit_str(
lines, "VendorClassIdentifier", "vendor_class_identifier", dhcp_client
)
_emit_str(lines, "RequestOptions", "request_options", dhcp_client)
lines.append("")
if render_v6:
lines.append("[DHCPv6]")
_emit_bool(lines, "SendHostname", "send_hostname", dhcp_client)
_emit_str(lines, "Hostname", "hostname", dhcp_client)
_emit_any(lines, "DUID", "duid", dhcp_client)
_emit_str(lines, "DUIDType", "duid_type", dhcp_client)
_emit_any(lines, "DUIDRawData", "duid_raw_data", dhcp_client)
_emit_str(lines, "IAID", "iaid", dhcp_client)
_emit_bool(lines, "Anonymize", "anonymize", dhcp_client)
_emit_str(lines, "RapidCommit", "rapid_commit", dhcp_client)
_emit_str(
lines, "PrefixDelegationHint", "prefix_delegation_hint", dhcp_client
)
_emit_str(
lines, "UnassignedSubnetPolicy", "unassigned_subnet_policy", dhcp_client
)
_emit_bool(lines, "UseAddress", "use_address", dhcp_client)
_emit_bool(lines, "UseCaptivePortal", "use_captive_portal", dhcp_client)
_emit_bool(lines, "UseDelegatedPrefix", "use_delegated_prefix", dhcp_client)
_emit_bool(lines, "UseDNS", "use_dns", dhcp_client)
_emit_bool(lines, "UseNTP", "use_ntp", dhcp_client)
_emit_bool(lines, "UseSIP", "use_sip", dhcp_client)
_emit_bool(lines, "UseDNR", "use_dnr", dhcp_client)
_emit_bool(lines, "UseHostname", "use_hostname", dhcp_client)
_emit_any(lines, "UseDomains", "use_domains", dhcp_client)
_emit_bool(lines, "SendRelease", "send_release", dhcp_client)
_emit_str(lines, "NetLabel", "net_label", dhcp_client)
_emit_str(lines, "NFTSet", "nft_set", dhcp_client)
_emit_str(lines, "WithoutRA", "without_ra", dhcp_client)
for opt in dhcp_client.get("send_option", []):
if isinstance(opt, dict):
lines.append(
f"SendOption={opt.get('code', '-')} {opt.get('value', '')}"
)
else:
lines.append(f"SendOption={opt}")
for opt in dhcp_client.get("send_vendor_option", []):
if isinstance(opt, dict):
lines.append(
f"SendVendorOption={opt.get('code', '-')}"
f" {opt.get('vendor_code', '')}"
f" {opt.get('value', '')}"
)
else:
lines.append(f"SendVendorOption={opt}")
for uc in dhcp_client.get("user_class", []):
lines.append(f"UserClass={uc}")
for vc in dhcp_client.get("vendor_class", []):
lines.append(f"VendorClass={vc}")
lines.append("")
return "\n".join(lines)
def _bytes_to_ip(addr_bytes: list[int], family: int) -> str:
"""Convert networkctl JSON address byte array to string."""
if family == 2:
return str(ipaddress.ip_address(bytes(addr_bytes)))
return str(ipaddress.IPv6Address(bytes(addr_bytes)))
def parse_networkctl_status(output: str) -> dict[str, Any]:
"""Parse ``networkctl status --json=short --all`` JSON output into runtime state dict.
Args:
output: JSON command output from networkctl status.
Returns:
Dict mapping interface names to their runtime state including
addresses, gateway, DNS, and link state.
"""
try:
data = json.loads(output)
except (json.JSONDecodeError, TypeError):
return {}
result: dict[str, Any] = {}
for iface in data.get("Interfaces", []):
name = iface.get("Name")
if not name:
continue
# Addresses
addresses = []
for a in iface.get("Addresses", []):
try:
ip = _bytes_to_ip(a["Address"], a["Family"])
addresses.append(f"{ip}/{a['PrefixLength']}")
except (KeyError, ValueError, TypeError):
continue
# Gateway — find default route (Destination 0.0.0.0/0)
gateway = None
for route in iface.get("Routes", []):
if route.get("Family") != 2:
continue
dest = route.get("Destination", [])
prefix = route.get("DestinationPrefixLength", 32)
if len(dest) == 4 and all(d == 0 for d in dest) and prefix == 0:
gw_bytes = route.get("Gateway")
if gw_bytes:
with contextlib.suppress(ValueError, TypeError):
gateway = _bytes_to_ip(gw_bytes, 2)
break
# DNS
dns = []
for d in iface.get("DNS", []):
try:
dns.append(_bytes_to_ip(d["Address"], d["Family"]))
except (KeyError, ValueError, TypeError):
continue
# MAC
mac = None
hw = iface.get("HardwareAddress")
if hw:
mac = ":".join(f"{b:02x}" for b in hw)
# State
state = iface.get("OperationalState") or "unknown"
result[name] = {
"addresses": addresses,
"gateway": gateway,
"dns": dns,
"mac": mac,
"state": state,
"link": iface.get("Type", "unknown"),
}
return result
def generate_network_files(cfg: dict[str, Any]) -> dict[str, list[Path]]:
"""Walk config and write all 99-<name>.network files to data/networkd/.
Also removes stale .network files that no longer match config.
Args:
cfg: Network config dict (from get_config).
Returns:
Dict with ``generated`` (new/updated files) and ``cleaned``
(removed stale files) path lists.
"""
DATA_DIR.mkdir(parents=True, exist_ok=True)
generated: list[Path] = []
cleaned: list[Path] = []
# Build set of expected filenames
expected_names: set[str] = set()
interfaces_cfg = cfg.get("interfaces", {})
for iface_name, entry in interfaces_cfg.items():
if not isinstance(entry, dict):
continue
fname = f"99-{iface_name}.network"
expected_names.add(fname)
content = render_network_file(iface_name, entry)
out_path = DATA_DIR / fname
out_path.write_text(content)
generated.append(out_path)
# Remove stale files from DATA_DIR
existing_files: set[str] = {
f.name for f in DATA_DIR.iterdir() if f.name.endswith(".network")
}
for fname in existing_files - expected_names:
(DATA_DIR / fname).unlink()
cleaned.append(DATA_DIR / fname)
if cleaned:
logger.info("Cleaned %d stale .network files from %s", len(cleaned), DATA_DIR)
logger.info("Generated %d .network files in %s", len(generated), DATA_DIR)
return {"generated": generated, "cleaned": cleaned}
# ---------------------------------------------------------------------------
# TF-8: upstream DNS collection
# ---------------------------------------------------------------------------
_IS_LOCAL = [
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("169.254.0.0/16"),
ipaddress.ip_network("::1/128"),
ipaddress.ip_network("fc00::/7"),
ipaddress.ip_network("fe80::/10"),
]
def _is_local_dns(addr: str) -> bool:
try:
ip = ipaddress.ip_address(addr)
for net in _IS_LOCAL:
if ip.version == net.version and ip in net:
return True
except ValueError:
pass
return False
def collect_upstream_dns(cfg: dict[str, Any]) -> list[str]:
"""Collect public DNS servers from networkd config, filtering local ranges.
Args:
cfg: Network config dict (from get_config).
Returns:
Deduplicated list of upstream DNS server addresses.
"""
seen: set[str] = set()
result: list[str] = []
for entry in cfg.get("interfaces", {}).values():
if not isinstance(entry, dict):
continue
for dns in entry.get("dns", []):
if not _is_local_dns(dns) and dns not in seen:
seen.add(dns)
result.append(dns)
for dns in entry.get("ipv6_dns", []):
if not _is_local_dns(dns) and dns not in seen:
seen.add(dns)
result.append(dns)
return result
# ---------------------------------------------------------------------------
# TF-9: DHCP range inference
# ---------------------------------------------------------------------------
def infer_dhcp_ranges(cfg: dict[str, Any]) -> dict[str, dict[str, Any]]:
"""Infer candidate DHCP ranges from static interface IPs.
For each interface with a static IPv4 address, calculates a candidate
DHCP range covering the usable addresses in the subnet.
Args:
cfg: Network config dict (from get_config).
Returns:
Dict mapping interface name to dict with ``subnet``, ``prefix``,
``start``, and ``end`` keys. Interfaces with no inferrable range
are omitted.
"""
result: dict[str, dict[str, Any]] = {}
for name, entry in cfg.get("interfaces", {}).items():
if not isinstance(entry, dict):
continue
for addr in entry.get("addresses", []):
if isinstance(addr, dict):
addr = addr.get("address", "")
if not addr or "/" not in str(addr):
continue
try:
net = ipaddress.ip_network(addr, strict=False)
except ValueError:
continue
if net.version != 4:
continue
if net.num_addresses < 4:
continue
net_addr = net.network_address
broadcast = net.broadcast_address
_start = net_addr + 100
_end = net_addr + 200
_start = min(_start, broadcast - 1)
_end = min(_end, broadcast - 1)
if _start > _end:
continue
result[name] = {
"subnet": str(net_addr),
"prefix": net.prefixlen,
"start": str(_start),
"end": str(_end),
}
break
return result
# ---------------------------------------------------------------------------
# TF-10: firewalld zone inference
# ---------------------------------------------------------------------------
def _looks_wan(entry: dict[str, Any]) -> bool:
"""Heuristic: interface has DHCP or public-facing address."""
if entry.get("dhcp") in ("yes", "ipv4"):
return True
for addr in entry.get("addresses", []):
if isinstance(addr, dict):
addr = addr.get("address", "")
if not addr or "/" not in str(addr):
continue
try:
net = ipaddress.ip_network(addr, strict=False)
except ValueError:
continue
if net.version != 4:
continue
ga = list(net.hosts())
if ga and not _is_local_dns(str(ga[0])):
return True
return False
def _looks_management(entry: dict[str, Any]) -> bool:
"""Heuristic: interface has management-subnet addresses."""
return bool(entry.get("routes"))
def infer_zones(
cfg: dict[str, Any],
) -> dict[str, str]:
"""Classify network interfaces into firewalld zones.
Heuristics:
- Interface name contains ``wg`` ``"wan"``
- DHCP-enabled or public-facing IP ``"wan"``
- Has explicit routes configured ``"management"``
- Everything else ``"lan"``
Args:
cfg: Network config dict (from get_config).
Returns:
Dict mapping interface name to suggested zone name.
"""
result: dict[str, str] = {}
for name, entry in cfg.get("interfaces", {}).items():
if not isinstance(entry, dict):
continue
if "wg" in name or _looks_wan(entry):
result[name] = "wan"
elif _looks_management(entry):
result[name] = "management"
else:
result[name] = "lan"
return result
+114 -9
View File
@@ -59,6 +59,14 @@ DEFAULT_CONFIG: dict[str, Any] = {
def get_config() -> dict[str, Any]:
"""Load the current nginx config, initializing with defaults if needed.
Ensure config and sites directories exist, then return a copy of the
JSON file. On missing file or missing keys, populate from defaults.
Returns:
The complete config dict with ``domains``, ``ssl``, and ``management`` keys.
"""
ensure_dirs(CONFIG_DIR, SITES_DIR)
raw = load_json(CONFIG_FILE)
if not raw:
@@ -69,10 +77,19 @@ def get_config() -> dict[str, Any]:
def save_config(cfg: dict[str, Any]) -> None:
"""Persist *cfg* to the nginx config file atomically."""
save_json(CONFIG_FILE, cfg)
def get_domains() -> list[dict[str, Any]]:
"""Return a list of all configured proxy domains with status.
Each entry includes the domain name, backend info, SSL flag, and
whether a site config file currently exists on disk.
Returns:
List of dicts with ``domain``, ``backend``, ``online``, and ``force_ssl``.
"""
cfg = get_config()
result: list[dict[str, Any]] = []
for name, dom in cfg.get("domains", {}).items():
@@ -101,6 +118,19 @@ def add_domain(
cert: str | None = None,
extra_headers: dict[str, str] | None = None,
) -> None:
"""Add a new proxy domain with the given backend and optional settings.
Args:
domain: Domain name to add.
backend_host: Upstream host to proxy to.
backend_port: Upstream port.
backend_proto: Protocol (``http`` or ``https``).
cert: Optional certificate type identifier.
extra_headers: Optional dict of extra headers to forward.
Raises:
ValueError: If the domain is already configured.
"""
cfg = get_config()
if domain in cfg["domains"]:
raise ValueError(f"Domain {domain!r} already configured")
@@ -128,6 +158,7 @@ def add_domain(
def remove_domain(domain: str) -> None:
"""Remove *domain* from the config and delete its site file."""
cfg = get_config()
cfg["domains"].pop(domain, None)
save_config(cfg)
@@ -138,6 +169,15 @@ def remove_domain(domain: str) -> None:
def update_domain(domain: str, **kwargs: Any) -> None:
"""Update fields of an existing domain entry in-place.
Args:
domain: Domain name to update.
**kwargs: Key-value pairs to merge into the domain config.
Raises:
KeyError: If the domain is not configured.
"""
cfg = get_config()
if domain not in cfg["domains"]:
raise KeyError(f"Domain {domain!r} not configured")
@@ -157,6 +197,14 @@ def update_domain(domain: str, **kwargs: Any) -> None:
def generate_server_conf(domain_cfg: dict[str, Any]) -> str:
"""Render the Jinja template for a standard domain server block.
Args:
domain_cfg: Domain entry dict including the ``domain`` key.
Returns:
The complete nginx server-block configuration as a string.
"""
tmpl = ENV.get_template("nginx/server_block.conf")
return tmpl.render(
domain=domain_cfg["domain"],
@@ -173,6 +221,14 @@ def generate_server_conf(domain_cfg: dict[str, Any]) -> str:
def _generate_management_conf(management: dict[str, Any]) -> str:
"""Render the Jinja template for the management WebUI server block.
Args:
management: Management proxy config dict containing ``domain`` and optional ``auth``.
Returns:
The complete nginx server-block configuration for the management UI.
"""
tmpl = ENV.get_template("nginx/server_block.conf")
return tmpl.render(
domain=management.get("domain"),
@@ -196,6 +252,12 @@ def _generate_management_conf(management: dict[str, Any]) -> str:
def write_site(domain: str, conf_text: str) -> None:
"""Atomically write *conf_text* to the site config file for *domain*.
Args:
domain: Domain name (becomes the ``<domain>.conf`` file).
conf_text: Nginx server-block configuration text.
"""
ensure_dirs(SITES_DIR)
path = SITES_DIR / f"{domain}.conf"
tmp = path.with_suffix(".tmp")
@@ -207,7 +269,7 @@ def write_site(domain: str, conf_text: str) -> None:
def write_acme_challenge() -> None:
"""Write the ACME HTTP-01 challenge catch-all nginx config.
"""Write the catch-all nginx config for ACME HTTP-01 challenges.
Serves ``/.well-known/acme-challenge/`` on port 80 from the ACME
webroot for any domain not yet covered by a dedicated server block.
@@ -226,6 +288,12 @@ def write_acme_challenge() -> None:
def write_all_sites() -> None:
"""Regenerate all site configs from the current config state.
Writes server blocks for every configured domain and the management
proxy (if any), removes orphaned site files, and ensures the ACME
challenge config is present.
"""
ensure_dirs(SITES_DIR)
cfg = get_config()
@@ -252,6 +320,11 @@ def write_all_sites() -> None:
def write_include_file() -> None:
"""Write the nginx include file that pulls in managed site configs.
The include file is installed at ``/etc/nginx/conf.d/vacuum-wall.conf``
and must be owned by root.
"""
tmpl = ENV.get_template("nginx/include.conf")
content = tmpl.render(sites_glob=str(SITES_DIR / "*.conf"))
tmp = INCLUDE_FILE.with_suffix(".tmp")
@@ -264,6 +337,11 @@ def write_include_file() -> None:
def write_ssl_snippet() -> None:
"""Write the shared SSL settings snippet to ``/etc/nginx/snippets/``.
The snippet is populated from the ``ssl`` section of the nginx config
and installed with root ownership.
"""
cfg = get_config()
ssl_cfg = cfg.get("ssl", {})
ssl_cfg.setdefault("prefer_server_ciphers", DEFAULT_SSL["prefer_server_ciphers"])
@@ -287,6 +365,12 @@ def write_ssl_snippet() -> None:
def test_config() -> tuple[bool, str]:
"""Run ``nginx -t`` and return the pass/fail result.
Returns:
Tuple of ``(ok, message)`` where ``ok`` is ``True`` if the
config test passed and ``message`` contains output or a summary.
"""
result = subprocess.run(
["sudo", "nginx", "-t"], capture_output=True, text=True, check=False
)
@@ -302,6 +386,11 @@ def test_config() -> tuple[bool, str]:
def apply() -> None:
"""Generate all configs, test them, and reload nginx.
Raises:
RuntimeError: If the nginx config test fails.
"""
write_ssl_snippet()
write_all_sites()
write_include_file()
@@ -329,6 +418,15 @@ def set_management_proxy(
auth_user: str | None = None,
auth_pass: str | None = None,
) -> None:
"""Configure the management reverse proxy for the WebUI.
Args:
domain: Management domain name.
flask_host: Upstream Flask host (default ``127.0.0.1``).
flask_port: Upstream Flask port (default ``9090``).
auth_user: Optional basic-auth username.
auth_pass: Optional basic-auth password; writes htpasswd when provided with ``auth_user``.
"""
cfg = get_config()
entry: dict[str, Any] = {
"domain": domain,
@@ -356,7 +454,12 @@ def set_management_proxy(
def write_htpasswd(user: str, password: str) -> None:
"""Append (or create) an htpasswd entry for *user*."""
"""Append (or create) an htpasswd entry for *user*.
Args:
user: Username for the htpasswd entry.
password: Plain-text password to hash and store.
"""
ensure_dirs(DATA_DIR)
hashed = _hash_password(password)
existing: dict[str, str] = {}
@@ -381,15 +484,17 @@ def write_htpasswd(user: str, password: str) -> None:
def _hash_password(password: str) -> str:
try:
from passlib.hash import apache_passwd
"""Hash *password* using SHA-256 crypt (``$5$`` format) via passlib.
return apache_passwd.using(rounds=12).hash(password)
except Exception:
import crypt as _crypt
Args:
password: Plain-text password to hash.
salt = os.urandom(16).hex()[:16]
return _crypt.crypt(password, f"$5${salt}")
Returns:
The hashed password string suitable for ``.htpasswd`` (e.g. ``$5$rounds=$``).
"""
from passlib.hash import sha256_crypt
return sha256_crypt.hash(password)
__all__ = [
+207 -31
View File
@@ -7,7 +7,6 @@ state instead of invoking subprocesses on every request.
import contextlib
import logging
import os
import re
import shutil
import subprocess
from copy import deepcopy
@@ -23,6 +22,7 @@ from lib.firewall import (
from lib.firewall import (
config_pending as _config_pending,
)
from lib.network import parse_networkctl_status
logger = logging.getLogger(__name__)
@@ -40,23 +40,91 @@ class State:
Each subsystem's value is a dict collected from the corresponding
``collect_*`` function. A value of ``None`` means the subsystem has
not been populated yet or the last collection failed.
Attributes:
SUBSYSTEMS: Ordered list of subsystem names.
_data: Dict mapping subsystem names to their state data.
"""
SUBSYSTEMS: ClassVar[list[str]] = ["firewall", "dnsmasq", "nginx", "acme", "wireguard"]
SUBSYSTEMS: ClassVar[list[str]] = [
"firewall",
"dnsmasq",
"nginx",
"acme",
"wireguard",
"networkd",
]
def __init__(self) -> None:
"""Initialize the state store with empty subsystem slots."""
self._data: dict[str, dict[str, Any] | None] = {
name: None for name in self.SUBSYSTEMS
}
self._versions: dict[str, int] = {name: 0 for name in self.SUBSYSTEMS}
self._last_broadcast: dict[str, int] | None = None
def bump(self, subsystem: str) -> None:
"""Increment the version counter for *subsystem*.
Args:
subsystem: Subsystem name.
"""
if subsystem in self._versions:
self._versions[subsystem] += 1
def get_versions(self) -> dict[str, int]:
"""Return a shallow copy of all subsystem versions.
Returns:
Dict mapping subsystem names to their current version integers.
"""
return dict(self._versions)
def get_updated_versions(self) -> dict[str, int]:
"""Return versions that changed since the last broadcast.
After calling, ``_last_broadcast`` is updated to match current versions.
Returns:
Dict of subsystems whose versions changed, or empty dict.
"""
if self._last_broadcast is None:
self._last_broadcast = dict(self._versions)
return {}
updated: dict[str, int] = {}
for name, v in self._versions.items():
if v != self._last_broadcast.get(name, 0):
updated[name] = v
self._last_broadcast[name] = v
return updated
def get(self, subsystem: str) -> dict[str, Any] | None:
"""Get state data for *subsystem*.
Args:
subsystem: Subsystem name.
Returns:
State dict, or ``None`` if not populated.
"""
return self._data.get(subsystem)
def set(self, subsystem: str, data: dict[str, Any] | None) -> None:
"""Set state data for *subsystem*.
Args:
subsystem: Subsystem name.
data: State data, or ``None`` to clear.
"""
self._data[subsystem] = data
def populate(self, subsystems: list[str] | None = None) -> None:
"""Collect state for *subsystems* (all if None)."""
"""Collect state for *subsystems* (all if ``None``).
Args:
subsystems: List of subsystem names to collect. Collects all
subsystems when ``None``.
"""
targets = subsystems or self.SUBSYSTEMS
for name in targets:
collector = _COLLECTORS.get(name)
@@ -73,6 +141,11 @@ class State:
self._data[name] = None
def is_populated(self) -> bool:
"""Check whether all subsystem states have been populated.
Returns:
``True`` if every subsystem has non-``None`` state data.
"""
return all(v is not None for v in self._data.values())
@@ -88,6 +161,15 @@ _COLLECTORS: dict[str, Any] = {}
def register_collector(subsystem: str, fn: Any) -> Any:
"""Register *fn* as the state collector for *subsystem*.
Args:
subsystem: Subsystem name to register for.
fn: Collector function to register.
Returns:
The *fn* function (for decorator usage).
"""
_COLLECTORS[subsystem] = fn
return fn
@@ -98,10 +180,19 @@ def register_collector(subsystem: str, fn: Any) -> Any:
def _now_iso() -> str:
"""Return the current UTC time as an ISO 8601 string."""
return datetime.now(UTC).isoformat()
def _fp_to_str(fp: dict[str, Any]) -> str:
"""Convert a port-forward dict to a compact string representation.
Args:
fp: Port-forward entry containing port and proto keys.
Returns:
Comma-separated string of key=value pairs (e.g. ``port=443,proto=tcp``).
"""
parts = [f"port={fp['port']}", f"proto={fp['proto']}"]
if "toaddr" in fp:
parts.append(f"toaddr={fp['toaddr']}")
@@ -111,7 +202,12 @@ def _fp_to_str(fp: dict[str, Any]) -> str:
def _collect_firewall() -> dict[str, Any]:
"""Return the complete current state of firewalld."""
"""Return the complete current state of firewalld.
Returns:
Dict containing firewall zones, interfaces, rules, config, and
pending changes.
"""
zone_names = run(["firewall-cmd", "--get-zones"], sudo=True).split()
active_raw = run(["firewall-cmd", "--get-active-zones"], sudo=True)
active = _parse_active_zones(active_raw)
@@ -126,7 +222,7 @@ def _collect_firewall() -> dict[str, Any]:
parts = line.split()
if len(parts) < 2:
continue
raw_name = parts[1].rstrip(":")
raw_name = parts[1].rstrip(":").split("@")[0]
iface_state = "UNKNOWN"
mtu = None
mac = None
@@ -139,7 +235,6 @@ def _collect_firewall() -> dict[str, Any]:
mac = parts[i + 1]
iface_map[raw_name] = {
"name": raw_name,
"display_name": raw_name.partition("@")[0],
"mac": mac,
"state": iface_state,
"mtu": mtu,
@@ -154,18 +249,17 @@ def _collect_firewall() -> dict[str, Any]:
parts = line.split()
if len(parts) < 4:
continue
addr_name = parts[1]
addr_name = parts[1].split("@")[0]
addr_key = "ipv6" if parts[2] == "inet6" else "ips"
for entry in iface_map.values():
if entry["display_name"] == addr_name:
if entry["name"] == addr_name:
entry[addr_key].append(parts[3])
break
for zone_name, ifaces in active.items():
for raw_if in ifaces:
clean = raw_if.partition("@")[0]
for entry in iface_map.values():
if entry["display_name"] == clean or entry["name"] == raw_if:
if entry["name"] == raw_if:
entry["zone"] = zone_name
break
@@ -221,7 +315,11 @@ register_collector("firewall", _collect_firewall)
def _collect_dnsmasq() -> dict[str, Any]:
"""Collect dnsmasq status, config, and leases."""
"""Collect dnsmasq status, config, and leases.
Returns:
Dict containing config, service status, leases, and timestamp.
"""
CONFIG_DIR = PROJECT_DIR / "config" / "dnsmasq"
CONFIG_PATH = CONFIG_DIR / "config.json"
DNSMASQ_CONF = "/etc/dnsmasq.d/vacuum-wall.conf"
@@ -311,7 +409,11 @@ register_collector("dnsmasq", _collect_dnsmasq)
def _collect_nginx() -> dict[str, Any]:
"""Collect nginx config and domains list."""
"""Collect nginx config and domains list.
Returns:
Dict containing config, domains, and timestamp.
"""
CONFIG_DIR = PROJECT_DIR / "config" / "nginx"
CONFIG_FILE = CONFIG_DIR / "config.json"
SITES_DIR = PROJECT_DIR / "data" / "nginx" / "sites-enabled"
@@ -341,11 +443,6 @@ def _collect_nginx() -> dict[str, Any]:
if raw:
from lib.common import deep_merge
default_cfg: dict[str, Any] = {
"domains": {},
"management": None,
"ssl": deepcopy(DEFAULT_SSL),
}
cfg = deep_merge(default_cfg, raw)
if "ssl" not in cfg:
cfg["ssl"] = deepcopy(DEFAULT_SSL)
@@ -381,6 +478,14 @@ register_collector("nginx", _collect_nginx)
def _find_acme() -> str:
"""Locate the ``acme.sh`` binary on the filesystem.
Returns:
Absolute path to the ``acme.sh`` executable.
Raises:
FileNotFoundError: If acme.sh cannot be found.
"""
acme_home = PROJECT_DIR / "data" / "acme"
candidates = [acme_home / "acme.sh", Path("/usr/local/bin/acme.sh")]
for path in candidates:
@@ -393,6 +498,17 @@ def _find_acme() -> str:
def _run_acme(args: list[str]) -> str:
"""Run ``acme.sh`` with *args* and return combined output.
Args:
args: Command-line arguments to pass after the home/config flags.
Returns:
Combined stdout/stderr output.
Raises:
RuntimeError: If acme.sh exits non-zero or times out.
"""
acme_bin = _find_acme()
acme_home_env = os.environ.get("ACME_HOME", str(PROJECT_DIR / "data" / "acme"))
cmd = [acme_bin, "--home", acme_home_env, "--config-home", acme_home_env, *args]
@@ -421,6 +537,14 @@ def _run_acme(args: list[str]) -> str:
def _days_until(date_str: str) -> int | None:
"""Parse a date string and return days until *date_str* from now.
Args:
date_str: Date string in common ACME formats.
Returns:
Number of days remaining, or ``None`` if empty or unparseable.
"""
if not date_str:
return None
for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"):
@@ -438,6 +562,14 @@ def _days_until(date_str: str) -> int | None:
def _parse_acme_list_output(raw: str) -> list[dict]:
"""Parse ``acme.sh --list`` output into a list of certificate dicts.
Args:
raw: Raw output string from ``acme.sh --list``.
Returns:
List of dicts with certificate entry fields.
"""
entries: list[dict] = []
for line in raw.strip().splitlines():
line = line.strip()
@@ -455,27 +587,35 @@ def _parse_acme_list_output(raw: str) -> list[dict]:
def _has_auto_renew(domain: str) -> bool:
"""Check whether *domain* has an auto-renew configuration file.
Args:
domain: Domain name to check.
Returns:
``True`` if a corresponding ``acme.sh`` config file exists.
"""
acme_home_env = os.environ.get("ACME_HOME", str(PROJECT_DIR / "data" / "acme"))
return bool(Path(acme_home_env) / f"{domain}.conf")
def _get_acme_email() -> str:
acme_home_default = str(PROJECT_DIR / "data" / "acme")
try:
acme_home = Path(os.environ.get("ACME_HOME", acme_home_default))
account_conf = acme_home / "account.conf"
if account_conf.is_file():
text = account_conf.read_text()
match = re.search(r"^ACME_LEEMAIL=(.+)$", text, re.MULTILINE)
if match:
return match.group(1).strip().strip("'\"")
except OSError:
pass
return ""
"""Read the ACME ``acme.sh`` email from the account config file.
Falls back to the declarative ACME config (config/acme/config.json)
if acme.sh account has not been registered yet.
"""
from lib.acme import _read_acme_email
return _read_acme_email()
def _collect_acme() -> dict[str, Any]:
"""Collect ACME certificate list and email."""
"""Collect ACME certificate list and email.
Returns:
Dict containing certificate details and registered email.
"""
email = _get_acme_email()
certs: list[dict[str, Any]] = []
@@ -529,7 +669,11 @@ register_collector("acme", _collect_acme)
def _collect_wireguard() -> dict[str, Any]:
"""Collect WireGuard config, status, and peers."""
"""Collect WireGuard config, status, and peers.
Returns:
Dict containing interface config, runtime status, and peers.
"""
CONFIG_PATH = PROJECT_DIR / "config" / "wireguard" / "config.json"
DEFAULT_CONFIG: dict[str, Any] = {
@@ -648,6 +792,38 @@ def _collect_wireguard() -> dict[str, Any]:
register_collector("wireguard", _collect_wireguard)
# ---------------------------------------------------------------------------
# Networkd collector
# ---------------------------------------------------------------------------
def _collect_networkd() -> dict[str, Any]:
"""Collect networkd interface state from networkctl.
Returns:
Dict with interface runtime state parsed from networkctl output.
Returns empty data if networkctl is not available.
"""
result: dict[str, dict[str, Any]] = {}
try:
raw = run(["networkctl", "status", "--json=short", "--all"], sudo=True)
result = parse_networkctl_status(raw)
if not result:
return {"interfaces": {}, "timestamp": _now_iso()}
except Exception:
return {
"interfaces": {},
"timestamp": _now_iso(),
}
return {
"interfaces": result,
"timestamp": _now_iso(),
}
register_collector("networkd", _collect_networkd)
__all__ = [
"State",
+1
View File
@@ -10,6 +10,7 @@ requires-python = ">=3.13"
dependencies = [
"Flask>=3.0,<4.0",
"aiohttp>=3.9,<4.0",
"passlib>=1.7.4,<2.0",
"requests-unixsocket>=0.2,<1.0",
]
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
systemctl restart nginx
sleep 1
systemctl restart vacuum-walld
sleep 1
systemctl restart vacuum-wall
# Verify services are running
failed=0
for svc in nginx vacuum-walld vacuum-wall; do
if ! systemctl is-active --quiet "$svc"; then
echo "ERROR: $svc is not running" >&2
failed=1
fi
done
if [ "$failed" -eq 1 ]; then
exit 1
fi
-15
View File
@@ -4,12 +4,9 @@
set -euo pipefail
# ---- Library versions ----
HTMX_VERSION="2.0.4"
HTMX_JSON_ENC_VERSION="2.0.0"
ACME_VERSION="3.1.3"
VENDOR="vendor"
STATIC="webui/static"
download() {
local name="$1" url="$2" dest="$3"
@@ -21,22 +18,10 @@ download() {
curl -sfL -o "$dest" "$url"
}
download "htmx@${HTMX_VERSION}" \
"https://unpkg.com/htmx.org@${HTMX_VERSION}/dist/htmx.min.js" \
"${VENDOR}/htmx-${HTMX_VERSION}.min.js"
download "htmx-ext-json-enc@${HTMX_JSON_ENC_VERSION}" \
"https://unpkg.com/htmx-ext-json-enc@${HTMX_JSON_ENC_VERSION}/json-enc.js" \
"${VENDOR}/json-enc-${HTMX_JSON_ENC_VERSION}.js"
download "acme.sh@${ACME_VERSION}" \
"https://raw.githubusercontent.com/acmesh-official/acme.sh/${ACME_VERSION}/acme.sh" \
"${VENDOR}/acme.sh"
chmod +x "${VENDOR}/acme.sh"
# Symlinks in webui/static/ select the active version
ln -sf "../../vendor/htmx-${HTMX_VERSION}.min.js" "${STATIC}/htmx.min.js"
ln -sf "../../vendor/json-enc-${HTMX_JSON_ENC_VERSION}.js" "${STATIC}/json-enc.js"
echo "[done] All libraries vendored."
+2 -1
View File
@@ -13,6 +13,7 @@ try:
import requests_unixsocket
from daemon.client import post
from daemon.iface import POST_NGINX_RELOAD
logging.basicConfig(level=logging.INFO)
project_dir = os.environ.get("INSTALL_DIR", os.path.dirname(os.path.dirname(__file__)))
@@ -20,7 +21,7 @@ try:
"VACUUM_WALLD_SOCKET",
os.path.join(project_dir, "data", "daemon.sock"),
)
post("/nginx/reload", socket_path=socket_path)
post(POST_NGINX_RELOAD, socket_path=socket_path)
sys.exit(0)
except Exception as exc:
logging.error("acme-deploy hook failed: %s", exc)
+5 -1
View File
@@ -1,9 +1,13 @@
# ---- vacuum-wall managed dnsmasq configuration ----
# generated {{ timestamp }}
bind-interfaces
{% if interfaces %}
interface={{ interfaces | join(',') }}
bind-interfaces
{% elif listen_addresses %}
{% for addr in listen_addresses %}
listen-address={{ addr }}
{% endfor %}
{% endif %}
{% for srv in dns.upstreams %}
server={{ srv }}
+34 -33
View File
@@ -39,8 +39,8 @@ server {
{% endif %}
{% elif is_management %}
ssl_certificate {{ certs_dir }}/{{ domain }}.crt;
ssl_certificate_key {{ certs_dir }}/{{ domain }}.key;
ssl_certificate {{ acme_home }}/{{ domain }}/fullchain.cer;
ssl_certificate_key {{ acme_home }}/{{ domain }}/{{ domain }}.key;
{% endif %}
# Shared SSL settings
@@ -57,48 +57,49 @@ server {
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Proxy headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
{% endif %}
location / {
# Proxy headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
{% if not is_management %}
{% for hname, hval in headers.items() %}
proxy_set_header {{ hname }} {{ hval }};
proxy_set_header {{ hname }} {{ hval }};
{% endfor %}
{% endif %}
# Proxy pass to backend
proxy_pass {{ backend.proto }}://{{ backend.host }}:{{ backend.port }};
proxy_http_version 1.1;
# Proxy pass to backend
proxy_pass {{ backend.proto }}://{{ backend.host }}:{{ backend.port }};
proxy_http_version 1.1;
# Timeouts
proxy_connect_timeout 30s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
proxy_buffering off;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
{% if is_management %}
location /ws {
auth_basic off;
proxy_pass http://127.0.0.1:9091;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
{% endif %}
{% if not is_management %}
# Timeouts
proxy_connect_timeout 30s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
proxy_buffering off;
# Access / error logs
access_log /var/log/nginx/{{ domain }}_access.log;
error_log /var/log/nginx/{{ domain }}_error.log warn;
{% else %}
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 30s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
proxy_buffering off;
access_log /var/log/nginx/wall_mgmt_access.log;
error_log /var/log/nginx/wall_mgmt_error.log warn;
{% endif %}
location / {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
+16 -5
View File
@@ -8,9 +8,9 @@ Defaults:{{ USER_DAEMON_NAME }} secure_path="/usr/local/sbin:/usr/local/bin:/usr
# Nginx management
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/nginx -s reload
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/nginx -t
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/nginx/
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/nginx/conf.d/
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/nginx/snippets/
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/nginx/*
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/nginx/conf.d/*
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/nginx/snippets/*
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/conf.d/vacuum-wall.conf
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/snippets/vacuum-wall-ssl.conf
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/chown root\:root /etc/nginx/snippets/vacuum-wall-ssl.conf
@@ -18,21 +18,32 @@ Defaults:{{ USER_DAEMON_NAME }} secure_path="/usr/local/sbin:/usr/local/bin:/usr
# Dnsmasq management
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/systemctl reload dnsmasq
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/systemctl is-active dnsmasq
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/dnsmasq.d/
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cat /var/lib/dnsmasq/dnsmasq.leases
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/tee /etc/dnsmasq.d/vacuum-wall.conf
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/mkdir -p /etc/dnsmasq.d
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/dnsmasq.d/*
# WireGuard management
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/wg-quick *
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/wg *
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/wireguard/
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/wireguard/*
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/chown root\:root /etc/wireguard/wg0.conf
# Network interface queries
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/ip -o link show
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/ip -o addr show
# Networkd management
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /bin/networkctl status *
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /bin/networkctl reload
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /bin/networkctl reconfigure *
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/systemd/network/*
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/systemd/network/*.network
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/mkdir -p /etc/systemd/network
# Sysctl
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/sysctl -w *
# Misc
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/journalctl --unit=* -n *
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cat /var/log/nginx/*
+1 -1
View File
@@ -19,7 +19,7 @@ Environment=HOME={{ PROJECT_DIR }}
# Security hardening
ProtectSystem=strict
ReadWritePaths={{ PROJECT_DIR }} /tmp
ReadWritePaths={{ PROJECT_DIR }} /tmp /etc/systemd/network /etc/nginx /etc/dnsmasq.d /etc/wireguard /run/sudo /run/firewalld
PrivateTmp=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
+120
View File
@@ -0,0 +1,120 @@
import { reactive, h, html, render, Router, Link } from '../webui/static/reactive-dom.js';
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(`${name}`);
passed++;
} catch (e) {
console.error(`${name}: ${e.message}`);
failed++;
}
}
function assert(cond, msg) {
if (!cond) throw new Error(msg || 'Assertion failed');
}
console.log('Testing reactive-dom.js\n');
// === Reactive ===
test('reactive() returns proxy', () => {
const s = reactive({ x: 1 });
assert(s.x === 1);
});
test('reactive() mutation triggers render callback', () => {
const s = reactive({ x: 1 });
let fired = false;
// We can't easily test the render callback without a DOM, but we can check the proxy works
s.x = 2;
assert(s.x === 2);
});
// === h() ===
test('h() creates element VNode', () => {
const v = h('div', { class: 'foo' });
assert(v.tag === 'div' && v.props.class === 'foo');
});
test('h() flattens children array', () => {
const v = h('div', null, h('span', null, 'hi'));
assert(v.ch.length === 1 && v.ch[0].tag === 'span');
});
test('h() converts strings to text nodes', () => {
const v = h('div', null, 'hello', 42);
assert(v.ch.length === 2 && v.ch[0].tag === '#text' && v.ch[0].text === 'hello');
assert(v.ch[1].text === '42');
});
test('h() drops null/boolean children', () => {
const v = h('div', null, null, undefined, true, false, 'x');
assert(v.ch.length === 1 && v.ch[0].text === 'x');
});
// === html() ===
test('html() parses static element', () => {
const nodes = html`<div>hello</div>`;
assert(nodes[0].tag === 'div' && nodes[0].ch[0].text === 'hello');
});
test('html() interpolates text into element children', () => {
const name = 'World';
const nodes = html`<div>Hello ${name}</div>`;
assert(nodes[0].tag === 'div');
// Should have: text "Hello ", then text "World"
assert(nodes[0].ch[0].text && nodes[0].ch[0].text === 'Hello ');
assert(nodes[0].ch[1].tag === '#text' && nodes[0].ch[1].text === 'World');
});
test('html() interpolates class attribute value', () => {
const cls = 'active';
const nodes = html`<div class="${cls}">x</div>`;
assert(nodes[0].tag === 'div' && nodes[0].props.class === 'active');
});
test('html() interpolates on:click attribute value', () => {
const handler = function click() {};
const nodes = html`<button on:click="${handler}">Go</button>`;
assert(nodes[0].tag === 'button' && typeof nodes[0].props['on:click'] === 'function');
});
test('html() handles multiple interpolations', () => {
const a = 'first', b = 'second';
const nodes = html`<div><span>${a}</span> <span>${b}</span></div>`;
assert(nodes[0].tag === 'div');
assert(nodes[0].ch[0].tag === 'span' && nodes[0].ch[0].ch[0].text === 'first');
});
test('html() interpolates VNode into element children', () => {
const nodes = html`<ul>${html`<li>item</li>`[0]}</ul>`;
assert(nodes[0].tag === 'ul' && nodes[0].ch[0].tag === 'li');
});
// === Router ===
test('Router initializes with current hash', () => {
globalThis.location = { hash: '' };
globalThis.window = { addEventListener: () => {} };
const router = Router({ '/home': () => {} });
assert(router.state.path === '/');
});
// === Link ===
test('Link creates anchor with hash href', () => {
const link = Link({ path: '/dashboard' });
assert(link.tag === 'a' && link.props.href === '#/dashboard');
});
// === DOM functions (basic, no actual DOM) ===
test('createDom produces document.createElement call', () => {
const v = h('div', { class: 'foo' }, 'hi', h('span', null, 'nested'));
// Can't test actual DOM without jsdom, but we can verify the VNode structure
assert(v.tag === 'div' && v.ch.length === 2);
});
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);
+110
View File
@@ -35,6 +35,11 @@ def _wg(func, **kw):
return _patch(f"webui.api.wireguard.{func}", **kw)
def _ne(func, **kw):
"""Patch daemon.client.{func} in the network blueprint namespace."""
return _patch(f"webui.api.network.{func}", **kw)
@pytest.fixture
def client():
from flask import Flask
@@ -42,11 +47,13 @@ def client():
from webui.api.certs import bp as certs_bp
from webui.api.dhcp import bp as dhcp_bp
from webui.api.firewall import bp as firewall_bp
from webui.api.network import bp as network_bp
from webui.api.proxy import bp as proxy_bp
from webui.api.wireguard import bp as wg_bp
app = Flask(__name__)
app.register_blueprint(firewall_bp, url_prefix="/api/firewall")
app.register_blueprint(network_bp, url_prefix="/api/network")
app.register_blueprint(dhcp_bp, url_prefix="/api/dhcp")
app.register_blueprint(proxy_bp, url_prefix="/api/proxy")
app.register_blueprint(certs_bp, url_prefix="/api/certs")
@@ -788,3 +795,106 @@ class TestProxyDomainUpdate:
json={"backend_host": "10.0.0.2"},
)
assert resp.status_code == 200
# ============================================================================
# Network
# ============================================================================
class TestNetworkListInterfaces:
@_ne("get")
def test_success(self, mock_get, client):
mock_get.return_value = [
{"name": "eth0", "config": {"addresses": ["10.0.0.1/24"]}}
]
resp = client.get("/api/network/interfaces")
assert resp.status_code == 200
data = resp.get_json()
assert data["ok"] is True
@_ne("get")
def test_runtime_error(self, mock_get, client):
mock_get.side_effect = RuntimeError("networkctl not found")
resp = client.get("/api/network/interfaces")
assert resp.status_code == 500
assert resp.get_json()["ok"] is False
class TestNetworkGetInterface:
@_ne("get")
def test_success(self, mock_get, client):
mock_get.return_value = {
"name": "eth0",
"config": {"addresses": ["10.0.0.1/24"]},
}
resp = client.get("/api/network/interfaces/eth0")
assert resp.status_code == 200
data = resp.get_json()
assert data["ok"] is True
@_ne("get")
def test_not_found(self, mock_get, client):
from daemon.client import NotFound
mock_get.side_effect = NotFound("interface not found")
resp = client.get("/api/network/interfaces/nonexist")
assert resp.status_code == 404
class TestNetworkSaveInterface:
@_ne("post")
def test_success(self, mock_post, client):
mock_post.return_value = {"name": "eth0", "applied": True}
resp = client.post(
"/api/network/interfaces/eth0",
json={
"addresses": ["10.0.0.1/24"],
"gateway": "10.0.0.254",
"dns": ["8.8.8.8"],
"routes": [],
},
)
assert resp.status_code == 200
data = resp.get_json()
assert data["ok"] is True
@_ne("post")
def test_not_found(self, mock_post, client):
from daemon.client import NotFound
mock_post.side_effect = NotFound("interface not found")
resp = client.post("/api/network/interfaces/missing", json={})
assert resp.status_code == 404
class TestNetworkReloadInterface:
@_ne("post")
def test_success(self, mock_post, client):
mock_post.return_value = {"name": "eth0", "reloaded": True}
resp = client.post("/api/network/interfaces/eth0/reload")
assert resp.status_code == 200
assert resp.get_json()["ok"] is True
@_ne("post")
def test_runtime_error(self, mock_post, client):
mock_post.side_effect = RuntimeError("reload failed")
resp = client.post("/api/network/interfaces/eth0/reload")
assert resp.status_code == 500
class TestNetworkApplyAll:
@_ne("post")
def test_success(self, mock_post, client):
mock_post.return_value = {"applied": 2, "interfaces": ["eth0", "eth1"]}
resp = client.post("/api/network/apply")
assert resp.status_code == 200
data = resp.get_json()
assert data["ok"] is True
assert data["data"]["applied"] == 2
@_ne("post")
def test_runtime_error(self, mock_post, client):
mock_post.side_effect = RuntimeError("apply failed")
resp = client.post("/api/network/apply")
assert resp.status_code == 500
+74
View File
@@ -0,0 +1,74 @@
"""Tests for daemon client path parameter substitution."""
import contextlib
from unittest.mock import patch
from daemon.client import _format_path, request
class TestFormatPath:
def test_simple_substitution(self):
assert (
_format_path("/network/interfaces/<name>", {"name": "eth0"})
== "/network/interfaces/eth0"
)
def test_multiple_params(self):
assert _format_path("/a/<x>/b/<y>", {"x": "1", "y": "2"}) == "/a/1/b/2"
def test_no_params_unchanged(self):
assert (
_format_path("/network/interfaces/<name>", None)
== "/network/interfaces/<name>"
)
def test_empty_params_unchanged(self):
assert (
_format_path("/network/interfaces/<name>", {})
== "/network/interfaces/<name>"
)
def test_partial_substitution(self):
assert _format_path("/a/<x>/b/<y>", {"x": "1"}) == "/a/1/b/<y>"
def test_url_encodes_special_chars(self):
assert _format_path("/a/<x>", {"x": "foo bar"}) == "/a/foo%20bar"
def test_preserves_non_param_brackets(self):
assert _format_path("/foo[bar]/<x>", {"x": "z"}) == "/foo[bar]/z"
def test_numeric_value(self):
assert _format_path("/items/<id>", {"id": 42}) == "/items/42"
def test_path_without_params(self):
assert _format_path("/health", {"foo": "bar"}) == "/health"
class TestRequestPathSubstitution:
@patch("daemon.client.requests_unixsocket.Session")
def test_post_substitutes_name_from_body(self, mock_session_cls):
mock_sess = mock_session_cls.return_value
mock_resp = mock_sess.request.return_value
mock_resp.status_code = 200
mock_resp.json.return_value = {"ok": True, "data": {"name": "eth0"}}
with contextlib.suppress(Exception):
request("POST", "/network/interfaces/<name>", json_body={"name": "eth0"})
call_args = mock_sess.request.call_args
url = call_args[0][1] if call_args else ""
assert "/interfaces/eth0" in url
@patch("daemon.client.requests_unixsocket.Session")
def test_get_substitutes_name_from_query(self, mock_session_cls):
mock_sess = mock_session_cls.return_value
mock_resp = mock_sess.request.return_value
mock_resp.status_code = 200
mock_resp.json.return_value = {"ok": True, "data": {}}
with contextlib.suppress(Exception):
request("GET", "/network/interfaces/<name>", query_params={"name": "eth0"})
call_args = mock_sess.request.call_args
url = call_args[0][1] if call_args else ""
assert "/interfaces/eth0" in url
+1 -3
View File
@@ -314,7 +314,6 @@ _FakeState = {
"interfaces": [
{
"name": "eth0",
"display_name": "eth0",
"mac": "aa:bb:cc:dd:ee:00",
"state": "UP",
"mtu": 1500,
@@ -324,7 +323,6 @@ _FakeState = {
},
{
"name": "eth1",
"display_name": "eth1",
"mac": "aa:bb:cc:dd:ee:01",
"state": "UP",
"mtu": 1500,
@@ -437,7 +435,7 @@ class TestDaemonGetState:
class TestDaemonConfigApply:
@patch(
"daemon.handlers.firewall._get_config",
"lib.firewall.get_config",
return_value={
"zones": {
"public": {
+84
View File
@@ -0,0 +1,84 @@
"""Tests for daemon/handlers/acme.py — handler endpoint logic."""
from pathlib import Path
from unittest.mock import patch
import pytest
from daemon.handlers.acme import generate_self_signed
class TestGenerateSelfSigned:
def test_generate_creates_files(self, tmp_path):
with (
patch("daemon.handlers.acme._ACME_HOME", tmp_path / "acme"),
):
result = generate_self_signed(None, {"domain": "test.local"})
assert result["domain"] == "test.local"
assert result["generated"] is True
cert_dir = tmp_path / "acme" / "test.local"
assert result["cert"] == str(cert_dir / "fullchain.cer")
assert result["key"] == str(cert_dir / "test.local.key")
assert (cert_dir / "fullchain.cer").is_file()
assert (cert_dir / "test.local.key").is_file()
def test_generate_idempotent_skips_existing(self, tmp_path):
cert_dir = tmp_path / "acme" / "test.local"
cert_dir.mkdir(parents=True)
(cert_dir / "fullchain.cer").write_text("dummy-cert")
(cert_dir / "test.local.key").write_text("dummy-key")
with patch("daemon.handlers.acme._ACME_HOME", tmp_path / "acme"):
result = generate_self_signed(None, {"domain": "test.local"})
assert result["generated"] is False
def test_generate_partial_existing(self, tmp_path):
cert_dir = tmp_path / "acme" / "test.local"
cert_dir.mkdir(parents=True)
(cert_dir / "fullchain.cer").write_text("dummy-cert")
# key missing -> should regenerate
with patch("daemon.handlers.acme._ACME_HOME", tmp_path / "acme"):
result = generate_self_signed(None, {"domain": "test.local"})
assert result["generated"] is True
def test_generate_custom_days(self, tmp_path):
with (
patch("daemon.handlers.acme._ACME_HOME", tmp_path / "acme"),
patch("subprocess.run") as mock_run,
):
def _create_files(*args, **kwargs):
cert_dir = tmp_path / "acme" / "test.local"
cert_dir.mkdir(parents=True, exist_ok=True)
(cert_dir / "fullchain.cer").touch()
(cert_dir / "test.local.key").touch()
return Path("")
mock_run.side_effect = _create_files
generate_self_signed(None, {"domain": "test.local", "days": 730})
args = mock_run.call_args[0][0]
assert "-days" in args
idx = args.index("-days")
assert args[idx + 1] == "730"
cert_dir = tmp_path / "acme" / "test.local"
if (cert_dir / "fullchain.cer").is_file():
assert cert_dir.is_dir()
def test_generate_creates_directory(self, tmp_path):
with patch("daemon.handlers.acme._ACME_HOME", tmp_path / "acme"):
generate_self_signed(None, {"domain": "test.local"})
assert (tmp_path / "acme" / "test.local").is_dir()
def test_generate_requires_domain(self):
with pytest.raises(ValueError, match="domain"):
generate_self_signed(None, {"foo": "bar"})
def test_generate_requires_body(self):
with pytest.raises(ValueError, match="body"):
generate_self_signed(None, None)
+393
View File
@@ -0,0 +1,393 @@
"""Tests for daemon/handlers/network.py — handler endpoint logic."""
from pathlib import Path
from unittest.mock import patch
import pytest
from daemon.handlers.network import (
apply_all,
get_infer_dhcp_ranges,
get_infer_zones,
get_interface,
get_interfaces,
reload_interface,
save_interface,
set_sysctl,
)
from lib import network as _net
@pytest.fixture
def tmp_network(tmp_path):
orig_config = _net.CONFIG_FILE
orig_data = _net.DATA_DIR
_net.CONFIG_FILE = tmp_path / "config" / "network" / "config.json"
_net.DATA_DIR = tmp_path / "data" / "networkd"
yield tmp_path
_net.CONFIG_FILE = orig_config
_net.DATA_DIR = orig_data
# =================================================================
# TF-12: Handler tests
# =================================================================
class TestSaveInterface:
def test_save_interface_saves_config(self, tmp_network):
with (
patch("daemon.handlers.network.run") as mock_run,
patch(
"daemon.handlers.network.DATA_DIR", tmp_network / "data" / "networkd"
),
):
mock_run.return_value = "1: eth0 ethernet routable\n State: routable\n"
save_interface(
None,
{"name": "eth0", "addresses": ["10.0.0.1/24"], "gateway": "10.0.0.254"},
)
cfg = _net.get_config()
assert "eth0" in cfg["interfaces"]
assert cfg["interfaces"]["eth0"]["addresses"] == ["10.0.0.1/24"]
def test_save_interface_renders_file(self, tmp_network):
with (
patch("daemon.handlers.network.run") as mock_run,
patch(
"daemon.handlers.network.DATA_DIR", tmp_network / "data" / "networkd"
),
):
mock_run.return_value = "1: eth0 ethernet routable\n State: routable\n"
save_interface(
None,
{"name": "eth0", "addresses": ["10.0.0.1/24"]},
)
data_dir = tmp_network / "data" / "networkd"
assert (data_dir / "99-eth0.network").exists()
content = (data_dir / "99-eth0.network").read_text()
assert "Name=eth0" in content
assert "Address=10.0.0.1/24" in content
def test_save_interface_requires_name(self, tmp_network):
with pytest.raises(ValueError, match="name"):
save_interface(None, {"addresses": ["10.0.0.1/24"]})
def test_save_interface_requires_body(self):
with pytest.raises(ValueError, match="body"):
save_interface(None, None)
def test_save_interface_rejects_invalid_name(self, tmp_network):
invalid_names = [
"../../etc/passwd",
"eth 0",
"",
"eth/0",
"eth..0",
]
for invalid in invalid_names:
with (
patch("daemon.handlers.network.run") as mock_run,
patch(
"daemon.handlers.network.DATA_DIR",
tmp_network / "data" / "networkd",
),
):
mock_run.return_value = (
"1: eth0 ethernet routable\n State: routable\n"
)
with pytest.raises(ValueError, match="name"):
save_interface(None, {"name": invalid})
class TestReloadInterfaceValidation:
def test_reload_interface_rejects_invalid_name(self):
invalid_names = [
"../../etc/passwd",
"eth 0",
"",
"eth/0",
]
for invalid in invalid_names:
with pytest.raises(ValueError, match="name"):
reload_interface(None, {"name": invalid})
class TestGetInterfaceValidation:
def test_get_interface_rejects_invalid_name(self, tmp_network):
invalid_names = [
"../../etc/passwd",
"eth 0",
"",
"eth/0",
]
for invalid in invalid_names:
with pytest.raises(ValueError, match="name"):
get_interface(None, {"name": invalid})
class TestReloadInterface:
def test_reload_interface(self):
with patch("daemon.handlers.network.run") as mock_run:
mock_run.return_value = "reloaded"
result = reload_interface(None, {"name": "eth0"})
assert result["name"] == "eth0"
assert result["reloaded"] is True
mock_run.assert_called_with(
["networkctl", "reconfigure", "eth0"], sudo=True
)
def test_reload_interface_requires_name(self):
with pytest.raises(ValueError, match="name"):
reload_interface(None, None)
def test_reload_interface_missing_name(self):
with pytest.raises(ValueError, match="name"):
reload_interface(None, {})
class TestApplyAll:
def test_apply_all_generates_files(self, tmp_network):
_net.save_config(
{
"interfaces": {
"eth0": {"addresses": ["10.0.0.1/24"]},
"eth1": {"addresses": ["192.168.1.1/24"]},
}
}
)
with (
patch("daemon.handlers.network.generate_network_files") as mock_gen,
patch("daemon.handlers.network.run") as mock_run,
patch("daemon.handlers.network.collect_upstream_dns", return_value=[]),
):
mock_gen.return_value = {
"generated": [_net.DATA_DIR / "99-eth0.network"],
"cleaned": [],
}
mock_run.return_value = ""
result = apply_all(None, None)
assert result["applied"] == 1
assert len(result["files"]) == 1
def test_apply_all_syncs_dns(self, tmp_network):
_net.save_config(
{
"interfaces": {
"eth0": {
"dhcp": "ipv4",
"dns": ["8.8.8.8", "192.168.1.1"],
}
}
}
)
with (
patch("daemon.handlers.network.generate_network_files") as mock_gen,
patch("daemon.handlers.network.run") as mock_run,
patch("daemon.handlers.network.set_upstreams") as mock_set_upstreams,
patch("daemon.handlers.network.collect_upstream_dns") as mock_collect,
):
mock_gen.return_value = {
"generated": [_net.DATA_DIR / "99-eth0.network"],
"cleaned": [],
}
mock_run.return_value = ""
mock_collect.return_value = ["8.8.8.8"]
apply_all(None, None)
mock_set_upstreams.assert_called_once_with(["8.8.8.8"])
def test_apply_all_handles_dns_sync_failure(self, tmp_network):
_net.save_config({"interfaces": {"eth0": {"dns": ["8.8.8.8"]}}})
with (
patch("daemon.handlers.network.generate_network_files") as mock_gen,
patch("daemon.handlers.network.run") as mock_run,
patch(
"daemon.handlers.network.set_upstreams",
side_effect=RuntimeError("fail"),
),
patch("daemon.handlers.network.collect_upstream_dns") as mock_collect,
):
mock_gen.return_value = {
"generated": [_net.DATA_DIR / "99-eth0.network"],
"cleaned": [],
}
mock_run.return_value = ""
mock_collect.return_value = ["8.8.8.8"]
result = apply_all(None, None)
assert "applied" in result
def test_apply_all_removes_stale_system_files(self, tmp_network):
_net.save_config({"interfaces": {"eth0": {"addresses": ["10.0.0.1/24"]}}})
sys_dir = tmp_network / "etc" / "systemd" / "network"
sys_dir.mkdir(parents=True)
(sys_dir / "stale-file.network").write_text("[Match]\nName=old\n")
with (
patch("daemon.handlers.network.generate_network_files") as mock_gen,
patch("daemon.handlers.network.run") as mock_run,
patch("daemon.handlers.network.collect_upstream_dns", return_value=[]),
):
mock_gen.return_value = {
"generated": [_net.DATA_DIR / "99-eth0.network"],
"cleaned": [],
}
mock_run.return_value = ""
class FakePath:
def __init__(self, p="/etc/systemd/network") -> None:
self._p = sys_dir if p == "/etc/systemd/network" else Path(p)
def exists(self):
return True
def iterdir(self):
return iter(self._p.iterdir())
def __truediv__(self, other):
return self._p / other
def mkdir(self, *args, **kwargs) -> None:
self._p.mkdir(parents=True, exist_ok=True)
with patch("daemon.handlers.network.Path", FakePath):
apply_all(None, None)
assert (sys_dir / "stale-file.network").exists()
class TestGetInterfaces:
def test_get_interfaces_returns_merged_data(self, tmp_network):
_net.save_config({"interfaces": {"eth0": {"addresses": ["10.0.0.1/24"]}}})
with patch("daemon.handlers.network.run") as mock_run:
mock_run.return_value = (
"1: eth0 ethernet 10.0.0.0/24 routable\n"
" State: routable\n"
" Addresses: 10.0.0.1/24,\n"
)
result = get_interfaces(None, None)
assert "interfaces" in result
assert "eth0" in result["interfaces"]
assert "config" in result["interfaces"]["eth0"]
assert "runtime" in result["interfaces"]["eth0"]
def test_get_interfaces_handles_networkctl_failure(self, tmp_network):
_net.save_config({"interfaces": {"eth0": {"addresses": ["10.0.0.1/24"]}}})
with patch(
"daemon.handlers.network.run", side_effect=RuntimeError("no networkctl")
):
result = get_interfaces(None, None)
assert "interfaces" in result
assert "eth0" in result["interfaces"]
class TestGetInterface:
def test_get_single_interface(self, tmp_network):
_net.save_config({"interfaces": {"eth0": {"addresses": ["10.0.0.1/24"]}}})
with patch("daemon.handlers.network.run") as mock_run:
mock_run.return_value = "1: eth0 ethernet\n State: routable\n"
result = get_interface(None, {"name": "eth0"})
assert result["name"] == "eth0"
assert "config" in result
assert result["config"]["addresses"] == ["10.0.0.1/24"]
def test_get_interface_not_found(self, tmp_network):
_net.save_config({"interfaces": {}})
with pytest.raises(Exception, match="not found"):
get_interface(None, {"name": "eth0"})
def test_get_interface_requires_name(self):
with pytest.raises(ValueError, match="required"):
get_interface(None, None)
class TestInferEndpoints:
def test_infer_dhcp_ranges_endpoint(self, tmp_network):
_net.save_config(
{
"interfaces": {
"eth0": {"addresses": [{"address": "192.168.1.1/24"}]},
}
}
)
result = get_infer_dhcp_ranges(None, None)
assert "ranges" in result
assert "eth0" in result["ranges"]
def test_infer_zones_endpoint(self, tmp_network):
_net.save_config(
{
"interfaces": {
"wg0": {},
"eth0": {"addresses": [{"address": "192.168.1.1/24"}]},
}
}
)
result = get_infer_zones(None, None)
assert "zones" in result
assert result["zones"]["wg0"] == "wan"
assert result["zones"]["eth0"] == "lan"
class TestSetSysctl:
def test_set_sysctl_success(self):
with (
patch("daemon.handlers.network.run") as mock_run,
patch.object(Path, "read_text", return_value="1"),
):
mock_run.return_value = "" # sysctl -w call
result = set_sysctl(None, {"name": "net.ipv4.ip_forward", "value": "1"})
assert result["name"] == "net.ipv4.ip_forward"
assert result["value"] == "1"
assert mock_run.call_count == 1
assert mock_run.call_args_list[0].args == (
["sysctl", "-w", "net.ipv4.ip_forward=1"],
)
assert mock_run.call_args_list[0].kwargs == {"sudo": True}
def test_set_sysctl_rejects_slash_in_name(self):
with pytest.raises(ValueError, match="valid sysctl key"):
set_sysctl(None, {"name": "net.ipv4/ip_forward", "value": "1"})
def test_set_sysctl_rejects_double_dot(self):
with pytest.raises(ValueError, match="valid sysctl key"):
set_sysctl(None, {"name": "net..ipv4", "value": "1"})
def test_set_sysctl_requires_name(self):
with pytest.raises(ValueError, match="name"):
set_sysctl(None, {"value": "1"})
def test_set_sysctl_requires_value(self):
with pytest.raises(ValueError, match="value"):
set_sysctl(None, {"name": "net.ipv4.ip_forward"})
def test_set_sysctl_requires_body(self):
with pytest.raises(ValueError, match="body"):
set_sysctl(None, None)
def test_set_sysctl_verify_failure(self):
with (
patch("daemon.handlers.network.run") as mock_run,
patch.object(Path, "read_text", return_value="0"),
):
mock_run.return_value = "" # sysctl -w call succeeds
with pytest.raises(RuntimeError, match="verify failed"):
set_sysctl(None, {"name": "net.ipv4.ip_forward", "value": "1"})
+145
View File
@@ -0,0 +1,145 @@
"""Tests that daemon.iface stays in sync with registered server routes.
Verifies a two-way contract:
1. Every iface constant has a matching handler registered.
2. Every registered handler has a matching iface constant.
Run with: pytest tests/test_iface_sync.py -v
"""
from collections import defaultdict
import pytest
@pytest.fixture(autouse=True)
def _load_handlers():
"""Load all handler modules so registry is populated."""
# Import server to get registry, then load handlers
from daemon import server
# Force route registration
server._register_routes()
@pytest.fixture()
def registry():
from daemon import server
return server.registry
@pytest.fixture()
def iface_module():
import daemon.iface as iface
return iface
def _get_iface_pairs(iface_module):
"""Extract all (method, path) pairs from iface module."""
iface = iface_module
return {
name: val
for name, val in iface.__dict__.items()
if isinstance(val, tuple) and len(val) == 2 and isinstance(val[0], str)
}
def _get_registered_routes(registry):
"""Extract all (METHOD, path) keys from the registry."""
return {(method.upper(), path) for (method, path) in registry._routes}
class TestIfaceSync:
"""Verify iface constants match registered routes."""
def test_iface_constants_non_empty(self, iface_module):
pairs = _get_iface_pairs(iface_module)
assert len(pairs) >= 50, f"Expected many iface constants, got {len(pairs)}"
def test_iface_constants_have_registered_handlers(self, registry, iface_module):
"""Every iface constant should map to a registered route."""
registered = _get_registered_routes(registry)
iface_pairs = _get_iface_pairs(iface_module)
# These 5 routes go through add_route() in create_app(), not @registry.register
add_route_paths = {
"/health",
"/status/all",
"/status/refresh",
"/ws",
"/batch",
}
missing = []
for name, (method, path) in iface_pairs.items():
key = (method.upper(), path)
if path not in add_route_paths and key not in registered:
missing.append(
(
name,
{
"method": method,
"path": path,
},
)
)
if missing:
detail = "\n".join(f" {name}: {pair}" for name, pair in missing)
pytest.fail(
f"{len(missing)} iface constant(s) have no matching handler:\n{detail}"
)
def test_registered_routes_have_iface_constants(self, registry, iface_module):
"""Every registered route should have a matching iface constant."""
registered = _get_registered_routes(registry)
iface_pairs = _get_iface_pairs(iface_module)
iface_keys = set(iface_pairs.values())
missing = registered - iface_keys
if missing:
detail = "\n".join(f" {method} {path}" for method, path in sorted(missing))
pytest.fail(
f"{len(missing)} registered route(s) have no matching iface constant:\n{detail}"
)
def test_no_duplicate_iface_constants(self, iface_module):
"""All iface constants should have unique (method, path) pairs."""
iface_pairs = _get_iface_pairs(iface_module)
seen = defaultdict(list)
for name, val in iface_pairs.items():
seen[val].append(name)
dupes = {pair: names for pair, names in seen.items() if len(names) > 1}
assert not dupes, "Duplicate iface constants:\n" + "".join(
f" {pair}: {names}\n" for pair, names in dupes.items()
)
class TestIfaceFormat:
"""Verify iface constants follow the expected format."""
def test_all_constants_are_tuples_of_str(self, iface_module):
iface_pairs = _get_iface_pairs(iface_module)
for name, val in iface_pairs.items():
assert isinstance(val, tuple), f"{name} should be a tuple"
assert len(val) == 2, f"{name} should have length 2"
assert isinstance(val[0], str), f"{name} method should be a string"
assert isinstance(val[1], str), f"{name} path should be a string"
def test_all_constants_have_uppercase_methods(self, iface_module):
iface_pairs = _get_iface_pairs(iface_module)
valid_methods = {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}
for name, val in iface_pairs.items():
assert val[0].upper() in valid_methods, (
f"{name} has invalid method: {val[0]}"
f"should be one of {valid_methods}"
)
def test_all_constants_have_leading_slash_path(self, iface_module):
iface_pairs = _get_iface_pairs(iface_module)
for name, val in iface_pairs.items():
assert val[1].startswith("/"), f"{name} path should start with /: {val[1]}"
File diff suppressed because it is too large Load Diff
+338
View File
@@ -0,0 +1,338 @@
"""Integration tests for networkd interactions with other subsystems.
Tests TF-8 (DNS upstream sync), TF-9 (DHCP range inference), TF-10 (zone inference).
"""
import json
from pathlib import Path
from unittest.mock import patch
import pytest
from lib import network as _net
@pytest.fixture
def tmp_network(tmp_path):
orig_config = _net.CONFIG_FILE
orig_data = _net.DATA_DIR
_net.CONFIG_FILE = tmp_path / "config" / "network" / "config.json"
_net.DATA_DIR = tmp_path / "data" / "networkd"
yield tmp_path
_net.CONFIG_FILE = orig_config
_net.DATA_DIR = orig_data
# =================================================================
# TF-13: Integration tests — DNS upstream sync
# =================================================================
class TestDnsUpstreamIntegration:
"""collect_upstream_dns + set_upstreams integration."""
def test_wan_dns_becomes_dnsmasq_upstream(self, tmp_network):
"""WAN interface with public DNS should produce upstream list."""
cfg = _net.get_config()
cfg["interfaces"] = {
"eth0": {
"dhcp": "ipv4",
"dns": ["8.8.8.8", "1.1.1.1"],
},
"lan0": {
"addresses": [{"address": "192.168.1.1/24"}],
"dns": ["127.0.0.1"],
},
}
_net.save_config(cfg)
upstreams = _net.collect_upstream_dns(cfg)
assert "8.8.8.8" in upstreams
assert "1.1.1.1" in upstreams
assert "127.0.0.1" not in upstreams
def test_all_dns_local_yields_empty(self, tmp_network):
"""When all DNS servers are local, no upstreams."""
cfg = {
"interfaces": {
"lan0": {
"addresses": [{"address": "192.168.1.1/24"}],
"dns": ["127.0.0.1", "192.168.1.1"],
"ipv6_dns": ["fe80::1"],
}
}
}
_net.save_config(cfg)
upstreams = _net.collect_upstream_dns(cfg)
assert upstreams == []
def test_mixed_v4_v6_upstreams(self, tmp_network):
"""Collects both IPv4 and IPv6 public DNS."""
cfg = {
"interfaces": {
"eth0": {
"dns": ["8.8.8.8"],
"ipv6_dns": ["2001:4860:4860::8888"],
}
}
}
_net.save_config(cfg)
upstreams = _net.collect_upstream_dns(cfg)
assert "8.8.8.8" in upstreams
assert "2001:4860:4860::8888" in upstreams
def test_upstream_dns_after_generate_files(self, tmp_network):
"""Full flow: save config -> generate files -> collect upstreams."""
cfg = {
"interfaces": {
"eth0": {
"dhcp": "ipv4",
"dns": ["8.8.8.8", "192.168.1.1"],
"gateway": "10.0.0.254",
}
}
}
_net.save_config(cfg)
result = _net.generate_network_files(cfg)
assert len(result["generated"]) == 1
upstreams = _net.collect_upstream_dns(cfg)
assert "8.8.8.8" in upstreams
assert "192.168.1.1" not in upstreams
# =================================================================
# TF-13: Integration tests — DHCP range inference
# =================================================================
class TestDhcpRangesIntegration:
"""infer_dhcp_ranges for multiple interface scenarios."""
def test_multi_interface_ranges(self, tmp_network):
"""Each interface with static IP gets its own range."""
cfg = {
"interfaces": {
"lan1": {
"addresses": [{"address": "192.168.1.1/24"}],
},
"lan2": {
"addresses": [{"address": "10.10.0.1/16"}],
},
"wan0": {
"dhcp": "ipv4",
},
}
}
_net.save_config(cfg)
ranges = _net.infer_dhcp_ranges(cfg)
assert "lan1" in ranges
assert "lan2" in ranges
assert "wan0" not in ranges
assert ranges["lan1"]["start"] == "192.168.1.100"
assert ranges["lan1"]["end"] == "192.168.1.200"
assert ranges["lan2"]["start"] == "10.10.0.100"
assert ranges["lan2"]["end"] == "10.10.0.200"
def test_generate_then_infer(self, tmp_network):
"""End-to-end: save, generate, infer ranges."""
cfg = {
"interfaces": {
"eth0": {
"addresses": [{"address": "172.16.0.1/24"}],
"gateway": "172.16.0.254",
"dns": ["8.8.8.8"],
}
}
}
_net.save_config(cfg)
_net.generate_network_files(cfg)
ranges = _net.infer_dhcp_ranges(cfg)
assert "eth0" in ranges
assert ranges["eth0"]["subnet"] == "172.16.0.0"
assert ranges["eth0"]["prefix"] == 24
def test_ranges_cross_reference_with_zones(self, tmp_network):
"""DHCP ranges for LAN interfaces correlate with zone inference."""
cfg = {
"interfaces": {
"lan0": {
"addresses": [{"address": "192.168.1.1/24"}],
},
"wan0": {
"dhcp": "ipv4",
},
}
}
_net.save_config(cfg)
ranges = _net.infer_dhcp_ranges(cfg)
zones = _net.infer_zones(cfg)
assert "lan0" in ranges
assert zones["lan0"] == "lan"
assert "wan0" not in ranges
assert zones["wan0"] == "wan"
# =================================================================
# TF-13: Integration tests — zone inference with networkd config
# =================================================================
class TestZonesIntegration:
"""infer_zones with realistic networkd configurations."""
def test_typical_router_setup(self, tmp_network):
"""WAN (DHCP), LAN (static), WG (WireGuard) zones."""
cfg = {
"interfaces": {
"eth0": {
"dhcp": "ipv4",
},
"eth1": {
"addresses": [{"address": "192.168.1.1/24"}],
"gateway": "192.168.1.254",
},
"wg0": {
"addresses": [{"address": "10.137.0.1/24"}],
},
"br-mgmt": {
"addresses": [{"address": "10.0.0.1/24"}],
"routes": [
{"destination": "0.0.0.0/0", "gateway": "10.0.0.254"},
],
},
}
}
_net.save_config(cfg)
zones = _net.infer_zones(cfg)
assert zones["eth0"] == "wan"
assert zones["eth1"] == "lan"
assert zones["wg0"] == "wan"
assert zones["br-mgmt"] == "management"
def test_zone_inference_after_generate(self, tmp_network):
"""Zone inference works after generate_network_files."""
cfg = {
"interfaces": {
"wan0": {"dhcp": "ipv4"},
"lan0": {"addresses": [{"address": "192.168.10.1/24"}]},
}
}
_net.save_config(cfg)
_net.generate_network_files(cfg)
zones = _net.infer_zones(cfg)
assert zones["wan0"] == "wan"
assert zones["lan0"] == "lan"
def test_full_pipeline(self, tmp_network):
"""Full pipeline: config -> generate -> DNS -> ranges -> zones."""
cfg = {
"interfaces": {
"eth0": {
"dhcp": "ipv4",
"dns": ["8.8.8.8", "1.1.1.1", "192.168.1.1"],
},
"eth1": {
"addresses": [{"address": "192.168.1.1/24"}],
"dns": ["127.0.0.1"],
},
"wg0": {
"addresses": [{"address": "10.137.0.1/24"}],
},
}
}
_net.save_config(cfg)
gen_result = _net.generate_network_files(cfg)
assert len(gen_result["generated"]) == 3
upstreams = _net.collect_upstream_dns(cfg)
assert "8.8.8.8" in upstreams
assert "1.1.1.1" in upstreams
assert "192.168.1.1" not in upstreams
assert "127.0.0.1" not in upstreams
ranges = _net.infer_dhcp_ranges(cfg)
assert "eth1" in ranges
assert "eth0" not in ranges
# wg0 has a static address so it also gets a candidate range
assert "wg0" in ranges
zones = _net.infer_zones(cfg)
assert zones["eth0"] == "wan"
assert zones["eth1"] == "lan"
assert zones["wg0"] == "wan"
# =================================================================
# TF-11: state.py parser dedup verification
# =================================================================
class TestStateParserDedup:
"""Verify lib/state.py uses lib.network.parse_networkctl_status()."""
def test_state_uses_network_parser(self):
"""The networkd collector in state.py should import from lib.network."""
import lib.state as _state
source = Path(_state.__file__).read_text()
assert "from lib.network import parse_networkctl_status" in source
assert "parse_networkctl_status" in source
def test_networkd_collector_returns_correct_format(self):
"""_collect_networkd should return interfaces dict + timestamp."""
import lib.state as _state
with patch("lib.state.run") as mock_run:
mock_run.return_value = json.dumps(
{
"Interfaces": [
{
"Name": "eth0",
"Type": "ether",
"OperationalState": "routable",
"Addresses": [
{
"Family": 2,
"Address": [10, 0, 0, 1],
"PrefixLength": 24,
}
],
"DNS": [
{"Family": 2, "Address": [8, 8, 8, 8]},
],
"Routes": [
{
"Family": 2,
"Destination": [0, 0, 0, 0],
"DestinationPrefixLength": 0,
"Gateway": [10, 0, 0, 254],
}
],
}
]
}
)
result = _state._collect_networkd()
assert "interfaces" in result
assert "timestamp" in result
assert "eth0" in result["interfaces"]
assert "10.0.0.1/24" in result["interfaces"]["eth0"]["addresses"]
def test_networkd_collector_handles_failure(self):
"""_collect_networkd returns empty interfaces on error."""
import lib.state as _state
with patch("lib.state.run", side_effect=RuntimeError("no networkctl")):
result = _state._collect_networkd()
assert result["interfaces"] == {}
assert "timestamp" in result
+98 -83
View File
@@ -1,3 +1,5 @@
import os
from pathlib import Path
from unittest.mock import patch
import pytest
@@ -5,93 +7,106 @@ import pytest
@pytest.fixture
def client():
from webui.server import app
app.config["TESTING"] = True
return app.test_client()
class TestTemplateFilters:
@pytest.fixture
def env(self):
with patch("lib.logging.setup_logging"):
from webui.server import app
return app.jinja_env
def test_timestamp_filter_valid(self, env):
result = env.filters["timestamp"]("2026-04-01T12:00:00Z")
assert "2026-04-01" in result
def test_timestamp_filter_empty(self, env):
assert env.filters["timestamp"]("") == ""
assert env.filters["timestamp"](None) == ""
def test_timestamp_filter_invalid(self, env):
result = env.filters["timestamp"]("not-a-date")
assert result == "not-a-date"
def test_bytes_filter_zero(self, env):
assert env.filters["bytes"](0) == "0.0 B"
def test_bytes_filter_kb(self, env):
result = env.filters["bytes"](1536)
assert "KB" in result
def test_bytes_filter_mb(self, env):
result = env.filters["bytes"](1500000)
assert "MB" in result
def test_bytes_filter_negative(self, env):
assert env.filters["bytes"](-1) == "0 B"
def test_bytes_filter_invalid(self, env):
assert env.filters["bytes"]("not-a-number") == "not-a-number"
def test_duration_filter_zero(self, env):
assert env.filters["duration"](0) == "0s"
def test_duration_filter_seconds(self, env):
assert env.filters["duration"](65) == "1m 5s"
def test_duration_filter_hours(self, env):
result = env.filters["duration"](3661)
assert "1h" in result
def test_duration_filter_days(self, env):
result = env.filters["duration"](90000)
assert "1d" in result
def test_duration_filter_invalid(self, env):
assert env.filters["duration"]("bad") == "bad"
def test_json_pretty_filter(self, env):
result = env.filters["json_pretty"]({"key": "value"})
assert '{"key": "value"}' in result or "key" in result
app.config["TESTING"] = True
return app.test_client()
class TestSafelyHelper:
def test_returns_result(self):
from webui.server import _safely
class TestSPARoutes:
def test_root_serves_index(self, client):
resp = client.get("/")
assert resp.status_code == 200
assert b'id="app"' in resp.data
result = _safely(lambda: 42)
assert result == 42
def test_returns_default_on_exception(self):
from webui.server import _safely
result = _safely(lambda: 1 / 0, default=None)
assert result is None
def test_returns_custom_default(self):
from webui.server import _safely
result = _safely(lambda: 1 / 0, default="fallback")
assert result == "fallback"
class TestPageRoutes:
@patch("webui.server.get")
def test_dashboard_no_crash(self, mock_get, client):
mock_get.return_value = {}
def test_spa_catch_all_serves_index(self, client):
resp = client.get("/dashboard")
assert resp.status_code == 200
assert b"index.html" in resp.data or b'id="app"' in resp.data
def test_spa_catch_all_other_page(self, client):
resp = client.get("/zones")
assert resp.status_code == 200
def test_api_routes_still_work(self, client):
resp = client.get("/api/firewall/zones")
assert resp.status_code in (200, 502, 503)
class TestWsUrlGeneration:
def test_ws_url_ipv4_host(self, client):
resp = client.get("/", headers={"Host": "192.168.1.1:9090"})
assert b"ws://192.168.1.1:9090/ws" in resp.data
def test_ws_url_ipv6_host(self, client):
resp = client.get("/", headers={"Host": "[::1]:9090"})
assert b"ws://[::1]:9090/ws" in resp.data
class TestApiStatusAll:
@patch("webui.server.get")
def test_success(self, mock_get, client):
mock_get.return_value = {"firewall": {"zones": {}}, "dnsmasq": {}}
resp = client.get("/api/status/all")
assert resp.status_code == 200
data = resp.get_json()
assert data["ok"] is True
assert "firewall" in data["data"]
@patch("webui.server.get")
def test_error(self, mock_get, client):
mock_get.side_effect = RuntimeError("connection refused")
resp = client.get("/api/status/all")
assert resp.status_code == 500
data = resp.get_json()
assert data["ok"] is False
class TestBlueprintsRegistered:
def test_all_blueprints_registered(self, client):
from webui.server import BLUEPRINTS
assert len(BLUEPRINTS) == 7
names = [name for name, _ in BLUEPRINTS]
assert "firewall" in names
assert "network" in names
assert "dhcp" in names
assert "proxy" in names
assert "certs" in names
assert "wireguard" in names
assert "logs" in names
class TestGroupWriteHandler:
def test_creates_file_with_group_write(self, tmp_path: Path) -> None:
"""GroupWriteHandler creates new log files with group-write (0o664)."""
import contextlib
from logging.handlers import RotatingFileHandler
log_file = tmp_path / "test.log"
old = os.umask(0o022)
try:
class GroupWriteHandler(RotatingFileHandler):
def _open(self):
with contextlib.suppress(OSError):
os.chmod(self.baseFilename, 0o664)
saved = os.umask(0o002)
try:
fd = os.open(
self.baseFilename,
os.O_WRONLY | os.O_CREAT | os.O_APPEND,
0o664,
)
finally:
os.umask(saved)
return os.fdopen(fd, "a", errors="backslashreplace")
fh = GroupWriteHandler(str(log_file))
fh.close()
finally:
os.umask(old)
mode = os.stat(log_file).st_mode & 0o777
assert mode == 0o664, f"Expected 0o664, got {oct(mode)}"
+97
View File
@@ -56,6 +56,49 @@ class TestCollectAll:
assert "interfaces" in result
assert "timestamp" in result
@patch("lib.state.run")
def test_collect_firewall_vlan_ips_populated(self, mock_run):
"""VLAN interfaces with @suffix in ip addr output get their IPs collected."""
from lib.state import _collect_firewall
def run_side(args, **kwargs):
if "--get-zones" in args:
return "public\ninternal"
if "--get-active-zones" in args:
return "public\n eth0\ninternal eth0.100"
if "--get-services" in args:
return "ssh http"
if "ip" in args[0]:
if "link" in args:
return (
"1: lo: <LOOPBACK> mtu 65536\n"
"2: eth0: <UP> mtu 1500 link/ether aa:bb\n"
"3: eth0.100@eth0: <UP> mtu 1500 link/ether aa:bb\n"
)
if "addr" in args:
return (
"2: eth0 inet 192.168.1.1/24\n"
"3: eth0.100@if100 inet 10.0.0.1/24\n"
)
return ""
if "--list-all" in args:
return (
"target: default\ninterfaces: eth0\nsources: "
"services: \nports: \nprotocols: \nforward-ports: "
"masquerade: no\nics: no\nrich-rules: "
"icmp-blocks: \nmodule: \n"
)
return ""
mock_run.side_effect = run_side
result = _collect_firewall()
vlan_iface = next(
(i for i in result["interfaces"] if i["name"] == "eth0.100"), None
)
assert vlan_iface is not None, "VLAN interface should be present"
assert vlan_iface["ips"], "VLAN interface should have collected IPs"
assert "10.0.0.1/24" in vlan_iface["ips"]
@patch("lib.state.run_proc")
def test_collect_dnsmasq_returns_dict(self, mock_proc):
from unittest.mock import Mock
@@ -78,3 +121,57 @@ class TestCollectFailure:
s.set("firewall", None) # simulates failure
assert s.get("firewall") is None
assert s.is_populated() is False
class TestStateVersions:
def test_version_starts_at_zero(self):
s = State()
versions = s.get_versions()
assert versions["firewall"] == 0
assert versions["dnsmasq"] == 0
def test_bump_increments_version(self):
s = State()
assert s.get_versions()["firewall"] == 0
s.bump("firewall")
assert s.get_versions()["firewall"] == 1
def test_bump_unknown_subsystem_noop(self):
s = State()
versions = s.get_versions()
s.bump("nonexistent")
assert versions == s.get_versions()
def test_get_updated_versions_first_call_empty(self):
s = State()
s.bump("firewall")
updated = s.get_updated_versions()
assert updated == {}
assert s.get_updated_versions() == {}
def test_get_updated_versions_detects_change(self):
s = State()
_ = s.get_updated_versions() # snapshot
s.bump("firewall")
updated = s.get_updated_versions()
assert updated["firewall"] == 1
def test_broadcast_maintains_snapshot(self):
s = State()
s.bump("firewall")
s.bump("dnsmasq")
_ = s.get_updated_versions() # snapshot at fw=1, dm=1
s.bump("wireguard")
updated = s.get_updated_versions()
assert updated["wireguard"] == 1
assert s.get_updated_versions() == {}
def test_multiple_bumps_aggregate(self):
s = State()
_ = s.get_updated_versions()
s.bump("firewall")
s.bump("firewall")
s.bump("dnsmasq")
updated = s.get_updated_versions()
assert updated["firewall"] == 2
assert updated["dnsmasq"] == 1
+76 -8
View File
@@ -8,6 +8,16 @@ import logging
from flask import Blueprint, request
from daemon.client import BadRequest, NotFound, delete, get, post
from daemon.iface import (
DELETE_ACME_REMOVE,
GET_ACME_INFO,
GET_ACME_ISSUE_STATUS,
GET_ACME_LIST,
POST_ACME_EMAIL,
POST_ACME_ISSUE,
POST_ACME_RENEW,
POST_ACME_VALIDATE,
)
from webui.api.common import _error, _ok
logger = logging.getLogger(__name__)
@@ -16,8 +26,13 @@ bp = Blueprint("certs", __name__)
@bp.route("/list", methods=["GET"])
def list_certs_bp():
"""GET /api/certs/list — list all managed ACME certificates.
Returns:
Response containing the list of certificates or an error message.
"""
try:
return _ok(get("/acme/list"))
return _ok(get(GET_ACME_LIST))
except RuntimeError as exc:
logger.error("Failed to list certificates: %s", exc)
return _error(str(exc), 500)
@@ -25,8 +40,16 @@ def list_certs_bp():
@bp.route("/<domain>", methods=["GET"])
def cert_details(domain: str):
"""GET /api/certs/<domain> — get details for a specific certificate.
Args:
domain: Domain name to look up.
Returns:
Response containing certificate info or an error message.
"""
try:
return _ok(get("/acme/info", {"domain": domain}))
return _ok(get(GET_ACME_INFO, {"domain": domain}))
except NotFound as exc:
logger.info("Cert for '%s' not found: %s", domain, exc)
return _error(str(exc), 404)
@@ -37,12 +60,19 @@ def cert_details(domain: str):
@bp.route("/validate", methods=["POST"])
def validate():
"""POST /api/certs/validate — run pre-flight checks for certificate issuance.
Expects JSON body with ``{``domain``}``.
Returns:
Response containing validation results or an error message.
"""
body = request.get_json(silent=True) or {}
domain = body.get("domain", "").strip()
if not domain:
return _error("'domain' is required", 400)
try:
result = post("/acme/validate", {"domain": domain})
result = post(POST_ACME_VALIDATE, {"domain": domain})
return _ok(result)
except BadRequest as exc:
logger.info("Validation rejected: %s", exc)
@@ -54,6 +84,13 @@ def validate():
@bp.route("/issue/start", methods=["POST"])
def issue_start():
"""POST /api/certs/issue/start — create a new certificate issuance request.
Expects JSON body with ``{``domain``}``; optional ``email`` and ``webroot``.
Returns:
Response containing an issuance request ID or an error message.
"""
body = request.get_json(silent=True) or {}
domain = body.get("domain", "").strip()
if not domain:
@@ -63,7 +100,7 @@ def issue_start():
try:
logger.info("Certificate issuance requested for '%s' via API", domain)
result = post(
"/acme/issue", {"domain": domain, "webroot": webroot, "email": email}
POST_ACME_ISSUE, {"domain": domain, "webroot": webroot, "email": email}
)
logger.info(
"Certificate issuance started for '%s' (id=%s)",
@@ -81,8 +118,16 @@ def issue_start():
@bp.route("/issue/<request_id>", methods=["GET"])
def issue_status(request_id: str):
"""GET /api/certs/issue/<request_id> — poll status of a certificate issuance request.
Args:
request_id: Issuance request identifier returned by issue_start.
Returns:
Response containing issuance status or an error message.
"""
try:
result = get("/acme/issue/status", {"id": request_id})
result = get(GET_ACME_ISSUE_STATUS, {"id": request_id})
return _ok(result)
except NotFound as exc:
logger.info("Issuance request '%s' not found: %s", request_id, exc)
@@ -94,9 +139,17 @@ def issue_status(request_id: str):
@bp.route("/<domain>/renew", methods=["POST"])
def renew_bp(domain: str):
"""POST /api/certs/<domain>/renew — renew an existing certificate.
Args:
domain: Domain name whose certificate should be renewed.
Returns:
Response confirming renewal or an error message.
"""
try:
logger.info("Certificate renewal requested for '%s' via API", domain)
post("/acme/renew", {"domain": domain})
post(POST_ACME_RENEW, {"domain": domain})
logger.info("Certificate renewed for '%s'", domain)
return _ok(None)
except BadRequest as exc:
@@ -109,8 +162,16 @@ def renew_bp(domain: str):
@bp.route("/<domain>", methods=["DELETE"])
def remove_bp(domain: str):
"""DELETE /api/certs/<domain> — remove a certificate from ACME management.
Args:
domain: Domain name whose certificate should be removed.
Returns:
Response confirming removal or an error message.
"""
try:
delete("/acme/remove", {"domain": domain})
delete(DELETE_ACME_REMOVE, {"domain": domain})
logger.info("Certificate removed for '%s' via API", domain)
return _ok(None)
except NotFound as exc:
@@ -123,12 +184,19 @@ def remove_bp(domain: str):
@bp.route("/email", methods=["POST"])
def set_email_bp():
"""POST /api/certs/email — set the ACME account email address.
Expects JSON body with ``{``email``}``.
Returns:
Response confirming the email was set or an error message.
"""
body = request.get_json(silent=True) or {}
email = body.get("email", "").strip()
if not email:
return _error("'email' is required", 400)
try:
post("/acme/email", {"email": email})
post(POST_ACME_EMAIL, {"email": email})
logger.info("ACME email set via API: %s", email)
return _ok({"email": email})
except BadRequest as exc:
+104 -12
View File
@@ -8,6 +8,20 @@ import logging
from flask import Blueprint, request
from daemon.client import BadRequest, NotFound, delete, get, patch, post
from daemon.iface import (
DELETE_DNSMASQ_DNS_RECORD_REMOVE,
DELETE_DNSMASQ_RANGES_REMOVE,
DELETE_DNSMASQ_STATIC_LEASE_REMOVE,
GET_DNSMASQ_CONFIG,
GET_DNSMASQ_LEASES,
GET_DNSMASQ_STATUS,
PATCH_DNSMASQ_CONFIG,
POST_DNSMASQ_APPLY,
POST_DNSMASQ_CONFIG,
POST_DNSMASQ_DNS_RECORD_ADD,
POST_DNSMASQ_RANGES_ADD,
POST_DNSMASQ_STATIC_LEASE_ADD,
)
from webui.api.common import _error, _ok
logger = logging.getLogger(__name__)
@@ -21,8 +35,13 @@ bp = Blueprint("dhcp", __name__)
@bp.route("/config", methods=["GET"])
def get_config_bp():
"""GET /api/dhcp/config — Retrieve the current dnsmasq configuration.
Returns:
JSON response with the config or an error.
"""
try:
return _ok(get("/dnsmasq/config"))
return _ok(get(GET_DNSMASQ_CONFIG))
except RuntimeError as exc:
logger.error("Failed to read DHCP config: %s", exc)
return _error(str(exc), 500)
@@ -30,11 +49,19 @@ def get_config_bp():
@bp.route("/config", methods=["POST"])
def post_config():
"""POST /api/dhcp/config — Save a full replacement dnsmasq configuration.
Args:
request: JSON body containing the complete config object.
Returns:
JSON response with success status or an error.
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
try:
post("/dnsmasq/config", body)
post(POST_DNSMASQ_CONFIG, body)
return _ok(None)
except BadRequest as exc:
logger.info("DHCP config save rejected: %s", exc)
@@ -46,11 +73,19 @@ def post_config():
@bp.route("/config", methods=["PATCH"])
def patch_config():
"""PATCH /api/dhcp/config — Partially update the dnsmasq configuration.
Args:
request: JSON body containing the fields to update.
Returns:
JSON response with success status or an error.
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
try:
patch("/dnsmasq/config", body)
patch(PATCH_DNSMASQ_CONFIG, body)
return _ok(None)
except BadRequest as exc:
logger.info("DHCP config patch rejected: %s", exc)
@@ -62,8 +97,10 @@ def patch_config():
@bp.route("/apply", methods=["POST"])
def apply_bp():
"""POST /api/dhcp/apply — Apply the current dnsmasq configuration to the running service."""
try:
post("/dnsmasq/apply")
post(POST_DNSMASQ_APPLY)
logger.info("dnsmasq config applied via API")
return _ok(None)
except RuntimeError as exc:
@@ -78,8 +115,10 @@ def apply_bp():
@bp.route("/status", methods=["GET"])
def status_bp():
"""GET /api/dhcp/status — Retrieve dnsmasq service status."""
try:
return _ok(get("/dnsmasq/status"))
return _ok(get(GET_DNSMASQ_STATUS))
except RuntimeError as exc:
logger.error("Failed to get DHCP status: %s", exc)
return _error(str(exc), 500)
@@ -92,6 +131,14 @@ def status_bp():
@bp.route("/ranges", methods=["POST"])
def add_range_bp():
"""POST /api/dhcp/ranges — Add a DHCP address range for an interface.
Args:
request: JSON body with `interface`, `start`, `end`, and optional `lease_time`.
Returns:
JSON response with success status or an error.
"""
body = request.get_json(silent=True) or {}
iface = body.get("interface", "").strip() or None
start = body.get("start", "").strip()
@@ -101,7 +148,7 @@ def add_range_bp():
return _error("'start' and 'end' are required", 400)
try:
post(
"/dnsmasq/ranges/add",
POST_DNSMASQ_RANGES_ADD,
{
"interface": iface or "",
"start": start,
@@ -121,6 +168,14 @@ def add_range_bp():
@bp.route("/ranges", methods=["DELETE"])
def remove_range_bp():
"""DELETE /api/dhcp/ranges — Remove a DHCP address range.
Args:
request: JSON body with `interface`, `start`, and `end`.
Returns:
JSON response with success status or an error.
"""
body = request.get_json(silent=True) or {}
iface = body.get("interface", "").strip() or ""
start = body.get("start", "").strip()
@@ -129,7 +184,8 @@ def remove_range_bp():
return _error("'start' and 'end' are required", 400)
try:
delete(
"/dnsmasq/ranges/remove", {"interface": iface, "start": start, "end": end}
DELETE_DNSMASQ_RANGES_REMOVE,
{"interface": iface, "start": start, "end": end},
)
logger.info("DHCP range removed via API: %s-%s", start, end)
return _ok(None)
@@ -148,8 +204,10 @@ def remove_range_bp():
@bp.route("/leases", methods=["GET"])
def leases_bp():
"""GET /api/dhcp/leases — Retrieve the current DHCP lease table."""
try:
return _ok(get("/dnsmasq/leases"))
return _ok(get(GET_DNSMASQ_LEASES))
except RuntimeError as exc:
logger.error("Failed to read lease table: %s", exc)
return _error(str(exc), 500)
@@ -162,6 +220,14 @@ def leases_bp():
@bp.route("/static-lease", methods=["POST"])
def add_static_lease_bp():
"""POST /api/dhcp/static-lease — Add a static DHCP lease by MAC address.
Args:
request: JSON body with `mac`, `ip`, and optional `hostname`.
Returns:
JSON response with lease details or an error.
"""
body = request.get_json(silent=True) or {}
mac = body.get("mac", "").strip()
ip = body.get("ip", "").strip()
@@ -169,7 +235,9 @@ def add_static_lease_bp():
if not mac or not ip:
return _error("'mac' and 'ip' are required", 400)
try:
post("/dnsmasq/static-lease/add", {"mac": mac, "ip": ip, "hostname": hostname})
post(
POST_DNSMASQ_STATIC_LEASE_ADD, {"mac": mac, "ip": ip, "hostname": hostname}
)
logger.info("Static lease added via API: %s -> %s", mac, ip)
return _ok({"mac": mac, "ip": ip, "hostname": hostname})
except BadRequest as exc:
@@ -182,8 +250,16 @@ def add_static_lease_bp():
@bp.route("/static-lease/<mac>", methods=["DELETE"])
def remove_static_lease_bp(mac):
"""DELETE /api/dhcp/static-lease/<mac> — Remove a static DHCP lease by MAC address.
Args:
mac: MAC address of the static lease to remove.
Returns:
JSON response with success status or an error.
"""
try:
delete("/dnsmasq/static-lease/remove", {"mac": mac})
delete(DELETE_DNSMASQ_STATIC_LEASE_REMOVE, {"mac": mac})
logger.info("Static lease removed via API: %s", mac)
return _ok(None)
except NotFound as exc:
@@ -201,6 +277,14 @@ def remove_static_lease_bp(mac):
@bp.route("/dns-record", methods=["POST"])
def add_dns_record_bp():
"""POST /api/dhcp/dns-record — Add a DNS record.
Args:
request: JSON body with `name`, `address`, and optional `hostname`.
Returns:
JSON response with record details or an error.
"""
body = request.get_json(silent=True) or {}
name = body.get("name", "").strip()
address = body.get("address", "").strip()
@@ -209,7 +293,7 @@ def add_dns_record_bp():
return _error("'name' and 'address' are required", 400)
try:
post(
"/dnsmasq/dns-record/add",
POST_DNSMASQ_DNS_RECORD_ADD,
{"name": name, "address": address, "hostname": hostname},
)
logger.info("DNS record added via API: %s -> %s", name, address)
@@ -224,8 +308,16 @@ def add_dns_record_bp():
@bp.route("/dns-record/<name>", methods=["DELETE"])
def remove_dns_record_bp(name):
"""DELETE /api/dhcp/dns-record/<name> — Remove a DNS record by name.
Args:
name: Name of the DNS record to remove.
Returns:
JSON response with success status or an error.
"""
try:
delete("/dnsmasq/dns-record/remove", {"name": name})
delete(DELETE_DNSMASQ_DNS_RECORD_REMOVE, {"name": name})
logger.info("DNS record removed via API: %s", name)
return _ok(None)
except NotFound as exc:
+280 -21
View File
@@ -8,6 +8,28 @@ import logging
from flask import Blueprint, request
from daemon.client import BadRequest, NotFound, delete, get, patch, post
from daemon.iface import (
DELETE_FIREWALL_FORWARD_PORT_REMOVE,
DELETE_FIREWALL_RICH_RULES_REMOVE,
DELETE_FIREWALL_ZONES_DELETE,
GET_FIREWALL_CONFIG,
GET_FIREWALL_CONFIG_PENDING,
GET_FIREWALL_INTERFACES,
GET_FIREWALL_RICH_RULES,
GET_FIREWALL_SERVICES,
GET_FIREWALL_STATE,
GET_FIREWALL_ZONES,
GET_FIREWALL_ZONES_INFO,
PATCH_FIREWALL_CONFIG,
POST_FIREWALL_CONFIG,
POST_FIREWALL_CONFIG_APPLY,
POST_FIREWALL_FORWARD_PORT_ADD,
POST_FIREWALL_MASQUERADE,
POST_FIREWALL_RICH_RULES_ADD,
POST_FIREWALL_ZONES_CREATE,
POST_FIREWALL_ZONES_INTERFACES,
POST_FIREWALL_ZONES_SERVICES,
)
from webui.api.common import _error, _ok
logger = logging.getLogger(__name__)
@@ -21,8 +43,18 @@ bp = Blueprint("firewall", __name__)
@bp.route("/config", methods=["GET"])
def config_list():
"""Retrieve the current firewall declarative configuration.
Returns JSON containing the full firewall config from the daemon.
Endpoint:
GET /api/firewall/config
Returns:
JSON response with the config data or an error message.
"""
try:
return _ok(get("/firewall/config"))
return _ok(get(GET_FIREWALL_CONFIG))
except RuntimeError as exc:
logger.error("Failed to read firewall config: %s", exc)
return _error(str(exc), 500)
@@ -30,15 +62,29 @@ def config_list():
@bp.route("/config", methods=["POST"])
def config_save():
"""Save a new firewall declarative configuration.
Validates that the request body contains a ``zones`` dict, forwards
to the daemon, and returns the pending state including unmanaged zones.
Endpoint:
POST /api/firewall/config
Args:
body: JSON with ``zones`` dict mapping zone names to zone configs.
Returns:
JSON with ``config_saved`` flag and pending apply information.
"""
body = request.get_json(silent=True) or {}
if "zones" not in body:
return _error("'zones' key is required", 400)
if not isinstance(body["zones"], dict):
return _error("'zones' must be a dict", 400)
try:
post("/firewall/config", body)
post(POST_FIREWALL_CONFIG, body)
try:
pending = get("/firewall/config/pending")
pending = get(GET_FIREWALL_CONFIG_PENDING)
pending_data = {
"pending": pending.get("pending", []),
"needs_apply": pending.get("needs_apply", False),
@@ -64,13 +110,27 @@ def config_save():
@bp.route("/config", methods=["PATCH"])
def patch_config():
"""Partially update the firewall declarative configuration.
Accepts a JSON body and forwards it as a patch to the daemon config
endpoint, returning the updated pending state.
Endpoint:
PATCH /api/firewall/config
Args:
body: JSON object with configuration fields to patch.
Returns:
JSON with ``config_saved`` flag and pending apply information.
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
try:
patch("/firewall/config", body)
patch(PATCH_FIREWALL_CONFIG, body)
try:
pending = get("/firewall/config/pending")
pending = get(GET_FIREWALL_CONFIG_PENDING)
pending_data = {
"pending": pending.get("pending", []),
"needs_apply": pending.get("needs_apply", False),
@@ -95,8 +155,19 @@ def patch_config():
@bp.route("/config/apply", methods=["POST"])
def config_apply_bp():
"""Apply any pending firewall configuration changes.
Triggers the daemon to apply saved declarative config to the live
firewalld instance.
Endpoint:
POST /api/firewall/config/apply
Returns:
JSON with ``applied_zones`` list or an error message.
"""
try:
result = post("/firewall/config/apply")
result = post(POST_FIREWALL_CONFIG_APPLY)
logger.info("Firewall config applied: %s", result.get("applied_zones", []))
return _ok(result)
except RuntimeError as exc:
@@ -106,13 +177,46 @@ def config_apply_bp():
@bp.route("/config/pending", methods=["GET"])
def config_pending_bp():
"""Check the pending firewall configuration state.
Returns information about unsaved changes, whether an apply is
needed, and any unmanaged zones detected on the system.
Endpoint:
GET /api/firewall/config/pending
Returns:
JSON with pending changes and apply status.
"""
try:
return _ok(get("/firewall/config/pending"))
return _ok(get(GET_FIREWALL_CONFIG_PENDING))
except RuntimeError as exc:
logger.error("Failed to check pending config: %s", exc)
return _error(str(exc), 500)
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
@bp.route("/state", methods=["GET"])
def get_state():
"""Retrieve current firewall state from the state store.
Endpoint:
GET /api/firewall/state
Returns:
JSON with firewall state data or an error message.
"""
try:
return _ok(get(GET_FIREWALL_STATE))
except RuntimeError as exc:
logger.error("Failed to get firewall state: %s", exc)
return _error(str(exc), 500)
# ---------------------------------------------------------------------------
# Zones
# ---------------------------------------------------------------------------
@@ -120,8 +224,16 @@ def config_pending_bp():
@bp.route("/zones", methods=["GET"])
def list_zones():
"""List all active and available firewall zones.
Endpoint:
GET /api/firewall/zones
Returns:
JSON with ``active`` zones dict and ``available`` zones list.
"""
try:
data = get("/firewall/zones")
data = get(GET_FIREWALL_ZONES)
return _ok(
{"active": data.get("active", {}), "available": data.get("available", [])}
)
@@ -132,8 +244,19 @@ def list_zones():
@bp.route("/zones/<name>", methods=["GET"])
def zone_details(name: str):
"""Retrieve details for a specific firewall zone.
Endpoint:
GET /api/firewall/zones/<name>
Args:
name: Name of the zone to look up.
Returns:
JSON with zone configuration details or 404 error.
"""
try:
info = get("/firewall/zones/info", {"zone": name})
info = get(GET_FIREWALL_ZONES_INFO, {"zone": name})
return _ok(info)
except NotFound as exc:
logger.info("Zone '%s' not found: %s", name, exc)
@@ -145,13 +268,24 @@ def zone_details(name: str):
@bp.route("/zones", methods=["POST"])
def create_zone_bp():
"""Create a new firewall zone.
Endpoint:
POST /api/firewall/zones
Args:
body: JSON with ``name`` (required) and optional ``target`` string.
Returns:
JSON confirmation or error if the zone already exists.
"""
body = request.get_json(silent=True) or {}
zone_name = body.get("name", "").strip()
target = body.get("target", "default").strip() or "default"
if not zone_name:
return _error("Zone name is required", 400)
try:
post("/firewall/zones/create", {"name": zone_name, "target": target})
post(POST_FIREWALL_ZONES_CREATE, {"name": zone_name, "target": target})
logger.info("Zone '%s' created via API", zone_name)
return _ok(None)
except BadRequest as exc:
@@ -164,8 +298,19 @@ def create_zone_bp():
@bp.route("/zones/<name>", methods=["DELETE"])
def delete_zone_bp(name: str):
"""Delete a firewall zone by name.
Endpoint:
DELETE /api/firewall/zones/<name>
Args:
name: Name of the zone to delete.
Returns:
JSON confirmation or 404 if the zone does not exist.
"""
try:
delete("/firewall/zones/delete", {"zone": name})
delete(DELETE_FIREWALL_ZONES_DELETE, {"zone": name})
logger.info("Zone '%s' deleted via API", name)
return _ok(None)
except NotFound as exc:
@@ -183,12 +328,26 @@ def delete_zone_bp(name: str):
@bp.route("/zones/<name>/interfaces", methods=["POST"])
def set_zone_interfaces_bp(name: str):
"""Set the network interfaces assigned to a firewall zone.
Replaces all existing interfaces for the zone with the provided list.
Endpoint:
POST /api/firewall/zones/<name>/interfaces
Args:
name: Zone name.
body: JSON with ``interfaces`` list of interface names.
Returns:
JSON confirmation with zone and updated interfaces list.
"""
body = request.get_json(silent=True) or {}
interfaces = body.get("interfaces", [])
if not isinstance(interfaces, list):
return _error("'interfaces' must be a list", 400)
try:
post("/firewall/zones/interfaces", {"zone": name, "interfaces": interfaces})
post(POST_FIREWALL_ZONES_INTERFACES, {"zone": name, "interfaces": interfaces})
logger.info("Zone '%s' interfaces updated: %s", name, interfaces)
return _ok({"zone": name, "interfaces": interfaces})
except BadRequest as exc:
@@ -209,12 +368,26 @@ def set_zone_interfaces_bp(name: str):
@bp.route("/zones/<name>/services", methods=["POST"])
def set_zone_services_bp(name: str):
"""Set the allowed services for a firewall zone.
Replaces all existing services for the zone with the provided list.
Endpoint:
POST /api/firewall/zones/<name>/services
Args:
name: Zone name.
body: JSON with ``services`` list of service names.
Returns:
JSON confirmation with zone and updated services list.
"""
body = request.get_json(silent=True) or {}
services = body.get("services", [])
if not isinstance(services, list):
return _error("'services' must be a list", 400)
try:
post("/firewall/zones/services", {"zone": name, "services": services})
post(POST_FIREWALL_ZONES_SERVICES, {"zone": name, "services": services})
return _ok({"zone": name, "services": services})
except BadRequest as exc:
logger.info("Set services for zone '%s' rejected: %s", name, exc)
@@ -234,8 +407,16 @@ def set_zone_services_bp(name: str):
@bp.route("/services", methods=["GET"])
def list_services():
"""List all available firewall services.
Endpoint:
GET /api/firewall/services
Returns:
JSON with the list of available service names.
"""
try:
return _ok(get("/firewall/services"))
return _ok(get(GET_FIREWALL_SERVICES))
except RuntimeError as exc:
logger.error("Failed to list services: %s", exc)
return _error(str(exc), 500)
@@ -243,8 +424,16 @@ def list_services():
@bp.route("/interfaces", methods=["GET"])
def list_interfaces():
"""List all available network interfaces.
Endpoint:
GET /api/firewall/interfaces
Returns:
JSON with the list of available interface names.
"""
try:
return _ok(get("/firewall/interfaces"))
return _ok(get(GET_FIREWALL_INTERFACES))
except RuntimeError as exc:
logger.error("Failed to list interfaces: %s", exc)
return _error(str(exc), 500)
@@ -257,13 +446,24 @@ def list_interfaces():
@bp.route("/rich-rules", methods=["POST"])
def add_rich_rule_bp():
"""Add a rich rule to a firewall zone.
Endpoint:
POST /api/firewall/rich-rules
Args:
body: JSON with ``zone`` (zone name) and ``rule`` (XML rule string).
Returns:
JSON with zone, generated rule ID, and rule string.
"""
body = request.get_json(silent=True) or {}
zone = body.get("zone", "").strip()
rule = body.get("rule", "").strip()
if not zone or not rule:
return _error("Both 'zone' and 'rule' are required", 400)
try:
entry = post("/firewall/rich-rules/add", {"zone": zone, "rule": rule})
entry = post(POST_FIREWALL_RICH_RULES_ADD, {"zone": zone, "rule": rule})
logger.info("Rich rule added to zone '%s': %s", zone, rule[:80])
return _ok({"zone": zone, "id": entry["id"], "rule": rule})
except BadRequest as exc:
@@ -276,8 +476,19 @@ def add_rich_rule_bp():
@bp.route("/rich-rules/<zone>", methods=["GET"])
def list_rich_rules(zone: str):
"""List rich rules for a specific firewall zone.
Endpoint:
GET /api/firewall/rich-rules/<zone>
Args:
zone: Zone name to list rules for.
Returns:
JSON with list of rich rule entries for the zone.
"""
try:
return _ok(get("/firewall/rich-rules", {"zone": zone}))
return _ok(get(GET_FIREWALL_RICH_RULES, {"zone": zone}))
except RuntimeError as exc:
logger.error("Failed to get rich rules for zone '%s': %s", zone, exc)
return _error(str(exc), 500)
@@ -285,8 +496,20 @@ def list_rich_rules(zone: str):
@bp.route("/rich-rules/<zone>/<rule_id>", methods=["DELETE"])
def remove_rich_rule_bp(zone: str, rule_id: str):
"""Remove a rich rule from a firewall zone by ID.
Endpoint:
DELETE /api/firewall/rich-rules/<zone>/<rule_id>
Args:
zone: Zone name.
rule_id: Rule identifier.
Returns:
JSON confirmation or 404 if the rule does not exist.
"""
try:
delete("/firewall/rich-rules/remove", {"zone": zone, "id": rule_id})
delete(DELETE_FIREWALL_RICH_RULES_REMOVE, {"zone": zone, "id": rule_id})
logger.info("Rich rule '%s' removed from zone '%s'", rule_id, zone)
return _ok({"zone": zone, "id": rule_id})
except NotFound as exc:
@@ -304,13 +527,24 @@ def remove_rich_rule_bp(zone: str, rule_id: str):
@bp.route("/masquerade", methods=["POST"])
def set_masquerade_bp():
"""Enable or disable masquerade (NAT) on a firewall zone.
Endpoint:
POST /api/firewall/masquerade
Args:
body: JSON with ``zone`` (zone name) and ``enable`` (boolean).
Returns:
JSON confirmation with zone and masquerade status.
"""
body = request.get_json(silent=True) or {}
zone = body.get("zone", "").strip()
enable = body.get("enable")
if not zone or enable is None:
return _error("'zone' and 'enable' (bool) are required", 400)
try:
post("/firewall/masquerade", {"zone": zone, "enable": bool(enable)})
post(POST_FIREWALL_MASQUERADE, {"zone": zone, "enable": bool(enable)})
logger.info(
"Masquerade %s on zone '%s' via API",
"enabled" if enable else "disabled",
@@ -332,6 +566,18 @@ def set_masquerade_bp():
@bp.route("/forward-port", methods=["POST"])
def add_forward_port_bp():
"""Add a port forwarding rule to a firewall zone.
Endpoint:
POST /api/firewall/forward-port
Args:
body: JSON with ``zone`` (zone name), ``port`` (int), ``proto``
(tcp/udp), optional ``toaddr`` and ``toport``.
Returns:
JSON confirmation with zone, generated ID, port, and protocol.
"""
body = request.get_json(silent=True) or {}
zone = body.get("zone", "").strip()
port = body.get("port")
@@ -353,7 +599,7 @@ def add_forward_port_bp():
toaddr_str = str(toaddr) if toaddr else None
try:
entry = post(
"/firewall/forward-port/add",
POST_FIREWALL_FORWARD_PORT_ADD,
{
"zone": zone,
"port": port_int,
@@ -373,9 +619,22 @@ def add_forward_port_bp():
@bp.route("/forward-port/<zone>/<int:port>/<proto>", methods=["DELETE"])
def remove_forward_port_bp(zone: str, port: int, proto: str):
"""Remove a port forwarding rule from a firewall zone.
Endpoint:
DELETE /api/firewall/forward-port/<zone>/<port>/<proto>
Args:
zone: Zone name.
port: Port number.
proto: Protocol (tcp/udp).
Returns:
JSON confirmation or 404 if the rule does not exist.
"""
try:
delete(
"/firewall/forward-port/remove",
DELETE_FIREWALL_FORWARD_PORT_REMOVE,
{"zone": zone, "port": port, "proto": proto},
)
logger.info("Forward port %s/%s removed from zone '%s'", port, proto, zone)
+32 -28
View File
@@ -1,68 +1,72 @@
"""Log viewing API blueprint.
Serves log content to the /logs page via HTMX endpoints through vacuum-walld.
Wraps raw log text in the standard JSON response contract.
"""
import logging
from flask import Blueprint, render_template_string
from flask import Blueprint
from daemon.client import get
from daemon.client import NotFound, get
from daemon.iface import (
GET_LOGS_APP,
GET_LOGS_DNSMASQ,
GET_LOGS_JOURNAL,
GET_LOGS_NGINX_ACCESS,
GET_LOGS_NGINX_ERROR,
)
from webui.api.common import _error, _ok
logger = logging.getLogger(__name__)
bp = Blueprint("logs", __name__)
_LOG_LINE_TEMPLATE = """\
{% for line in lines %}
<div class="log-line{% if 'ERROR' in line %} log-error{% elif 'WARN' in line %} log-warn{% endif %}">{{ line | e }}</div>
{% endfor %}"""
def _render_log_lines(text: str) -> str:
lines = text.rstrip("\n").split("\n") if text.strip() else []
return render_template_string(_LOG_LINE_TEMPLATE, lines=lines)
@bp.route("/journal")
def journal():
"""GET /api/logs/journal — Return systemd journal log lines."""
try:
text = get("/logs/journal")
return _render_log_lines(text)
return _ok(get(GET_LOGS_JOURNAL))
except RuntimeError:
return _render_log_lines("(error reading journal)\n")
return _error("error reading journal", 500)
@bp.route("/nginx/access")
def nginx_access():
"""GET /api/logs/nginx/access — Return nginx access log lines."""
try:
text = get("/logs/nginx/access")
return _render_log_lines(text)
return _ok(get(GET_LOGS_NGINX_ACCESS))
except NotFound:
return _error("log file not found", 404)
except RuntimeError:
return _render_log_lines("(log file not found)\n")
return _error("error reading log", 500)
@bp.route("/nginx/error")
def nginx_error():
"""GET /api/logs/nginx/error — Return nginx error log lines."""
try:
text = get("/logs/nginx/error")
return _render_log_lines(text)
return _ok(get(GET_LOGS_NGINX_ERROR))
except NotFound:
return _error("log file not found", 404)
except RuntimeError:
return _render_log_lines("(log file not found)\n")
return _error("error reading log", 500)
@bp.route("/dnsmasq")
def dnsmasq():
"""GET /api/logs/dnsmasq — Return dnsmasq log lines."""
try:
text = get("/logs/dnsmasq")
return _render_log_lines(text)
return _ok(get(GET_LOGS_DNSMASQ))
except RuntimeError:
return _render_log_lines("(error reading journal)\n")
return _error("error reading journal", 500)
@bp.route("/app")
def app_log():
"""GET /api/logs/app — Return application log lines."""
try:
text = get("/logs/app")
return _render_log_lines(text)
return _ok(get(GET_LOGS_APP))
except NotFound:
return _error("log file not found", 404)
except RuntimeError:
return _render_log_lines("(log file not found)\n")
return _error("error reading log", 500)
+168
View File
@@ -0,0 +1,168 @@
"""Network management API blueprint.
Exposes /api/network/* and delegates to vacuum-walld for interface
IP configuration via systemd-networkd.
"""
import logging
from flask import Blueprint, request
from daemon.client import NotFound, get, post
from daemon.iface import (
GET_NETWORK_INFER_DHCP_RANGES,
GET_NETWORK_INFER_ZONES,
GET_NETWORK_INTERFACE_NAME,
GET_NETWORK_INTERFACES,
POST_NETWORK_APPLY,
POST_NETWORK_INTERFACE_NAME,
POST_NETWORK_INTERFACE_RELOAD,
)
from lib.common import validate_interface_name
from webui.api.common import _error, _ok
logger = logging.getLogger(__name__)
bp = Blueprint("network", __name__)
@bp.route("/interfaces", methods=["GET"])
def list_interfaces():
"""List all interfaces with their network config and runtime state.
Endpoint:
GET /api/network/interfaces
Returns:
JSON with interface config + runtime state.
"""
try:
return _ok(get(GET_NETWORK_INTERFACES))
except RuntimeError as exc:
logger.error("Failed to list network interfaces: %s", exc)
return _error(str(exc), 500)
@bp.route("/interfaces/<name>", methods=["GET"])
def get_interface(name: str):
"""Get config + runtime state for a specific interface.
Endpoint:
GET /api/network/interfaces/<name>
Returns:
JSON with interface config and runtime state.
"""
try:
validate_interface_name(name)
return _ok(get(GET_NETWORK_INTERFACE_NAME, {"name": name}))
except ValueError as exc:
return _error(str(exc), 400)
except NotFound as exc:
logger.info("Interface '%s' not found: %s", name, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to get interface '%s': %s", name, exc)
return _error(str(exc), 500)
@bp.route("/interfaces/<name>", methods=["POST"])
def save_interface(name: str):
"""Save and apply network config for an interface.
Endpoint:
POST /api/network/interfaces/<name>
Args:
body: JSON with addresses, gateway, dns, routes.
Returns:
JSON confirmation.
"""
body = {**(request.get_json(silent=True) or {}), "name": name}
try:
validate_interface_name(name)
post(POST_NETWORK_INTERFACE_NAME, body)
logger.info("Interface '%s' config saved", name)
return _ok({"name": name, "applied": True})
except ValueError as exc:
return _error(str(exc), 400)
except NotFound as exc:
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to save interface '%s': %s", name, exc)
return _error(str(exc), 500)
@bp.route("/interfaces/<name>/reload", methods=["POST"])
def reload_interface(name: str):
"""Reload networkd for a single interface.
Endpoint:
POST /api/network/interfaces/<name>/reload
Returns:
JSON confirmation.
"""
try:
validate_interface_name(name)
post(POST_NETWORK_INTERFACE_RELOAD, {"name": name})
logger.info("Interface '%s' reloaded", name)
return _ok({"name": name, "reloaded": True})
except ValueError as exc:
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to reload interface '%s': %s", name, exc)
return _error(str(exc), 500)
@bp.route("/apply", methods=["POST"])
def apply_all():
"""Apply network config for ALL interfaces (full sync).
Endpoint:
POST /api/network/apply
Returns:
JSON with number of interfaces applied.
"""
try:
result = post(POST_NETWORK_APPLY, {})
logger.info("Network config applied: %d interfaces", result.get("applied", 0))
return _ok(result)
except RuntimeError as exc:
logger.error("Failed to apply network config: %s", exc)
return _error(str(exc), 500)
@bp.route("/infer-dhcp-ranges", methods=["GET"])
def infer_dhcp_ranges():
"""Suggest candidate DHCP ranges based on static interface IPs.
Endpoint:
GET /api/network/infer-dhcp-ranges
Returns:
JSON with per-interface suggested DHCP ranges.
"""
try:
return _ok(get(GET_NETWORK_INFER_DHCP_RANGES))
except RuntimeError as exc:
logger.error("Failed to infer DHCP ranges: %s", exc)
return _error(str(exc), 500)
@bp.route("/infer-zones", methods=["GET"])
def infer_zones():
"""Suggest firewalld zone assignments for configured interfaces.
Endpoint:
GET /api/network/infer-zones
Returns:
JSON with per-interface suggested zone names.
"""
try:
return _ok(get(GET_NETWORK_INFER_ZONES))
except RuntimeError as exc:
logger.error("Failed to infer zones: %s", exc)
return _error(str(exc), 500)
+128 -11
View File
@@ -8,6 +8,19 @@ import logging
from flask import Blueprint, request
from daemon.client import BadRequest, NotFound, delete, get, patch, post
from daemon.iface import (
DELETE_NGINX_DOMAINS_REMOVE,
GET_NGINX_CONFIG,
GET_NGINX_DOMAINS,
PATCH_NGINX_CONFIG,
POST_NGINX_APPLY,
POST_NGINX_CONFIG,
POST_NGINX_DOMAINS_ADD,
POST_NGINX_DOMAINS_UPDATE,
POST_NGINX_MANAGEMENT,
POST_NGINX_SSL_APPLY,
POST_NGINX_TEST,
)
from webui.api.common import _error, _ok
logger = logging.getLogger(__name__)
@@ -16,8 +29,18 @@ bp = Blueprint("proxy", __name__)
@bp.route("/ssl-apply", methods=["POST"])
def ssl_apply_bp():
"""Apply SSL snippet config.
POST /api/proxy/ssl-apply
Returns:
``{"ok": true}`` on success.
Raises:
RuntimeError: If nginx SSL snippet write fails.
"""
try:
post("/nginx/ssl-apply")
post(POST_NGINX_SSL_APPLY)
logger.info("SSL snippet written via API")
return _ok(None)
except RuntimeError as exc:
@@ -27,8 +50,15 @@ def ssl_apply_bp():
@bp.route("/config", methods=["GET"])
def get_config_bp():
"""Get the current nginx proxy configuration.
GET /api/proxy/config
Returns:
Current config dict from the daemon.
"""
try:
return _ok(get("/nginx/config"))
return _ok(get(GET_NGINX_CONFIG))
except RuntimeError as exc:
logger.error("Failed to read proxy config: %s", exc)
return _error(str(exc), 500)
@@ -36,11 +66,21 @@ def get_config_bp():
@bp.route("/config", methods=["POST"])
def post_config():
"""Save the nginx proxy configuration.
POST /api/proxy/config
Body:
Any JSON object to merge into the config.
Returns:
``{"ok": true}`` on success.
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
try:
post("/nginx/config", body)
post(POST_NGINX_CONFIG, body)
logger.info("Proxy config saved: %s", sorted(body.keys()))
return _ok(None)
except BadRequest as exc:
@@ -53,11 +93,21 @@ def post_config():
@bp.route("/config", methods=["PATCH"])
def patch_config():
"""Partially update the nginx proxy configuration.
PATCH /api/proxy/config
Body:
JSON object with fields to patch.
Returns:
``{"ok": true}`` on success.
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
try:
patch("/nginx/config", body)
patch(PATCH_NGINX_CONFIG, body)
logger.info("Proxy config patched: %s", sorted(body.keys()))
return _ok(None)
except BadRequest as exc:
@@ -70,8 +120,15 @@ def patch_config():
@bp.route("/domains", methods=["GET"])
def list_domains():
"""List all configured proxy domains.
GET /api/proxy/domains
Returns:
List of domain dicts from the daemon.
"""
try:
return _ok(get("/nginx/domains"))
return _ok(get(GET_NGINX_DOMAINS))
except RuntimeError as exc:
logger.error("Failed to list proxy domains: %s", exc)
return _error(str(exc), 500)
@@ -79,6 +136,21 @@ def list_domains():
@bp.route("/domains", methods=["POST"])
def add_domain_bp():
"""Add a new proxy domain.
POST /api/proxy/domains
Body fields:
domain: Domain name.
backend_host: Upstream host.
backend_port: Upstream port.
backend_proto: Protocol (``http`` or ``https``, default ``http``).
cert: Optional certificate type.
extra_headers: Optional extra headers dict.
Returns:
``{"domain": ...}`` on success.
"""
body = request.get_json(silent=True) or {}
domain = body.get("domain", "").strip()
backend_host = body.get("backend_host", "").strip()
@@ -94,7 +166,7 @@ def add_domain_bp():
return _error("'backend_port' is required", 400)
try:
post(
"/nginx/domains/add",
POST_NGINX_DOMAINS_ADD,
{
"domain": domain,
"backend_host": backend_host,
@@ -116,11 +188,21 @@ def add_domain_bp():
@bp.route("/domains/<domain>", methods=["PUT"])
def update_domain_bp(domain):
"""Update an existing proxy domain in-place.
PUT /api/proxy/domains/<domain>
Body fields:
Fields to merge into the domain config.
Returns:
``{"domain": ...}`` on success.
"""
body = request.get_json(silent=True) or {}
if not body:
return _error("Request body must be a JSON object with fields to update", 400)
try:
post("/nginx/domains/update", {"domain": domain, **body})
post(POST_NGINX_DOMAINS_UPDATE, {"domain": domain, **body})
logger.info("Proxy domain '%s' updated via API", domain)
return _ok({"domain": domain})
except BadRequest as exc:
@@ -136,8 +218,15 @@ def update_domain_bp(domain):
@bp.route("/domains/<domain>", methods=["DELETE"])
def remove_domain_bp(domain):
"""Remove a proxy domain.
DELETE /api/proxy/domains/<domain>
Returns:
``{"domain": ...}`` on success.
"""
try:
delete("/nginx/domains/remove", {"domain": domain})
delete(DELETE_NGINX_DOMAINS_REMOVE, {"domain": domain})
logger.info("Proxy domain removed via API: %s", domain)
return _ok({"domain": domain})
except NotFound as exc:
@@ -150,8 +239,15 @@ def remove_domain_bp(domain):
@bp.route("/apply", methods=["POST"])
def apply_bp():
"""Generate all nginx configs and reload nginx.
POST /api/proxy/apply
Returns:
``{"ok": true}`` on success.
"""
try:
post("/nginx/apply")
post(POST_NGINX_APPLY)
logger.info("nginx config applied via API")
return _ok(None)
except RuntimeError as exc:
@@ -161,8 +257,15 @@ def apply_bp():
@bp.route("/test", methods=["POST"])
def test_bp():
"""Test nginx configuration without reloading.
POST /api/proxy/test
Returns:
``{"valid": true, "output": ...}`` on success. Returns 400 if test fails.
"""
try:
result = post("/nginx/test")
result = post(POST_NGINX_TEST)
if result.get("valid"):
return _ok({"valid": True, "output": result.get("output", "")})
return _error(result.get("output", "unknown error"), 400)
@@ -173,6 +276,20 @@ def test_bp():
@bp.route("/management", methods=["POST"])
def management_bp():
"""Configure the management reverse proxy for the WebUI.
POST /api/proxy/management
Body fields:
domain: Management domain name.
flask_host: Upstream Flask host (default ``127.0.0.1``).
flask_port: Upstream Flask port (default 9090).
auth_user: Optional basic-auth username.
auth_pass: Optional basic-auth password.
Returns:
``{"ok": true}`` on success.
"""
body = request.get_json(silent=True) or {}
domain = body.get("domain", "").strip()
if not domain:
@@ -183,7 +300,7 @@ def management_bp():
auth_pass = body.get("auth_pass")
try:
post(
"/nginx/management",
POST_NGINX_MANAGEMENT,
{
"domain": domain,
"flask_host": flask_host,
+149 -13
View File
@@ -8,6 +8,20 @@ import logging
from flask import Blueprint, request
from daemon.client import BadRequest, NotFound, delete, get, patch, post
from daemon.iface import (
DELETE_WIREGUARD_PEERS_REMOVE,
GET_WIREGUARD_CONFIG,
GET_WIREGUARD_PEER_STATUS,
GET_WIREGUARD_PEERS,
GET_WIREGUARD_STATUS,
PATCH_WIREGUARD_CONFIG,
POST_WIREGUARD_APPLY,
POST_WIREGUARD_CONFIG,
POST_WIREGUARD_DOWN,
POST_WIREGUARD_GENERATE_CLIENT,
POST_WIREGUARD_INITIALIZE,
POST_WIREGUARD_PEERS_ADD,
)
from webui.api.common import _error, _ok
logger = logging.getLogger(__name__)
@@ -16,8 +30,16 @@ bp = Blueprint("wireguard", __name__)
@bp.route("/config", methods=["GET"])
def get_config_bp():
"""Get the current WireGuard configuration.
Endpoint: GET /api/wireguard/config
Returns:
JSON response with the WireGuard config on success, or an error
response on failure.
"""
try:
return _ok(get("/wireguard/config"))
return _ok(get(GET_WIREGUARD_CONFIG))
except RuntimeError as exc:
logger.error("Failed to read WireGuard config: %s", exc)
return _error(str(exc), 500)
@@ -25,6 +47,18 @@ def get_config_bp():
@bp.route("/config", methods=["POST"])
def post_config():
"""Create or fully replace the WireGuard configuration.
Endpoint: POST /api/wireguard/config
Args:
body: JSON body with the configuration. If an ``interface`` key
is present, the private key will be stripped before forwarding.
Returns:
Success response on acceptance, 400 on validation failure, or 500
on server error.
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
@@ -33,7 +67,7 @@ def post_config():
body = dict(body)
body["interface"] = dict(body["interface"])
body["interface"].pop("private_key", None)
post("/wireguard/config", body)
post(POST_WIREGUARD_CONFIG, body)
return _ok(None)
except BadRequest as exc:
logger.info("WireGuard config save rejected: %s", exc)
@@ -45,6 +79,18 @@ def post_config():
@bp.route("/config", methods=["PATCH"])
def patch_config():
"""Partially update the WireGuard configuration.
Endpoint: PATCH /api/wireguard/config
Args:
body: JSON body with the fields to update. If an ``interface``
key is present, the private key will be stripped before forwarding.
Returns:
Success response on acceptance, 400 on validation failure, or 500
on server error.
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
@@ -53,7 +99,7 @@ def patch_config():
body = dict(body)
body["interface"] = dict(body["interface"])
body["interface"].pop("private_key", None)
patch("/wireguard/config", body)
patch(PATCH_WIREGUARD_CONFIG, body)
logger.info("WireGuard config patched: %s", sorted(body.keys()))
return _ok(None)
except BadRequest as exc:
@@ -66,8 +112,15 @@ def patch_config():
@bp.route("/apply", methods=["POST"])
def apply_bp():
"""Apply the current WireGuard configuration to the live tunnel.
Endpoint: POST /api/wireguard/apply
Returns:
Success response on acceptance, or 500 on server error.
"""
try:
post("/wireguard/apply")
post(POST_WIREGUARD_APPLY)
logger.info("WireGuard tunnel applied via API")
return _ok(None)
except RuntimeError as exc:
@@ -77,8 +130,15 @@ def apply_bp():
@bp.route("/up", methods=["POST"])
def up_bp():
"""Bring the WireGuard tunnel interface up.
Endpoint: POST /api/wireguard/up
Returns:
Success response on acceptance, or 500 on server error.
"""
try:
post("/wireguard/apply")
post(POST_WIREGUARD_APPLY)
logger.info("WireGuard tunnel started via API")
return _ok(None)
except RuntimeError as exc:
@@ -88,8 +148,15 @@ def up_bp():
@bp.route("/down", methods=["POST"])
def down_bp():
"""Bring the WireGuard tunnel interface down.
Endpoint: POST /api/wireguard/down
Returns:
Success response on acceptance, or 500 on server error.
"""
try:
post("/wireguard/down")
post(POST_WIREGUARD_DOWN)
logger.info("WireGuard tunnel brought down via API")
return _ok(None)
except RuntimeError as exc:
@@ -99,8 +166,16 @@ def down_bp():
@bp.route("/status", methods=["GET"])
def status_bp():
"""Get the current WireGuard tunnel status.
Endpoint: GET /api/wireguard/status
Returns:
JSON response with the tunnel status on success, or an error
response on failure.
"""
try:
return _ok(get("/wireguard/status"))
return _ok(get(GET_WIREGUARD_STATUS))
except RuntimeError as exc:
logger.error("Failed to get WireGuard status: %s", exc)
return _error(str(exc), 500)
@@ -108,8 +183,15 @@ def status_bp():
@bp.route("/initialize", methods=["POST"])
def initialize_bp():
"""Initialize WireGuard for first-time use.
Endpoint: POST /api/wireguard/initialize
Returns:
Success response on acceptance, or 500 on server error.
"""
try:
post("/wireguard/initialize")
post(POST_WIREGUARD_INITIALIZE)
logger.info("WireGuard initialized via API")
return _ok(None)
except RuntimeError as exc:
@@ -119,13 +201,28 @@ def initialize_bp():
@bp.route("/peers", methods=["POST"])
def add_peer_bp():
"""Add a new peer to the WireGuard configuration.
Endpoint: POST /api/wireguard/peers
Args:
name: Peer display name (required).
endpoint: Optional peer endpoint address.
allowed_ips: Optional list of allowed IP CIDRs.
persistent_keepalive: Optional keepalive interval in seconds.
preshared_key: Optional pre-shared key in hex.
Returns:
JSON response with the created peer on success, 400 on validation
failure, or 500 on server error.
"""
body = request.get_json(silent=True) or {}
name = body.get("name", "").strip()
if not name:
return _error("'name' is required", 400)
try:
peer = post(
"/wireguard/peers/add",
POST_WIREGUARD_PEERS_ADD,
{
"name": name,
"endpoint": body.get("endpoint"),
@@ -146,8 +243,19 @@ def add_peer_bp():
@bp.route("/peers/<name>", methods=["DELETE"])
def remove_peer_bp(name):
"""Remove a peer from the WireGuard configuration.
Endpoint: DELETE /api/wireguard/peers/<name>
Args:
name: Peer name to remove (from URL path).
Returns:
Success response with peer name on removal, 404 if peer not found,
or 500 on server error.
"""
try:
delete("/wireguard/peers/remove", {"name": name})
delete(DELETE_WIREGUARD_PEERS_REMOVE, {"name": name})
logger.info("WireGuard peer '%s' removed via API", name)
return _ok({"name": name})
except NotFound as exc:
@@ -160,8 +268,16 @@ def remove_peer_bp(name):
@bp.route("/peers", methods=["GET"])
def peers_bp():
"""List all configured WireGuard peers.
Endpoint: GET /api/wireguard/peers
Returns:
JSON response with the peers list on success, or an error response
on failure.
"""
try:
return _ok(get("/wireguard/peers"))
return _ok(get(GET_WIREGUARD_PEERS))
except RuntimeError as exc:
logger.error("Failed to list WireGuard peers: %s", exc)
return _error(str(exc), 500)
@@ -169,8 +285,16 @@ def peers_bp():
@bp.route("/peer-status", methods=["GET"])
def peer_status_bp():
"""Get real-time status information for all WireGuard peers.
Endpoint: GET /api/wireguard/peer-status
Returns:
JSON response with peer status on success, or an error response
on failure.
"""
try:
return _ok(get("/wireguard/peer-status"))
return _ok(get(GET_WIREGUARD_PEER_STATUS))
except RuntimeError as exc:
logger.error("Failed to get WireGuard peer status: %s", exc)
return _error(str(exc), 500)
@@ -178,6 +302,18 @@ def peer_status_bp():
@bp.route("/generate-client", methods=["POST"])
def generate_client_bp():
"""Generate a WireGuard client configuration file for a peer.
Endpoint: POST /api/wireguard/generate-client
Args:
name: Peer name (required).
server_endpoint: Server endpoint address for the client config (required).
Returns:
JSON response with the generated config string on success, 404 if
peer not found, 400 on validation failure, or 500 on server error.
"""
body = request.get_json(silent=True) or {}
name = body.get("name", "").strip()
if not name:
@@ -187,7 +323,7 @@ def generate_client_bp():
return _error("Field 'server_endpoint' is required", 400)
try:
result = post(
"/wireguard/generate-client",
POST_WIREGUARD_GENERATE_CLIENT,
{
"name": name,
"server_endpoint": server_endpoint,
+72 -223
View File
@@ -12,18 +12,19 @@ import os
import signal
import sys
import time
from datetime import datetime
from pathlib import Path
from typing import Any
from flask import Flask, render_template, request
from flask import Flask, abort, request
from werkzeug.middleware.proxy_fix import ProxyFix
from daemon.client import get
from daemon.iface import GET_STATUS_ALL
from lib.logging import setup_logging
from webui.api.certs import bp as certs_bp
from webui.api.dhcp import bp as dhcp_bp
from webui.api.firewall import bp as firewall_bp
from webui.api.logs import bp as logs_bp
from webui.api.network import bp as network_bp
from webui.api.proxy import bp as proxy_bp
from webui.api.wireguard import bp as wireguard_bp
@@ -49,6 +50,11 @@ _reloading = False
def _sighup_handler(signum, frame):
"""Handle SIGHUP by reloading modules then restarting via SIGTERM.
Reloads all ``webui.*`` and ``lib.*`` modules, re-registers blueprints,
and requests systemd restart by sending SIGTERM with default handler.
"""
global _reloading
if _reloading:
return
@@ -72,7 +78,17 @@ signal.signal(signal.SIGHUP, _sighup_handler)
app = Flask(__name__)
app.config["SECRET_KEY"] = os.urandom(32).hex()
STATIC_DIR = Path(__file__).resolve().parent / "static"
# Cache-control: short TTL in dev, aggressive caching in prod (versioned assets)
_DEV_MODE = bool(os.environ.get("VACUUM_WALL_DEV")) or False
app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 5 if _DEV_MODE else 31536000
# Flask is behind nginx — trust X-Forwarded-* headers for scheme/host detection
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
app.register_blueprint(firewall_bp, url_prefix="/api/firewall")
app.register_blueprint(network_bp, url_prefix="/api/network")
app.register_blueprint(dhcp_bp, url_prefix="/api/dhcp")
app.register_blueprint(proxy_bp, url_prefix="/api/proxy")
app.register_blueprint(certs_bp, url_prefix="/api/certs")
@@ -81,6 +97,7 @@ app.register_blueprint(logs_bp, url_prefix="/api/logs")
BLUEPRINTS = [
("firewall", firewall_bp),
("network", network_bp),
("dhcp", dhcp_bp),
("proxy", proxy_bp),
("certs", certs_bp),
@@ -91,7 +108,6 @@ BLUEPRINTS = [
for name, _ in BLUEPRINTS:
logger.info("Registered blueprint '%s' at /api/%s", name, name)
# ---------------------------------------------------------------------------
# Request logging
# ---------------------------------------------------------------------------
@@ -99,11 +115,20 @@ for name, _ in BLUEPRINTS:
@app.before_request
def _log_request_start():
"""Record request start time for duration tracking."""
request._start_time = time.monotonic()
@app.after_request
def _log_request_finish(response):
"""Log request duration and status code after response generation.
Args:
response: The HTTP response object.
Returns:
The unchanged response object.
"""
elapsed_ms = (
time.monotonic() - getattr(request, "_start_time", time.monotonic())
) * 1000
@@ -114,241 +139,65 @@ def _log_request_finish(response):
response.status_code,
elapsed_ms,
)
# Set cache headers: short in dev, long with staleness tolerance in prod
if response.content_type.startswith("text/html"):
# index.html: always short cache so browser revalidates
response.headers["Cache-Control"] = "no-cache"
elif response.content_type.startswith(("text/javascript", "text/css")):
if _DEV_MODE:
response.headers["Cache-Control"] = "max-age=5"
else:
response.headers["Cache-Control"] = (
"public, max-age=31536000, stale-while-revalidate=86400"
)
return response
# ---------------------------------------------------------------------------
# Jinja2 custom filters
# API proxy routes
# ---------------------------------------------------------------------------
@app.template_filter("timestamp")
def timestamp_filter(value):
if not value:
return ""
@app.route("/api/status/all")
def api_status_all():
"""Return aggregated status from all subsystems.
Proxies the daemon's ``/status/all`` endpoint for SPA consumption.
Returns:
JSON response with state data for all subsystems.
"""
try:
dt = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
return dt.strftime("%Y-%m-%d %H:%M:%S")
except (ValueError, TypeError):
return str(value)
@app.template_filter("bytes")
def bytes_filter(value):
try:
num = float(value)
except (ValueError, TypeError):
return str(value)
if num < 0:
return "0 B"
for unit in ("B", "KB", "MB", "GB", "TB"):
if abs(num) < 1024:
return f"{num:.1f} {unit}"
num /= 1024
return f"{num:.1f} PB"
@app.template_filter("duration")
def duration_filter(value):
try:
total = int(float(value))
except (ValueError, TypeError):
return str(value)
if total < 0:
return "0s"
parts = []
days, remainder = divmod(total, 86400)
hours, remainder = divmod(remainder, 3600)
minutes, seconds = divmod(remainder, 60)
if days:
parts.append(f"{days}d")
if hours:
parts.append(f"{hours}h")
if minutes:
parts.append(f"{minutes}m")
parts.append(f"{seconds}s")
return " ".join(parts)
@app.template_filter("json_pretty")
def json_pretty_filter(value):
import json
try:
return json.dumps(value, indent=2, default=str)
except (TypeError, ValueError):
return str(value)
# ---------------------------------------------------------------------------
# Page routes
# ---------------------------------------------------------------------------
def _safely(fn, default=None):
"""Call *fn* and return *default* on any exception."""
try:
return fn()
return {"ok": True, "data": get(GET_STATUS_ALL)}
except Exception as exc:
logger.warning("WebUI data load failed: %s", exc)
return default
logger.warning("Status all failed: %s", exc)
return {"ok": False, "error": str(exc)}, 500
def _get_service_status(dnsmasq_info, wg_info):
"""Build a service status dict for the dashboard template."""
services = {}
if dnsmasq_info:
services["Dnsmasq"] = {
"running": dnsmasq_info.get("service_active", False),
}
if wg_info:
services["WireGuard"] = {
"running": wg_info.get("up", False),
}
return services
# ---------------------------------------------------------------------------
# SPA catch-all
# ---------------------------------------------------------------------------
def _fw_config_get() -> dict[str, Any]:
"""Read firewall config via daemon."""
return get("/firewall/config")
def _load_status_all() -> dict[str, Any]:
"""Load all system state in one call."""
return get("/status/all")
SPA_DIR = STATIC_DIR
@app.route("/")
def root_redirect():
from flask import redirect, url_for
@app.route("/<path:path>")
def spa_page(path=""):
"""Single-page application catch-all.
return redirect(url_for("dashboard"))
@app.route("/dashboard")
def dashboard():
all_status = _safely(_load_status_all, {})
fw_state = all_status.get("firewall", {}) or {}
dm_state = all_status.get("dnsmasq", {}) or {}
ng_state = all_status.get("nginx", {}) or {}
ac_state = all_status.get("acme", {}) or {}
wg_state = all_status.get("wireguard", {}) or {}
active_zones = {k: v for k, v in fw_state.get("active_zones", {}).items()}
interfaces = fw_state.get("interfaces", [])
dnsmasq = dm_state.get("status", {})
domains = ng_state.get("domains", [])
certs = ac_state.get("certs", [])
wg = wg_state.get("status", {})
return render_template(
"dashboard.html",
active_zones=active_zones,
interfaces=interfaces,
dnsmasq=dnsmasq,
domains=domains,
certs=certs,
wg_status=wg,
services=_get_service_status(dnsmasq, wg),
firewall_config=_safely(_fw_config_get, {}),
firewall_pending=fw_state.get("pending", {}),
)
@app.route("/interfaces")
def interfaces_page():
all_status = _safely(_load_status_all, {})
fw_state = all_status.get("firewall", {}) or {}
return render_template(
"interfaces.html",
interfaces=fw_state.get("interfaces", []),
zones=fw_state.get("active_zones", {}).keys() or [],
firewall_config=_safely(_fw_config_get, {}),
firewall_pending=fw_state.get("pending", {}),
)
@app.route("/zones")
def zones_page():
all_status = _safely(_load_status_all, {})
fw_state = all_status.get("firewall", {}) or {}
return render_template(
"zones.html",
zones=list(fw_state.get("zones", {}).values()),
services=fw_state.get("available_services", []),
firewall_config=_safely(_fw_config_get, {}),
firewall_pending=fw_state.get("pending", {}),
)
@app.route("/rules")
def rules_page():
all_status = _safely(_load_status_all, {})
fw_state = all_status.get("firewall", {}) or {}
zones = list(fw_state.get("zones", {}).keys())
rules: dict[str, list[str]] = {}
for zname, zcfg in fw_state.get("zones", {}).items():
rr = zcfg.get("rich-rules", [])
if rr:
rules[zname] = rr
return render_template("rules.html", zones=zones, rules=rules or None)
@app.route("/nat")
def nat_page():
all_status = _safely(_load_status_all, {})
fw_state = all_status.get("firewall", {}) or {}
return render_template("nat.html", zones=list(fw_state.get("zones", {}).values()))
@app.route("/dhcp")
def dhcp_page():
all_status = _safely(_load_status_all, {})
dm_state = all_status.get("dnsmasq", {}) or {}
return render_template(
"dhcp.html",
config=dm_state.get("config", {}),
status=dm_state.get("status", {}),
leases=dm_state.get("leases", []),
)
@app.route("/proxy")
def proxy_page():
all_status = _safely(_load_status_all, {})
ng_state = all_status.get("nginx", {}) or {}
return render_template(
"proxy.html",
domains=ng_state.get("domains", []),
config=ng_state.get("config", {}),
)
@app.route("/certs")
def certs_page():
all_status = _safely(_load_status_all, {})
ac_state = all_status.get("acme", {}) or {}
return render_template(
"certs.html",
certs=ac_state.get("certs", []),
email=ac_state.get("email", ""),
)
@app.route("/wireguard")
def wireguard_page():
all_status = _safely(_load_status_all, {})
wg_state = all_status.get("wireguard", {}) or {}
return render_template(
"wireguard.html",
config=wg_state.get("config", {}),
status=wg_state.get("status", {}),
)
@app.route("/logs")
def logs_page():
return render_template("logs.html")
Serves ``index.html`` (rendered as a Jinja2 template) for all non-API,
non-static paths. The client-side router handles navigation and defaults
to ``#dashboard``.
"""
if path.startswith("api/") or path.startswith("static/"):
abort(404)
scheme = "wss" if request.is_secure else "ws"
ws_url = f"{scheme}://{request.host}/ws"
html = (SPA_DIR / "index.html").read_text()
return html.replace("__WS_URL_PLACEHOLDER__", ws_url)
if __name__ == "__main__":
+202 -478
View File
@@ -1,497 +1,221 @@
// Toast notifications
const showToast = (message, type, duration = 4000) => {
const container = document.getElementById('toast-container');
if (!container) return;
const toast = document.createElement('div');
toast.className = 'toast toast-' + type;
toast.textContent = message;
container.appendChild(toast);
requestAnimationFrame(() => toast.classList.add('show'));
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, duration);
};
import { h, render, Link, hComp, ToastContainer, connect, apiFetch, modelRegister, modelFetch, reactive } from '/static/hoover/index.js?v=7';
const showSuccessToast = (msg) => showToast(msg, 'success');
import DashboardPage from '/static/pages/dashboard.js?v=7';
import InterfacesPage from '/static/pages/interfaces.js?v=7';
import ZonesPage from '/static/pages/zones.js?v=7';
import RulesPage from '/static/pages/rules.js?v=7';
import NatPage from '/static/pages/nat.js?v=7';
import DhcpPage from '/static/pages/dhcp.js?v=7';
import ProxyPage from '/static/pages/proxy.js?v=7';
import CertsPage from '/static/pages/certs.js?v=7';
import WireguardPage from '/static/pages/wireguard.js?v=7';
import LogsPage from '/static/pages/logs.js?v=7';
import NotFoundPage from '/static/pages/notfound.js?v=7';
const showErrorToast = (msg) => showToast(msg, 'error');
/* ── Navigation items ──────────────────────────────────────── */
const Nav = [
{ path: '/dashboard', label: 'Dashboard' },
{ path: '/interfaces', label: 'Interfaces' },
{ path: '/zones', label: 'Zones' },
{ path: '/rules', label: 'Rules' },
{ path: '/nat', label: 'NAT' },
{ path: '/dhcp', label: 'DHCP' },
{ path: '/proxy', label: 'Proxy' },
{ path: '/certs', label: 'Certs' },
{ path: '/wireguard', label: 'WireGuard' },
{ path: '/logs', label: 'Logs' },
];
// Modal helpers
const openModal = (id) => {
const el = document.getElementById(id);
if (el) el.classList.add('active');
};
const closeModal = (id) => {
const el = document.getElementById(id);
if (el) el.classList.remove('active');
};
// Tab switching
let switchTab = (tabName) => {
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.tab').forEach(el => el.classList.remove('active'));
document.getElementById('tab-' + tabName).classList.add('active');
const clickedTab = document.querySelector('.tab[data-tab="' + tabName + '"]');
if (clickedTab) clickedTab.classList.add('active');
};
// Refresh a container from a JSON GET endpoint using a renderer callback
const refreshTable = (url, container, renderer) => {
fetch(url)
.then(r => r.json())
.then(data => {
const json = data.ok ? data.data : data;
container.innerHTML = renderer(json);
htmx.process(container);
})
.catch(() => {});
};
// HTMX event handlers
document.body.addEventListener('htmx:afterSwap', (evt) => {
const toastHeader = evt.detail.xhr?.getResponseHeader('X-Toast');
if (toastHeader) {
const parts = toastHeader.split(':');
const msg = parts.slice(1).join(':').trim();
showToast(msg, parts[0]?.trim() || 'info');
}
/* ── Model registration ────────────────────────────────────── */
modelRegister('status', {
subsystem: 'status',
fetch: async () => {
const r = await apiFetch('/api/status/all');
if (!r.ok) throw new Error(r.error);
return r.data;
},
});
document.body.addEventListener('htmx:responseError', (evt) => {
const status = evt.detail.xhr?.status || 0;
const json = evt.detail.xhr?.response;
let msg = 'Request failed (' + status + ')';
try {
const parsed = JSON.parse(json);
if (parsed.error) msg = parsed.error;
} catch (e) {}
showToast(msg, 'error');
modelRegister('firewall', {
subsystem: 'firewall',
fetch: async () => {
const [cfg, zones, services, interfaces, state] = await Promise.allSettled([
apiFetch('/api/firewall/config'),
apiFetch('/api/firewall/zones'),
apiFetch('/api/firewall/services'),
apiFetch('/api/firewall/interfaces'),
apiFetch('/api/firewall/state'),
]);
const result = {};
if (cfg.status === 'fulfilled' && cfg.value.ok) result.config = cfg.value.data || {};
else if (cfg.status === 'rejected' || !cfg.value.ok) throw new Error(cfg.status === 'rejected' ? (cfg.reason?.message || 'Failed') : (cfg.value.error || 'Failed'));
if (zones.status === 'fulfilled' && zones.value.ok) result.zones = zones.value.data || {};
else if (zones.status === 'rejected' || !zones.value.ok) throw new Error(zones.status === 'rejected' ? (zones.reason?.message || 'Failed') : (zones.value.error || 'Failed'));
if (services.status === 'fulfilled' && services.value.ok) result.services = services.value.data || [];
else if (services.status === 'rejected' || !services.value.ok) throw new Error(services.status === 'rejected' ? (services.reason?.message || 'Failed') : (services.value.error || 'Failed'));
if (interfaces.status === 'fulfilled' && interfaces.value.ok) result.interfaces = interfaces.value.data || [];
else if (interfaces.status === 'rejected' || !interfaces.value.ok) throw new Error(interfaces.status === 'rejected' ? (interfaces.reason?.message || 'Failed') : (interfaces.value.error || 'Failed'));
if (state.status === 'fulfilled' && state.value.ok) result.state = state.value.data || {};
return result;
},
});
document.body.addEventListener('htmx:beforeRequest', (evt) => {
const btn = evt.target.closest('.btn');
if (btn) {
btn.dataset.originalText = btn.textContent;
btn.disabled = true;
btn.textContent = 'Loading...';
}
modelRegister('network', {
subsystem: 'networkd',
fetch: async () => {
const r = await apiFetch('/api/network/interfaces');
if (!r.ok) throw new Error(r.error);
return r.data || { interfaces: {} };
},
});
document.body.addEventListener('htmx:afterRequest', (evt) => {
const btn = evt.target.closest('.btn');
if (btn && btn.dataset.originalText !== undefined) {
btn.disabled = false;
btn.textContent = btn.dataset.originalText;
delete btn.dataset.originalText;
}
modelRegister('dnsmasq', {
subsystem: 'dnsmasq',
fetch: async () => {
const [cfg, status, leases] = await Promise.allSettled([
apiFetch('/api/dhcp/config'),
apiFetch('/api/dhcp/status'),
apiFetch('/api/dhcp/leases'),
]);
const result = {};
if (cfg.status === 'fulfilled' && cfg.value.ok) result.config = cfg.value.data || {};
else if (cfg.status === 'rejected' || !cfg.value.ok) throw new Error(cfg.status === 'rejected' ? (cfg.reason?.message || 'Failed') : (cfg.value.error || 'Failed'));
if (status.status === 'fulfilled' && status.value.ok) result.status = status.value.data || {};
else if (status.status === 'rejected' || !status.value.ok) throw new Error(status.status === 'rejected' ? (status.reason?.message || 'Failed') : (status.value.error || 'Failed'));
if (leases.status === 'fulfilled' && leases.value.ok) result.leases = leases.value.data || [];
else if (leases.status === 'rejected' || !leases.value.ok) throw new Error(leases.status === 'rejected' ? (leases.reason?.message || 'Failed') : (leases.value.error || 'Failed'));
return result;
},
});
// Keyboard: Escape closes all modals
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
document.querySelectorAll('.modal-overlay.active').forEach(el => el.classList.remove('active'));
}
modelRegister('nginx', {
subsystem: 'nginx',
fetch: async () => {
const r = await apiFetch('/api/proxy/domains');
if (!r.ok) throw new Error(r.error);
return r.data || [];
},
});
// -------- Renderer helpers for htmx-driven DOM updates --------
modelRegister('acme', {
subsystem: 'acme',
fetch: async () => {
const r = await apiFetch('/api/certs/list');
if (!r.ok) throw new Error(r.error);
return r.data || [];
},
});
const renderZones = (data) => {
const active = Array.isArray(data) ? data : (data.active || []);
if (!active.length) return '<div class="card"><div class="text-muted text-sm">No zones configured. Create a zone to get started.</div></div>';
return active.map(zone =>
'<div class="card" style="position:relative;">' +
'<div style="display:flex;justify-content:space-between;align-items:flex-start;">' +
'<div><h3 style="font-size:16px;color:var(--accent);">' + escHtml(zone.name) + '</h3>' +
'<div class="text-muted text-sm" style="margin-bottom:10px;">' + (zone.target ? 'Target: ' + escHtml(zone.target) : '') + '</div></div></div>' +
'<div class="text-sm mb-4"><div class="text-muted" style="margin-bottom:4px;">Interfaces</div>' +
(zone.interfaces && zone.interfaces.length ? zone.interfaces.map(i => '<span class="badge badge-info">' + escHtml(i) + '</span>').join('') : '<span class="text-muted">None</span>') +
'</div><div class="text-sm mb-4"><div class="text-muted" style="margin-bottom:4px;">Services</div>' +
(zone.services && zone.services.length ? zone.services.map(s => '<span class="badge badge-success">' + escHtml(s) + '</span>').join('') : '<span class="text-muted">None</span>') +
'</div><div style="display:flex;justify-content:flex-end;gap:6px;margin-top:12px;">' +
'<form hx-delete="/api/firewall/zones/' + escAttr(zone.name) + '" hx-swap="none" hx-confirm="Delete zone ' + escHtml(zone.name) + '? This will affect traffic to its interfaces." hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/firewall/zones\', document.getElementById(\'zone-grid\'), renderZones); showSuccessToast(\'Zone deleted\'); }">' +
'<button type="submit" class="btn btn-sm btn-danger">Delete</button></form></div></div>'
).join('');
modelRegister('wireguard', {
subsystem: 'wireguard',
fetch: async () => {
const [stR, pR, cfgR] = await Promise.allSettled([
apiFetch('/api/wireguard/status'),
apiFetch('/api/wireguard/peers'),
apiFetch('/api/wireguard/config'),
]);
const result = {};
if (stR.status === 'fulfilled' && stR.value.ok) result.status = stR.value.data || {};
else if (stR.status === 'rejected' || !stR.value.ok) throw new Error(stR.status === 'rejected' ? (stR.reason?.message || 'Failed') : (stR.value.error || 'Failed'));
if (pR.status === 'fulfilled' && pR.value.ok) result.peers = pR.value.data || [];
else if (pR.status === 'rejected' || !pR.value.ok) throw new Error(pR.status === 'rejected' ? (pR.reason?.message || 'Failed') : (pR.value.error || 'Failed'));
if (cfgR.status === 'fulfilled' && cfgR.value.ok) result.config = cfgR.value.data || {};
return result;
},
});
const LOG_TABS = {
journal: '/api/logs/journal',
'nginx-access': '/api/logs/nginx/access',
'nginx-error': '/api/logs/nginx/error',
dnsmasq: '/api/logs/dnsmasq',
app: '/api/logs/app',
};
const renderRules = (data) => {
let html = '';
let zoneRules = {};
const cfgZones = data && data.zones ? data.zones : null;
if (cfgZones) {
Object.keys(cfgZones).forEach(zname => {
const rr = cfgZones[zname].rich_rules || [];
if (rr.length) zoneRules[zname] = rr;
});
} else {
zoneRules = data || {};
modelRegister('logs', {
subsystem: '*',
fetch: async (signal, tab) => {
const tabKey = tab || 'journal';
const url = LOG_TABS[tabKey];
if (!url) throw new Error('Unknown log tab: ' + tabKey);
const r = await apiFetch(url, { signal });
if (!r.ok) throw new Error(r.error);
return { data: (r.data || '').split('\n').filter(l => l.length > 0), tab: tabKey };
},
});
/* ── Initial fetch ─────────────────────────────────────────── */
for (const name of ['status', 'firewall', 'network', 'dnsmasq', 'nginx', 'wireguard', 'acme']) {
modelFetch(name);
}
modelFetch('logs', 'journal');
/* ── Page map ──────────────────────────────────────────────── */
const Pages = {
dashboard: DashboardPage,
interfaces: InterfacesPage,
zones: ZonesPage,
rules: RulesPage,
nat: NatPage,
dhcp: DhcpPage,
proxy: ProxyPage,
certs: CertsPage,
wireguard: WireguardPage,
logs: LogsPage,
};
/* ── Router ────────────────────────────────────────────────── */
const router = {
state: reactive({ path: location.hash.slice(1) || '/dashboard' }),
component() {
const name = this.state.path.replace(/^\//, '');
const page = Pages[name] || NotFoundPage;
return hComp(page, this.state.path);
},
};
window.location.hash || (window.location.hash = router.state.path);
window.addEventListener('hashchange', () => {
router.state.path = location.hash.slice(1) || '/dashboard';
});
/* ── Sidebar render root ───────────────────────────────────── */
function Sidebar() {
const current = router.state.path;
return h('div', { class: 'sidebar' },
h('div', { class: 'logo' }, 'Vacuum Wall'),
h('nav', null,
Nav.map(item =>
Link({
path: item.path,
class: current === item.path ? 'active' : '',
children: [item.label],
}),
),
),
);
}
/* ── Main content render root ──────────────────────────────── */
function MainContent() {
return [
router.component(),
ToastContainer(),
];
}
/* ── Init ──────────────────────────────────────────────────── */
export function initApp() {
const sidebarEl = document.getElementById('sidebar');
const mainEl = document.getElementById('main');
if (sidebarEl && mainEl) {
render(sidebarEl, Sidebar);
render(mainEl, MainContent);
}
Object.keys(zoneRules).forEach(zone => {
let entries = zoneRules[zone];
if (!Array.isArray(entries)) entries = [];
html += '<div class="card"><h3>Zone: <span style="color:var(--accent);">' + escHtml(zone || '(default)') + '</span></h3>';
if (entries.length) {
html += '<table><thead><tr><th>#</th><th>Rule</th><th style="width:80px;">Action</th></tr></thead><tbody>';
entries.forEach((entry, i) => {
let ruleId, ruleText;
if (typeof entry === 'object' && entry.rule) {
ruleId = entry.id;
ruleText = entry.rule;
} else {
ruleId = null;
ruleText = String(entry);
}
html += '<tr><td class="text-muted">' + (i + 1) + '</td>' +
'<td style="font-family:monospace;font-size:12px;word-break:break-all;" hx-disable>' + escHtml(ruleText) + '</td>' +
'<td><form hx-delete="/api/firewall/rich-rules/' + escAttr(zone) + (ruleId ? '/' + encodeURIComponent(ruleId) : '') + '"' +
(ruleId ? '' : ' hx-encoding="json" hx-vals=\'{"rule": ' + JSON.stringify(ruleText) + ' }\'') +
' hx-swap="none" hx-confirm="Remove rule ' + escHtml(ruleText.substring(0, 40)) + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/firewall/config\', document.getElementById(\'rules-container\'), renderRules); showSuccessToast(\'Rule removed\'); }">' +
'<button type="submit" class="btn btn-sm btn-danger">Remove</button></form></td></tr>';
});
html += '</tbody></table>';
} else {
html += '<div class="text-muted text-sm">No rich rules configured for this zone.</div>';
}
html += '</div>';
});
return html || '<div class="card"><div class="text-muted text-sm">No rules loaded.</div></div>';
};
const renderForwards = (forwards) => {
if (!forwards.length) return '<tr><td colspan="6" class="text-muted text-sm">No port forwarding rules configured</td></tr>';
return forwards.map(fwd => {
const proto = fwd['proxy-protocol'] || fwd.proto;
return '<tr><td><strong>' + escHtml(fwd.zone) + '</strong></td>' +
'<td><span class="badge badge-info">' + escHtml(proto) + '</span></td>' +
'<td>' + fwd.port + '</td><td>' + escHtml(fwd['to-addr'] || fwd.toaddr) + '</td>' +
'<td>' + (fwd['to-port'] || fwd.toport || '-') + '</td>' +
'<td><form hx-delete="/api/firewall/forward-port/' + encodeURIComponent(fwd.zone) + '/' + fwd.port + '/' + encodeURIComponent(proto) + '" hx-swap="none" hx-confirm="Remove forward rule ' + fwd.port + '/' + proto + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/firewall/config\', document.getElementById(\'forward-rows\'), renderForwardsFromConfig); showSuccessToast(\'Rule removed\'); }">' +
'<button type="submit" class="btn btn-sm btn-danger">Remove</button></form></td></tr>';
}).join('');
};
const renderForwardsFromConfig = (data) => {
const zones = data.zones || {};
const forwards = [];
Object.keys(zones).forEach(name => {
zones[name].forward_ports = zones[name].forward_ports || [];
zones[name].forward_ports.forEach(fwd => {
forwards.push({
zone: name,
'proxy-protocol': fwd['proxy-protocol'] || fwd.proto,
port: fwd.port,
'to-addr': fwd['to-addr'] || fwd.toaddr,
'to-port': fwd['to-port'] || fwd.toport
});
});
});
return renderForwards(forwards);
};
const renderRanges = (ranges) => {
if (!ranges.length) return '<tr><td colspan="5" class="text-muted text-sm">No DHCP ranges configured</td></tr>';
return ranges.map(rng =>
'<tr><td>' + escHtml(rng.interface || '(global)') + '</td>' +
'<td>' + escHtml(rng.start) + '</td><td>' + escHtml(rng.end) + '</td>' +
'<td>' + escHtml(rng.lease_time || '1h') + '</td>' +
'<td><form hx-delete="/api/dhcp/ranges" hx-encoding="json" hx-vals=\'{"interface": "' + escAttr(rng.interface || '') + '", "start": "' + escAttr(rng.start) + '", "end": "' + escAttr(rng.end) + '"}\' hx-swap="none" hx-confirm="Remove DHCP range ' + escHtml(rng.start) + ' - ' + escHtml(rng.end) + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/dhcp/config\', document.getElementById(\'range-rows\'), renderRanges); showSuccessToast(\'Range removed\'); }">' +
'<button type="submit" class="btn btn-sm btn-danger">Remove</button></form></td></tr>'
).join('');
};
const renderStaticLeases = (leases) => {
if (!leases.length) return '<tr><td colspan="4" class="text-muted text-sm">No static leases configured</td></tr>';
return leases.map(lease =>
'<tr><td>' + escHtml(lease.mac) + '</td><td>' + escHtml(lease.ip) + '</td>' +
'<td>' + escHtml(lease.hostname || '-') + '</td>' +
'<td><form hx-delete="/api/dhcp/static-lease/' + encodeURIComponent(lease.mac) + '" hx-swap="none" hx-confirm="Remove lease ' + escHtml(lease.mac) + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/dhcp/config\', document.getElementById(\'lease-rows\'), renderStaticLeases); showSuccessToast(\'Lease removed\'); }">' +
'<button type="submit" class="btn btn-sm btn-danger">Remove</button></form></td></tr>'
).join('');
};
const renderDnsRecords = (records) => {
if (!records.length) return '<tr><td colspan="3" class="text-muted text-sm">No custom DNS records</td></tr>';
return records.map(rec =>
'<tr><td><strong>' + escHtml(rec.name || 'unnamed') + '</strong></td>' +
'<td class="text-sm">' + escHtml(rec.address || '-') + '</td>' +
'<td><form hx-delete="/api/dhcp/dns-record/' + encodeURIComponent(rec.name) + '" hx-swap="none" hx-confirm="Remove DNS record ' + escHtml(rec.name || 'unnamed') + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/dhcp/config\', document.getElementById(\'dns-rows\'), renderDnsRecords); showSuccessToast(\'Record removed\'); }">' +
'<button type="submit" class="btn btn-sm btn-danger">Remove</button></form></td></tr>'
).join('');
};
const renderDomains = (domains) => {
if (!domains.length) return '<tr><td colspan="6" class="text-muted text-sm">No proxy domains configured. Add a domain to start terminating SSL.</td></tr>';
return domains.map(d => {
let certHtml = '<span class="badge badge-danger">' + (d.cert_status || 'No cert') + '</span>';
if (d.cert_status === 'expired') certHtml = '<span class="badge badge-danger">Expired</span>';
else if (d.cert_status === 'valid' || d.cert_status === 'active') certHtml = '<span class="badge badge-success">Valid</span>';
else if (typeof d.days_remaining === 'number') {
if (d.days_remaining <= 0) certHtml = '<span class="badge badge-danger">Expired</span>';
else if (d.days_remaining <= 30) certHtml = '<span class="badge badge-warning">' + d.days_remaining + 'd</span>';
else certHtml = '<span class="badge badge-success">Valid</span>';
}
return '<tr><td><strong>' + escHtml(d.domain) + '</strong></td>' +
'<td>' + escHtml(d.backend_host || '-') + '</td>' +
'<td>' + (d.backend_port || '-') + '</td>' +
'<td><span class="badge badge-info">' + escHtml(d.protocol || 'http') + '</span></td>' +
'<td>' + certHtml + '</td>' +
'<td><div class="flex gap-2">' +
'<button class="btn btn-sm btn-outline" onclick="openEditDomainModal(\'' + escAttr(d.domain) + '\', ' + JSON.stringify(d) + ')">Edit</button>' +
'<form hx-delete="/api/proxy/domains/' + escAttr(d.domain) + '" hx-swap="none" hx-confirm="Remove proxy for ' + escHtml(d.domain) + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/proxy/domains\', document.getElementById(\'domain-rows\'), renderDomains); showSuccessToast(\'Domain removed\'); }">' +
'<button type="submit" class="btn btn-sm btn-danger">Delete</button></form></div></td></tr>';
}).join('');
};
const renderPeers = (peers) => {
if (!peers.length) return '<tr><td colspan="7" class="text-muted text-sm">No peers configured. Add a peer above.</td></tr>';
return peers.map(peer =>
'<tr><td><span class="status-dot ' + (peer.latest_handshake ? 'status-up' : 'status-down') + '"></span>' +
'<strong>' + escHtml(peer.name || 'unnamed') + '</strong></td>' +
'<td style="font-family:monospace;font-size:11px;">' + escHtml((peer.public_key || 'N/A').substring(0, 20)) + '...</td>' +
'<td class="text-sm">' + escHtml(peer.allowed_ips || '-') + '</td>' +
'<td class="text-sm">' + escHtml(peer.endpoint || '-') + '</td>' +
'<td class="text-sm">' + escHtml(peer.latest_handshake || 'Never') + '</td>' +
'<td class="text-sm"><div>Recv: ' + escHtml(peer.transfer_recv || '0') + '</div><div>Sent: ' + escHtml(peer.transfer_sent || '0') + '</div></td>' +
'<td><div class="flex gap-2">' +
'<button class="btn btn-sm btn-outline" onclick="downloadPeerConfig(\'' + escAttr(peer.name) + '\')">Config</button>' +
'<form hx-delete="/api/wireguard/peers/' + encodeURIComponent(peer.name) + '" hx-swap="none" hx-confirm="Remove peer ' + escHtml(peer.name) + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/wireguard/peers\', document.getElementById(\'peer-rows\'), renderPeers); showSuccessToast(\'Peer removed\'); }">' +
'<button type="submit" class="btn btn-sm btn-danger">Remove</button></form></div></td></tr>'
).join('');
};
const renderCerts = (certs) => {
if (!certs.length) return '<tr><td colspan="5" class="text-muted text-sm">No certificates found. Issue a certificate to get started.</td></tr>';
return certs.map(cert => {
const days = cert.days_remaining;
let badgeHtml;
if (cert.expired || (days !== undefined && days <= 0)) {
badgeHtml = '<span class="badge badge-danger">Expired' + (days !== undefined && Math.abs(days) ? ' (' + Math.abs(days) + 'd ago)' : '') + '</span>';
} else if (days !== undefined && days <= 30) {
badgeHtml = '<span class="badge badge-warning">' + days + ' days</span>';
} else {
badgeHtml = '<span class="badge badge-success">' + (days !== undefined ? days + ' days' : 'N/A') + '</span>';
}
return '<tr><td><strong>' + escHtml(cert.domain || 'unknown') + '</strong></td>' +
'<td class="text-sm">' + escHtml(cert.issuer || '-') + '</td>' +
'<td>' + escHtml(cert.expiry || 'N/A') + '</td>' +
'<td>' + badgeHtml + '</td>' +
'<td><form hx-post="/api/certs/' + escAttr(cert.domain) + '/renew" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/certs/list\', document.getElementById(\'cert-rows\'), renderCerts); showSuccessToast(\'Renewal started for ' + escHtml(cert.domain) + '\'); }">' +
'<button type="submit" class="btn btn-sm btn-outline">Renew</button></form></td></tr>';
}).join('');
};
const renderInterfaces = (interfaces) => {
if (!interfaces.length) return '<tr><td colspan="5" class="text-muted text-sm">No interfaces found</td></tr>';
return interfaces.map(iface => {
const zoneOptions = (iface.zones || []).map(z =>
'<option value="' + escAttr(z) + '"' + (z === iface.zone ? ' selected' : '') + '>' + escHtml(z) + '</option>'
).join('');
return '<tr><td><strong>' + escHtml(iface.name) + '</strong></td>' +
'<td class="text-muted">' + escHtml(iface.mac || 'N/A') + '</td>' +
'<td>' + (iface.ips && iface.ips.length ? iface.ips.map(ip => escHtml(ip)).join(', ') : 'N/A') + '</td>' +
'<td><span class="status-dot ' + (iface.state === 'up' ? 'status-up' : 'status-down') + '"></span>' +
(iface.state === 'up' ? 'Up' : 'Down') + '</td>' +
'<td><select hx-on::change="assignZone(\'' + escAttr(iface.name) + '\', this)">' + zoneOptions + '</select></td></tr>';
}).join('');
};
const assignZone = (ifaceName, selectEl) => {
fetch('/api/firewall/zones/' + encodeURIComponent(selectEl.value) + '/interfaces', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ interfaces: [ifaceName] })
})
.then(r => {
if (r.ok) {
showSuccessToast(ifaceName + ' assigned to ' + selectEl.value);
refreshTable('/api/firewall/interfaces', document.getElementById('interface-list'), renderInterfaces);
}
else return r.json().then(j => { throw new Error(j.error || r.statusText); });
})
.catch(e => { showErrorToast(e.message); });
};
const escHtml = (s) => {
const div = document.createElement('div');
div.appendChild(document.createTextNode(s));
return div.innerHTML;
};
const escAttr = (s) => {
return String(s).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#39;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
};
// ─── Certificate Issue Wizard ────────────────────────────────────────
let _issuePollHandle = null;
let _issueRequestId = null;
function closeIssueWizard() {
if (_issuePollHandle) {
clearInterval(_issuePollHandle);
_issuePollHandle = null;
}
_issueRequestId = null;
resetIssueWizard();
closeModal('issue-cert-modal');
// Defer connect() after the first render microtask settles to prevent
// the initial requestUpdate() from triggering a second commit while
// the vnode tree is still being finalized.
setTimeout(connect, 0);
}
function resetIssueWizard() {
document.getElementById('cert-wizard-input').style.display = '';
document.getElementById('cert-wizard-progress').style.display = 'none';
document.getElementById('cert-check-results').style.display = 'none';
document.getElementById('cert-check-btn').style.display = '';
document.getElementById('cert-issue-btn').style.display = 'none';
document.getElementById('cert-close-progress').style.display = 'none';
}
function validateCertIssue() {
const domain = document.getElementById('cert-domain').value.trim();
if (!domain) {
showErrorToast('Domain is required');
return;
}
const email = document.getElementById('cert-email').value.trim() || undefined;
const checkBtn = document.getElementById('cert-check-btn');
checkBtn.disabled = true;
checkBtn.textContent = 'Checking...';
fetch('/api/certs/validate', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ domain, email })
})
.then(r => r.json())
.then(data => {
checkBtn.disabled = false;
checkBtn.textContent = 'Check';
const result = data.ok ? data.data : data;
renderChecks(result.checks);
if (result.ready) {
document.getElementById('cert-check-btn').style.display = 'none';
document.getElementById('cert-issue-btn').style.display = '';
} else {
document.getElementById('cert-issue-btn').style.display = 'none';
}
})
.catch(e => {
checkBtn.disabled = false;
checkBtn.textContent = 'Check';
showErrorToast('Validation failed: ' + e.message);
});
}
function renderChecks(checks) {
const container = document.getElementById('cert-checks-list');
const resultsDiv = document.getElementById('cert-check-results');
resultsDiv.style.display = '';
container.innerHTML = checks.map(c => {
let icon, badge;
if (c.passed) {
icon = '✓';
badge = c.blocking ? 'badge-success' : 'badge-info';
} else {
icon = '✗';
badge = 'badge-danger';
}
return '<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;font-size:12px;">' +
'<span class="badge ' + badge + '">' + icon + '</span>' +
'<span>' + escHtml(c.name).replace(/_/g, ' ') + '</span>' +
'<span class="text-muted" style="flex:1;text-align:right;">' + escHtml(c.message || '') + '</span>' +
'</div>';
}).join('');
}
function startCertIssue() {
const domain = document.getElementById('cert-domain').value.trim();
const email = document.getElementById('cert-email').value.trim() || undefined;
document.getElementById('cert-wizard-input').style.display = 'none';
document.getElementById('cert-wizard-progress').style.display = '';
document.getElementById('cert-steps-list').innerHTML = '<div class="text-muted text-sm" style="margin:16px 0;">Starting certificate issuance…</div>';
fetch('/api/certs/issue/start', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ domain, email })
})
.then(r => r.json())
.then(data => {
const result = data.ok ? data.data : data;
_issueRequestId = result.request_id;
if (!result.request_id) throw new Error('No request_id returned');
// If issuance already exists for this domain, follow the existing request
startIssuePoll(result.request_id);
})
.catch(e => {
showErrorToast('Failed to start issuance: ' + e.message);
// Fall back to input phase
document.getElementById('cert-wizard-input').style.display = '';
document.getElementById('cert-wizard-progress').style.display = 'none';
});
}
function startIssuePoll(requestId) {
_issueRequestId = requestId;
_issuePollHandle = setInterval(() => pollIssueStatus(requestId), 2000);
// Also poll immediately
pollIssueStatus(requestId);
}
function pollIssueStatus(requestId) {
fetch('/api/certs/issue/' + encodeURIComponent(requestId))
.then(r => r.json())
.then(data => {
const result = data.ok ? data.data : data;
renderIssueSteps(result.steps, result.status);
if (result.status === 'completed') {
clearInterval(_issuePollHandle);
_issuePollHandle = null;
document.getElementById('cert-close-progress').style.display = '';
showSuccessToast('Certificate issued for ' + result.domain);
} else if (result.status === 'failed') {
clearInterval(_issuePollHandle);
_issuePollHandle = null;
// Show failed — user can see which step failed
document.getElementById('cert-close-progress').style.display = '';
showErrorToast('Certificate issuance failed for ' + result.domain);
}
})
.catch(e => {
// Don't poll on error — but keep trying since request might still be running
});
}
function renderIssueSteps(steps, status) {
const container = document.getElementById('cert-steps-list');
if (!steps || !steps.length) {
container.innerHTML = '<div class="text-muted text-sm">Pending…</div>';
return;
}
container.innerHTML = steps.map(s => {
let icon;
if (s.status === 'done') icon = '<span class="status-dot status-up"></span>';
else if (s.status === 'running') icon = '<span class="status-dot status-pending"></span>';
else if (s.status === 'error') icon = '<span class="status-dot status-down"></span>';
else icon = '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--border);margin-right:6px;"></span>';
return '<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;font-size:13px;">' +
icon +
'<span>' + escHtml(s.label) + '</span>' +
(s.status === 'running' ? '<span class="text-muted text-sm">(in progress…)</span>' :
s.status === 'error' ? '<span class="badge badge-danger" style="margin-left:auto;">' + escHtml(s.message || 'failed') + '</span>' :
'<span class="badge badge-success" style="margin-left:auto;">done</span>') +
'</div>';
}).join('');
if (status === 'completed') {
container.innerHTML += '<div style="margin-top:12px;text-align:center;"><span class="badge badge-success" style="font-size:13px;padding:4px 12px;">✓ Certificate issued</span></div>';
}
}
document.addEventListener('DOMContentLoaded', initApp);
+253
View File
@@ -0,0 +1,253 @@
/**
* Hoover api.js
*
* JSON-friendly fetch wrapper with automatic header management.
* Toast notification system with auto-dismiss.
* ToastContainer component for rendering queued toasts.
*/
import { h } from './vdom.js?v=7';
import { modelFetch } from './model.js?v=7';
/**
* JSON-friendly fetch wrapper.
*
* Automatically sets Content-Type for object bodies, parses JSON
* responses, and normalises the result to { ok, data, error, status }.
*
* @param {string} url Target URL
* @param {object} [options] Fetch options (method, body, headers, )
* @returns {Promise<{ok, data, error, status}>}
*/
export async function apiFetch(url, options = {}) {
const { method = 'GET', body, ...opts } = options;
const headers = { 'Accept': 'application/json', ...opts.headers };
if (body && typeof body === 'object' && !(body instanceof FormData)) {
headers['Content-Type'] = 'application/json';
options.body = JSON.stringify(body);
}
try {
const res = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...opts });
if (opts.signal?.aborted) {
return { ok: false, data: null, error: 'Aborted', status: 0 };
}
if (res.status === 401) {
window.location.reload();
return { ok: false, data: null, error: 'Session expired', status: 401 };
}
const json = await res.json();
if (!res.ok) {
return { ok: false, data: null, error: json.error || `HTTP ${res.status}`, status: res.status };
}
return { ok: json.ok, data: json.ok ? json.data : json, error: null, status: res.status };
} catch (e) {
return { ok: false, data: null, error: e.message || 'Network error', status: 0 };
}
}
/** ─── Toast notifications ────────────────────────────────── */
/** Toast notification queue. Exported for ToastContainer component. */
export const _toasts = [];
const _toastIds = { next: 1 };
/**
* Show a toast notification. Auto-dismisses after `duration` ms.
*
* @param {string} message Toast text
* @param {string} [type] 'info' | 'success' | 'error' | 'warning'
* @param {number} [duration] Auto-dismiss timeout in ms (0 = indefinite)
* @returns {number} id
*/
export function toast(message, type = 'info', duration = 4000) {
const id = _toastIds.next++;
_toasts.push({ id, message, type, createdAt: Date.now(), duration });
if (duration > 0) setTimeout(() => dismissToast(id), duration);
return id;
}
/**
* Dismiss a toast by id.
*/
export function dismissToast(id) {
const idx = _toasts.findIndex(t => t.id === id);
if (idx !== -1) _toasts.splice(idx, 1);
}
/**
* Render the queued toast notifications.
*
* @returns {VNode} Toast container (empty text node when no toasts)
*/
export function ToastContainer() {
if (!_toasts.length) return h('#text', '');
const clsMap = { info: 'toast-info', success: 'toast-success', error: 'toast-error', warning: 'toast-warning' };
return h('div', { class: 'toast-container' },
..._toasts.map(t =>
h('div', { class: `toast ${clsMap[t.type] || clsMap.info}`, 'on:click': () => dismissToast(t.id) },
h('span', null, t.message),
h('button', { class: 'toast-close', 'on:click': (e) => { e.stopPropagation(); dismissToast(t.id); } }, '\u00d7'),
),
),
);
}
/**
* Create an abort-checking function from an AbortController.
*
* @deprecated Use model layer (`modelRegister` / `modelFetch`) for data
* fetching with abort handling and loading state management.
* @param {AbortController} ac
* @returns {function} () => boolean
*/
export function checkAbort(ac) {
return () => ac?.signal?.aborted || false;
}
/**
* Standard data loading wrapper with state management and abort handling.
*
* Sets loading=true before, loading=false after, tracks errors.
*
* @deprecated Use model layer (`modelRegister` / `modelFetch`) for data
* fetching with abort handling and loading state management.
* @param {object} state - Reactive state object
* @param {function} dataKey - (s) => any, current data to compare for refresh detection
* @param {function} fetchFn - (state, signal, isAborted) => Promise
* @param {object} [opts] - Additional options
* @param {object} [opts.entry] - Component entry for requestId tracking
* @param {AbortController} [opts.abortController] - Fresh abort controller
*/
export async function refactorLoad(state, dataKey, fetchFn, opts = {}) {
const entry = opts.entry;
const myId = entry ? entry.requestId : 0;
const ab = opts.abortController;
const isAborted = ab ? checkAbort(ab) : () => false;
const signal = ab ? ab.signal : null;
if (entry) {
if (dataKey(state) !== undefined) state.refreshing = true;
else state.loading = true;
}
state.error = null;
try {
await fetchFn(state, signal, isAborted);
} catch (e) {
if (!isAborted()) state.error = e.message || 'Request failed';
} finally {
if (!isAborted()) {
if (entry) {
state.loading = false;
state.refreshing = false;
}
}
}
}
/**
* Poll a URL until success or error condition is met.
*
* @param {object} opts
* @param {string} opts.url - URL to poll
* @param {function} opts.successKey - (data) => boolean, when true poll succeeds
* @param {function} opts.onErrorKey - (data) => boolean, when true poll fails
* @param {function} [opts.onComplete] - (data) => void, called on success
* @param {function} [opts.onError] - (data) => void, called on failure
* @param {number} [opts.interval] - Poll interval in ms (default: 3000)
* @param {number} [opts.timeout] - Overall timeout in ms (default: 60000)
*/
export async function poll(opts) {
const {
url,
successKey,
onErrorKey,
onComplete,
onError,
interval = 3000,
timeout = 60000,
} = opts;
const start = Date.now();
const timer = setInterval(async () => {
if (Date.now() - start > timeout) {
clearInterval(timer);
if (onError) onError(null);
return;
}
const res = await apiFetch(url);
if (!res.ok) {
clearInterval(timer);
if (onError) onError(res);
return;
}
if (successKey(res.data)) {
clearInterval(timer);
if (onComplete) onComplete(res.data);
} else if (onErrorKey(res.data)) {
clearInterval(timer);
if (onError) onError(res.data);
}
}, interval);
}
/**
* Generate action button descriptors for modal form submission.
*
* Returns an array of action descriptors that can be spread into the
* actions array passed to formModal. First item is the submit button.
*
* @param {object} opts
* @param {string} opts.url - API URL to POST/PUT to
* @param {string} [opts.method] - HTTP method (default: 'POST')
* @param {function} [opts.body] - () => object, body builder
* @param {function} [opts.validate] - (body) => string|null, validation function
* @param {string} [opts.successMsg] - Success toast message
* @param {string|string[]} [opts.refresh] - Model name(s) to refresh via modelFetch
* @param {string} [opts.submitText] - Submit button text (default: 'Submit')
* @returns {object[]} Array of action descriptors
*/
export function apiSubmit(opts) {
const {
url,
method = 'POST',
body,
validate,
successMsg = 'Saved',
refresh,
submitText = 'Submit',
closeModal,
} = opts;
return [
{
label: submitText,
cls: 'btn-primary',
action: 's',
handler: async () => {
const b = body ? body() : {};
if (validate) {
const err = validate(b);
if (err) { toast(err, 'error'); return; }
}
const res = await apiFetch(url, { method, body: b });
if (res.ok) {
toast(successMsg, 'success');
if (closeModal) closeModal();
if (refresh) {
const models = Array.isArray(refresh) ? refresh : [refresh];
await Promise.all(models.map(m => modelFetch(m)));
}
} else {
toast(res.error || 'Failed', 'error');
}
},
},
];
}
+118
View File
@@ -0,0 +1,118 @@
/**
* Hoover component.js
*
* Component wrapper: definePage, lifecycle hooks, state caching.
*
* definePage wraps a page definition into a renderer function compatible
* with hoover's render engine. Handles reactive state creation and
* lifecycle management. Data loading is handled by the model layer.
*
* Usage:
* export default definePage({
* init() { return { firewall: getModel('firewall') }; },
* async load(state) { ... }, // optional, for one-time setup
* render(state) { return [vnodes],
* });
*/
import { reactive } from './reactivity.js?v=7';
import { h } from './vdom.js?v=7';
import { _compExpandedCache } from './render.js?v=7';
/** Registry of mounted components: key → { state } */
const _mounted = new Map();
/**
* Define a page component.
*
* @param {object} def Page definition
* @param {function} def.init Return initial state object
* @param {function} [def.load] Optional one-time setup called on mount
* @param {function} def.render Render function that returns vnodes
* @returns {object} Component renderer compatible with h('#comp', ...)
*/
export function definePage(def) {
let state = null;
let stateInitialized = false;
const renderer = () => {
if (!stateInitialized) {
state = reactive(def.init());
stateInitialized = true;
}
return def.render(state);
};
renderer._pageDef = {
get state() {
if (!stateInitialized) {
state = reactive(def.init());
stateInitialized = true;
}
return state;
},
load: def.load || null,
onUnmount: def.onUnmount || null,
};
return renderer;
}
/**
* Mount a page component. Called by the render engine when a #comp vnode
* enters the tree for the first time.
*/
export function mountComponent(key, renderer) {
const pd = renderer._pageDef;
if (!pd) return;
let entry = _mounted.get(key);
if (entry) {
// Re-mount: component already exists with its state.
// Don't re-run load — that re-render was triggered by a reactive update.
return;
}
entry = { state: pd.state };
_mounted.set(key, entry);
pd.state.error = null;
if (pd.load) {
Promise.resolve().then(() => pd.load(pd.state));
}
}
/**
* Unmount a page component. Called by the render engine when a #comp vnode
* is removed from the tree.
*/
export function unmountComponent(key, renderer) {
const entry = _mounted.get(key);
if (!entry) return;
const pd = renderer._pageDef;
if (pd.onUnmount) {
try { pd.onUnmount(entry.state); } catch (_) {}
}
_compExpandedCache.delete(key);
_mounted.delete(key);
}
/**
* Get the state of a mounted component.
*/
export function getComponentState(key) {
const entry = _mounted.get(key);
return entry ? entry.state : null;
}
/**
* Create a component vnode that the render engine will wire up to lifecycle.
*/
export function hComp(renderer, key) {
return h('#comp', { component: renderer, key }, []);
}
+316
View File
@@ -0,0 +1,316 @@
/**
* Hoover components/data.js
*
* Data display components: Badge, StatusDot, Empty, Card.
*/
import { h } from '../vdom.js?v=7';
import { esc } from '../helpers.js?v=7';
import { apiFetch, toast } from '../api.js?v=7';
import { modelFetch } from '../model.js?v=7';
/**
* Colored badge/span.
*
* @param {object} props
* @param {string} props.text Badge text
* @param {string} [props.variant] 'info' | 'success' | 'warning' | 'danger'
*/
export function Badge(props = {}) {
return h('span', { class: `badge badge-${props.variant || 'info'}` }, String(props.text || ''));
}
/**
* Status indicator dot.
*
* @param {object} props
* @param {string} props.status 'success' | 'up' | 'danger' | 'down' | 'pending'
*/
export function StatusDot(props = {}) {
const v = ['success', 'up'].includes(props.status) ? 'up' :
['danger', 'down'].includes(props.status) ? 'down' : 'pending';
return h('span', { class: `status-dot status-${v}` });
}
/**
* Empty-state placeholder card.
*
* @param {object} props
* @param {string} [props.text]
*/
export function Empty(props = {}) {
return h('div', { class: 'card' },
h('div', { class: 'text-muted text-sm' }, props.text || 'No data available'),
);
}
/**
* Card wrapper with optional header and body content.
*
* @param {object} props
* @param {string} [props.header]
* @param {VNode[]} [props.children]
*/
export function Card(props = {}) {
const key = props.key !== undefined ? { key: props.key } : {};
if (props.header) {
return h('div', { class: 'card', ...key },
h('div', { class: 'card-header' }, props.header),
h('div', { class: 'card-body' }, props.children || []),
);
}
return h('div', { class: 'card', ...key }, props.children || []);
}
/**
* A Remove button that confirms, deletes via API, toasts, and refreshes models.
*
* @param {object} props
* @param {string} props.url - API DELETE URL
* @param {string} props.message - Confirmation prompt text
* @param {string} [props.success] - Success toast message (default: 'Removed')
* @param {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch
* @param {string} [props.label] - Button text (default: 'Remove')
* @param {object} [props.body] - Optional JSON body to send with DELETE
*/
export function ConfirmDelete(props = {}) {
const opts = { method: 'DELETE' };
if (props.body) opts.body = props.body;
return h('button', { class: 'btn btn-sm btn-danger',
'on:click': async () => {
if (!confirm(props.message)) return;
const r = await apiFetch(props.url, opts);
if (r.ok) {
toast(props.success || 'Removed', 'success');
if (props.refresh) {
const names = Array.isArray(props.refresh) ? props.refresh : [props.refresh];
names.forEach(n => modelFetch(n));
}
} else {
toast(r.error || 'Failed', 'error');
}
}}, props.label || 'Remove');
}
/**
* An action button that POSTs to an API endpoint, toasts on result,
* and optionally refreshes models. Supports toggle labels for on/off buttons.
*
* @param {object} props
* @param {string} props.url - API URL
* @param {string} [props.method] - HTTP method (default: 'POST')
* @param {function} [props.body] - () => body, or undefined for no body
* @param {string} [props.label] - Button text
* @param {string} [props.labelOn] - Label when condition is true (toggle)
* @param {string} [props.labelOff] - Label when condition is false (toggle)
* @param {boolean} [props.condition] - Toggle condition for labelOn/labelOff
* @param {string} [props.successMsg] - Success toast message
* @param {string} [props.errorType] - Toast type for errors (default: 'error')
* @param {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch
* @param {string} [props.cls] - Button CSS classes (default: 'btn btn-outline')
* @param {boolean} [props.disabled] - Disabled state
*/
export function ActionButton(props = {}) {
const label = props.label !== undefined ? props.label :
(props.labelOn !== undefined && props.labelOff !== undefined
? (props.condition ? props.labelOn : props.labelOff)
: 'Action');
const cls = props.cls || 'btn btn-outline';
return h('button', {
class: cls,
disabled: props.disabled,
'on:click': async () => {
const body = props.body ? props.body() : undefined;
const opts = { method: props.method || 'POST' };
if (body !== undefined) opts.body = body;
const resp = await apiFetch(props.url, opts);
if (resp.ok) {
if (props.successMsg) toast(props.successMsg, 'success');
if (props.refresh) {
const names = Array.isArray(props.refresh) ? props.refresh : [props.refresh];
names.forEach(n => modelFetch(n));
}
} else {
toast(resp.error || 'Failed', props.errorType || 'error');
}
}
}, label);
}
/**
* Table wrapper with header, body, and empty-state row.
*
* @param {object} props
* @param {string[]} props.columns - Column header labels
* @param {VNode[]} props.rows - Body row vnodes
* @param {string} [props.emptyText] - Empty-state message
* @param {boolean} [props.wrapCard] - Wrap in div.card (default: true)
* @param {string} [props.key] - VNode key
*/
export function Table(props = {}) {
const cols = props.columns || [];
const ths = cols.map(c => h('th', null, c));
const table = h('table', { class: 'table' },
h('thead', null, h('tr', null, ...ths)),
h('tbody', null,
props.rows.length ? props.rows : [
h('tr', null,
h('td', { colspan: cols.length, class: 'text-muted text-sm' },
props.emptyText || 'No data'),
),
],
),
);
const key = props.key !== undefined ? { key: props.key } : {};
if (props.wrapCard !== false) {
return h('div', { class: 'card', ...key }, table);
}
return h('div', key, table);
}
/**
* Dashboard stat card.
*
* @param {object} props
* @param {string} props.label
* @param {*} props.value
* @param {*} [props.meta]
*/
export function StatCard(props = {}) {
return h('div', { class: 'stat-card' },
h('div', { class: 'label' }, props.label),
h('div', { class: 'value' }, props.value),
props.meta ? h('div', { class: 'meta' }, props.meta) : null,
);
}
/**
* StatusDot + human-readable label.
*
* @param {object} props
* @param {string} props.status
*/
export function StatusText(props = {}) {
const status = props.status || 'down';
const label = status === 'up' ? 'Up' : status === 'pending' ? 'Pending' : 'Down';
return [StatusDot({ status }), ' ', label];
}
/**
* Badge for certificate status based on expiry data.
*
* @param {object} props
* @param {number} [props.daysRemaining] - Days until expiry
* @param {boolean} [props.expired] - Explicitly expired flag
* @param {string} [props.certStatus] - Status string (e.g. 'valid', 'active', 'expired')
*/
export function certStatusBadge(props = {}) {
const { daysRemaining, expired, certStatus } = props;
if (certStatus === 'valid' || certStatus === 'active')
return Badge({ text: 'Valid', variant: 'success' });
if (expired || certStatus === 'expired' || (daysRemaining !== undefined && daysRemaining <= 0))
return Badge({ text: 'Expired', variant: 'danger' });
if (daysRemaining !== undefined && daysRemaining <= 30)
return Badge({ text: daysRemaining + 'd left', variant: 'warning' });
if (daysRemaining !== undefined)
return Badge({ text: daysRemaining + 'd left', variant: 'success' });
return Badge({ text: certStatus || 'N/A', variant: 'info' });
}
/**
* StatusDot + Badge pair for a service state string.
*
* @param {object} props
* @param {string} props.state - Service state (e.g. 'up', 'down')
*/
export function serviceStatusBadge(props = {}) {
const state = props.state || 'down';
const isUp = state === 'up';
return [
StatusDot({ status: isUp ? 'success' : 'danger' }),
' ',
Badge({ text: state, variant: isUp ? 'success' : 'danger' }),
];
}
/**
* ServiceStatusBadge + label in a single vnode.
*
* @param {object} props
* @param {string} props.state - Service state string
* @param {string} [props.label] - Optional label text after the badge
*/
export function ServiceStatus(props = {}) {
return h('span', { class: 'service-status' },
...serviceStatusBadge({ state: props.state }),
props.label ? ' ' + props.label : null,
);
}
/**
* ActionCell standardizes "action button + ConfirmDelete" in a table cell.
*
* @param {object} props
* @param {string} props.editLabel - First button text
* @param {function} props.editClick - First button click handler
* @param {string} props.removeUrl - API DELETE URL
* @param {string} props.removeMessage - Confirmation prompt text
* @param {string} [props.removeSuccess] - Success toast message
* @param {string|string[]} [props.removeRefresh] - Model name(s) to refresh
* @param {string} [props.removeLabel] - Delete button label (default: 'Remove')
* @param {object} [props.removeBody] - Optional JSON body to send with DELETE
* @param {string} [props.editCls] - Override classes for edit button (default: 'btn btn-sm btn-outline')
*/
export function ActionCell(props = {}) {
return h('td', null,
h('button', {
class: props.editCls || 'btn btn-sm btn-outline',
style: 'margin-right:4px;',
'on:click': props.editClick,
}, props.editLabel),
ConfirmDelete({
url: props.removeUrl,
message: props.removeMessage,
success: props.removeSuccess,
refresh: props.removeRefresh,
label: props.removeLabel || 'Remove',
body: props.removeBody,
}),
);
}
/**
* Monospace text with optional truncation.
*
* @param {object} props
* @param {string} props.text
* @param {number} [props.maxLength] - Truncate with "..." if longer
*/
export function MonoText(props = {}) {
const text = String(props.text || '');
const display = props.maxLength && text.length > props.maxLength
? text.substring(0, props.maxLength) + '...'
: text;
return h('span', { class: 'mono-text' }, esc(display));
}
/**
* Dropdown to select a firewall zone.
*
* @param {object} props
* @param {string[]} props.zones - Available zone names
* @param {string} [props.value] - Currently selected zone
* @param {function} [props.onChange] - (zone) => void
* @param {string} [props.placeholder]
*/
export function ZoneSelect(props = {}) {
return h('select', {
class: 'form-select',
'on:change': (e) => props.onChange?.(e.target.value),
},
props.placeholder ? h('option', { value: '' }, props.placeholder) : null,
props.zones.map(z =>
h('option', { value: z, selected: z === props.value }, z),
),
);
}
+165
View File
@@ -0,0 +1,165 @@
/**
* Hoover components/layout.js
*
* Layout components: PageHeader, Tabs, SectionTitle, DataTableSection, ActionGroup.
*/
import { h } from '../vdom.js?v=7';
import { Table } from './data.js?v=7';
import { collectLoadingModels } from '../model.js?v=7';
/**
* Page header with title, optional subtitle, and action buttons.
*
* @param {object} props
* @param {string} props.title
* @param {string} [props.subtitle]
* @param {VNode} [props.actions]
*/
export function PageHeader(props = {}) {
return h('div', { class: 'page-header' },
h('div', null,
h('h1', null, props.title || ''),
props.subtitle ? h('div', { class: 'subtitle' }, props.subtitle) : null,
),
props.actions ? h('div', { class: 'page-actions' }, props.actions) : null,
);
}
/**
* Handle loading/error/no-data states and return early if applicable.
* Returns null when data is ready for the page to render its content.
*
* Accepts a model object (with loading/refreshing/error/data properties) as
* the `data` parameter to check the model's data property directly.
*
* @param {object} state - Page state with loading/error flags
* @param {string} title - Page header title
* @param {string} [subtitle] - Page header subtitle
* @param {*} [data] - Data to check (or model object with .data property)
* @returns {VNode[]|null}
*/
export function renderGuard(state, title, subtitle, data) {
if (state.loading && !state.refreshing) {
return [
PageHeader({ title, subtitle }),
h('div', { class: 'card', key: 'loading' },
h('div', { class: 'card-body loading' },
state.refreshing ? 'Refreshing...' : 'Loading...',
),
),
];
}
if (state.error) {
return [
PageHeader({ title, subtitle }),
h('div', { class: 'card', key: 'error' },
h('div', { class: 'card-body error-msg' }, state.error),
),
];
}
if (isEmpty(data) && !state.loading) {
return [
PageHeader({ title, subtitle }),
h('div', { class: 'card', key: 'no-data' },
h('div', { class: 'card-body loading' }, 'No data available'),
),
];
}
return null;
}
/**
* Convenience wrapper for pages consuming multiple models.
* Internally calls collectLoadingModels then delegates to renderGuard.
*
* @param {string} title - Page header title
* @param {string} [subtitle] - Page header subtitle
* @param {...object} models - Model objects to combine
* @returns {VNode[]|null}
*/
export function renderGuardMulti(title, subtitle, ...models) {
const combined = collectLoadingModels(...models);
return renderGuard(combined, title, subtitle);
}
/**
* Check if a value is "empty" for renderGuard's no-data check.
* @param {*} data
* @returns {boolean}
*/
function isEmpty(data) {
if (data === null || data === undefined || data === '') return true;
if (Array.isArray(data)) return data.length === 0;
if (typeof data === 'object') return Object.keys(data).length === 0;
if (typeof data === 'number') return false;
return !data;
}
/**
* Tab bar component. Writes to state[prop] on tab click.
* The caller is responsible for rendering tab body content.
*
* @param {object} props
* @param {object} props.state - Reactive state object
* @param {string[]} props.tabs - Array of tab keys (e.g. ['ranges', 'leases'])
* @param {string} [props.prop] - State property name for active tab (default: 'activeTab')
* @param {function} [props.formatLabel] - (key) => label string (default: capitalize)
* @param {function} [props.onTabClick] - (key) => void, called after state update (for async side effects)
*/
export function Tabs(props = {}) {
const tabKeys = props.tabs || [];
const prop = props.prop || 'activeTab';
const formatLabel = props.formatLabel || ((k) => k.charAt(0).toUpperCase() + k.slice(1));
return h('div', { class: 'tabs' },
tabKeys.map(t => h('span', {
class: 'tab ' + (props.state[prop] === t ? 'active' : ''),
'on:click': () => {
props.state[prop] = t;
if (props.onTabClick) props.onTabClick(t);
},
style: 'cursor:pointer;',
}, formatLabel(t))),
);
}
/**
* Section header.
*
* @param {object} props
* @param {string} props.title
*/
export function SectionTitle(props = {}) {
return h('h3', { class: 'section-title' }, props.title);
}
/**
* Flex button container with 8px gap.
*
* @param {VNode[]} children
*/
export function ActionGroup(...children) {
return h('div', { style: 'display:flex;gap:8px;' }, ...children);
}
/**
* DataTableSection SectionTitle heading followed by a Table.
*
* @param {object} props
* @param {string} props.title - Section heading
* @param {string[]} props.columns
* @param {VNode[]} props.rows
* @param {string} [props.emptyText]
* @param {string} [props.key]
*/
export function DataTableSection(props = {}) {
const key = props.key !== undefined ? { key: props.key } : {};
return h('div', { class: 'data-table-section', ...key },
SectionTitle({ title: props.title }),
Table({
columns: props.columns,
rows: props.rows,
emptyText: props.emptyText,
}),
);
}
+205
View File
@@ -0,0 +1,205 @@
/**
* Hoover components/modal.js
*
* Modal overlay system: openModal, closeModal, closeAllModals, formModal.
* Renders directly into #modal-root using DOM manipulation (not vdom) to
* avoid fighting with the main render cycle.
*/
import { esc } from '../helpers.js?v=7';
import { att_esc } from '../helpers.js?v=7';
import { apiSubmit } from '../api.js?v=7';
const _modalQueue = [];
function _renderModals() {
const root = document.getElementById('modal-root');
if (!root) return;
root.innerHTML = '';
_modalQueue.forEach((m, idx) => {
const wrap = document.createElement('div');
wrap.className = 'modal-overlay active';
wrap.onclick = (e) => { if (e.target === wrap) closeModal(idx); };
const content = document.createElement('div');
content.className = 'modal';
content.onclick = (e) => e.stopPropagation();
if (m.renderFn) {
try { m.renderFn(content, idx); }
catch (err) { content.textContent = err.message; }
}
wrap.appendChild(content);
root.appendChild(wrap);
});
}
/**
* Open a modal dialog.
*
* @param {function} renderFn (contentEl, idx) => void, renders into contentEl
*/
export function openModal(renderFn) {
_modalQueue.push({ renderFn, id: _modalQueue.length });
_renderModals();
}
/**
* Close a modal by index. Closes the topmost modal if index is omitted.
*
* @param {number} [idx]
*/
export function closeModal(idx) {
if (idx === undefined) idx = _modalQueue.length - 1;
if (idx >= 0 && idx < _modalQueue.length) _modalQueue.splice(idx, 1);
_renderModals();
}
/**
* Close all open modals.
*/
export function closeAllModals() {
_modalQueue.length = 0;
_renderModals();
}
/**
* Render a standard modal layout: title, form fields, action buttons.
*
* @param {HTMLElement} inner Modal content element to fill
* @param {string} title Modal title
* @param {object[]} fields Form field descriptors
* @param {object[]} actions Action button descriptors
*
* Field shape:
* { label, id, [tag: 'input'|'select'|'textarea'], [type], [value], [placeholder], [options] }
*
* Action shape:
* { label, cls, action, handler }
*/
export function formModal(inner, title, fields, actions) {
inner.innerHTML = '<h2 class="modal-title">' + esc(title) + '</h2><div class="modal-body">'
+ fields.map(f => {
if (f.tag === 'select')
return '<div class="form-group"><label>' + esc(f.label) + '</label><select id="' + att_esc(f.id) + '"'
+ (f.multiple ? ' multiple' : '') + '>'
+ (f.options || []).map(o =>
typeof o === 'string'
? '<option value="' + att_esc(o) + '">' + esc(o) + '</option>'
: '<option value="' + att_esc(o[0]) + '"' + (o[1] ? ' selected' : '') + '>' + esc(o[1]) + '</option>',
).join('') + '</select></div>';
const tag = f.tag || 'input';
return '<div class="form-group"><label>' + esc(f.label) + '</label><' + tag + ' id="' + att_esc(f.id) + '"'
+ (f.type !== 'text' ? ' type="' + att_esc(f.type) + '"' : '')
+ (f.value !== undefined ? ' value="' + att_esc(f.value) + '"' : '')
+ (f.placeholder ? ' placeholder="' + att_esc(f.placeholder) + '"' : '')
+ '></' + tag + '></div>';
}).join('') + '</div><div class="modal-actions">'
+ actions.map(a =>
'<button class="btn ' + att_esc(a.cls) + '" data-action="' + att_esc(a.action) + '">' + esc(a.label) + '</button>',
).join('') + '</div>';
actions.forEach(a => {
const btn = inner.querySelector('[data-action="' + att_esc(a.action) + '"]');
if (btn) btn.addEventListener('click', a.handler);
});
}
/**
* Factory that returns a function to open a multi-select modal.
*
* @param {object} props
* @param {string} props.title - Modal title
* @param {string} props.url - API POST URL
* @param {string[]} props.options - All selectable options
* @param {string[]} props.selected - Currently selected values
* @param {string} props.fieldKey - JSON key for the field
* @param {string} [props.successMsg] - Success toast message
* @param {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch
* @returns {function} () => void, calls openModal
*/
export function MultiSelectModal(props = {}) {
return () => {
const selectId = 'ms-' + props.fieldKey;
openModal((inner) => {
formModal(inner, props.title,
[{
label: props.fieldKey,
id: selectId,
tag: 'select',
multiple: true,
options: (props.options || []).map(o => [o, (props.selected || []).includes(o)]),
}],
[
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() },
...apiSubmit({
url: props.url,
body: () => ({
[props.fieldKey]: Array.from(document.getElementById(selectId).selectedOptions)
.map(o => o.value),
}),
successMsg: props.successMsg || 'Updated',
refresh: props.refresh,
closeModal: () => closeModal(),
}),
],
);
});
};
}
/**
* Factory that returns a function to open a modal with form fields and apiSubmit.
* Accepts an optional `data` argument forwarded to title, fields, submit.url, submit.body resolvers.
*
* @param {object} props
* @param {string|function} props.title - Modal title or (data) => string
* @param {object[]|function} props.fields - Form field descriptors or (data) => object[]
* @param {object} props.submit - Submit configuration
* @param {string|function} props.submit.url - API URL or (data) => string
* @param {string} [props.submit.method] - HTTP method (default: 'POST')
* @param {function} [props.submit.body] - (data) => object
* @param {function} [props.submit.validate] - (body) => string|null
* @param {string|function} [props.submit.successMsg] - Toast message or (data) => string
* @param {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch
* @param {function} [props.handler] - Custom submit handler override (bypasses apiSubmit)
* @param {string} [props.submitLabel] - Submit button label (default: 'Submit')
* @returns {function} (data) => void, calls openModal
*/
export function QuickModal(props = {}) {
return (data) => {
const title = typeof props.title === 'function' ? props.title(data) : props.title;
const fields = typeof props.fields === 'function' ? props.fields(data) : props.fields;
const url = typeof props.submit.url === 'function' ? props.submit.url(data) : props.submit.url;
openModal((inner) => {
let actions;
if (props.handler) {
actions = [
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() },
{
label: props.submitLabel || 'Submit',
cls: 'btn-primary',
action: 's',
handler: () => props.handler(data, () => closeModal()),
},
];
} else {
actions = [
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() },
...apiSubmit({
url,
method: props.submit.method || 'POST',
body: props.submit.body ? () => props.submit.body(data) : undefined,
validate: props.submit.validate,
successMsg: typeof props.submit.successMsg === 'function'
? props.submit.successMsg(data)
: (props.submit.successMsg || 'Done'),
refresh: props.refresh || undefined,
closeModal: () => closeModal(),
}),
];
}
formModal(inner, title, fields, actions);
});
};
}
+40
View File
@@ -0,0 +1,40 @@
/**
* Hoover components/toast.js
*
* ToastContainer component that renders queued toast notifications.
* Uses the toast/dismissToast state from api.js.
*/
import { h } from '../vdom.js?v=7';
import { _toasts, dismissToast } from '../api.js?v=7';
/**
* Render all pending toast notifications.
*
* @returns {VNode}
*/
export function ToastContainer() {
if (!_toasts.length) return h('#text', '');
const clsMap = {
info: 'toast-info',
success: 'toast-success',
error: 'toast-error',
warning: 'toast-warning',
};
return h('div', { class: 'toast-container' },
..._toasts.map(t =>
h('div', {
class: `toast ${clsMap[t.type] || clsMap.info}`,
'on:click': () => dismissToast(t.id),
},
h('span', null, t.message),
h('button', {
class: 'toast-close',
'on:click': (e) => { e.stopPropagation(); dismissToast(t.id); },
}, '\u00d7'),
),
),
);
}
+68
View File
@@ -0,0 +1,68 @@
/**
* Hoover helpers.js
*
* Shared utilities: text escaping, attribute escaping, DOM value helpers,
* zone parsing, form utilities.
*/
/**
* Escape text for safe HTML output.
* Appends the string to a temporary div and reads innerHTML,
* which safely escapes all HTML special characters.
*/
export function esc(s) {
const d = document.createElement('div');
d.append(String(s ?? ''));
return d.innerHTML;
}
/**
* Escape a string for safe use in HTML attributes.
*/
export function att_esc(s) {
return String(s ?? '')
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
/**
* URL-encode a string.
*/
export const enc = encodeURIComponent;
/**
* Get the value of a DOM element by ID.
*/
export function $val(id) {
return document.getElementById(id)?.value;
}
/**
* Parse zone data from various API response shapes into a flat string array.
*/
export function parseZones(data) {
let z = data?.active || data?.zones || [];
if (typeof z === 'object' && !Array.isArray(z))
z = Object.values(z).map(i => i?.name || i);
return Array.isArray(z) ? z : [];
}
/**
* Trigger a browser file download from a Blob.
*
* @param {Blob} blob
* @param {string} filename
*/
export function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
+44
View File
@@ -0,0 +1,44 @@
/**
* Hoover index.js
*
* Barrel export of all public Hoover APIs.
*/
/* ── Reactivity ──────────────────────────────────────────────── */
export { reactive, requestUpdate } from './reactivity.js?v=7';
/* ── VDOM ────────────────────────────────────────────────────── */
export { h } from './vdom.js?v=7';
/* ── Render ──────────────────────────────────────────────────── */
export { render } from './render.js?v=7';
/* ── Component ───────────────────────────────────────────────── */
export { definePage, hComp } from './component.js?v=7';
/* ── Router ──────────────────────────────────────────────────── */
export { createRouter, Link } from './router.js?v=7';
/* ── WebSocket ───────────────────────────────────────────────── */
export { connect, onMessage } from './websocket.js?v=7';
/* ── API & Toast ─────────────────────────────────────────────── */
export { apiFetch, toast, dismissToast, apiSubmit, checkAbort, poll, refactorLoad } from './api.js?v=7';
/* ── Model ───────────────────────────────────────────────────── */
export { modelRegister, getModel, modelFetch, collectLoadingModels } from './model.js?v=7';
/* ── Helpers ─────────────────────────────────────────────────── */
export { esc, att_esc, enc, $val, parseZones, downloadBlob } from './helpers.js?v=7';
/* ── UI Components: Layout ───────────────────────────────────── */
export { PageHeader, renderGuard, renderGuardMulti, Tabs, SectionTitle, ActionGroup, DataTableSection } from './components/layout.js?v=7';
/* ── UI Components: Data ─────────────────────────────────────── */
export { Badge, StatusDot, Empty, Card, ConfirmDelete, Table, ActionButton, certStatusBadge, serviceStatusBadge, StatCard, StatusText, ServiceStatus, ActionCell, MonoText, ZoneSelect } from './components/data.js?v=7';
/* ── UI Components: Modal ────────────────────────────────────── */
export { openModal, closeModal, closeAllModals, formModal, MultiSelectModal, QuickModal } from './components/modal.js?v=7';
/* ── UI Components: Toast ────────────────────────────────────── */
export { ToastContainer } from './components/toast.js?v=7';
+138
View File
@@ -0,0 +1,138 @@
/**
* Hoover model.js
*
* Central reactive store for subsystem models. Each subsystem gets one
* reactive model with { data, loading, refreshing, error }. Hoover handles
* fetching, WS invalidation, loading states, and abort management.
*
* API:
* modelRegister(name, definition) register at app bootstrap
* getModel(name) return reactive model object
* modelFetch(name, signal?, param?) trigger fetch with in-flight dedup
* refreshByTopic(topic) WS callback: refresh all models matching topic
* collectLoadingModels(...models) combine loading/refreshing/error
*/
import { reactive } from './reactivity.js?v=7';
/** Registered models: name → { model, subsystem, fetch } */
const _models = new Map();
/** In-flight fetch promises for dedup: name → Promise */
const _fetchPromises = new Map();
/**
* Register a subsystem model.
*
* @param {string} name - Model name (e.g. 'firewall', 'dnsmasq')
* @param {object} definition
* @param {string} definition.subsystem - WS topic to listen for ('*' = all)
* @param {function} definition.fetch - async (signal?, param?) => Promise<data>
* @param {any} [definition.defaultData] - Initial data value (default: null)
* @returns {object} reactive model
*/
export function modelRegister(name, definition) {
const model = reactive({
data: definition.defaultData ?? null,
loading: true,
refreshing: false,
error: null,
});
_models.set(name, {
model,
subsystem: definition.subsystem,
fetch: definition.fetch,
});
return model;
}
/**
* Get a reactive model by name. Throws if not registered.
* @param {string} name
* @returns {object} reactive model
*/
export function getModel(name) {
const entry = _models.get(name);
if (!entry) throw new Error('Model not registered: ' + name);
return entry.model;
}
/** Build dedup key from model name and optional param. */
function _dedupKey(name, param) {
return param !== undefined ? `${name}:${String(param)}` : name;
}
/**
* Trigger a fetch for the named model.
*
* In-flight dedup: if a fetch is already running, returns the existing
* promise. Models never abort in-progress fetches since other consumers
* may still need the data.
*
* @param {string} name - Model name
* @param {AbortSignal|*} [signalOrParam] - AbortSignal (backward compat) or param
* @param {AbortSignal} [signal] - AbortSignal when a param was provided
*/
export function modelFetch(name, signalOrParam, signal) {
const entry = _models.get(name);
if (!entry) return;
const isSignal = signalOrParam instanceof AbortSignal || signalOrParam === undefined;
const param = isSignal ? undefined : signalOrParam;
const actualSignal = isSignal ? signalOrParam : signal;
const model = entry.model;
const isInitial = model.loading && model.data === null;
const key = _dedupKey(name, param);
if (_fetchPromises.has(key)) return _fetchPromises.get(key);
if (isInitial) model.loading = true;
else model.refreshing = true;
model.error = null;
const promise = (async () => {
try {
const data = await entry.fetch(actualSignal, param);
model.data = data;
} catch (e) {
model.error = e.message || 'Fetch failed';
} finally {
model.loading = false;
model.refreshing = false;
}
})();
_fetchPromises.set(key, promise);
promise.finally(() => _fetchPromises.delete(key));
return promise;
}
/**
* Refresh all models whose subsystem topic matches the given topic.
* Topic '*' matches every model. Model subsystem '*' matches every topic.
*/
export function refreshByTopic(topic) {
for (const [name, entry] of _models) {
if (entry.subsystem === '*') {
modelFetch(name);
} else if (entry.subsystem === topic || topic === '*') {
modelFetch(name);
}
}
}
/**
* Combine loading/refreshing/error from multiple models.
* @param {...object} models
* @returns {{loading: boolean, refreshing: boolean, error: string|null}}
*/
export function collectLoadingModels(...models) {
return {
loading: models.some(m => m.loading),
refreshing: models.some(m => m.refreshing),
error: models.find(m => m.error)?.error ?? null,
};
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Hoover reactivity.js
*
* Reactive Proxy state + batched render requests via queueMicrotask.
* Multiple property mutations in the same microtask tick produce a single
* render cycle across all registered render roots.
*/
/**
* Global flag to prevent duplicate microtask scheduling.
*/
let _scheduled = false;
/**
* Callback invoked by render.js to perform the actual batched re-render.
* Set via setCommitFn() during render engine initialization.
*/
let _commitFn = null;
/**
* Register the commit callback that performs batched re-renders.
* Called by render.js during initialization.
*/
export function setCommitFn(fn) {
_commitFn = fn;
}
/**
* Schedule a single batched re-render for all active render roots.
* Multiple reactive property mutations in the same tick produce one diff pass.
*/
export function requestUpdate() {
if (_scheduled) return;
_scheduled = true;
queueMicrotask(() => {
_scheduled = false;
if (_commitFn) _commitFn();
});
}
/**
* Wrap an object in a reactive Proxy.
* Any property *assignment* that changes the value automatically triggers
* a batched re-render via requestUpdate().
*/
export function reactive(obj = {}) {
return new Proxy(obj, {
set(target, key, value, receiver) {
const old = target[key];
const ok = Reflect.set(target, key, value, receiver);
if (ok && !Object.is(old, value)) {
requestUpdate();
}
return ok;
}
});
}
+253
View File
@@ -0,0 +1,253 @@
/**
* Hoover render.js
*
* Render engine: render(container, fn), container-level diffing,
* batched re-render loop integration with reactivity.js.
*/
import { requestUpdate, setCommitFn } from './reactivity.js?v=7';
import {
_vnodeDom, createDom, getDom, patchNode, sweepDom,
setMountFn, setUnmountFn,
} from './vdom.js?v=7';
import { mountComponent, unmountComponent } from './component.js?v=7';
/** Container → previous root vnodes */
export const _renderSlots = new Map();
/** Container → render function */
export const _renderFns = new Map();
/** Component key → last normalized #comp output (for _vnodeDom preservation) */
export const _compExpandedCache = new Map();
/** Component key → renderer function (survives normalization that expands #comp) */
const _compRegistry = new Map();
/**
* Set up lifecycle callback hooks from vdom.js.
* Called once during render initialization.
*/
setMountFn((el) => {
// Reserved for future DOM-level mount hooks
});
setUnmountFn((el) => {
// Called during sweepDom for cleanup
});
/**
* Commit callback: re-renders all registered containers in batch.
* Set as the callback for reactivity.js's requestUpdate().
*/
function commitAll() {
for (const container of _renderFns.keys()) {
commit(container);
}
}
setCommitFn(commitAll);
/**
* Mount a render function onto a DOM container.
* - First call: create DOM from scratch, append to container
* - Subsequent calls: diff against previous VNodes, patch in place
*/
export function render(container, fn) {
_renderFns.set(container, fn);
commit(container);
}
/**
* Evaluate render function, diff vs previous, commit to _renderSlots.
*/
function commit(container) {
const fn = _renderFns.get(container);
if (!fn) return;
let result = fn();
if (typeof result === 'function') result = result();
const prev = _renderSlots.get(container);
// Normalize: expand #comp vnodes and track lifecycle
const vnodes = normalizeVNodesWithLifecycle(result, prev);
if (!prev) {
for (const v of vnodes) {
const d = createDom(v);
_vnodeDom.set(v, d);
container.appendChild(d);
}
} else {
diffContainer(container, prev, vnodes);
}
_renderSlots.set(container, vnodes);
}
/**
* Normalize render output: filter nulls, expand #comp vnodes,
* and manage component lifecycle based on key changes.
*/
function normalizeVNodesWithLifecycle(result, prevVnodes) {
const oldEntries = [..._compRegistry.entries()].map(([key, renderer]) => ({ key, renderer }));
const oldKeyMap = new Map(oldEntries.map(e => [e.key, e]));
const newEntries = [];
const normalized = normalizeRecursive(result, oldKeyMap, newEntries);
for (const entry of oldEntries) {
if (!newEntries.some(e => e.key === entry.key)) {
unmountComponent(entry.key, entry.renderer);
}
}
for (const entry of newEntries) {
if (!oldKeyMap.has(entry.key)) {
mountComponent(entry.key, entry.renderer);
}
}
// Sync registry with current render (prevVnodes are normalized and lack #comp tags,
// so collectCompEntries always returns [] after the first render)
const newKeySet = new Set(newEntries.map(e => e.key));
for (const [key] of _compRegistry) {
if (!newKeySet.has(key)) _compRegistry.delete(key);
}
for (const entry of newEntries) {
_compRegistry.set(entry.key, entry.renderer);
}
return normalized;
}
/**
* Recursively normalize a value to a flat VNode array, expanding
* #comp vnodes into their rendered content while tracking lifecycle.
*
* When prevCh is provided, preserves _vnodeDom entries so that diff
* can locate existing DOM after normalization creates new vnode objects.
*/
function normalizeRecursive(result, oldKeyMap, newEntries, prevCh) {
if (result == null) return [];
if (Array.isArray(result)) {
const flat = [];
let idx = 0;
for (const item of result) {
flat.push(...normalizeRecursive(item, oldKeyMap, newEntries, prevCh && prevCh[idx]));
idx++;
}
return flat;
}
const vnode = result;
if (typeof vnode !== 'object') return [{ tag: '#text', text: String(vnode) }];
if (vnode.tag === '#text') return [vnode];
if (vnode.tag === '#comp') {
const renderer = vnode.props?.component;
const key = vnode.props?.key;
if (key !== undefined) {
const existing = newEntries.find(e => e.key === key);
if (!existing) newEntries.push({ key, renderer });
}
if (renderer && typeof renderer === 'function') {
const content = renderer();
const prevExpanded = key !== undefined ? _compExpandedCache.get(key) : null;
const result = normalizeRecursive(content, oldKeyMap, newEntries, prevExpanded);
if (key !== undefined) _compExpandedCache.set(key, result);
return result;
}
return normalizeRecursive([...(vnode.ch || [])], oldKeyMap, newEntries, prevCh);
}
const rawChildren = vnode.ch || [];
const prevChildren = prevCh && prevCh.ch ? prevCh.ch : null;
const children = [];
for (let i = 0; i < rawChildren.length; i++) {
const normalized = normalizeRecursive(rawChildren[i], oldKeyMap, newEntries, prevChildren && prevChildren[i]);
children.push(...normalized);
}
const newVNode = { tag: vnode.tag, props: vnode.props, ch: children };
// Preserve _vnodeDom entry: if the old vnode at this position had a
// DOM association, transfer it to the new normalized vnode so diff
// can locate existing DOM without creating duplicates.
if (prevCh && _vnodeDom.has(prevCh)) {
_vnodeDom.set(newVNode, _vnodeDom.get(prevCh));
}
return [newVNode];
}
/** Collect all #comp entries {key, renderer} from a vnode tree. */
function collectCompEntries(vnodes, entries) {
for (const v of vnodes || []) {
if (!v) continue;
if (v.tag === '#comp') {
const key = v.props?.key;
const renderer = v.props?.component;
if (key !== undefined) entries.push({ key, renderer });
}
if (v.ch) collectCompEntries(v.ch, entries);
}
return entries;
}
/**
* Diff two VNode arrays inside a container, patching in place.
*
* Fix: anchor tracking ensures correct DOM insertion order.
* Fix: _vnodeDom updated after every patch.
*/
function diffContainer(container, prev, vnodes) {
const maxLen = Math.max(vnodes.length, prev.length);
let lastDom = null;
for (let i = 0; i < maxLen; i++) {
const oldV = prev[i], newV = vnodes[i];
if (!newV && oldV) {
const d = getDom(oldV);
if (d?.parentNode) {
if (d.nodeType === Node.ELEMENT_NODE) sweepDom(d);
d.parentNode.removeChild(d);
lastDom = i > 0 ? getDom(prev[i - 1]) : null;
}
continue;
}
if (newV && !oldV) {
const d = createDom(newV);
_vnodeDom.set(newV, d);
container.insertBefore(d, lastDom ? lastDom.nextSibling : null);
lastDom = d;
continue;
}
const oldDom = getDom(oldV);
if (oldDom && oldV.tag === newV.tag) {
patchNode(oldDom.nodeType === 1 ? oldDom.parentNode : container, oldV, newV, null);
lastDom = getDom(newV);
} else if (oldDom && !oldDom.parentNode) {
// oldDom exists in _vnodeDom but detached from the tree
if (oldDom.nodeType === Node.ELEMENT_NODE) sweepDom(oldDom);
const nd = createDom(newV);
_vnodeDom.set(newV, nd);
container.insertBefore(nd, lastDom ? lastDom.nextSibling : null);
lastDom = nd;
} else if (oldDom && oldV.tag !== newV.tag) {
// tag mismatch — replace old DOM with new
if (oldDom.nodeType === Node.ELEMENT_NODE) sweepDom(oldDom);
const nd = createDom(newV);
_vnodeDom.set(newV, nd);
oldDom.parentNode.replaceChild(nd, oldDom);
lastDom = nd;
} else {
// oldDom is null — create and insert new DOM
const nd = createDom(newV);
_vnodeDom.set(newV, nd);
container.insertBefore(nd, lastDom ? lastDom.nextSibling : null);
lastDom = nd;
}
}
}
+61
View File
@@ -0,0 +1,61 @@
/**
* Hoover router.js
*
* Hash-based SPA router with reactive state (triggers re-render on
* navigation). Link component for client-side navigation.
*/
import { reactive } from './reactivity.js?v=7';
import { h } from './vdom.js?v=7';
/**
* Hash-based router.
*
* const router = createRouter({
* '/dashboard': () => h('#comp', { component: DashboardPage, key: '/dashboard' }, []),
* '/interfaces': () => h('#comp', { component: InterfacesPage, key: '/interfaces' }, []),
* '*': () => h('#comp', { component: NotFoundPage, key: '*' }, []),
* });
*
* Reactive `router.state.path` updates trigger re-renders automatically.
*/
export function createRouter(routes) {
const initialPath = location.hash.slice(1) || '/dashboard';
if (!location.hash) location.hash = initialPath;
const state = reactive({ path: initialPath });
window.addEventListener('hashchange', () => {
state.path = location.hash.slice(1) || '/dashboard';
});
const component = () => {
const handler = routes[state.path] || routes['*'];
if (!handler) {
return h('div', { class: 'card' },
h('div', { class: 'text-muted' }, `404 — Not found: ${state.path}`));
}
try {
return handler();
} catch (e) {
return h('div', { class: 'card' },
h('div', { class: 'text-muted' }, `Error: ${e.message || String(e)}`));
}
};
return { state, navigate: (p) => { location.hash = p; }, component };
}
/**
* Client-side navigation link component.
* Sets `location.hash` without full page navigation.
*/
export function Link(props) {
const { path, class: cls, children, ...rest } = props || {};
return h('a', {
href: '#' + path,
class: cls || '',
'on:click': (e) => { e.preventDefault(); location.hash = path; },
...rest,
}, children || []);
}
+318
View File
@@ -0,0 +1,318 @@
/**
* Hoover vdom.js
*
* Virtual DOM: h() factory, vnode creation, diffing, patching.
* Maintains _vnodeDom WeakMap for vnode DOM element resolution.
*
* Critical fixes vs. reactive-dom.js:
* - _vnodeDom updated after EVERY vnodedom assignment
* - Keyed diff with proper element reordering
* - Unkeyed diff with anchor tracking
* - Proper unmountTree for cleanup (fires registered onUnmount hooks)
*/
// Exported so render.js can access it
export const _vnodeDom = new WeakMap();
// Lifecycle hooks registry (component.js populates this)
export const _mountFn = { fn: null };
export const _unmountFn = { fn: null };
export function setMountFn(fn) { _mountFn.fn = fn; }
export function setUnmountFn(fn) { _unmountFn.fn = fn; }
/**
* Build a VNode. Three forms:
* h('div', { class: 'x' }, h('span', null, 'hi')) element
* h(ComponentFn, { prop: 1 }, child1, child2) component (fn called)
* h('#text', 'some text') text node
*/
export function h(tag, props, ...children) {
if (typeof tag === 'function') {
const base = typeof props === 'object' && props !== null ? props : {};
if (!base.children && children.length)
base.children = flatten(children);
return tag(base);
}
if (tag === '#text')
return { tag: '#text', text: String(props) };
if (tag === '#comp') {
return { tag: '#comp', props: props || {}, ch: flatten(children) };
}
return { tag, props: props || {}, ch: flatten(children) };
}
/** Flatten nested arrays / primitives → VNode array. */
function flatten(arr) {
const out = [];
for (const c of arr.flat(Infinity)) {
if (c == null || typeof c === 'boolean') continue;
out.push(
typeof c === 'string' || typeof c === 'number'
? { tag: '#text', text: String(c) }
: c,
);
}
return out;
}
/**
* Look up the real DOM element for a VNode via _vnodeDom.
*/
export function getDom(vnode) {
return vnode ? _vnodeDom.get(vnode) : null;
}
/**
* Create a real DOM element (or subtree) from a VNode.
* Also registers _vnodeDom mapping for the created element and all descendants.
*/
export function createDom(vnode) {
if (!vnode) return document.createTextNode('');
if (vnode.tag === '#text') {
const tn = document.createTextNode(vnode.text || '');
_vnodeDom.set(vnode, tn);
return tn;
}
const el = document.createElement(vnode.tag);
applyProps(el, vnode.props);
_vnodeDom.set(vnode, el);
for (const c of vnode.ch || []) {
el.appendChild(createDom(c));
}
return el;
}
/** Apply every prop on an element (initial mount). */
export function applyProps(el, props) {
for (const [k, v] of Object.entries(props)) setProp(el, k, v);
}
/** Set a single prop (or event) on an element. */
export function setProp(el, key, value) {
if (key === 'key' || key === 'ref') return;
if (key === 'html') { el.innerHTML = String(value); return; }
if (key === 'innerHTML') { el.innerHTML = String(value); return; }
if (key === 'textContent') { el.textContent = String(value); return; }
if (key.startsWith('on:')) {
const ev = key.slice(3);
const map = el._evMap || {};
if (map[ev]) el.removeEventListener(ev, map[ev]);
if (typeof value === 'function') {
el.addEventListener(ev, value);
map[ev] = value;
} else delete map[ev];
el._evMap = map;
return;
}
if (key === 'class' && typeof value === 'object' && value !== null) {
el.className = Object.keys(value).filter(k => value[k]).join(' ');
return;
}
if (key === 'style' && typeof value === 'object' && value !== null) {
for (const [sk, sv] of Object.entries(value)) el.style[sk] = sv;
return;
}
const tag = el.tagName.toLowerCase();
if (key === 'value' && ['input', 'textarea', 'select'].includes(tag)) {
el.value = value == null ? '' : String(value); return;
}
if (key === 'checked' && tag === 'input') { el.checked = !!value; return; }
if (key === 'disabled') { el.disabled = !!value; return; }
if (key === 'selected' && tag === 'option') { el.selected = !!value; return; }
if (value == null || value === false || value === undefined)
el.removeAttribute(key);
else
el.setAttribute(key, value === true ? '' : String(value));
}
/** Remove a single prop from an element. */
export function unsetProp(el, key) {
if (key === 'key' || key === 'ref') return;
if (key.startsWith('on:')) {
const ev = key.slice(3);
const map = el._evMap || {};
if (map[ev]) { el.removeEventListener(ev, map[ev]); delete map[ev]; }
el._evMap = map;
return;
}
const tag = el.tagName.toLowerCase();
if (key === 'value' && ['input', 'textarea', 'select'].includes(tag)) return;
if (key === 'checked' && tag === 'input') { el.checked = false; return; }
if (key === 'disabled') { el.disabled = false; return; }
if (key === 'selected' && tag === 'option') { el.selected = false; return; }
el.removeAttribute(key);
}
/** Diff two props objects and patch the element in place. */
export function patchProps(el, oldP = {}, newP = {}) {
for (const k of new Set([...Object.keys(oldP), ...Object.keys(newP)])) {
const hasOld = k in oldP, hasNew = k in newP;
if (hasOld && hasNew && Object.is(oldP[k], newP[k])) continue;
if (hasNew) setProp(el, k, newP[k]);
else unsetProp(el, k);
}
}
/** Recursively clean up event listeners and child nodes. */
export function sweepDom(el) {
if (_unmountFn.fn) _unmountFn.fn(el);
for (const ev of Object.keys(el._evMap || {})) el.removeEventListener(ev, el._evMap[ev]);
while (el.firstChild) {
const child = el.firstChild;
if (child.nodeType === Node.ELEMENT_NODE) sweepDom(child);
el.removeChild(child);
}
}
/**
* Patch children of a parent element.
* Dispatches to keyed or unkeyed patching based on whether any vnode has a key.
*/
export function patchChildren(parent, oldCh, newCh) {
const hasKeys = (ch) => ch.some(v => v?.props?.key != null);
if (hasKeys(newCh) && hasKeys(oldCh))
patchKeyed(parent, oldCh, newCh);
else
patchUnkeyed(parent, oldCh, newCh);
}
/**
* Unkeyed (index-based) children diff.
*
* Fix: _vnodeDom updated after EVERY vnodedom assignment.
*/
export function patchUnkeyed(parent, oldCh, newCh) {
const maxLen = Math.max(oldCh.length, newCh.length);
let lastDom = null;
for (let i = 0; i < maxLen; i++) {
const oldV = oldCh[i], newV = newCh[i];
if (!newV && oldV) {
const d = getDom(oldV);
if (d?.parentNode) {
if (d.nodeType === Node.ELEMENT_NODE) sweepDom(d);
d.parentNode.removeChild(d);
lastDom = i > 0 ? getDom(oldCh[i - 1]) : null;
}
continue;
}
if (newV && !oldV) {
const d = createDom(newV);
_vnodeDom.set(newV, d);
parent.insertBefore(d, lastDom ? lastDom.nextSibling : null);
lastDom = d;
continue;
}
patchNode(parent, oldV, newV, null);
lastDom = getDom(newV);
}
}
/**
* Keyed children diff preserves order, reuses DOM by key.
*
* Fix: proper element reordering using lastDom anchor tracking.
*/
export function patchKeyed(parent, oldCh, newCh) {
const oldMap = new Map(
oldCh.filter(v => v?.props?.key != null).map(v => [v.props.key, v])
);
const toRemove = new Set(oldMap.keys());
let lastDom = null;
for (const newV of newCh) {
const key = newV.props?.key;
toRemove.delete(key);
const oldV = oldMap.get(key);
if (oldV) {
patchNode(parent, oldV, newV, null);
const d = getDom(newV);
if (d) {
if (lastDom && d !== lastDom.nextSibling) {
parent.insertBefore(d, lastDom.nextSibling || null);
}
lastDom = d;
}
} else {
const d = createDom(newV);
_vnodeDom.set(newV, d);
parent.insertBefore(d, lastDom ? lastDom.nextSibling : null);
lastDom = d;
}
}
for (const key of toRemove) {
const oldV = oldMap.get(key);
const d = getDom(oldV);
if (d?.parentNode) {
if (d.nodeType === Node.ELEMENT_NODE) sweepDom(d);
d.parentNode.removeChild(d);
}
}
}
/**
* Patch one VNode against another inside parent.
*
* - no old create + insert
* - no new sweep + remove
* - tag match patchProps + patchChildren
* - tag mismatch replace
*
* Fix: _vnodeDom always set to the correct dom after patch.
*/
export function patchNode(parent, oldV, newV, anchor) {
if (!oldV && !newV) return;
if (!oldV) {
const d = createDom(newV);
_vnodeDom.set(newV, d);
parent.insertBefore(d, anchor || null);
return;
}
if (!newV) {
const d = getDom(oldV);
if (d?.parentNode) {
if (d.nodeType === Node.ELEMENT_NODE) sweepDom(d);
d.parentNode.removeChild(d);
}
return;
}
const dom = getDom(oldV);
if (!dom || !dom.parentNode) {
const d = createDom(newV);
_vnodeDom.set(newV, d);
parent.insertBefore(d, anchor || null);
return;
}
// Tag changed → full replace
if (oldV.tag !== newV.tag) {
if (dom.nodeType === Node.ELEMENT_NODE) sweepDom(dom);
const nd = createDom(newV);
_vnodeDom.set(newV, nd);
dom.parentNode.replaceChild(nd, dom);
return;
}
// Text node — fast path
if (oldV.tag === '#text') {
if (oldV.text !== newV.text) dom.nodeValue = newV.text;
_vnodeDom.set(newV, dom);
return;
}
// Element: patch in place
patchProps(dom, oldV.props || {}, newV.props || {});
patchChildren(dom, oldV.ch || [], newV.ch || []);
_vnodeDom.set(newV, dom);
}
+109
View File
@@ -0,0 +1,109 @@
/**
* Hoover websocket.js
*
* WebSocket connection manager with auto-reconnect. WS messages are routed
* to model-based refresh and direct onMessage handlers.
* Page-level subscribe/unsubscribe is replaced by the model layer.
*/
import { refreshByTopic } from './model.js?v=7';
let _wsConn = null;
let _wsReconnectMs = 0;
/** Direct onMessage handlers: { topics, handler, unsubscribed }[] */
const _directHandlers = [];
/**
* Build the WebSocket URL. Supports an override via `window.__WS_URL__`
* (useful for proxy setups). Falls back to port 9091 when the current
* origin has no port (nginx fronting the WS on a different port).
*/
function _wsUrl() {
if (window.__WS_URL__) return window.__WS_URL__;
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
return proto + '//' + location.host + '/ws';
}
/** Attempt a WebSocket connection. */
function _wsConnect() {
if (_wsConn && _wsConn.readyState <= 1) return;
_wsConn = new WebSocket(_wsUrl());
_wsConn.onopen = () => {
_wsReconnectMs = 0;
};
_wsConn.onclose = () => {
_wsReconnectMs = Math.min(_wsReconnectMs * 2 + 1000, 15000);
setTimeout(_wsConnect, _wsReconnectMs);
};
_wsConn.onerror = () => {
_wsConn.close();
};
_wsConn.onmessage = (ev) => {
try {
const msg = typeof ev.data === 'string' ? JSON.parse(ev.data) : ev.data;
handleMessage(msg);
} catch (_) {}
};
}
/**
* Route an incoming WS message to model refresh and direct handlers.
*
* Expected message shapes:
* { type: 'versions', updated: ['firewall', 'dnsmasq', ] }
* { type: 'notify', topic: 'firewall' }
* { type: 'status', topic: 'firewall', }
*/
function handleMessage(msg) {
const topics = [];
if (msg.type === 'versions' || msg.type === 'refresh') {
topics.push(...(msg.updated || msg.topics || []));
} else if (msg.type === 'notify') {
topics.push(msg.topic);
} else if (msg.type === 'status') {
topics.push(msg.topic || '*');
}
// Refresh models for each topic
for (const topic of topics) {
refreshByTopic(topic);
}
// Notify direct onMessage handlers
for (const h of _directHandlers) {
if (h.unsubscribed) continue;
if (topics.some(t => h.topics.includes(t) || h.topics.includes('*'))) {
try { h.handler(msg); } catch (_) {}
}
}
}
/**
* Public subscribe API for direct one-off usage (e.g. from page code).
* Handler receives the raw parsed message when a matching topic arrives.
* @param {string|string[]} topics
* @param {function} handler
* @returns {function} unsubscribe
*/
export function onMessage(topics, handler) {
const tArray = Array.isArray(topics) ? topics : [topics];
const entry = { topics: tArray, handler, unsubscribed: false };
_directHandlers.push(entry);
return () => {
entry.unsubscribed = true;
const idx = _directHandlers.indexOf(entry);
if (idx !== -1) _directHandlers.splice(idx, 1);
};
}
/** Start the WebSocket connection. */
export function connect() {
_wsConnect();
}
+19
View File
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vacuum Wall</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div id="app">
<div class="layout">
<div id="sidebar"></div>
<div class="main" id="main"></div>
</div>
</div>
<script>window.__WS_URL__ = "__WS_URL_PLACEHOLDER__"</script>
<script type="module" src="/static/app.js?v=7"></script>
</body>
</html>
+99
View File
@@ -0,0 +1,99 @@
import { h, PageHeader, Empty, Table, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, modelFetch, ActionCell, certStatusBadge, poll } from '/static/hoover/index.js?v=7';
function issueCertModal(state) {
openModal((inner, idx) => {
formModal(inner, 'Issue Certificate',
[
{ label: 'Domain', id: 'ic-domain', placeholder: 'example.com' },
{ label: 'Email (optional)', id: 'ic-email', placeholder: 'you@example.com' },
],
[
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
{
label: 'Issue', cls: 'btn-primary', action: 's', handler: async () => {
const domain = ($val('ic-domain') || '').trim();
if (!domain) { toast('Domain is required', 'error'); return; }
const body = { domain, email: ($val('ic-email') || '').trim() || undefined };
const resp = await apiFetch('/api/certs/issue/start', {
method: 'POST',
body,
});
if (resp.ok) {
toast('Issuance started for ' + domain, 'success');
closeModal(idx);
const rid = resp.data?.request_id;
if (rid) pollCertIssue(rid, state);
} else {
toast(resp.error || 'Failed', 'error');
}
},
},
],
);
});
}
async function pollCertIssue(rid, state) {
poll({
url: '/api/certs/issue/' + enc(rid),
successKey: (d) => d.status === 'completed',
onErrorKey: (d) => d.status === 'failed',
onComplete: (d) => {
toast('Certificate issued for ' + (d.domain || rid), 'success');
modelFetch('acme');
},
onError: (d) => {
toast('Issuance failed: ' + (d?.error || 'unknown'), 'error');
},
});
}
export default definePage({
init() {
return {
acme: getModel('acme'),
};
},
render(state) {
const guard = renderGuard(state.acme, 'Certificates', 'ACME certificate management', state.acme.data);
if (guard) return guard;
const rows = (state.acme.data || []).map(c => {
const badge = certStatusBadge({ expired: c.expired, daysRemaining: c.days_remaining });
return h('tr', { key: c.domain },
h('td', null, h('strong', null, esc(c.domain || 'unknown'))),
h('td', { class: 'text-sm' }, esc(c.issuer || '-')),
h('td', null, esc(c.expiry || 'N/A')),
h('td', null, badge),
ActionCell({
editLabel: 'Renew',
editClick: async () => {
const resp = await apiFetch('/api/certs/' + enc(c.domain) + '/renew', { method: 'POST' });
if (resp.ok) toast('Renewal started for ' + c.domain, 'success');
else toast(resp.error || 'Failed', 'error');
},
removeUrl: '/api/certs/' + enc(c.domain),
removeMessage: 'Remove certificate for ' + c.domain + '?',
removeSuccess: 'Certificate removed',
removeRefresh: 'acme',
}),
);
});
return [
PageHeader({
title: 'Certificates',
subtitle: 'ACME certificate management',
actions: h('button', { class: 'btn btn-primary',
'on:click': () => issueCertModal(state) }, 'Issue Certificate'),
}),
rows.length
? Table({
columns: ['Domain', 'Issuer', 'Expiry', 'Status', 'Actions'],
rows,
})
: Empty({ text: 'No certificates found. Issue a certificate to get started.' }),
];
},
});
+61
View File
@@ -0,0 +1,61 @@
import { h, PageHeader, definePage, getModel, renderGuard, ServiceStatus, StatCard } from '/static/hoover/index.js?v=7';
export default definePage({
init() {
return {
status: getModel('status'),
};
},
render(state) {
const guard = renderGuard(state.status, 'Dashboard', 'System overview', state.status.data);
if (guard) return guard;
const d = state.status.data;
const fwZones = (d.firewall?.zones) || {};
const net = d.net || {};
const nCount = Object.keys(net).length;
const upI = Object.values(net).filter(i => i.state === 'up');
const upC = upI.length;
const certs = d.certs || [];
const certW = certs.filter(c => c.expired || c.days_remaining <= 30);
const dmsk = d.dnsmasq?.status || {};
const wP = (d.wg || {}).peers || [];
return [
PageHeader({ title: 'Dashboard', subtitle: 'System overview' }),
h('div', { class: 'grid grid-4' },
StatCard({
label: 'Active Zones',
value: Object.keys(fwZones).length,
meta: Object.keys(fwZones).join(', ') || 'None',
}),
StatCard({
label: 'Interfaces Up',
value: upC + '/' + nCount,
meta: upI.map(i => i.name).join(', ') || 'None up',
}),
StatCard({
label: 'WireGuard',
value: String(d.wg?.state || 'unknown'),
meta: wP.length + ' peers',
}),
StatCard({
label: 'Certificates',
value: certs.length,
meta: certW.length + ' expiring/expired',
}),
),
h('div', { class: 'grid grid-2' },
h('div', { class: 'card' },
h('div', { class: 'card-header' }, 'Services'),
h('div', { class: 'card-body' },
h('ul', { class: 'service-list' },
h('li', null, ServiceStatus({ state: dmsk.state || 'down', label: 'Dnsmasq' })),
h('li', null, ServiceStatus({ state: d.wg?.state || 'down', label: 'WireGuard' })),
),
),
),
),
];
},
});
+152
View File
@@ -0,0 +1,152 @@
import { h, PageHeader, Badge, ConfirmDelete, Tabs, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionGroup, QuickModal } from '/static/hoover/index.js?v=7';
const addRange = QuickModal({
title: 'Add DHCP Range',
fields: [
{ label: 'Interface (optional)', id: 'r-iface', placeholder: 'Leave empty for global' },
{ label: 'Start IP', id: 'r-start', placeholder: '192.168.1.100' },
{ label: 'End IP', id: 'r-end', placeholder: '192.168.1.200' },
{ label: 'Lease Time', id: 'r-lease', placeholder: '12h' },
],
submit: {
url: '/api/dhcp/ranges',
body: () => ({
interface: ($val('r-iface') || '').trim() || undefined,
start: ($val('r-start') || '').trim(),
end: ($val('r-end') || '').trim(),
lease_time: ($val('r-lease') || '').trim() || '12h',
}),
validate: (b) => !b.start || !b.end ? 'Start and end are required' : null,
successMsg: 'Range added',
},
refresh: 'dnsmasq',
});
const addLease = QuickModal({
title: 'Add Static Lease',
fields: [
{ label: 'MAC', id: 'l-mac', placeholder: 'aa:bb:cc:dd:ee:ff' },
{ label: 'IP', id: 'l-ip', placeholder: '192.168.1.50' },
{ label: 'Hostname (optional)', id: 'l-host', placeholder: 'device-name' },
],
submit: {
url: '/api/dhcp/static-lease',
body: () => ({
mac: ($val('l-mac') || '').trim(),
ip: ($val('l-ip') || '').trim(),
hostname: ($val('l-host') || '').trim() || undefined,
}),
validate: (b) => !b.mac || !b.ip ? 'MAC and IP are required' : null,
successMsg: 'Lease added',
},
refresh: 'dnsmasq',
});
const addDns = QuickModal({
title: 'Add DNS Record',
fields: [
{ label: 'Name', id: 'd-name', placeholder: 'host.local' },
{ label: 'Address', id: 'd-addr', placeholder: '192.168.1.10' },
],
submit: {
url: '/api/dhcp/dns-record',
body: () => ({ name: ($val('d-name') || '').trim(), address: ($val('d-addr') || '').trim() }),
validate: (b) => !b.name || !b.address ? 'Name and address are required' : null,
successMsg: 'DNS record added',
},
refresh: 'dnsmasq',
});
export default definePage({
init() {
return {
dnsmasq: getModel('dnsmasq'),
activeTab: 'ranges',
};
},
render(state) {
const guard = renderGuard(state.dnsmasq, 'DHCP & DNS', 'Dnsmasq management', state.dnsmasq.data);
if (guard) return guard;
const cfg = state.dnsmasq.data?.config || {};
const ranges = cfg.ranges || [];
const staticLeases = cfg.static_leases || [];
const dnsRecords = cfg.dns_records || [];
const status = state.dnsmasq.data?.status || {};
const rangesRows = ranges.map((r) => h('tr', { key: (r.interface || '_g') + '-' + r.start + '-' + r.end },
h('td', null, r.interface || '(global)'),
h('td', null, esc(r.start)),
h('td', null, esc(r.end)),
h('td', null, esc(r.lease_time || '12h')),
h('td', null,
ConfirmDelete({
url: '/api/dhcp/ranges',
message: 'Remove range ' + r.start + ' - ' + r.end + '?',
body: { interface: r.interface || '', start: r.start, end: r.end },
success: 'Range removed',
refresh: 'dnsmasq',
}),
),
));
const leaseRows = staticLeases.map((l) => h('tr', { key: l.mac },
h('td', null, esc(l.mac)),
h('td', null, esc(l.ip)),
h('td', null, l.hostname || '-'),
h('td', null,
ConfirmDelete({
url: '/api/dhcp/static-lease/' + enc(l.mac),
message: 'Remove lease ' + l.mac + '?',
success: 'Lease removed',
refresh: 'dnsmasq',
}),
),
));
const dnsRows = dnsRecords.map((rec) => h('tr', { key: rec.name },
h('td', null, h('strong', null, esc(rec.name || 'unnamed'))),
h('td', { class: 'text-sm' }, esc(rec.address || '-')),
h('td', null,
ConfirmDelete({
url: '/api/dhcp/dns-record/' + enc(rec.name || ''),
message: 'Remove DNS record ' + (rec.name || 'unnamed') + '?',
success: 'Record removed',
refresh: 'dnsmasq',
}),
),
));
const tabNames = ['ranges', 'leases', 'dns', 'active'];
const actions = ActionGroup(
h('button', { class: 'btn btn-primary', 'on:click': () => addRange(state) }, 'Add Range'),
h('button', { class: 'btn btn-outline', 'on:click': () => addLease(state) }, 'Static Lease'),
h('button', { class: 'btn btn-outline', 'on:click': () => addDns(state) }, 'DNS Record'),
ActionButton({
url: '/api/dhcp/apply',
successMsg: 'dnsmasq applied',
label: 'Apply',
refresh: 'dnsmasq',
}),
);
return [
PageHeader({ title: 'DHCP & DNS', subtitle: 'Dnsmasq management', actions }),
ServiceStatus({ state: status.state || 'down', label: 'Dnsmasq' }),
Tabs({ state, tabs: tabNames }),
state.activeTab === 'ranges'
? Table({ columns: ['Interface', 'Start', 'End', 'Lease', 'Action'], rows: rangesRows, emptyText: 'No DHCP ranges' }) : null,
state.activeTab === 'leases'
? Table({ columns: ['MAC', 'IP', 'Hostname', 'Action'], rows: leaseRows, emptyText: 'No static leases' }) : null,
state.activeTab === 'dns'
? Table({ columns: ['Name', 'Address', 'Action'], rows: dnsRows, emptyText: 'No custom DNS records' }) : null,
state.activeTab === 'active'
? Table({ columns: ['MAC', 'IP', 'Hostname', 'Expires'], rows: (state.dnsmasq.data?.leases || []).map((l) => h('tr', { key: l.mac || l.ip },
h('td', null, esc(l.mac || '-')),
h('td', null, esc(l.ip || '-')),
h('td', null, esc(l.hostname || '-')),
h('td', null, esc(l.expires || '-')),
)), emptyText: 'No active leases' }) : null,
];
},
});
+103
View File
@@ -0,0 +1,103 @@
import { h, PageHeader, Table, renderGuardMulti, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, StatusText, QuickModal, ZoneSelect } from '/static/hoover/index.js?v=7';
async function changeZone(name, zone, state) {
const r = await apiFetch('/api/firewall/zones/' + enc(zone) + '/interfaces', {
method: 'POST',
body: { interfaces: [name] },
});
if (r.ok) {
toast(name + ' \u2192 ' + zone, 'success');
modelFetch('firewall');
modelFetch('network');
} else {
toast(r.error || 'Failed', 'error');
}
}
const cfgModalFn = QuickModal({
title: (d) => 'Config: ' + d.name,
fields: (d) => {
const cfg = d.config || {};
return [
{ label: 'Addresses (comma-separated)', id: 'cfg-addrs', value: (cfg.addresses || []).join(', '), placeholder: '192.168.1.1/24' },
{ label: 'Gateway', id: 'cfg-gw', value: cfg.gateway || '' },
{ label: 'DNS (comma-separated)', id: 'cfg-dns', value: (cfg.dns || []).join(', '), placeholder: '1.1.1.1, 8.8.8.8' },
];
},
submit: {
url: (d) => '/api/network/interfaces/' + enc(d.name),
body: () => ({
addresses: ($val('cfg-addrs') || '').split(',').map(s => s.trim()).filter(Boolean),
gateway: ($val('cfg-gw') || '').trim() || undefined,
dns: ($val('cfg-dns') || '').split(',').map(s => s.trim()).filter(Boolean),
}),
successMsg: 'Config saved',
},
refresh: ['firewall', 'network'],
});
export default definePage({
init() {
return {
firewall: getModel('firewall'),
network: getModel('network'),
};
},
render(state) {
const guard = renderGuardMulti('Interfaces', 'Network interface management', state.firewall, state.network);
if (guard) return guard;
const fwZones = state.firewall.data?.zones || {};
const netData = state.network.data?.interfaces || {};
const zones = fwZones.available || [];
const activeZones = fwZones.active || {};
const ifaces = Object.entries(netData).map(([name, entry]) => {
let zone = null;
for (const [zoneName, ifaces] of Object.entries(activeZones)) {
if ((ifaces || []).includes(name)) {
zone = zoneName;
break;
}
}
return {
name,
mac: entry?.runtime?.mac || null,
ips: [...(entry?.config?.addresses || []), ...(entry?.runtime?.addresses || [])],
state: (entry?.runtime?.state || '').startsWith('routable') || (entry?.runtime?.state || '').startsWith('carrier') ? 'up' : 'down',
zone,
config: entry?.config || {},
};
});
const rows = ifaces.map(iface => {
return h('tr', { key: iface.name },
h('td', null, h('strong', null, iface.name)),
h('td', { class: 'text-muted' }, String(iface.mac || 'N/A')),
h('td', null, (iface.ips || []).join(', ') || 'N/A'),
h('td', null, StatusText({ status: iface.state })),
h('td', null,
ZoneSelect({
zones,
value: iface.zone,
onChange: (z) => changeZone(iface.name, z, state),
}),
h('button', {
class: 'btn btn-sm btn-outline',
style: 'margin-left:8px',
'on:click': () => cfgModalFn(iface),
}, 'Config'),
),
);
});
return [
PageHeader({ title: 'Interfaces', subtitle: 'Network interface management' }),
Table({
columns: ['Name', 'MAC', 'IPs', 'State', 'Zone / Actions'],
rows,
emptyText: 'No interfaces found',
}),
];
},
});
+58
View File
@@ -0,0 +1,58 @@
import { h, PageHeader, Tabs, esc, definePage, renderGuard, modelFetch, getModel } from '/static/hoover/index.js?v=7';
const logTabs = [
{ key: 'journal', label: 'Journal' },
{ key: 'nginx-access', label: 'Nginx Access' },
{ key: 'nginx-error', label: 'Nginx Error' },
{ key: 'dnsmasq', label: 'Dnsmasq' },
{ key: 'app', label: 'App' },
];
export default definePage({
init() {
return {
logs: getModel('logs'),
activeTab: 'journal',
};
},
render(state) {
const logData = state.logs.data;
const stale = logData?.tab !== state.activeTab;
const guard = renderGuard(state.logs, 'Logs', 'System and service logs', stale ? undefined : logData?.data);
if (guard) return guard;
const lines = logData.data || [];
const tab = logTabs.find(t => t.key === state.activeTab) || logTabs[0];
const lineVnodes = lines.map((line, i) =>
h('div', { class: 'log-line', key: i }, esc(line))
);
const tabsBody = Tabs({
state,
tabs: logTabs.map(t => t.key),
formatLabel: (k) => {
const t = logTabs.find(t => t.key === k);
return t ? t.label : k.charAt(0).toUpperCase() + k.slice(1);
},
onTabClick: (key) => modelFetch('logs', key),
});
return [
PageHeader({ title: 'Logs', subtitle: 'System and service logs' }),
h('div', { class: 'card', key: 'log-card' },
tabsBody,
h('div', { class: 'card-header' },
h('span', null, tab.label),
h('button', {
class: 'btn btn-sm btn-outline',
style: 'float:right;',
'on:click': () => modelFetch('logs', state.activeTab),
}, '\u21BB'),
),
h('div', { class: 'card-body log-body' },
h('pre', null, lineVnodes),
),
),
];
},
});
+143
View File
@@ -0,0 +1,143 @@
import { h, PageHeader, Badge, StatusDot, Card, Table, renderGuard, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, DataTableSection, SectionTitle, ActionGroup, QuickModal, ConfirmDelete } from '/static/hoover/index.js?v=7';
const addFwd = QuickModal({
title: 'Add Port Forward',
fields: (d) => [
{ label: 'Zone', id: 'fwd-zone', tag: 'select', options: d.zones },
{ label: 'Port', id: 'fwd-port', type: 'number' },
{ label: 'Protocol', id: 'fwd-proto', placeholder: 'tcp or udp' },
{ label: 'To Address', id: 'fwd-toaddr', placeholder: '192.168.1.100' },
{ label: 'To Port (optional)', id: 'fwd-toport', type: 'number' },
],
submit: {
url: '/api/firewall/forward-port',
body: () => ({
zone: $val('fwd-zone'),
port: parseInt($val('fwd-port')),
proto: ($val('fwd-proto') || 'tcp').trim(),
toaddr: ($val('fwd-toaddr') || '').trim() || undefined,
toport: $val('fwd-toport') ? parseInt($val('fwd-toport')) : undefined,
}),
validate: (b) => !b.zone || !b.port || !b.proto ? 'Zone, port, and proto are required' : null,
successMsg: 'Forward rule added',
},
refresh: 'firewall',
});
export default definePage({
init() {
return {
firewall: getModel('firewall'),
};
},
render(state) {
const guard = renderGuard(state.firewall, 'NAT', 'Masquerade & port forwarding', state.firewall.data?.config);
if (guard) return guard;
const cfg = state.firewall.data?.config || {};
const zoneData = cfg.zones || {};
const sIface = (state.firewall.data?.state || {}).interfaces || [];
const masqZones = new Set(
Object.entries(zoneData)
.filter(([, zcfg]) => !!zcfg.masquerade)
.map(([z]) => z)
);
const wanIface = sIface.filter((i) => i.zone && masqZones.has(i.zone));
const lanIface = sIface.filter((i) => i.zone && !masqZones.has(i.zone));
const ifaceRows = (ifaces) =>
ifaces.map((iface) =>
h('tr', { key: 'ii-' + iface.name },
h('td', null,
h('div', { class: 'd-flex align-items-center gap-2' },
StatusDot({ status: iface.state === 'UP' ? 'up' : 'down' }),
h('strong', null, iface.name),
),
),
h('td', null, (iface.ips || []).join(', ') || h('span', { class: 'text-muted' }, '—')),
h('td', null, (iface.ipv6 || []).join(', ') || h('span', { class: 'text-muted' }, '—')),
h('td', null, iface.mac || h('span', { class: 'text-muted' }, '—')),
h('td', null, Badge({ text: iface.zone || '—', variant: 'secondary' })),
)
);
const masqRows = Object.entries(zoneData).map(([zone, zcfg]) => {
const masq = !!zcfg.masquerade;
return h('tr', { key: 'm-' + zone },
h('td', null, h('strong', null, zone)),
h('td', null, Badge({ text: masq ? 'Enabled' : 'Disabled', variant: masq ? 'success' : 'info' })),
h('td', null,
ActionButton({
url: '/api/firewall/masquerade',
cls: 'btn btn-sm btn-outline',
labelOn: 'Disable', labelOff: 'Enable', condition: masq,
body: () => ({ zone, enable: !masq }),
successMsg: 'Masquerade ' + (masq ? 'disabled' : 'enabled') + ' on ' + zone,
refresh: 'firewall',
}),
),
);
});
const fwRows = [];
Object.entries(zoneData).forEach(([zone, zcfg]) => {
const forwards = zcfg.forward_ports || [];
forwards.forEach((fwd, i) => {
const port = fwd.port;
const proto = fwd['proxy-protocol'] || fwd.proto;
fwRows.push(h('tr', { key: 'f-' + zone + '-' + i },
h('td', null, h('strong', null, zone)),
h('td', null, Badge({ text: proto || 'tcp', variant: 'info' })),
h('td', null, port),
h('td', null, fwd['to-addr'] || fwd.toaddr || '-'),
h('td', null, fwd['to-port'] || fwd.toport || '-'),
h('td', null,
ConfirmDelete({
url: '/api/firewall/forward-port/' + enc(zone) + '/' + port + '/' + enc(proto),
message: 'Remove forward ' + zone + ':' + port + '/' + proto + '?',
success: 'Rule removed',
refresh: 'firewall',
}),
),
));
});
});
return [
PageHeader({ title: 'NAT', subtitle: 'Masquerade & port forwarding' }),
DataTableSection({
title: 'WAN / External',
columns: ['Interface', 'IPv4', 'IPv6', 'MAC', 'Zone'],
rows: ifaceRows(wanIface),
emptyText: 'No WAN interfaces with masquerade enabled',
}),
DataTableSection({
title: 'Internal / LAN',
columns: ['Interface', 'IPv4', 'IPv6', 'MAC', 'Zone'],
rows: ifaceRows(lanIface),
emptyText: 'No internal interfaces',
}),
DataTableSection({
title: 'Masquerade',
columns: ['Zone', 'Status', 'Action'],
rows: masqRows,
emptyText: 'No zones',
}),
SectionTitle({ title: 'Port Forwarding' }),
Card({ children: [
ActionGroup(
h('button', { class: 'btn btn-sm btn-primary',
'on:click': () => addFwd({ zones: Object.keys(zoneData) })
}, 'Add Forward'),
),
Table({
columns: ['Zone', 'Proto', 'Port', 'To Addr', 'To Port', 'Action'],
rows: fwRows,
emptyText: 'No port forwarding rules',
wrapCard: false,
}),
]}),
];
},
});
+15
View File
@@ -0,0 +1,15 @@
import { h, PageHeader, definePage } from '/static/hoover/index.js?v=7';
export default definePage({
init() {
return { path: location.hash.slice(1) || '' };
},
render(state) {
return [
PageHeader({ title: '404' }),
h('div', { class: 'card' },
h('div', { class: 'card-body text-muted' }, 'Page not found: ' + state.path),
),
];
},
});
+107
View File
@@ -0,0 +1,107 @@
import { h, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, certStatusBadge, ActionGroup, QuickModal } from '/static/hoover/index.js?v=7';
const addDomain = QuickModal({
title: 'Add Proxy Domain',
fields: [
{ label: 'Domain', id: 'p-domain', placeholder: 'example.com' },
{ label: 'Backend Host', id: 'p-host', placeholder: '192.168.1.10' },
{ label: 'Backend Port', id: 'p-port', type: 'number', placeholder: '8080' },
{ label: 'Protocol', id: 'p-proto', placeholder: 'http or https' },
{ label: 'Cert (optional)', id: 'p-cert', placeholder: 'acme' },
],
submit: {
url: '/api/proxy/domains',
body: () => ({
domain: ($val('p-domain') || '').trim(),
backend_host: ($val('p-host') || '').trim(),
backend_port: parseInt($val('p-port')),
backend_proto: ($val('p-proto') || 'http').trim() || 'http',
cert: ($val('p-cert') || '').trim() || undefined,
}),
validate: (b) => !b.domain || !b.backend_host || !b.backend_port ? 'Domain, host, and port are required' : null,
successMsg: 'Domain added',
},
refresh: ['nginx', 'acme'],
});
const editDomain = QuickModal({
title: (d) => 'Edit: ' + d.domain,
fields: (d) => [
{ label: 'Backend Host', id: 'pe-host', value: d.backend_host || '' },
{ label: 'Backend Port', id: 'pe-port', type: 'number', value: d.backend_port || '' },
{ label: 'Protocol', id: 'pe-proto', value: d.backend_proto || d.protocol || 'http' },
{ label: 'Cert (optional)', id: 'pe-cert', value: d.cert || '' },
],
submit: {
url: (d) => '/api/proxy/domains/' + enc(d.domain),
method: 'PUT',
body: () => ({
backend_host: ($val('pe-host') || '').trim(),
backend_port: parseInt($val('pe-port')),
backend_proto: ($val('pe-proto') || 'http').trim(),
cert: ($val('pe-cert') || '').trim() || undefined,
}),
validate: (b) => !b.backend_host || !b.backend_port ? 'Host and port are required' : null,
successMsg: 'Domain updated',
},
refresh: ['nginx', 'acme'],
});
export default definePage({
init() {
return {
nginx: getModel('nginx'),
acme: getModel('acme'),
};
},
render(state) {
const guard = renderGuardMulti('Proxy', 'Nginx reverse proxy', state.nginx, state.acme);
if (guard) return guard;
const domains = state.nginx.data || [];
const rows = domains.map(d => {
const certBadge = certStatusBadge({
certStatus: d.cert_status,
daysRemaining: d.days_remaining,
expired: d.cert_status === 'expired',
});
return h('tr', { key: d.domain },
h('td', null, h('strong', null, esc(d.domain))),
h('td', null, esc(d.backend_host || '-')),
h('td', null, d.backend_port || '-'),
h('td', null, Badge({ text: d.backend_proto || d.protocol || 'http', variant: 'info' })),
h('td', null, certBadge),
ActionCell({
editLabel: 'Edit',
editClick: () => editDomain(d),
removeUrl: '/api/proxy/domains/' + enc(d.domain),
removeMessage: 'Remove proxy for ' + d.domain + '?',
removeSuccess: 'Domain removed',
removeRefresh: ['nginx', 'acme'],
removeLabel: 'Delete',
}),
);
});
const actions = ActionGroup(
h('button', { class: 'btn btn-primary', 'on:click': () => addDomain(state) }, 'Add Domain'),
ActionButton({
url: '/api/proxy/apply',
successMsg: 'Nginx applied & reloaded',
label: 'Apply',
refresh: ['nginx', 'acme'],
}),
);
return [
PageHeader({ title: 'Proxy', subtitle: 'Nginx reverse proxy', actions }),
rows.length
? Table({
columns: ['Domain', 'Backend Host', 'Port', 'Proto', 'Cert', 'Actions'],
rows,
})
: Empty({ text: 'No proxy domains configured. Add a domain to start terminating SSL.' }),
];
},
});
+75
View File
@@ -0,0 +1,75 @@
import { h, PageHeader, Empty, Card, ConfirmDelete, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, MonoText, QuickModal } from '/static/hoover/index.js?v=7';
const addRule = QuickModal({
title: 'Add Rich Rule',
fields: (d) => [
{ label: 'Zone', id: 'rule-zone', tag: 'select', options: d.zones },
{ label: 'Rule (XML)', id: 'rule-text', tag: 'textarea', placeholder: 'rule family="ipv4" source address="192.168.1.0/24" accept' },
],
submit: {
url: '/api/firewall/rich-rules',
body: () => ({ zone: $val('rule-zone'), rule: ($val('rule-text') || '').trim() }),
validate: (b) => !b.zone || !b.rule ? 'Zone and rule are required' : null,
successMsg: 'Rule added',
},
refresh: 'firewall',
});
export default definePage({
init() {
return {
firewall: getModel('firewall'),
};
},
render(state) {
const guard = renderGuard(state.firewall, 'Rules', 'Firewall rich rules', state.firewall.data?.config);
if (guard) return guard;
const cfg = state.firewall.data?.config || {};
const zones = state.firewall.data?.zones?.available || [];
const zoneData = cfg.zones || {};
const zoneRules = {};
Object.entries(zoneData).forEach(([zname, zcfg]) => {
const rr = zcfg.rich_rules || [];
if (rr.length) zoneRules[zname] = rr;
});
const cards = Object.entries(zoneRules).map(([zone, rules]) => {
return Card({
header: 'Zone: ' + esc(zone),
key: zone,
children: [Table({
columns: ['#', 'Rule', 'Action'],
rows: (Array.isArray(rules) ? rules : []).map((entry, i) => {
const ruleId = typeof entry === 'object' ? entry.id : null;
const ruleText = typeof entry === 'object' ? (entry.rule || '') : String(entry);
return h('tr', { key: i },
h('td', { class: 'text-muted' }, i + 1),
h('td', { class: 'mono-text td-fullwidth' }, MonoText({ text: ruleText })),
h('td', null,
ConfirmDelete({
url: '/api/firewall/rich-rules/' + enc(zone) + '/' + enc(ruleId || ''),
message: 'Remove rule: ' + ruleText.substring(0, 40) + '...?',
success: 'Rule removed',
refresh: 'firewall',
}),
),
);
}),
emptyText: 'No rules',
wrapCard: false,
})],
});
});
return [
PageHeader({
title: 'Rules',
subtitle: 'Firewall rich rules',
actions: h('button', { class: 'btn btn-primary',
'on:click': () => addRule({ zones }), }, 'Add Rule'),
}),
...(cards.length ? cards : [Empty({ text: 'No rich rules configured.' })]),
];
},
});
+125
View File
@@ -0,0 +1,125 @@
import { h, PageHeader, Badge, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, modelFetch, ActionButton, ActionCell, MonoText, ActionGroup, QuickModal, downloadBlob } from '/static/hoover/index.js?v=7';
const addPeer = QuickModal({
title: 'Add WireGuard Peer',
fields: [
{ label: 'Name', id: 'wg-name', placeholder: 'client-name' },
{ label: 'Endpoint (optional)', id: 'wg-endpoint', placeholder: '1.2.3.4:51820' },
{ label: 'Allowed IPs (optional)', id: 'wg-allowed', placeholder: '10.0.0.2/32' },
{ label: 'Persistent Keepalive (optional)', id: 'wg-keepalive', type: 'number', placeholder: '25' },
],
submit: {
url: '/api/wireguard/peers',
body: () => ({
name: ($val('wg-name') || '').trim(),
endpoint: ($val('wg-endpoint') || '').trim() || undefined,
allowed_ips: ($val('wg-allowed') || '').trim() ? [($val('wg-allowed') || '').trim()] : [],
persistent_keepalive: $val('wg-keepalive') ? parseInt($val('wg-keepalive')) : undefined,
}),
validate: (b) => !b.name ? 'Name is required' : null,
successMsg: 'Peer added',
},
refresh: 'wireguard',
});
function downloadConfigModal(peerName, config, state) {
openModal((inner, idx) => {
formModal(inner, 'Download Config for ' + peerName,
[{ label: 'Server Endpoint', id: 'wg-srv-endpoint', placeholder: 'vpn.example.com:51820' }],
[
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
{
label: 'Generate', cls: 'btn-primary', action: 's', handler: async () => {
const endpoint = ($val('wg-srv-endpoint') || '').trim();
if (!endpoint) { toast('Server endpoint is required', 'error'); return; }
const resp = await apiFetch('/api/wireguard/generate-client', {
method: 'POST',
body: { name: peerName, server_endpoint: endpoint },
});
if (resp.ok && resp.data?.config) {
downloadBlob(new Blob([resp.data.config], { type: 'text/plain' }), peerName + '.conf');
toast('Config downloaded', 'success');
closeModal(idx);
} else {
toast(resp.error || 'Failed', 'error');
}
},
},
],
);
});
}
export default definePage({
init() {
return {
wireguard: getModel('wireguard'),
};
},
render(state) {
const guard = renderGuard(state.wireguard, 'WireGuard', null, state.wireguard.data?.peers);
if (guard) return guard;
const st = state.wireguard.data?.status || {};
const isUp = st.state === 'up';
const listenPort = (state.wireguard.data?.config?.interface || {}).listen_port || '-';
const peerRows = (state.wireguard.data?.peers || []).map(p => {
const hasHandshake = !!p.latest_handshake;
return h('tr', { key: p.name },
h('td', null,
StatusDot({ status: hasHandshake ? 'success' : 'danger' }),
h('strong', null, esc(p.name || 'unnamed')),
),
h('td', null, MonoText({ text: p.public_key || 'N/A', maxLength: 20 })),
h('td', { class: 'text-sm' }, esc(p.allowed_ips || '-')),
h('td', { class: 'text-sm' }, esc(p.endpoint || '-')),
h('td', { class: 'text-sm' }, esc(p.latest_handshake || 'Never')),
h('td', { class: 'text-sm' },
'Recv: ' + esc(p.transfer_recv || '0'),
h('br'),
'Sent: ' + esc(p.transfer_sent || '0'),
),
ActionCell({
editLabel: 'Config',
editClick: () => downloadConfigModal(p.name, state.wireguard.data?.config, state),
removeUrl: '/api/wireguard/peers/' + enc(p.name),
removeMessage: 'Remove peer ' + p.name + '?',
removeSuccess: 'Peer removed',
removeRefresh: 'wireguard',
}),
);
});
const actions = ActionGroup(
h('button', { class: 'btn btn-primary', 'on:click': () => addPeer(state) }, 'Add Peer'),
ActionButton({
url: '/api/wireguard/' + (isUp ? 'down' : 'up'),
labelOn: 'Stop', labelOff: 'Start', condition: isUp,
successMsg: 'Tunnel ' + (isUp ? 'stopped' : 'started'),
refresh: 'wireguard',
}),
ActionButton({
url: '/api/wireguard/apply',
successMsg: 'Config applied',
label: 'Apply',
refresh: 'wireguard',
}),
);
return [
PageHeader({
title: 'WireGuard',
subtitle: 'Tunnel: ' + (st.state || 'unknown') + ', Listen: ' + listenPort,
actions,
}),
ServiceStatus({ state: st.state || 'down' }),
peerRows.length
? Table({
columns: ['Peer', 'Public Key', 'Allowed IPs', 'Endpoint', 'Handshake', 'Transfer', 'Actions'],
rows: peerRows,
})
: Empty({ text: 'No peers configured. Add a peer above.' }),
];
},
});
+107
View File
@@ -0,0 +1,107 @@
import { h, PageHeader, Badge, Empty, ConfirmDelete, renderGuard, enc, esc, $val, definePage, getModel, MultiSelectModal, QuickModal } from '/static/hoover/index.js?v=7';
const addZone = QuickModal({
title: 'Add Zone',
fields: [
{ label: 'Zone name', id: 'zone-name', placeholder: 'e.g. internal' },
{ label: 'Target', id: 'zone-target', placeholder: 'default (usually leave as default)' },
],
submit: {
url: '/api/firewall/zones',
body: () => ({ name: ($val('zone-name') || '').trim(), target: ($val('zone-target') || '').trim() || 'default' }),
validate: (b) => !b.name ? 'Zone name required' : null,
successMsg: 'Zone created',
},
refresh: 'firewall',
});
export default definePage({
init() {
return {
firewall: getModel('firewall'),
};
},
render(state) {
const guard = renderGuard(state.firewall, 'Zones', 'Firewall zones', state.firewall.data?.zones);
if (guard) return guard;
const zones = state.firewall.data?.zones?.available || [];
const activeZones = state.firewall.data?.zones?.active || {};
const zoneDetails = {};
for (const name of zones) {
const activeIfaces = activeZones[name];
zoneDetails[name] = { interfaces: Array.isArray(activeIfaces) ? activeIfaces : [] };
}
const zoneCards = Object.entries(zoneDetails).map(([name, zdata]) => {
const z = typeof zdata === 'object' ? zdata : {};
const ifacesArr = Array.isArray(z.interfaces) ? z.interfaces : [];
const svcsArr = Array.isArray(z.services) ? z.services : [];
return h('div', { class: 'card', key: name, style: 'position:relative;' },
h('div', { style: 'display:flex;justify-content:space-between;align-items:flex-start;' },
h('div', null,
h('h3', { style: 'font-size:16px;color:var(--accent);' }, name),
h('div', { class: 'text-muted text-sm', style: 'margin-bottom:10px;' },
z.target ? 'Target: ' + esc(z.target) : '',
),
),
),
h('div', { class: 'text-sm mb-4' },
h('div', { class: 'text-muted', style: 'margin-bottom:4px;' }, 'Interfaces'),
ifacesArr.length
? ifacesArr.map(i => Badge({ text: esc(i) }))
: h('span', { class: 'text-muted' }, 'None'),
),
h('div', { class: 'text-sm mb-4' },
h('div', { class: 'text-muted', style: 'margin-bottom:4px;' }, 'Services'),
svcsArr.length
? svcsArr.map(s => Badge({ text: esc(s), variant: 'success' }))
: h('span', { class: 'text-muted' }, 'None'),
),
h('div', { style: 'display:flex;gap:6px;' },
h('button', { class: 'btn btn-sm btn-outline',
'on:click': () => MultiSelectModal({
title: 'Interfaces: ' + name,
url: '/api/firewall/zones/' + enc(name) + '/interfaces',
options: state.firewall.data?.interfaces || [],
selected: ifacesArr,
fieldKey: 'interfaces',
successMsg: 'Interfaces updated',
refresh: 'firewall',
})(),
}, 'Interfaces'),
h('button', { class: 'btn btn-sm btn-outline',
'on:click': () => MultiSelectModal({
title: 'Services: ' + name,
url: '/api/firewall/zones/' + enc(name) + '/services',
options: state.firewall.data?.services || [],
selected: svcsArr,
fieldKey: 'services',
successMsg: 'Services updated',
refresh: 'firewall',
})(),
}, 'Services'),
ConfirmDelete({
url: '/api/firewall/zones/' + enc(name),
message: 'Delete zone ' + name + '?',
success: 'Zone ' + name + ' deleted',
refresh: 'firewall',
label: 'Delete',
}),
),
);
});
return [
PageHeader({
title: 'Zones',
subtitle: 'Firewall zones',
actions: h('button', { class: 'btn btn-primary',
'on:click': () => addZone(), }, 'Add Zone'),
}),
zoneCards.length
? h('div', { class: 'card-grid' }, ...zoneCards)
: Empty({ text: 'No zones configured. Add a zone to get started.' }),
];
},
});
+229 -1
View File
@@ -301,10 +301,40 @@ body {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
animation: toastSlideIn 0.3s ease forwards;
display: flex;
align-items: center;
align-items: flex-start;
justify-content: space-between;
gap: 0.6rem;
word-break: break-word;
}
.toast-message .toast-text {
flex: 1;
min-width: 0;
white-space: pre-wrap;
}
.toast-message .toast-actions {
display: flex;
gap: 2px;
flex-shrink: 0;
align-items: center;
height: 1.4em;
}
.toast-message .toast-btn {
background: none;
border: none;
cursor: pointer;
color: inherit;
font-size: 14px;
padding: 0 2px;
line-height: 1;
opacity: 0.6;
transition: opacity 0.15s;
}
.toast-message .toast-btn:hover { opacity: 1; }
.toast-message.toast-success {
background: var(--success);
color: #fff;
@@ -464,6 +494,204 @@ body {
.mb-1 { margin-bottom: 0.5rem; }
.mb-2 { margin-bottom: 1rem; }
/* Page header */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.page-header h1 {
font-size: 22px;
font-weight: 600;
}
.page-header .subtitle {
color: var(--text-muted);
font-size: 13px;
}
/* Stat cards */
.stat-card {
background: var(--bg-card);
border-radius: 8px;
padding: 1rem 1.25rem;
}
.stat-card .label {
font-size: 12px;
color: var(--text-muted);
margin-bottom: 4px;
}
.stat-card .value {
font-size: 28px;
font-weight: 700;
}
.stat-card .meta {
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
}
/* Status dot */
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
}
.status-up { background: var(--success); }
.status-down { background: var(--danger); }
.status-pending { background: var(--warning); }
/* Badge info */
.badge-info {
background: rgba(0, 180, 216, 0.15);
color: var(--accent);
}
/* Modal actions */
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 1rem;
}
/* Section title */
.section-title {
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
margin-bottom: 1rem;
margin-top: 1.5rem;
}
.section-title:first-child { margin-top: 0; }
/* Card grid */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1.25rem;
margin-bottom: 1.5rem;
}
.card .card-grid {
padding: 1.25rem;
}
/* Service list */
.service-list {
list-style: none;
padding: 0;
margin: 0;
}
.service-list li {
padding: 6px 0;
display: flex;
align-items: center;
gap: 8px;
}
.service-list .svc-name {
flex: 1;
}
/* Tabs */
.tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border);
margin-bottom: 1rem;
}
.tab {
padding: 0.75rem 1.25rem;
cursor: pointer;
color: var(--text-muted);
font-size: 14px;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.tab:hover {
color: var(--text);
}
.tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* Logs area */
.logs-area {
max-height: 60vh;
overflow-y: auto;
font-family: monospace;
font-size: 12px;
line-height: 1.5;
padding: 1rem;
background: #0d0d1a;
border-radius: 6px;
border: 1px solid var(--border);
}
.log-line {
padding: 2px 0;
border-bottom: 1px solid rgba(255,255,255,0.04);
}
.log-line.error { color: var(--danger); }
.log-line.warn { color: var(--warning); }
.log-line.info { color: var(--text); }
/* Auto-refresh indicator */
.refresh-active::before {
content: "";
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--success);
margin-right: 6px;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* Loading / error */
.loading {
color: var(--text-muted);
padding: 2rem;
text-align: center;
}
.error-msg {
color: var(--danger);
padding: 2rem;
text-align: center;
}
/* Responsive */
@media (max-width: 768px) {
.sidebar {
-598
View File
@@ -1,598 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Vacuum Wall{% endblock %}</title>
<style>
:root {
--bg-primary: #1a1a2e;
--bg-secondary: #16213e;
--bg-card: #0f3460;
--bg-card-hover: #0f3460d0;
--accent: #00b4d8;
--accent-hover: #0096c7;
--text: #e0e0e0;
--text-muted: #888;
--danger: #e63946;
--danger-hover: #c62828;
--success: #2ecc71;
--warning: #f1c40f;
--border: #1a1a3e;
--input-bg: #0d1b2a;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: var(--bg-primary);
color: var(--text);
display: flex;
min-height: 100vh;
}
/* Sidebar */
.sidebar {
width: 220px;
background: var(--bg-secondary);
border-right: 1px solid var(--border);
padding: 0;
flex-shrink: 0;
position: fixed;
top: 0;
left: 0;
bottom: 0;
overflow-y: auto;
z-index: 100;
}
.sidebar-header {
padding: 20px 16px;
border-bottom: 1px solid var(--border);
font-size: 16px;
font-weight: 700;
color: var(--accent);
letter-spacing: 1px;
}
.sidebar-header span {
color: var(--text-muted);
font-weight: 400;
font-size: 11px;
display: block;
margin-top: 2px;
letter-spacing: 0;
}
.sidebar nav {
padding: 8px 0;
}
.sidebar nav a {
display: flex;
align-items: center;
padding: 10px 20px;
color: var(--text-muted);
text-decoration: none;
font-size: 13.5px;
transition: all 0.15s ease;
border-left: 3px solid transparent;
}
.sidebar nav a:hover {
color: var(--text);
background: rgba(0, 180, 216, 0.05);
}
.sidebar nav a.active {
color: var(--accent);
background: rgba(0, 180, 216, 0.08);
border-left-color: var(--accent);
}
/* Main */
.main {
margin-left: 220px;
flex: 1;
padding: 24px 32px;
min-height: 100vh;
width: calc(100vw - 220px);
}
.page-header {
margin-bottom: 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.page-header h1 {
font-size: 22px;
font-weight: 600;
}
.page-header .subtitle {
color: var(--text-muted);
font-size: 13px;
margin-top: 4px;
}
/* Cards */
.card {
background: var(--bg-card);
border-radius: 8px;
padding: 20px;
margin-bottom: 16px;
border: 1px solid var(--border);
}
.card h3 {
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
color: var(--text);
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: var(--bg-card);
border-radius: 8px;
padding: 18px 20px;
border: 1px solid var(--border);
}
.stat-card .label {
font-size: 12px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-card .value {
font-size: 28px;
font-weight: 700;
margin-top: 6px;
color: var(--accent);
}
.stat-card .meta {
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
text-decoration: none;
}
.btn-primary {
background: var(--accent);
color: #000;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-danger {
background: var(--danger);
color: #fff;
}
.btn-danger:hover {
background: var(--danger-hover);
}
.btn-sm {
padding: 5px 10px;
font-size: 12px;
}
.btn-outline {
background: transparent;
color: var(--accent);
border: 1px solid var(--accent);
}
.btn-outline:hover {
background: rgba(0, 180, 216, 0.1);
}
/* Tables */
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 10px 14px;
text-align: left;
border-bottom: 1px solid var(--border);
font-size: 13px;
}
th {
color: var(--text-muted);
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
tr:hover td {
background: rgba(0, 180, 216, 0.03);
}
/* Forms */
.form-group {
margin-bottom: 14px;
}
.form-group label {
display: block;
font-size: 12px;
color: var(--text-muted);
margin-bottom: 5px;
font-weight: 500;
}
input[type="text"],
input[type="number"],
input[type="email"],
input[type="password"],
input[type="url"],
select,
textarea {
width: 100%;
padding: 8px 12px;
background: var(--input-bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 13px;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
}
input:focus,
select:focus,
textarea:focus {
border-color: var(--accent);
}
textarea {
resize: vertical;
min-height: 80px;
}
select {
cursor: pointer;
}
/* Badges */
.badge {
display: inline-block;
padding: 3px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}
.badge-success { background: rgba(46, 204, 113, 0.15); color: var(--success); }
.badge-warning { background: rgba(241, 196, 15, 0.15); color: var(--warning); }
.badge-danger { background: rgba(230, 57, 70, 0.15); color: var(--danger); }
.badge-info { background: rgba(0, 180, 216, 0.15); color: var(--accent); }
/* Status indicator */
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
}
.status-up { background: var(--success); }
.status-down { background: var(--danger); }
.status-pending { background: var(--warning); }
/* Modal */
.modal-overlay {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 200;
align-items: center;
justify-content: center;
}
.modal-overlay.active {
display: flex;
}
.modal {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 10px;
padding: 24px;
width: 90%;
max-width: 480px;
max-height: 80vh;
overflow-y: auto;
}
.modal h2 {
font-size: 16px;
margin-bottom: 16px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 18px;
}
/* Toast */
.toast-container {
position: fixed;
top: 16px;
right: 16px;
z-index: 300;
display: flex;
flex-direction: column;
gap: 8px;
}
.toast {
padding: 12px 18px;
border-radius: 8px;
font-size: 13px;
min-width: 250px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
opacity: 0;
transform: translateX(20px);
transition: all 0.3s ease;
}
.toast.show {
opacity: 1;
transform: translateX(0);
}
.toast-success { background: #0d3b2e; border: 1px solid var(--success); color: var(--success); }
.toast-error { background: #3b0d0d; border: 1px solid var(--danger); color: var(--danger); }
.toast-warning { background: #3b3408; border: 1px solid var(--warning); color: var(--warning); }
/* Tabs */
.tabs {
display: flex;
border-bottom: 1px solid var(--border);
margin-bottom: 16px;
gap: 0;
}
.tab {
padding: 10px 18px;
font-size: 13px;
color: var(--text-muted);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
background: none;
border-top: none;
border-left: none;
border-right: none;
transition: all 0.15s;
}
.tab:hover { color: var(--text); }
.tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.tab-content { display: none; }
.tab-content.active { display: block; }
/* Scrollable log */
.log-viewer {
background: #0a0a14;
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
line-height: 1.6;
max-height: 600px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
color: var(--text);
}
/* Toggle switch */
.switch {
position: relative;
display: inline-block;
width: 40px;
height: 22px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.switch .slider {
position: absolute;
cursor: pointer;
top: 0; left: 0; right: 0; bottom: 0;
background: var(--border);
border-radius: 22px;
transition: 0.2s;
}
.switch .slider:before {
content: "";
position: absolute;
height: 16px;
width: 16px;
left: 3px;
bottom: 3px;
background: var(--text);
border-radius: 50%;
transition: 0.2s;
}
.switch input:checked + .slider {
background: var(--accent);
}
.switch input:checked + .slider:before {
transform: translateX(18px);
}
/* Flex utils */
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.gap-2 { gap: 8px; }
.gap-4 { gap: 16px; }
.mt-2 { margin-top: 8px; }
.mt-4 { margin-top: 16px; }
.mb-4 { margin-bottom: 16px; }
.text-muted { color: var(--text-muted); }
.text-sm { font-size: 12px; }
.text-right { text-align: right; }
.w-full { width: 100%; }
/* Service status list */
.service-list {
list-style: none;
}
.service-list li {
padding: 6px 0;
display: flex;
align-items: center;
font-size: 13px;
}
.service-list .svc-name {
flex: 1;
}
/* Inline form row */
.inline-form {
display: flex;
gap: 8px;
align-items: flex-end;
flex-wrap: wrap;
}
.inline-form .form-group {
margin-bottom: 0;
}
/* Section titles */
.section-title {
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
margin-top: 24px;
}
.section-title:first-child { margin-top: 0; }
/* Auto-refresh indicator */
.htmx-indicator {
opacity: 0;
transition: opacity 0.3s;
}
.htmx-request .htmx-indicator {
opacity: 1;
}
/* Responsive */
@media (max-width: 768px) {
.sidebar {
display: none;
}
.main {
margin-left: 0;
width: 100vw;
padding: 16px;
}
.card-grid {
grid-template-columns: 1fr;
}
.inline-form {
flex-direction: column;
align-items: stretch;
}
}
</style>
</head>
<body hx-ext="json-enc">
<aside class="sidebar">
<div class="sidebar-header">
VACUUM WALL
<span>Firewall Management</span>
</div>
<nav>
<a href="/dashboard" class="{{ 'active' if request.path == '/dashboard' or request.path == '/' else '' }}">Dashboard</a>
<a href="/interfaces" class="{{ 'active' if request.path == '/interfaces' else '' }}">Interfaces</a>
<a href="/zones" class="{{ 'active' if request.path == '/zones' else '' }}">Zones</a>
<a href="/rules" class="{{ 'active' if request.path == '/rules' else '' }}">Rules</a>
<a href="/nat" class="{{ 'active' if request.path == '/nat' else '' }}">NAT</a>
<a href="/dhcp" class="{{ 'active' if request.path == '/dhcp' else '' }}">DHCP & DNS</a>
<a href="/proxy" class="{{ 'active' if request.path == '/proxy' else '' }}">Proxy</a>
<a href="/certs" class="{{ 'active' if request.path == '/certs' else '' }}">Certificates</a>
<a href="/wireguard" class="{{ 'active' if request.path == '/wireguard' else '' }}">WireGuard</a>
<a href="/logs" class="{{ 'active' if request.path == '/logs' else '' }}">Logs</a>
</nav>
</aside>
<main class="main">
{% block content %}{% endblock %}
</main>
<div class="toast-container" id="toast-container"></div>
<script src="/static/htmx.min.js"></script>
<script src="/static/json-enc.js"></script>
<script src="/static/app.js"></script>
</body>
</html>
-94
View File
@@ -1,94 +0,0 @@
{% extends "base.html" %}
{% block title %}Certificates - Vacuum Wall{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>Certificates</h1>
<div class="subtitle">SSL/TLS certificate management</div>
</div>
<button class="btn btn-primary" onclick="resetIssueWizard(); openModal('issue-cert-modal')">+ Issue New Certificate</button>
</div>
<div class="card">
<table>
<thead>
<tr>
<th>Domain</th>
<th>Issuer</th>
<th>Expiry Date</th>
<th>Days Left</th>
<th style="width:120px;">Actions</th>
</tr>
</thead>
<tbody id="cert-rows">
{% for cert in (certs or []) %}
<tr>
<td><strong>{{ cert.get('domain', 'unknown') }}</strong></td>
<td class="text-sm">{{ cert.get('issuer', '-') }}</td>
<td>{{ cert.get('expiry', 'N/A') }}</td>
<td>
{% set days = cert.get('days_remaining') %}
{% if cert.get('expired') or (days is not none and days <= 0) %}
<span class="badge badge-danger">Expired{% if days %} ({{ days }}d ago){% endif %}</span>
{% elif days is not none and days <= 30 %}
<span class="badge badge-warning">{{ days }} days</span>
{% else %}
<span class="badge badge-success">{{ days }} days</span>
{% endif %}
</td>
<td>
<form hx-post="/api/certs/{{ cert.get('domain', '') }}/renew" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/certs/list', document.getElementById('cert-rows'), renderCerts); showSuccessToast('Renewal started for {{ cert.domain }}'); }">
<button type="submit" class="btn btn-sm btn-outline">Renew</button>
</form>
</td>
</tr>
{% endfor %}
{% if not (certs or []) %}
<tr>
<td colspan="5" class="text-muted text-sm">No certificates found. Issue a certificate to get started.</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<!-- Issue Certificate Modal — Phase 1: Validate -->
<div class="modal-overlay" id="issue-cert-modal" onclick="if(event.target===this) closeIssueWizard()">
<div class="modal" style="min-width:480px;">
<h2>Issue New Certificate</h2>
<!-- Phase 1: Input + Pre-flight Checks -->
<div id="cert-wizard-input">
<div class="form-group">
<label for="cert-domain">Domain</label>
<input type="text" id="cert-domain" placeholder="example.com" required>
</div>
<div class="form-group">
<label for="cert-email">Contact Email</label>
<input type="email" id="cert-email" placeholder="admin@example.com" value="{{ (email or '') | e }}">
</div>
<!-- Pre-flight validation results (shown after Check) -->
<div id="cert-check-results" style="display:none;">
<div class="section-title" style="margin-top:16px;">Pre-flight Checks</div>
<div id="cert-checks-list"></div>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-outline" onclick="closeIssueWizard()">Cancel</button>
<button type="button" id="cert-check-btn" class="btn btn-primary" onclick="validateCertIssue()">Check</button>
<button type="button" id="cert-issue-btn" class="btn btn-primary" style="display:none;" onclick="startCertIssue()">Issue</button>
</div>
</div>
<!-- Phase 2: Step progress -->
<div id="cert-wizard-progress" style="display:none;">
<div id="cert-steps-list"></div>
<div class="modal-actions">
<button type="button" class="btn btn-outline" id="cert-close-progress" style="display:none;" onclick="closeIssueWizard(); refreshTable('/api/certs/list', document.getElementById('cert-rows'), renderCerts);">Done</button>
</div>
</div>
</div>
</div>
{% endblock %}
-124
View File
@@ -1,124 +0,0 @@
{% extends "base.html" %}
{% block title %}Dashboard - Vacuum Wall{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>Dashboard</h1>
<div class="subtitle">System overview and status</div>
</div>
</div>
<div class="card-grid">
<div class="stat-card">
<div class="label">Zones</div>
<div class="value">{{ active_zones|default({})|length }}</div>
<div class="meta">Firewalld zones configured</div>
</div>
<div class="stat-card">
<div class="label">Proxy Domains</div>
<div class="value">{{ domains|default([])|length }}</div>
<div class="meta">SSL-terminated backends</div>
</div>
<div class="stat-card">
<div class="label">Certificates</div>
<div class="value">{{ certs|default([])|length }}</div>
{% set expired = certs|selectattr('days_until_expiry','lt',0)|list|default([])|length %}
{% set expiring = certs|rejectattr('days_until_expiry','lt',0)|selectattr('days_until_expiry','le',30)|list|default([])|length %}
<div class="meta">
{% if expired > 0 %}<span style="color:var(--danger)">{{ expired }} expired</span>. {% endif %}
{% if expiring > 0 %}<span style="color:var(--warning)">{{ expiring }} expiring soon</span>.{% endif %}
{% if expired == 0 and expiring == 0 %}All valid{% endif %}
</div>
</div>
<div class="stat-card">
<div class="label">WireGuard</div>
<div class="value" style="font-size:20px;">
<span class="status-dot {{ 'status-up' if (wg_status is defined and wg_status.get('up')) else 'status-down' }}"></span>
{{ 'UP' if (wg_status is defined and wg_status.get('up')) else 'DOWN' }}
</div>
<div class="meta">Tunnel state</div>
</div>
<div class="stat-card">
<div class="label">Active Leases</div>
<div class="value">{{ dnsmasq.get('leases', [])|length }}</div>
<div class="meta">DHCP clients connected</div>
</div>
</div>
<div class="section-title">Services</div>
<div class="card-grid">
{% for svc_name, svc in (services or {}).items() %}
<div class="stat-card">
<div class="label">{{ svc_name }}</div>
<div class="value" style="font-size:16px;">
<span class="status-dot {{ 'status-up' if svc.get('running') else 'status-down' }}"></span>
{{ 'Running' if svc.get('running') else 'Stopped' }}
</div>
<div class="meta">
{% if svc.get('pid') %}PID {{ svc.pid }}{% endif %}
{% if svc.get('since') %} · {{ svc.since }}{% endif %}
</div>
</div>
{% endfor %}
{% if not (services or {}) %}
<div class="stat-card">
<div class="label">Firewalld</div>
<div class="value" style="font-size:16px;">
<span class="status-dot status-up"></span>Running
</div>
</div>
<div class="stat-card">
<div class="label">Dnsmasq</div>
<div class="value" style="font-size:16px;">
<span class="status-dot status-up"></span>Running
</div>
</div>
<div class="stat-card">
<div class="label">Nginx</div>
<div class="value" style="font-size:16px;">
<span class="status-dot status-up"></span>Running
</div>
</div>
<div class="stat-card">
<div class="label">wg0</div>
<div class="value" style="font-size:16px;">
<span class="status-dot {{ 'status-up' if (wg_status is defined and wg_status.get('up')) else 'status-down' }}"></span>
{{ 'Up' if (wg_status is defined and wg_status.get('up')) else 'Down' }}
</div>
</div>
{% endif %}
</div>
{% set warnings = [] %}
{% if certs is defined %}
{% for cert in certs %}
{% if cert.get('days_until_expiry') is not none and cert.days_until_expiry < 0 %}
{% set _ = warnings.append("Certificate for " + cert.get('domain', 'unknown') + " has expired") %}
{% elif cert.get('days_until_expiry') is not none and cert.days_until_expiry <= 30 %}
{% set _ = warnings.append("Certificate for " + cert.get('domain', 'unknown') + " expires in " + cert.days_until_expiry|string + " days") %}
{% endif %}
{% endfor %}
{% endif %}
{% if warnings|length > 0 or (services is defined) %}
<div class="section-title">Warnings &amp; Activity</div>
<div class="card">
{% if warnings|length > 0 %}
<ul class="service-list">
{% for w in warnings %}
<li>
<span class="status-dot status-pending"></span>
<span class="svc-name">{{ w }}</span>
</li>
{% endfor %}
</ul>
{% endif %}
{% if not warnings and not (services or {}) %}
<div class="text-muted text-sm">No warnings</div>
{% endif %}
</div>
{% endif %}
{% endblock %}
-214
View File
@@ -1,214 +0,0 @@
{% extends "base.html" %}
{% block title %}DHCP & DNS - Vacuum Wall{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>DHCP &amp; DNS</h1>
<div class="subtitle">Dnsmasq configuration and lease management</div>
</div>
</div>
<!-- DHCP Ranges -->
<div class="section-title">DHCP Ranges</div>
<div class="card mb-4">
<form hx-post="/api/dhcp/ranges" hx-encoding="json" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/dhcp/config', document.getElementById('range-rows'), renderRanges); showSuccessToast('DHCP range added'); }">
<div class="inline-form">
<div class="form-group">
<label for="range-interface">Interface</label>
<select id="range-interface" name="interface">
<option value="">— Global —</option>
{% for iface in (interfaces or []) %}
<option value="{{ iface.get('name', '') }}">{{ iface.get('name', '') }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="range-start">Start IP</label>
<input type="text" id="range-start" name="start" placeholder="192.168.1.100" required style="width:160px;">
</div>
<div class="form-group">
<label for="range-end">End IP</label>
<input type="text" id="range-end" name="end" placeholder="192.168.1.200" required style="width:160px;">
</div>
<div class="form-group">
<label for="range-lease">Lease Time</label>
<input type="text" id="range-lease" name="lease_time" placeholder="1h" value="{{ (config or {}).get('dhcp_lease_time', '1h') }}" style="width:80px;">
</div>
<button type="submit" class="btn btn-primary">Add Range</button>
</div>
</form>
<div class="mt-4">
<table>
<thead>
<tr>
<th>Interface</th>
<th>Start</th>
<th>End</th>
<th>Lease Time</th>
<th style="width:80px;">Action</th>
</tr>
</thead>
<tbody id="range-rows">
{% for rng in ((config or {}).get('dhcp_ranges', []) or []) %}
<tr>
<td>{{ rng.get('interface', '(global)') }}</td>
<td>{{ rng.get('start', '') }}</td>
<td>{{ rng.get('end', '') }}</td>
<td>{{ rng.get('lease_time', '1h') }}</td>
<td>
<form hx-delete="/api/dhcp/ranges" hx-encoding="json" hx-vals='{"interface": "{{ rng.get("interface", "") }}", "start": "{{ rng.get("start", "") }}", "end": "{{ rng.get("end", "") }}" }' hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/dhcp/config', document.getElementById('range-rows'), renderRanges); showSuccessToast('Range removed'); }">
<button type="submit" class="btn btn-sm btn-danger" hx-confirm="Remove DHCP range {{ rng.get('start', '') }} - {{ rng.get('end', '') }}?">Remove</button>
</form>
</td>
</tr>
{% endfor %}
{% if not ((config or {}).get('dhcp_ranges', []) or []) %}
<tr>
<td colspan="5" class="text-muted text-sm">No DHCP ranges configured</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
<!-- Static Leases -->
<div class="section-title">Static Leases</div>
<div class="card mb-4">
<form hx-post="/api/dhcp/static-lease" hx-encoding="json" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/dhcp/config', document.getElementById('lease-rows'), renderStaticLeases); showSuccessToast('Static lease added'); }">
<div class="inline-form">
<div class="form-group">
<label for="lease-mac">MAC Address</label>
<input type="text" id="lease-mac" name="mac" placeholder="aa:bb:cc:dd:ee:ff" required style="width:180px;">
</div>
<div class="form-group">
<label for="lease-ip">IP Address</label>
<input type="text" id="lease-ip" name="ip" placeholder="192.168.1.50" required style="width:160px;">
</div>
<div class="form-group">
<label for="lease-host">Hostname</label>
<input type="text" id="lease-host" name="hostname" placeholder="myhost" style="width:140px;">
</div>
<button type="submit" class="btn btn-primary">Add Lease</button>
</div>
</form>
<div class="mt-4">
<table>
<thead>
<tr>
<th>MAC</th>
<th>IP</th>
<th>Hostname</th>
<th style="width:80px;">Action</th>
</tr>
</thead>
<tbody id="lease-rows">
{% for lease in ((config or {}).get('static_leases', []) or []) %}
<tr>
<td>{{ lease.get('mac', '') }}</td>
<td>{{ lease.get('ip', '') }}</td>
<td>{{ lease.get('hostname', '-') }}</td>
<td>
<form hx-delete="/api/dhcp/static-lease/{{ lease.get('mac', '') }}" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/dhcp/config', document.getElementById('lease-rows'), renderStaticLeases); showSuccessToast('Lease removed'); }">
<button type="submit" class="btn btn-sm btn-danger" hx-confirm="Remove lease {{ lease.get('mac', '') }}?">Remove</button>
</form>
</td>
</tr>
{% endfor %}
{% if not ((config or {}).get('static_leases', []) or []) %}
<tr>
<td colspan="4" class="text-muted text-sm">No static leases configured</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
<!-- Custom DNS Records -->
<div class="section-title">Custom DNS Records</div>
<div class="card mb-4">
<form hx-post="/api/dhcp/dns-record" hx-encoding="json" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/dhcp/config', document.getElementById('dns-rows'), renderDnsRecords); showSuccessToast('DNS record added'); }">
<div class="inline-form">
<div class="form-group">
<label for="dns-ip">IP Address</label>
<input type="text" id="dns-ip" name="address" placeholder="192.168.1.10" required style="width:160px;">
</div>
<div class="form-group">
<label for="dns-hostname">Hostname / Domain</label>
<input type="text" id="dns-hostname" name="name" placeholder="host.local" required style="width:200px;">
</div>
<button type="submit" class="btn btn-primary">Add Record</button>
</div>
</form>
<div class="mt-4">
<table>
<thead>
<tr>
<th>Name</th>
<th>Address</th>
<th style="width:80px;">Action</th>
</tr>
</thead>
<tbody id="dns-rows">
{% for rec in ((config or {}).get('dns_records', []) or []) %}
<tr>
<td><strong>{{ rec.get('name', 'unnamed') }}</strong></td>
<td class="text-sm">{{ rec.get('address', '-') }}</td>
<td>
<form hx-delete="/api/dhcp/dns-record/{{ rec.get('name', '') }}" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/dhcp/config', document.getElementById('dns-rows'), renderDnsRecords); showSuccessToast('Record removed'); }">
<button type="submit" class="btn btn-sm btn-danger" hx-confirm="Remove DNS record {{ rec.get('name', '') }}?">Remove</button>
</form>
</td>
</tr>
{% endfor %}
{% if not ((config or {}).get('dns_records', []) or []) %}
<tr>
<td colspan="3" class="text-muted text-sm">No custom DNS records</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
<!-- Current DHCP Leases -->
<div class="section-title">Current DHCP Leases</div>
<div class="card">
<table>
<thead>
<tr>
<th>Expires</th>
<th>MAC</th>
<th>IP</th>
<th>Hostname</th>
<th>Client ID</th>
</tr>
</thead>
<tbody>
{% for lease in (leases or []) %}
<tr>
<td>{{ lease.get('expires', 'N/A') }}</td>
<td>{{ lease.get('mac', 'N/A') }}</td>
<td>{{ lease.get('ip', 'N/A') }}</td>
<td>{{ lease.get('hostname', '*') or '*' }}</td>
<td class="text-muted text-sm">{{ lease.get('client_id', 'N/A') }}</td>
</tr>
{% endfor %}
{% if not (leases or []) %}
<tr>
<td colspan="5" class="text-muted text-sm">No active DHCP leases</td>
</tr>
{% endif %}
</tbody>
</table>
<div class="mt-2 text-right">
<button class="btn btn-sm btn-outline" hx-post="/api/dhcp/apply" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ showSuccessToast('Dnsmasq configuration reloaded'); }">Apply &amp; Restart Dnsmasq</button>
</div>
</div>
{% endblock %}
-61
View File
@@ -1,61 +0,0 @@
{% extends "base.html" %}
{% block title %}Interfaces - Vacuum Wall{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>Interfaces</h1>
<div class="subtitle">Network interface to zone bindings</div>
</div>
</div>
<div class="card">
<table>
<thead>
<tr>
<th>Interface</th>
<th>MAC Address</th>
<th>IP Address</th>
<th>State</th>
<th>Zone</th>
</tr>
</thead>
<tbody id="interface-list">
{% for iface in (interfaces or []) %}
<tr>
<td><strong>{{ iface.get('display_name', iface.get('name', 'unknown')) }}</strong></td>
<td class="text-muted">{{ iface.get('mac', 'N/A') }}</td>
<td>
{% for ip in iface.get('ips', []) %}
{{ ip }}{% if not loop.last %}, {% endif %}
{% endfor %}
{% if not iface.get('ips') %}N/A{% endif %}
</td>
<td>
<span class="status-dot {{ 'status-up' if iface.get('state') == 'UP' else 'status-down' }}"></span>
{{ 'Up' if iface.get('state') == 'UP' else 'Down' }}
</td>
<td>
{% if zones %}
<select
hx-on::change="fetch('/api/firewall/zones/'+encodeURIComponent(this.value)+'/interfaces',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({interfaces:['{{ iface.name }}']})}).then(r=>{if(!r.ok)throw r}).then(r=>r.ok?(showSuccessToast('{{ iface.display_name }} assigned to '+this.value),refreshTable('/api/firewall/interfaces',document.getElementById('interface-list'),renderInterfaces)):r.json().then(j=>{throw new Error(j.error||r.statusText)})).catch(e=>{showErrorToast(e.message);this.selectedIndex=0})"
>
{% for zname in zones %}
<option value="{{ zname }}" {% if zname == iface.get('zone') %}selected{% endif %}>{{ zname }}</option>
{% endfor %}
</select>
{% else %}
<span class="text-muted">No zones configured</span>
{% endif %}
</td>
</tr>
{% endfor %}
{% if not (interfaces or []) %}
<tr>
<td colspan="6" class="text-muted text-sm">No interfaces found</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
{% endblock %}
-151
View File
@@ -1,151 +0,0 @@
{% extends "base.html" %}
{% block title %}Logs - Vacuum Wall{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>System Logs</h1>
<div class="subtitle">Service logs and journal output</div>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-muted">Auto-refresh</span>
<label class="switch">
<input type="checkbox" id="auto-refresh-toggle" onchange="toggleAutoRefresh()">
<span class="slider"></span>
</label>
<span class="htmx-indicator text-sm" style="color:var(--accent);">Refreshing...</span>
</div>
</div>
<div class="tabs">
<button class="tab active" data-tab="journal" onclick="switchTab('journal')">Journal</button>
<button class="tab" data-tab="nginx-access" onclick="switchTab('nginx-access')">Nginx Access</button>
<button class="tab" data-tab="nginx-error" onclick="switchTab('nginx-error')">Nginx Error</button>
<button class="tab" data-tab="dnsmasq" onclick="switchTab('dnsmasq')">Dnsmasq</button>
<button class="tab" data-tab="app" onclick="switchTab('app')">App</button>
</div>
<div id="tab-journal" class="tab-content active">
<div class="card" style="padding:0;overflow:hidden;">
<div class="log-viewer" id="log-journal"
hx-get="/api/logs/journal"
hx-trigger="none"
hx-swap="innerHTML"
hx-indicator=".page-header .htmx-indicator">
Loading journal entries...
</div>
</div>
</div>
<div id="tab-nginx-access" class="tab-content">
<div class="card" style="padding:0;overflow:hidden;">
<div class="log-viewer" id="log-nginx-access"
hx-get="/api/logs/nginx/access"
hx-trigger="none"
hx-swap="innerHTML"
hx-indicator=".page-header .htmx-indicator">
Loading Nginx access log...
</div>
</div>
</div>
<div id="tab-nginx-error" class="tab-content">
<div class="card" style="padding:0;overflow:hidden;">
<div class="log-viewer" id="log-nginx-error"
hx-get="/api/logs/nginx/error"
hx-trigger="none"
hx-swap="innerHTML"
hx-indicator=".page-header .htmx-indicator">
Loading Nginx error log...
</div>
</div>
</div>
<div id="tab-dnsmasq" class="tab-content">
<div class="card" style="padding:0;overflow:hidden;">
<div class="log-viewer" id="log-dnsmasq"
hx-get="/api/logs/dnsmasq"
hx-trigger="none"
hx-swap="innerHTML"
hx-indicator=".page-header .htmx-indicator">
Loading dnsmasq log...
</div>
</div>
</div>
<div id="tab-app" class="tab-content">
<div class="card" style="padding:0;overflow:hidden;">
<div class="log-viewer" id="log-app"
hx-get="/api/logs/app"
hx-trigger="none"
hx-swap="innerHTML"
hx-indicator=".page-header .htmx-indicator">
Loading app log...
</div>
</div>
</div>
<script>
var refreshInterval = {{ (refresh_interval | default(15)) }};
var currentTab = 'journal';
function loadTabEl(el) {
var url = el.getAttribute('hx-get');
if (!url) return;
el.textContent = 'Loading...';
fetch(url).then(function(r) { return r.text(); })
.then(function(html) { el.innerHTML = html; })
.catch(function() { el.innerHTML = '<div class="log-line">(failed to load log)</div>'; });
}
function setActivePolling() {
document.querySelectorAll('.log-viewer').forEach(function(el) {
if (typeof htmx !== 'undefined') htmx.abort(el);
el.setAttribute('hx-trigger', 'none');
});
var activeEl = document.getElementById('log-' + currentTab);
if (activeEl) {
activeEl.setAttribute('hx-trigger', 'every ' + refreshInterval + 's');
}
if (typeof htmx !== 'undefined') htmx.process(document.body);
}
function loadActiveTab() {
var activeEl = document.getElementById('log-' + currentTab);
if (activeEl) {
loadTabEl(activeEl);
}
}
function toggleAutoRefresh() {
var toggle = document.getElementById('auto-refresh-toggle');
if (toggle.checked) {
setActivePolling();
loadActiveTab();
} else {
document.querySelectorAll('.log-viewer').forEach(function(el) {
if (typeof htmx !== 'undefined') htmx.abort(el);
el.setAttribute('hx-trigger', 'none');
});
}
}
document.addEventListener('DOMContentLoaded', function() {
loadActiveTab();
});
window.addEventListener('load', function() {
var origSwitchTab = typeof switchTab === 'function' ? switchTab : null;
switchTab = function(tabName) {
currentTab = tabName;
if (origSwitchTab) {
origSwitchTab(tabName);
}
if (document.getElementById('auto-refresh-toggle').checked) {
setActivePolling();
}
loadActiveTab();
};
});
</script>
{% endblock %}
-131
View File
@@ -1,131 +0,0 @@
{% extends "base.html" %}
{% block title %}NAT - Vacuum Wall{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>NAT &amp; Port Forwarding</h1>
<div class="subtitle">Masquerading and destination NAT rules</div>
</div>
</div>
<div class="section-title">Masquerade (Source NAT)</div>
<div class="card">
<table>
<thead>
<tr>
<th>Zone</th>
<th style="width:120px;">Masquerade</th>
</tr>
</thead>
<tbody>
{% for zone in (zones or []) %}
<tr>
<td><strong>{{ zone.get('name', 'unnamed') }}</strong></td>
<td>
<form hx-post="/api/firewall/masquerade" hx-encoding="json" hx-vals='{"zone": "{{ zone.get('name', '') }}", "enable": JSON.stringify(this.checked)}' hx-swap="none" hx-on::after-request="if(evt.detail.successful){ showSuccessToast('Masquerade '+(this.checked?'enabled':'disabled')+' for {{ zone.get('name', '') }}') } else { this.checked=!this.checked; }">
<label class="switch">
<input type="checkbox"
{% if zone.get('masquerade') %}checked{% endif %}
id="masq-{{ zone.get('name', '') }}"
hx-trigger="change from:#masq-{{ zone.get('name', '') }}"
disabled>
<span class="slider"></span>
</label>
<button type="submit" style="display:none"></button>
</form>
</td>
</tr>
{% endfor %}
{% if not (zones or []) %}
<tr>
<td colspan="2" class="text-muted text-sm">No zones configured</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<div class="section-title">Port Forwarding (DNAT)</div>
<div class="card mb-4">
<h3>Add Forward Rule</h3>
<form hx-post="/api/firewall/forward-port" hx-encoding="json" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/firewall/config', document.getElementById('forward-rows'), renderForwardsFromConfig); showSuccessToast('Forward rule added'); }">
<div class="inline-form">
<div class="form-group">
<label for="fw-zone">Zone</label>
<select id="fw-zone" name="zone" required>
<option value="">— Select —</option>
{% for zone in (zones or []) %}
<option value="{{ zone.get('name', '') }}">{{ zone.get('name', '') }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="fw-protocol">Protocol</label>
<select id="fw-protocol" name="proto">
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</select>
</div>
<div class="form-group">
<label for="fw-port">Port</label>
<input type="number" id="fw-port" name="port" placeholder="80" min="1" max="65535" required style="width:80px;">
</div>
<div class="form-group">
<label for="fw-target">Target Address</label>
<input type="text" id="fw-target" name="toaddr" placeholder="192.168.1.100" required style="width:160px;">
</div>
<div class="form-group">
<label for="fw-target-port">Target Port</label>
<input type="number" id="fw-target-port" name="toport" placeholder="80" min="1" max="65535" style="width:90px;">
</div>
<button type="submit" class="btn btn-primary">Add</button>
</div>
</form>
</div>
<div class="card">
<table>
<thead>
<tr>
<th>Zone</th>
<th>Proto</th>
<th>Port</th>
<th>Target</th>
<th>Tgt Port</th>
<th style="width:80px;">Action</th>
</tr>
</thead>
<tbody id="forward-rows">
{% set all_forwards = [] %}
{% for zone in (zones or []) %}
{% for fwd in zone.get('forward_ports', []) %}
{% set _ = all_forwards.append({'zone': zone.get('name'), 'proto': fwd.get('proto'), 'port': fwd.get('port'), 'toaddr': fwd.get('toaddr'), 'toport': fwd.get('toport')}) %}
{% endfor %}
{% endfor %}
{% for fwd in all_forwards %}
<tr>
<td><strong>{{ fwd.zone }}</strong></td>
<td><span class="badge badge-info">{{ fwd.proto }}</span></td>
<td>{{ fwd.port }}</td>
<td>{{ fwd.toaddr }}</td>
<td>{{ fwd.toport }}</td>
<td>
<form hx-delete="/api/firewall/forward-port/{{ fwd.zone }}/{{ fwd.port }}/{{ fwd.proto }}" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/firewall/config', document.getElementById('forward-rows'), renderForwardsFromConfig); showSuccessToast('Rule removed'); }">
<button type="submit" class="btn btn-sm btn-danger" hx-confirm="Remove forward rule {{ fwd.port }}/{{ fwd.proto }} → {{ fwd.toaddr }}:{{ fwd.toport }}?">Remove</button>
</form>
</td>
</tr>
{% endfor %}
{% if not all_forwards %}
<tr>
<td colspan="6" class="text-muted text-sm">No port forwarding rules configured</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
{% endblock %}
-151
View File
@@ -1,151 +0,0 @@
{% extends "base.html" %}
{% block title %}Proxy - Vacuum Wall{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>SSL Proxy Domains</h1>
<div class="subtitle">Reverse proxy and SSL termination managed by Nginx</div>
</div>
<div class="flex gap-2">
<button class="btn btn-primary" onclick="openModal('add-domain-modal')">+ Add Domain</button>
<button class="btn btn-outline" hx-post="/api/proxy/ssl-apply" hx-swap="none" hx-on::after-request="if(evt.detail.successful) showSuccessToast('SSL settings applied')">Apply SSL Settings</button>
<button class="btn btn-outline" hx-post="/api/proxy/apply" hx-swap="none" hx-on::after-request="if(evt.detail.successful) showSuccessToast('Nginx reloaded')">Apply Changes (Reload Nginx)</button>
</div>
</div>
<div class="card">
<table>
<thead>
<tr>
<th>Domain</th>
<th>Backend Host</th>
<th>Backend Port</th>
<th>Protocol</th>
<th>Certificate</th>
<th style="width:140px;">Actions</th>
</tr>
</thead>
<tbody id="domain-rows">
{% for domain in (domains or []) %}
<tr>
<td><strong>{{ domain.get('domain', 'unknown') }}</strong></td>
<td>{{ domain.get('backend_host', '-') }}</td>
<td>{{ domain.get('backend_port', '-') }}</td>
<td><span class="badge badge-info">{{ domain.get('protocol', 'http') }}</span></td>
<td>
{% set matched_cert = None %}
{% if certs %}
{% for cert in certs %}
{% if cert.get('domain') == domain.get('domain') %}
{% set matched_cert = cert %}
{% endif %}
{% endfor %}
{% endif %}
{% if matched_cert %}
{% if matched_cert.get('expired') %}
<span class="badge badge-danger">Expired</span>
{% elif matched_cert.get('days_remaining') is not none and matched_cert.days_remaining <= 30 %}
<span class="badge badge-warning">{{ matched_cert.days_remaining }}d</span>
{% else %}
<span class="badge badge-success">Valid</span>
{% endif %}
{% else %}
<span class="badge badge-danger">No cert</span>
{% endif %}
</td>
<td>
<div class="flex gap-2">
<button class="btn btn-sm btn-outline" onclick='openEditDomainModal('{{ domain.get("domain", "") }}', {{ domain | tojson | safe }})'>Edit</button>
<form hx-delete="/api/proxy/domains/{{ domain.get('domain', '') }}" hx-swap="none" hx-confirm="Remove proxy for {{ domain.domain }}?" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/proxy/domains', document.getElementById('domain-rows'), renderDomains); showSuccessToast('Domain removed'); }">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
</div>
</td>
</tr>
{% endfor %}
{% if not (domains or []) %}
<tr>
<td colspan="6" class="text-muted text-sm">No proxy domains configured. Add a domain to start terminating SSL.</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<!-- Add Domain Modal -->
<div class="modal-overlay" id="add-domain-modal" onclick="if(event.target===this) closeModal('add-domain-modal')">
<div class="modal">
<h2>Add Proxy Domain</h2>
<form hx-post="/api/proxy/domains" hx-encoding="json" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ closeModal('add-domain-modal'); refreshTable('/api/proxy/domains', document.getElementById('domain-rows'), renderDomains); showSuccessToast('Domain added'); }">
<div class="form-group">
<label for="new-domain">Domain</label>
<input type="text" id="new-domain" name="domain" placeholder="example.com" required>
</div>
<div class="form-group">
<label for="new-backend-host">Backend Host</label>
<input type="text" id="new-backend-host" name="backend_host" placeholder="127.0.0.1" required>
</div>
<div class="form-group">
<label for="new-backend-port">Backend Port</label>
<input type="number" id="new-backend-port" name="backend_port" placeholder="8080" min="1" max="65535" required>
</div>
<div class="form-group">
<label for="new-protocol">Backend Protocol</label>
<select id="new-protocol" name="protocol">
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
</select>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-outline" onclick="closeModal('add-domain-modal')">Cancel</button>
<button type="submit" class="btn btn-primary">Add Domain</button>
</div>
</form>
</div>
</div>
<!-- Edit Domain Modal -->
<div class="modal-overlay" id="edit-domain-modal" onclick="if(event.target===this) closeModal('edit-domain-modal')">
<div class="modal">
<h2>Edit Proxy Domain</h2>
<form id="edit-domain-form" hx-post="/api/proxy/domains" hx-swap="none" hx-encoding="json" hx-on::after-request="if(evt.detail.successful){ closeModal('edit-domain-modal'); refreshTable('/api/proxy/domains', document.getElementById('domain-rows'), renderDomains); showSuccessToast('Domain updated'); }">
<input type="hidden" id="edit-original-domain" name="original_domain">
<div class="form-group">
<label for="edit-domain">Domain</label>
<input type="text" id="edit-domain" name="domain" required>
</div>
<div class="form-group">
<label for="edit-backend-host">Backend Host</label>
<input type="text" id="edit-backend-host" name="backend_host" required>
</div>
<div class="form-group">
<label for="edit-backend-port">Backend Port</label>
<input type="number" id="edit-backend-port" name="backend_port" min="1" max="65535" required>
</div>
<div class="form-group">
<label for="edit-protocol">Backend Protocol</label>
<select id="edit-protocol" name="protocol">
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
</select>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-outline" onclick="closeModal('edit-domain-modal')">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
</div>
<script>
function openEditDomainModal(domainName, d) {
document.getElementById('edit-original-domain').value = d.domain;
document.getElementById('edit-domain').value = d.domain;
document.getElementById('edit-backend-host').value = d.backend_host || '';
document.getElementById('edit-backend-port').value = d.backend_port || '';
document.getElementById('edit-protocol').value = d.protocol || 'http';
openModal('edit-domain-modal');
}
</script>
{% endblock %}
-83
View File
@@ -1,83 +0,0 @@
{% extends "base.html" %}
{% block title %}Rules - Vacuum Wall{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>Rich Rules</h1>
<div class="subtitle">Firewalld rich firewall rules per zone</div>
</div>
</div>
<div class="card mb-4">
<h3>Add Rule</h3>
<form hx-post="/api/firewall/rich-rules" hx-encoding="json" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/firewall/config', document.getElementById('rules-container'), renderRules); showSuccessToast('Rule added'); }">
<div class="inline-form">
<div class="form-group">
<label for="rule-zone">Zone</label>
<select id="rule-zone" name="zone" required>
<option value="">— Select zone —</option>
{% for zone in (zones or []) %}
<option value="{{ zone }}">{{ zone }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="rule-text">Rule Expression</label>
<input type="text" id="rule-text" name="rule" placeholder="e.g., rule family=ipv4 source address=192.168.1.0/24 accept" required style="min-width:420px;">
</div>
<button type="submit" class="btn btn-primary">Add Rule</button>
</div>
</form>
<div class="text-muted text-sm mt-2">
Reference: <a href="https://firewalld.org/documentation/man-pages/firewalld.richlanguage.html" target="_blank" style="color:var(--accent);">firewalld rich language syntax</a>
</div>
</div>
<div id="rules-container">
{% if rules or False %}
{% for zone_name, zone_rules in rules.items() %}
<div class="card">
<h3>Zone: <span style="color:var(--accent);">{{ zone_name or '(default)' }}</span></h3>
{% if zone_rules %}
<table>
<thead>
<tr>
<th>#</th>
<th>Rule</th>
<th style="width:80px;">Action</th>
</tr>
</thead>
<tbody>
{% for rule in zone_rules %}
{% set rule_obj = rule if rule is mapping else {'id': None, 'rule': rule} %}
<tr>
<td class="text-muted">{{ loop.index }}</td>
<td hx-disable style="font-family:monospace;font-size:12px;word-break:break-all;">{{ rule_obj.rule }}</td>
<td>
<form hx-delete="/api/firewall/rich-rules/{{ zone_name | urlencode }}/{{ rule_obj.id }}" hx-swap="none" hx-confirm="Remove rule {{ rule_obj.rule[:50] }}?" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/firewall/config', document.getElementById('rules-container'), renderRules); showSuccessToast('Rule removed'); }">
<button type="submit" class="btn btn-sm btn-danger">Remove</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="text-muted text-sm">No rich rules configured for this zone.</div>
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="card">
<div class="text-muted text-sm">No rules loaded. Add rules using the form above, or ensure the zones API is providing rule data.</div>
</div>
{% endif %}
</div>
{% if not (zones or []) %}
<div class="card" style="border-color:var(--warning);">
<div class="text-muted text-sm" style="color:var(--warning);">No zones configured. Create a zone first before adding rich rules.</div>
</div>
{% endif %}
{% endblock %}
-123
View File
@@ -1,123 +0,0 @@
{% extends "base.html" %}
{% block title %}WireGuard - Vacuum Wall{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>WireGuard</h1>
<div class="subtitle">VPN tunnel management</div>
</div>
<div class="flex gap-2">
<button class="btn {{ 'btn-outline' if (wg_status is defined and wg_status.get('state') == 'up') else 'btn-primary' }}"
hx-post="/api/wireguard/down"
hx-swap="none"
hx-on::after-request="if(evt.detail.successful){ showSuccessToast('Tunnel stopped'); }">
Stop Tunnel
</button>
<button class="btn {{ 'btn-outline' if (wg_status is not defined or wg_status.get('state') != 'up') else 'btn-primary' }}"
hx-post="/api/wireguard/apply"
hx-swap="none"
hx-on::after-request="if(evt.detail.successful){ showSuccessToast('Tunnel started'); }">
Start Tunnel
</button>
</div>
</div>
<!-- Tunnel Status -->
<div class="card mb-4">
<div class="flex items-center justify-between">
<div>
<h3>
<span class="status-dot {{ 'status-up' if (wg_status is defined and wg_status.get('state') == 'up') else 'status-down' }}"></span>
Tunnel State: <strong>{{ 'UP' if (wg_status is defined and wg_status.get('state') == 'up') else 'DOWN' }}</strong>
</h3>
</div>
<div class="text-sm text-muted">
{% if config %}
Listen Port: <strong>{{ config.get('listen_port', 'N/A') }}</strong> &nbsp;|&nbsp;
Public Key: <strong>{{ config.get('public_key', 'N/A')[:12] if config.get('public_key') else 'N/A' }}...</strong> &nbsp;|&nbsp;
Address: <strong>{{ config.get('address', 'N/A') }}</strong>
{% endif %}
</div>
</div>
</div>
<!-- Add Peer Form -->
<div class="section-title">Peers</div>
<div class="card mb-4">
<h3>Add Peer</h3>
<form hx-post="/api/wireguard/peers" hx-encoding="json" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/wireguard/peers', document.getElementById('peer-rows'), renderPeers); showSuccessToast('Peer added'); }">
<div class="inline-form">
<div class="form-group">
<label for="peer-name">Name</label>
<input type="text" id="peer-name" name="name" placeholder="client-1" required style="width:140px;">
</div>
<div class="form-group">
<label for="peer-pubkey">Public Key</label>
<input type="text" id="peer-pubkey" name="public_key" placeholder="Base64 public key (48 chars)" required style="width:260px;">
</div>
<div class="form-group">
<label for="peer-allowed">Allowed IPs</label>
<input type="text" id="peer-allowed" name="allowed_ips" placeholder="10.8.0.2/32" value="10.8.0.{% set next = (peers or []|length + 2) %}{{ next }}/32" required style="width:160px;">
</div>
<button type="submit" class="btn btn-primary">Add Peer</button>
</div>
</form>
</div>
<!-- Peers Table -->
<div class="card">
<table>
<thead>
<tr>
<th>Name</th>
<th>Public Key</th>
<th>Allowed IPs</th>
<th>Endpoint</th>
<th>Latest Handshake</th>
<th>Transfer</th>
<th style="width:160px;">Actions</th>
</tr>
</thead>
<tbody id="peer-rows">
{% for peer in (peers or []) %}
<tr>
<td>
<span class="status-dot {{ 'status-up' if peer.get('latest_handshake') else 'status-down' }}"></span>
<strong>{{ peer.get('name', 'unnamed') }}</strong>
</td>
<td style="font-family:monospace;font-size:11px;">{{ peer.get('public_key', 'N/A')[:20] }}...</td>
<td class="text-sm">{{ peer.get('allowed_ips', '-') }}</td>
<td class="text-sm">{{ peer.get('endpoint', '-') }}</td>
<td class="text-sm">{{ peer.get('latest_handshake', 'Never') or 'Never' }}</td>
<td class="text-sm">
<div>Recv: {{ peer.get('transfer_recv', '0') or '0' }}</div>
<div>Sent: {{ peer.get('transfer_sent', '0') or '0' }}</div>
</td>
<td>
<div class="flex gap-2">
<button class="btn btn-sm btn-outline" onclick="downloadPeerConfig('{{ peer.get('name', '') }}')">Config</button>
<form hx-delete="/api/wireguard/peers/{{ peer.get('name', '') }}" hx-swap="none" hx-confirm="Remove peer {{ peer.get('name', '') }}?" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/wireguard/peers', document.getElementById('peer-rows'), renderPeers); showSuccessToast('Peer removed'); }">
<button type="submit" class="btn btn-sm btn-danger">Remove</button>
</form>
</div>
</td>
</tr>
{% endfor %}
{% if not (peers or []) %}
<tr>
<td colspan="7" class="text-muted text-sm">No peers configured. Add a peer above.</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<script>
function downloadPeerConfig(peerName) {
var url = '/api/wireguard/peers/' + encodeURIComponent(peerName) + '/config';
window.open(url, '_blank');
}
</script>
{% endblock %}
-90
View File
@@ -1,90 +0,0 @@
{% extends "base.html" %}
{% block title %}Zones - Vacuum Wall{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>Zones</h1>
<div class="subtitle">Firewalld zone management</div>
</div>
<button class="btn btn-primary" onclick="openModal('create-zone-modal')">+ Create Zone</button>
</div>
<div id="zone-grid" class="card-grid">
{% for zone in (zones or []) %}
<div class="card" style="position:relative;">
<div style="display:flex;justify-content:space-between;align-items:flex-start;">
<div>
<h3 style="font-size:16px;color:var(--accent);">{{ zone.get('name', 'unnamed') }}</h3>
<div class="text-muted text-sm" style="margin-bottom:10px;">
{% if zone.get('target') %}Target: {{ zone.target }}{% endif %}
</div>
</div>
</div>
<div class="text-sm mb-4">
<div class="text-muted" style="margin-bottom:4px;">Interfaces</div>
{% if zone.get('interfaces') %}
{% for iface in zone.interfaces %}
<span class="badge badge-info">{{ iface }}</span>
{% endfor %}
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
<div class="text-sm mb-4">
<div class="text-muted" style="margin-bottom:4px;">Services</div>
{% if zone.get('services') %}
{% for svc in zone.services %}
<span class="badge badge-success">{{ svc }}</span>
{% endfor %}
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
<div style="display:flex;justify-content:flex-end;gap:6px;margin-top:12px;">
<form hx-delete="/api/firewall/zones/{{ zone.get('name', '') }}" hx-swap="none" hx-confirm="Delete zone {{ zone.name }}? This will affect traffic to its interfaces." hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/firewall/zones', document.getElementById('zone-grid'), renderZones); showSuccessToast('Zone deleted'); }">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
</div>
</div>
{% endfor %}
{% if not (zones or []) %}
<div class="card">
<div class="text-muted text-sm">No zones configured. Create a zone to get started.</div>
</div>
{% endif %}
</div>
<!-- Create Zone Modal -->
<div class="modal-overlay" id="create-zone-modal" onclick="if(event.target===this) closeModal('create-zone-modal')">
<div class="modal">
<h2>Create Zone</h2>
<form hx-post="/api/firewall/zones" hx-encoding="json" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ closeModal('create-zone-modal'); refreshTable('/api/firewall/zones', document.getElementById('zone-grid'), renderZones); showSuccessToast('Zone created'); }">
<div class="form-group">
<label for="zone-name">Zone Name</label>
<input type="text" id="zone-name" name="name" placeholder="e.g., trusted, dmz, external" required>
</div>
<div class="form-group">
<label for="zone-target">Target</label>
<select id="zone-target" name="target">
<option value="default">default</option>
<option value="%%REJECT%%">%REJECT%</option>
<option value="%%DROP%%">%DROP%</option>
<option value="%%ACCEPT%%">%ACCEPT%</option>
</select>
</div>
<div class="form-group">
<label for="zone-services">Default Services (comma-separated)</label>
<input type="text" id="zone-services" name="services" placeholder="e.g., dhcp, dns, ssh">
</div>
<div class="modal-actions">
<button type="button" class="btn btn-outline" onclick="closeModal('create-zone-modal')">Cancel</button>
<button type="submit" class="btn btn-primary">Create</button>
</div>
</form>
</div>
</div>
{% endblock %}