backtrace/cmd/update-prefixes/main.go

187 lines
5.1 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net/http"
"net/netip"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
var knownASNs = []string{"AS23764", "AS4134", "AS4809", "AS4837", "AS58453", "AS58807", "AS9808", "AS9929"}
var errPrefixCountDrop = errors.New("prefix count dropped beyond protection threshold")
type updateConfig struct {
SourceBase string
OutputDir string
Timeout time.Duration
}
func main() {
config := updateConfig{}
flag.StringVar(&config.SourceBase, "source-base", "https://stat.ripe.net/data/announced-prefixes/data.json", "upstream announced-prefixes API")
flag.StringVar(&config.OutputDir, "output-dir", "bk/prefix", "prefix snapshot directory")
flag.DurationVar(&config.Timeout, "timeout", 30*time.Second, "upstream request timeout")
flag.Parse()
if err := updatePrefixes(context.Background(), http.DefaultClient, config); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func updatePrefixes(ctx context.Context, client *http.Client, config updateConfig) error {
if ctx == nil {
ctx = context.Background()
}
if client == nil {
client = http.DefaultClient
}
if config.SourceBase == "" || config.OutputDir == "" || config.Timeout <= 0 {
return errors.New("source-base, output-dir, and timeout must be valid")
}
valuesByASN := make(map[string][]string, len(knownASNs))
for _, asn := range knownASNs {
requestCtx, cancel := context.WithTimeout(ctx, config.Timeout)
endpoint, err := url.Parse(config.SourceBase)
if err != nil {
cancel()
return fmt.Errorf("parse source URL: %w", err)
}
query := endpoint.Query()
query.Set("resource", asn)
endpoint.RawQuery = query.Encode()
request, err := http.NewRequestWithContext(requestCtx, http.MethodGet, endpoint.String(), nil)
if err != nil {
cancel()
return err
}
request.Header.Set("User-Agent", "oneclickvirt-backtrace-prefix-sync/1")
response, err := client.Do(request)
if err != nil {
cancel()
return fmt.Errorf("fetch %s: %w", asn, err)
}
body, readErr := io.ReadAll(io.LimitReader(response.Body, 8<<20))
response.Body.Close()
cancel()
if readErr != nil {
return readErr
}
if response.StatusCode != http.StatusOK {
return fmt.Errorf("fetch %s: HTTP %d", asn, response.StatusCode)
}
values, err := extractIPv6Prefixes(body)
if err != nil {
return fmt.Errorf("parse %s: %w", asn, err)
}
valuesByASN[asn] = values
}
for _, asn := range knownASNs {
if err := writePrefixFile(filepath.Join(config.OutputDir, strings.ToLower(asn)+".txt"), valuesByASN[asn]); err != nil {
if errors.Is(err, errPrefixCountDrop) {
fmt.Fprintf(os.Stderr, "skip %s: %v\n", asn, err)
continue
}
return err
}
}
return nil
}
func extractIPv6Prefixes(data []byte) ([]string, error) {
var response struct {
Status string `json:"status"`
Data struct {
Resource string `json:"resource"`
Prefixes []struct {
Prefix string `json:"prefix"`
} `json:"prefixes"`
} `json:"data"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("decode announced-prefixes response: %w", err)
}
if response.Status != "ok" || response.Data.Resource == "" || response.Data.Prefixes == nil {
return nil, errors.New("announced-prefixes response is missing required fields")
}
seen := make(map[string]struct{})
for _, record := range response.Data.Prefixes {
prefix, err := netip.ParsePrefix(strings.TrimSpace(record.Prefix))
if err != nil || !prefix.Addr().Is6() || prefix.Bits() < 16 || prefix.Bits() > 128 {
continue
}
seen[prefix.String()] = struct{}{}
}
values := make([]string, 0, len(seen))
for value := range seen {
values = append(values, value)
}
sort.Strings(values)
if len(values) == 0 {
return nil, errors.New("no IPv6 prefixes found")
}
return values, nil
}
func writePrefixFile(output string, values []string) error {
if err := os.MkdirAll(filepath.Dir(output), 0o755); err != nil {
return err
}
candidate := []byte(strings.Join(values, "\n") + "\n")
current, readErr := os.ReadFile(output)
if readErr == nil {
currentCount := countNonemptyLines(current)
if currentCount > 0 && len(values)*100 < currentCount*65 {
return fmt.Errorf("%w: prefix count dropped from %d to %d for %s", errPrefixCountDrop, currentCount, len(values), filepath.Base(output))
}
if bytes.Equal(current, candidate) {
return nil
}
}
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
return readErr
}
temporary, err := os.CreateTemp(filepath.Dir(output), ".prefix-*")
if err != nil {
return err
}
temporaryName := temporary.Name()
defer os.Remove(temporaryName)
if err := temporary.Chmod(0o644); err != nil {
_ = temporary.Close()
return err
}
if _, err := temporary.Write(candidate); err != nil {
_ = temporary.Close()
return err
}
if err := temporary.Sync(); err != nil {
_ = temporary.Close()
return err
}
if err := temporary.Close(); err != nil {
return err
}
return os.Rename(temporaryName, output)
}
func countNonemptyLines(data []byte) int {
count := 0
for _, line := range strings.Split(string(data), "\n") {
if strings.TrimSpace(line) != "" {
count++
}
}
return count
}