init
All checks were successful
release-tag / release-image (push) Successful in 1m34s

This commit is contained in:
2025-09-23 17:32:39 +02:00
parent 6b19b144ed
commit 34f7d5c171
4 changed files with 583 additions and 0 deletions

View File

@@ -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 }}

29
Dockerfile Normal file
View File

@@ -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"]

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module git.send.nrw/sendnrw/go-ipv6-calculator
go 1.24.4

500
main.go Normal file
View File

@@ -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 09) → VLAN 14096 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 09;
// /64 nur VLAN 14096, deren 4. Hextet exakt dezimal ist (nur 09).
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 09 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 (09) enthält; /64 nur VLAN 14096, 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"
}