mirror of
https://github.com/oneclickvirt/backtrace.git
synced 2026-07-23 11:20:13 +08:00
187 lines
5.1 KiB
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
|
|
}
|