Commit Graph

20 Commits

Author SHA1 Message Date
gsadmin de49257c8d feat: Separate authorised resolvers from what they may forward towards
Modelled on the operator's production configuration, which was supplied as a
sample. Name resolution was one permit and one blanket deny, which said who may
be queried but had nothing to say about what a resolver is then allowed to do,
and pinned the appliance to whatever name server the machine generating the
document happened to be using.

Two aliases now carry the policy. The internal alias holds the resolvers a client
may query, which is the appliance itself by default, and the upstream alias holds
what an authorised resolver may forward towards. Eight rules follow, four per
transport, because each transport carries a different set of ports.

The deny inverts its destination rather than being a blanket deny placed after
the permits. That states the policy as it is actually meant, which is that name
resolution towards anything unauthorised is refused, and it stays true as the
alias is edited instead of depending upon the deny sitting at a later sequence
than every permit.

The rule permitting an authorised resolver to forward upstream changes nothing
while the internal alias holds only the appliance, because traffic the appliance
originates is not evaluated by an inbound rule. It is not redundant for that
reason. It is what makes the alias worth having: adding a real resolver to the
internal alias gives it both directions at once, reached by clients through one
rule and forwarding upstream through the other, with nothing further to write.

Nothing about name resolution is read from the machine that generates the
document any more. A resolver that machine uses is a fact about that machine
rather than about the network the appliance joins, and seeding it wrote a site
specific address into every document produced. The upstream alias defaults to
public resolvers and is overridable, and the host inspection that remains is only
for the networks it is attached to, which the parent network selection excludes.

The appliance resolves through its own resolver, which is what allows a local
record to be defined and answered authoritatively for the networks behind it, and
takes its upstream from the wide area network lease. Three settings carry that:
no static name server address is written, the lease is permitted to populate the
system list, and the local host is preferred so the appliance asks itself first.

The resolver is also switched to forwarding. Left recursing it would query the
root servers directly and the upstream the joined network expects would never be
consulted, so the appliance would resolve correctly while ignoring its own
network's resolver, which is not what taking the upstream from the lease is meant
to mean. The local zone stays transparent, so a local record is still answered
before anything is forwarded.

Verified that the document carries the eight rules in the intended order with the
deny inverted, that the aliases hold the appliance address on every planned
network and the public upstreams, that an explicit upstream list is honoured, that
no address belonging to this host appears anywhere in the output, and that every
alias named by any rule still resolves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 12:29:42 -04:00
gsadmin 9ada3b7079 feat: Scope the wide area network rule to a parent network alias
The rule that admits a private source over the wide area network interface named
every private range as its destination as well as its source. That is wider than
the path it exists to open, which is reaching the networks behind this appliance,
and it widens further on its own as routes are added, because it permits whatever
the appliance happens to be able to route towards rather than what it is
responsible for.

The destination is now a PARENT_NETWORK_001 alias holding the parent network that
the plan resolved, so the rule is scoped to the addresses this appliance actually
owns and stays that way. The source keeps the existing RFC1918 alias, so both ends
read as what they mean rather than as literal ranges when the rule set is being
reviewed in the interface.

The alias is generated from the plan rather than written as a constant, so it
follows a parent network that was selected at random just as readily as one that
was specified.

The rule stays enabled. That was raised again and reaffirmed, and the reasoning
from when it was first enabled has not changed: the route only decides where the
host sends the packet, so shipping the rule disabled means the documented path
does not work until an operator finds and enables it. The readme continues to name
it as the first thing to disable where the upstream network is not trusted, and
that note now also records that the destination is scoped rather than open.

Verified that the alias resolves to the parent network on both a specified and a
randomly selected plan, that the rule references it, and that every alias named by
any rule in the document exists, which is a check worth having now that a rule
depends on an alias built from the plan rather than from a constant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:37:39 -04:00
gsadmin dc79fb52db feat: Count the virtual local area network tags instead of deriving them
The tag was the third octet of the network address. That reads well while every
network happens to occupy a whole octet, and stops being possible the moment one
does not, because two networks that share an octet are assigned the same tag. It
had already forced a cap on the subnet mask length one commit earlier, since a
/25 produced thirty two networks carrying only sixteen distinct tags.

