diff --git a/.gitea/workflows/registry.yml b/.gitea/workflows/registry.yml new file mode 100644 index 0000000..20912ac --- /dev/null +++ b/.gitea/workflows/registry.yml @@ -0,0 +1,51 @@ +name: release-tag +on: + push: + branches: + - 'main' +jobs: + release-image: + runs-on: ubuntu-fast + env: + DOCKER_ORG: ${{ vars.DOCKER_ORG }} + DOCKER_LATEST: latest + RUNNER_TOOL_CACHE: /toolcache + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker BuildX + uses: docker/setup-buildx-action@v2 + with: # replace it with your local IP + config-inline: | + [registry."${{ vars.DOCKER_REGISTRY }}"] + http = true + insecure = true + + - name: Login to DockerHub + uses: docker/login-action@v2 + with: + registry: ${{ vars.DOCKER_REGISTRY }} # replace it with your local IP + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Get Meta + id: meta + run: | + echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT + echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT + + - name: Build and push + uses: docker/build-push-action@v4 + with: + context: . + file: ./Dockerfile + platforms: | + linux/amd64 + push: true + tags: | # replace it with your local IP and tags + ${{ vars.DOCKER_REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }} + ${{ vars.DOCKER_REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b5344e5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +# --- Builder Stage --- +FROM golang:1.24-alpine AS builder + +# Arbeitsverzeichnis +WORKDIR /app + +# Go-Module laden +COPY go.mod ./ +RUN go mod download + +# Quellcode +COPY . . + +# Binary bauen (statisch, kleiner) +RUN CGO_ENABLED=0 GOOS=linux go build -o subnetcalc . + +# --- Runtime Stage --- +FROM alpine:3.22 + +WORKDIR /app + +# Binary vom Builder kopieren +COPY --from=builder /app/subnetcalc . + +# Exponiere Port für Webinterface +EXPOSE 8080 + +# Standardkommando: immer Web-Modus starten +ENTRYPOINT ["./subnetcalc", "-web", ":8080"] diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..474b2d7 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.send.nrw/sendnrw/go-ipv6-calculator + +go 1.24.4 diff --git a/main.go b/main.go new file mode 100644 index 0000000..0eeb992 --- /dev/null +++ b/main.go @@ -0,0 +1,500 @@ +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(` + + + + + +IPv6 Subnet-Calculator (/48 → /56 → /58 → /64) + + + +
+

IPv6 Subnet-Calculator

+
/48 → /56 → /58 → /64 · numerisch sortiert
+
+ +
+ + + + + {{if .HasResult}} + JSON herunterladen + CSV herunterladen + {{end}} +
+ +{{if .Error}} +
+ Fehler: {{.Error}} +
+{{end}} + +{{if .HasResult}} +
+
Eingabe: {{.Result.Input48}} + {{if .ShowAll}}Alle Netze{{else}}Gefiltert (Ziffern/VLAN){{end}} +
+
+ {{.Result.Total56}} × /56 · {{.Result.Total58}} × /58 · {{.Result.Total64}} × /64 +
+
+ + {{range .Result.Items}} +
+ {{.Prefix56}} — {{len .Children}} × /58 + {{range .Children}} +
+ {{.Prefix58}} — {{len .Children}} × /64 +
+ {{range .Children}} + {{if .VLAN}} +
VLAN {{.VLAN}} — {{.Prefix64}}
+ {{else}} +
{{.Prefix64}}
+ {{end}} + {{end}} +
+
+ {{end}} +
+ {{end}} +{{else}} +
Gib oben ein ULA-/48 ein und starte die Berechnung.
+{{end}} + + + + +`)) + +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" +}