- \n'
310 | html+= events_part
311 | html+= '
├── doc
├── detection-prototype.png
├── 04-validation-approach.pdf
├── 01-datasets-description.pdf
├── 02-detection-system-design.pdf
└── 03-legitimate-route-change-identification.pdf
├── .gitignore
├── data
├── caida_as_rel
│ ├── query.py
│ └── fetch_data.py
├── caida_as_org
│ ├── query.py
│ └── fetch_data.py
├── bgpstream
│ ├── fetch_data.py
│ └── locate_route_change.py
└── routeviews
│ ├── fetch_rib.py
│ └── fetch_updates.py
├── post_processor
├── whois_lookup.py
├── rpki_validator.py
├── irr_validator.py
├── html
│ └── template_routeviews.html
├── alarm_postprocess_routeviews.py
└── summary_routeviews.py
├── BEAM_engine
├── train.py
└── BEAM_model.py
├── routing_monitor
├── detect_route_change_routeviews.py
└── monitor.py
├── anomaly_detector
├── BEAM_diff_evaluator_routeviews.py
├── report_anomaly_routeviews.py
└── utils.py
└── readme.md
/doc/detection-prototype.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yhchen-tsinghua/routing-anomaly-detection/HEAD/doc/detection-prototype.png
--------------------------------------------------------------------------------
/doc/04-validation-approach.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yhchen-tsinghua/routing-anomaly-detection/HEAD/doc/04-validation-approach.pdf
--------------------------------------------------------------------------------
/doc/01-datasets-description.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yhchen-tsinghua/routing-anomaly-detection/HEAD/doc/01-datasets-description.pdf
--------------------------------------------------------------------------------
/doc/02-detection-system-design.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yhchen-tsinghua/routing-anomaly-detection/HEAD/doc/02-detection-system-design.pdf
--------------------------------------------------------------------------------
/doc/03-legitimate-route-change-identification.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yhchen-tsinghua/routing-anomaly-detection/HEAD/doc/03-legitimate-route-change-identification.pdf
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.swp
2 | *.vim
3 |
4 | data/routeviews/updates
5 | data/routeviews/ribs
6 | data/routeviews/cache
7 | data/routeviews/bgpd
8 | data/caida_as_rel/serial-1
9 | data/caida_as_rel/serial-2
10 | data/caida_as_org/cache
11 | data/caida_as_org/fetched_data
12 | data/bgpstream/cache
13 | data/bgpstream/event
14 |
15 | __pycache__
16 |
17 | BEAM_engine/models/
18 | BEAM_engine/models
19 |
20 | routing_monitor/detection_result/
21 | routing_monitor/detection_result
22 |
23 | post_processor/rpki_cache/
24 | post_processor/rpki_cache
25 |
--------------------------------------------------------------------------------
/data/caida_as_rel/query.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | #-*- coding: utf-8 -*-
3 |
4 | from pathlib import Path
5 | import click
6 |
7 | SCRIPT_DIR = Path(__file__).resolve().parent
8 |
9 | def load(serial, time):
10 | f = SCRIPT_DIR/f"serial-{serial}"/f"{time}.as-rel{'' if serial == '1' else 2}.txt"
11 |
12 | ngbrs = dict()
13 | for line in open(f, "r"):
14 | if line[0] == "#": continue
15 | i, j, k = line.strip().split("|")[:3]
16 | ngbrs.setdefault(i, {-1: set(), 0: set(), 1: set()})[int(k)].add(j)
17 | ngbrs.setdefault(j, {-1: set(), 0: set(), 1: set()})[-int(k)].add(i)
18 |
19 | def query(i, j):
20 | if i not in ngbrs: print(f"Unknown AS: {i}"); return None
21 | if j not in ngbrs: print(f"Unknown AS: {j}"); return None
22 | for k,v in ngbrs[i].items():
23 | if j in v: return k
24 | return None
25 |
26 | return query
27 |
28 |
29 | @click.command()
30 | @click.option("--serial", "-s", type=click.Choice(["1", "2"]), default="1", help="serial 1 or 2")
31 | @click.option("--time", "-t", type=int, required=True, help="timestamp, e.g., 20200901")
32 | def main(serial, time):
33 | query = load(serial, time)
34 |
35 | while True:
36 | i = input("AS1: ")
37 | j = input("AS2: ")
38 | print(query(i, j))
39 |
40 | if __name__ == "__main__":
41 | main()
42 |
--------------------------------------------------------------------------------
/post_processor/whois_lookup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | #-*- coding: utf-8 -*-
3 |
4 | import subprocess
5 | from datetime import datetime
6 | from pathlib import Path
7 | import re
8 |
9 | script_dir = Path(__file__).resolve().parent
10 | cache_dir = script_dir/"whois_cache"
11 | cache_dir.mkdir(parents=True, exist_ok=True)
12 |
13 | def whois_lookup(target, cache_date=datetime.now().strftime("%Y-%m-%d")):
14 | cache_file = cache_dir/f"{target.replace('/', '_')}.{cache_date}.txt"
15 | if cache_file.exists():
16 | with cache_file.open("r", encoding="utf-8") as f:
17 | content = f.read()
18 | else:
19 | try:
20 | result = subprocess.run(["whois", target],
21 | text=True, capture_output=True, check=True)
22 | content = result.stdout
23 | with cache_file.open("w", encoding="utf-8") as f:
24 | f.write(content)
25 | except Exception as e:
26 | print(f"Failed to perform WHOIS lookup for {target}: {e}")
27 | content = ""
28 | return content
29 |
30 | def whois_match(prefix_str, asn_str):
31 | whois_content = whois_lookup(prefix_str)
32 | for line in whois_content.split("\n"):
33 | if not line or line.startswith("%"): continue
34 | match = re.match(r"^(\S+):\s+(.*)$", line)
35 | if match:
36 | _, value = match.groups()
37 | for asn_value in re.findall(r"as\d+", value):
38 | if f"as{asn_str}" == asn_value:
39 | return True
40 | return False
41 |
--------------------------------------------------------------------------------
/data/caida_as_org/query.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | #-*- coding: utf-8 -*-
3 |
4 | from pathlib import Path
5 | import click
6 |
7 | SCRIPT_DIR = Path(__file__).resolve().parent
8 |
9 | def load(time):
10 | fname = f"{time}.as-org2info.txt"
11 | lines = open(SCRIPT_DIR/"fetched_data"/fname, "r").readlines()
12 | field1 = "aut|changed|aut_name|org_id|opaque_id|source".split("|")
13 | field2 = "org_id|changed|name|country|source".split("|")
14 | as_info = {}
15 | org_info = {}
16 | for l in lines:
17 | if l[0] == "#": continue
18 | values = l.strip().split("|")
19 | if len(values) == len(field1):
20 | if values[0] in as_info and values[1] < as_info[values[0]]["changed"]: continue
21 | as_info[values[0]] = dict(zip(field1[1:], values[1:]))
22 | if len(values) == len(field2):
23 | if values[0] in org_info and values[1] < org_info[values[0]]["changed"]: continue
24 | org_info[values[0]] = dict(zip(field2[1:], values[1:]))
25 | return as_info, org_info
26 |
27 | @click.command()
28 | @click.option("--time", "-t", type=int, required=True, help="timestamp, like 20200901")
29 | def main(time):
30 | as_info, org_info = load(time)
31 | while True:
32 | inp = input("ASN or org_id: ")
33 | if inp in as_info:
34 | print(f"asn: {inp}, {as_info[inp]}")
35 | elif inp in org_info:
36 | print(f"org_id: {inp}, {org_info[inp]}")
37 | else:
38 | print("no result")
39 |
40 | if __name__ == "__main__":
41 | main()
42 |
--------------------------------------------------------------------------------
/data/caida_as_rel/fetch_data.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | #-*- coding: utf-8 -*-
3 |
4 | from pathlib import Path
5 | import subprocess
6 | import click
7 |
8 | SCRIPT_DIR = Path(__file__).resolve().parent
9 |
10 | SERIAL_1_DIR = SCRIPT_DIR / "serial-1"
11 | SERIAL_2_DIR = SCRIPT_DIR / "serial-2"
12 |
13 | SERIAL_1_DIR.mkdir(exist_ok=True, parents=True)
14 | SERIAL_2_DIR.mkdir(exist_ok=True, parents=True)
15 |
16 | def get(serial: str, time: int):
17 | if serial == "1":
18 | fname = f"{time}.as-rel.txt.bz2"
19 | obj = f"https://publicdata.caida.org/datasets/as-relationships/serial-1/{fname}"
20 | out = SERIAL_1_DIR / fname
21 | elif serial == "2":
22 | fname = f"{time}.as-rel2.txt.bz2"
23 | obj = f"https://publicdata.caida.org/datasets/as-relationships/serial-2/{fname}"
24 | out = SERIAL_2_DIR / fname
25 | else:
26 | raise RuntimeError("bad argument")
27 | if out.with_suffix("").exists():
28 | # print(f"as-relationship for {serial} {time} already existed")
29 | return out.with_suffix("")
30 | subprocess.run(["curl", obj, "--output", str(out)], check=True)
31 | subprocess.run(["bzip2", "-d", str(out)], check=True)
32 | print(f"get as-relationship for {serial} {time}")
33 | return out.with_suffix("")
34 |
35 | @click.command()
36 | @click.option("--serial", "-s", type=click.Choice(["1", "2"]), default="1", help="serial 1 or 2")
37 | @click.option("--time", "-t", type=int, required=True, help="timestamp, e.g., 20200901")
38 | def main(serial, time):
39 | get(serial, time)
40 |
41 | if __name__ == "__main__":
42 | main()
43 |
--------------------------------------------------------------------------------
/BEAM_engine/train.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | #-*- coding: utf-8 -*-
3 |
4 | from pathlib import Path
5 | from BEAM_model import BEAM
6 | from shutil import get_terminal_size
7 | import click
8 | import os
9 |
10 | import sys
11 | sys.path.append(str(Path(__file__).resolve().parent.parent))
12 |
13 | from data.caida_as_rel.fetch_data import get as prepare_edge_file
14 |
15 | @click.command()
16 | @click.option("--serial", "-s", type=click.Choice(["1", "2"]), default="1", help="serial 1 or 2")
17 | @click.option("--time", "-t", type=int, required=True, help="timestamp, e.g., 20200901")
18 | @click.option("--Q", "Q", type=int, default=10, help="hyperparameter Q, e.g., 10")
19 | @click.option("--dimension", type=int, default=128, help="hyperparameter dimension size, e.g., 128")
20 | @click.option("--epoches", type=int, default=1000, help="epoches to train, e.g., 1000")
21 | @click.option("--device", type=int, default=0, help="device to train on")
22 | @click.option("--num-workers", type=int, default=1, help="number of workers")
23 | def main(serial, time, device, **model_params):
24 | os.environ["CUDA_VISIBLE_DEVICES"] = f"{device}"
25 |
26 | edge_file = prepare_edge_file(serial, time)
27 | assert edge_file.exists(), f"fail to prepare {edge_file}"
28 |
29 | model_params["edge_file"] = edge_file
30 |
31 | for k, v in model_params.items():
32 | print(f"{k}: {v}")
33 | print("*"*get_terminal_size().columns)
34 | # input("Press Enter to start.")
35 |
36 | train_dir = Path(__file__).resolve().parent/"models"/ \
37 | f"{edge_file.stem}.{model_params['epoches']}.{model_params['Q']}.{model_params['dimension']}"
38 | train_dir.mkdir(parents=True, exist_ok=True)
39 | model_params["train_dir"] = train_dir
40 | epoches = model_params.pop("epoches")
41 |
42 | model = BEAM(**model_params)
43 | model.train(epoches=epoches)
44 | model.save_embeddings(path=str(train_dir))
45 |
46 | if __name__ == "__main__":
47 | main()
48 |
--------------------------------------------------------------------------------
/data/caida_as_org/fetch_data.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | #-*- coding: utf-8 -*-
3 |
4 | from pathlib import Path
5 | from urllib.parse import urljoin
6 | import numpy as np
7 | import json
8 | import subprocess
9 | import click
10 | import re
11 |
12 | SCRIPT_DIR = Path(__file__).resolve().parent
13 | CACHE_DIR = SCRIPT_DIR/"cache"
14 | CACHE_DIR.mkdir(parents=True, exist_ok=True)
15 | OUTPUT_DIR = SCRIPT_DIR/"fetched_data"
16 | OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
17 |
18 | def get_archive_list(refresh=False):
19 | cache_path = CACHE_DIR/f"time2url"
20 | if cache_path.exists() and not refresh:
21 | try: return json.load(open(cache_path, "r"))
22 | except: pass
23 |
24 | url_index = "https://publicdata.caida.org/datasets/as-organizations/"
25 | res = subprocess.check_output(["curl", "-s", url_index]).decode()
26 | res = re.sub(r"\s\s+", " ", res.replace("\n", " "))
27 | time2url = {}
28 | for fname, time in re.findall(r'\', res):
29 | time2url[time] = urljoin(url_index, fname)
30 |
31 | json.dump(time2url, open(cache_path, "w"), indent=2)
32 | return time2url
33 |
34 | def get_most_recent(time):
35 | time2url = get_archive_list()
36 | times = sorted(time2url.keys())
37 | idx = np.searchsorted(times, time, "right")
38 |
39 | target_time = times[idx-1]
40 | target_url = time2url[target_time]
41 |
42 | out = OUTPUT_DIR/target_url.split("/")[-1]
43 | if out.with_suffix("").exists():
44 | # print(f"as-organizations for {target_time} exists")
45 | return target_time, out.with_suffix("")
46 |
47 | subprocess.run(["curl", target_url, "--output", str(out)], check=True)
48 | subprocess.run(["gzip", "-d", str(out)], check=True)
49 | print(f"get as-organizations for {target_time}")
50 | return target_time, out.with_suffix("")
51 |
52 | @click.command()
53 | @click.option("--time", "-t", type=str, required=True, help="timestamp, e.g., 20200901")
54 | def main(time):
55 | get_most_recent(time)
56 |
57 | if __name__ == "__main__":
58 | main()
59 |
--------------------------------------------------------------------------------
/routing_monitor/detect_route_change_routeviews.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | #-*- coding: utf-8 -*-
3 |
4 | from pathlib import Path
5 | import pandas as pd
6 | from datetime import datetime, timedelta
7 | import pickle
8 | import click
9 |
10 | import sys
11 | sys.path.append(str(Path(__file__).resolve().parent.parent))
12 |
13 | from data.routeviews.fetch_updates import load_updates_to_df, get_all_collectors, get_archive_list, download_data
14 | from monitor import Monitor
15 |
16 | SCRIPT_DIR = Path(__file__).resolve().parent
17 |
18 | def detect(data, route_change_dir, snapshot_dir):
19 | mon = Monitor()
20 |
21 | for fpath in data:
22 | _, date, time = fpath.name.split(".")
23 |
24 | df = load_updates_to_df(fpath)
25 | df = df.sort_values(by="timestamp")
26 |
27 | mon.consume(df, detect=True)
28 |
29 | route_change_df = pd.DataFrame.from_records(mon.route_changes)
30 | mon.route_changes = []
31 |
32 | route_change_df.to_csv(route_change_dir/f"{date}.{time}.csv", index=False)
33 |
34 | if time == "2345":
35 | pickle.dump(mon, open(snapshot_dir/f"{date}.end-of-the-day", "wb"))
36 |
37 | @click.command()
38 | @click.option("--collector", "-c", type=str, default="wide", help="the name of RouteView collector to use")
39 | @click.option("--year", "-y", type=int, required=True, help="the year to monitor, e.g., 2024")
40 | @click.option("--month", "-m", type=int, required=True, help="the month to monitor, e.g., 8")
41 | def detect_monthly_for(collector, year, month):
42 | result_dir = SCRIPT_DIR/"detection_result"/collector
43 | route_change_dir = result_dir/"route_change"
44 | snapshot_dir = result_dir/"snapshot"
45 |
46 | route_change_dir.mkdir(exist_ok=True, parents=True)
47 | snapshot_dir.mkdir(exist_ok=True, parents=True)
48 |
49 | collectors2url = get_all_collectors()
50 |
51 | d1 = datetime(year=year, month=month, day=1)
52 | d2 = (datetime(year=year, month=month, day=28) + timedelta(days=4)
53 | ).replace(day=1) - timedelta(minutes=15)
54 |
55 | monthly_data = list(map(lambda url: download_data(url, collector),
56 | get_archive_list(collector, collectors2url, d1, d2)))
57 |
58 | detect(monthly_data, route_change_dir, snapshot_dir)
59 |
60 | if __name__ == "__main__":
61 | detect_monthly_for()
62 |
--------------------------------------------------------------------------------
/anomaly_detector/BEAM_diff_evaluator_routeviews.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | #-*- coding: utf-8 -*-
3 |
4 | from functools import lru_cache
5 | from pathlib import Path
6 | import pandas as pd
7 | import click
8 |
9 | from utils import load_emb_distance
10 |
11 | repo_dir = Path(__file__).resolve().parent.parent
12 | model_dir = repo_dir/"BEAM_engine"/"models"
13 |
14 | @click.command()
15 | @click.option("--collector", "-c", type=str, default="wide", help="the name of RouteView collector that the route changes to evaluate are from")
16 | @click.option("--year", "-y", type=int, required=True, help="the year of the route changes monitored, e.g., 2024")
17 | @click.option("--month", "-m", type=int, required=True, help="the month of the route changes monitored, e.g., 8")
18 | @click.option("--beam-model", "-b", type=str, required=True, help="the trained BEAM model to use, e.g., 20240801.as-rel2.1000.10.128")
19 | def evaluate_monthly_for(collector, year, month, beam_model):
20 | collector_result_dir = repo_dir/"routing_monitor"/"detection_result"/collector
21 | route_change_dir = collector_result_dir/"route_change"
22 | beam_metric_dir = collector_result_dir/"BEAM_metric"
23 | beam_metric_dir.mkdir(exist_ok=True, parents=True)
24 |
25 | emb_dir = model_dir/beam_model
26 | emb_d, dtw_d, path_d, emb, _, _ = load_emb_distance(emb_dir, return_emb=True)
27 |
28 | def dtw_d_only_exist(s, t):
29 | return dtw_d([i for i in s if i in emb], [i for i in t if i in emb])
30 |
31 | for i in route_change_dir.glob(f"{year}{month:02d}*.csv"):
32 | beam_metric_file = beam_metric_dir/f"{i.stem}.bm.csv"
33 | if beam_metric_file.exists(): continue
34 |
35 | df = pd.read_csv(i)
36 |
37 | path1 = [s.split(" ") for s in df["path1"].values]
38 | path2 = [t.split(" ") for t in df["path2"].values]
39 |
40 | metrics = pd.DataFrame.from_dict({
41 | "diff": [dtw_d(s,t) for s,t in zip(path1, path2)],
42 | "diff_only_exist": [dtw_d_only_exist(s,t) for s,t in zip(path1, path2)],
43 | "path_d1": [path_d(i) for i in path1],
44 | "path_d2": [path_d(i) for i in path2],
45 | "path_l1": [len(i) for i in path1],
46 | "path_l2": [len(i) for i in path2],
47 | "head_tail_d1": [emb_d(i[0], i[-1]) for i in path1],
48 | "head_tail_d2": [emb_d(i[0], i[-1]) for i in path2],
49 | })
50 |
51 | metrics.to_csv(beam_metric_file, index=False)
52 |
53 | if __name__ == "__main__":
54 | evaluate_monthly_for()
55 |
--------------------------------------------------------------------------------
/routing_monitor/monitor.py:
--------------------------------------------------------------------------------
1 | import pandas as pd
2 | import ipaddress
3 |
4 | class Monitor:
5 | class Node:
6 | def __init__(self):
7 | self.routes = dict() # forwarder -> aspath
8 | self.left = None
9 | self.right = None
10 |
11 | def get_left(self):
12 | if self.left is None:
13 | self.left = Monitor.Node()
14 | return self.left
15 |
16 | def get_right(self):
17 | if self.right is None:
18 | self.right = Monitor.Node()
19 | return self.right
20 |
21 | def find_route(self, forwarder):
22 | if forwarder in self.routes:
23 | return self.routes[forwarder]
24 | return None
25 |
26 | def __init__(self):
27 | self.root = Monitor.Node()
28 | self.route_changes = []
29 |
30 | def update(self, timestamp, prefix_str, vantage_point, aspath_str, detect):
31 | prefix = ipaddress.ip_network(prefix_str)
32 |
33 | if prefix.version == 6: return
34 | prefixlen = prefix.prefixlen
35 | prefix = int(prefix[0]) >> (32-prefixlen)
36 |
37 | aspath = aspath_str.split(" ")
38 | forwarder = aspath[0] # NOTE: forwarder could be vantage point, or could not
39 |
40 | n = self.root
41 | original_route = None
42 | for shift in range(prefixlen-1, -1, -1): # find the original route
43 | left = (prefix >> shift) & 1
44 |
45 | if left: n = n.get_left()
46 | else: n = n.get_right()
47 |
48 | if n.find_route(forwarder) is not None:
49 | original_route = [shift, n.find_route(forwarder)]
50 |
51 | if detect and original_route is not None:
52 | shift, original_path = original_route
53 | vict_prefix = ipaddress.ip_network(prefix_str) \
54 | .supernet(new_prefix=prefixlen-shift)
55 | if aspath != original_path:
56 | self.route_changes.append({
57 | "timestamp" : timestamp,
58 | "vantage_point": vantage_point,
59 | "forwarder" : forwarder,
60 | "prefix1" : str(vict_prefix),
61 | "prefix2" : prefix_str,
62 | "path1" : " ".join(original_path),
63 | "path2" : " ".join(aspath),
64 | })
65 |
66 | n.routes[forwarder] = aspath
67 |
68 | def consume(self, df, detect=False):
69 | if "A/W" in df.columns:
70 | df = df.loc[df["A/W"] == "A"] # NOTE: fair move
71 | cols = ["timestamp", "prefix", "peer-asn", "as-path"]
72 |
73 | for a in df[cols].values:
74 | self.update(*a, detect=detect)
75 |
--------------------------------------------------------------------------------
/post_processor/rpki_validator.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | #-*- coding: utf-8 -*-
3 | import requests
4 | import ipaddress
5 | import lzma
6 | from pathlib import Path
7 | import pandas as pd
8 |
9 | script_dir = Path(__file__).resolve().parent
10 | cache_dir = script_dir/"rpki_cache"
11 | cache_dir.mkdir(parents=True, exist_ok=True)
12 |
13 | def fetch_and_uncompress_xz(url, output_path):
14 | if output_path.exists():
15 | return output_path
16 | try:
17 | temp_xz_file = output_path.with_suffix(output_path.suffix + ".xz")
18 |
19 | response = requests.get(url, stream=True)
20 | response.raise_for_status()
21 |
22 | with temp_xz_file.open("wb") as file:
23 | for chunk in response.iter_content(chunk_size=8192):
24 | file.write(chunk)
25 |
26 | with lzma.open(temp_xz_file, "rb") as xz_file:
27 | with output_path.open("wb") as out_file:
28 | out_file.write(xz_file.read())
29 |
30 | temp_xz_file.unlink()
31 |
32 | return output_path
33 |
34 | except requests.RequestException as e:
35 | print(f"Error fetching file from {url}: {e}")
36 | except lzma.LZMAError as e:
37 | print(f"Error decompressing the .xz file: {e}")
38 | except Exception as e:
39 | print(f"An unexpected error occurred: {e}")
40 |
41 | # check this out if the current one is down: http://josephine.sobornost.net/
42 | def sync_cache(year, month, day, source="https://ftp.ripe.net/rpki"):
43 | dfs = []
44 | for rir in ["apnic", "afrinic", "arin", "lacnic", "ripencc"]:
45 | url = f"{source}/{rir}.tal/{year}/{month:02d}/{day:02d}/roas.csv.xz"
46 | output_path = cache_dir/f"roas-{rir}-{year}{month:02d}{day:02d}.csv"
47 | df = pd.read_csv(fetch_and_uncompress_xz(url, output_path))
48 | df["TA"] = rir
49 | dfs.append(df)
50 | return pd.concat(dfs, ignore_index=True)
51 |
52 | class RPKI:
53 | class PrefixNode:
54 | def __init__(self):
55 | self.left = None
56 | self.right = None
57 | self.data = []
58 |
59 | def get_left(self):
60 | if self.left is None:
61 | self.left = RPKI.PrefixNode()
62 | return self.left
63 |
64 | def get_right(self):
65 | if self.right is None:
66 | self.right = RPKI.PrefixNode()
67 | return self.right
68 |
69 | def update_data(self, **kwargs):
70 | self.data.append(kwargs)
71 |
72 | def __init__(self):
73 | self.root = RPKI.PrefixNode()
74 |
75 | def load_data(self, year, month, day):
76 | df = sync_cache(year, month, day)
77 | for _, row in df.iterrows():
78 | if row["IP Prefix"][-2:] == "/0": continue
79 | directions = self.prefix_to_dirs(row["IP Prefix"])
80 | if not directions: continue
81 | self.create_node(directions).update_data(**row.to_dict())
82 | return self
83 |
84 | @staticmethod
85 | def prefix_to_dirs(prefix_str):
86 | prefix = ipaddress.ip_network(prefix_str)
87 | if prefix.version == 6: return None
88 | prefixlen = prefix.prefixlen
89 | prefix = int(prefix[0]) >> (32-prefixlen)
90 | directions = [(prefix>>shift)&1
91 | for shift in range(prefixlen-1, -1, -1)]
92 | return directions
93 |
94 | def create_node(self, directions):
95 | n = self.root
96 | for left in directions:
97 | if left: n = n.get_left()
98 | else: n = n.get_right()
99 | return n
100 |
101 | def match_node(self, directions):
102 | matched = []
103 | n = self.root
104 | for left in directions:
105 | if left: n = n.get_left()
106 | else: n = n.get_right()
107 | if n is None: break
108 | if n.data: matched += n.data
109 | return matched
110 |
111 | def validate(self, prefix_str, asn_str):
112 | directions = self.prefix_to_dirs(prefix_str)
113 |
114 | if not directions: return "Not Found"
115 |
116 | matched = self.match_node(directions)
117 |
118 | if not matched: return "Not Found"
119 |
120 | for roa in matched:
121 | if int(prefix_str.split("/")[-1]) <= int(roa["Max Length"]) \
122 | and f"AS{asn_str}" == roa["ASN"]:
123 | return "Valid"
124 |
125 | return "Invalid"
126 |
127 | def all_matched(self, prefix_str):
128 | directions = self.prefix_to_dirs(prefix_str)
129 |
130 | if not directions: return []
131 |
132 | matched = self.match_node(directions)
133 |
134 | return matched
135 |
--------------------------------------------------------------------------------
/data/bgpstream/fetch_data.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | #-*- coding: utf-8 -*-
3 |
4 | from pathlib import Path
5 | import numpy as np
6 | from urllib.parse import urljoin
7 | from concurrent.futures import ThreadPoolExecutor
8 | import subprocess
9 | import json
10 | import re
11 |
12 | SCRIPT_DIR = Path(__file__).resolve().parent
13 | CACHE_DIR = SCRIPT_DIR/"cache"
14 | CACHE_DIR.mkdir(parents=True, exist_ok=True)
15 |
16 | url_index="https://bgpstream.crosswork.cisco.com/"
17 |
18 | def get_page(url):
19 | page = subprocess.check_output(["curl", "-s", url]).decode()
20 | return page
21 |
22 | def item_parser(index_page):
23 | events = []
24 | for item_str in re.finditer(r'\.+?\ ', index_page, flags=re.DOTALL):
25 | try:
26 | item_str = item_str[0]
27 | item = dict()
28 | for k, v in re.findall(r'\
10 |
11 | The system consists of three main modules:
12 |
13 | - **BEAM Engine** (`BEAM_engine/`): Uses AS business relationship data as input to train the BEAM model, which is used to quantify the path difference (abnormality) of route changes.
14 |
15 | - **Routing Monitor** (`routing_monitor/`): Takes BGP update announcements as input and outputs detected route changes.
16 |
17 | - **Anomaly Detector** (`anomaly_detector/`): Performs anomaly detection on the route changes and conducts correlation analysis on detected anomalous routing changes, outputting anomaly alarms.
18 |
19 | A post-processing module (`post_processor/`) is additionally introduced for anomaly inspection and well-formatted HTML reports.
20 |
21 | ## Workflow
22 |
23 | A typical workflow with this codebase is as follows:
24 |
25 | 1. Train the BEAM model.
26 | 2. Detect route changes from a window of routing data.
27 | 3. Use the BEAM model to quantify the path difference of the route changes.
28 | 4. Identify those with abnormally high path difference, aggregate them, and raise alarms.
29 | 5. Generate a formatted anomaly report.
30 |
31 | ## Get Started
32 |
33 | ### 0. Prepare the environment
34 |
35 | - Python (>=3.8) is required, along with necessary packages. GPU and CUDA is recommended for model training.
36 |
37 | Set it up using Anaconda or Miniconda as follows:
38 |
39 | ```bash
40 | conda create -n beam python=3.8 numpy pandas scipy tqdm joblib click pytorch torchvision torchaudio pytorch-cuda=11.8 -c pytorch -c nvidia -y
41 | conda activate beam
42 | ```
43 |
44 | - The [BGPdump tool](https://github.com/RIPE-NCC/bgpdump) is required for parsing MRT-format BGP routing data.
45 |
46 | Build it from source, and link the binary to `$YOUR_REPO_PATH/data/routeviews/bgpd`, as follows:
47 |
48 | ```bash
49 | git clone https://github.com/RIPE-NCC/bgpdump.git
50 | cd bgpdump
51 | sh ./bootstrap.sh
52 | make
53 | ln -s $(pwd)/bgpdump $YOUR_REPO_PATH/data/routeviews/bgpd
54 | $YOUR_REPO_PATH/data/routeviews/bgpd -T # should print the test output
55 | ```
56 |
57 | ### 1. Train the BEAM model
58 |
59 | Run `BEAM_engine/train.py` for model training. See all available parameters with `--help`.
60 |
61 | An example run is as follows:
62 |
63 | ```bash
64 | python train.py --serial 2 \
65 | --time 20240801 \
66 | --Q 10 \
67 | --dimension 128 \
68 | --epoches 1000 \
69 | --device 0 \
70 | --num-workers 10
71 | ```
72 |
73 | This example downloads the [CAIDA AS relationship data](https://publicdata.caida.org/datasets/as-relationships/) of serial-2, Aug. 1, 2024, to train a BEAM model for 1000 epoches, with the number of negative samples (`Q`) set to 10 and the embedding vector dimension (`d`) set to 128. Training is executed on device 0 (either CPU or GPU, depending on the machine), and up to 10 parallel workers are used for data processing.
74 |
75 | Notes:
76 |
77 | - The CAIDA AS relationship data is updated monthly. A typical archive today contains approximately 500,000 AS relationship records and is around 1.5MB in size.
78 |
79 | - The required CAIDA data is downloaded upon first use and stored in either `data/caida_as_rel/serial-1/` or `data/caida_as_rel/serial-2/`. Alternatively, other sources can be used if they follow the same format.
80 |
81 | - The trained model is saved in a directory under `BEAM_engine/models/`, named according to the training parameters. This includes the trained embedding vectors (`link.emb`, `node.emb`, `rela.emb`).
82 |
83 | - For reference, on a dual-core Xeon E5-2650v4 with a GeForce RTX 2080 Ti, training for 1000 epoches takes about 10 hours, with peak memory usage within 10GB.
84 |
85 | ### 2. Detect route changes
86 |
87 | Run `routing_monitor/detect_route_change_routeviews.py` for monthly route change detection. See all available parameters with `--help`.
88 |
89 | An example run is as follows:
90 |
91 | ```bash
92 | python detect_route_change_routeviews.py \
93 | --collector wide \
94 | --year 2024 \
95 | --month 8
96 | ```
97 |
98 | This example downloads and identifies route changes with the BGP update announcements from the `wide` collector of [RouteViews](http://routeviews.org/), for the entire month of August, 2024.
99 |
100 | Notes:
101 |
102 | - RouteViews maintains over 30 collectors, each of which archives BGP update announcements in [MRT format](https://www.rfc-editor.org/rfc/rfc6396) at approximately 15-minute intervals. BGPdump is called as a subprocess to load these data. Other sources of data can also be used if they adhere to the MRT format.
103 |
104 | - The required RouteViews data is downloaded upon first use and stored in a directory under `data/routeviews/updates/`, named after the chosen collector.
105 |
106 | - This script processes the routing data of an entire month sequentially, in an offline manner. A global routing table is maintained in a Trie structure to track the route changes. The results are stored in a directory under `routing_monitor/detection_result/`, named after the chosen collector. The results include the identified route changes and daily snapshots of the global routing table.
107 |
108 | ### 3. Quantify path difference
109 |
110 | Run `anomaly_detector/BEAM_diff_evaluator_routeviews.py` for path difference evaluation on the monthly route changes. See all available parameters with `--help`.
111 |
112 | An example run is as follows:
113 |
114 | ```bash
115 | python BEAM_diff_evaluator_routeviews.py \
116 | --collector wide \
117 | --year 2024 \
118 | --month 8 \
119 | --beam-model 20240801.as-rel2.1000.10.128
120 | ```
121 |
122 | This example uses the BEAM model trained in [Step 1](#1-train-the-beam-model) to evaluate the path difference of the route changes detected in [Step 2](#2-detect-route-changes).
123 |
124 | Notes:
125 |
126 | - This script evaluates the path difference for route changes of an entire month sequentially, in an offline manner. The results are stored in `BEAM_metric/`, under the same parent directory as the route change directory of the chosen collector.
127 |
128 | ### 4. Detect anomalies
129 |
130 | Run `anomaly_detector/report_anomaly_routeviews.py` to detect anomalies based on the path difference of route changes. See all available parameters with `--help`.
131 |
132 | An example run is as follows:
133 |
134 | ```bash
135 | python report_anomaly_routeviews.py \
136 | --collector wide \
137 | --year 2024 \
138 | --month 8
139 | ```
140 |
141 | This example detects anomalies based on the route changes detected in [Step 2](#2-detect-route-changes) and their path difference evaluated in [Step 3](#3-quantify-path-difference).
142 |
143 | Notes:
144 |
145 | - This script detects anomalies for route changes of an entire month sequentially, in an offline manner. The results are stored in `reported_alarms/`, under the same parent directory as the route change directory of the chosen collector.
146 |
147 | - The results include the anomaly alarms raised for each time window, in separate CSV files, as well as a JSON file describing the overall information of the month's detection. Each alarm contains the time window, prefixes, associated ASes, and corresponding anomalous route changes, all associated to a single anomaly.
148 |
149 | ### 5. Generate the report
150 |
151 | Run `post_processor/alarm_postprocess_routeviews.py` to incorporate additional knowledge, e.g., RPKI states, for identifying properties associated with the generated alarms. See all available parameters with `--help`.
152 |
153 | An example run is as follows:
154 |
155 | ```bash
156 | python alarm_postprocess_routeviews.py \
157 | --collector wide \
158 | --year 2024 \
159 | --month 8
160 | ```
161 |
162 | This example identifies properties associated with the alarms generated in [Step 4](#4-detect-anomalies).
163 |
164 | Notes:
165 |
166 | - This script utilizes additional knowledge to identify several properties associated with the alarms, for better understanding of the anomalies. The results are stored in `reported_alarms.flags/`, under the same parent directory as the route change directory of the chosen collector.
167 |
168 | - Each alarm would be associated with the following properties:
169 | - `subprefix_change`: the alarm includes route changes involving sub-prefixes.
170 | - `origin_change`: the alarm includes route changes involving change of origin ASes.
171 | - `origin_same_org`: the alarm includes origin changes where the different origin ASes are from the same organization.
172 | - `origin_country_change`: the alarm includes origin changes where the different origin ASes are from different countries.
173 | - `origin_connection`: the alarm includes origin changes where the different origin ASes are connected.
174 | - `origin_different_upstream`: the alarm includes route changes where the path go through different upstream providers from the same origin.
175 | - `origin_rpki_1`: the origin before the change is RPKI-valid.
176 | - `origin_rpki_2`: the origin after the change is RPKI-valid.
177 | - `unknown_asn_1`: the route before the change includes unknown ASN.
178 | - `unknown_asn_2`: the route after the change includes unknown ASN.
179 | - `reserved_path_1`: the route before the change includes reserved ASN.
180 | - `reserved_path_2`: the route after the change includes reserved ASN.
181 | - `non_valley_free_1`: the route before the change is non-valley-free.
182 | - `non_valley_free_2`: the route after the change is non-valley-free.
183 | - `none_rel_1`: the route before the change includes unknown links.
184 | - `none_rel_2`: the route after the change includes unknown links.
185 | - `as_prepend_1`: the route before the change includes prepended ASes.
186 | - `as_prepend_2`: the route after the change includes prepended ASes.
187 | - `detour_country`: the alarm includes route detouring through other countries.
188 | - `path1_in_path2`: the route before the change is the subset of that after the change.
189 | - `path2_in_path1`: the route after the change is the subset of that before the change.
190 |
191 | After the properties are associated, run `post_processor/summary_routeviews.py` to generate an HTML report about the month's detection results. See all available parameters with `--help`.
192 |
193 | An example run is as follows:
194 |
195 | ```bash
196 | python summary_routeviews.py \
197 | --collector wide \
198 | --year 2024 \
199 | --month 8
200 | ```
201 |
202 | This example will generate an HTML report and a JSON-line-format file from the alarms generated in [Step 4](#4-detect-anomalies).
203 |
204 | Notes:
205 |
206 | - The HTML report is stored in `post_processor/html/`, and the JSON-line-format file is stored in `post_processor/summary_output/`.
207 |
208 | - The HTML report is self-contained, with necessary descriptions of the terms used.
209 |
210 | ## Future Work
211 |
212 | **Updated on Sep. 13, 2024**:
213 |
214 | A full-featured, open-source version of the anomaly detection system is under development, aimed at deployment in production environments such as ISPs, and potentially as a public service to monitor the Internet and issue BGP anomaly warnings. This includes plans to refactor key functions using the Rust programming language and package them as Crates for public access. The current organization of these components is as follows:
215 |
216 | - **In progress:** A module to synchronize routing data in real time from RouteViews, RIPE RIS, and self-operated or peering ASes, stored locally in a database.
217 | - SQLite for local storage and management of routing data.
218 | - Part of functions from BGPstream.
219 | - Integration of BGPdump.
220 | - KVM/Docker support for virtual routers.
221 | - **Pending:** A module to train BEAM models using the latest CAIDA AS relationship data.
222 | - **Pending:** A module to process real-time routing data and detect anomalies.
223 | - **Pending:** A website or app for displaying and analyzing detection results in real time.
224 |
225 | ---
226 |
--------------------------------------------------------------------------------
/post_processor/summary_routeviews.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | #-*- coding: utf-8 -*-
3 |
4 | import json
5 | import pandas as pd
6 | import numpy as np
7 | from pathlib import Path
8 | from datetime import datetime
9 | import subprocess
10 | import calendar
11 | import click
12 |
13 | import sys
14 | sys.path.append(str(Path(__file__).resolve().parent.parent))
15 | from anomaly_detector.utils import event_aggregate
16 |
17 | @click.command()
18 | @click.option("--collector", "-c", type=str, default="wide", help="the name of RouteView collector to generate the report")
19 | @click.option("--year", "-y", type=int, required=True, help="the year of the detection results, e.g., 2024")
20 | @click.option("--month", "-m", type=int, required=True, help="the month of the detection results, e.g., 8")
21 | def main(collector, year, month):
22 | repo_dir = Path(__file__).resolve().parent.parent
23 | collector_result_dir = repo_dir/"routing_monitor"/"detection_result"/collector
24 | reported_alarm_dir = collector_result_dir/"reported_alarms"/f"{year}{month:02d}"
25 | route_change_dir = collector_result_dir/"route_change"
26 | info = json.load(open(reported_alarm_dir/f"info_{year}{month:02}.json", "r"))
27 | flags_dir = reported_alarm_dir.parent/f"{year}{month:02d}.flags"
28 |
29 | summary_dir = Path(__file__).resolve().parent/"summary_output"
30 | summary_dir.mkdir(parents=True, exist_ok=True)
31 |
32 | html_dir = Path(__file__).resolve().parent/"html"
33 |
34 | metric = "diff_balance"
35 |
36 | def has_flag(v):
37 | return v != "-"
38 | v_flag = np.vectorize(has_flag)
39 |
40 | def invalid_asn(v):
41 | return v == "Invalid"
42 | v_invalid_asn = np.vectorize(invalid_asn)
43 |
44 | def invalid_len(v):
45 | return v == "invalid_length"
46 | v_invalid_len = np.vectorize(invalid_len)
47 |
48 | def valid(v):
49 | return v == "valid"
50 | v_valid = np.vectorize(valid)
51 |
52 | def summary():
53 | global_group_id = 0
54 | dfs = []
55 | for i in info:
56 | if i["save_path"] is None: continue
57 | df = pd.read_csv(i["save_path"])
58 | flags = pd.read_csv(flags_dir/f"{Path(i['save_path']).stem}.flags.csv")
59 | df = pd.concat([df, flags], axis=1)
60 |
61 | # highly possible origin hijack
62 | anomaly_t1 = df["origin_change"] \
63 | & (~v_flag(df["origin_same_org"])) \
64 | & ((v_invalid_asn(df["origin_rpki_1"])
65 | ^ v_invalid_asn(df["origin_rpki_2"]))
66 | | (v_invalid_asn(df["origin_irr_1"])
67 | ^ v_invalid_asn(df["origin_irr_2"]))
68 | | (df["origin_whois_1"] ^ df["origin_whois_2"]))
69 |
70 | # highly possible route leak
71 | anomaly_t2 = v_flag(df["non_valley_free_1"]) \
72 | | v_flag(df["non_valley_free_2"])
73 |
74 | # highly possible path manipulation
75 | anomaly_t3 = v_flag(df["reserved_path_1"]) \
76 | | v_flag(df["reserved_path_2"]) \
77 | | v_flag(df["none_rel_1"]) \
78 | | v_flag(df["none_rel_2"]) \
79 | | np.isinf(df["diff"])
80 | exception_t3 = (~v_flag(df["reserved_path_1"])) \
81 | & (~v_flag(df["reserved_path_2"])) \
82 | & (v_flag(df["none_rel_1"]) \
83 | | v_flag(df["none_rel_2"]))
84 |
85 | # highly possible ROA/IRR/WHOIS misconfiguration
86 | anomaly_t4 = v_flag(df["origin_same_org"]) \
87 | & ((v_invalid_asn(df["origin_rpki_1"])
88 | ^ v_invalid_asn(df["origin_rpki_2"]))
89 | | (v_invalid_asn(df["origin_irr_1"])
90 | ^ v_invalid_asn(df["origin_irr_2"]))
91 | | (df["origin_whois_1"] ^ df["origin_whois_2"]))
92 |
93 | # highly possible benign MOAS
94 | benign_t1 = df["origin_change"] \
95 | & (v_flag(df["origin_same_org"])
96 | | v_flag(df["origin_connection"])
97 | | (v_valid(df["origin_rpki_1"])
98 | & v_valid(df["origin_rpki_2"])))
99 |
100 | # highly possible AS prepending
101 | benign_t2 = (~df["origin_change"]) \
102 | & (has_flag(df["as_prepend_1"])
103 | ^ has_flag(df["as_prepend_2"]))
104 |
105 | # highly possible multi-homing
106 | benign_t3 = v_flag(df["origin_different_upstream"])
107 |
108 | # no any sign of anomaly
109 | benign_t4 = (~df["detour_country"]) \
110 | & v_valid(df["origin_rpki_1"]) \
111 | & v_valid(df["origin_rpki_2"])
112 |
113 | # possible false alarms due to the nature of diff computation
114 | benign_t5 = (df["path_l1"]+df["path_l2"])/2 <= 3
115 |
116 | # possible prefix transfer
117 | benign_t6 = df["path1_in_path2"]
118 |
119 |
120 | df["a1"] = anomaly_t1
121 | df["a2"] = anomaly_t2
122 | df["a3"] = anomaly_t3
123 | df["a4"] = anomaly_t4
124 | df["b1"] = benign_t1
125 | df["b2"] = benign_t2
126 | df["b3"] = benign_t3
127 | df["b4"] = benign_t4
128 | df["b5"] = benign_t5
129 | df["b6"] = benign_t6
130 |
131 | anomaly = anomaly_t1 | anomaly_t2 | anomaly_t3 | anomaly_t4
132 | benign = benign_t1 | benign_t2 | benign_t3 | benign_t4 | benign_t5 | benign_t6
133 |
134 | df["pattern"] = "unknown"
135 | df.loc[benign, ["pattern"]] = "benign"
136 | df.loc[anomaly, ["pattern"]] = "anomaly"
137 |
138 | df = df.loc[anomaly | (~benign)] # post-filtering
139 | df = df.loc[(~exception_t3)|anomaly_t1|anomaly_t2|anomaly_t4]
140 |
141 | event_key = i["event_key"]
142 | forwarder_th = i["forwarder_th"]
143 |
144 | events = {}
145 | for key,ev in df.groupby(event_key): # re-grouping and filtering
146 | if ev.shape[0] <= forwarder_th: continue
147 | events[key] = ev
148 |
149 | if events:
150 | _, df = event_aggregate(events)
151 | n_alarms = len(df["group_id"].unique())
152 | assert np.max(df["group_id"]) == n_alarms-1, f"{np.max(df['group_id'])}, {n_alarms-1}"
153 | df["group_id"] += global_group_id
154 | global_group_id += n_alarms
155 | dfs.append(df)
156 |
157 | df = pd.concat(dfs)
158 | df.to_csv(summary_dir/f"alarms_after_post_process_{collector}_{year}{month:02}.csv", index=False)
159 | return df
160 |
161 | df = summary()
162 |
163 | def reason(tag, row):
164 | if tag == "a1":
165 | fields = ["origin_rpki_1", "origin_rpki_2", "origin_irr_1", "origin_irr_2", "origin_whois_1", "origin_whois_2"]
166 | elif tag == "a2":
167 | fields = ["non_valley_free_1", "non_valley_free_2"]
168 | elif tag == "a3":
169 | fields = ["reserved_path_1", "reserved_path_2",
170 | "none_rel_1", "none_rel_2", "unknown_asn_1", "unknown_asn_2"]
171 | elif tag == "a4":
172 | fields = ["origin_same_org", "origin_rpki_1", "origin_rpki_2", "origin_irr_1", "origin_irr_2", "origin_whois_1", "origin_whois_2"]
173 | elif tag == "b1":
174 | fields = ["origin_same_org", "origin_connection",
175 | "origin_rpki_1", "origin_rpki_2"]
176 | elif tag == "b2":
177 | fields = ["as_prepend_1", "as_prepend_2"]
178 | elif tag == "b3":
179 | fields = ["origin_different_upstream"]
180 | elif tag == "b4":
181 | fields = ["origin_rpki_1", "origin_rpki_2"]
182 | elif tag == "b5":
183 | fields = []
184 |
185 | r = {i: str(row[i]) for i in fields if has_flag(row[i])}
186 | return r
187 |
188 | def terminal_checkout(group_id, group):
189 | tags = ["a1", "a2", "a3", "a4", "b1", "b2", "b3", "b4", "b5"]
190 |
191 | print(f"alarm_id: {group_id}")
192 | for prefix_key, ev in group.groupby(["prefix1", "prefix2"]):
193 | print(f"* {' -> '.join(prefix_key)}")
194 | for _, row in ev.iterrows():
195 | print(f" path1: {row['path1']}")
196 | print(f" path2: {row['path2']}")
197 | print(f" diff={row[metric]}")
198 | print(f" culprit={row['culprit']}")
199 | for k,v in zip(tags, row[tags]):
200 | if v:
201 | r = reason(k, row)
202 | print(f"{k}: ", end="")
203 | print(",".join([f"{x}={y}" for x,y in r.items()]))
204 | print()
205 | input("..Enter to next")
206 |
207 | def json_checkout(group_id, group):
208 | tags = ["a1", "a2", "a3", "a4", "b1", "b2", "b3", "b4", "b5"]
209 |
210 | timestamp = group["timestamp"].values
211 | fmt = "%a %d %b %Y, %I:%M%p"
212 | start_time = datetime.fromtimestamp(timestamp.min()).strftime(fmt)
213 | end_time = datetime.fromtimestamp(timestamp.max()).strftime(fmt)
214 |
215 | events = []
216 | for prefix_key, ev in group.groupby(["prefix1", "prefix2"]):
217 | route_changes = []
218 | for _, row in ev.iterrows():
219 | route_changes.append({
220 | "timestamp": int(row["timestamp"]),
221 | "path1": str(row["path1"]),
222 | "path2": str(row["path2"]),
223 | "diff": float(row[metric]),
224 | "culprit": json.loads(str(row['culprit'])),
225 | "patterns": {k: reason(k, row) for k,v in zip(tags, row[tags]) if v},
226 | })
227 |
228 | events.append({
229 | "prefix": prefix_key,
230 | "route_changes": route_changes
231 | })
232 |
233 | ret = {
234 | "group_id": group_id,
235 | "start_time": start_time,
236 | "end_time": end_time,
237 | "events": events,
238 | }
239 | # print(json.dumps(ret, indent=2))
240 | return ret
241 |
242 | def group_html_checkout(group_id, group):
243 | tags = ["a1", "a2", "a3", "a4", "b1", "b2", "b3", "b4", "b5"]
244 |
245 | timestamp = group["timestamp"].values
246 | fmt = "%Y/%m/%d %H:%M:%S"
247 | start_time = datetime.fromtimestamp(timestamp.min()).strftime(fmt)
248 | end_time = datetime.fromtimestamp(timestamp.max()).strftime(fmt)
249 |
250 | def text_color(s, color):
251 | return f'{s}'
252 |
253 | events = []
254 | for prefix_key, ev in group.groupby(["prefix1", "prefix2"]):
255 | route_changes = []
256 | for _, row in ev.iterrows():
257 | timestamp = f"timestamp: {row['timestamp']}
" 258 | path1 = f"path1: {row['path1']}
" 259 | path2 = f"path2: {row['path2']}
" 260 | diff = f"diff: {row[metric]}
" 261 | culprit = f"culprit: {row['culprit']}
" 262 | 263 | patterns = [] 264 | for k,v in zip(tags, row[tags]): 265 | if v: 266 | r = reason(k, row) 267 | p = f"{k}: "+",".join([f"{x}={y}" for x,y in r.items()])+"
" 268 | patterns.append(p) 269 | pattern_part = "patterns: " 270 | if not patterns: 271 | pattern_part += "none" 272 | pattern_part += "
" 273 | 274 | rc_html = "{text_color(p0,c)} -> {text_color(p1,c)}
' 290 | route_change_part = "\n".join(route_changes) 291 | 292 | ev_html = "Route change total:{route_change_cnts:,},Alarm total:{daily_cnts.sum():,}(daily {daily_cnts.mean():.2f})。Among them,{daily_cnts_a.sum():,} show known anomalous patterns, {daily_cnts.sum()-daily_cnts_a.sum():,} unknown anomalous patterns