| 1 | // SPDX-License-Identifier: GPL-3.0-or-later |
| 2 | |
| 3 | package main |
| 4 | |
| 5 | import ( |
| 6 | "bufio" |
| 7 | "encoding/csv" |
| 8 | "flag" |
| 9 | "fmt" |
| 10 | "io" |
| 11 | "net/http" |
| 12 | "os" |
| 13 | "regexp" |
| 14 | "sort" |
| 15 | "strings" |
| 16 | "time" |
| 17 | ) |
| 18 | |
| 19 | type ieeeSource struct { |
| 20 | name string |
| 21 | url string |
| 22 | } |
| 23 | |
| 24 | var ieeeSources = []ieeeSource{ |
| 25 | {name: "ieee_oui", url: "https://standards-oui.ieee.org/oui/oui.csv"}, |
| 26 | {name: "ieee_cid", url: "https://standards-oui.ieee.org/cid/cid.csv"}, |
| 27 | {name: "ieee_oui36", url: "https://standards-oui.ieee.org/oui36/oui36.csv"}, |
| 28 | {name: "ieee_iab", url: "https://standards-oui.ieee.org/iab/iab.csv"}, |
| 29 | } |
| 30 | |
| 31 | var nonHex = regexp.MustCompile(`[^0-9A-Fa-f]`) |
| 32 | var httpClient = &http.Client{Timeout: 30 * time.Second} |
| 33 | |
| 34 | func main() { |
| 35 | var outputPath string |
| 36 | flag.StringVar(&outputPath, "out", "", "output TSV path (required)") |
| 37 | flag.Parse() |
| 38 | |
| 39 | if strings.TrimSpace(outputPath) == "" { |
| 40 | fmt.Fprintln(os.Stderr, "missing required -out argument") |
| 41 | os.Exit(2) |
| 42 | } |
| 43 | |
| 44 | index, err := buildVendorIndex() |
| 45 | if err != nil { |
| 46 | fmt.Fprintf(os.Stderr, "failed to build OUI index: %v\n", err) |
| 47 | os.Exit(1) |
| 48 | } |
| 49 | if err := writeDataset(outputPath, index); err != nil { |
| 50 | fmt.Fprintf(os.Stderr, "failed to write dataset: %v\n", err) |
| 51 | os.Exit(1) |
| 52 | } |
| 53 | fmt.Printf("wrote %d entries to %s\n", len(index), outputPath) |
| 54 | } |
| 55 | |
| 56 | func buildVendorIndex() (map[string]string, error) { |
| 57 | prefixToVendor := make(map[string]string, 120000) |
| 58 | |
| 59 | for _, src := range ieeeSources { |
| 60 | records, err := fetchCSV(src.url) |
| 61 | if err != nil { |
| 62 | return nil, fmt.Errorf("%s: %w", src.name, err) |
| 63 | } |
| 64 | for i, record := range records { |
| 65 | if i == 0 { // header |
| 66 | continue |
| 67 | } |
| 68 | if len(record) < 3 { |
| 69 | continue |
| 70 | } |
| 71 | registry := strings.TrimSpace(record[0]) |
| 72 | assignment := normalizeAssignment(record[1]) |
| 73 | vendor := strings.TrimSpace(record[2]) |
| 74 | if assignment == "" || vendor == "" { |
| 75 | continue |
| 76 | } |
| 77 | prefixLen := prefixLengthForRegistry(registry, assignment) |
| 78 | if prefixLen == 0 || len(assignment) < prefixLen { |
| 79 | continue |
| 80 | } |
| 81 | prefix := assignment[:prefixLen] |
| 82 | |
| 83 | if existing, ok := prefixToVendor[prefix]; ok { |
| 84 | // Prefer the more descriptive name on conflicts. |
| 85 | if len(existing) >= len(vendor) { |
| 86 | continue |
| 87 | } |
| 88 | } |
| 89 | prefixToVendor[prefix] = vendor |
| 90 | } |
| 91 | } |
| 92 | return prefixToVendor, nil |
| 93 | } |
| 94 | |
| 95 | func fetchCSV(url string) ([][]string, error) { |
| 96 | req, err := http.NewRequest(http.MethodGet, url, nil) |
| 97 | if err != nil { |
| 98 | return nil, err |
| 99 | } |
| 100 | req.Header.Set("User-Agent", "netdata-topology-oui-dataset-updater/1.0") |
| 101 | |
| 102 | resp, err := httpClient.Do(req) |
| 103 | if err != nil { |
| 104 | return nil, err |
| 105 | } |
| 106 | defer resp.Body.Close() |
| 107 | |
| 108 | if resp.StatusCode != http.StatusOK { |
| 109 | return nil, fmt.Errorf("unexpected status %d", resp.StatusCode) |
| 110 | } |
| 111 | |
| 112 | reader := csv.NewReader(resp.Body) |
| 113 | reader.FieldsPerRecord = -1 |
| 114 | records := make([][]string, 0, 65536) |
| 115 | for { |
| 116 | record, err := reader.Read() |
| 117 | if err == io.EOF { |
| 118 | break |
| 119 | } |
| 120 | if err != nil { |
| 121 | return nil, err |
| 122 | } |
| 123 | records = append(records, record) |
| 124 | } |
| 125 | return records, nil |
| 126 | } |
| 127 | |
| 128 | func normalizeAssignment(raw string) string { |
| 129 | raw = strings.TrimSpace(raw) |
| 130 | raw = nonHex.ReplaceAllString(raw, "") |
| 131 | raw = strings.ToUpper(raw) |
| 132 | if len(raw) > 12 { |
| 133 | raw = raw[:12] |
| 134 | } |
| 135 | return raw |
| 136 | } |
| 137 | |
| 138 | func prefixLengthForRegistry(registry, assignment string) int { |
| 139 | switch strings.ToUpper(strings.TrimSpace(registry)) { |
| 140 | case "MA-S", "IAB": |
| 141 | if len(assignment) >= 9 { |
| 142 | return 9 |
| 143 | } |
| 144 | case "MA-M": |
| 145 | if len(assignment) >= 7 { |
| 146 | return 7 |
| 147 | } |
| 148 | case "MA-L", "CID": |
| 149 | if len(assignment) >= 6 { |
| 150 | return 6 |
| 151 | } |
| 152 | } |
| 153 | // Fallback for non-standard/unknown registry values. |
| 154 | if len(assignment) >= 9 { |
| 155 | return 9 |
| 156 | } |
| 157 | if len(assignment) >= 7 { |
| 158 | return 7 |
| 159 | } |
| 160 | if len(assignment) >= 6 { |
| 161 | return 6 |
| 162 | } |
| 163 | return 0 |
| 164 | } |
| 165 | |
| 166 | func writeDataset(path string, index map[string]string) error { |
| 167 | f, err := os.Create(path) |
| 168 | if err != nil { |
| 169 | return err |
| 170 | } |
| 171 | defer f.Close() |
| 172 | |
| 173 | w := bufio.NewWriterSize(f, 1<<20) |
| 174 | defer w.Flush() |
| 175 | |
| 176 | prefixes := make([]string, 0, len(index)) |
| 177 | for prefix := range index { |
| 178 | prefixes = append(prefixes, prefix) |
| 179 | } |
| 180 | sort.Slice(prefixes, func(i, j int) bool { |
| 181 | if len(prefixes[i]) != len(prefixes[j]) { |
| 182 | return len(prefixes[i]) > len(prefixes[j]) |
| 183 | } |
| 184 | return prefixes[i] < prefixes[j] |
| 185 | }) |
| 186 | |
| 187 | fmt.Fprintf(w, "# Netdata Topology OUI Vendor Dataset\n") |
| 188 | fmt.Fprintf(w, "# Generated: %s\n", time.Now().UTC().Format(time.RFC3339)) |
| 189 | fmt.Fprintf(w, "# Sources:\n") |
| 190 | for _, src := range ieeeSources { |
| 191 | fmt.Fprintf(w, "# - %s %s\n", src.name, src.url) |
| 192 | } |
| 193 | fmt.Fprintf(w, "# Format: <HEX_PREFIX>\\t<VENDOR>\n") |
| 194 | |
| 195 | for _, prefix := range prefixes { |
| 196 | fmt.Fprintf(w, "%s\t%s\n", prefix, index[prefix]) |
| 197 | } |
| 198 | return nil |
| 199 | } |