2026-04-25 11:42:06 -05:00
|
|
|
// Package embed implements ip-algo: deterministic embedding of workload
|
|
|
|
|
// identity (namespace, app name, image) into the host portion of an IPv6
|
2026-04-24 21:17:42 -05:00
|
|
|
// address. The mapping is operator-friendly cosmetics — NOT a security
|
|
|
|
|
// boundary. See dfritz-cni.md "IPv6 IID Embedding" for the full spec.
|
|
|
|
|
package embed
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
"fmt"
|
|
|
|
|
"hash/fnv"
|
|
|
|
|
"net"
|
|
|
|
|
"strings"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Field is one of the supported identity fields.
|
|
|
|
|
type Field string
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
FieldNamespace Field = "namespace"
|
2026-04-25 11:42:06 -05:00
|
|
|
FieldApp Field = "app"
|
2026-04-24 21:17:42 -05:00
|
|
|
FieldImage Field = "image"
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-25 11:42:06 -05:00
|
|
|
// Values carries the inputs for one embedding call.
|
|
|
|
|
//
|
|
|
|
|
// App is the stable workload identifier — typically the owning Deployment /
|
|
|
|
|
// StatefulSet / DaemonSet name (callers strip the pod-template-hash from
|
|
|
|
|
// ReplicaSet names before passing it in). Caller is responsible for picking
|
|
|
|
|
// the right level of stability; this package just hashes whatever it gets.
|
|
|
|
|
//
|
|
|
|
|
// Image is whatever string the caller wants embedded for the image field;
|
|
|
|
|
// the most common choice is pod.Spec.Containers[0].Image (the spec'd
|
|
|
|
|
// reference). If the caller passes a 64-hex-char SHA-256 digest, the top
|
|
|
|
|
// bits are taken as a hex value; otherwise it is FNV-1a-64'd as a plain
|
|
|
|
|
// string. ImageFallback is used only when Image == "".
|
2026-04-24 21:17:42 -05:00
|
|
|
type Values struct {
|
2026-04-25 11:42:06 -05:00
|
|
|
Namespace string
|
|
|
|
|
App string
|
|
|
|
|
Image string // sha256 hex (64 chars), or any string to FNV; empty → fallback
|
|
|
|
|
ImageFallback string // typically containerID, used when Image=="".
|
2026-04-24 21:17:42 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MaxFieldNibbles is the largest single-field width supported by this
|
|
|
|
|
// implementation. 16 nibbles = 64 bits = the output width of FNV-1a-64.
|
|
|
|
|
// Wider fields would require a wider hash; the design doc tolerates this
|
|
|
|
|
// because real deployments use /64 nodes (15 field nibbles total).
|
|
|
|
|
const MaxFieldNibbles = 16
|
|
|
|
|
|
|
|
|
|
// Embed returns the IPv6 address inside `network` whose host portion encodes
|
|
|
|
|
// `fields` (in the given order) followed by the random nibble nNibble.
|
|
|
|
|
//
|
|
|
|
|
// `network` must be an IPv6 prefix whose length is a multiple of 4 (so the
|
|
|
|
|
// host portion is a whole number of nibbles).
|
|
|
|
|
//
|
|
|
|
|
// `fields` must be non-empty. For a fully-random IID, the caller should pick
|
|
|
|
|
// random bytes directly rather than calling Embed.
|
|
|
|
|
//
|
|
|
|
|
// nNibble is the random "instance" nibble; only the low 4 bits are used.
|
|
|
|
|
// Callers regenerate it on collision (see allocations.json).
|
|
|
|
|
func Embed(network *net.IPNet, fields []Field, vals Values, nNibble byte) (net.IP, error) {
|
|
|
|
|
ones, bits := network.Mask.Size()
|
|
|
|
|
if bits != 128 {
|
|
|
|
|
return nil, fmt.Errorf("network is not IPv6: %s", network)
|
|
|
|
|
}
|
|
|
|
|
if ones%4 != 0 {
|
|
|
|
|
return nil, fmt.Errorf("prefix length %d is not a multiple of 4", ones)
|
|
|
|
|
}
|
|
|
|
|
hostNibbles := (128 - ones) / 4
|
|
|
|
|
if hostNibbles < 2 {
|
|
|
|
|
return nil, fmt.Errorf("prefix /%d leaves %d host nibble(s); need at least 2 (one field + N)", ones, hostNibbles)
|
|
|
|
|
}
|
|
|
|
|
if len(fields) == 0 {
|
|
|
|
|
return nil, fmt.Errorf("no fields specified; caller should generate random IID directly")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fieldNibbles := hostNibbles - 1
|
|
|
|
|
dist, err := distribute(fieldNibbles, len(fields))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
addr := make(net.IP, net.IPv6len)
|
|
|
|
|
copy(addr, network.IP.To16())
|
|
|
|
|
|
|
|
|
|
// Stream nibbles left-to-right starting at the first host nibble.
|
|
|
|
|
startNibble := ones / 4
|
|
|
|
|
pos := 0
|
|
|
|
|
for i, f := range fields {
|
|
|
|
|
n := dist[i]
|
|
|
|
|
v, err := fieldValue(f, vals, n*4)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
// Write `n` nibbles, most-significant first.
|
|
|
|
|
for j := n - 1; j >= 0; j-- {
|
|
|
|
|
nb := byte((v >> uint(j*4)) & 0xF)
|
|
|
|
|
writeNibble(addr, startNibble+pos, nb)
|
|
|
|
|
pos++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
writeNibble(addr, startNibble+pos, nNibble&0x0F)
|
|
|
|
|
|
|
|
|
|
return addr, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// distribute splits `total` nibbles across `k` fields as evenly as possible,
|
|
|
|
|
// giving any remainder to earlier fields one extra nibble at a time.
|
|
|
|
|
func distribute(total, k int) ([]int, error) {
|
|
|
|
|
if k <= 0 {
|
|
|
|
|
return nil, fmt.Errorf("k must be > 0")
|
|
|
|
|
}
|
|
|
|
|
if total < k {
|
|
|
|
|
return nil, fmt.Errorf("not enough host nibbles (%d) for %d fields", total, k)
|
|
|
|
|
}
|
|
|
|
|
out := make([]int, k)
|
|
|
|
|
base := total / k
|
|
|
|
|
rem := total % k
|
|
|
|
|
for i := range out {
|
|
|
|
|
out[i] = base
|
|
|
|
|
if i < rem {
|
|
|
|
|
out[i]++
|
|
|
|
|
}
|
|
|
|
|
if out[i] > MaxFieldNibbles {
|
|
|
|
|
return nil, fmt.Errorf("field %d would need %d nibbles; max supported is %d", i, out[i], MaxFieldNibbles)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return out, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// fieldValue returns the top `bits` bits of the hash-or-digest for `f`,
|
|
|
|
|
// right-aligned in the returned uint64.
|
|
|
|
|
func fieldValue(f Field, v Values, bits int) (uint64, error) {
|
|
|
|
|
if bits <= 0 || bits > 64 {
|
|
|
|
|
return 0, fmt.Errorf("bad field bits %d (1..64)", bits)
|
|
|
|
|
}
|
|
|
|
|
switch f {
|
|
|
|
|
case FieldNamespace:
|
|
|
|
|
return topBitsFNV(v.Namespace, bits), nil
|
2026-04-25 11:42:06 -05:00
|
|
|
case FieldApp:
|
|
|
|
|
return topBitsFNV(v.App, bits), nil
|
2026-04-24 21:17:42 -05:00
|
|
|
case FieldImage:
|
2026-04-25 11:42:06 -05:00
|
|
|
if v.Image == "" {
|
|
|
|
|
return topBitsFNV(v.ImageFallback, bits), nil
|
|
|
|
|
}
|
|
|
|
|
// SHA-256 manifest digests are exactly 64 hex chars (with optional
|
|
|
|
|
// "sha256:" prefix). Anything else — image:tag references like
|
|
|
|
|
// "traefik:v3", or short SHAs — gets FNV-1a-64'd as a string. This
|
|
|
|
|
// preserves the original digest behaviour while letting callers
|
|
|
|
|
// pass pod.Spec.Containers[0].Image directly.
|
|
|
|
|
s := strings.TrimPrefix(v.Image, "sha256:")
|
|
|
|
|
if len(s) == 64 && isHex(s) {
|
2026-04-24 21:17:42 -05:00
|
|
|
return topBitsHex(v.Image, bits)
|
|
|
|
|
}
|
2026-04-25 11:42:06 -05:00
|
|
|
return topBitsFNV(v.Image, bits), nil
|
2026-04-24 21:17:42 -05:00
|
|
|
default:
|
|
|
|
|
return 0, fmt.Errorf("unknown field %q", f)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func topBitsFNV(s string, bits int) uint64 {
|
|
|
|
|
h := fnv.New64a()
|
|
|
|
|
_, _ = h.Write([]byte(s))
|
|
|
|
|
return h.Sum64() >> uint(64-bits)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// topBitsHex parses a leading sha256-digest-style hex string and returns
|
|
|
|
|
// its top `bits` bits, right-aligned. Accepts an optional "sha256:" prefix.
|
|
|
|
|
func topBitsHex(s string, bits int) (uint64, error) {
|
|
|
|
|
s = strings.TrimPrefix(s, "sha256:")
|
|
|
|
|
if len(s) < 16 { // need at least 8 bytes / 64 bits to right-shift
|
|
|
|
|
return 0, fmt.Errorf("image digest too short: %d hex chars", len(s))
|
|
|
|
|
}
|
|
|
|
|
b, err := hex.DecodeString(s[:16])
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0, fmt.Errorf("image digest not hex: %w", err)
|
|
|
|
|
}
|
|
|
|
|
var v uint64
|
|
|
|
|
for _, x := range b {
|
|
|
|
|
v = (v << 8) | uint64(x)
|
|
|
|
|
}
|
|
|
|
|
return v >> uint(64-bits), nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 11:42:06 -05:00
|
|
|
// isHex reports whether every byte in s is a valid hex digit.
|
|
|
|
|
func isHex(s string) bool {
|
|
|
|
|
for i := 0; i < len(s); i++ {
|
|
|
|
|
c := s[i]
|
|
|
|
|
switch {
|
|
|
|
|
case c >= '0' && c <= '9':
|
|
|
|
|
case c >= 'a' && c <= 'f':
|
|
|
|
|
case c >= 'A' && c <= 'F':
|
|
|
|
|
default:
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 21:17:42 -05:00
|
|
|
// writeNibble sets the (nibIdx)-th nibble of addr (0 = highest nibble of byte 0).
|
|
|
|
|
func writeNibble(addr net.IP, nibIdx int, nb byte) {
|
|
|
|
|
bytePos := nibIdx / 2
|
|
|
|
|
if nibIdx%2 == 0 {
|
|
|
|
|
addr[bytePos] = (addr[bytePos] & 0x0F) | (nb << 4)
|
|
|
|
|
} else {
|
|
|
|
|
addr[bytePos] = (addr[bytePos] & 0xF0) | (nb & 0x0F)
|
|
|
|
|
}
|
|
|
|
|
}
|