All checks were successful
release-tag / release-image (push) Successful in 1m34s
501 lines
14 KiB
Go
501 lines
14 KiB
Go
package main
|
||
|
||
import (
|
||
"encoding/csv"
|
||
"encoding/json"
|
||
"errors"
|
||
"flag"
|
||
"fmt"
|
||
"html/template"
|
||
"net"
|
||
"net/http"
|
||
"os"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
)
|
||
|
||
// ===== Datenmodelle =====
|
||
type Vlan64 struct {
|
||
VLAN int `json:"vlan,omitempty"` // nur gesetzt im gefilterten (VLAN) Modus
|
||
Hextet string `json:"hextet,omitempty"` // 4. Hextet als 4-stelliger String, z.B. "1110"
|
||
Prefix64 string `json:"prefix64"`
|
||
// interne Sortierhilfe:
|
||
hextetNum uint16 `json:"-"`
|
||
}
|
||
|
||
type Sub58 struct {
|
||
Prefix58 string `json:"prefix58"`
|
||
Children []Vlan64 `json:"children"`
|
||
// interne Sortierhilfe:
|
||
num58 uint16 `json:"-"`
|
||
}
|
||
|
||
type Sub56 struct {
|
||
Prefix56 string `json:"prefix56"`
|
||
Children []Sub58 `json:"children"`
|
||
// interne Sortierhilfe:
|
||
num56 uint16 `json:"-"`
|
||
}
|
||
|
||
type Export struct {
|
||
Input48 string `json:"input48"`
|
||
Items []Sub56 `json:"items"`
|
||
// Zusatz für UI
|
||
Total56 int `json:"-"`
|
||
Total58 int `json:"-"`
|
||
Total64 int `json:"-"`
|
||
}
|
||
|
||
// ===== Flags =====
|
||
var (
|
||
flagPrefix = flag.String("prefix", "", "ULA-/48 Präfix, z.B. fd12:3456:789a::/48")
|
||
flagFormat = flag.String("format", "text", "Ausgabeformat: text|json|csv")
|
||
flagOut = flag.String("out", "", "Dateipfad für Export (leer = stdout)")
|
||
flagWebAddr = flag.String("web", "", "Webserver starten an Adresse, z.B. :8080 (leer = aus)")
|
||
flagAll = flag.Bool("all", false, "Alle Netze (unabhängig von Filter) ausgeben (nur CLI). Im Web als Checkbox verfügbar.")
|
||
)
|
||
|
||
// ===== main =====
|
||
func main() {
|
||
flag.Parse()
|
||
|
||
// Webmodus?
|
||
if *flagWebAddr != "" {
|
||
startWeb(*flagWebAddr)
|
||
return
|
||
}
|
||
|
||
// CLI-Modus
|
||
if *flagPrefix == "" {
|
||
fail("Fehler: bitte mit -prefix ein ULA-/48 angeben, z.B. -prefix fd12:3456:789a::/48 (oder -web :8080 für Webinterface)")
|
||
}
|
||
result, err := compute(*flagPrefix, *flagAll)
|
||
if err != nil {
|
||
fail(err.Error())
|
||
}
|
||
if len(result.Items) == 0 {
|
||
fail("keine passenden Netze gefunden (Filter zu eng?)")
|
||
}
|
||
|
||
var out *os.File = os.Stdout
|
||
if *flagOut != "" {
|
||
f, err := os.Create(*flagOut)
|
||
if err != nil {
|
||
fail("konnte Datei nicht erstellen: " + err.Error())
|
||
}
|
||
defer f.Close()
|
||
out = f
|
||
}
|
||
|
||
switch *flagFormat {
|
||
case "json":
|
||
enc := json.NewEncoder(out)
|
||
enc.SetIndent("", " ")
|
||
_ = enc.Encode(result)
|
||
case "csv":
|
||
w := csv.NewWriter(out)
|
||
_ = w.Write([]string{"input48", "prefix56", "prefix58", "vlan", "hextet", "prefix64"})
|
||
for _, s56 := range result.Items {
|
||
for _, s58 := range s56.Children {
|
||
for _, v := range s58.Children {
|
||
_ = w.Write([]string{
|
||
result.Input48, s56.Prefix56, s58.Prefix58,
|
||
intOrEmpty(v.VLAN), v.Hextet, v.Prefix64,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
w.Flush()
|
||
if err := w.Error(); err != nil {
|
||
fail("CSV-Export fehlgeschlagen: " + err.Error())
|
||
}
|
||
case "text":
|
||
fmt.Fprintf(out, "# Eingabe: %s\n", result.Input48)
|
||
if !*flagAll {
|
||
fmt.Fprintln(out, "# Sortierung: /56 numerisch → /58 numerisch (nur 0–9) → VLAN 1–4096 numerisch (4. Hextet = Dezimalzahl)")
|
||
} else {
|
||
fmt.Fprintln(out, "# Sortierung: /56 numerisch → /58 numerisch → /64 numerisch (alle Netze)")
|
||
}
|
||
fmt.Fprintln(out)
|
||
for _, s56 := range result.Items {
|
||
fmt.Fprintln(out, s56.Prefix56)
|
||
for _, s58 := range s56.Children {
|
||
fmt.Fprintf(out, " %s\n", s58.Prefix58)
|
||
for _, v := range s58.Children {
|
||
if !*flagAll {
|
||
fmt.Fprintf(out, " - VLAN %-4d %s\n", v.VLAN, v.Prefix64)
|
||
} else {
|
||
fmt.Fprintf(out, " - %s\n", v.Prefix64)
|
||
}
|
||
}
|
||
}
|
||
fmt.Fprintln(out)
|
||
}
|
||
default:
|
||
fail("unbekanntes -format (erlaubt: text|json|csv)")
|
||
}
|
||
}
|
||
|
||
// ===== Kernlogik =====
|
||
|
||
// compute erzeugt die Hierarchie. includeAll = true zeigt ALLE /58 und deren 64 /64-Kinder.
|
||
// includeAll = false wendet deine Filter an: /58 nur wenn 4. Hextet ausschließlich 0–9;
|
||
// /64 nur VLAN 1–4096, deren 4. Hextet exakt dezimal ist (nur 0–9).
|
||
func compute(prefix48 string, includeAll bool) (Export, error) {
|
||
baseIP, baseNet, err := net.ParseCIDR(prefix48)
|
||
if err != nil {
|
||
return Export{}, err
|
||
}
|
||
ones, bits := baseNet.Mask.Size()
|
||
if bits != 128 || ones != 48 {
|
||
return Export{}, errors.New("nur /48 Präfixe sind erlaubt")
|
||
}
|
||
if !isULA(baseIP) {
|
||
return Export{}, errors.New("nur ULA (fc00::/7) sind erlaubt")
|
||
}
|
||
|
||
// Basisadresse normalisieren (ab 4. Hextet = 0)
|
||
base := baseIP.To16()
|
||
for i := 6; i < 16; i++ {
|
||
base[i] = 0x00
|
||
}
|
||
|
||
var result Export
|
||
result.Input48 = prefix48
|
||
result.Items = make([]Sub56, 0, 256)
|
||
|
||
// VLAN-Liste (gefilterter Modus)
|
||
type vlanItem struct {
|
||
num int
|
||
hextet uint16
|
||
str string
|
||
}
|
||
var vlanList []vlanItem
|
||
if !includeAll {
|
||
vlanList = make([]vlanItem, 0, 4096)
|
||
for vlan := 1; vlan <= 4096; vlan++ {
|
||
s := strconv.Itoa(vlan) // "1".."4096" -> nur Ziffern
|
||
val, err := strconv.ParseUint(s, 16, 16)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
h := uint16(val)
|
||
if hextetAllDigits(h) {
|
||
vlanList = append(vlanList, vlanItem{
|
||
num: vlan,
|
||
hextet: h,
|
||
str: fmt.Sprintf("%04x", val),
|
||
})
|
||
}
|
||
}
|
||
sort.Slice(vlanList, func(i, j int) bool { return vlanList[i].num < vlanList[j].num })
|
||
}
|
||
|
||
total56, total58, total64 := 0, 0, 0
|
||
|
||
// /48 → /56
|
||
for hb := 0; hb <= 0xFF; hb++ {
|
||
hextet56 := uint16(hb) << 8 // 0xXX00
|
||
p56 := formatCIDR(withFourthHextet(base, hextet56), 56)
|
||
|
||
sub56 := Sub56{
|
||
Prefix56: p56,
|
||
num56: hextet56,
|
||
}
|
||
|
||
// /56 → /58 (0xXX00, 0xXX40, 0xXX80, 0xXXC0)
|
||
for two := 0; two < 4; two++ {
|
||
lbTop := two << 6
|
||
hextet58 := hextet56 | uint16(lbTop)
|
||
|
||
// Filter: im gefilterten Modus müssen /58 komplett nur aus 0–9 bestehen.
|
||
if !includeAll && !hextetAllDigits(hextet58) {
|
||
continue
|
||
}
|
||
|
||
children := make([]Vlan64, 0, 64)
|
||
|
||
if includeAll {
|
||
// ALLE 64 /64-Kinder (untere 6 Bits variieren)
|
||
for child := 0; child < 64; child++ {
|
||
h := hextet58 | uint16(child)
|
||
children = append(children, Vlan64{
|
||
Prefix64: formatCIDR(withFourthHextet(base, h), 64),
|
||
hextetNum: h,
|
||
})
|
||
}
|
||
// numerisch sortieren nach hextet
|
||
sort.Slice(children, func(i, j int) bool { return children[i].hextetNum < children[j].hextetNum })
|
||
} else {
|
||
// Nur VLAN-/64, deren 4. Hextet = Dezimalzahl (1..4096) ist
|
||
for _, v := range vlanList {
|
||
if (v.hextet & 0xFFC0) == hextet58 {
|
||
children = append(children, Vlan64{
|
||
VLAN: v.num,
|
||
Hextet: v.str,
|
||
Prefix64: formatCIDR(withFourthHextet(base, v.hextet), 64),
|
||
hextetNum: v.hextet,
|
||
})
|
||
}
|
||
}
|
||
// VLANs numerisch sortiert (v.num bereits aufsteigend)
|
||
}
|
||
|
||
if len(children) > 0 {
|
||
total64 += len(children)
|
||
sub56.Children = append(sub56.Children, Sub58{
|
||
Prefix58: formatCIDR(withFourthHextet(base, hextet58), 58),
|
||
Children: children,
|
||
num58: hextet58,
|
||
})
|
||
}
|
||
}
|
||
|
||
if len(sub56.Children) > 0 {
|
||
// /58 numerisch sortieren
|
||
sort.Slice(sub56.Children, func(i, j int) bool { return sub56.Children[i].num58 < sub56.Children[j].num58 })
|
||
result.Items = append(result.Items, sub56)
|
||
total56++
|
||
total58 += len(sub56.Children)
|
||
}
|
||
}
|
||
|
||
// /56 numerisch sortieren global
|
||
sort.Slice(result.Items, func(i, j int) bool { return result.Items[i].num56 < result.Items[j].num56 })
|
||
result.Total56, result.Total58, result.Total64 = total56, total58, total64
|
||
return result, nil
|
||
}
|
||
|
||
// ===== Helpers =====
|
||
func isULA(ip net.IP) bool {
|
||
ip16 := ip.To16()
|
||
if ip16 == nil {
|
||
return false
|
||
}
|
||
return ip16[0]&0xFE == 0xFC // fc00::/7
|
||
}
|
||
|
||
func withFourthHextet(base []byte, hextet uint16) net.IP {
|
||
ip := make([]byte, 16)
|
||
copy(ip, base)
|
||
ip[6] = byte(hextet >> 8)
|
||
ip[7] = byte(hextet)
|
||
return net.IP(ip)
|
||
}
|
||
|
||
func formatCIDR(ip net.IP, mask int) string {
|
||
return fmt.Sprintf("%s/%d", ip.String(), mask)
|
||
}
|
||
|
||
// true, wenn alle 4 Nibbles ∈ {0..9}
|
||
func hextetAllDigits(h uint16) bool {
|
||
for shift := 0; shift <= 12; shift += 4 {
|
||
if ((h >> uint(shift)) & 0xF) > 9 {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func fail(msg string) {
|
||
fmt.Fprintln(os.Stderr, "Fehler:", msg)
|
||
os.Exit(1)
|
||
}
|
||
|
||
func intOrEmpty(v int) string {
|
||
if v == 0 {
|
||
return ""
|
||
}
|
||
return strconv.Itoa(v)
|
||
}
|
||
|
||
// ===== Webserver =====
|
||
var pageTmpl = template.Must(template.New("page").Parse(`
|
||
<!doctype html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="utf-8"/>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||
<title>IPv6 Subnet-Calculator (/48 → /56 → /58 → /64)</title>
|
||
<style>
|
||
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Arial,sans-serif;line-height:1.45;margin:2rem;max-width:1200px}
|
||
header{margin-bottom:1rem}
|
||
form{display:flex;gap:0.75rem;flex-wrap:wrap;align-items:center;margin-bottom:1rem}
|
||
input[type=text]{padding:0.5rem;border:1px solid #ddd;border-radius:8px;min-width:320px}
|
||
button{padding:0.6rem 0.9rem;border-radius:8px;border:1px solid #ccc;background:#f7f7f7;cursor:pointer}
|
||
.card{border:1px solid #e5e5e5;border-radius:12px;padding:1rem;margin-bottom:1rem;background:#fff}
|
||
.meta{color:#666;font-size:0.9rem;margin-top:0.25rem}
|
||
details{margin:0.25rem 0 0.25rem 0.5rem}
|
||
summary{cursor:pointer;font-weight:600}
|
||
code{background:#f5f5f5;padding:0.1rem 0.3rem;border-radius:6px}
|
||
.small{font-size:0.9rem;color:#666}
|
||
.badge{display:inline-block;padding:0.15rem 0.4rem;border:1px solid #ddd;border-radius:6px;margin-left:0.25rem;background:#fafafa;font-size:0.85rem}
|
||
.row64{margin-left:2rem}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>IPv6 Subnet-Calculator</h1>
|
||
<div class="small">/48 → /56 → /58 → /64 · numerisch sortiert</div>
|
||
</header>
|
||
|
||
<form method="GET" action="/">
|
||
<label for="prefix">ULA-/48:</label>
|
||
<input id="prefix" name="prefix" type="text" placeholder="z.B. fd12:3456:789a::/48" value="{{.Input48}}">
|
||
<label><input type="checkbox" name="all" value="1" {{if .ShowAll}}checked{{end}}> Alle Netze anzeigen</label>
|
||
<button type="submit">Berechnen</button>
|
||
{{if .HasResult}}
|
||
<a class="badge" href="/download?format=json&prefix={{.Input48URL}}&all={{.AllParam}}">JSON herunterladen</a>
|
||
<a class="badge" href="/download?format=csv&prefix={{.Input48URL}}&all={{.AllParam}}">CSV herunterladen</a>
|
||
{{end}}
|
||
</form>
|
||
|
||
{{if .Error}}
|
||
<div class="card" style="border-color:#f2c9c9;background:#fff7f7">
|
||
<strong>Fehler:</strong> {{.Error}}
|
||
</div>
|
||
{{end}}
|
||
|
||
{{if .HasResult}}
|
||
<div class="card">
|
||
<div><strong>Eingabe:</strong> <code>{{.Result.Input48}}</code>
|
||
{{if .ShowAll}}<span class="badge">Alle Netze</span>{{else}}<span class="badge">Gefiltert (Ziffern/VLAN)</span>{{end}}
|
||
</div>
|
||
<div class="meta">
|
||
{{.Result.Total56}} × /56 · {{.Result.Total58}} × /58 · {{.Result.Total64}} × /64
|
||
</div>
|
||
</div>
|
||
|
||
{{range .Result.Items}}
|
||
<details>
|
||
<summary><code>{{.Prefix56}}</code> — {{len .Children}} × /58</summary>
|
||
{{range .Children}}
|
||
<details>
|
||
<summary><code>{{.Prefix58}}</code> — {{len .Children}} × /64</summary>
|
||
<div class="row64">
|
||
{{range .Children}}
|
||
{{if .VLAN}}
|
||
<div>VLAN {{.VLAN}} — <code>{{.Prefix64}}</code></div>
|
||
{{else}}
|
||
<div><code>{{.Prefix64}}</code></div>
|
||
{{end}}
|
||
{{end}}
|
||
</div>
|
||
</details>
|
||
{{end}}
|
||
</details>
|
||
{{end}}
|
||
{{else}}
|
||
<div class="card">Gib oben ein ULA-/48 ein und starte die Berechnung.</div>
|
||
{{end}}
|
||
|
||
<footer class="small" style="margin-top:2rem">
|
||
Hinweis: „Alle Netze“ zeigt sämtliche /58 und alle 64 /64-Kinder je /58 an. Ohne Häkchen wird gefiltert:
|
||
/58 nur, wenn das gesamte 4. Hextet ausschließlich Ziffern (0–9) enthält; /64 nur VLAN 1–4096, wobei das 4. Hextet exakt die Dezimalzahl (nur Ziffern) ist.
|
||
</footer>
|
||
</body>
|
||
</html>
|
||
`))
|
||
|
||
type pageData struct {
|
||
Input48 string
|
||
Input48URL string
|
||
ShowAll bool
|
||
HasResult bool
|
||
Error string
|
||
Result Export
|
||
AllParam string
|
||
}
|
||
|
||
func startWeb(addr string) {
|
||
http.HandleFunc("/", handleIndex)
|
||
http.HandleFunc("/download", handleDownload)
|
||
fmt.Println("Webinterface läuft auf", addr)
|
||
if err := http.ListenAndServe(addr, nil); err != nil {
|
||
fail(err.Error())
|
||
}
|
||
}
|
||
|
||
func handleIndex(w http.ResponseWriter, r *http.Request) {
|
||
q := r.URL.Query()
|
||
prefix := strings.TrimSpace(q.Get("prefix"))
|
||
showAll := q.Get("all") == "1" || strings.EqualFold(q.Get("all"), "true")
|
||
if prefix == "" {
|
||
prefix = "fd09:1:2::/48"
|
||
}
|
||
data := pageData{
|
||
Input48: prefix,
|
||
Input48URL: template.URLQueryEscaper(prefix),
|
||
ShowAll: showAll,
|
||
AllParam: boolTo01(showAll),
|
||
}
|
||
|
||
if prefix != "" {
|
||
res, err := compute(prefix, showAll)
|
||
if err != nil {
|
||
data.Error = err.Error()
|
||
} else {
|
||
data.Result = res
|
||
data.HasResult = true
|
||
}
|
||
}
|
||
|
||
if err := pageTmpl.Execute(w, data); err != nil {
|
||
http.Error(w, "Template-Fehler: "+err.Error(), 500)
|
||
}
|
||
}
|
||
|
||
func handleDownload(w http.ResponseWriter, r *http.Request) {
|
||
q := r.URL.Query()
|
||
prefix := strings.TrimSpace(q.Get("prefix"))
|
||
showAll := q.Get("all") == "1" || strings.EqualFold(q.Get("all"), "true")
|
||
format := strings.ToLower(q.Get("format"))
|
||
if format == "" {
|
||
format = "json"
|
||
}
|
||
|
||
if prefix == "" {
|
||
http.Error(w, "prefix fehlt", http.StatusBadRequest)
|
||
return
|
||
}
|
||
res, err := compute(prefix, showAll)
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
switch format {
|
||
case "json":
|
||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
enc := json.NewEncoder(w)
|
||
enc.SetIndent("", " ")
|
||
_ = enc.Encode(res)
|
||
case "csv":
|
||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||
w.Header().Set("Content-Disposition", "attachment; filename=\"subnets.csv\"")
|
||
cw := csv.NewWriter(w)
|
||
_ = cw.Write([]string{"input48", "prefix56", "prefix58", "vlan", "hextet", "prefix64"})
|
||
for _, s56 := range res.Items {
|
||
for _, s58 := range s56.Children {
|
||
for _, v := range s58.Children {
|
||
_ = cw.Write([]string{
|
||
res.Input48, s56.Prefix56, s58.Prefix58,
|
||
intOrEmpty(v.VLAN), v.Hextet, v.Prefix64,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
cw.Flush()
|
||
default:
|
||
http.Error(w, "format muss json oder csv sein", http.StatusBadRequest)
|
||
}
|
||
}
|
||
|
||
func boolTo01(b bool) string {
|
||
if b {
|
||
return "1"
|
||
}
|
||
return "0"
|
||
}
|