The tag is now counted. StartingVlanId gives the first tagged network its tag and
defaults to 2, and VlanIdIncrement advances it for each network after that and
defaults to 1, so a sixteen network deployment carries the tags 2 through 16. The
default leaves the tag of 1 unused, because a switch commonly treats it as its
own default network and colliding with that is awkward to diagnose.

Counting removes the constraint at its source rather than fencing it off. The tag
no longer has anything to do with the address, so it stays unique however the
parent network was divided, and the cap on the subnet mask length is lifted back
to a /30. The same /25 and /26 that collided now produce thirty one and sixty
three distinct tags.

The highest tag is computed before the loop runs, so a range that would pass 4094
throws and names the range it would have produced rather than emitting invalid
tags. A run starting at 4080 across fifteen networks is accepted and ends exactly
at 4094.

The increment also makes tag ranges expressible, which a derived tag could never
have offered. An increment of ten gives 2, 12, 22 and so on, which is useful when
a tag is meant to carry meaning of its own.

What is given up is that a tag no longer tells you the address. That was a real
convenience, but it was only ever available while the addressing cooperated, and
paying for it with a cap on how the parent network could be divided was the worse
side of the trade.

The screenshot walkthrough is annotated rather than restaged. Those frames are a
real run captured while the tag was still derived, so the caption now says so and
gives the names the same deployment would produce today.

Verified end to end with the defaults, which produced the tags 2 through 16 with
matching interface descriptions and vlan devices, and with a /22 parent divided
into /26 networks at an increment of ten, which is a combination the previous
scheme could not express at all and which produced fifteen distinct tags from 2
to 142. Both exited 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:06:15 -04:00
gsadmin af715b67d2 feat: Expose the subnet mask length that the parent network is divided by
The size of each individual network was fixed at a /24 by a parameter the plan
function held privately and the main script never surfaced, so the only way to
change how the parent network was divided was to edit the function. It is now
ParentNetworkSubnetMaskLength on both, defaulting to 24, which pairs it with
ParentNetworkMaskLength and makes the relationship between the two legible: one
says how large the whole range is, the other says how large each network inside
it is, and the count is what falls out.

It applies on both paths. It describes how the parent network is divided rather
than how the parent network is chosen, so the mask length that an explicitly
supplied parent network carries has no bearing on it. Only
ParentNetworkMaskLength is bypassed when a parent network is named, and the
distinction is now stated in the help of both parameters rather than left to be
inferred.

Testing it across a range of values surfaced a constraint that had not been
considered. The virtual local area network tag is the third octet of the network
address, so a network has to occupy a whole third octet to own a tag. A network
shorter than a /24 spans several and takes the first, which stays unique, but a
network longer than a /24 occupies only part of one, so several networks share a
third octet and are assigned the same tag. Measured against a /20 parent, a /25
produced thirty two networks carrying only sixteen distinct tags, and a /26
produced sixty four carrying sixteen.

Duplicate tags on one parent interface are not a valid virtual local area
network configuration, so the range is capped at /24 rather than allowed to emit
a document the appliance would refuse. Supporting smaller networks would mean
decoupling the tag from the address, which is the property that makes the
addressing readable, so the cap is the right side of that trade rather than a
limitation to be worked around later.

Verified with mask lengths from /22 through /26 against both a specified and a
randomly selected parent network, confirming that the tags stay distinct at every
accepted value, that both rejected values are refused by the range rather than by
producing a broken plan, and that the existing guard against a subnet mask no
longer than the parent mask still fires. An end to end run with a /20 parent
divided by /22 produced four networks with tags 244, 248 and 252 and exited 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:56:42 -04:00
gsadmin c60da93b58 feat: Name the parent network instead of seeding its selection
The seed was repeatable in the strict sense and unusable in the practical one.
Reaching a wanted network meant brute forcing seeds until one happened to land
on the right index, and the seed did not even mean the same thing twice, because
the index was taken against the candidate list that remains after the host's own
networks are excluded. On the development host, which holds a network inside
10.0.0.0/8, one candidate is removed and every index above it shifts by one, so
the seed that selects 10.130.240.0/20 there selects the adjacent network on a
host with no network in that range.

The parent network is now named directly. Supplying it takes it verbatim, so it
means the same thing on every host and needs no calculation, and leaving it out
selects one at random and reports the exact argument that reproduces the
selection. Copying one line out of the summary is now the whole of making a
build repeatable.

