diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d4ab5f --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +cmd/subnetter/subnetter diff --git a/README.adoc b/README.adoc new file mode 100644 index 0000000..755367b --- /dev/null +++ b/README.adoc @@ -0,0 +1,48 @@ += Subnetter +Brent Saner +Last rendered {localdatetime} +:doctype: book +:docinfo: shared +:data-uri: +:imagesdir: images +:sectlinks: +:sectnums: +:sectnumlevels: 7 +:toc: preamble +:toc2: left +:idprefix: +:toclevels: 7 +:source-highlighter: rouge +:docinfo: shared +:rfc: https://datatracker.ietf.org/doc/html/rfc + +[id="wat"] +== What is it? +A tool to assist in design of segregate/segment/split/subnet networks. + +[id="out"] +== Output + +* `Unicast` refers to "Global Unicast" ({rfc}1122[RFC 1122^], {rfc}4291#section-2.5.4[RFC 4291 § 2.5.4^], {rfc}4632[RFC 4632^]). +** For IPv6 addresses, it will be `true` for ULA (_Unique Local Addresses_) ({rfc}4193[RFC 4193^]) also. +** For IPv4 addresses, it will be `true` if the address is routable by external hosts (a unicast address), including private IP addresses ({rfc}1918[RFC 1918^]). +* `ILM` refers to "Interface-Local Multicast" ({rfc}4291#section-2.7[RFC 4291 § 2.7^], {rfc}7346[RFC 7346^]). +** It will always be `false` for IPv4 addresses. +* `LLM` refers to "Link-Local Multicast" ({rfc}4291#section-2.7[RFC 4291 § 2.7^], {rfc}7346[RFC 7346^]). +** For IPv4 addresses, it will be `true` if it is in the `224.0.0.0/4` range ({rfc}5735[RFC 5735^]). +* `LLU` refers to "Link-Local Unicast" ({rfc}4291#section-2.7[RFC 4291 § 2.7^], {rfc}7346[RFC 7346^]). +** For IPv4 addresses, it will be `true` if it is an APIPA (_Automatic Private IP Addressing_) address ({rfc}3927[RFC 3927^]) (in the `169.254.0.0/16` range). +* `First` and `Last` refer to the first and last "usable" ("host"/assignable) addresses in a subnet/network. +** Note that for IPv6, the first address (`x::`) in a subnet *may* or *may not* be assignable/"usable". If it is assigned to a device, that device *must* be a router for anycast. See {rfc}4291#section-2.6.1[RFC 4291 § 2.6.1^] for details. In the interest of convenience, `subnetter` will report this address as *not usable/addressable* in ranges for this reason as it is technically not a "host" address. +** Note that for IPv6, some subnetting calculators erroneously report the last address for /64's (e.g. `x:ffff:ffff:ffff:ffff/64`) as usable. They are actually reserved in strictly RFC-compliant networks for EUI-64 reasons (per {rfc}2526[RFC 2526^]). For this reason, *if and only if* a prefix is a /64 *exactly*, `subnetter` will use `x:ffff:ffff:ffff:fffe` as the last host address. +** There are additional restrictions for /64 subnets, but they fall earlier in the range. These are *not explicitly excluded* in the usable host range, nor are they excluded from the total host count. +* Private networks ({rfc}1918[RFC 1918^]), ULA prefixes ({rfc}4193[RFC 4193^]), and documentation prefixes ({rfc}3849[RFC 3849^], {rfc}5737[RFC 5737^], {rfc}9637[RFC 9637^]) are treated as "normal" networks (in that it is allowed to subnet them). +* Various other reserved IPv4 and IPv6 addresses/networks will print warnings with their corresponding RFC(s) (unless `-R`/`--allow-reserved` is specified) if they are specified as/included in the initial prefix/network. + +[id="ref"] +== References +This program in general draws inspiration from `ipcalc` (http://jodies.de/ipcalc[0^], https://github.com/kjokjo/ipcalc[1^], https://gitlab.com/ipcalc/ipcalc[2^]) and http://www.routemeister.net/projects/sipcalc/[`sipcalc`^]. + +The `table` subcommand is inspired by `iptab` from https://metacpan.org/pod/Net::IP[Perl Net-IP^]. + +Additional notes for certain contexts are primarily taken from https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing[the Wikipedia article on _Classless Inter-Domain Routing_^] (as of _Jan 28, 2025_). diff --git a/README.md b/README.md deleted file mode 100644 index 6c08fe2..0000000 --- a/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# go_subnetter - -Easily split networks into subnets. \ No newline at end of file diff --git a/cmd/subnetter/args.go b/cmd/subnetter/args.go new file mode 100644 index 0000000..57edc8d --- /dev/null +++ b/cmd/subnetter/args.go @@ -0,0 +1,64 @@ +package main + +type Args struct { + SplitCIDR SplitCIDRArgs `command:"split-cidr" alias:"se" description:"Split a network into as many equal parts of a given prefix as possible." validate:"omitempty"` + SplitHost SplitHostArgs `command:"split-hosts" alias:"sh" description:"Split a network into n total number of hosts into subnet as cleanly/evenly as possible." validate:"omitempty"` + SplitSubnets SplitSubnetArgs `command:"split-nets" alias:"sn" description:"Split a network into n number of subnets as cleanly as possible." validate:"omitempty"` + VLSM VLSMArgs `command:"vlsm" alias:"v" description:"Use VLSM (Variable-Length Subnet Masks) to split a network into differently sized subnets." validate:"omitempty"` + Parse ParseArgs `command:"parse" alias:"p" alias:"read" alias:"convert" description:"Parse/convert output from a previous subnetter run." validate:"omitempty"` + Table TableArgs `command:"table" alias:"t" alias:"tab" alias:"tbl" description:"Show prefix summaries (by default both IPv4 and IPv6)." validate:"omitempty"` +} + +type outputOpts struct { + SuppressRemaining bool `short:"r" long:"no-remaining" description:"Don't show leftover/unallocated/remaining space.'"` + Verbose []bool `short:"v" long:"verbose" description:"Show verbose information. May be specified multiple times to increase verbosity (up to 3 levels)."` + Seperator string `short:"S" long:"seperator" default:"\n" description:"Separator between addresses; only used for -f/--format=pretty with no verbosity."` + Fmt string `short:"f" long:"format" choice:"json" choice:"pretty" choice:"yml" choice:"xml" default:"pretty" description:"Output format. DO NOT parse 'pretty' as its output is not guaranteed between versions."` +} + +type common struct { + outputOpts + AllowReserved bool `short:"R" long:"allow-reserved" description:"If specified, do not warn about reserved IP addresses/networks."` + AllowHostNet bool `short:"H" long:"allow-host" description:"If specified, do not warn about host bits. Host bits are always removed for subnetting (as otherwise there would be errors); this is only used only for output."` + Network Net `positional-args:"yes" required:"true" description:"The network to be split/subnetted." validate:"required"` +} + +type ParseArgs struct { + outputOpts + AllowReserved bool `short:"R" long:"allow-reserved" description:"If specified, do not warn about reserved IP addresses/networks."` + AllowHostNet bool `short:"H" long:"allow-host" description:"If specified, do not warn about host bits."` + InFile string `short:"i" long:"input" default:"-" description:"Input file to parse. Default is '-' (for STDIN)." required:"true" validate:"required,filepath|eq=-"` +} + +type SplitCIDRArgs struct { + Prefix uint8 `short:"s" long:"size" required:"true" description:"Prefix length/network size in bits (as CIDR number)." validate:"required"` + common +} + +type SplitHostArgs struct { + Hosts uint `short:"n" long:"num-hosts" required:"true" description:"Number of hosts (usable addresses) per subnet." validate:"required"` + common +} + +type SplitSubnetArgs struct { + NumNets uint `short:"n" long:"num-nets" required:"true" description:"Number of networks." validate:"required"` + common +} + +type TableArgs struct { + NoIpv6 bool `short:"4" long:"ipv4" description:"Show IPv4 table."` + NoIpv4 bool `short:"6" long:"ipv6" description:"Show IPv6 table."` + Verbose []bool `short:"v" long:"verbose" description:"Show verbose information. May be specified multiple times to increase verbosity (up to 3 levels)."` + Fmt string `short:"f" long:"format" choice:"csv" choice:"json" choice:"pretty" choice:"tsv" choice:"yml" choice:"xml" default:"pretty" description:"Output format."` + Net *string `short:"n" long:"network" description:"If specified, provide information explicitly about this network. Ignores -4/--ipv4 and -6/--ipv6." validate:"omitempty,cidr"` +} + +type VLSMArgs struct { + Asc bool `short:"a" long:"asc" description:"If specified, place smaller networks (larger prefixes) at the beginning. You almost assuredly do not want to do this."` + Sizes []uint8 `short:"s" long:"size" required:"true" description:"Prefix lengths. May be specified multiple times." validate:"required"` + common +} + +type Net struct { + Network string `positional-arg-name:"/" description:"network address with prefix. Can be IPv4 or IPv6." validate:"required,cidr"` +} diff --git a/cmd/subnetter/consts.go b/cmd/subnetter/consts.go new file mode 100644 index 0000000..a8fc530 --- /dev/null +++ b/cmd/subnetter/consts.go @@ -0,0 +1,18 @@ +package main + +import ( + "github.com/go-playground/validator/v10" + "strings" +) + +var ( + args *Args = new(Args) + validate *validator.Validate = validator.New(validator.WithRequiredStructEnabled()) +) + +var ( + sectSepCnt int = 48 + sectSep1 string = strings.Repeat("=", sectSepCnt) + sectSep2 string = strings.Repeat("-", sectSepCnt) + sectSep3 string = strings.Repeat(".", sectSepCnt) +) diff --git a/cmd/subnetter/funcs.go b/cmd/subnetter/funcs.go new file mode 100644 index 0000000..d335999 --- /dev/null +++ b/cmd/subnetter/funcs.go @@ -0,0 +1,438 @@ +package main + +import ( + "bytes" + "encoding/binary" + "encoding/json" + "encoding/xml" + "fmt" + "github.com/goccy/go-yaml" + "github.com/projectdiscovery/mapcidr" + "go4.org/netipx" + "io" + "net" + "net/netip" + "os" + "strings" + "subnetter/netsplit" + "time" +) + +func printHostPrefix(label string, pfx *netip.Prefix, verb, indent int, indentStr string) (out string) { + + var maskEvery uint + var sb *strings.Builder = new(strings.Builder) + var pre string = strings.Repeat(indentStr, indent) + var pre2 string = strings.Repeat(indentStr, indent+1) + + if pfx == nil { + fmt.Fprintf(sb, "%s%s:\n%sAddress:\t(N/A)\n", pre, label, pre2) + if verb >= 2 { + fmt.Fprintf(sb, "%sExpanded:\t(N/A)\n", pre2) + fmt.Fprintf(sb, "%sCompressed:\t(N/A)\n", pre2) + fmt.Fprintf(sb, "%sHex:\t\t(N/A)\n", pre2) + } + if verb >= 3 { + fmt.Fprintf(sb, "%sDecimal:\t(N/A)\n", pre2) + fmt.Fprintf(sb, "%sBinary:\t\t(N/A)\n", pre2) + fmt.Fprintf(sb, "%sOctal:\t\t(N/A)\n", pre2) + fmt.Fprintf(sb, "%sUnicast:\t(N/A)\n", pre2) + fmt.Fprintf(sb, "%sILM:\t\t(N/A)\n", pre2) + fmt.Fprintf(sb, "%sLLM:\t\t(N/A)\n", pre2) + fmt.Fprintf(sb, "%sLLU:\t\t(N/A)\n", pre2) + fmt.Fprintf(sb, "%sLoopback:\t(N/A)\n", pre2) + fmt.Fprintf(sb, "%sMulticast:\t(N/A)\n", pre2) + fmt.Fprintf(sb, "%sPrivate:\t(N/A)\n", pre2) + } + out = sb.String() + return + } + + if pfx.Addr().Is4() { + maskEvery = 1 + } else { + maskEvery = 2 + } + + fmt.Fprintf(sb, + "%s%s:\n%sAddress:\t%s\n", + pre, label, pre2, pfx.Addr().String(), + ) + if verb >= 2 { + fmt.Fprintf(sb, "%sExpanded:\t%s\n", pre2, netsplit.AddrExpand(pfx.Addr())) + fmt.Fprintf(sb, "%sCompressed:\t%s\n", pre2, netsplit.AddrCompress(pfx)) + fmt.Fprintf(sb, + "%sHex:\t\t0x%s\n", + pre2, netsplit.AddrFmt(pfx.Addr(), "02x", "", "", 0, 0), + ) + } + if verb >= 3 { + fmt.Fprintf(sb, "%sDecimal:\t%d\n", pre2, binary.BigEndian.Uint32(pfx.Addr().AsSlice())) + fmt.Fprintf(sb, + "%sBinary:\t\t0b%s\n", + pre2, netsplit.AddrFmt( + pfx.Addr(), "08b", ".", fmt.Sprintf("\n%s\t\t ", pre2), maskEvery, 2, + ), + ) + fmt.Fprintf(sb, + "%sOctal:\t\t0o%s\n", + pre2, netsplit.AddrFmt( + pfx.Addr(), "03o", ".", fmt.Sprintf("\n%s\t\t ", pre2), 1, 8, + ), + ) + fmt.Fprintf(sb, "%sUnicast:\t%v\n", pre2, pfx.Addr().IsGlobalUnicast()) + fmt.Fprintf(sb, "%sILM:\t\t%v\n", pre2, pfx.Addr().IsInterfaceLocalMulticast()) + fmt.Fprintf(sb, "%sLLM:\t\t%v\n", pre2, pfx.Addr().IsLinkLocalMulticast()) + fmt.Fprintf(sb, "%sLLU:\t\t%v\n", pre2, pfx.Addr().IsLinkLocalUnicast()) + fmt.Fprintf(sb, "%sLoopback:\t%v\n", pre2, pfx.Addr().IsLoopback()) + fmt.Fprintf(sb, "%sMulticast:\t%v\n", pre2, pfx.Addr().IsMulticast()) + fmt.Fprintf(sb, "%sPrivate:\t%v\n", pre2, pfx.Addr().IsPrivate()) + } + + out = sb.String() + + return +} + +func printMask(label string, pfx netip.Prefix, verb, indent int, indentStr string) (out string) { + + var maskF string + var maskSep string + var maskEvery uint + var mask net.IPMask + var first netip.Addr + var last netip.Addr + var sb *strings.Builder = new(strings.Builder) + var pre string = strings.Repeat(indentStr, indent) + var pre2 string = strings.Repeat(indentStr, indent+1) + var pre3 string = strings.Repeat(indentStr, indent+2) + + if !pfx.IsValid() { + return + } + mask = netipx.PrefixIPNet(pfx).Mask + + if pfx.Addr().Is4() { + maskF = "d" + maskSep = "." + maskEvery = 1 + // IPv4 *always* reserves last addr for broadcast UNLESS it's a /31 (or /32). RFC 919, RFC 1770, RFC 5735. + switch pfx.Bits() { + case 32: // Host + first = pfx.Masked().Addr() + last = pfx.Masked().Addr() + case 31: // Point-to-Point + first = pfx.Masked().Addr() + last = pfx.Masked().Addr().Next() + default: // RFC 919, RFC 5735 + first = pfx.Masked().Addr().Next() + last = netipx.PrefixLastIP(pfx.Masked()).Prev() + } + } else { + maskF = "02x" + maskSep = ":" + maskEvery = 2 + switch pfx.Bits() { + case 128: // Host/Loopback + first = pfx.Masked().Addr() + last = pfx.Masked().Addr() + case 127: // Point-to-Point + first = pfx.Masked().Addr() + last = pfx.Masked().Addr().Next() + case 64: + first = pfx.Masked().Addr().Next() + // IPv6 only reserves the last address (for EUI-64 reasons) for /64's. + last = netipx.PrefixLastIP(pfx.Masked()).Prev() + default: + first = pfx.Masked().Addr() + last = netipx.PrefixLastIP(pfx.Masked()) + } + } + + fmt.Fprintf(sb, + "%s%s:\n%sNetmask:\t%s\n", + pre, label, pre2, netsplit.MaskFmt(mask, maskF, maskSep, "", maskEvery, 0), + ) + fmt.Fprintf(sb, "%sBits:\t\t%d\n", pre2, pfx.Bits()) + fmt.Fprintf(sb, "%sFirst:\t\t%s\n", pre2, first.String()) + fmt.Fprintf(sb, "%sLast:\t\t%s\n", pre2, last.String()) + fmt.Fprintf(sb, "%sAddresses:\t%d\n", pre2, mapcidr.CountIPsInCIDR()) + if verb >= 2 { + fmt.Fprintf(sb, "%sExpanded:\t%s\n", pre2, netsplit.MaskExpand(mask, pfx.Addr().Is6())) + fmt.Fprintf(sb, "%sHex:\t\t0x%s\n", pre2, mask.String()) + } + if verb >= 3 { + fmt.Fprintf(sb, "%sDecimal:\t%d\n", pre2, binary.BigEndian.Uint32(mask)) + fmt.Fprintf(sb, + "%sBinary:\t\t0b%s\n", + pre2, netsplit.MaskFmt( + mask, "08b", ".", fmt.Sprintf("\n%s\t\t ", pre2), maskEvery, 2, + ), + ) + fmt.Fprintf(sb, + "%sOctal:\t\t0o%s\n", + pre2, netsplit.MaskFmt( + mask, "03o", ".", fmt.Sprintf("\n%s\t\t ", pre2), 1, 8, + ), + ) + + // Inverted mask + mask = netsplit.MaskInvert(mask) + fmt.Fprintf(sb, + "%sInverted Mask (\"Cisco Wildcard\"):\n%sNetmask:\t%s\n", + pre2, pre3, netsplit.MaskFmt(mask, maskF, maskSep, "", maskEvery, 0), + ) + fmt.Fprintf(sb, "%sBits:\t\t%d\n", pre3, pfx.Bits()) + fmt.Fprintf(sb, "%sExpanded:\t%s\n", pre3, netsplit.MaskExpand(mask, pfx.Addr().Is6())) + fmt.Fprintf(sb, "%sHex:\t\t0x%s\n", pre3, mask.String()) + fmt.Fprintf(sb, "%sDecimal:\t%d\n", pre3, binary.BigEndian.Uint32(mask)) + fmt.Fprintf(sb, + "%sBinary:\t\t0b%s\n", + pre3, netsplit.MaskFmt( + mask, "08b", ".", fmt.Sprintf("\n%s\t\t ", pre3), maskEvery, 2, + ), + ) + fmt.Fprintf(sb, + "%sOctal:\t\t0o%s\n", + pre3, netsplit.MaskFmt( + mask, "03o", ".", fmt.Sprintf("\n%s\t\t ", pre3), 1, 8, + ), + ) + } + + out = sb.String() + + return +} + +func printNets(orig *netip.Prefix, origNet *net.IPNet, nets []*netip.Prefix, remaining *netipx.IPSet, args *common, splitter netsplit.NetSplitter) (err error) { + + var b []byte + var netsLen uint + var remLen uint + var buf *bytes.Buffer + var masked netip.Prefix + var remPfxs []*netip.Prefix + var invertedMask net.IPMask + var res *netsplit.StructuredResults + var verb int = -1 + + if orig == nil { + return + } + + if args == nil { + args = &common{ + outputOpts: outputOpts{ + Seperator: "\n", + }, + } + } + + if args.outputOpts.Verbose != nil { + verb = 0 + for _, i := range args.outputOpts.Verbose { + if i { + verb++ + } + } + } + + if nets != nil && len(nets) > 0 { + netsLen = uint(len(nets)) + } + if remaining != nil { + remLen = uint(len(remaining.Prefixes())) + remPfxs = make([]*netip.Prefix, remLen) + for idx, p := range remaining.Prefixes() { + remPfxs[idx] = new(netip.Prefix) + *remPfxs[idx] = p + } + } + + masked = orig.Masked() + + invertedMask = netsplit.MaskInvert(origNet.Mask) + + if verb < 0 { + verb = -1 + } + + if args.outputOpts.Fmt == "pretty" { + // "Human"-formatted + + // Header + if !args.AllowHostNet && (orig.String() != origNet.String()) { + // The host bits were removed. Warn to STDERR. + fmt.Fprintf( + os.Stderr, + "!! WARNING: !!"+ + "\n\tOriginal prefix '%s' had host bits set; converted to actual network boundary '%s'.\n", + orig.String(), origNet.String(), + ) + } + if verb >= 1 { + fmt.Printf( + "= %s =\n%d Subnets, %d Remaining/Left Over/Unallocated.\n", + origNet.String(), netsLen, remLen, + ) + fmt.Println(sectSep1) + + // Host (if specified) + if orig.String() != origNet.String() { + fmt.Print(printHostPrefix("Host", orig, verb, 0, "\t")) + } else { + fmt.Print(printHostPrefix("Host", nil, verb, 0, "\t")) + } + fmt.Println(sectSep2) + + // Net mask + fmt.Print(printMask("Mask", orig.Masked(), verb, 0, "\t")) + fmt.Println(sectSep2) + + // network address + fmt.Print(printHostPrefix("Network", &masked, verb, 0, "\t")) + + fmt.Println(sectSep1) + } + + // Allocations + if verb >= 1 { + fmt.Println() + fmt.Println(sectSep1) + fmt.Println("Subnets:") + } + if netsLen == 0 { + if verb >= 1 { + fmt.Println("(Subnetting not possible.)") + } + } else { + for _, n := range nets { + fmt.Print(resFromPfx(n).pretty(verb, 1, args.outputOpts.Seperator, "\t", false)) + } + } + if verb >= 1 { + fmt.Println(sectSep1) + } + + // Remaining + if !args.outputOpts.SuppressRemaining { + if verb >= 1 { + fmt.Println() + fmt.Println(sectSep1) + fmt.Println("Remaining/Left Over/Unallocated:") + } + if remLen == 0 { + if verb >= 1 { + fmt.Println("(No network space left over/unallocated.)") + } + } else { + if remaining == nil { + // This will never, ever fire; it's here to make IDEs stop being dumb and complaining. + return + } + for _, n := range remaining.Prefixes() { + fmt.Print(resFromPfx(&n).pretty(verb, 1, args.outputOpts.Seperator, "\t", true)) + } + } + if verb >= 1 { + fmt.Println(sectSep1) + } + } + } else { + buf = new(bytes.Buffer) + // TODO: data-formatted/structured output + if res, err = netsplit.Contain(orig, nets, remaining, splitter); err != nil { + return + } + switch strings.ToLower(args.outputOpts.Fmt) { + case "json": + if b, err = json.MarshalIndent(res, "", " "); err != nil { + return + } + buf.Write(b) + case "xml": + fmt.Fprintf( + buf, + ``+ + "\n", + time.Now().String(), + ) + if b, err = xml.MarshalIndent(res, "", " "); err != nil { + return + } + buf.Write(b) + case "yml": + fmt.Fprintf( + buf, + "# Generated by subnetter.\n"+ + "# %s\n\n", + time.Now().String(), + ) + if b, err = yaml.Marshal(res); err != nil { + return + } + buf.Write(b) + default: + return + } + if _, err = io.Copy(os.Stdout, buf); err != nil { + return + } + } + + _ = b + _ = invertedMask + + return +} + +func printSplitErr(e *netsplit.SplitErr) { + + if e == nil { + return + } + + os.Stderr.WriteString("\n!! ERROR !!!\n") + + os.Stderr.WriteString("\t" + e.Wrapped.Error() + "\n") + os.Stderr.WriteString("\nnetwork Iteration Details\n(when error was encountered):\n\n") + if e.Nets == nil { + os.Stderr.WriteString("Nets:\t\t\t(N/A)\n") + } else { + os.Stderr.WriteString("Nets:\n") + for _, n := range e.Nets { + fmt.Fprintf(os.Stderr, "\t%s\n", n.String()) + } + } + if e.Remaining == nil { + os.Stderr.WriteString("Remaining:\t\t(N/A)\n") + } else { + os.Stderr.WriteString("Remaining:\n") + for _, n := range e.Remaining.Prefixes() { + fmt.Fprintf(os.Stderr, "\t%s\n", n.String()) + } + } + if e.LastSubnet == nil { + os.Stderr.WriteString("Last Subnet:\t\t(N/A)") + } else { + fmt.Fprintf(os.Stderr, "Last Subnet:\t\t%s\n", e.LastSubnet.String()) + } + fmt.Fprintf(os.Stderr, "Desired Prefix Length:\t%d\n", e.RequestedPrefixLen) +} + +func resFromPfx(pfx *netip.Prefix) (res *subnetResult) { + + var txPfx subnetResult + + if pfx == nil { + return + } + txPfx = subnetResult(*pfx) + res = &txPfx + + return +} diff --git a/cmd/subnetter/funcs_subnetresult.go b/cmd/subnetter/funcs_subnetresult.go new file mode 100644 index 0000000..1183a80 --- /dev/null +++ b/cmd/subnetter/funcs_subnetresult.go @@ -0,0 +1,53 @@ +package main + +import ( + "fmt" + "net/netip" + "strings" +) + +func (s *subnetResult) pretty(verb, indent int, sep, indentStr string, isRemaining bool) (out string) { + + var pfx netip.Prefix + var bullet string = "+" + var sb *strings.Builder = new(strings.Builder) + var pre string = strings.Repeat(indentStr, indent) + var pre2 string = strings.Repeat(indentStr, indent+1) + + if s == nil { + return + } + pfx = netip.Prefix(*s) + + if verb < 0 { + verb = -1 + } + + if isRemaining { + bullet = "-" + } + + if verb <= 0 { + sb.WriteString(pfx.String() + sep) + out = sb.String() + return + } else { + sb.WriteString(pre + sectSep2 + "\n") + sb.WriteString( + printMask( + fmt.Sprintf("%s %s", bullet, pfx.String()), + pfx, + verb, + indent, + indentStr, + ), + ) + sb.WriteString(pre2 + sectSep3 + "\n") + sb.WriteString(printHostPrefix("Network", &pfx, verb, 2, "\t")) + sb.WriteString(pre + sectSep2 + "\n") + } + + out = sb.String() + + return +} diff --git a/cmd/subnetter/main.go b/cmd/subnetter/main.go new file mode 100644 index 0000000..951941a --- /dev/null +++ b/cmd/subnetter/main.go @@ -0,0 +1,161 @@ +package main + +import ( + "bytes" + "errors" + "go4.org/netipx" + "io" + "log" + "net" + "net/netip" + "os" + "strings" + "subnetter/netsplit" + + "github.com/jessevdk/go-flags" + "r00t2.io/sysutils/paths" +) + +func main() { + + var err error + var b []byte + var pfx *net.IPNet + var resPfx *netip.Prefix + var origPfx netip.Prefix + var splitter netsplit.NetSplitter + var cmnArgs common + var nets []*netip.Prefix + var remaining *netipx.IPSet + var buf *bytes.Buffer + var res *netsplit.StructuredResults + var splitErr *netsplit.SplitErr = new(netsplit.SplitErr) + var parser *flags.Parser = flags.NewParser(args, flags.Default) + + if _, err = parser.Parse(); err != nil { + switch flagsErr := err.(type) { + case *flags.Error: + switch flagsErr.Type { + case flags.ErrHelp, flags.ErrCommandRequired, flags.ErrRequired: // These print their relevant messages by themselves. + return + default: + log.Panicln(err) + } + default: + log.Panicln(err) + } + } + + switch parser.Active.Name { + case "table": + // TODO: print table and exit + return + case "parse": + // TODO: parse file/bytes, unmarshal, and render with new options then exit + if strings.TrimSpace(args.Parse.InFile) == "-" { + buf = new(bytes.Buffer) + if _, err = io.Copy(buf, os.Stdin); err != nil { + log.Panicln(err) + } + b = buf.Bytes() + } else { + if err = paths.RealPath(&args.Parse.InFile); err != nil { + log.Panicln(err) + } + if b, err = os.ReadFile(args.Parse.InFile); err != nil { + log.Panicln(err) + } + } + if res, err = netsplit.Parse(b); err != nil { + log.Panicln(err) + } + if resPfx, nets, remaining, splitter, err = res.Uncontain(); err != nil { + log.Panicln(err) + } + if resPfx != nil { + origPfx = *resPfx + } + pfx = netipx.PrefixIPNet(origPfx.Masked()) + cmnArgs = common{ + outputOpts: args.Parse.outputOpts, + AllowReserved: args.Parse.AllowReserved, + AllowHostNet: args.Parse.AllowHostNet, + } + if err = printNets(&origPfx, pfx, nets, remaining, &cmnArgs, res.GetSplitter()); err != nil { + log.Panicln(err) + } + return + default: + // Actually subnet (and print results). + /* + A netsplit.NetSplitter is needed, along with: + * prefix + * verbosity + * disable showing remaining + * formatter + These are all handily-dandily enclosed in a `common` struct type. + */ + switch parser.Active.Name { + case "split-hosts": + if err = validate.Struct(args.SplitHost); err != nil { + log.Panicln(err) + } + cmnArgs = args.SplitHost.common + splitter = &netsplit.HostSplitter{ + BaseSplitter: new(netsplit.BaseSplitter), + NumberHosts: args.SplitHost.Hosts, + } + case "split-nets": + if err = validate.Struct(args.SplitSubnets); err != nil { + log.Panicln(err) + } + cmnArgs = args.SplitSubnets.common + splitter = &netsplit.SubnetSplitter{ + BaseSplitter: new(netsplit.BaseSplitter), + NumberSubnets: args.SplitSubnets.NumNets, + } + case "split-cidr": + if err = validate.Struct(args.SplitCIDR); err != nil { + log.Panicln(err) + } + cmnArgs = args.SplitCIDR.common + splitter = &netsplit.CIDRSplitter{ + BaseSplitter: new(netsplit.BaseSplitter), + PrefixLength: args.SplitCIDR.Prefix, + } + case "vlsm": + if err = validate.Struct(args.VLSM); err != nil { + log.Panicln(err) + } + cmnArgs = args.VLSM.common + splitter = &netsplit.VLSMSplitter{ + BaseSplitter: new(netsplit.BaseSplitter), + Ascending: args.VLSM.Asc, + PrefixLengths: args.VLSM.Sizes, + } + } + if origPfx, err = netip.ParsePrefix(cmnArgs.Network.Network); err != nil { + log.Panicln(err) + } + // This can be a direct conversion. We have to make sure we mask off the host bits to avoid errors, though. + /* + if _, pfx, err = net.ParseCIDR(cmnArgs.network.network); err != nil { + log.Panicln(err) + } + */ + pfx = netipx.PrefixIPNet(origPfx.Masked()) + splitter.SetParent(*pfx) + if nets, remaining, err = splitter.Split(); err != nil { + if errors.As(err, &splitErr) { + printSplitErr(splitErr) + os.Exit(1) + } else { + log.Panicln(err) + } + } + if err = printNets(&origPfx, pfx, nets, remaining, &cmnArgs, splitter); err != nil { + log.Panicln(err) + } + } + +} diff --git a/cmd/subnetter/types.go b/cmd/subnetter/types.go new file mode 100644 index 0000000..24cfc73 --- /dev/null +++ b/cmd/subnetter/types.go @@ -0,0 +1,8 @@ +package main + +import ( + "net/netip" +) + +// subnetResult is only used for human/"pretty" printing. +type subnetResult netip.Prefix diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..eef2685 --- /dev/null +++ b/go.mod @@ -0,0 +1,35 @@ +module subnetter + +go 1.23.2 + +toolchain go1.23.5 + +require ( + github.com/go-playground/validator/v10 v10.24.0 + github.com/goccy/go-yaml v1.15.16 + github.com/jessevdk/go-flags v1.6.1 + github.com/projectdiscovery/mapcidr v1.1.34 + go4.org/netipx v0.0.0-20231129151722-fdeea329fbba + r00t2.io/sysutils v1.12.0 +) + +require ( + github.com/aymerick/douceur v0.2.0 // indirect + github.com/djherbis/times v1.6.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/gorilla/css v1.0.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/microcosm-cc/bluemonday v1.0.25 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/projectdiscovery/blackrock v0.0.1 // indirect + github.com/projectdiscovery/utils v0.0.85 // indirect + github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect + golang.org/x/crypto v0.32.0 // indirect + golang.org/x/net v0.34.0 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/text v0.21.0 // indirect + r00t2.io/goutils v1.7.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..76a4117 --- /dev/null +++ b/go.sum @@ -0,0 +1,63 @@ +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= +github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg= +github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= +github.com/goccy/go-yaml v1.15.16 h1:PMTVcGI9uNPIn7KLs0H7KC1rE+51yPl5YNh4i8rGuRA= +github.com/goccy/go-yaml v1.15.16/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= +github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= +github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= +github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/microcosm-cc/bluemonday v1.0.25 h1:4NEwSfiJ+Wva0VxN5B8OwMicaJvD8r9tlJWm9rtloEg= +github.com/microcosm-cc/bluemonday v1.0.25/go.mod h1:ZIOjCQp1OrzBBPIJmfX4qDYFuhU02nx4bn030ixfHLE= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/projectdiscovery/blackrock v0.0.1 h1:lHQqhaaEFjgf5WkuItbpeCZv2DUIE45k0VbGJyft6LQ= +github.com/projectdiscovery/blackrock v0.0.1/go.mod h1:ANUtjDfaVrqB453bzToU+YB4cUbvBRpLvEwoWIwlTss= +github.com/projectdiscovery/mapcidr v1.1.34 h1:udr83vQ7oz3kEOwlsU6NC6o08leJzSDQtls1wmXN/kM= +github.com/projectdiscovery/mapcidr v1.1.34/go.mod h1:1+1R6OkKSAKtWDXE9RvxXtXPoajXTYX0eiEdkqlhQqQ= +github.com/projectdiscovery/utils v0.0.85 h1:JpCVc9GJwJLNHy1MBPmAHJcE6rs7bRv72Trb3u84OHE= +github.com/projectdiscovery/utils v0.0.85/go.mod h1:ttiPgS2LmLFd+VRBUdgfLKMMdrF98zX7z5W+K71MX40= +github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA= +github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +r00t2.io/goutils v1.7.1 h1:Yzl9rxX1sR9WT0FcjK60qqOgBoFBOGHYKZVtReVLoQc= +r00t2.io/goutils v1.7.1/go.mod h1:9ObJI9S71wDLTOahwoOPs19DhZVYrOh4LEHmQ8SW4Lk= +r00t2.io/sysutils v1.1.1/go.mod h1:Wlfi1rrJpoKBOjWiYM9rw2FaiZqraD6VpXyiHgoDo/o= +r00t2.io/sysutils v1.12.0 h1:Ce3qUOyLixE1ZtFT/+SVwOT5kSkzg5+l1VloGeGugrU= +r00t2.io/sysutils v1.12.0/go.mod h1:bNTKNBk9MnUhj9coG9JBNicSi5FrtJHEM645um85pyw= diff --git a/netsplit/errs.go b/netsplit/errs.go new file mode 100644 index 0000000..690e5c9 --- /dev/null +++ b/netsplit/errs.go @@ -0,0 +1,12 @@ +package netsplit + +import "errors" + +var ( + ErrBadBoundary error = errors.New("subnet does not align on bit boundary") + ErrBadPrefix error = errors.New("prefix is invalid") + ErrBadPrefixLen error = errors.New("prefix length exceeds maximum possible for prefix's inet family") + ErrBadSplitter error = errors.New("invalid or unknown splitter when containing") + ErrBigPrefix error = errors.New("prefix length exceeds remaining network space") + ErrNoNetSpace error = errors.New("reached end of network space before splitting finished") +) diff --git a/netsplit/funcs.go b/netsplit/funcs.go new file mode 100644 index 0000000..609cc75 --- /dev/null +++ b/netsplit/funcs.go @@ -0,0 +1,356 @@ +package netsplit + +import ( + "encoding/json" + "encoding/xml" + "fmt" + "github.com/goccy/go-yaml" + "go4.org/netipx" + "net" + "net/netip" + "strings" +) + +/* +AddrExpand expands a netip.Addr's string format. +Like netip.Addr.StringExpanded() but for IPv4 too. +*/ +func AddrExpand(ip netip.Addr) (s string) { + + var sb *strings.Builder + + if ip.IsUnspecified() || !ip.IsValid() { + return + } + + if ip.Is6() { + s = ip.StringExpanded() + } else { + // IPv4 we have to do by hand. + sb = new(strings.Builder) + for idx, b := range ip.AsSlice() { + sb.WriteString(fmt.Sprintf("%03d", b)) + if idx != net.IPv4len-1 { + sb.WriteString(".") + } + } + s = sb.String() + } + + return +} + +/* +AddrCompress returns the shortest possible CIDR representation as a string from a netip.Prefix. +Note that IPv6 netip.Prefix.String() already does this automatically, as IPv6 has special condensing rules. +*/ +func AddrCompress(pfx *netip.Prefix) (s string) { + + var sl []string + var lastNonzero int + + if pfx == nil || !pfx.IsValid() || !pfx.Addr().IsValid() { + return + } + + if pfx.Addr().Is6() { + s = pfx.String() + return + } + + sl = strings.Split(pfx.Addr().String(), ".") + + for idx, oct := range sl { + if oct != "0" { + lastNonzero = idx + } + } + + s = fmt.Sprintf("%s/%d", strings.Join(sl[:lastNonzero+1], "."), pfx.Bits()) + + return +} + +/* +AddrFmt provides a string representation for an IP (as a netip.Addr). + +`f` is the string formatter to use (without the %). For IPv4, you generally want `d`, +for IPv6, you generally want `x`. + +`sep` indicates a character to insert every `every` bytes of the mask. +For IPv4, you probably want `.`, +for IPv6 there isn't really a standard representation; CIDR notation is preferred. +Thus for IPv6 you probably want to set sep as blank and/or set `every` to 0. + +`segSep` indicates a character sequence to use for segmenting the string. +Specify as an empty string and/or set `everySeg` to 0 to disable. + +`every` indicates how many bytes should pass before sep is inserted. +For IPv4, this should be 1. +For IPv6, there isn't really a standard indication but it's recommended to do 2. +Set as 0 or `sep` to an empty string to do no separation characters. + +`everySeg` indicates how many *seperations* should pass before segSep is inserted. +Set as 0 or `segSep` to an empty string to do no string segmentation. +*/ +func AddrFmt(ip netip.Addr, f, sep, segSep string, every, everySeg uint) (s string) { + + var numSegs int + var doSep bool = every > 0 + var fs string = "%" + f + var sb *strings.Builder = new(strings.Builder) + + if ip.IsUnspecified() || !ip.IsValid() { + return + } + for idx, b := range ip.AsSlice() { + if doSep && idx > 0 { + if idx%int(every) == 0 { + sb.WriteString(sep) + numSegs++ + } + if everySeg > 0 { + if numSegs >= int(everySeg) { + sb.WriteString(segSep) + numSegs = 0 + } + } + } + fmt.Fprintf(sb, fs, b) + } + + s = strings.TrimSpace(sb.String()) + + return +} + +/* +AddrInvert returns an inverted form of netip.Addr as another netip.Addr. + +Note that it doesn't really make sense to use this for IPv6. +*/ +func AddrInvert(ip netip.Addr) (inverted netip.Addr) { + + var b []byte + + if !ip.IsValid() { + return + } + + b = make([]byte, len([]byte(ip.AsSlice()))) + + for idx, i := range []byte(ip.AsSlice()) { + b[idx] = ^i + } + + inverted, _ = netip.AddrFromSlice(b) + + return +} + +// Contain takes the results of a NetSplitter and returns a StructuredResults. +func Contain(origPfx *netip.Prefix, nets []*netip.Prefix, remaining *netipx.IPSet, splitter NetSplitter) (s *StructuredResults, err error) { + + var rem []netip.Prefix + var sr StructuredResults = StructuredResults{ + Original: origPfx, + } + + if origPfx == nil { + return + } + + if origPfx.Addr() != origPfx.Masked().Addr() { + sr.Canonical = new(netip.Prefix) + *sr.Canonical = origPfx.Masked() + sr.HostAddr = new(netip.Addr) + *sr.HostAddr = origPfx.Addr() + } + + if splitter != nil { + sr.Splitter = new(SplitOpts) + switch t := splitter.(type) { + case *CIDRSplitter: + sr.Splitter.CIDR = t + case *HostSplitter: + sr.Splitter.Host = t + case *SubnetSplitter: + sr.Splitter.Subnet = t + case *VLSMSplitter: + sr.Splitter.VLSM = t + default: + err = ErrBadSplitter + return + } + } + + if nets != nil { + sr.Allocated = make([]*ContainedResult, len(nets)) + for idx, n := range nets { + sr.Allocated[idx] = &ContainedResult{ + Network: n, + } + } + } + + if remaining != nil { + rem = remaining.Prefixes() + sr.Unallocated = make([]*ContainedResult, len(rem)) + for idx, i := range rem { + sr.Unallocated[idx] = &ContainedResult{ + Network: new(netip.Prefix), + } + *sr.Unallocated[idx].Network = i + } + } + + s = &sr + + return +} + +/* +MaskExpand expands a net.IPMask's string format. +Like AddrExpand but for netmasks. +*/ +func MaskExpand(mask net.IPMask, isIpv6 bool) (s string) { + + var sb *strings.Builder + + // IPv6 is always expanded in string format, but not split out. + if isIpv6 { + s = MaskFmt(mask, "02x", ":", "", 2, 0) + return + } + + sb = new(strings.Builder) + for idx, b := range mask { + sb.WriteString(fmt.Sprintf("%03d", b)) + if idx != net.IPv4len-1 { + sb.WriteString(".") + } + } + s = sb.String() + + return +} + +/* +MaskFmt provides a string representation for a netmask (as a net.IPMask). + +Its parameters hold the same significance as in AddrFmt. +*/ +func MaskFmt(mask net.IPMask, f, sep, segSep string, every, everySeg uint) (s string) { + + var numSegs int + var doSep bool = every > 0 + var fs string = "%" + f + var sb *strings.Builder = new(strings.Builder) + + if mask == nil || len(mask) == 0 { + return + } + for idx, b := range mask { + if doSep && idx > 0 { + if idx%int(every) == 0 { + sb.WriteString(sep) + numSegs++ + } + if everySeg > 0 { + if numSegs >= int(everySeg) { + sb.WriteString(segSep) + numSegs = 0 + } + } + } + fmt.Fprintf(sb, fs, b) + } + + s = strings.TrimSpace(sb.String()) + + return +} + +/* +MaskInvert returns an inverted form of net.IPMask as another net.IPMask. + +Note that it doesn't really make sense to use this for IPv6. +*/ +func MaskInvert(mask net.IPMask) (inverted net.IPMask) { + + var b []byte + + b = make([]byte, len([]byte(mask))) + + for idx, i := range []byte(mask) { + b[idx] = ^i + } + + inverted = net.IPMask(b) + + return +} + +// Parse parses b for JSON/XML/YAML and tries to return a StructuredResults from it. +func Parse(b []byte) (s *StructuredResults, err error) { + + if b == nil { + return + } + + if err = json.Unmarshal(b, &s); err != nil { + if err = xml.Unmarshal(b, &s); err != nil { + if err = yaml.Unmarshal(b, &s); err != nil { + return + } else { + return + } + } else { + return + } + } + + return +} + +/* +ValidateSizes ensures that none of the prefix lengths in sizes exceeds the maximum possible in pfx. +No-ops with nil error if pfx is nil, sizes is nil, or sizes is empty. + +err is also nil if validation succeeds. +If validation fails on a prefix length size, the error will be a SplitErr +with only Wrapped and RequestedPrefixLen fields populated *for the first failing size only*. +*/ +func ValidateSizes(pfx *net.IPNet, sizes ...uint8) (err error) { + + var ok bool + var addr netip.Addr + var familyMax uint8 + + if pfx == nil || sizes == nil || len(sizes) == 0 { + return + } + if addr, ok = netipx.FromStdIP(pfx.IP); !ok { + err = ErrBadPrefix + return + } + if addr.Is4() { + familyMax = 32 + } else { + familyMax = 128 + } + for _, size := range sizes { + if size > familyMax { + err = &SplitErr{ + Wrapped: ErrBadPrefixLen, + Nets: nil, + Remaining: nil, + LastSubnet: nil, + RequestedPrefixLen: size, + } + return + } + } + + return +} diff --git a/netsplit/funcs_basesplitter.go b/netsplit/funcs_basesplitter.go new file mode 100644 index 0000000..49b6692 --- /dev/null +++ b/netsplit/funcs_basesplitter.go @@ -0,0 +1,51 @@ +package netsplit + +import ( + "net" +) + +// SetParent sets the net.IPNet for a Splitter. +func (b *BaseSplitter) SetParent(pfx net.IPNet) { + + b.network = &pfx + +} + +// MarshalText lets a BaseSplitter conform to an encoding.TextMarshaler. +func (b *BaseSplitter) MarshalText() (text []byte, err error) { + + if b == nil || b.network == nil { + return + } + + text = []byte(b.network.String()) + + return +} + +/* +UnmarshalText lets a BaseSplitter conform to an encoding.TextUnmarshaler. + +This is a potentially lossy operation! Any host bits set in the prefix's address will be lost. +They will not be set if the output was originally generated by `subnetter`. +*/ +func (b *BaseSplitter) UnmarshalText(text []byte) (err error) { + + var s string + var n *net.IPNet + + if text == nil { + return + } + s = string(text) + + if _, n, err = net.ParseCIDR(s); err != nil { + return + } + + *b = BaseSplitter{ + network: n, + } + + return +} diff --git a/netsplit/funcs_cidrsplitter.go b/netsplit/funcs_cidrsplitter.go new file mode 100644 index 0000000..0009187 --- /dev/null +++ b/netsplit/funcs_cidrsplitter.go @@ -0,0 +1,14 @@ +package netsplit + +import ( + "go4.org/netipx" + "net/netip" +) + +// Split splits the network defined in a CIDRSplitter alongside its configuration and performs the subnetting. +func (c *CIDRSplitter) Split() (nets []*netip.Prefix, remaining *netipx.IPSet, err error) { + + // TODO + + return +} diff --git a/netsplit/funcs_hostsplitter.go b/netsplit/funcs_hostsplitter.go new file mode 100644 index 0000000..40f2d08 --- /dev/null +++ b/netsplit/funcs_hostsplitter.go @@ -0,0 +1,14 @@ +package netsplit + +import ( + "go4.org/netipx" + "net/netip" +) + +// Split splits the network defined in a HostSplitter alongside its configuration and performs the subnetting. +func (h *HostSplitter) Split() (nets []*netip.Prefix, remaining *netipx.IPSet, err error) { + + // TODO + + return +} diff --git a/netsplit/funcs_spliterr.go b/netsplit/funcs_spliterr.go new file mode 100644 index 0000000..eeb7d0b --- /dev/null +++ b/netsplit/funcs_spliterr.go @@ -0,0 +1,14 @@ +package netsplit + +// Error makes a SplitErr conform to error. +func (s *SplitErr) Error() (errStr string) { + + if s == nil { + errStr = "(error unknown; nil error)" + return + } + + errStr = s.Wrapped.Error() + + return +} diff --git a/netsplit/funcs_structuredresults.go b/netsplit/funcs_structuredresults.go new file mode 100644 index 0000000..0f8da06 --- /dev/null +++ b/netsplit/funcs_structuredresults.go @@ -0,0 +1,73 @@ +package netsplit + +import ( + "go4.org/netipx" + "net/netip" +) + +/* +GetSplitter returns the first (should be *only*) non-nill NetSplitter on a StructuredResults. + +If none is found, splitter will be nil but no panic/error will occur. +*/ +func (s *StructuredResults) GetSplitter() (splitter NetSplitter) { + + if s == nil || s.Splitter == nil { + return + } + + /* + TODO(?): It'd be nice if I could just reflect .Interface() this + to a NetSplitter but I think I'd then have to typeswitch + into the real type regardless, which is lame. + */ + + if s.Splitter.CIDR != nil { + splitter = s.Splitter.CIDR + } else if s.Splitter.Host != nil { + splitter = s.Splitter.Host + } else if s.Splitter.Subnet != nil { + splitter = s.Splitter.Subnet + } else if s.Splitter.VLSM != nil { + splitter = s.Splitter.VLSM + } + + return +} + +/* +Uncontain returns a set of values that "unstructure" a StructuredResults. + +(Essentially the opposite procedure of Contain().) +*/ +func (s *StructuredResults) Uncontain() (origPfx *netip.Prefix, nets []*netip.Prefix, remaining *netipx.IPSet, splitter NetSplitter, err error) { + + var ipsb *netipx.IPSetBuilder + + if s == nil { + return + } + + origPfx = s.Original + if s.Allocated != nil { + nets = make([]*netip.Prefix, len(s.Allocated)) + for idx, i := range s.Allocated { + nets[idx] = i.Network + } + } + if s.Unallocated != nil { + ipsb = new(netipx.IPSetBuilder) + for _, i := range s.Unallocated { + if i.Network != nil { + ipsb.AddPrefix(*i.Network) + } + } + if remaining, err = ipsb.IPSet(); err != nil { + return + } + } + + splitter = s.GetSplitter() + + return +} diff --git a/netsplit/funcs_subnetsplitter.go b/netsplit/funcs_subnetsplitter.go new file mode 100644 index 0000000..68507db --- /dev/null +++ b/netsplit/funcs_subnetsplitter.go @@ -0,0 +1,14 @@ +package netsplit + +import ( + "go4.org/netipx" + "net/netip" +) + +// Split splits the network defined in a SubnetSplitter alongside its configuration and performs the subnetting. +func (s *SubnetSplitter) Split() (nets []*netip.Prefix, remaining *netipx.IPSet, err error) { + + // TODO + + return +} diff --git a/netsplit/funcs_vlsmsplitter.go b/netsplit/funcs_vlsmsplitter.go new file mode 100644 index 0000000..5a344fa --- /dev/null +++ b/netsplit/funcs_vlsmsplitter.go @@ -0,0 +1,97 @@ +package netsplit + +import ( + "go4.org/netipx" + "net/netip" + "sort" +) + +// Split splits the network defined in a VLSMSplitter alongside its configuration and performs the subnetting. +func (v *VLSMSplitter) Split() (nets []*netip.Prefix, remaining *netipx.IPSet, err error) { + + var ok bool + var pfxLen int + var pfxLen8 uint8 + var base netip.Prefix + var sub netip.Prefix + var subPtr *netip.Prefix + var ipsb *netipx.IPSetBuilder = new(netipx.IPSetBuilder) + + if err = ValidateSizes(v.network, v.PrefixLengths...); err != nil { + return + } + + /* + I thought about using the following: + + * https://pkg.go.dev/net/netip + * https://pkg.go.dev/github.com/sacloud/packages-go/cidr + * https://pkg.go.dev/github.com/projectdiscovery/mapcidr + * https://pkg.go.dev/github.com/EvilSuperstars/go-cidrman + + But, as I expected, netipx ftw again. + */ + + if v == nil || v.PrefixLengths == nil || len(v.PrefixLengths) == 0 || v.BaseSplitter == nil || v.network == nil { + return + } + + sort.SliceStable( + v.PrefixLengths, + func(i, j int) (isBefore bool) { // We use a reverse sorting by default so we get larger prefixes at the beginning. + if v.Ascending { + isBefore = v.PrefixLengths[i] > v.PrefixLengths[j] + } else { + isBefore = v.PrefixLengths[i] < v.PrefixLengths[j] + } + return + }, + ) + + pfxLen, _ = v.network.Mask.Size() + pfxLen8 = uint8(pfxLen) + + if base, ok = netipx.FromStdIPNet(v.network); !ok { + err = ErrBadBoundary + return + } + if !base.IsValid() { + err = ErrBadBoundary + return + } + + ipsb.AddPrefix(base) + if remaining, err = ipsb.IPSet(); err != nil { + return + } + + for _, size := range v.PrefixLengths { + if size < pfxLen8 { + err = &SplitErr{ + Wrapped: ErrBigPrefix, + Nets: nets, + Remaining: remaining, + LastSubnet: &sub, + RequestedPrefixLen: size, + } + return + } + + if sub, remaining, ok = remaining.RemoveFreePrefix(size); !ok { + err = &SplitErr{ + Wrapped: ErrNoNetSpace, + Nets: nets, + Remaining: remaining, + LastSubnet: &sub, + RequestedPrefixLen: size, + } + return + } + + subPtr = new(netip.Prefix) + *subPtr = sub + nets = append(nets, subPtr) + } + + return +} diff --git a/netsplit/types.go b/netsplit/types.go new file mode 100644 index 0000000..d3e6836 --- /dev/null +++ b/netsplit/types.go @@ -0,0 +1,112 @@ +package netsplit + +import ( + "encoding/xml" + "go4.org/netipx" + "net" + "net/netip" +) + +// SplitErr is used to wrap an error with context surrounding when/how that error was encountered. +type SplitErr struct { + // Wrapped is the originating error during a split (or other parsing operation). + Wrapped error + // Nets are the subnets parsed out/collected so far. + Nets []*netip.Prefix + // Remaining is an IPSet of subnets/addresses that haven't been, or were unable to be, split out. + Remaining *netipx.IPSet + // LastSubnet is the most recently split out subnet. + LastSubnet *netip.Prefix + // RequestedPrefixLen is the network prefix length size, if relevant, that was attempted to be split out of Remaining. + RequestedPrefixLen uint8 +} + +// NetSplitter is used to split a network into multiple nets (and any remaining prefixes/addresses that didn't fit). +type NetSplitter interface { + SetParent(pfx net.IPNet) + Split() (nets []*netip.Prefix, remaining *netipx.IPSet, err error) +} + +// BaseSplitter is used to encapsulate the "parent" network to be split. +type BaseSplitter struct { + network *net.IPNet +} + +/* +CIDRSplitter is used to split a network based on a fixed prefix size. +It attemps to split the network into as many networks of size PrefixLength as cleanly as possible. +*/ +type CIDRSplitter struct { + // PrefixLength specifies the CIDR/prefix length of the subnets to split out. + PrefixLength uint8 `json:"prefix" xml:"prefix,attr" yaml:"network Prefix Length"` + *BaseSplitter `json:"net" xml:"net,omitempty" yaml:"network,omitempty"` +} + +/* +HostSplitter is used to split a network based on total number of hosts. +It attempts to evenly distribute addresses amoungs subnets. +*/ +type HostSplitter struct { + // NumberHosts is the number of hosts to be placed in each subnet to split out. + NumberHosts uint `json:"hosts" xml:"hosts,attr" yaml:"Number of Hosts Per Subnet"` + *BaseSplitter `json:"net" xml:"net,omitempty" yaml:"network,omitempty"` +} + +/* +SubnetSplitter is used to split a network into a specific number of subnets of equal prefix lengths +as cleanly as poossible. +*/ +type SubnetSplitter struct { + // NumberSubnets indicates the number of subnets to split the network into. + NumberSubnets uint `json:"nets" xml:"nets,attr" yaml:"Number of Target Subnets"` + *BaseSplitter `json:"net" xml:"net,omitempty" yaml:"network,omitempty"` +} + +/* +VLSMSplitter is used to split a network via VLSM (Variable-Length Subnet Masks) into multiple PrefixLengths, +in which there are multiple desired subnets of varying lengths. +*/ +type VLSMSplitter struct { + /* + Ascending, if true, will subnet smaller networks/larger prefixes near the beginning + (ascending order) instead of larger networks/smaller prefixes (descending order). + You almost assuredly do not want to do this. + */ + Ascending bool + // PrefixLengths contains the prefix lengths of each subnet to split out from the network. + PrefixLengths []uint8 `json:"prefixes" xml:"prefixes>prefix" yaml:"Prefix Lengths"` + *BaseSplitter `json:"net" xml:"net,omitempty" yaml:"network,omitempty"` +} + +/* +StructuredResults is used for serializing prefixes into a structured/defined data format. +*/ +type StructuredResults struct { + XMLName xml.Name `json:"-" xml:"results" yaml:"-"` + // Original is the provided parent network/prefix. + Original *netip.Prefix `json:"orig" xml:"orig,attr,omitempty" yaml:"Original/Parent network"` + // HostAddr is nil if Original falls on a network prefix boundary, otherwise it is the specified host address. + HostAddr *netip.Addr `json:"host" xml:"host,attr,omitempty" yaml:"Host Address,omitempty"` + // Canonical is the canonical network of Original (e.g. with host bits masked out). It is nil if Original.Addr() falls on the (lower) boundary. + Canonical *netip.Prefix `json:"masked" xml:"masked,attr,omitempty" yaml:"Bound Original/Parent network"` + // Splitter contains the spplitter and its options used to split the network. + Splitter *SplitOpts `json:"splitter" xml:"splitter,omitempty" yaml:"Splitter,omitempty"` + // Allocated contains valid subnet(s) in Original per the user-specified subnetting rules. + Allocated []*ContainedResult `json:"subnets" xml:"subnets>subnet,omitempty" yaml:"Subnets"` + // Unallocated contains subnets from Original that did not meet the splitting criteria or were left over from the split operation. + Unallocated []*ContainedResult `json:"remaining" xml:"remaining>subnet,omitempty" yaml:"Remaining/Unallocated/Left Over,omitempty"` +} + +type SplitOpts struct { + XMLName xml.Name `json:"-" xml:"splitter" yaml:"-"` + CIDR *CIDRSplitter `json:"cidr,omitempty" xml:"cidr,omitempty" yaml:"CIDR Splitter,omitempty"` + Host *HostSplitter `json:"host,omitempty" xml:"host,omitempty" yaml:"Host Splitter,omitempty"` + Subnet *SubnetSplitter `json:"subnet,omitempty" xml:"subnet,omitempty" yaml:"Subnet Splitter,omitempty"` + VLSM *VLSMSplitter `json:"vlsm,omitempty" xml:"vlsm,omitempty" yaml:"VLSM Splitter,omitempty"` +} + +// ContainedResult is a single Network (either an allocated subnet or a remaining block). +type ContainedResult struct { + XMLName xml.Name `json:"-" yaml:"-" xml:"subnet"` + Network *netip.Prefix `json:"net" xml:"net,attr,omitempty" yaml:"network,omitempty"` +}