231 lines
5.8 KiB
Go
231 lines
5.8 KiB
Go
package pipeline
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/netip"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"evobgp/internal/birdfmt"
|
|
"evobgp/internal/store"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// MaterializedASPrefixKey returns the revision snapshot key for an AS-only entry (not a CIDR).
|
|
func MaterializedASPrefixKey(asn int64) string {
|
|
return fmt.Sprintf("as:%d", asn)
|
|
}
|
|
|
|
// RefreshModule runs ingest (where applicable) and creates a new rendered revision for the module.
|
|
func RefreshModule(ctx context.Context, st store.Backend, hc *http.Client, tenantID, moduleID string) (revisionID string, err error) {
|
|
if hc == nil {
|
|
hc = http.DefaultClient
|
|
}
|
|
mod, err := st.GetModule(tenantID, moduleID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !mod.Enabled {
|
|
return "", fmt.Errorf("module disabled")
|
|
}
|
|
|
|
var rows []store.PrefixRow
|
|
switch mod.Type {
|
|
case "IP_RANGES":
|
|
list, err := st.ListIPRangeEntries(tenantID, moduleID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for _, e := range list {
|
|
comm := e.CommunityID
|
|
if comm == nil && mod.DefaultCommunityID != nil {
|
|
c := *mod.DefaultCommunityID
|
|
comm = &c
|
|
}
|
|
rows = append(rows, store.PrefixRow{Prefix: e.Prefix, CommunityID: comm, Source: "ip_range"})
|
|
}
|
|
case "AS_PREFIXES":
|
|
list, err := st.ListASEntries(tenantID, moduleID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for _, e := range list {
|
|
if !store.ValidASN(e.ASN) {
|
|
continue
|
|
}
|
|
comm := e.CommunityID
|
|
if comm == nil && mod.DefaultCommunityID != nil {
|
|
c := *mod.DefaultCommunityID
|
|
comm = &c
|
|
}
|
|
rows = append(rows, store.PrefixRow{Prefix: MaterializedASPrefixKey(e.ASN), CommunityID: comm, Source: "as_entry"})
|
|
}
|
|
case "CDN_CIDRS":
|
|
sources, err := st.ListCDNSources(tenantID, moduleID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for _, src := range sources {
|
|
u := strings.TrimSpace(src.URL)
|
|
if u == "" {
|
|
continue
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if strings.TrimSpace(src.Etag) != "" {
|
|
req.Header.Set("If-None-Match", strings.TrimSpace(src.Etag))
|
|
}
|
|
resp, err := hc.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cdn fetch %s: %w", u, err)
|
|
}
|
|
if resp.StatusCode == http.StatusNotModified {
|
|
_ = resp.Body.Close()
|
|
continue
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
_, _ = io.Copy(io.Discard, resp.Body)
|
|
_ = resp.Body.Close()
|
|
return "", fmt.Errorf("cdn url %s: %s", u, resp.Status)
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
|
_ = resp.Body.Close()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
etag := strings.TrimSpace(resp.Header.Get("ETag"))
|
|
if etag != "" && etag != strings.TrimSpace(src.Etag) {
|
|
e := etag
|
|
_, _ = st.UpdateCDNSource(tenantID, moduleID, src.ID, &store.CDNSourcePatch{Etag: &e})
|
|
}
|
|
for _, pfx := range ParseCIDRLines(string(body)) {
|
|
comm := src.CommunityID
|
|
if comm == nil && mod.DefaultCommunityID != nil {
|
|
c := *mod.DefaultCommunityID
|
|
comm = &c
|
|
}
|
|
rows = append(rows, store.PrefixRow{Prefix: pfx.String(), CommunityID: comm, Source: "cdn:" + src.ID})
|
|
}
|
|
}
|
|
case "DOMAINS":
|
|
if _, err := st.ListDomainEntries(tenantID, moduleID); err != nil {
|
|
return "", err
|
|
}
|
|
// DNS/DoH resolution not wired yet; emit empty prefix set (valid revision).
|
|
default:
|
|
return "", fmt.Errorf("unknown module type %q", mod.Type)
|
|
}
|
|
|
|
revisionID = uuid.NewString()
|
|
parent := parentRevision(st, tenantID, moduleID)
|
|
hash := hashMaterialization(moduleID, rows)
|
|
preview, err := buildPreviewFragments(revisionID, rows)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := st.CreateRenderRevision(revisionID, tenantID, moduleID, parent, hash, preview, rows); err != nil {
|
|
return "", err
|
|
}
|
|
return revisionID, nil
|
|
}
|
|
|
|
func parentRevision(st store.Backend, tenantID, moduleID string) *string {
|
|
items, _, _ := st.ListRevisions(tenantID, moduleID, "", 1)
|
|
if len(items) == 0 {
|
|
return nil
|
|
}
|
|
id := items[0].ID
|
|
return &id
|
|
}
|
|
|
|
func hashMaterialization(moduleID string, rows []store.PrefixRow) string {
|
|
type line struct{ p, c, s string }
|
|
var lines []line
|
|
for _, r := range rows {
|
|
c := ""
|
|
if r.CommunityID != nil {
|
|
c = *r.CommunityID
|
|
}
|
|
lines = append(lines, line{r.Prefix, c, r.Source})
|
|
}
|
|
sort.Slice(lines, func(i, j int) bool {
|
|
if lines[i].p != lines[j].p {
|
|
return lines[i].p < lines[j].p
|
|
}
|
|
if lines[i].c != lines[j].c {
|
|
return lines[i].c < lines[j].c
|
|
}
|
|
return lines[i].s < lines[j].s
|
|
})
|
|
h := sha256.New()
|
|
h.Write([]byte(moduleID))
|
|
h.Write([]byte{0})
|
|
for _, l := range lines {
|
|
h.Write([]byte(l.p))
|
|
h.Write([]byte{1})
|
|
h.Write([]byte(l.c))
|
|
h.Write([]byte{1})
|
|
h.Write([]byte(l.s))
|
|
h.Write([]byte{0})
|
|
}
|
|
return fmt.Sprintf("sha256:%x", h.Sum(nil))
|
|
}
|
|
|
|
func buildPreviewFragments(revisionID string, rows []store.PrefixRow) (map[string]string, error) {
|
|
var v4, v6 []netip.Prefix
|
|
var pathASNs []int64
|
|
for _, pr := range rows {
|
|
p := strings.TrimSpace(pr.Prefix)
|
|
if strings.HasPrefix(p, "as:") {
|
|
n, err := strconv.ParseInt(strings.TrimPrefix(p, "as:"), 10, 64)
|
|
if err != nil || !store.ValidASN(n) {
|
|
continue
|
|
}
|
|
pathASNs = append(pathASNs, n)
|
|
continue
|
|
}
|
|
pfx, err := netip.ParsePrefix(p)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if pfx.Addr().Is4() {
|
|
v4 = append(v4, pfx.Masked())
|
|
} else {
|
|
v6 = append(v6, pfx.Masked())
|
|
}
|
|
}
|
|
f4, err := birdfmt.RenderExportFilterIPv4("evobgp_export_v4", v4, pathASNs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
f6, err := birdfmt.RenderExportFilterIPv6("evobgp_export_v6", v6, pathASNs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
birdD := birdfmt.JoinFragments(birdfmt.ManagedBanner(revisionID), f4, f6)
|
|
main := `# EvoBGP generated (pipeline refresh)
|
|
router id 192.0.2.1;
|
|
include "bird.d/evobgp_generated.conf";
|
|
|
|
protocol device {
|
|
}
|
|
|
|
protocol direct {
|
|
ipv4;
|
|
ipv6;
|
|
}
|
|
`
|
|
return map[string]string{
|
|
"bird.conf": main,
|
|
"bird.d/evobgp_generated.conf": birdD,
|
|
}, nil
|
|
}
|