The network count follows from the mask lengths rather than standing beside them
as a parameter that could disagree, so a /20 divided into /24 networks is sixteen
networks and a /21 is eight. NetworkCount is therefore gone, and the count is
changed by choosing a different parent mask length.

The base network is restricted to the three ranges RFC 1918 reserves. It only
bounds a random selection, so a parent network that was named explicitly is not
required to sit inside it, nor to avoid the networks this host is attached to.
Both are reported and then honoured, because substituting a different network for
one that was named deliberately would defeat the only reason to name it. An
unaligned value is aligned to its own mask length and the substitution reported.

Naming the supernet ParentNetwork collided with the existing use of that term for
the first of the divided networks, which is the one bound directly to the adapter
rather than to a tag. That one is now the untagged network, which is what
actually distinguishes it, and the VLAN sense of parent is left alone since the
parent interface of a tagged interface is standard terminology.

One defect found while testing rather than reasoned about: the two paths read the
subnet mask from different sources, and Get-NetworkSummary spells it Mask where
Get-Subnet spells it SubnetMask, so the host route command emitted an empty mask
on the explicit path until it was corrected.

Verified across both paths, four parent mask lengths, all three base networks,
unaligned input, an out of base network, an overlapping network, and the guard
against a network mask that is not longer than the parent mask. Two consecutive
explicit runs produce identical addressing across all 48 interface, VLAN and
scope entries; the documents themselves differ only in the UUIDs, which are
regenerated by design.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:31:15 -04:00
gsadmin 5bbed33cdd feat: Enable the wide area network rule and suppress its reply-to
A route on the host only decides where the packet is sent. It says nothing
about whether the packet is admitted once it arrives, so shipping the rule at
sequence 40 disabled meant the documented path did not work as documented. The
operator followed the route instruction, the traffic reached the interface, and
the firewall dropped it. The rule is now enabled.

Enabling it also widens the appliance beyond the private switch topology it was
added for, since a private source anywhere on the upstream network can now reach
the networks behind the appliance. For a lab behind an operator's own router
that is the intent, and it is what keeps the appliance reachable for management,
but it is a real widening and the readme names it as the first thing to disable
if the upstream network is not trusted.

Looking at whether the rule alone was now sufficient turned up a third
mechanism that was not accounted for. OPNsense adds reply-to automatically to
any rule that names an interface and a direction other than any, which forces
the reply traffic of a matched connection to the gateway of that interface
rather than letting it follow the routing table. This rule names the interface
by design, and the source it serves is by design a host on the same segment as
that interface, so every reply would have been sent out to the upstream router
purely for the router to turn it around and deliver it back onto the segment it
came from. Some routers will not hairpin that at all.

The behaviour is verified against FilterRule::convertReplyTo in opnsense/core
rather than assumed, and the guard there is empty() rather than a comparison,
which matters twice. Writing a zero into the element does not suppress the
behaviour, because a string holding zero satisfies empty() in PHP, so the
element has to carry a one. Floating rules are exempt for the same reason, since
the empty interface that makes a rule floating fails the interface check.

The blast radius was measured rather than reasoned about. The document emits
twenty two interface bound rules with a direction other than any, but the
automatic behaviour also needs the interface to resolve to a gateway, and no
interface in the document defines one. Only the wide area network interface
acquires a gateway at all, dynamically, because it is configured for DHCP. That
makes this rule the only one affected and the only one that sets the element.

The wording around the private network block is reworked throughout to say that
it is disabled rather than cleared, and to say what that accomplishes, which is
that traffic can ingress from the host. The block discards any packet whose
source falls within the private ranges and is applied ahead of the rule set, so
with it enabled every packet the host sent would be discarded on arrival and no
rule could have permitted it, because the packet would never have reached the
rules.

The deployment summary drops the step that asked the operator to enable the rule
and states that the firewall side is already in place, leaving the route as the
only outstanding action.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 17:46:44 -04:00
gsadmin a98967ebc4 fix: Decide the host route by the local area network switch
The route into the lab is needed because the host has no adapter behind the
appliance, and that is decided by the local area network switch rather than the
wide area network one. Gating the offer on the wide area network switch was
gating on the wrong thing. That switch is external in almost every deployment,
because it is what gives the machines behind the appliance a path to the
internet, so the gate passed nearly always and the summary printed a route for
the shipped internal topology where the host already has a path and the route is
redundant. When it did decline, it declined for a reason about reachability
rather than the reason that matters.

The two questions are now separated. Whether the route is needed is answered by
the local area network switch, through the count of management operating system
adapters attached to it, which is zero on a private switch and one on an
internal one. Whether the route can work is answered by the wide area network
switch as before. That yields three outcomes rather than two, and the third is
the one worth reporting: a route that is required and cannot presently be
created, along with the reason and the remedy.

Reusing an existing local area network switch also rejected a private one. The
candidate filter required the type to be internal, so a private switch under the
expected name fell through to the name resolver and an internal switch was built
beside it under a generated name, quietly producing the opposite of the topology
the operator had built by hand. Both types are accepted now, and the requested
type no longer filters the candidates at all, since the type is the whole
difference between the two topologies. A mismatch is reported as a warning and
the existing switch is reused as it is, because changing the type of a switch
would detach every virtual machine already connected to it.

A LANSwitchType parameter makes the transition a flag rather than a switch built
by hand, which also means the deployment marker is applied and the removal path
can clean it up afterwards. It defaults to internal, so the shipped behaviour is
unchanged.

The host adapter rename needed no change, since it already skips a switch for
which no virtual adapter is found.

Verified by creating a throwaway private switch, which reported its type as
private, matched the widened filter, and yielded no management operating system
adapter and no virtual adapter before being removed. All three route outcomes
were exercised, and the wide area network reachability test still passes for the
external switch and declines for both internal ones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 17:19:19 -04:00
gsadmin d8e5bebf57 feat: Offer a routed host path into the lab over the wide area network
The default topology attaches the host to an internal local area network
switch, which gives it an adapter inside the lab. That adapter then takes a
lease from Kea on the parent network, and the lease carries a default gateway,
so the host ends up holding two of them. Which one wins becomes a matter of
interface metrics rather than intent.

The alternative is to drop the host adapter entirely, use a private switch for
the local area network, and reach every network behind the appliance through
one low priority static route towards its wide area network address. Three
pieces make that possible.

A rule at sequence 40 permits a private source to reach a private destination
over the wide area network interface. It is bound to the interface rather than
floating, because a floating rule would apply it everywhere and widen it far
past the single path intended, and it is created disabled, because that
interface faces the upstream network and an enabled rule of this shape is an
unsolicited inbound path into the lab. Enabling it is a decision rather than a
default, and it is the only rule in the document emitted disabled.

The private network block was already cleared on that interface, which turns
out to be a precondition rather than a coincidence. The block is applied ahead
of the rule set, so it would discard the traffic before sequence 40 was ever
evaluated. The bogon block stays on, since the bogon list excludes the private
ranges.

The route command is printed in the deployment summary, carrying the supernet
and dotted mask of the block that was selected, so one route covers the whole
range and no future network needs adding. The address itself cannot be
resolved ahead of time because the interface is configured for DHCP and holds
no lease until the appliance boots, so the command is printed with a
placeholder and the operator reads the address from the console banner.

The route is offered only when it would work. A static route towards that
address is only reachable when the address is a single hop away, which holds
when the switch is external and the management operating system still shares
the adapter, since both then lease from the same upstream network. A private
switch leaves the host with no interface into the segment, and an internal one
carries no address server to lease a reachable address. In those cases the
summary reports that the route is not offered rather than printing one that
would fail on an unreachable gateway.

Verified against the live host, which offered the route for the external
switch and declined for both internal ones, and the printed command was
executed against a documentation prefix to confirm the syntax and the metric
before being removed again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 14:53:04 -04:00
gsadmin e9a8c59150 fix: Share each shaping pipe instead of masking it per address
A dummynet mask does not subdivide a pipe. It instantiates a separate copy of
the pipe for every distinct value of the masked field, each with the full
configured bandwidth, so masking by address gave every member of a profile its
own complete allowance. Five members of a 20 Mbit profile would have carried
100 Mbit between them, and a constrained link would have grown faster as hosts
were added to it, which is the opposite of a bottleneck.

Every pipe now carries no mask and is a single shared bucket, so the members of
a profile contend for the bandwidth as hosts behind a real link do. The
contention is the behaviour being reproduced.

The corollary, now documented, is that one profile is one link. Two independent
constrained paths need two profiles, and ten profiles provide ten independent
buckets per direction.

The rules stay floating rather than bound to WAN. The intent is to imitate
constrained links between the networks behind the router, one router standing
in for what would otherwise be several, so that one client reaching one server
can be slow while others are fast.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 14:22:53 -04:00
gsadmin 5d88376499 feat: Turn traffic shaping into a set of named scenarios
A single inbound and outbound pair meant deciding the bandwidth up front. Ten
scenarios are now emitted instead, spanning a badly congested connection
through to gigabit, so imposing one is a matter of choosing which alias to put
an address into.

  512KBPS  1MBPS  5MBPS  10MBPS  25MBPS  45MBPS  100MBPS  250MBPS  500MBPS  1000MBPS

Each scenario produces a matched set that carries its own name throughout, so
the intent is legible in an alphabetical alias list rather than requiring the
pipe to be opened to discover what it does:

- Two aliases, INBOUND_45MBPS and OUTBOUND_45MBPS, both empty
- Two pipes carrying the same names, one masked per destination address and
  one per source address
- Two rules binding one to the other

An address placed in an inbound and an outbound alias of different speeds gets
an asymmetric link, which is what most real connections are. An address should
appear in at most one of each, since the rules are quick and the profiles are
emitted slowest first, so the slower of the two would win.

The bandwidth parameters are removed. The scenarios are fixed, which is the
point of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 14:10:01 -04:00
gsadmin 83c6280568 feat: Add traffic shaping that is imposed by editing an alias
Two shaper pipes are created and enabled, and are bound to two firewall
aliases that start empty. Shaping is imposed later by adding an address or a
network to an alias rather than by building a shaper from scratch. An empty
alias produces an empty table, which matches nothing, so both rules are live
but inert until then.

The shaping is attached to filter rules rather than to shaper rules. A shaper
rule declares its source and destination as NetworkField, whose validator only
accepts a literal address or the word any, so it cannot reference an alias. A
filter rule uses NetworkAliasField for both and additionally carries shaper1
and shaper2, which relate to a shaper pipe or queue. Binding the pipes through
filter rules is therefore the only construction that lets an alias drive the
shaping. The shaper rule collection is emitted empty on purpose.

Two rules rather than one, because a pipe carries traffic in one direction
only. Rule 500 matches an address as a source and shapes what it sends, and
rule 501 matches it as a destination and shapes what it receives, so an address
placed in both aliases is shaped in both directions.

Each pipe carries a mask, source address for outbound and destination address
for inbound, so the limit applies per address instead of being shared across
every entry in the alias.

Shaping is applied at layer three, so traffic between two hosts on the same
network is switched and is out of scope by construction, while inter network
and internet traffic is routed and can be shaped with full layer three context.

Bandwidths default to 100 Mbit and are exposed, and SkipTrafficShaping omits
the pipes, the aliases and the rules entirely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 14:01:49 -04:00
gsadmin a21add5b6e fix: Return the Kea socket type to raw
Raw is what the appliance ships with, and it is the only one of the two that
reliably answers a client which holds no address yet. That is precisely the
preboot execution environment case, so defaulting to udp risked breaking the
netboot path this deployment sets up.

The udp socket remains available through DHCPSocketType for an environment
that requires it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 20:08:42 -04:00
gsadmin 6e86c481a4 feat: Reserve a PXE server, float the shared rules, and soften the defaults
Credentials:
- The stock appliance password is retained by default. It is typed at a
  console before being changed, and the appliance prompts for a change on
  first sign in regardless
- GenerateRootPassword opts into a random password, now sixteen characters
  without symbols rather than twenty with them

Preboot execution environment:
- Get-HyperVNextAvailableMACAddress reads the next dynamic address the
  hypervisor will issue, treating the six bytes as one integer so an offset
  carries correctly across octets
- That address is reserved within Kea for a PXE server on the Infrastructure
  network, which allows the reservation to exist before the virtual machine
  that will carry the address does
- The reserved address populates a PXE_SERVERS_001 alias, becomes next_server
  and tftp_server_name within every scope, and is reachable through a floating
  rule from RFC1918
- The reservation is skipped with a warning on a host that does not run the
  hypervisor, so the document still generates

Rules:
- The management, jump host, name resolution and PXE rules are now floating.
  They were identical on every interface, so a sixteen network policy falls
  from 87 rules to 28
- Descriptions no longer name the network they were generated for, since a
  floating rule applies everywhere
- The RFC1918 alias now has a purpose as the source of the internal permits

Kea:
- The socket type defaults to udp and is exposed as DHCPSocketType. Raw
  remains the safer choice for a client that holds no address yet
- Option data auto collection is enabled

Elsewhere:
- Outbound network address translation moves to hybrid, with no manual rules
- Zone roles carry three digit indexes, so Servers_Zone_001

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 19:27:03 -04:00
gsadmin 75d3dcb69c feat: Size the appliance for the live image and document the operator sequence
Memory:
- The virtual machine now defaults to 4 GB rather than 2 GB. The live image
  runs from memory before it is installed and refuses to start below roughly
  3 GB, so the previous default failed during the import phase rather than
  later. The dynamic memory floor moves to 2 GB to match

Configuration disk:
- The disk that carries the configuration document now defaults to 4 GB rather
  than 256 MB. It is dynamically expanding, so nothing more is consumed on the
  host, but the installer reports the declared size and the operator has to
  pick this disk out by size at the importer prompt. Four gigabytes against a
  thirty two gigabyte boot disk is unmistakable
- The size is exposed as ConfigurationDiskSizeBytes

Boot order:
- The disc drive was placed first, which meant the installer booted again on
  every subsequent start until the media was detached by hand
- The boot disk is now first and the disc drive second. The firmware falls
  through a disk that carries no boot loader, so the installer still boots
  while the disk is empty, and the installed appliance takes precedence by
  itself afterwards

Documentation:
- The readme now carries the full operator sequence as two phases: importing
  the configuration from da1, then logging in as installer, choosing UFS,
  installing onto da0, confirming the destroy and rebooting
- Both disks are documented with the sizes that identify them

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 18:51:41 -04:00
gsadmin 0c61eda44f feat: Name the edge interfaces WAN and LAN, and stop Kea contending for its sockets
Naming:
- The wide area network interface is now described as WAN rather than WAN_0000
- The untagged parent network is now named LAN rather than Core
- The Storage zone becomes VPN

Kea:
- A stock installation enables dnsmasq and hands it the address pool. Two
  services cannot bind the same DHCP socket and the loser is the one that fails
  to start, so dnsmasq is now left with no DHCP interfaces, its enable and
  dhcp_ranges nodes are removed, and the legacy dhcpd sections are removed
  outright
- service_sockets_max_retries and service_sockets_retry_wait_time are set, so a
  VLAN interface that is not ready when the service starts is retried within a
  bounded window rather than the start appearing to hang
- The socket type stays raw on purpose, because udp is a documented cause of
  clients failing to obtain a lease
- Both retry values are exposed as parameters

Reporting:
- The deployment summary described the planned VLAN list as the trunk contents.
  The trunk has carried the whole 1-4094 range since it was widened, so the
  summary now reports what the trunk actually carries and lists the planned
  VLANs separately

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 18:39:23 -04:00
gsadmin 3914d52123 fix: Resolve the release directory index and stop errors being masked
Running the script with no parameters failed at the media acquisition stage
with "You cannot call a method on a null-valued expression", which described
neither the cause nor the location.

Release resolution:
- The expression that parses the mirror release directory index required the
  version to follow href=" immediately, so it matched nothing on a mirror that
  emits href="./26.1/" rather than href="26.1/", which is what the default
  mirror does
- It now tolerates either quoting style and any leading relative fragment, and
  resolves 26.1.6 against the live mirror

Error reporting:
- The main catch captured $Error[0] rather than the error record it was handed,
  and delegated straight to the toolkit error handler
- That handler dereferences InvocationInfo.Line.Trim(), which throws when the
  property is not populated, and the resulting failure replaced the original
  diagnostic entirely
- The error record is now captured directly, and its message, category, script,
  line number, code and stack trace are reported before anything else is
  attempted
- The delegation to the toolkit handler is wrapped so that a failure inside it
  can no longer hide the real cause

Name prefix:
- The main script still forced a prefix of LAB onto every generated name after
  the prefix was made optional, so interfaces were named LAB_129_Management
  rather than 129_Management
- The prefix is now only passed through when one has actually been supplied

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 17:45:45 -04:00
gsadmin 98d5863b8c feat: Widen the DHCP pools and split the DNS port aliases per transport
DHCP pools:
- Each pool now spans every usable address within its network rather than a
  hundred address window, so a /24 hands out 253 addresses
- Only the network address, the broadcast address, and the address of the
  firewall itself are excluded
- When the firewall does not sit at the first host address the pool is emitted
  as two ranges either side of it, which Kea accepts as a newline separated
  pools value
- DHCPPoolStartHostOffset and DHCPPoolEndHostOffset are now optional overrides
  that are clamped to the usable range, and the firewall is carved out of them
  as well
- The plan object gained DHCPPoolRangeList and DHCPPoolAddressCount, and a
  network too small to yield a usable address warns instead of emitting an
  invalid pool

DNS port aliases:
- DNS_PORTS_001 becomes DNS_PORTS_TCP_001 and DNS_PORTS_UDP_001
- TCP carries 53 and 853, UDP carries 53, 853 and 5353, because multicast name
  resolution is UDP only
- The allow and block rules are emitted once per transport rather than once as
  TCP/UDP, so each references the alias that belongs to it

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 17:03:02 -04:00
gsadmin c470c51a9a feat: Rank the external switch adapter and handle the network interruption
The physical adapter for a new external virtual switch was previously the
first operational one by interface index, which could bind a wireless adapter
or an adapter already carrying another virtual switch, and the connectivity
drop that the bind causes was neither announced nor waited out.

Adapter selection:
- Exclude adapters already bound to an existing virtual switch
- Rank candidates so ethernet always beats wireless, and within each class an
  adapter carrying a default gateway wins because it is the one providing
  upstream connectivity today
- Break ties on receive link speed, then interface index
- Log every candidate with its rank, and the reason the winner was chosen
- PreferredNetAdapterName, surfaced as WANNetAdapterName, overrides the ranking
  and matches on either the adapter name or its interface description
- Only enable embedded teaming on a server operating system, across ethernet
  adapters, and never when the selected adapter is wireless

Network interruption:
- Warn before binding, naming the adapter, with an extra warning when it is
  wireless because a bridged attachment interrupts for longer
- Pause NetworkInterruptionWarningDelay, default ten seconds, so an operator
  can interrupt the script before a remote session drops
- Wait up to ConnectivitySettleTimeout, default ninety seconds, for the host
  virtual adapter to come up with a non APIPA address, logging how long it
  took, and warn rather than fail when it times out

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 16:53:49 -04:00
gsadmin bc3dd6c535 feat: Add idempotent OPNsense virtual firewall deployment for Hyper-V
Deploys a fully preconfigured OPNsense appliance into a Hyper-V lab in a
single execution, re-running safely because every stage detects the current
state before it acts.

Main script:
- Hyper-V platform detection and installation, exiting 3010 only when the
  hypervisor itself needs a restart
- Random /20 block selection out of a private base network, divided into /24
  networks whose VLAN tag is the third octet of their own network address
- Zone based roles, with five server zones paired by index to five client
  zones, plus Management, Infrastructure, DMZ, Storage and Guest
- Generated OPNsense config.xml delivered on a FAT32 VHDX at conf/config.xml
- Generation 2 virtual machine with secure boot disabled and a LAN trunk
  carrying VLANs 1-4094
- Marker scoped teardown via RemoveExistingDeployment

Toolkit functions:
- Save-ToolkitModule, Install-HyperVPlatform, Test-PendingReboot
- Expand-CompressedFile, Get-OPNSenseInstallationMedia
- Get-HyperVStorageLocation, Get-HostUpstreamDNSConfiguration
- New-RandomPassword, New-OPNSensePasswordHash
- New-OPNSenseNetworkPlan, New-OPNSenseConfigurationDocument,
  Save-OPNSenseConfigurationDocument, New-OPNSenseConfigurationDisk
- Initialize-OPNSenseVirtualSwitch, New-OPNSenseVirtualMachine,
  Remove-OPNSenseDeployment

Configuration document covers interfaces, VLANs, Kea DHCPv4 scopes with PXE
options, Unbound, outbound NAT, six firewall aliases and an ordered rule set
that grants management full reach, allows the jump hosts over well known
management ports, forces name resolution to approved resolvers, and pairs the
client and server zones.

Bundles 7-Zip, because the tar.exe included with Windows cannot read a raw
bzip2 stream, and BCrypt.Net-Next for the appliance password hash.

docs: Add readme with execution flow and generated per function reference
docs: Add design specification under .ai/specification

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 16:45:14 -04:00
gsadmin de577f1058 Initial Commit 2026-08-04 13:34:54 -04:00