diff --git a/982_config_export.txt b/982_config_export.txt deleted file mode 100644 index 6e7770c..0000000 --- a/982_config_export.txt +++ /dev/null @@ -1,22 +0,0 @@ -# Configuration for router (10.254.254.110) -# Exported on 2025-10-04 11:22:56 - -# IP Addresses -/ip address add address=10.254.254.110/32 interface=loopback -/ip address add address=10.250.1.33/29 interface=ether7-380 -/ip address add address=10.10.63.254/20 interface=mgmt -/ip address add address=100.64.35.254/22 interface=982 -/ip address add address=204.110.188.126/27 interface=982 - -# IP Pools -/ip pool add name=cgnat ranges=100.64.32.1-100.64.35.199 -/ip pool add name=mgmt ranges=10.10.48.1-10.10.62.254 -/ip pool add name=public ranges=204.110.189.1-204.110.189.29 - -# PPPoE Servers -/interface pppoe-server server add name=982 interface=982 default-profile=982 - -# PPP Profiles -/ppp profile add name=982 local-address=100.64.35.253 remote-address=cgnat - -# NAT Rules diff --git a/CLAUDE.md b/CLAUDE.md index 0db7623..96e7087 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -138,7 +138,7 @@ above the `fasttrack-connection` rule on both endpoints. Documented in ``` verona ──AF11── climax ──AF24── core ──AF11── culleoka │ │ │ - │ AF11 │ AF11 │ AF11 (DOWN: power injector unplugged) + │ 5GHz │ AF11 │ AF11 (DOWN: power injector unplugged) │ │ │ 494 newhope ──AF24── lowry │ @@ -146,7 +146,7 @@ verona ──AF11── climax ──AF24── core ──AF11── culleoka │ 982 ``` -Wait — that diagram's links are: climax↔494 (AF11), core↔newhope (AF11), +Wait — that diagram's links are: climax↔494 (5 GHz airMAX, **not** AF11 — see below), core↔newhope (AF11), core↔982 (60 GHz), newhope↔lowry (AF24). The climax↔culleoka direct AF11 is currently down at the radio (physical issue), so culleoka traffic transits via core. @@ -179,7 +179,7 @@ fasttrack-bypass rules in both directions on both routers. |--|--|--|--|--|--| | verona↔climax | AF11 | verona `ether3-climax-11ghz` | climax `ether6-verona-11ghz` | 10.250.1.24/29 | 2024 | | climax↔core | AF24 | climax `ether4-380-airfiber24` | core `ether5-climax` | 10.250.1.88/29 | 2024 | -| climax↔494 | AF11 | climax `ether5-494` | 494 `ether2-climax` | 10.250.1.64/29 | 1580 | +| climax↔494 | 5 GHz airMAX | climax `ether5-494` | 494 `ether2-climax` | 10.250.1.64/29 | 1580 | | climax↔culleoka | AF11 | climax `ether3-culleoka-11ghz` | culleoka `ether1-climax-11ghz` | 10.250.1.8/29 | 2024 (link DOWN) | | core↔culleoka | AF11 | core `ether6-culleoka-11ghz` | culleoka `ether6-380-11ghz` | 10.250.1.48/29 | 2024 | | core↔newhope | AF11 | core `ether4-newhope` | newhope `ether2-380` | 10.250.1.56/29 | 9000 | @@ -194,12 +194,32 @@ sides symmetrically when changing l2mtu** — single-side raises usually work because Ethernet receivers accept anything ≤ their cap, but symmetric is the rule. +### climax↔494 backhaul is 5 GHz airMAX, not AF11 +Old docs called this hop AF11; it is actually a Ubiquiti airMAX AC PtP +pair (SSID `vntx_pr_494`, WPA2, 40 MHz wide): AP **"Climax to 494"** +(PBE-5AC-500) at `10.250.1.69` on the climax side, station **"494 to +Climax"** (PBE-5AC-400) at `10.250.1.66` on the 494 side. Both answer +SNMP v1 (`kdyyJrT0Mm`, airMAX MIB `1.3.6.1.4.1.41112.1.4`); added to +`radios.yaml` 2026-07-17. The station's mgmt IP (and the whole 494 +tower) is only reachable **across this RF link** — when changing the +channel, first make sure the station's frequency scan list covers the +target, then change the AP (climax side), which stays reachable for +rollback either way. + +2026-07-17 flap incident: link ran on **5260 MHz (DFS)**; associations +dropped for 40–90 s every 45–130 min (OSPF 40 s dead-interval timeouts +on both routers; ethernet ports never dropped, radios never rebooted — +association-uptime SNMP counters matched each OSPF flap). Local 5 GHz +survey: climax APs on 5335/5545/5575 (airMAX) + 5750/5775 (ePMP), 494 +ePMP omni on 5800 → U-NII-1 (5170–5250) was empty at both towers and +is non-DFS, so the fix was moving the PtP there. + ### IGP / routing - OSPFv2 area `backbone-v2` (id 0.0.0.0) on all spine links, SHA-512 auth with `auth-id=1` and a shared key. PTP type. **BFD is off on every wireless backbone interface (AF11/AF24/60GHz)** — the global timers (`/routing/bfd/configuration` = 200ms×5 = 1s detection) are too - aggressive for AF RF: a single >1s burst tore down OSPF on + aggressive for backhaul RF: a single >1s burst tore down OSPF on climax↔494 every ~15–40 min until `use-bfd=false` was applied to both ends 2026-05-09. Wired fiber links may keep BFD if desired. If sub-second failover on a wireless link is genuinely needed, also @@ -241,6 +261,33 @@ without it, hotspot drops all `204.110.191.x` traffic in `hs-unauth-to` chain with `icmp-host-prohibited`. Symptom is "I can reach verona but nothing past it" from the home network. +### graham's home network — multi-WAN failover (recursive check-gateway) +Home (RB5009, `10.0.19.254`, **not** in `routers.yaml` — use a temp +config with the same default creds to reach it via mikrotik-tool) fails +over between TMO (distance 1), VNTX/verona (distance 2), and Starlink +(distance 3, disabled) using the standard ROS recursive pattern, applied +2026-07-17 via `mikrotik-tool/home-recursive-failover.rsc`: +- Probe pins: `4.2.2.1/32→192.168.12.1%ether6-tmobile`, + `4.2.2.2/32→204.110.191.30%ether5-vntx-static`, + `4.2.2.3/32→192.168.1.1%ether7-starlink`, all `scope=10`, **no + check-gateway on the pins** — they stay active whenever the interface + has link, gluing probes to their WAN regardless of route state. +- Defaults: `gateway=4.2.2.x target-scope=11 check-gateway=ping` — the + check pings the probe IP through the pin; ~20s to go inactive, first + success to return. `tmo table default` (eweka policy routing) has the + same check so it falls back to main when TMO upstream dies. + +**Do not reintroduce netwatch enable/disable failover scripts.** The old +design (netwatch 2s/1s single-packet → `/ip route disable`) deadlocked: +`disabled=yes` persists across reboots, and the probe was only tied to +the VNTX path while its pin was active — so the verona hotspot-binding +loss (verona pingable, transit dead) left the route disabled forever, +and probes leaked out other WANs giving false "up" (TMO netwatch read up +with the TMO interface physically down). Same lesson as the fleet BFD +incident: 1-2s detection on AF RF with >1s bursts causes false failover; +10–20s check-gateway damping is intentional. + + ### IPv6 plan Per-tower /44s + central server LAN at `2606:1c80::/64` on edge. Full allocation plan in `mikrotik-tool/ipv6.md`. NetBox has these as IPAM diff --git a/backups/494-to-climax_STA_10.250.1.66_system.cfg.2026-07-17 b/backups/494-to-climax_STA_10.250.1.66_system.cfg.2026-07-17 new file mode 100644 index 0000000..18cb220 --- /dev/null +++ b/backups/494-to-climax_STA_10.250.1.66_system.cfg.2026-07-17 @@ -0,0 +1,198 @@ +spawn ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=8 -o NumberOfPasswordPrompts=1 -o PreferredAuthentications=password -o HostKeyAlgorithms=+ssh-rsa -o KexAlgorithms=+diffie-hellman-group14-sha1 ubnt@10.250.1.66 cat /tmp/system.cfg +aaa.1.radius.acct.1.port=1813 +aaa.1.radius.acct.1.status=disabled +aaa.1.radius.auth.1.port=1812 +aaa.1.status=disabled +aaa.1.wpa.psk=0000:0000 +aaa.status=disabled +airview.status=enabled +bridge.1.devname=br0 +bridge.1.port.1.devname=eth0 +bridge.1.port.1.status=enabled +bridge.1.port.2.devname=ath0 +bridge.1.port.2.status=enabled +bridge.1.status=enabled +bridge.1.stp.status=disabled +bridge.status=enabled +discovery.beacon.status=enabled +discovery.cdp.status=disabled +discovery.lldp.status=enabled +discovery.status=enabled +ebtables.status=enabled +ebtables.sys.eap.1.devname=ath0 +ebtables.sys.eap.1.status=enabled +ebtables.sys.eap.status=enabled +ebtables.sys.fw.status=disabled +ebtables.sys.fw6.status=disabled +ebtables.sys.status=enabled +gui.language=en_US +gui.network.advanced.status=disabled +httpd.https.port=443 +httpd.https.status=enabled +httpd.port=80 +httpd.session.timeout=900 +httpd.status=enabled +igmpproxy.status=disabled +iptables.status=disabled +iptables.sys.portfw.status=disabled +iptables.sys.status=enabled +netconf.1.autoip.status=disabled +netconf.1.devname=ath0 +netconf.1.mtu=1500 +netconf.1.status=enabled +netconf.1.up=enabled +netconf.2.autoip.status=disabled +netconf.2.autoneg=enabled +netconf.2.devname=eth0 +netconf.2.flowcontrol.rx.status=enabled +netconf.2.flowcontrol.tx.status=enabled +netconf.2.mtu=1500 +netconf.2.status=enabled +netconf.2.up=enabled +netconf.3.autoip.status=disabled +netconf.3.devname=br0 +netconf.3.ip6.status=enabled +netconf.3.ip=10.250.1.66 +netconf.3.mtu=1500 +netconf.3.netmask=255.255.255.248 +netconf.3.role=mlan +netconf.3.status=enabled +netconf.status=enabled +netmode=bridge +ntpclient.1.server=0.us.pool.ntp.org +ntpclient.1.status=enabled +ntpclient.status=enabled +pppoe-relay.status=disabled +pwdog.delay=300 +pwdog.period=300 +pwdog.retry=3 +pwdog.status=disabled +radio.1.ack.auto=enabled +radio.1.ackdistance=600 +radio.1.acktimeout=35 +radio.1.antenna.gain=25 +radio.1.antenna.id=1 +radio.1.atpc.sta.status=enabled +radio.1.atpc.status=disabled +radio.1.atpc.threshold=36 +radio.1.cable.loss=0 +radio.1.center.1.freq=5165 +radio.1.chanbw=0 +radio.1.cmsbias=0 +radio.1.countrycode=840 +radio.1.cwm.enable=0 +radio.1.cwm.mode=1 +radio.1.devname=ath0 +radio.1.dfs.slave=1 +radio.1.dfs.status=enabled +radio.1.ff_cap_rep=0 +radio.1.freq=5165 +radio.1.ieee_mode=auto +radio.1.low_txpower_mode=disabled +radio.1.mode=managed +radio.1.obey=enabled +radio.1.polling=enabled +radio.1.polling_ff_dl_ratio=50 +radio.1.polling_ff_dur=0 +radio.1.polling_ff_flex=0 +radio.1.polling_ff_sta_rx_rssi_th=0 +radio.1.pollingnoack=0 +radio.1.pollingpri=2 +radio.1.ptpmode=1 +radio.1.rc_mode=0 +radio.1.reg_obey=enabled +radio.1.rx_sensitivity=-96 +radio.1.scan_list.status=disabled +radio.1.scanbw.status=disabled +radio.1.status=enabled +radio.1.txpower=20 +radio.countrycode=840 +radio.status=enabled +resolv.host.1.name=494 to Climax +resolv.host.1.status=enabled +resolv.nameserver.1.ip=204.110.191.240 +resolv.nameserver.1.status=enabled +resolv.nameserver.2.ip=204.110.191.250 +resolv.nameserver.2.status=enabled +resolv.nameserver.status=enabled +route.1.devname=br0 +route.1.gateway=10.250.1.70 +route.1.ip=0.0.0.0 +route.1.netmask=0 +route.1.status=enabled +route.status=enabled +snmp.community=kdyyJrT0Mm +snmp.contact=vntx +snmp.location=vntx +snmp.status=enabled +sshd.auth.passwd=enabled +sshd.port=22 +sshd.status=enabled +syslog.remote.port=514 +syslog.remote.status=disabled +syslog.status=enabled +system.airosx.prov.status=enabled +system.autosend_crash_reports=disabled +system.cfg.editor.webui=2026-06-27T20:28:48.251Z +system.cfg.version=65547 +system.date.status=disabled +system.external.reset=enabled +system.height=0 +system.latitude=33.1801 +system.leds.nightmode=disabled +system.longitude=-96.498 +system.poe_passthrough=disabled +system.timezone.name=America/Chicago +system.timezone=CST6CDT,M3.2.0,M11.1.0 +telnetd.port=23 +telnetd.status=disabled +tshaper.status=disabled +unms.status=enabled +unms.ui_url=https://uisp.vntx.net +unms.uri=wss://uisp.vntx.net:443+NHjWcWdwuHlhAfrBqATpt6_Tz3-Sv-xYka64WI4hBGcAAAAA+allowUntrustedCertificate +update.check.status=enabled +users.1.name=ubnt +users.1.password=$1$.vDPgZgX$bg0TprBhNiUYo4uDoMwvi1 +users.1.status=enabled +users.2.gid=100 +users.2.shell=/bin/false +users.2.status=disabled +users.2.uid=100 +users.status=enabled +wireless.1.ampdu.frames=32 +wireless.1.ampdu.status=enabled +wireless.1.amsdu=3 +wireless.1.devname=ath0 +wireless.1.hide_ssid=disabled +wireless.1.l2_isolation=disabled +wireless.1.mac_acl.policy=allow +wireless.1.mac_acl.status=disabled +wireless.1.mcast.enhance=0 +wireless.1.rate.auto=enabled +wireless.1.rate.mcs=-1 +wireless.1.security.type=none +wireless.1.signal_led1=94 +wireless.1.signal_led2=80 +wireless.1.signal_led3=73 +wireless.1.signal_led4=65 +wireless.1.signal_led_status=enabled +wireless.1.ssid=vntx_pr_494 +wireless.1.status=enabled +wireless.1.wds.status=enabled +wireless.status=enabled +wpasupplicant.device.1.devname=ath0 +wpasupplicant.device.1.profile=AUTO +wpasupplicant.device.1.status=enabled +wpasupplicant.profile.1.name=AUTO +wpasupplicant.profile.1.network.1.eap.1.status=disabled +wpasupplicant.profile.1.network.1.key_mgmt.1.name=WPA-PSK +wpasupplicant.profile.1.network.1.pairwise.1.name=CCMP +wpasupplicant.profile.1.network.1.priority=100 +wpasupplicant.profile.1.network.1.proto.1.name=RSN +wpasupplicant.profile.1.network.1.psk=0000:0000 +wpasupplicant.profile.1.network.1.ssid=vntx_pr_494 +wpasupplicant.profile.1.network.1.status=enabled +wpasupplicant.profile.1.network.2.key_mgmt.1.name=NONE +wpasupplicant.profile.1.network.2.priority=2 +wpasupplicant.profile.1.network.2.status=disabled +wpasupplicant.status=enabled diff --git a/backups/climax-to-494_AP_10.250.1.69_system.cfg.2026-07-17 b/backups/climax-to-494_AP_10.250.1.69_system.cfg.2026-07-17 new file mode 100644 index 0000000..17e5e53 --- /dev/null +++ b/backups/climax-to-494_AP_10.250.1.69_system.cfg.2026-07-17 @@ -0,0 +1,204 @@ +spawn ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=8 -o NumberOfPasswordPrompts=1 -o PreferredAuthentications=password -o HostKeyAlgorithms=+ssh-rsa -o KexAlgorithms=+diffie-hellman-group14-sha1 ubnt@10.250.1.69 cat /tmp/system.cfg +aaa.status=enabled +aaa.1.devname=ath0 +aaa.1.radius.acct.1.port=1813 +aaa.1.radius.acct.1.status=disabled +aaa.1.radius.auth.1.port=1812 +aaa.1.radius.auth.1.status=disabled +aaa.1.ssid=vntx_pr_494 +aaa.1.status=enabled +aaa.1.wpa.key.1.mgmt=WPA-PSK +aaa.1.wpa.mode=2 +aaa.1.wpa.psk=0000:0000 +aaa.1.wpa.1.pairwise=CCMP +airview.status=enabled +bridge.status=enabled +bridge.1.devname=br0 +bridge.1.port.1.devname=eth0 +bridge.1.port.1.status=enabled +bridge.1.port.2.devname=ath0 +bridge.1.port.2.status=enabled +bridge.1.status=enabled +bridge.1.stp.status=disabled +discovery.beacon.status=enabled +discovery.cdp.status=enabled +discovery.lldp.status=enabled +discovery.status=enabled +ebtables.status=enabled +ebtables.sys.eap.status=enabled +ebtables.sys.eap.1.devname=ath0 +ebtables.sys.eap.1.status=enabled +ebtables.sys.fw.status=disabled +ebtables.sys.fw6.status=disabled +ebtables.sys.status=enabled +gui.language=en_US +gui.network.advanced.status=disabled +httpd.https.port=443 +httpd.https.status=enabled +httpd.https.tlsv1=enabled +httpd.port=80 +httpd.session.timeout=900 +httpd.status=enabled +igmpproxy.status=disabled +iptables.status=disabled +iptables.sys.portfw.status=disabled +iptables.sys.status=enabled +netconf.status=enabled +netconf.1.autoip.status=disabled +netconf.1.devname=ath0 +netconf.1.mtu=1500 +netconf.1.status=enabled +netconf.1.up=enabled +netconf.2.autoip.status=disabled +netconf.2.autoneg=enabled +netconf.2.devname=eth0 +netconf.2.flowcontrol.rx.status=enabled +netconf.2.flowcontrol.tx.status=enabled +netconf.2.mtu=1500 +netconf.2.status=enabled +netconf.2.up=enabled +netconf.3.autoip.status=disabled +netconf.3.devname=br0 +netconf.3.ip=10.250.1.69 +netconf.3.ip6.status=enabled +netconf.3.mtu=1500 +netconf.3.netmask=255.255.255.248 +netconf.3.role=mlan +netconf.3.status=enabled +netmode=bridge +ntpclient.status=enabled +ntpclient.1.server=0.us.pool.ntp.org +ntpclient.1.status=enabled +pppoe-relay.status=disabled +pwdog.delay=300 +pwdog.period=300 +pwdog.retry=3 +pwdog.status=disabled +radio.countrycode=840 +radio.status=enabled +radio.1.ack.auto=enabled +radio.1.ackdistance=600 +radio.1.acktimeout=35 +radio.1.antenna.gain=27 +radio.1.antenna.id=1 +radio.1.atpc.sta.status=enabled +radio.1.atpc.status=disabled +radio.1.atpc.threshold=36 +radio.1.cable.loss=0 +radio.1.center.1.freq=5270 +radio.1.chanbw=40 +radio.1.cmsbias=0 +radio.1.countrycode=840 +radio.1.cwm.enable=0 +radio.1.cwm.mode=1 +radio.1.devname=ath0 +radio.1.dfs.status=enabled +radio.1.ff_cap_rep=0 +radio.1.freq=5260 +radio.1.ieee_mode=11acvht40 +radio.1.low_txpower_mode=disabled +radio.1.mode=master +radio.1.obey=enabled +radio.1.polling=enabled +radio.1.polling_ff_dl_ratio=50 +radio.1.polling_ff_dur=0 +radio.1.polling_ff_flex=0 +radio.1.polling_ff_sta_rx_rssi_th=0 +radio.1.polling_11ac_11n_compat=0 +radio.1.pollingnoack=0 +radio.1.pollingpri=2 +radio.1.ptpmode=1 +radio.1.rc_mode=0 +radio.1.reg_obey=disabled +radio.1.rx_sensitivity=-96 +radio.1.scan_list.status=disabled +radio.1.scanbw.status=disabled +radio.1.status=enabled +radio.1.txpower=24 +resolv.host.1.name=Climax to 494 +resolv.host.1.status=enabled +resolv.nameserver.status=enabled +resolv.nameserver.1.ip=204.110.191.240 +resolv.nameserver.1.status=enabled +resolv.nameserver.2.ip=204.110.191.250 +resolv.nameserver.2.status=enabled +route.status=enabled +route.1.devname=br0 +route.1.gateway=10.250.1.70 +route.1.ip=0.0.0.0 +route.1.netmask=0 +route.1.status=enabled +snmp.community=kdyyJrT0Mm +snmp.contact=Verona Networks +snmp.location=vntx +snmp.status=enabled +sshd.auth.passwd=enabled +sshd.hostkey_dss=disabled +sshd.port=22 +sshd.status=enabled +syslog.remote.port=514 +syslog.remote.status=disabled +syslog.status=enabled +system.airosx.prov.status=enabled +system.autosend_crash_reports=disabled +system.cfg.editor.webui=2026-07-06T16:34:39.082Z +system.cfg.version=65547 +system.date.status=disabled +system.external.reset=enabled +system.height=0 +system.latitude=33.1801 +system.leds.nightmode=disabled +system.longitude=-96.498 +system.poe_passthrough=disabled +system.timezone=CST6CDT,M3.2.0,M11.1.0 +system.timezone.name=America/Chicago +telnetd.port=23 +telnetd.status=disabled +tshaper.status=disabled +unms.status=enabled +unms.ui_url=https://uisp.vntx.net +unms.uri=wss://uisp.vntx.net:443+xz9wxAnRIoFBZ30L4DUMQPPezD08hN-CXuge7VFMaIkAAAAA+allowUntrustedCertificate +update.check.status=enabled +users.status=enabled +users.1.name=ubnt +users.1.password=$1$CbZ01ZTh$yxjG1yPTbJ7wBlbif5hEC0 +users.1.status=enabled +users.2.gid=100 +users.2.shell=/bin/false +users.2.status=disabled +users.2.uid=100 +wireless.status=enabled +wireless.1.ampdu.frames=32 +wireless.1.ampdu.status=enabled +wireless.1.amsdu=3 +wireless.1.devname=ath0 +wireless.1.hide_ssid=disabled +wireless.1.l2_isolation=disabled +wireless.1.mac_acl.policy=allow +wireless.1.mac_acl.status=disabled +wireless.1.mcast.enhance=0 +wireless.1.rate.auto=enabled +wireless.1.rate.mcs=-1 +wireless.1.security.type=none +wireless.1.signal_led_status=enabled +wireless.1.signal_led1=94 +wireless.1.signal_led2=80 +wireless.1.signal_led3=73 +wireless.1.signal_led4=65 +wireless.1.ssid=vntx_pr_494 +wireless.1.status=enabled +wireless.1.wds.status=enabled +wpasupplicant.device.1.devname=ath0 +wpasupplicant.device.1.profile=AUTO +wpasupplicant.device.1.status=disabled +wpasupplicant.profile.1.name=AUTO +wpasupplicant.profile.1.network.1.key_mgmt.1.name=WPA-PSK +wpasupplicant.profile.1.network.1.pairwise.1.name=CCMP +wpasupplicant.profile.1.network.1.proto.1.name=RSN +wpasupplicant.profile.1.network.1.psk=0000:0000 +wpasupplicant.profile.1.network.1.ssid=vntx_pr_494 +wpasupplicant.profile.1.network.1.status=enabled +wpasupplicant.profile.1.network.2.key_mgmt.1.name=NONE +wpasupplicant.profile.1.network.2.priority=2 +wpasupplicant.profile.1.network.2.status=disabled +wpasupplicant.status=disabled diff --git a/cpes.txt b/cpes.txt new file mode 100644 index 0000000..098ddc5 --- /dev/null +++ b/cpes.txt @@ -0,0 +1,108 @@ + +## ePMP Force 110 (1 devices) + 00:04:56:F0:36:0F #125 Eddie Yarbrough 10.10.16.51 + +## ePMP Force 180 (36 devices) + 00:04:56:2E:B9:2E #185 Joanna Rodriguez 10.10.160.16 + 00:04:56:2F:D7:CC #2098 Stephen Beegle 10.10.160.2 + 00:04:56:2E:C9:FF #1829 Ashley Smith 10.10.160.6 + 58:C1:7A:AD:14:9A #357 Brian Swedlund 10.10.160.7 + 58:C1:7A:AC:65:08 #2200 Jennifer Boon 10.10.16.38 + 00:04:56:44:5E:7B #343 Tim Bagert 10.10.16.40 + 58:C1:7A:AD:22:EC #1650 luis Cabrera 10.10.64.1 + 58:C1:7A:AC:64:51 #2454 Nancy Avellaneda 10.10.64.5 + 58:C1:7A:AC:62:5F #2206 Wanda Davis 10.10.96.102 + 00:04:56:2F:DB:A1 #355 Treceda Schmid 10.10.96.11 + 58:C1:7A:AC:69:46 #2190 Ray Schaak 10.10.96.17 + 00:04:56:2E:C1:6E #177 JERRY L JOHNSON 10.10.96.2 + 00:04:56:2F:D4:BD #283 Paul Sammataro 10.10.96.29 + 00:04:56:2F:CE:8D #2375 Yolanda Judge 10.10.96.35 + 00:04:56:2F:DA:84 #1908 Gabriela Sereno 10.10.96.39 + 58:C1:7A:AD:17:85 #2043 Angelica Cuayahuitl 10.10.96.45 + 58:C1:7A:AC:63:19 #215 Kileigh Even 10.10.96.46 + 58:C1:7A:AC:5F:11 #1538 Shirley Elmore 10.10.96.5 + 00:04:56:2E:B7:6C #1316 Iris Delira 10.10.96.51 + 00:04:56:44:54:0A #2046 HEATHER WOMACK 10.10.96.54 + 00:04:56:44:50:AA #2023 Billy Horton 10.10.96.55 + 58:C1:7A:B0:B6:1F #1128 Patricia Vasquez 10.10.96.58 + 00:04:56:2F:DC:4C #10040 Thomas Graham 10.10.144.10 + 58:C1:7A:AC:6A:C3 #10059 Nicholas Terry 10.10.144.11 + 00:04:56:2F:CD:C7 #84 Coy Ungemach 10.10.144.2 + 58:C1:7A:AD:15:7B #1906 Georgina Covarrubias 10.10.144.6 + 58:C1:7A:AD:1F:20 #246 Matt Budjenska 10.10.158.247 + 58:C1:7A:B0:B7:AE #1950 Leandra Beshea 10.10.128.1 + 00:04:56:2F:D9:10 #1971 Clifford Jennings 10.10.128.11 + 58:C1:7A:AD:13:D7 #100 Dave Kiowski 10.10.128.14 + 00:04:56:44:5A:37 #997 Heather Brister 10.10.128.25 + 00:04:56:44:52:ED #318 Shannon Brown 10.10.128.32 + 00:04:56:44:50:68 #1949 RICHARD CONKLIN 10.10.128.34 + 58:C1:7A:AC:5A:E5 #2489 Martha Flores 10.10.128.35 + 58:C1:7A:AC:E8:81 #2492 Kory Biggs Shop 10.10.128.43 + 00:04:56:2F:DA:E7 #2010 Brady Clayton 10.10.128.48 + +## ePMP Force 200 (59 devices) + 00:04:56:DC:69:AE #932 Matt Kosarek 10.10.160.19 + 00:04:56:DB:D2:CA #1168 Stephen Day 10.10.160.22 + 00:04:56:DB:EC:11 #2037 Kevin Arana 10.10.160.9 + 00:04:56:DB:63:5E #2410 Carol Strickland 10.10.16.37 + 00:04:56:4A:12:EE #2133 Glenda Beauchamp 10.10.16.41 + 58:C1:7A:B1:05:12 #2275 John Vayo 10.10.16.47 + 58:C1:7A:B0:D7:10 #2233 Crystal Harney 10.10.16.50 + 00:04:56:DB:57:2B #855 Amber Prater 10.10.16.56 + 58:C1:7A:B1:09:F8 #2360 Doug Garber 10.10.16.57 + 00:04:56:DC:73:98 #2530 Donna Campbell 10.10.16.58 + 58:C1:7A:B3:07:A1 #435 Maria Cortes 10.10.64.12 + 00:04:56:DB:EB:18 #490 Terry Bates 10.10.64.8 + 58:C1:7A:B1:02:63 #192 Aurea Sanchez 10.10.96.10 + 00:04:56:DB:99:40 #2407 David Roesch 10.10.96.13 + 00:04:56:48:63:EF #587 Tamy Thomas 10.10.96.14 + 00:04:56:DB:C2:2C #497 Jody McCluskey 10.10.96.26 + 00:04:56:DB:EA:A0 #2487 Anthony Longo 10.10.96.33 + 00:04:56:4A:17:53 #10074 Pablo Martinez 10.10.96.36 + 00:04:56:DB:E9:FE #2479 Thomas Hernandez 10.10.96.38 + 00:04:56:DB:E5:ED #1788 Tim Bronschidle 10.10.96.40 + 58:C1:7A:B0:F3:39 #2335 Tiffany Stewart 10.10.96.42 + 00:04:56:DC:73:6B #10027 Irene Garibay 10.10.96.50 + 00:04:56:DC:6B:55 #10052 Khushboo Agarwal 10.10.96.53 + 58:C1:7A:B0:D7:0D #1601 Jerid Rutledge 10.10.96.65 + 00:04:56:DC:6C:9F #293 Jeff Harder 10.10.96.67 + 58:C1:7A:B3:06:A8 #875 Ken Patrick 10.10.96.7 + 00:04:56:DC:67:59 #1467 Terri Mack 10.10.96.72 + 00:04:56:DC:59:A6 #427 Ausencio Luna 10.10.96.73 + 00:04:56:DB:C2:11 #433 Balnca Diaz 10.10.96.77 + 00:04:56:DC:6D:77 #1712 Bobby Mack 10.10.96.84 + 00:04:56:48:66:E3 #10024 Janis Stewart 10.10.96.9 + 00:04:56:4A:15:B5 #10073 Jeffrey Harder 10.10.96.95 + 00:04:56:4A:13:7E #1441 Doris Avalos 10.10.144.16 + 58:C1:7A:B0:F7:8F #224 Leonard Lewis 10.10.144.20 + 00:04:56:DC:79:38 #477 Helen Lumpkin 10.10.144.24 + 00:04:56:DC:72:2A #735 FRED HECKEL 10.10.144.25 + 00:04:56:DB:E8:D5 #990 Jennifer Dunaway 10.10.144.27 + 00:04:56:DC:6A:65 #345 Tim Fisher 10.10.144.31 + 00:04:56:DB:AA:E6 #815 Terry Gortney 10.10.144.32 + 00:04:56:DC:5D:63 #243 Mark Nunnelee 10.10.144.35 + 00:04:56:DC:60:8D #1081 Abbie Andrews 10.10.144.36 + 00:04:56:DC:04:EF #301 Ron Berger 10.10.144.4 + 00:04:56:DC:72:24 #339 Terry Miesen 10.10.144.7 + 00:04:56:DB:97:F9 #848 Douglas Wilson 10.10.128.13 + 00:04:56:4A:17:B3 #2027 Chris Clayton 10.10.128.18 + 00:04:56:DC:69:F0 #2458 Marcus Patton 10.10.128.28 + 00:04:56:DB:E7:4C #46 Bernard Heer 10.10.128.3 + 00:04:56:DC:5E:50 #2465 Kory Biggs 10.10.128.33 + 00:04:56:DC:06:B7 #568 Frances Kirby 10.10.128.37 + 00:04:56:DC:05:04 #556 Mike Bell 10.10.128.45 + 58:C1:7A:B0:D0:20 #997 Heather Brister 10.10.128.50 + 00:04:56:DC:71:E8 #1879 Chris Telles 10.10.128.51 + 00:04:56:DC:6A:0B #1585 Tim Thomas 10.10.128.52 + 00:04:56:DC:66:06 #146 Geok Choon Pang 10.10.128.54 + 58:C1:7A:B0:F8:49 #2280 Ken Hall 10.10.128.6 + 58:C1:7A:B1:04:8B #2292 Joe Speciale 10.10.128.63 + 00:04:56:DB:EB:78 #809 Brad Gurney 10.10.128.8 + 58:C1:7A:B0:C1:3B #2295 Christine Hamparyan 10.10.142.252 + 58:C1:7A:B1:05:D2 #2218 Greg Hummel 10.10.142.253 + +## no response (1 devices) + 58:C1:7A:B3:08:B8 #623 Michael Newby 10.10.96.91 + +## Ubiquiti (unknown model) (1 devices) + E0:63:DA:0F:B8:F5 #2529 Terry Rector 10.10.62.208 diff --git a/cpes_annotated.txt b/cpes_annotated.txt new file mode 100644 index 0000000..ed10553 --- /dev/null +++ b/cpes_annotated.txt @@ -0,0 +1,97 @@ +MAC Model Account IP +------------------------------------------------------------------------------------------------------------------------ +00:04:56:DC:60:8D PowerBeam M5 / Loco M5 | fw=5.10.4 #1081 Abbie Andrews 10.10.144.36 +00:04:56:DB:57:2B PowerBeam M5 / Loco M5 | fw=5.11.0 #855 Amber Prater 10.10.16.56 +58:C1:7A:AD:17:85 NanoBeam M5 / LiteBeam M5 | fw=5.10.4 #2043 Angelica Cuayahuitl 10.10.96.45 +00:04:56:DB:EA:A0 PowerBeam M5 / Loco M5 | fw=5.10.4 #2487 Anthony Longo 10.10.96.33 +00:04:56:2E:C9:FF NanoBeam M5 / LiteBeam M5 | fw=5.11.1 #1829 Ashley Smith 10.10.160.6 +58:C1:7A:B1:02:63 PowerBeam M5 / Loco M5 | fw=5.10.4 #192 Aurea Sanchez 10.10.96.10 +00:04:56:DC:59:A6 PowerBeam M5 / Loco M5 | fw=5.10.4 #427 Ausencio Luna 10.10.96.73 +00:04:56:DB:C2:11 PowerBeam M5 / Loco M5 | fw=5.11.1 #433 Balnca Diaz 10.10.96.77 +00:04:56:DB:E7:4C PowerBeam M5 / Loco M5 | fw=5.11.0 #46 Bernard Heer 10.10.128.3 +00:04:56:44:50:AA NanoBeam M5 / LiteBeam M5 | fw=5.10.4 #2023 Billy Horton 10.10.96.55 +00:04:56:DC:6D:77 PowerBeam M5 / Loco M5 | fw=5.10.4 #1712 Bobby Mack 10.10.96.84 +00:04:56:DB:EB:78 PowerBeam M5 / Loco M5 | fw=5.11.0 #809 Brad Gurney 10.10.128.8 +00:04:56:2F:DA:E7 NanoBeam M5 / LiteBeam M5 | fw=5.11.0 #2010 Brady Clayton 10.10.128.48 +58:C1:7A:AD:14:9A NanoBeam M5 / LiteBeam M5 | fw=5.11.1 #357 Brian Swedlund 10.10.160.7 +00:04:56:DB:63:5E PowerBeam M5 / Loco M5 | fw=5.11.1 #2410 Carol Strickland 10.10.16.37 +00:04:56:4A:17:B3 PowerBeam M5 / Loco M5 | fw=5.11.1 #2027 Chris Clayton 10.10.128.18 +00:04:56:DC:71:E8 PowerBeam M5 / Loco M5 | fw=5.11.1 #1879 Chris Telles 10.10.128.51 +58:C1:7A:B0:C1:3B PowerBeam M5 / Loco M5 | fw=5.10.4 #2295 Christine Hamparyan 10.10.142.252 +00:04:56:2F:D9:10 NanoBeam M5 / LiteBeam M5 | fw=5.11.1 #1971 Clifford Jennings 10.10.128.11 +00:04:56:2F:CD:C7 NanoBeam M5 / LiteBeam M5 | fw=5.10.4 #84 Coy Ungemach 10.10.144.2 +58:C1:7A:B0:D7:10 PowerBeam M5 / Loco M5 | fw=5.11.0 #2233 Crystal Harney 10.10.16.50 +58:C1:7A:AD:13:D7 NanoBeam M5 / LiteBeam M5 | fw=5.10.4 #100 Dave Kiowski 10.10.128.14 +00:04:56:DB:99:40 PowerBeam M5 / Loco M5 | fw=5.10.4 #2407 David Roesch 10.10.96.13 +00:04:56:DC:73:98 PowerBeam M5 / Loco M5 | fw=5.11.0 #2530 Donna Campbell 10.10.16.58 +00:04:56:4A:13:7E PowerBeam M5 / Loco M5 | fw=5.10.4 #1441 Doris Avalos 10.10.144.16 +58:C1:7A:B1:09:F8 PowerBeam M5 / Loco M5 | fw=5.11.1 #2360 Doug Garber 10.10.16.57 +00:04:56:DB:97:F9 PowerBeam M5 / Loco M5 | fw=5.11.1 #848 Douglas Wilson 10.10.128.13 +00:04:56:DC:06:B7 PowerBeam M5 / Loco M5 | fw=5.11.1 #568 Frances Kirby 10.10.128.37 +00:04:56:DC:72:2A PowerBeam M5 / Loco M5 | fw=5.10.4 #735 FRED HECKEL 10.10.144.25 +00:04:56:2F:DA:84 NanoBeam M5 / LiteBeam M5 | fw=5.10.4 #1908 Gabriela Sereno 10.10.96.39 +00:04:56:DC:66:06 PowerBeam M5 / Loco M5 | fw=5.11.1 #146 Geok Choon Pang 10.10.128.54 +58:C1:7A:AD:15:7B NanoBeam M5 / LiteBeam M5 | fw=5.10.4 #1906 Georgina Covarrubias 10.10.144.6 +00:04:56:4A:12:EE PowerBeam M5 / Loco M5 | fw=5.11.0 #2133 Glenda Beauchamp 10.10.16.41 +58:C1:7A:B1:05:D2 PowerBeam M5 / Loco M5 | fw=5.10.4 #2218 Greg Hummel 10.10.142.253 +00:04:56:44:5A:37 NanoBeam M5 / LiteBeam M5 | fw=5.10.4 #997 Heather Brister 10.10.128.25 +58:C1:7A:B0:D0:20 PowerBeam M5 / Loco M5 | fw=5.10.4 #997 Heather Brister 10.10.128.50 +00:04:56:44:54:0A NanoBeam M5 / LiteBeam M5 | fw=5.11.1 #2046 HEATHER WOMACK 10.10.96.54 +00:04:56:DC:79:38 PowerBeam M5 / Loco M5 | fw=5.10.4 #477 Helen Lumpkin 10.10.144.24 +00:04:56:DC:73:6B PowerBeam M5 / Loco M5 | fw=4.8.1 #10027 Irene Garibay 10.10.96.50 +00:04:56:2E:B7:6C NanoBeam M5 / LiteBeam M5 | fw=5.10.4 #1316 Iris Delira 10.10.96.51 +00:04:56:48:66:E3 PowerBeam M5 / Loco M5 | fw=5.10.4 #10024 Janis Stewart 10.10.96.9 +00:04:56:DC:6C:9F PowerBeam M5 / Loco M5 | fw=5.10.4 #293 Jeff Harder 10.10.96.67 +00:04:56:4A:15:B5 PowerBeam M5 / Loco M5 | fw=5.10.4 #10073 Jeffrey Harder 10.10.96.95 +58:C1:7A:AC:65:08 NanoBeam M5 / LiteBeam M5 | fw=5.10.4 #2200 Jennifer Boon 10.10.16.38 +00:04:56:DB:E8:D5 PowerBeam M5 / Loco M5 | fw=5.10.4 #990 Jennifer Dunaway 10.10.144.27 +58:C1:7A:B0:D7:0D PowerBeam M5 / Loco M5 | fw=5.10.4 #1601 Jerid Rutledge 10.10.96.65 +00:04:56:2E:C1:6E NanoBeam M5 / LiteBeam M5 | fw=5.11.0 #177 JERRY L JOHNSON 10.10.96.2 +00:04:56:2E:B9:2E NanoBeam M5 / LiteBeam M5 | fw=5.10.4 #185 Joanna Rodriguez 10.10.160.16 +00:04:56:DB:C2:2C PowerBeam M5 / Loco M5 | fw=5.10.4 #497 Jody McCluskey 10.10.96.26 +58:C1:7A:B1:04:8B PowerBeam M5 / Loco M5 | fw=5.11.0 #2292 Joe Speciale 10.10.128.63 +58:C1:7A:B1:05:12 PowerBeam M5 / Loco M5 | fw=5.11.0 #2275 John Vayo 10.10.16.47 +58:C1:7A:B0:F8:49 PowerBeam M5 / Loco M5 | fw=5.11.0 #2280 Ken Hall 10.10.128.6 +58:C1:7A:B3:06:A8 PowerBeam M5 / Loco M5 | fw=5.11.1 #875 Ken Patrick 10.10.96.7 +00:04:56:DB:EC:11 PowerBeam M5 / Loco M5 | fw=5.11.1 #2037 Kevin Arana 10.10.160.9 +00:04:56:DC:6B:55 PowerBeam M5 / Loco M5 | fw=5.10.4 #10052 Khushboo Agarwal 10.10.96.53 +58:C1:7A:AC:63:19 NanoBeam M5 / LiteBeam M5 | fw=4.6.0.1 #215 Kileigh Even 10.10.96.46 +00:04:56:DC:5E:50 PowerBeam M5 / Loco M5 | fw=5.11.1 #2465 Kory Biggs 10.10.128.33 +58:C1:7A:AC:E8:81 NanoBeam M5 / LiteBeam M5 | fw=5.11.1 #2492 Kory Biggs Shop 10.10.128.43 +58:C1:7A:B0:B7:AE NanoBeam M5 / LiteBeam M5 | fw=5.11.0 #1950 Leandra Beshea 10.10.128.1 +58:C1:7A:B0:F7:8F PowerBeam M5 / Loco M5 | fw=5.10.4 #224 Leonard Lewis 10.10.144.20 +58:C1:7A:AD:22:EC NanoBeam M5 / LiteBeam M5 | fw=5.11.1 #1650 luis Cabrera 10.10.64.1 +00:04:56:DC:69:F0 PowerBeam M5 / Loco M5 | fw=5.11.1 #2458 Marcus Patton 10.10.128.28 +58:C1:7A:B3:07:A1 PowerBeam M5 / Loco M5 | fw=5.11.1 #435 Maria Cortes 10.10.64.12 +00:04:56:DC:5D:63 PowerBeam M5 / Loco M5 | fw=5.10.4 #243 Mark Nunnelee 10.10.144.35 +58:C1:7A:AC:5A:E5 NanoBeam M5 / LiteBeam M5 | fw=5.11.1 #2489 Martha Flores 10.10.128.35 +58:C1:7A:AD:1F:20 NanoBeam M5 / LiteBeam M5 | fw=5.10.4 #246 Matt Budjenska 10.10.158.247 +00:04:56:DC:69:AE PowerBeam M5 / Loco M5 | fw=5.11.1 #932 Matt Kosarek 10.10.160.19 +58:C1:7A:B3:08:B8 PowerBeam M5 / Loco M5 | fw=5.10.4 #623 Michael Newby 10.10.96.91 +00:04:56:DC:05:04 PowerBeam M5 / Loco M5 | fw=5.11.1 #556 Mike Bell 10.10.128.45 +58:C1:7A:AC:64:51 NanoBeam M5 / LiteBeam M5 | fw=4.7 #2454 Nancy Avellaneda 10.10.64.5 +58:C1:7A:AC:6A:C3 NanoBeam M5 / LiteBeam M5 | fw=5.10.4 #10059 Nicholas Terry 10.10.144.11 +00:04:56:4A:17:53 PowerBeam M5 / Loco M5 | fw=5.10.4 #10074 Pablo Martinez 10.10.96.36 +58:C1:7A:B0:B6:1F NanoBeam M5 / LiteBeam M5 | fw=5.10.4 #1128 Patricia Vasquez 10.10.96.58 +00:04:56:2F:D4:BD NanoBeam M5 / LiteBeam M5 | fw=5.10.4 #283 Paul Sammataro 10.10.96.29 +58:C1:7A:AC:69:46 NanoBeam M5 / LiteBeam M5 | fw=4.7 #2190 Ray Schaak 10.10.96.17 +00:04:56:44:50:68 NanoBeam M5 / LiteBeam M5 | fw=5.11.1 #1949 RICHARD CONKLIN 10.10.128.34 +00:04:56:DC:04:EF PowerBeam M5 / Loco M5 | fw=5.10.4 #301 Ron Berger 10.10.144.4 +00:04:56:44:52:ED NanoBeam M5 / LiteBeam M5 | fw=5.11.1 #318 Shannon Brown 10.10.128.32 +58:C1:7A:AC:5F:11 NanoBeam M5 / LiteBeam M5 | fw=5.10.4 #1538 Shirley Elmore 10.10.96.5 +00:04:56:2F:D7:CC NanoBeam M5 / LiteBeam M5 | fw=5.10.4 #2098 Stephen Beegle 10.10.160.2 +00:04:56:DB:D2:CA PowerBeam M5 / Loco M5 | fw=5.11.1 #1168 Stephen Day 10.10.160.22 +00:04:56:48:63:EF PowerBeam M5 / Loco M5 | fw=4.7.0.1 #587 Tamy Thomas 10.10.96.14 +00:04:56:DC:67:59 PowerBeam M5 / Loco M5 | fw=5.10.4 #1467 Terri Mack 10.10.96.72 +00:04:56:DB:EB:18 PowerBeam M5 / Loco M5 | fw=5.11.1 #490 Terry Bates 10.10.64.8 +00:04:56:DB:AA:E6 PowerBeam M5 / Loco M5 | fw=5.10.4 #815 Terry Gortney 10.10.144.32 +00:04:56:DC:72:24 PowerBeam M5 / Loco M5 | fw=5.10.4 #339 Terry Miesen 10.10.144.7 +00:04:56:2F:DC:4C NanoBeam M5 / LiteBeam M5 | fw=5.10.4 #10040 Thomas Graham 10.10.144.10 +00:04:56:DB:E9:FE PowerBeam M5 / Loco M5 | fw=5.10.4 #2479 Thomas Hernandez 10.10.96.38 +58:C1:7A:B0:F3:39 PowerBeam M5 / Loco M5 | fw=5.10.4 #2335 Tiffany Stewart 10.10.96.42 +00:04:56:DC:6A:0B PowerBeam M5 / Loco M5 | fw=4.8.1 #1585 Tim Thomas 10.10.128.52 +00:04:56:44:5E:7B NanoBeam M5 / LiteBeam M5 | fw=5.11.1 #343 Tim Bagert 10.10.16.40 +00:04:56:DB:E5:ED PowerBeam M5 / Loco M5 | fw=5.10.4 #1788 Tim Bronschidle 10.10.96.40 +00:04:56:DC:6A:65 PowerBeam M5 / Loco M5 | fw=5.10.4 #345 Tim Fisher 10.10.144.31 +00:04:56:2F:DB:A1 NanoBeam M5 / LiteBeam M5 | fw=5.10.4 #355 Treceda Schmid 10.10.96.11 +00:04:56:2F:CE:8D NanoBeam M5 / LiteBeam M5 | fw=5.10.4 #2375 Yolanda Judge 10.10.96.35 diff --git a/cpes_by_model.txt b/cpes_by_model.txt new file mode 100644 index 0000000..6f251d3 --- /dev/null +++ b/cpes_by_model.txt @@ -0,0 +1,119 @@ +Identifying 91 devices via HTTPS... + 20/91 + 40/91 + 60/91 + 80/91 + 91/91 done + +## Cambium ePMP (unknown model) (1 devices) + 58:C1:7A:B3:08:B8 #623 Michael Newby 10.10.96.91 + +## ePMP Force 110 (fw=Version 5.11.1) (1 devices) + 00:04:56:F0:36:0F #125 Eddie Yarbrough 10.10.16.51 + +## ePMP Force 180 (fw=Version 5.10.4) (17 devices) + 58:C1:7A:AD:17:85 #2043 Angelica Cuayahuitl 10.10.96.45 + 00:04:56:44:50:AA #2023 Billy Horton 10.10.96.55 + 00:04:56:2F:CD:C7 #84 Coy Ungemach 10.10.144.2 + 58:C1:7A:AD:13:D7 #100 Dave Kiowski 10.10.128.14 + 00:04:56:2F:DA:84 #1908 Gabriela Sereno 10.10.96.39 + 58:C1:7A:AD:15:7B #1906 Georgina Covarrubias 10.10.144.6 + 00:04:56:44:5A:37 #997 Heather Brister 10.10.128.25 + 00:04:56:2E:B7:6C #1316 Iris Delira 10.10.96.51 + 58:C1:7A:AC:65:08 #2200 Jennifer Boon 10.10.16.38 + 00:04:56:2E:B9:2E #185 Joanna Rodriguez 10.10.160.16 + 58:C1:7A:AD:1F:20 #246 Matt Budjenska 10.10.158.247 + 58:C1:7A:B0:B6:1F #1128 Patricia Vasquez 10.10.96.58 + 00:04:56:2F:D4:BD #283 Paul Sammataro 10.10.96.29 + 58:C1:7A:AC:5F:11 #1538 Shirley Elmore 10.10.96.5 + 00:04:56:2F:D7:CC #2098 Stephen Beegle 10.10.160.2 + 00:04:56:2F:DB:A1 #355 Treceda Schmid 10.10.96.11 + 00:04:56:2F:CE:8D #2375 Yolanda Judge 10.10.96.35 + +## ePMP Force 180 (fw=Version 5.11.0) (3 devices) + 00:04:56:2F:DA:E7 #2010 Brady Clayton 10.10.128.48 + 00:04:56:2E:C1:6E #177 JERRY L JOHNSON 10.10.96.2 + 58:C1:7A:B0:B7:AE #1950 Leandra Beshea 10.10.128.1 + +## ePMP Force 180 (fw=Version 5.11.1) (10 devices) + 00:04:56:2E:C9:FF #1829 Ashley Smith 10.10.160.6 + 58:C1:7A:AD:14:9A #357 Brian Swedlund 10.10.160.7 + 00:04:56:2F:D9:10 #1971 Clifford Jennings 10.10.128.11 + 00:04:56:44:54:0A #2046 HEATHER WOMACK 10.10.96.54 + 58:C1:7A:AC:E8:81 #2492 Kory Biggs Shop 10.10.128.43 + 58:C1:7A:AD:22:EC #1650 luis Cabrera 10.10.64.1 + 58:C1:7A:AC:5A:E5 #2489 Martha Flores 10.10.128.35 + 00:04:56:44:50:68 #1949 RICHARD CONKLIN 10.10.128.34 + 00:04:56:44:52:ED #318 Shannon Brown 10.10.128.32 + 00:04:56:44:5E:7B #343 Tim Bagert 10.10.16.40 + +## ePMP Force 200 (fw=Version 4.8.1) (1 devices) + 00:04:56:DC:6A:0B #1585 Tim 10.10.128.52 + +## ePMP Force 200 (fw=Version 5.10.4) (26 devices) + 00:04:56:DC:60:8D #1081 Abbie Andrews 10.10.144.36 + 00:04:56:DB:EA:A0 #2487 Anthony Longo 10.10.96.33 + 58:C1:7A:B1:02:63 #192 Aurea Sanchez 10.10.96.10 + 00:04:56:DC:59:A6 #427 Ausencio Luna 10.10.96.73 + 00:04:56:DC:6D:77 #1712 Bobby Mack 10.10.96.84 + 58:C1:7A:B0:C1:3B #2295 Christine Hamparyan 10.10.142.252 + 00:04:56:DB:99:40 #2407 David Roesch 10.10.96.13 + 00:04:56:4A:13:7E #1441 Doris Avalos 10.10.144.16 + 00:04:56:DC:72:2A #735 FRED HECKEL 10.10.144.25 + 58:C1:7A:B1:05:D2 #2218 Greg Hummel 10.10.142.253 + 58:C1:7A:B0:D0:20 #997 Heather Brister 10.10.128.50 + 00:04:56:DC:79:38 #477 Helen Lumpkin 10.10.144.24 + 00:04:56:DC:6C:9F #293 Jeff Harder 10.10.96.67 + 00:04:56:DB:E8:D5 #990 Jennifer Dunaway 10.10.144.27 + 58:C1:7A:B0:D7:0D #1601 Jerid Rutledge 10.10.96.65 + 00:04:56:DB:C2:2C #497 Jody McCluskey 10.10.96.26 + 58:C1:7A:B0:F7:8F #224 Leonard Lewis 10.10.144.20 + 00:04:56:DC:5D:63 #243 Mark Nunnelee 10.10.144.35 + 00:04:56:DC:04:EF #301 Ron Berger 10.10.144.4 + 00:04:56:DC:67:59 #1467 Terri Mack 10.10.96.72 + 00:04:56:DB:AA:E6 #815 Terry Gortney 10.10.144.32 + 00:04:56:DC:72:24 #339 Terry Miesen 10.10.144.7 + 00:04:56:DB:E9:FE #2479 Thomas Hernandez 10.10.96.38 + 58:C1:7A:B0:F3:39 #2335 Tiffany Stewart 10.10.96.42 + 00:04:56:DB:E5:ED #1788 Tim Bronschidle 10.10.96.40 + 00:04:56:DC:6A:65 #345 Tim Fisher 10.10.144.31 + +## ePMP Force 200 (fw=Version 5.11.0) (9 devices) + 00:04:56:DB:57:2B #855 Amber Prater 10.10.16.56 + 00:04:56:DB:E7:4C #46 Bernard Heer 10.10.128.3 + 00:04:56:DB:EB:78 #809 Brad Gurney 10.10.128.8 + 58:C1:7A:B0:D7:10 #2233 Crystal Harney 10.10.16.50 + 00:04:56:DC:73:98 #2530 Donna Campbell 10.10.16.58 + 00:04:56:4A:12:EE #2133 Glenda Beauchamp 10.10.16.41 + 58:C1:7A:B1:04:8B #2292 Joe Speciale 10.10.128.63 + 58:C1:7A:B1:05:12 #2275 John Vayo 10.10.16.47 + 58:C1:7A:B0:F8:49 #2280 Ken Hall 10.10.128.6 + +## ePMP Force 200 (fw=Version 5.11.1) (17 devices) + 00:04:56:DB:C2:11 #433 Balnca Diaz 10.10.96.77 + 00:04:56:DB:63:5E #2410 Carol Strickland 10.10.16.37 + 00:04:56:4A:17:B3 #2027 Chris Clayton 10.10.128.18 + 00:04:56:DC:71:E8 #1879 Chris Telles 10.10.128.51 + 58:C1:7A:B1:09:F8 #2360 Doug Garber 10.10.16.57 + 00:04:56:DB:97:F9 #848 Douglas Wilson 10.10.128.13 + 00:04:56:DC:06:B7 #568 Frances Kirby 10.10.128.37 + 00:04:56:DC:66:06 #146 Geok Choon Pang 10.10.128.54 + 58:C1:7A:B3:06:A8 #875 Ken Patrick 10.10.96.7 + 00:04:56:DB:EC:11 #2037 Kevin Arana 10.10.160.9 + 00:04:56:DC:5E:50 #2465 Kory Biggs 10.10.128.33 + 00:04:56:DC:69:F0 #2458 Marcus Patton 10.10.128.28 + 58:C1:7A:B3:07:A1 #435 Maria Cortes 10.10.64.12 + 00:04:56:DC:69:AE #932 Matt Kosarek 10.10.160.19 + 00:04:56:DC:05:04 #556 Mike Bell 10.10.128.45 + 00:04:56:DB:D2:CA #1168 Stephen Day 10.10.160.22 + 00:04:56:DB:EB:18 #490 Terry Bates 10.10.64.8 + +## no HTTPS response (5 devices) + 58:C1:7A:AC:63:19 #215 Kileigh Even 10.10.96.46 + 58:C1:7A:AC:64:51 #2454 Nancy Avellaneda 10.10.64.5 + 58:C1:7A:AC:69:46 #2190 Ray Schaak 10.10.96.17 + 58:C1:7A:AC:62:5F #2206 Wanda Davis 10.10.96.102 + 00:04:56:48:63:EF #587 Tamy Thomas 10.10.96.14 + +## Ubiquiti (unknown model) (1 devices) + E0:63:DA:0F:B8:F5 #2529 Terry Rector 10.10.62.208 diff --git a/cpes_raw.txt b/cpes_raw.txt new file mode 100644 index 0000000..bcdb940 --- /dev/null +++ b/cpes_raw.txt @@ -0,0 +1,196 @@ + router=494 ip=10.10.160.16 mac=00:04:56:2E:B9:2E device=Ubiquiti (legacy) + account=#185 name='Joanna Rodriguez' host='Joanna Rodriguez' + router=494 ip=10.10.160.19 mac=00:04:56:DC:69:AE device=Ubiquiti (legacy) + account=#932 name='Matt Kosarek' host='Matt Kosarek' + router=494 ip=10.10.160.2 mac=00:04:56:2F:D7:CC device=Ubiquiti (legacy) + account=#2098 name='Stephen Beegle' host='Stephen Beegle' + router=494 ip=10.10.160.22 mac=00:04:56:DB:D2:CA device=Ubiquiti (legacy) + account=#1168 name='Stephen Day' host='Stephen Day' + router=494 ip=10.10.160.6 mac=00:04:56:2E:C9:FF device=Ubiquiti (legacy) + account=#1829 name='Ashley Smith' host='Ashley Smith' + router=494 ip=10.10.160.7 mac=58:C1:7A:AD:14:9A device=Cambium ePMP + account=#357 name='Brian Swedlund' host='Brian Swedlund' + router=494 ip=10.10.160.9 mac=00:04:56:DB:EC:11 device=Ubiquiti (legacy) + account=#2037 name='Kevin Arana' host='Kevin Arana' + router=982 ip=10.10.62.208 mac=E0:63:DA:0F:B8:F5 device=Ubiquiti PowerBeam M5 / Loco M5 + account=#2529 name='Terry Rector' host='Terry-Rector' + router=climax ip=10.10.16.37 mac=00:04:56:DB:63:5E device=Ubiquiti (legacy) + account=#2410 name='Carol Strickland' host='Carol Strickland' + router=climax ip=10.10.16.38 mac=58:C1:7A:AC:65:08 device=Cambium ePMP + account=#2200 name='Jennifer Boon' host='Jennifer Boon' + router=climax ip=10.10.16.40 mac=00:04:56:44:5E:7B device=Ubiquiti (legacy) + account=#343 name='Tim Bagert' host='Tim Bagert' + router=climax ip=10.10.16.41 mac=00:04:56:4A:12:EE device=Ubiquiti (legacy) + account=#2133 name='Glenda Beauchamp' host='Glenda Beauchamp' + router=climax ip=10.10.16.47 mac=58:C1:7A:B1:05:12 device=Cambium ePMP + account=#2275 name='John Vayo' host='John Vayo' + router=climax ip=10.10.16.50 mac=58:C1:7A:B0:D7:10 device=Cambium ePMP + account=#2233 name='Crystal Harney' host='Crystal Harney' + router=climax ip=10.10.16.51 mac=00:04:56:F0:36:0F device=Ubiquiti (legacy) + account=#125 name='Eddie Yarbrough' host='Eddie Yarbrough' + router=climax ip=10.10.16.56 mac=00:04:56:DB:57:2B device=Ubiquiti (legacy) + account=#855 name='Amber Prater' host='Amber Prater' + router=climax ip=10.10.16.57 mac=58:C1:7A:B1:09:F8 device=Cambium ePMP + account=#2360 name='Doug Garber' host='Doug Garber' + router=climax ip=10.10.16.58 mac=00:04:56:DC:73:98 device=Ubiquiti (legacy) + account=#2530 name='Donna Campbell' host='Donna Campbell' + router=core ip=10.10.64.1 mac=58:C1:7A:AD:22:EC device=Cambium ePMP + account=#1650 name='luis Cabrera' host='Luis Cabrera' + router=core ip=10.10.64.12 mac=58:C1:7A:B3:07:A1 device=Cambium ePMP + account=#435 name='Maria Cortes' host='Maria Cortes' + router=core ip=10.10.64.5 mac=58:C1:7A:AC:64:51 device=Cambium ePMP + account=#2454 name='Nancy Avellaneda' host='Nancy Avellaneda' + router=core ip=10.10.64.8 mac=00:04:56:DB:EB:18 device=Ubiquiti (legacy) + account=#490 name='Terry Bates' host='Terry Bates' + router=culleoka ip=10.10.96.10 mac=58:C1:7A:B1:02:63 device=Cambium ePMP + account=#192 name='Aurea Sanchez' host='Aurea Sanchez' + router=culleoka ip=10.10.96.102 mac=58:C1:7A:AC:62:5F device=Cambium ePMP + account=#2206 name='Wanda Davis' host='Wanda Davis' + router=culleoka ip=10.10.96.11 mac=00:04:56:2F:DB:A1 device=Ubiquiti (legacy) + account=#355 name='Treceda Schmid' host='Treceda Schmid' + router=culleoka ip=10.10.96.13 mac=00:04:56:DB:99:40 device=Ubiquiti (legacy) + account=#2407 name='David Roesch' host='David Roesch' + router=culleoka ip=10.10.96.14 mac=00:04:56:48:63:EF device=Ubiquiti (legacy) + account=#587 name='Tamy Thomas' host='Tamy Thomas' + router=culleoka ip=10.10.96.17 mac=58:C1:7A:AC:69:46 device=Cambium ePMP + account=#2190 name='Ray Schaak' host='Ray Schaak' + router=culleoka ip=10.10.96.2 mac=00:04:56:2E:C1:6E device=Ubiquiti (legacy) + account=#177 name='JERRY L JOHNSON' host='Jerry Johnson' + router=culleoka ip=10.10.96.26 mac=00:04:56:DB:C2:2C device=Ubiquiti (legacy) + account=#497 name='Jody McCluskey' host='Jody McCluskey' + router=culleoka ip=10.10.96.29 mac=00:04:56:2F:D4:BD device=Ubiquiti (legacy) + account=#283 name='Paul Sammataro' host='Paul Sammataro' + router=culleoka ip=10.10.96.33 mac=00:04:56:DB:EA:A0 device=Ubiquiti (legacy) + account=#2487 name='Anthony Longo' host='Anthony Longo' + router=culleoka ip=10.10.96.35 mac=00:04:56:2F:CE:8D device=Ubiquiti (legacy) + account=#2375 name='Yolanda Judge' host='Yolanda Judge' + router=culleoka ip=10.10.96.36 mac=00:04:56:4A:17:53 device=Ubiquiti (legacy) + account=#10074 name='Pablo Martinez' host='Pablo Martinez' + router=culleoka ip=10.10.96.38 mac=00:04:56:DB:E9:FE device=Ubiquiti (legacy) + account=#2479 name='Thomas Hernandez' host='Thomas Hernandez' + router=culleoka ip=10.10.96.39 mac=00:04:56:2F:DA:84 device=Ubiquiti (legacy) + account=#1908 name='Gabriela Sereno' host='Gabriela Sereno' + router=culleoka ip=10.10.96.40 mac=00:04:56:DB:E5:ED device=Ubiquiti (legacy) + account=#1788 name='Tim Bronschidle' host='Tim Bronschidle' + router=culleoka ip=10.10.96.42 mac=58:C1:7A:B0:F3:39 device=Cambium ePMP + account=#2335 name='Tiffany Stewart' host='Tiffany Stewart' + router=culleoka ip=10.10.96.45 mac=58:C1:7A:AD:17:85 device=Cambium ePMP + account=#2043 name='Angelica Cuayahuitl' host='Angelica Cuayahuitl' + router=culleoka ip=10.10.96.46 mac=58:C1:7A:AC:63:19 device=Cambium ePMP + account=#215 name='Kileigh Even' host='Kileigh Even' + router=culleoka ip=10.10.96.5 mac=58:C1:7A:AC:5F:11 device=Cambium ePMP + account=#1538 name='Shirley Elmore' host='Shirley Elmore' + router=culleoka ip=10.10.96.50 mac=00:04:56:DC:73:6B device=Ubiquiti (legacy) + account=#10027 name='Irene Garibay' host='Irene Garibay' + router=culleoka ip=10.10.96.51 mac=00:04:56:2E:B7:6C device=Ubiquiti (legacy) + account=#1316 name='Iris Delira' host='Iris Delira' + router=culleoka ip=10.10.96.53 mac=00:04:56:DC:6B:55 device=Ubiquiti (legacy) + account=#10052 name='Khushboo Agarwal' host='Khushboo Agarwal' + router=culleoka ip=10.10.96.54 mac=00:04:56:44:54:0A device=Ubiquiti (legacy) + account=#2046 name='HEATHER WOMACK' host='Heather Womack' + router=culleoka ip=10.10.96.55 mac=00:04:56:44:50:AA device=Ubiquiti (legacy) + account=#2023 name='Billy Horton' host='Billy Horton' + router=culleoka ip=10.10.96.58 mac=58:C1:7A:B0:B6:1F device=Cambium ePMP + account=#1128 name='Patricia Vasquez' host='Patricia Vasquez' + router=culleoka ip=10.10.96.65 mac=58:C1:7A:B0:D7:0D device=Cambium ePMP + account=#1601 name='Jerid Rutledge' host='Jerid Rutledge' + router=culleoka ip=10.10.96.67 mac=00:04:56:DC:6C:9F device=Ubiquiti (legacy) + account=#293 name='Jeff Harder' host='Jeff Harder' + router=culleoka ip=10.10.96.7 mac=58:C1:7A:B3:06:A8 device=Cambium ePMP + account=#875 name='Ken Patrick' host='Ken Patrick' + router=culleoka ip=10.10.96.72 mac=00:04:56:DC:67:59 device=Ubiquiti (legacy) + account=#1467 name='Terri Mack' host='Terri Mack' + router=culleoka ip=10.10.96.73 mac=00:04:56:DC:59:A6 device=Ubiquiti (legacy) + account=#427 name='Ausencio Luna' host='Ausencio Luna' + router=culleoka ip=10.10.96.77 mac=00:04:56:DB:C2:11 device=Ubiquiti (legacy) + account=#433 name='Balnca Diaz' host='Balnca Diaz' + router=culleoka ip=10.10.96.84 mac=00:04:56:DC:6D:77 device=Ubiquiti (legacy) + account=#1712 name='Bobby Mack' host='Bobby Mack' + router=culleoka ip=10.10.96.9 mac=00:04:56:48:66:E3 device=Ubiquiti (legacy) + account=#10024 name='Janis Stewart' host='Janis Stewart' + router=culleoka ip=10.10.96.91 mac=58:C1:7A:B3:08:B8 device=Cambium ePMP + account=#623 name='Michael Newby' host='Michael Newby' + router=culleoka ip=10.10.96.95 mac=00:04:56:4A:15:B5 device=Ubiquiti (legacy) + account=#10073 name='Jeffrey Harder' host='Jeffrey Harder' + router=lowry ip=10.10.144.10 mac=00:04:56:2F:DC:4C device=Ubiquiti (legacy) + account=#10040 name='Thomas Graham' host='Thomas Graham' + router=lowry ip=10.10.144.11 mac=58:C1:7A:AC:6A:C3 device=Cambium ePMP + account=#10059 name='Nicholas Terry' host='Nicholas Terry' + router=lowry ip=10.10.144.16 mac=00:04:56:4A:13:7E device=Ubiquiti (legacy) + account=#1441 name='Doris Avalos' host='Doris Avalos' + router=lowry ip=10.10.144.2 mac=00:04:56:2F:CD:C7 device=Ubiquiti (legacy) + account=#84 name='Coy Ungemach' host='Coy Ungemach' + router=lowry ip=10.10.144.20 mac=58:C1:7A:B0:F7:8F device=Cambium ePMP + account=#224 name='Leonard Lewis' host='Leonard Lewis' + router=lowry ip=10.10.144.24 mac=00:04:56:DC:79:38 device=Ubiquiti (legacy) + account=#477 name='Helen Lumpkin' host='Helen Lumpkin' + router=lowry ip=10.10.144.25 mac=00:04:56:DC:72:2A device=Ubiquiti (legacy) + account=#735 name='FRED HECKEL' host='Fred Heckel' + router=lowry ip=10.10.144.27 mac=00:04:56:DB:E8:D5 device=Ubiquiti (legacy) + account=#990 name='Jennifer Dunaway' host='Jennifer Dunaway' + router=lowry ip=10.10.144.31 mac=00:04:56:DC:6A:65 device=Ubiquiti (legacy) + account=#345 name='Tim Fisher' host='Tim Fisher' + router=lowry ip=10.10.144.32 mac=00:04:56:DB:AA:E6 device=Ubiquiti (legacy) + account=#815 name='Terry Gortney' host='Terry Gortney' + router=lowry ip=10.10.144.35 mac=00:04:56:DC:5D:63 device=Ubiquiti (legacy) + account=#243 name='Mark Nunnelee' host='Mark Nunnelee' + router=lowry ip=10.10.144.36 mac=00:04:56:DC:60:8D device=Ubiquiti (legacy) + account=#1081 name='Abbie Andrews' host='Abbie Andrews' + router=lowry ip=10.10.144.4 mac=00:04:56:DC:04:EF device=Ubiquiti (legacy) + account=#301 name='Ron Berger' host='Ron Berger' + router=lowry ip=10.10.144.6 mac=58:C1:7A:AD:15:7B device=Cambium ePMP + account=#1906 name='Georgina Covarrubias' host='Georgina Covarrubias' + router=lowry ip=10.10.144.7 mac=00:04:56:DC:72:24 device=Ubiquiti (legacy) + account=#339 name='Terry Miesen' host='Terry Miesen' + router=lowry ip=10.10.158.247 mac=58:C1:7A:AD:1F:20 device=Cambium ePMP + account=#246 name='Matt Budjenska' host='Matt Budjenska' + router=newhope ip=10.10.128.1 mac=58:C1:7A:B0:B7:AE device=Cambium ePMP + account=#1950 name='Leandra Beshea' host='Leandra Beshea' + router=newhope ip=10.10.128.11 mac=00:04:56:2F:D9:10 device=Ubiquiti (legacy) + account=#1971 name='Clifford Jennings' host='Clifford M Jennings' + router=newhope ip=10.10.128.13 mac=00:04:56:DB:97:F9 device=Ubiquiti (legacy) + account=#848 name='Douglas Wilson' host='Douglas Wilson' + router=newhope ip=10.10.128.14 mac=58:C1:7A:AD:13:D7 device=Cambium ePMP + account=#100 name='Dave Kiowski' host='Dave Kiowski' + router=newhope ip=10.10.128.18 mac=00:04:56:4A:17:B3 device=Ubiquiti (legacy) + account=#2027 name='Chris Clayton' host='Chris Clayton' + router=newhope ip=10.10.128.25 mac=00:04:56:44:5A:37 device=Ubiquiti (legacy) + account=#997 name='Heather Brister' host='Heather Brister 2' + router=newhope ip=10.10.128.28 mac=00:04:56:DC:69:F0 device=Ubiquiti (legacy) + account=#2458 name='Marcus Patton' host='Marcus Patton' + router=newhope ip=10.10.128.3 mac=00:04:56:DB:E7:4C device=Ubiquiti (legacy) + account=#46 name='Bernard Heer' host='Bernard Heer' + router=newhope ip=10.10.128.32 mac=00:04:56:44:52:ED device=Ubiquiti (legacy) + account=#318 name='Shannon Brown' host='Shannon Brown' + router=newhope ip=10.10.128.33 mac=00:04:56:DC:5E:50 device=Ubiquiti (legacy) + account=#2465 name='Kory Biggs' host='Kory Biggs' + router=newhope ip=10.10.128.34 mac=00:04:56:44:50:68 device=Ubiquiti (legacy) + account=#1949 name='RICHARD CONKLIN' host='Richard Conklin' + router=newhope ip=10.10.128.35 mac=58:C1:7A:AC:5A:E5 device=Cambium ePMP + account=#2489 name='Martha Flores' host='Martha Flores' + router=newhope ip=10.10.128.37 mac=00:04:56:DC:06:B7 device=Ubiquiti (legacy) + account=#568 name='Frances Kirby' host='Frances Kirby' + router=newhope ip=10.10.128.43 mac=58:C1:7A:AC:E8:81 device=Cambium ePMP + account=#2492 name='Kory Biggs Shop' host='Kory Biggs Shop' + router=newhope ip=10.10.128.45 mac=00:04:56:DC:05:04 device=Ubiquiti (legacy) + account=#556 name='Mike Bell' host='Mike Bell' + router=newhope ip=10.10.128.48 mac=00:04:56:2F:DA:E7 device=Ubiquiti (legacy) + account=#2010 name='Brady Clayton' host='Brady Clayton' + router=newhope ip=10.10.128.50 mac=58:C1:7A:B0:D0:20 device=Cambium ePMP + account=#997 name='Heather Brister' host='Heather Brister' + router=newhope ip=10.10.128.51 mac=00:04:56:DC:71:E8 device=Ubiquiti (legacy) + account=#1879 name='Chris Telles' host='Chris Telles' + router=newhope ip=10.10.128.52 mac=00:04:56:DC:6A:0B device=Ubiquiti (legacy) + account=#1585 name='Tim Thomas' host='Tim Thomas' + router=newhope ip=10.10.128.54 mac=00:04:56:DC:66:06 device=Ubiquiti (legacy) + account=#146 name='Geok Choon Pang' host='Geok Choon Pang' + router=newhope ip=10.10.128.6 mac=58:C1:7A:B0:F8:49 device=Cambium ePMP + account=#2280 name='Ken Hall' host='Ken Hall' + router=newhope ip=10.10.128.63 mac=58:C1:7A:B1:04:8B device=Cambium ePMP + account=#2292 name='Joe Speciale' host='Joe Speciale' + router=newhope ip=10.10.128.8 mac=00:04:56:DB:EB:78 device=Ubiquiti (legacy) + account=#809 name='Brad Gurney' host='Brad Gurney' + router=newhope ip=10.10.142.252 mac=58:C1:7A:B0:C1:3B device=Cambium ePMP + account=#2295 name='Christine Hamparyan' host='Christine Hamparyan' + router=newhope ip=10.10.142.253 mac=58:C1:7A:B1:05:D2 device=Cambium ePMP + account=#2218 name='Greg Hummel' host='Greg Hummel' diff --git a/docs/plans/2026-05-14-ipv6-traceroute-collapse-6pe.md b/docs/plans/2026-05-14-ipv6-traceroute-collapse-6pe.md new file mode 100644 index 0000000..f1094a0 --- /dev/null +++ b/docs/plans/2026-05-14-ipv6-traceroute-collapse-6pe.md @@ -0,0 +1,229 @@ +# IPv6 Traceroute Collapse (6PE) Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Make IPv6 traffic transit the vntx backbone as MPLS-labeled frames so `traceroute6` collapses the internal core to ~1 hop, matching the existing IPv4 behaviour. + +**Architecture:** Today LDP is IPv4-only and `/mpls settings propagate-ttl=no` is set fleet-wide, so labeled IPv4 hides the core. IPv6 is forwarded natively (hop-limit decremented every hop). To get the same collapse, IPv6 must ride the existing IPv4 LSPs — either via **6PE** (RFC 4798: labeled-unicast IPv6 in the global table, BGP next-hop = IPv4 loopback) or **VPNv6** (IPv6 in a VRF over the v4 core). Both are unproven on this fleet, so this plan is **spike-first**: Phase 1 is a disposable lab that produces a hard go/no-go before any production router is touched. + +**Tech Stack:** RouterOS 7.21.4 (8 tower routers, arm64/tile), RouterOS 6.49.18 (edge), MP-BGP, LDP, OSPFv2/v3, Proxmox + CHR for the lab. + +--- + +## Why this plan is spike-first (read before doing anything) + +Verified facts that make the production syntax **unknowable today**: + +1. **ROS7 6PE is undocumented.** MikroTik's MPLS Overview page never names 6PE. Community testing (forum, v7.8 CHR) reports "mixed/uncertain" results. There is no authoritative config reference. +2. **Possible hard conflict with the existing v6 iBGP.** The fleet already runs `afi=ipv6` iBGP (tower → core RR → edge) to carry the per-tower `/44` aggregates. A third-party note (netlab) states you *cannot run 6PE and plain IPv6 BGP-LU on the same node*. If that holds on ROS7, 6PE would **replace**, not augment, the current v6 iBGP — a much larger change. +3. **edge is RouterOS 6.49.18.** It is the PE toward TWC (AS11427). ROS6's MPLS/BGP stack is entirely separate from ROS7's, and ROS6 6PE support is at least as questionable. edge may need to stay native-v6 (its single upstream hop is cosmetically irrelevant anyway). + +Therefore: **no production router is reconfigured until Phase 1 proves the mechanism in a lab and answers conflict-question #2.** Phases 2–4 deliberately do not contain exact commands — they are filled in from the spike's findings (Task 1.7). + +**If you are tempted to skip Phase 1: don't.** A half-working 6PE deployment black-holes customer IPv6 fleet-wide. + +--- + +## Phase 1 — Lab spike (disposable, zero production impact) + +Build a 3-router CHR lab on graham's home Proxmox that mirrors a slice of the +backbone (`edge′ — core′ — verona′`) plus a CE host at each end, prove that +labeled IPv6 collapses `traceroute6`, and determine whether 6PE coexists with +plain IPv6 iBGP. + +**Topology:** +``` +[CE-A]──ce-a/v6──[verona′]──p2p v4 LDP──[core′]──p2p v4 LDP──[edge′]──ce-b/v6──[CE-B] + 2606:db8:a::/64 2606:db8:b::/64 + loopbacks: verona′ 10.0.0.1 core′ 10.0.0.2 edge′ 10.0.0.3 (v6: 2606:db8:0:10::1/2/3) +``` +CE-A/CE-B can be CHRs or any Linux VM — they just need an IPv6 address and `traceroute6`. + +### Task 1.1: Stand up the CHR lab VMs + +**Files:** +- Create: `docs/plans/lab/6pe-lab-notes.md` (running log of what works — this becomes the source for Phases 2–4) + +**Step 1:** On Proxmox, import the RouterOS CHR image (https://mikrotik.com/download → "Cloud Hosted Router" → 7.21.4 stable VMDK/qcow2). Create 5 VMs: `verona-lab`, `core-lab`, `edge-lab`, `ce-a`, `ce-b`. 256 MB RAM / 1 vCPU each is plenty. CHR is licence-free at ≤1 Mbps — fine for a lab. + +**Step 2:** Create 4 isolated Proxmox bridges (no uplink): `vmbr-cea`, `vmbr-vc` (verona′↔core′), `vmbr-ce` (core′↔edge′), `vmbr-ceb`. Wire NICs: +- verona-lab: eth1→vmbr-cea, eth2→vmbr-vc +- core-lab: eth1→vmbr-vc, eth2→vmbr-ce +- edge-lab: eth1→vmbr-ce, eth2→vmbr-ceb +- ce-a: eth1→vmbr-cea ; ce-b: eth1→vmbr-ceb + +**Step 3:** Boot all 5. Confirm console login on each. + +**Step 4 (commit):** +```bash +cd /Users/graham/dev/network +git add docs/plans/lab/6pe-lab-notes.md +git commit -m "6pe lab: stand up 5-VM CHR topology" +``` +Expected: lab notes file committed with the topology + VM IDs recorded. + +### Task 1.2: Build the IPv4 + LDP core (mirror of production) + +**Step 1:** On each lab router configure loopback, p2p /30s, OSPFv2, LDP, and `propagate-ttl=no` — a scaled-down copy of the real fleet. Example for `core-lab` (adapt addresses per router): +```rsc +/interface bridge add name=lo +/ip address add address=10.0.0.2/32 interface=lo +/ip address add address=10.1.12.2/30 interface=ether1 ;; to verona′ +/ip address add address=10.1.23.1/30 interface=ether2 ;; to edge′ +/routing ospf instance add name=v2 router-id=10.0.0.2 +/routing ospf area add name=backbone instance=v2 area-id=0.0.0.0 +/routing ospf interface-template add area=backbone interfaces=lo passive +/routing ospf interface-template add area=backbone interfaces=ether1,ether2 type=ptp +/mpls ldp add lsr-id=10.0.0.2 transport-addresses=10.0.0.2 +/mpls ldp interface add interface=ether1 +/mpls ldp interface add interface=ether2 +/mpls settings set propagate-ttl=no +``` + +**Step 2: Verify the v4 core.** From `verona-lab`: +```rsc +/routing ospf neighbor print ;; expect Full to core′ +/mpls ldp neighbor print ;; expect operational to core′ +/mpls forwarding-table print ;; expect labels for 10.0.0.2, 10.0.0.3 +/tool traceroute 10.0.0.3 ;; edge′ loopback — expect 1 hop (LDP + propagate-ttl=no) +``` +Expected: traceroute to the far loopback shows **one hop**. This proves the lab reproduces the production v4 collapse. If it does not, fix the lab before continuing — Phase 1's whole value is a faithful mirror. + +**Step 3 (commit):** Record the working core configs in `6pe-lab-notes.md`, commit. + +### Task 1.3: Baseline — native IPv6, measure the problem + +**Step 1:** Add native IPv6 + OSPFv3 across the lab core (loopbacks `2606:db8:0:10::1/2/3`, p2p `2606:db8:0:1N::/64`), and give CE-A/CE-B addresses in `2606:db8:a::/64` and `2606:db8:b::/64` with static routes or OSPFv3. + +**Step 2: Measure baseline.** From CE-A: +``` +traceroute6 2606:db8:b::1 +``` +Expected: **3+ hops** (verona′, core′, edge′ each appear). Record exact output in lab notes — this is the "before". + +**Step 3 (commit):** Commit lab notes with the native-v6 baseline traceroute. + +### Task 1.4: Add the existing-style IPv6 iBGP (reproduce the conflict surface) + +**Step 1:** Configure v6 iBGP exactly like production: `verona-lab` and `edge-lab` as RR-clients of `core-lab`, `afi=ip,ipv6`, sessions over the v6 loopbacks, redistributing connected. This is the config that 6PE may conflict with. + +**Step 2: Verify** v6 iBGP sessions establish and the CE prefixes are learned. Record in lab notes. + +**Step 3 (commit).** + +### Task 1.5: Attempt 6PE — the actual unknown + +**This is the discovery task. Do not fabricate commands — find them.** + +**Step 1:** Read, in this order, and capture findings in lab notes: +- MikroTik MPLS Overview + BGP doc pages (help.mikrotik.com) — search for "label", "ipv6", "6PE", "labeled-unicast". +- `/routing bgp connection` and `/routing bgp template` — dump every available property on a lab router (`/routing bgp connection print detail` on a connection with all afi values; try `afi=ipv6` plus any label-related sub-property the CLI tab-completes). +- The MikroTik forum thread "IPv6-on-MPLS BGP-free Core?" for working/failing examples. + +**Step 2:** On the lab, attempt 6PE: make the v6 iBGP advertise labeled IPv6 with BGP next-hop = the IPv4 loopback (as `::ffff:10.0.0.x`). Try it **alongside** the Task 1.4 plain v6 iBGP first. + +**Step 3: Decision question A — coexistence.** Does enabling 6PE break or get rejected next to the plain `afi=ipv6` iBGP? Record the exact behaviour. If they cannot coexist, note that production Phase 3 must *replace* the v6 iBGP, and re-test 1.5 with the plain v6 iBGP removed. + +**Step 4 (commit):** Lab notes updated with the exact 6PE config that loads without error (or the proof that it can't). + +### Task 1.6: Verify the collapse (the success criterion) + +**Step 1:** Confirm labels are bound to IPv6 routes: +```rsc +/mpls forwarding-table print ;; expect 2606:db8:b::/64 etc. with a label +``` + +**Step 2:** From CE-A: +``` +traceroute6 2606:db8:b::1 +``` +**Success = 1 hop across the lab core** (or core′ invisible), matching the v4 behaviour from Task 1.2. + +**Step 3:** Failure-mode checks — confirm nothing else broke: +- CE-A ↔ CE-B large-packet ping (`ping6 -s 1452`) — no new MTU loss (label adds 4 bytes; lab p2p MTU must have headroom, same as the real `mpls-mtu=1508`). +- Kill the `core-lab`↔`edge-lab` link, confirm v6 reconverges (or fails predictably). + +**Step 4 (commit):** Lab notes updated with the before/after traceroute and the failure-mode results. + +### Task 1.7: Write the go/no-go and fill in Phases 2–4 + +**Step 1:** In `6pe-lab-notes.md`, write a **GO / NO-GO** verdict answering: +- Does labeled IPv6 collapse `traceroute6` on ROS 7.21.4? (Task 1.6) +- Can 6PE coexist with the existing v6 iBGP, or must it replace it? (Task 1.5 Step 3) +- What is the **exact, tested** per-router 6PE config delta? +- Does anything require an MTU change beyond the existing `mpls-mtu=1508`? + +**Step 2:** If **GO**: replace the "TBD pending spike" bodies of Phase 2 and Phase 3 below with the exact tested commands from the lab. If **NO-GO**: stop here, jump to "Alternatives", and bring the recommendation to graham. + +**Step 3 (commit):** +```bash +git add docs/plans/lab/6pe-lab-notes.md docs/plans/2026-05-14-ipv6-traceroute-collapse-6pe.md +git commit -m "6pe lab: go/no-go verdict + fill production phases" +``` + +### Task 1.8: Tear down the lab + +**Step 1:** Delete the 5 CHR VMs and 4 Proxmox bridges. The lab notes (committed) are the deliverable; the VMs are disposable. + +**Step 2 (commit):** Note teardown in lab notes, commit. + +--- + +## Phase 2 — Production pilot: verona ↔ climax ↔ core *(contingent on Phase 1 = GO)* + +**Body TBD — populated from Task 1.7.** Constraints the filled-in version must honour: + +- **Apply order matters.** Bring up the labeled-v6 path core-first, then climax, then verona, so a tower is never advertising labeled v6 toward a router that can't resolve it. +- **One change window, one link triangle.** Pilot only verona/climax/core; do not touch the other 5 towers until the pilot soaks ≥24h. +- **Each router task = change → verify → rollback-ready**, e.g.: + - apply 6PE delta on core + - `/mpls forwarding-table print` shows labels for the pilot towers' v6 + - `/routing bgp advertisements` / `/routing route print` confirms labeled v6 NLRI + - from graham's home Mac: `traceroute6` to an off-net v6 host — core no longer a distinct hop + - `ping6 -s 1452` to `2606:4700:4700::1111` still 0% loss + - customer-facing: a known v6 PPPoE/wired client still reaches the internet +- **Rollback (per router):** the inverse delta, recorded in Task 1.7. Native v6 + OSPFv3 + the existing v6 iBGP must still be present underneath (do not delete them in the pilot — if 6PE coexists, native is the fallback; if it doesn't, Phase 2 rollback = re-add v6 iBGP). +- **Soak gate:** 24h with no v6 customer complaints + traceroute stays collapsed before Phase 3. + +## Phase 3 — Fleet rollout: culleoka, newhope, lowry, 982, 494 *(contingent)* + +**Body TBD — populated from Task 1.7.** Constraints: + +- Roll out in the CGNAT-renumber order from `subnets.md` (one tower per change window). +- Same per-router verify set as Phase 2. +- newhope carries lowry's transit — do newhope before lowry. +- After each tower: `traceroute6` from home Mac still collapsed, customer v6 spot-check. +- Keep `mikrotik-tool` (`api`/`script`) as the push mechanism; one `.rsc` per router or a templated apply. + +## Phase 4 — edge (RouterOS 6.49.18) *(contingent, likely "leave native")* + +**Body TBD.** Default expectation from Phase 1 framing point #3: **edge stays native IPv6.** edge is one hop from core and one hop from TWC — collapsing it changes nothing a user sees. Only revisit if Task 1.7 specifically finds ROS6 6PE both works and is needed. If edge stays native, document that the v6 path is "labeled across the ROS7 core, native for the edge↔TWC hop" so the next operator isn't surprised. + +--- + +## Rollback (whole project) + +- **Phase 1:** delete VMs. Nothing to roll back. +- **Phase 2/3:** per-router inverse delta from Task 1.7, applied in reverse order (towers before core). Native v6 forwarding + OSPFv3 are never removed during pilot/rollout, so the floor is always "back to today's working native v6." +- **Trigger:** any v6 customer outage, `traceroute6` black-holing (not just collapsing), or MTU loss that won't resolve. +- The firewall, DNS, and home-router work already done (forward `accept src 2606:1c80::/32`, `v6-dns` resolvers, home `/56`) are **independent of this plan** and are not touched by any phase. + +--- + +## Alternatives (if Phase 1 = NO-GO) + +| Option | What it gives | Cost / risk | +|---|---|---| +| **Do nothing** | v6 traceroute keeps showing the core (internal hops currently appear as `*` — they don't even reply). Everything works. | Zero. The collapse is cosmetic. | +| **VPNv6** | IPv6 in a VRF over the v4 core — *is* labeled, so traceroute collapses. ROS7 documents `vpnv6`. | Large: every router's customer v6 (addresses, pools, PPP profiles, firewall, the home `/56`) moves into a VRF. Much bigger blast radius than 6PE. | +| **Make internal hops reply instead of hiding them** | Opposite of the goal, but "honest" traceroute — each router answers with ICMPv6 time-exceeded. | Small `chain=input` tweak. Only relevant if the real annoyance is the `* * *` lines, not the hop count. | + +**Recommendation if NO-GO:** do nothing. The IPv4 collapse is a side-effect of MPLS existing for real routing reasons; chasing the same cosmetics for IPv6 via a VRF migration is not worth the blast radius. Revisit only if MikroTik documents real 6PE support in a later ROS7 release. + +--- + +## Open questions for graham (resolve before Phase 2, ideally before Phase 1) + +1. Is collapsing v6 traceroute worth a multi-window backbone change, or is it "nice to have"? (Sets how hard to push past a marginal Phase 1 result.) +2. Proxmox capacity at home for 5 throwaway CHR VMs — OK to spin up? +3. edge's eventual ROS6→ROS7 upgrade — if that's on the roadmap anyway, Phase 4 should wait for it rather than attempt ROS6 6PE. diff --git a/gaiia/.direnv b/gaiia/.direnv new file mode 100644 index 0000000..92dff2c --- /dev/null +++ b/gaiia/.direnv @@ -0,0 +1,3 @@ +export GAIIA_KEY=gaiia_sk_live_955b27e3680747a69ad40b403a912155_b0d1be3c +export EXPIRE_WF_ID=workflow_gbL1BTRA5Xt4WkMy2uRLdW + diff --git a/gaiia/.envrc b/gaiia/.envrc new file mode 100644 index 0000000..586648c --- /dev/null +++ b/gaiia/.envrc @@ -0,0 +1,2 @@ +export GAIIA_KEY=gaiia_sk_live_955b27e3680747a69ad40b403a912155_b0d1be3c + diff --git a/gaiia/.gitignore b/gaiia/.gitignore new file mode 100644 index 0000000..d30c5ef --- /dev/null +++ b/gaiia/.gitignore @@ -0,0 +1,17 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.eggs/ +build/ +dist/ +.venv/ +venv/ +.env +.envrc.local +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ +*.swp +.DS_Store diff --git a/gaiia/LICENSE b/gaiia/LICENSE new file mode 100644 index 0000000..1085637 --- /dev/null +++ b/gaiia/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Graham McIntire + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/gaiia/README.md b/gaiia/README.md new file mode 100644 index 0000000..d30e21c --- /dev/null +++ b/gaiia/README.md @@ -0,0 +1,198 @@ +# gaiia-py + +Python client for the [gaiia](https://app.gaiia.com/docs/overview) GraphQL API +and webhooks. Thin wrapper around the official GraphQL surface — no codegen, no +opinionated ORM. You write GraphQL, the client handles auth, pagination, rate +limits, GlobalID encoding, and webhook signature verification. + +## Install + +```bash +pip install gaiia +``` + +Requires Python 3.10+. Depends on `httpx`. + +## Quick start + +```python +from gaiia import GaiiaClient + +with GaiiaClient(api_key="gaiia_sk_live_...", timezone="America/Chicago") as g: + data = g.query( + "query Q($first: Int!) { technicians(first: $first) { nodes { id firstName lastName } } }", + {"first": 10}, + ) + for t in data["technicians"]["nodes"]: + print(t) +``` + +If `api_key=` is omitted, the client reads `GAIIA_KEY` from the environment. + +## Mutations + +The `mutate()` helper unwraps the mutation result and raises +`GaiiaMutationError` when the response contains validation errors under +`data..errors`: + +```python +res = g.mutate( + """ + mutation A($i: AssignTechnicianToWorkOrderInput!) { + assignTechnicianToWorkOrder(input: $i) { + workOrder { id status startDate } + errors { code message } + } + } + """, + "assignTechnicianToWorkOrder", + {"i": { + "workOrderId": "work_order_...", + "technicianId": "technician_...", + "startDate": "2026-05-12T14:00:00Z", + }}, +) +print(res["workOrder"]["id"]) +``` + +## Pagination + +`iter_nodes()` paginates a Relay Connection automatically. Your query must +accept an `$after: String` variable and pass it to the paginated field: + +```python +for tech in g.iter_nodes( + """ + query Q($after: String) { + technicians(first: 50, after: $after) { + nodes { id firstName lastName } + pageInfo { hasNextPage endCursor } + } + } + """, + "technicians", +): + print(tech["firstName"], tech["lastName"]) +``` + +Default page size is 50, max 250 (per the gaiia docs). Some fields cap lower — +check the field's reference page. + +For bulk extracts (millions of rows, reporting, analytics), gaiia recommends +[Snowflake](https://app.gaiia.com/docs/rate-limits) rather than the GraphQL +API. + +## Rate limiting + +After every call, `client.last_rate_limit` is populated from the response +headers (`X-Rate-Limit-*`). If gaiia returns a `RATE_LIMITED` error, the +client raises `GaiiaRateLimitedError` with `limit`, `used`, `remaining`, and +`retry_at`: + +```python +from gaiia import GaiiaClient, GaiiaRateLimitedError + +with GaiiaClient(max_retries=2) as g: + try: + g.query("{ ... }") + except GaiiaRateLimitedError as e: + print("retry after", e.retry_at) +``` + +Setting `max_retries` > 0 makes the client sleep until `retry_at` and retry up +to that many times before re-raising. + +## GlobalIDs + +```python +from gaiia import encode_global_id, decode_global_id + +encode_global_id("Account", "3c3b1978-6a68-4a13-bdc2-2d51c8ef7519") +# 'account_8rnXNuR5sKP5uNwoPL41Zp' + +decode_global_id("account_8rnXNuR5sKP5uNwoPL41Zp") +# ('account', '3c3b1978-6a68-4a13-bdc2-2d51c8ef7519') +``` + +The encoding follows gaiia's `short-uuid` setup: Flickr Base-58 of the UUID's +128-bit integer, left-padded to 22 chars, prefixed with the snake-cased type. + +## Webhooks + +```python +from gaiia import verify_webhook_signature, WebhookSignatureError + +try: + verify_webhook_signature( + header_value=request.headers["X-Gaiia-Webhook-Signature"], + raw_body=request.body_bytes, # the exact bytes gaiia signed + secret=os.environ["GAIIA_WEBHOOK_SECRET"], + tolerance_seconds=300, + ) +except WebhookSignatureError: + return 400 +``` + +The verifier: + +- Parses `t=...,v1=...` (multiple `v1=` allowed for key rotation), +- Computes `HMAC-SHA256(secret, "${t}.${rawBody}")`, +- Constant-time compares against each `v1=` candidate, +- Rejects timestamps outside the tolerance window. + +See `examples/webhook_flask.py` for a complete handler. + +## Async + +`AsyncGaiiaClient` mirrors `GaiiaClient` for `asyncio` code: + +```python +import asyncio +from gaiia import AsyncGaiiaClient + +async def main(): + async with AsyncGaiiaClient(timezone="America/Chicago") as g: + data = await g.query("{ technicians(first: 5) { nodes { id firstName lastName } } }") + print(data) + +asyncio.run(main()) +``` + +`async for` over `iter_nodes()` paginates lazily. + +## Timezones + +gaiia stores all timestamps in UTC. Pass `timezone="America/Chicago"` (or +whatever IANA TZ applies) at the client level — it becomes the `x-timezone` +header on every request, which gaiia uses for scheduling/availability logic. +Override per-call with `g.query(..., timezone="America/Phoenix")`. + +For display, query the work order's `location.timezone` and format times in +that zone — not the caller's. + +## Error model + +| Class | When it's raised | +| ------------------------ | ---------------------------------------------------------------------- | +| `GaiiaTransportError` | Network failure, non-JSON response, HTTP 5xx. | +| `GaiiaAPIError` | Top-level GraphQL `errors[]` returned (schema/auth/permission). | +| `GaiiaRateLimitedError` | Subclass of `GaiiaAPIError` for `RATE_LIMITED`. | +| `GaiiaMutationError` | Mutation `data..errors[]` is non-empty (validation/business rule). | + +All inherit from `GaiiaError`. + +## Development + +```bash +git clone https://github.com/grahammcintire/gaiia-py +cd gaiia-py +python -m venv .venv && source .venv/bin/activate +pip install -e ".[dev]" +pytest +ruff check . +mypy src/gaiia +``` + +## License + +MIT. diff --git a/gaiia/account_status_suspension_workflow.json b/gaiia/account_status_suspension_workflow.json new file mode 100644 index 0000000..1f0ee4e --- /dev/null +++ b/gaiia/account_status_suspension_workflow.json @@ -0,0 +1,116 @@ +{ + "name": "Account Status - RADIUS Suspension", + "description": "EVENT: When account status changes to SUSPENDED or INACTIVE, update RADIUS usergroup to suspended. When it returns to ACTIVE, restore normal groups.", + "trigger": { + "type": "EVENT", + "events": ["account.status_updated"], + "nextSteps": [{ "slug": "get-account" }] + }, + "steps": [ + { + "name": "Get account status and products", + "slug": "get-account", + "type": "GAIIA_API_GATEWAY_V1_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-public-api-gateway-graphql-request", + "allowSkipExecution": false, + "outputMapper": "({ output, state }) => {\n const acct = output.response.account;\n if (!acct) {\n workflow.fail('Account not found', 'ACCOUNT_NOT_FOUND');\n }\n const status = acct.status?.name?.toUpperCase() || '';\n const products = [];\n for (const sub of (acct.billingSubscriptions?.nodes || [])) {\n const p = sub.productVersion?.product;\n if (p && !products.includes(p.id)) {\n products.push(p.id);\n }\n }\n return { status, products, accountId: acct.id };\n}", + "inputs": [ + { + "key": "query", + "type": "LITERAL", + "value": "string:query GetAccount($id: GlobalID!) {\n account(id: $id) {\n id\n status { name }\n billingSubscriptions {\n nodes {\n productVersion {\n product { id name }\n }\n }\n }\n }\n}" + }, + { + "key": "variables", + "type": "FUNCTION", + "value": "({ state }) => ({ id: state.input.objectId })" + } + ], + "nextSteps": [{ "slug": "check-radius-exists" }] + }, + { + "name": "Check if account has RADIUS usernames", + "slug": "check-radius-exists", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "allowSkipExecution": false, + "outputMapper": "({ output, state }) => {\n const acct = state.steps['get-account'].output;\n acct.hasRadius = output && output.length > 0;\n return acct;\n}", + "inputs": [ + { + "key": "function", + "type": "FUNCTION", + "value": "async ({ state, utils }) => {\n const { accountId } = state.steps['get-account'].output;\n const existingData = await utils.integration.datastore.getItem(`radiusAccount.${accountId}`);\n const accounts = existingData ? JSON.parse(existingData) : [];\n return accounts;\n}" + } + ], + "nextSteps": [{ "slug": "should-suspend" }] + }, + { + "name": "Should suspend?", + "slug": "should-suspend", + "type": "CONDITION", + "nodeSlug": "condition", + "condition": "({ secrets, state, utils, variables }) => {\n const { status, hasRadius } = state.steps['check-radius-exists'].output;\n return hasRadius && (status === 'SUSPENDED' || status === 'INACTIVE');\n}", + "elseSteps": [{ "slug": "should-reactivate" }], + "thenSteps": [{ "slug": "update-groups-suspended" }] + }, + { + "name": "Update RADIUS groups to SUSPENDED", + "slug": "update-groups-suspended", + "type": "GAIIA_START_WORKFLOW_EXECUTION", + "nodeSlug": "start-workflow-execution", + "allowSkipExecution": false, + "inputs": [ + { + "key": "workflowId", + "type": "LITERAL", + "value": "string:workflow_fLXTNCvridPqwEEXxf3CK1" + }, + { + "key": "input", + "type": "FUNCTION", + "value": "({ state }) => {\n const { accountId, products, status } = state.steps['check-radius-exists'].output;\n return {\n accountId,\n status,\n productIds: products\n };\n}" + }, + { + "key": "fireAndForget", + "type": "LITERAL", + "value": "boolean:true" + } + ], + "nextSteps": [{ "slug": "end" }] + }, + { + "name": "Should reactivate?", + "slug": "should-reactivate", + "type": "CONDITION", + "nodeSlug": "condition", + "condition": "({ secrets, state, utils, variables }) => {\n const { status, hasRadius } = state.steps['check-radius-exists'].output;\n return hasRadius && status === 'ACTIVE';\n}", + "elseSteps": [{ "slug": "end" }], + "thenSteps": [{ "slug": "update-groups-active" }] + }, + { + "name": "Update RADIUS groups to ACTIVE", + "slug": "update-groups-active", + "type": "GAIIA_START_WORKFLOW_EXECUTION", + "nodeSlug": "start-workflow-execution", + "allowSkipExecution": false, + "inputs": [ + { + "key": "workflowId", + "type": "LITERAL", + "value": "string:workflow_fLXTNCvridPqwEEXxf3CK1" + }, + { + "key": "input", + "type": "FUNCTION", + "value": "({ state }) => {\n const { accountId, products } = state.steps['check-radius-exists'].output;\n return {\n accountId,\n status: 'ACTIVE',\n productIds: products\n };\n}" + }, + { + "key": "fireAndForget", + "type": "LITERAL", + "value": "boolean:true" + } + ], + "nextSteps": [{ "slug": "end" }] + } + ] +} diff --git a/gaiia/cleanup_stale_byod_workflow.json b/gaiia/cleanup_stale_byod_workflow.json new file mode 100644 index 0000000..0d477dc --- /dev/null +++ b/gaiia/cleanup_stale_byod_workflow.json @@ -0,0 +1,78 @@ +{ + "name": "Cleanup Stale BYOD Items", + "description": "SCHEDULED (nightly 2am): Unassigns Customer Provided Router items that have no active IP assignment. Keeps items with active IPs intact.", + "trigger": { + "type": "SCHEDULE", + "cron": "0 2 * * *", + "nextSteps": [{ "slug": "find-stale-byod" }] + }, + "steps": [ + { + "name": "Find stale BYOD items", + "slug": "find-stale-byod", + "type": "GAIIA_API_GATEWAY_V1_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-public-api-gateway-graphql-request", + "allowSkipExecution": false, + "outputMapper": "({ output }) => {\n const items = output.response.inventoryItems.nodes;\n const stale = [];\n for (const item of items) {\n const assigns = item.ipAddressAssignments?.nodes || [];\n const hasActive = assigns.some((a) => a.isActive !== false);\n if (!hasActive) {\n const assignee = item.assignation?.assignee || {};\n if (assignee.__typename === 'Account' && assignee.id) {\n stale.push({ inventoryId: item.id, accountId: assignee.id, accountName: assignee.name || '' });\n }\n }\n }\n return stale;\n}", + "inputs": [ + { + "key": "query", + "type": "LITERAL", + "value": "string:query {\n inventoryItems(\n first: 250\n filters: { modelId: { equals: \"inventory_model_522SxVU9VvPaMXYqWNbfbx\" }, assigneeType: { equals: ACCOUNT } }\n ) {\n nodes {\n id\n ipAddressAssignments {\n nodes { ipAddress macAddress isActive }\n }\n assignation {\n assignee { __typename ... on Account { id name } }\n }\n }\n totalCount\n }\n}" + }, + { + "key": "variables", + "type": "FUNCTION", + "value": "({ secrets, state, utils, variables }) => ({})" + } + ], + "nextSteps": [{ "slug": "if-any-stale" }] + }, + { + "name": "Any stale items?", + "slug": "if-any-stale", + "type": "CONDITION", + "nodeSlug": "condition", + "condition": "({ secrets, state, utils, variables }) => {\n const items = state.steps['find-stale-byod'].output;\n return Array.isArray(items) && items.length > 0;\n}", + "elseSteps": [{ "slug": "end" }], + "thenSteps": [{ "slug": "unassign-stale" }] + }, + { + "name": "Unassign each stale item", + "slug": "unassign-stale", + "type": "PARALLEL_LOOP", + "nodeSlug": "parallel-loop", + "maxConcurrentIterations": 10, + "inputs": [ + { + "key": "iterable", + "type": "FUNCTION", + "value": "({ state }) => state.steps['find-stale-byod'].output" + } + ], + "initialSteps": [{ "slug": "unassign-one" }], + "nextSteps": [{ "slug": "end" }] + }, + { + "name": "Unassign from account", + "slug": "unassign-one", + "type": "GAIIA_API_GATEWAY_V1_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-public-api-gateway-graphql-request", + "parentParallelLoopSlug": "unassign-stale", + "allowSkipExecution": false, + "inputs": [ + { + "key": "query", + "type": "LITERAL", + "value": "string:mutation UnassignItem($input: UnassignCustomerOwnedItemInput!) {\n unassignCustomerOwnedItem(input: $input) {\n customerOwnedItem { id }\n }\n}" + }, + { + "key": "variables", + "type": "FUNCTION", + "value": "({ state }) => {\n const item = state.currentParallelLoopIterations['unassign-stale'];\n return { input: { id: item.inventoryId } };\n}" + } + ], + "nextSteps": [{ "slug": "unassign-stale-end" }] + } + ] +} diff --git a/gaiia/clear_all_ips.py b/gaiia/clear_all_ips.py new file mode 100644 index 0000000..27e4501 --- /dev/null +++ b/gaiia/clear_all_ips.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +""" +Clear all MAC->IP address assignments from customer inventory items. + +Two-phase approach: + 1. List all active IP assignments via paginated V1 public API + 2. Expire each one via startWorkflowExecution mutation + +Before running: + 1. Create the "Expire Single IP Assignment" workflow in Gaiia UI + (use expire_one_workflow.json) + 2. Copy its workflow ID and set EXPIRE_WF_ID below + 3. source .envrc + 4. uv run python3 clear_all_ips.py --dry-run (preview) + 5. uv run python3 clear_all_ips.py (execute) +""" + +from gaiia import GaiiaClient, GaiiaMutationError +import json +import os +import sys +import time + +EXPIRE_WF_ID = os.environ.get("EXPIRE_WF_ID", "") + +def list_all_assignments(client): + """Paginate through all inventory items and return active MAC->IP pairs.""" + all_assignments = [] + after = None + page = 0 + + while True: + page += 1 + query = """ + query Q($after: String) { + inventoryItems( + filters: {assigneeType: {equals: ACCOUNT}} + first: 250 + after: $after + ) { + nodes { + ipAddressAssignments { + nodes { ipAddress macAddress isActive } + } + } + pageInfo { hasNextPage endCursor } + totalCount + } + } + """ + data = client.query(query, {"after": after} if after else {}) + result = data['inventoryItems'] + + if page == 1: + print(f"Total inventory items: {result['totalCount']}") + + for item in result['nodes']: + for a in item.get('ipAddressAssignments', {}).get('nodes', []): + if a.get('isActive') != False: + all_assignments.append({ + 'macAddress': a['macAddress'], + 'ipAddress': a['ipAddress'] + }) + + page_info = result['pageInfo'] + sys.stderr.write(f"\r Page {page}: {len(all_assignments)} assignments so far...") + sys.stderr.flush() + + if not page_info.get('hasNextPage'): + break + after = page_info['endCursor'] + + sys.stderr.write("\n") + return all_assignments + + +def expire_one(client, mac_address, ip_address, dry_run=False): + """Call the Expire Single IP Assignment workflow via startWorkflowExecution.""" + if dry_run: + print(f" [DRY-RUN] {mac_address} -> {ip_address}") + return True + + mutation = """ + mutation Expire($input: StartWorkflowExecutionInput!) { + startWorkflowExecution(input: $input) { + workflowExecution { id status } + } + } + """ + try: + client.query(mutation, { + "input": { + "workflowId": EXPIRE_WF_ID, + "input": {"macAddress": mac_address, "ipAddress": ip_address} + } + }) + return True + except GaiiaMutationError as e: + print(f" FAILED {mac_address} -> {ip_address}: {e}") + return False + + +def main(): + if not EXPIRE_WF_ID: + print("ERROR: Set EXPIRE_WF_ID env var to the workflow ID of 'Expire Single IP Assignment'") + print("Example: EXPIRE_WF_ID=workflow_xxx uv run python3 clear_all_ips.py --dry-run") + sys.exit(1) + + dry_run = "--dry-run" in sys.argv + + with GaiiaClient(timezone='America/Chicago') as g: + print("Listing all active IP assignments...") + assignments = list_all_assignments(g) + print(f"\nFound {len(assignments)} active IP assignments") + + if dry_run: + print("\n--dry-run mode: showing what would be expired --") + else: + print(f"\nExpiring via workflow {EXPIRE_WF_ID}...") + + succeeded = 0 + failed = 0 + for i, a in enumerate(assignments): + if not dry_run: + sys.stderr.write(f"\r {i+1}/{len(assignments)}: {a['macAddress']} -> {a['ipAddress']}") + sys.stderr.flush() + if expire_one(g, a['macAddress'], a['ipAddress'], dry_run): + succeeded += 1 + else: + failed += 1 + if not dry_run: + time.sleep(0.1) # avoid rate limiting + + if not dry_run: + sys.stderr.write("\n") + + print(f"\nDone: {succeeded} succeeded, {failed} failed") + if dry_run: + print("(nothing was changed — remove --dry-run to execute)") + + +if __name__ == '__main__': + main() diff --git a/gaiia/clear_ip_assignments_workflow.json b/gaiia/clear_ip_assignments_workflow.json new file mode 100644 index 0000000..33ab83b --- /dev/null +++ b/gaiia/clear_ip_assignments_workflow.json @@ -0,0 +1,85 @@ +{ + "name": "Clear All IP Assignments", + "description": "ONE-SHOT: Expires all MAC→IP address assignments across all customer inventory items. Run once, then delete this workflow.", + "trigger": { + "type": "MANUAL", + "nextSteps": [ + { "slug": "find-assigned-items" } + ] + }, + "steps": [ + { + "name": "Find all inventory items assigned to accounts", + "slug": "find-assigned-items", + "type": "GAIIA_API_GATEWAY_V1_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-public-api-gateway-graphql-request", + "allowSkipExecution": false, + "outputMapper": "({ output }) => {\n const items = output.response.inventoryItems.nodes;\n const withIps = [];\n for (const item of items) {\n const assignments = item.ipAddressAssignments?.nodes || [];\n for (const a of assignments) {\n if (a.isActive !== false) {\n withIps.push({\n macAddress: a.macAddress,\n ipAddress: a.ipAddress\n });\n }\n }\n }\n return withIps;\n}", + "inputs": [ + { + "key": "query", + "type": "LITERAL", + "value": "string:query AllAssignedItems($filters: InventoryItemsQueryFilters, $first: Int, $after: String) {\n inventoryItems(filters: $filters, first: $first, after: $after) {\n nodes {\n ipAddressAssignments {\n nodes {\n ipAddress\n macAddress\n isActive\n }\n }\n }\n pageInfo { hasNextPage endCursor }\n totalCount\n }\n}" + }, + { + "key": "variables", + "type": "LITERAL", + "value": "json:{\"filters\":{\"assigneeType\":{\"equals\":\"ACCOUNT\"}},\"first\":250}" + } + ], + "nextSteps": [ + { "slug": "if-any-assignments" } + ] + }, + { + "name": "Any assignments to clear?", + "slug": "if-any-assignments", + "type": "CONDITION", + "nodeSlug": "condition", + "condition": "({ secrets, state, utils, variables }) => {\n const items = state.steps['find-assigned-items'].output;\n return items && items.length > 0;\n}", + "elseSteps": [{ "slug": "end" }], + "thenSteps": [{ "slug": "expire-assignments" }] + }, + { + "name": "Expire each assignment", + "slug": "expire-assignments", + "type": "PARALLEL_LOOP", + "nodeSlug": "parallel-loop", + "maxConcurrentIterations": 10, + "inputs": [ + { + "key": "iterable", + "type": "FUNCTION", + "value": "({ state }) => state.steps['find-assigned-items'].output" + } + ], + "initialSteps": [ + { "slug": "expire-one" } + ], + "nextSteps": [{ "slug": "end" }] + }, + { + "name": "Expire single assignment", + "slug": "expire-one", + "type": "GAIIA_API_GATEWAY_V0_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-internal-api-gateway-graphql-request", + "parentParallelLoopSlug": "expire-assignments", + "allowSkipExecution": false, + "inputs": [ + { + "key": "query", + "type": "LITERAL", + "value": "string:mutation ExpireAssignation($input: UpsertMacAddressIpAddressAssignationsInput!) {\n upsertMacAddressIpAddressAssignations(input: $input) {\n assignations {\n macAddress\n ipAddress\n }\n }\n}" + }, + { + "key": "variables", + "type": "FUNCTION", + "value": "({ state }) => {\n const item = state.currentParallelLoopIterations['expire-assignments'];\n return {\n input: {\n assignations: {\n macAddress: item.macAddress,\n ipAddress: item.ipAddress,\n expired: true\n }\n }\n };\n}" + } + ], + "nextSteps": [ + { "slug": "expire-assignments-end" } + ] + } + ] +} diff --git a/gaiia/clear_ips_step1_list.json b/gaiia/clear_ips_step1_list.json new file mode 100644 index 0000000..99aba34 --- /dev/null +++ b/gaiia/clear_ips_step1_list.json @@ -0,0 +1,29 @@ +{ + "name": "Test - List IPs", + "trigger": { + "type": "MANUAL", + "nextSteps": [{ "slug": "list" }] + }, + "steps": [ + { + "name": "List assigned items", + "slug": "list", + "type": "GAIIA_API_GATEWAY_V1_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-public-api-gateway-graphql-request", + "allowSkipExecution": false, + "inputs": [ + { + "key": "query", + "type": "LITERAL", + "value": "string:query { inventoryItems(filters: {assigneeType: {equals: ACCOUNT}}, first: 5) { totalCount } }" + }, + { + "key": "variables", + "type": "FUNCTION", + "value": "({ secrets, state, utils, variables }) => ({})" + } + ], + "nextSteps": [{ "slug": "end" }] + } + ] +} diff --git a/gaiia/clear_ips_step2_expire_one.json b/gaiia/clear_ips_step2_expire_one.json new file mode 100644 index 0000000..8ab2bf2 --- /dev/null +++ b/gaiia/clear_ips_step2_expire_one.json @@ -0,0 +1,27 @@ +{ + "name": "Test - Expire One IP", + "description": "Bare minimum V0 expire test with hardcoded MAC+IP", + "trigger": { "type": "MANUAL", "nextSteps": [{ "slug": "expire" }] }, + "steps": [ + { + "name": "Expire", + "slug": "expire", + "type": "GAIIA_API_GATEWAY_V0_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-internal-api-gateway-graphql-request", + "allowSkipExecution": false, + "inputs": [ + { + "key": "query", + "type": "LITERAL", + "value": "string:mutation ExpireOne($input: UpsertMacAddressIpAddressAssignationsInput!) {\n upsertMacAddressIpAddressAssignations(input: $input) {\n assignations { macAddress ipAddress }\n }\n}" + }, + { + "key": "variables", + "type": "LITERAL", + "value": "json:{\"input\":{\"assignations\":{\"macAddress\":\"DC:9F:DB:92:0D:AA\",\"ipAddress\":\"10.10.16.74\",\"expired\":true}}}" + } + ], + "nextSteps": [{ "slug": "end" }] + } + ] +} diff --git a/gaiia/compare_pppoe.py b/gaiia/compare_pppoe.py new file mode 100644 index 0000000..6fd3e96 --- /dev/null +++ b/gaiia/compare_pppoe.py @@ -0,0 +1,249 @@ +"""Compare PPPoE active sessions from climax MikroTik against Gaiia accounts. + +Checks: + 1. Is the PPPoE username found as a Gaiia account? + 2. Does the Gaiia account's inventory item IP match the PPPoE IP? + +Usage: cd /Users/graham/dev/network/gaiia && uv run python compare_pppoe.py +""" + +from __future__ import annotations + +import os +import sys +from typing import Any + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src")) +from gaiia.client import GaiiaClient + +# PPPoE active sessions from climax (2026-07-02) +PPPOE_SESSIONS: list[dict[str, str]] = [ + {"name": "charlesboone", "address": "100.64.7.247", "mac": "C8:9E:43:E3:99:EA"}, + {"name": "amberprater", "address": "100.64.7.243", "mac": "6C:CD:D6:2C:31:94"}, + {"name": "matthewgoodwin", "address": "100.64.7.241", "mac": "34:98:B5:44:42:A4"}, + {"name": "robbymccollom", "address": "100.64.7.239", "mac": "D8:B3:70:92:0C:C6"}, + {"name": "gordonhamilton", "address": "100.64.7.237", "mac": "A0:63:91:3A:29:B1"}, + {"name": "chasewilliams", "address": "100.64.7.235", "mac": "38:94:ED:5B:82:E9"}, + {"name": "cynthiajones", "address": "100.64.7.233", "mac": "E4:FA:C4:56:CA:D9"}, + {"name": "janicealexander", "address": "100.64.7.225", "mac": "E0:D3:62:E8:65:C5"}, + {"name": "michaelray", "address": "204.110.188.38", "mac": "9C:05:D6:59:FF:63"}, + {"name": "marysmelser", "address": "100.64.7.218", "mac": "00:01:9F:33:54:B9"}, + {"name": "eddieyarbrough", "address": "100.64.7.216", "mac": "10:C3:7B:42:D8:B8"}, + {"name": "timgilbert", "address": "100.64.7.213", "mac": "94:A6:7E:EE:98:B8"}, + {"name": "chadwhitsell", "address": "100.64.7.211", "mac": "04:95:E6:76:3C:91"}, + {"name": "donnacampbell", "address": "100.64.7.209", "mac": "74:FE:CE:3A:6D:AC"}, + {"name": "bryangoulart", "address": "100.64.7.205", "mac": "8C:90:2D:E9:90:7A"}, + {"name": "crystalharney", "address": "100.64.7.201", "mac": "98:25:4A:04:B6:43"}, + {"name": "johnvayo", "address": "100.64.7.197", "mac": "98:25:4A:04:B5:89"}, + {"name": "richardbarragan", "address": "100.64.7.193", "mac": "8C:90:2D:E9:98:E1"}, + {"name": "carolstrickland", "address": "100.64.7.189", "mac": "E8:DA:00:18:54:45"}, + {"name": "timbagert", "address": "100.64.7.140", "mac": "74:FE:CE:3A:6D:8E"}, + {"name": "douggarber", "address": "100.64.7.136", "mac": "00:01:9F:33:79:A9"}, + {"name": "janetkern", "address": "100.64.7.61", "mac": "28:C6:8E:C1:06:AF"}, + {"name": "gregmcintire", "address": "100.64.7.59", "mac": "66:62:8B:10:10:65"}, + {"name": "jenniferboon", "address": "100.64.7.35", "mac": "98:25:4A:04:BA:EA"}, + {"name": "glendabeauchamp", "address": "100.64.7.15", "mac": "8C:90:2D:E9:97:85"}, + {"name": "jmichaelculverhouse", "address": "100.64.7.250", "mac": "34:98:B5:31:D3:3A"}, + {"name": "elviraquezada", "address": "100.64.7.249", "mac": "E0:C2:50:47:DC:1A"}, + {"name": "tammieventris", "address": "100.64.7.232", "mac": "98:25:4A:04:A5:E1"}, + {"name": "taekim", "address": "204.110.188.42", "mac": "9C:5C:8E:44:85:B4"}, + {"name": "ruthfengler", "address": "100.64.7.246", "mac": "A0:63:91:25:F6:E5"}, + {"name": "buddyswan", "address": "100.64.7.245", "mac": "00:01:9F:34:D2:21"}, + {"name": "rhondabolton", "address": "100.64.7.231", "mac": "A0:04:60:94:D5:FF"}, +] + +# Known PPPoE-name → Gaiia-displayName mismatches +MANUAL_MAP: dict[str, str] = { + "bryangoulart": "Brian Goulart", # typo in PPPoE username +} + + +def flatten(name: str) -> str: + """Lowercase and remove spaces/punctuation from a name.""" + return name.lower().replace(" ", "").replace("-", "").replace("'", "") + + +def fetch_all_accounts(client: GaiiaClient) -> list[dict[str, Any]]: + """Fetch all Gaiia accounts (id, readableId, displayName).""" + query = """ + query($after: String) { + accounts(first: 50, after: $after) { + nodes { id readableId displayName } + pageInfo { hasNextPage endCursor } + totalCount + } + } + """ + accounts: list[dict[str, Any]] = [] + cursor: str | None = None + has_next = True + while has_next: + variables: dict[str, Any] = {} + if cursor: + variables["after"] = cursor + data = client.query(query, variables) + conn = data["accounts"] + accounts.extend(conn.get("nodes") or []) + has_next = conn["pageInfo"]["hasNextPage"] + cursor = conn["pageInfo"].get("endCursor") + print(f" Fetched {len(accounts)}/{conn.get('totalCount', '?')} accounts...", + file=sys.stderr) + return accounts + + +def fetch_account_inventory( + client: GaiiaClient, account_id: str +) -> list[dict[str, Any]]: + """Fetch inventory items for a specific account, including IP addresses.""" + query = """ + query($id: GlobalID!) { + account(id: $id) { + assignedInventoryItems(first: 10) { + nodes { + inventoryItem { + id + ipAddressV4 + ipAddressV6 + model { + name + manufacturer { name } + } + } + } + } + } + } + """ + data = client.query(query, {"id": account_id}) + acct = data.get("account") + if not acct: + return [] + items = acct.get("assignedInventoryItems", {}) + assignations = items.get("nodes") or [] + # Extract the actual inventory items from assignations + result = [] + for assign in assignations: + item = assign.get("inventoryItem") + if item: + result.append(item) + return result + + +def main() -> None: + api_key = os.environ.get("GAIIA_KEY") + if not api_key: + print("Error: GAIIA_KEY env var not set", file=sys.stderr) + sys.exit(1) + + client = GaiiaClient(api_key, timezone="America/Chicago") + + print("Fetching all accounts from Gaiia...", file=sys.stderr) + accounts = fetch_all_accounts(client) + print(f"Total: {len(accounts)} accounts\n", file=sys.stderr) + + # Build lookup: flattened displayName → account + by_flat: dict[str, dict[str, Any]] = {} + for a in accounts: + by_flat[flatten(a.get("displayName", ""))] = a + + # --- Step 1: Match PPPoE names to Gaiia accounts --- + print("=" * 75) + print("CLIMAX PPPoE → GAIIA ACCOUNT MATCHING") + print("=" * 75) + + matched: list[dict[str, Any]] = [] + unmatched: list[dict[str, Any]] = [] + + for session in PPPOE_SESSIONS: + pname = session["name"] + flat = flatten(pname) + account = by_flat.get(flat) + + # Try manual map + if not account and pname in MANUAL_MAP: + mapped_flat = flatten(MANUAL_MAP[pname]) + account = by_flat.get(mapped_flat) + + session["_account"] = account + if account: + matched.append(session) + else: + unmatched.append(session) + + print(f"{'PPPoE':<25} {'PPPoE IP':<18} {'Gaiia Name':<25} {'Account ID':<8}") + print("-" * 78) + for s in matched: + acc = s["_account"] + print(f"{s['name']:<25} {s['address']:<18} {acc['displayName']:<25} {acc['readableId']:<8}") + for s in unmatched: + print(f"{s['name']:<25} {s['address']:<18} {'(not in Gaiia)':<25} {'-':<8}") + + print(f"\nMatched: {len(matched)} | Not in Gaiia: {len(unmatched)}") + if unmatched: + print("Not in Gaiia:") + for s in unmatched: + print(f" - {s['name']} ({s['address']})") + + # --- Step 2: Compare IP addresses from inventory items --- + print(f"\n{'=' * 75}") + print("IP ADDRESS COMPARISON (PPPoE active IP vs Gaiia inventory item IP)") + print("=" * 75) + + ip_matched = 0 + ip_mismatched = 0 + ip_no_inventory = 0 + ip_not_checked = 0 + + for s in matched: + acc = s["_account"] + pppoe_ip = s["address"] + pppoe_name = s["name"] + + # Fetch inventory items for this account + items = fetch_account_inventory(client, acc["id"]) + s["_items"] = items + + if not items: + print(f"{pppoe_name:<25} {pppoe_ip:<18} (no inventory items in Gaiia)") + ip_no_inventory += 1 + continue + + # Check if any inventory item has an IP matching the PPPoE IP + found_match = False + for item in items: + item_ip = item.get("ipAddressV4") + if item_ip == pppoe_ip: + model = item.get("model", {}) + model_name = model.get("name", "?") if model else "?" + print(f"{pppoe_name:<25} {pppoe_ip:<18} MATCH (item: {model_name})") + ip_matched += 1 + found_match = True + break + + if not found_match: + item_ips = [i.get("ipAddressV4") for i in items if i.get("ipAddressV4")] + if item_ips: + print(f"{pppoe_name:<25} {pppoe_ip:<18} MISMATCH (Gaiia has: {', '.join(item_ips)})") + ip_mismatched += 1 + else: + print(f"{pppoe_name:<25} {pppoe_ip:<18} NO IP SET (has inventory items but no IPs)") + ip_no_inventory += 1 + + print(f"\n--- Summary ---") + print(f"Accounts in Gaiia: {len(matched)}/{len(PPPOE_SESSIONS)}") + print(f"Not in Gaiia: {len(unmatched)}") + print(f"IP matches PPPoE: {ip_matched}") + print(f"IP mismatch (different IP): {ip_mismatched}") + print(f"No inventory / no IP in Gaiia: {ip_no_inventory}") + + if unmatched: + print(f"\nAccounts missing from Gaiia (need investigation):") + for s in unmatched: + mac = s.get("mac", "") + print(f" {s['name']} IP={s['address']} MAC={mac}") + + client.close() + + +if __name__ == "__main__": + main() diff --git a/gaiia/daily_preseem_sync_workflow.json b/gaiia/daily_preseem_sync_workflow.json new file mode 100644 index 0000000..dbf67ed --- /dev/null +++ b/gaiia/daily_preseem_sync_workflow.json @@ -0,0 +1,83 @@ +{ + "name": "Daily Preseem Site Inventory Sync", + "description": "SCHEDULED (daily 3am): Iterates all network sites, extracts MACs from assigned equipment fields, syncs each item to Preseem access points with its site name as tower.", + "trigger": { + "type": "SCHEDULE", + "cron": "0 3 * * *", + "nextSteps": [{ "slug": "fetch-sites" }] + }, + "steps": [ + { + "name": "Fetch all network sites with assigned equipment", + "slug": "fetch-sites", + "type": "GAIIA_API_GATEWAY_V1_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-public-api-gateway-graphql-request", + "allowSkipExecution": false, + "outputMapper": "({ output }) => {\n const sites = output.response.networkSites.nodes;\n const items = [];\n for (const site of sites) {\n for (const assign of (site.assignedInventoryItems?.nodes || [])) {\n const inv = assign.inventoryItem || {};\n const model = inv.model?.name || '';\n // Extract MAC from any field whose name contains 'mac' (case-insensitive)\n const macs = [];\n for (const f of (inv.fields?.nodes || [])) {\n const fieldName = (f.modelField?.name || '').toLowerCase();\n const data = (f.data || '').trim();\n if (fieldName.includes('mac') && data && data !== '00:00:00:00:00:00') {\n // Normalize MAC: strip '1:' prefix, pad segments, uppercase\n const cleaned = data.startsWith('1:') ? data.slice(2) : data;\n const parts = cleaned.split(':');\n if (parts.length === 6) {\n const normalized = parts\n .map((p) => p.padStart(2, '0').toUpperCase())\n .join(':');\n macs.push(normalized);\n }\n }\n }\n if (macs.length > 0) {\n items.push({\n inventoryId: inv.id,\n model,\n siteName: site.name,\n macs: [...new Set(macs)]\n });\n }\n }\n }\n return items;\n}", + "inputs": [ + { + "key": "query", + "type": "LITERAL", + "value": "string:query {\n networkSites(first: 250) {\n nodes {\n id\n name\n assignedInventoryItems {\n nodes {\n inventoryItem {\n id\n model { name }\n ipAddressV4\n fields {\n nodes { data modelField { name } }\n }\n }\n }\n }\n }\n }\n}" + }, + { + "key": "variables", + "type": "FUNCTION", + "value": "({ secrets, state, utils, variables }) => ({})" + } + ], + "nextSteps": [{ "slug": "if-any-items" }] + }, + { + "name": "Any items to sync?", + "slug": "if-any-items", + "type": "CONDITION", + "nodeSlug": "condition", + "condition": "({ secrets, state, utils, variables }) => {\n const items = state.steps['fetch-sites'].output;\n return Array.isArray(items) && items.length > 0;\n}", + "elseSteps": [{ "slug": "end" }], + "thenSteps": [{ "slug": "sync-to-preseem" }] + }, + { + "name": "Sync each item to Preseem", + "slug": "sync-to-preseem", + "type": "PARALLEL_LOOP", + "nodeSlug": "parallel-loop", + "maxConcurrentIterations": 10, + "inputs": [ + { + "key": "iterable", + "type": "FUNCTION", + "value": "({ state }) => state.steps['fetch-sites'].output" + } + ], + "initialSteps": [{ "slug": "preseem-put-ap" }], + "nextSteps": [{ "slug": "end" }] + }, + { + "name": "PUT to Preseem access points", + "slug": "preseem-put-ap", + "type": "PRESEEM_REST_REQUEST", + "nodeSlug": "preseem-rest-request", + "parentParallelLoopSlug": "sync-to-preseem", + "allowSkipExecution": false, + "inputs": [ + { + "key": "method", + "type": "LITERAL", + "value": "string:PUT" + }, + { + "key": "path", + "type": "FUNCTION", + "value": "({ state }) => {\n const item = state.currentParallelLoopIterations['sync-to-preseem'];\n return `/access_points/${item.inventoryId}`;\n}" + }, + { + "key": "body", + "type": "FUNCTION", + "value": "({ state }) => {\n const item = state.currentParallelLoopIterations['sync-to-preseem'];\n return {\n id: item.inventoryId,\n name: `${item.model} (${item.macs[0]})`,\n tower: item.siteName\n };\n}" + } + ], + "nextSteps": [{ "slug": "sync-to-preseem-end" }] + } + ] +} diff --git a/gaiia/dhcp_opt82_workflow.json b/gaiia/dhcp_opt82_workflow.json new file mode 100644 index 0000000..634874a --- /dev/null +++ b/gaiia/dhcp_opt82_workflow.json @@ -0,0 +1,227 @@ +{ + "name": "DHCP Option 82 Auth", + "description": "WEBHOOK: Always upserts IP. If Option 82 present AND MAC linked to an account, syncs all inventoried MACs to Preseem with plan details.", + "trigger": { + "type": "WEBHOOK", + "inputJsonSchema": { + "type": "object", + "required": ["bound", "mac", "ip", "server", "remoteId"], + "properties": { + "bound": { "type": "string" }, + "mac": { "type": "string" }, + "ip": { "type": "string" }, + "server": { "type": "string" }, + "remoteId": { "type": "string" } + } + }, + "nextSteps": [{ "slug": "check-inventory" }] + }, + "steps": [ + { + "name": "Find inventory item by MAC", + "slug": "check-inventory", + "type": "GAIIA_API_GATEWAY_V0_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-internal-api-gateway-graphql-request", + "allowSkipExecution": false, + "outputMapper": "({ output, state }) => {\n const items = output.response.findInventoryItemsFromDeviceIdentifiers;\n // Accept items assigned to Accounts OR NetworkSites\n const assigned = items.filter(\n (item) => {\n const type = item.inventoryItem?.assignation?.assignee?.__typename;\n return type === 'Account' || type === 'NetworkSite';\n }\n );\n if (assigned.length === 0) return null;\n const inv = assigned[0].inventoryItem;\n const assignee = inv.assignation.assignee;\n return {\n assigneeId: assignee.id,\n assigneeType: assignee.__typename,\n assigneeName: assignee.name || ''\n };\n}", + "inputs": [ + { + "key": "query", + "type": "LITERAL", + "value": "string:query FindItem($input: FindInventoryItemsFromDeviceIdentifiersQueryInput!) {\n findInventoryItemsFromDeviceIdentifiers(input: $input) {\n identifier\n inventoryItem {\n id\n model { name }\n assignation {\n assignee {\n __typename\n ... on Account { id name }\n }\n }\n }\n }\n}" + }, + { + "key": "variables", + "type": "FUNCTION", + "value": "({ state }) => {\n const rawMac = state.input.mac;\n const cleanedMac = rawMac.startsWith('1:') ? rawMac.slice(2) : rawMac;\n const formattedMac = cleanedMac\n .split(':')\n .map((segment) => segment.padStart(2, '0'))\n .join(':')\n .toUpperCase();\n return { input: { identifiers: [formattedMac] } };\n}" + } + ], + "nextSteps": [{ "slug": "is-connect" }] + }, + { + "name": "Connect or disconnect?", + "slug": "is-connect", + "type": "CONDITION", + "nodeSlug": "condition", + "condition": "({ secrets, state, utils, variables }) => {\n return state.input.bound === '1';\n}", + "elseSteps": [{ "slug": "expire-ip" }], + "thenSteps": [{ "slug": "has-account-connect" }] + }, + { + "name": "Account found? (connect)", + "slug": "has-account-connect", + "type": "CONDITION", + "nodeSlug": "condition", + "condition": "({ secrets, state, utils, variables }) => {\n return state.steps['check-inventory'].output != null;\n}", + "elseSteps": [{ "slug": "upsert-ip-no-account" }], + "thenSteps": [{ "slug": "upsert-ip-with-account" }] + }, + { + "name": "Upsert IP (no account)", + "slug": "upsert-ip-no-account", + "type": "GAIIA_API_GATEWAY_V0_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-internal-api-gateway-graphql-request", + "allowSkipExecution": false, + "inputs": [ + { + "key": "query", + "type": "LITERAL", + "value": "string:mutation UpsertIp($input: UpsertMacAddressIpAddressAssignationsInput!) {\n upsertMacAddressIpAddressAssignations(input: $input) {\n assignations { macAddress ipAddress }\n }\n}" + }, + { + "key": "variables", + "type": "FUNCTION", + "value": "({ state }) => {\n const rawMac = state.input.mac;\n const cleanedMac = rawMac.startsWith('1:') ? rawMac.slice(2) : rawMac;\n const formattedMac = cleanedMac\n .split(':')\n .map((segment) => segment.padStart(2, '0'))\n .join(':')\n .toUpperCase();\n return {\n input: {\n assignations: { macAddress: formattedMac, ipAddress: state.input.ip, expired: false }\n }\n };\n}" + } + ], + "nextSteps": [{ "slug": "end" }] + }, + { + "name": "Upsert IP (with account)", + "slug": "upsert-ip-with-account", + "type": "GAIIA_API_GATEWAY_V0_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-internal-api-gateway-graphql-request", + "allowSkipExecution": false, + "inputs": [ + { + "key": "query", + "type": "LITERAL", + "value": "string:mutation UpsertIp($input: UpsertMacAddressIpAddressAssignationsInput!) {\n upsertMacAddressIpAddressAssignations(input: $input) {\n assignations { macAddress ipAddress }\n }\n}" + }, + { + "key": "variables", + "type": "FUNCTION", + "value": "({ state }) => {\n const rawMac = state.input.mac;\n const cleanedMac = rawMac.startsWith('1:') ? rawMac.slice(2) : rawMac;\n const formattedMac = cleanedMac\n .split(':')\n .map((segment) => segment.padStart(2, '0'))\n .join(':')\n .toUpperCase();\n return {\n input: {\n assignations: { macAddress: formattedMac, ipAddress: state.input.ip, expired: false }\n }\n };\n}" + } + ], + "nextSteps": [{ "slug": "should-sync-preseem" }] + }, + { + "name": "Has Option 82 and account?", + "slug": "should-sync-preseem", + "type": "CONDITION", + "nodeSlug": "condition", + "condition": "({ secrets, state, utils, variables }) => {\n const inv = state.steps['check-inventory'].output;\n if (inv == null) return false;\n if (!state.input.remoteId || state.input.remoteId === '') return false;\n return inv.assigneeType === 'Account';\n}", + "elseSteps": [{ "slug": "should-sync-site" }], + "thenSteps": [{ "slug": "get-account-macs-and-plan" }] + }, + { + "name": "Get all inventoried MACs and plan", + "slug": "get-account-macs-and-plan", + "type": "GAIIA_API_GATEWAY_V1_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-public-api-gateway-graphql-request", + "allowSkipExecution": false, + "outputMapper": "({ output, state }) => {\n const account = output.response.account;\n if (!account) {\n workflow.fail('Account not found', 'ACCOUNT_NOT_FOUND');\n }\n const macs = [];\n for (const assign of (account.assignedInventoryItems?.nodes || [])) {\n const inv = assign.inventoryItem || {};\n for (const f of (inv.fields?.nodes || [])) {\n const fieldName = (f.modelField?.name || '').toLowerCase();\n const data = (f.data || '').trim();\n if (fieldName.includes('mac') && data && data !== '00:00:00:00:00:00') {\n const cleaned = data.startsWith('1:') ? data.slice(2) : data;\n const parts = cleaned.split(':');\n if (parts.length === 6) {\n macs.push(parts.map((p) => p.padStart(2, '0').toUpperCase()).join(':'));\n }\n }\n }\n for (const a of (inv.ipAddressAssignments?.nodes || [])) {\n if (a.macAddress) macs.push(a.macAddress.toUpperCase());\n }\n }\n const plan = (account.billingSubscriptions?.nodes || [])\n .map((s) => s.productVersion?.product?.slug)\n .find(Boolean) || '';\n return { macs: [...new Set(macs)], plan, gaiiaId: account.readableId };\n}", + "inputs": [ + { + "key": "query", + "type": "FUNCTION", + "value": "({ state }) => {\n const accountId = state.steps['check-inventory'].output.accountId;\n return `query { account(id: \"${accountId}\") { readableId assignedInventoryItems { nodes { inventoryItem { fields { nodes { data modelField { name } } } ipAddressAssignments { nodes { macAddress } } } } } billingSubscriptions { nodes { productVersion { product { slug } } } } } }`;\n}" + }, + { + "key": "variables", + "type": "FUNCTION", + "value": "({ state }) => ({})" + } + ], + "nextSteps": [{ "slug": "preseem-update" }] + }, + { + "name": "Update Preseem", + "slug": "preseem-update", + "type": "PRESEEM_REST_REQUEST", + "nodeSlug": "preseem-rest-request", + "allowSkipExecution": false, + "inputs": [ + { + "key": "method", + "type": "LITERAL", + "value": "string:PUT" + }, + { + "key": "path", + "type": "FUNCTION", + "value": "({ state }) => {\n const { gaiiaId } = state.steps['get-account-macs-and-plan'].output;\n return `/services/${gaiiaId}`;\n}" + }, + { + "key": "body", + "type": "FUNCTION", + "value": "({ state }) => {\n const { gaiiaId, macs, plan } = state.steps['get-account-macs-and-plan'].output;\n const rawMac = state.input.mac;\n const cleanedMac = rawMac.startsWith('1:') ? rawMac.slice(2) : rawMac;\n const connectingMac = cleanedMac\n .split(':')\n .map((segment) => segment.padStart(2, '0'))\n .join(':')\n .toLowerCase();\n const framedIp = state.input.ip;\n const inventoriedMacs = macs.map((m) => m.toLowerCase());\n const attachments = inventoriedMacs\n .filter((m) => state.input.bound === '0' || m !== connectingMac)\n .map((m) => ({ cpe_mac: m }));\n if (state.input.bound === '1') {\n attachments.push({ cpe_mac: connectingMac, network_prefixes: [framedIp] });\n }\n return {\n id: String(gaiiaId),\n attachments,\n account: String(gaiiaId),\n package: plan\n };\n}" + } + ], + "nextSteps": [{ "slug": "end" }] + }, + { + "name": "Expire IP", + "slug": "expire-ip", + "type": "GAIIA_API_GATEWAY_V0_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-internal-api-gateway-graphql-request", + "allowSkipExecution": false, + "inputs": [ + { + "key": "query", + "type": "LITERAL", + "value": "string:mutation ExpireIp($input: UpsertMacAddressIpAddressAssignationsInput!) {\n upsertMacAddressIpAddressAssignations(input: $input) {\n assignations { macAddress ipAddress }\n }\n}" + }, + { + "key": "variables", + "type": "FUNCTION", + "value": "({ state }) => {\n const rawMac = state.input.mac;\n const cleanedMac = rawMac.startsWith('1:') ? rawMac.slice(2) : rawMac;\n const formattedMac = cleanedMac\n .split(':')\n .map((segment) => segment.padStart(2, '0'))\n .join(':')\n .toUpperCase();\n return {\n input: {\n assignations: { macAddress: formattedMac, ipAddress: state.input.ip, expired: true }\n }\n };\n}" + } + ], + "nextSteps": [{ "slug": "should-sync-preseem-disconnect" }] + }, + { + "name": "Network site with Option 82?", + "slug": "should-sync-site", + "type": "CONDITION", + "nodeSlug": "condition", + "condition": "({ secrets, state, utils, variables }) => {\n const inv = state.steps['check-inventory'].output;\n if (inv == null) return false;\n if (!state.input.remoteId || state.input.remoteId === '') return false;\n return inv.assigneeType === 'NetworkSite';\n}", + "elseSteps": [{ "slug": "end" }], + "thenSteps": [{ "slug": "preseem-site-update" }] + }, + { + "name": "Update Preseem (site)", + "slug": "preseem-site-update", + "type": "PRESEEM_REST_REQUEST", + "nodeSlug": "preseem-rest-request", + "allowSkipExecution": false, + "inputs": [ + { + "key": "method", + "type": "LITERAL", + "value": "string:PUT" + }, + { + "key": "path", + "type": "FUNCTION", + "value": "({ state }) => {\n const inv = state.steps['check-inventory'].output;\n return `/access_points/${inv.assigneeId}`;\n}" + }, + { + "key": "body", + "type": "FUNCTION", + "value": "({ state }) => {\n const inv = state.steps['check-inventory'].output;\n const rawMac = state.input.mac;\n const cleanedMac = rawMac.startsWith('1:') ? rawMac.slice(2) : rawMac;\n const mac = cleanedMac\n .split(':')\n .map((segment) => segment.padStart(2, '0'))\n .join(':')\n .toLowerCase();\n return {\n id: inv.assigneeId,\n name: inv.assigneeName || inv.assigneeId,\n tower: inv.assigneeName || '',\n ip_address: state.input.ip\n };\n}" + } + ], + "nextSteps": [{ "slug": "end" }] + }, + { + "name": "Has Option 82 and account? (disconnect)", + "slug": "should-sync-preseem-disconnect", + "type": "CONDITION", + "nodeSlug": "condition", + "condition": "({ secrets, state, utils, variables }) => {\n const inv = state.steps['check-inventory'].output;\n if (inv == null) return false;\n if (!state.input.remoteId || state.input.remoteId === '') return false;\n return inv.assigneeType === 'Account';\n}", + "elseSteps": [{ "slug": "should-sync-site-disconnect" }], + "thenSteps": [{ "slug": "get-account-macs-and-plan" }] + }, + { + "name": "Network site? (disconnect)", + "slug": "should-sync-site-disconnect", + "type": "CONDITION", + "nodeSlug": "condition", + "condition": "({ secrets, state, utils, variables }) => {\n const inv = state.steps['check-inventory'].output;\n if (inv == null) return false;\n if (!state.input.remoteId || state.input.remoteId === '') return false;\n return inv.assigneeType === 'NetworkSite';\n}", + "elseSteps": [{ "slug": "end" }], + "thenSteps": [{ "slug": "preseem-site-update" }] + } + ] +} diff --git a/gaiia/examples/basic.py b/gaiia/examples/basic.py new file mode 100644 index 0000000..9fabc82 --- /dev/null +++ b/gaiia/examples/basic.py @@ -0,0 +1,39 @@ +"""Minimal usage example. + +Set GAIIA_KEY in your env, then ``python examples/basic.py``. +""" + +from __future__ import annotations + +import os + +from gaiia import GaiiaClient + + +def main() -> None: + with GaiiaClient(timezone="America/Chicago") as g: + techs = g.query("{ technicians(first: 5) { nodes { id readableId firstName lastName } } }") + for t in techs["technicians"]["nodes"]: + print(f"{t['readableId']:>4} {t['firstName']} {t['lastName']} {t['id']}") + + print("rate-limit:", g.last_rate_limit) + + print("\npaginating all technicians:") + for t in g.iter_nodes( + """ + query Q($after: String) { + technicians(first: 50, after: $after) { + nodes { id readableId firstName lastName } + pageInfo { hasNextPage endCursor } + } + } + """, + "technicians", + ): + print(f" {t['readableId']:>4} {t['firstName']} {t['lastName']}") + + +if __name__ == "__main__": + if not os.environ.get("GAIIA_KEY"): + raise SystemExit("Set GAIIA_KEY in your env first.") + main() diff --git a/gaiia/examples/webhook_flask.py b/gaiia/examples/webhook_flask.py new file mode 100644 index 0000000..c66d12d --- /dev/null +++ b/gaiia/examples/webhook_flask.py @@ -0,0 +1,34 @@ +"""Verify gaiia webhooks in a Flask endpoint. + + pip install flask gaiia + GAIIA_WEBHOOK_SECRET=whsec_... flask --app examples/webhook_flask.py run +""" + +from __future__ import annotations + +import json +import os + +from flask import Flask, abort, request + +from gaiia import WebhookSignatureError, verify_webhook_signature + +app = Flask(__name__) +SECRET = os.environ["GAIIA_WEBHOOK_SECRET"] + + +@app.post("/gaiia/webhook") +def webhook() -> tuple[str, int]: + raw = request.get_data() + try: + verify_webhook_signature( + header_value=request.headers.get("X-Gaiia-Webhook-Signature", ""), + raw_body=raw, + secret=SECRET, + ) + except WebhookSignatureError as e: + abort(400, description=str(e)) + + event = json.loads(raw) + app.logger.info("gaiia event: %s", event.get("type")) + return "", 204 diff --git a/gaiia/expire_one_workflow.json b/gaiia/expire_one_workflow.json new file mode 100644 index 0000000..21e9529 --- /dev/null +++ b/gaiia/expire_one_workflow.json @@ -0,0 +1,37 @@ +{ + "name": "Expire Single IP Assignment", + "trigger": { + "type": "MANUAL", + "inputJsonSchema": { + "type": "object", + "required": ["macAddress", "ipAddress"], + "properties": { + "macAddress": { "type": "string" }, + "ipAddress": { "type": "string" } + } + }, + "nextSteps": [{ "slug": "expire" }] + }, + "steps": [ + { + "name": "Expire assignment", + "slug": "expire", + "type": "GAIIA_API_GATEWAY_V0_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-internal-api-gateway-graphql-request", + "allowSkipExecution": false, + "inputs": [ + { + "key": "query", + "type": "LITERAL", + "value": "string:mutation Expire($input: UpsertMacAddressIpAddressAssignationsInput!) {\n upsertMacAddressIpAddressAssignations(input: $input) {\n assignations { macAddress ipAddress }\n }\n}" + }, + { + "key": "variables", + "type": "FUNCTION", + "value": "({ state }) => ({\n input: {\n assignations: {\n macAddress: state.input.macAddress,\n ipAddress: state.input.ipAddress,\n expired: true\n }\n }\n})" + } + ], + "nextSteps": [{ "slug": "end" }] + } + ] +} diff --git a/gaiia/fuzzy_match.py b/gaiia/fuzzy_match.py new file mode 100644 index 0000000..5215669 --- /dev/null +++ b/gaiia/fuzzy_match.py @@ -0,0 +1,307 @@ +#!/usr/bin/env python3 +""" +Fuzzy match unmatched RADIUS users against Gaiia accounts. +Smarter matching: requires first-name correlation when matching on last name, +handles substring traps ("kim" in "kimberly"), etc. +""" + +import csv +import json +import re +import subprocess +import sys +from difflib import SequenceMatcher + +GAIIA_KEY = "gaiia_sk_live_955b27e3680747a69ad40b403a912155_b0d1be3c" +GAIIA_ENDPOINT = "https://api.gaiia.com/api/v1" + + +def fetch_all_accounts(): + accounts = [] + cursor = None + while True: + after_arg = f', after: "{cursor}"' if cursor else "" + query = f""" + query {{ + accounts(first: 250{after_arg}) {{ + nodes {{ id readableId name displayName }} + pageInfo {{ hasNextPage endCursor }} + }} + }} + """ + result = subprocess.run( + ["curl", "-s", "-X", "POST", GAIIA_ENDPOINT, + "-H", "Content-Type: application/json", + "-H", f"X-Gaiia-Api-Key: {GAIIA_KEY}", + "-H", "x-timezone: America/Chicago", + "-d", json.dumps({"query": query})], + capture_output=True, text=True, timeout=30, + ) + data = json.loads(result.stdout) + for n in data["data"]["accounts"]["nodes"]: + name = n.get("displayName") or n.get("name") or "" + if name: + accounts.append((n["id"], name.strip(), n["readableId"])) + page_info = data["data"]["accounts"]["pageInfo"] + if not page_info["hasNextPage"]: + break + cursor = page_info["endCursor"] + return accounts + + +def normalize(s): + return re.sub(r'[^\w\s]', '', s.lower()).strip() + + +def split_name(display_name): + """Split into first name(s) and last name.""" + parts = normalize(display_name).split() + if len(parts) == 1: + return parts[0], "" + # Filter out suffixes, LLC, numbers, etc. + content_parts = [p for p in parts if not re.match(r'^(#?\d+|llc|inc|ltd|shop|jr|sr)$', p)] + if len(content_parts) >= 2: + return " ".join(content_parts[:-1]), content_parts[-1] + return content_parts[0], "" + + +def first_name_match_score(username, last_name, first_name_str): + """How well does the first name correlate with the username? Returns 0-1. + + The first initial must appear as the FIRST character of the username + portion before the last name (natural "firstname+lastname" order). + This prevents 'a' from "Amy" spuriously matching inside "ryan". + """ + if not first_name_str: + return 0.5 # neutral + + # Find where last name appears in username + last_idx = username.find(last_name) if last_name else -1 + username_before_last = username[:last_idx] if last_idx > 0 else username + + first_names = first_name_str.split() + best = 0.0 + + for fn in first_names: + if len(fn) < 2: + continue + fn_init = fn[0] + if fn in username: + return 1.0 # full first name in username + # First initial must be the FIRST character before the last name + if username_before_last and username_before_last[0] == fn_init: + best = max(best, 0.7) + # Fuzzy match + ratio = SequenceMatcher(None, fn, username).ratio() + best = max(best, ratio * 0.8) + + return best + + +def match_unmatched(unmatched_users, accounts): + """Fuzzy match with smarter scoring.""" + # Manual overrides from user knowledge + MANUAL_MATCHES = { + # (username, readable_id) -> forces match to specific Gaiia account + "barbihardin": "James Hardin", + "daycor": ("Cathy Day", 755), + "tech": ("Graham McIntire", 1), + # Add more as discovered + } + + # Build lookup by name + accounts_by_name = {normalize(name): (aid, name, rid) for aid, name, rid in accounts} + + results = [] + + for user in unmatched_users: + username = user["username"].lower() + username_no_digits = re.sub(r'\d+$', '', username) + + # Check manual overrides first + if username in MANUAL_MATCHES: + override = MANUAL_MATCHES[username] + # tuple form: (display_name, readable_id) + if isinstance(override, tuple): + target_name, target_rid = override + for aid, name, rid in accounts: + if normalize(name) == normalize(target_name) and rid == target_rid: + results.append({ + **user, + "gaiia_id": aid, "gaiia_name": name, + "gaiia_readable_id": rid, + "match_type": "fuzzy(manual_override)", + }) + break + else: + # Fallback: match by name only + norm_target = normalize(target_name) + if norm_target in accounts_by_name: + aid, name, rid = accounts_by_name[norm_target] + results.append({ + **user, + "gaiia_id": aid, "gaiia_name": name, + "gaiia_readable_id": rid, + "match_type": "fuzzy(manual_override)", + }) + continue + continue + + # String form: match by name + target_name = override + norm_target = normalize(target_name) + if norm_target in accounts_by_name: + aid, name, rid = accounts_by_name[norm_target] + results.append({ + **user, + "gaiia_id": aid, + "gaiia_name": name, + "gaiia_readable_id": rid, + "match_type": "fuzzy(manual_override)", + }) + continue + + best_match = None + best_score = 0.0 + best_reason = "" + + for acct_id, acct_name, readable_id in accounts: + first_names, last_name = split_name(acct_name) + aname = normalize(acct_name) + aname_parts = aname.split() + + score = 0.0 + reason = "" + + # ---- Strategy 1: last name match + first name correlation ---- + if last_name and len(last_name) >= 3: + # Check that last_name appears in username as a distinct substring + # (not "kim" inside "kimberly") + idx = username.find(last_name) + if idx >= 0: + # Check it's a reasonable boundary match + # Allow: at start, at end, or surrounded by non-alpha + before_ok = idx == 0 or not username[idx-1].isalpha() + after_ok = (idx + len(last_name) == len(username)) or not username[idx+len(last_name)].isalpha() + if not before_ok and not after_ok: + # "kim" inside "kimberly" - reject this last name match + pass + else: + # Check first name correlation + fn_score = first_name_match_score(username, last_name, first_names) + if fn_score >= 0.5: + score = 0.7 + (fn_score * 0.2) + reason = f"last+first: {last_name}+{first_names}(fn={fn_score:.2f})" + elif fn_score > 0: + score = 0.6 + fn_score * 0.1 + reason = f"last+weakfirst: {last_name}+{first_names}(fn={fn_score:.2f})" + else: + # Last name match but no first name correlation - low confidence + score = 0.55 + reason = f"lastonly_nofirst: {last_name}" + + # ---- Strategy 2: Sequence matcher on nodigits variant ---- + if score < 0.6 and username_no_digits != username: + seq_score = SequenceMatcher(None, username_no_digits, aname).ratio() + if seq_score > 0.75: + score = max(score, seq_score * 0.9) + reason = f"seq_nodigits: {seq_score:.2f}" + + # ---- Strategy 3: Direct sequence match ---- + if score < 0.6: + seq_score = SequenceMatcher(None, username, aname).ratio() + if seq_score > 0.75: + score = max(score, seq_score * 0.9) + reason = f"sequence: {seq_score:.2f}" + + # ---- Strategy 4: All account name parts in username ---- + if score < 0.6: + all_found = True + for part in aname_parts: + if len(part) >= 3 and part not in username: + all_found = False + break + if all_found and len(aname_parts) >= 2: + score = 0.85 + reason = f"allparts: {aname}" + + if score > best_score: + best_score = score + best_match = (acct_id, acct_name, readable_id, score) + best_reason = reason + + # Threshold: 0.7+ + if best_match and best_score >= 0.7: + results.append({ + **user, + "gaiia_id": best_match[0], + "gaiia_name": best_match[1], + "gaiia_readable_id": best_match[2], + "match_type": f"fuzzy({best_reason})", + }) + else: + results.append({ + **user, + "gaiia_id": user.get("gaiia_id", ""), + "gaiia_name": user.get("gaiia_name", ""), + "gaiia_readable_id": user.get("gaiia_readable_id", ""), + "match_type": f"still_unmatched(best: {best_reason}, score: {round(best_score,3)})", + }) + + return results + + +def resolve_ambiguous(ambiguous_rows, accounts): + """For ambiguous matches (duplicate Gaiia names), pick the lowest readable_id.""" + results = [] + for row in ambiguous_rows: + ambig_names = row["gaiia_name"].replace("AMBIGUOUS: ", "").split(", ") + candidates = [(a_id, name, r_id) for a_id, name, r_id in accounts if name in ambig_names] + if candidates: + candidates.sort(key=lambda x: x[2]) + row["gaiia_id"] = candidates[0][0] + row["gaiia_name"] = candidates[0][1] + row["gaiia_readable_id"] = candidates[0][2] + row["match_type"] = "ambiguous_resolved(lowest_id)" + else: + row["match_type"] = "ambiguous_unresolved" + results.append(row) + return results + + +def main(): + reader = csv.DictReader(sys.stdin) + all_rows = list(reader) + + unmatched = [r for r in all_rows if r["match_type"] == "no_match"] + ambiguous = [r for r in all_rows if r["match_type"] == "ambiguous"] + + print(f"Unmatched: {len(unmatched)}, Ambiguous: {len(ambiguous)}", file=sys.stderr) + + print("Fetching Gaiia accounts...", file=sys.stderr) + accounts = fetch_all_accounts() + print(f"Fetched {len(accounts)} accounts", file=sys.stderr) + + matched_results = match_unmatched(unmatched, accounts) + ambig_results = resolve_ambiguous(ambiguous, accounts) + + all_results = matched_results + ambig_results + + fieldnames = ["radius_id", "username", "password", "gaiia_id", "gaiia_name", "gaiia_readable_id", "match_type"] + writer = csv.DictWriter(sys.stdout, fieldnames=fieldnames) + writer.writeheader() + for r in all_results: + writer.writerow({k: r.get(k, "") for k in fieldnames}) + + newly_matched = sum(1 for r in matched_results if r["gaiia_id"]) + still = sum(1 for r in matched_results if "still_unmatched" in r.get("match_type", "")) + print(f"\nNewly matched: {newly_matched}, Still unmatched: {still}", file=sys.stderr) + + print("\nStill unmatched:", file=sys.stderr) + for r in matched_results: + if "still_unmatched" in r.get("match_type", ""): + print(f" {r['username']:30s} ({r['match_type']})", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/gaiia/ip_assignments_page1.json b/gaiia/ip_assignments_page1.json new file mode 100644 index 0000000..d08d625 --- /dev/null +++ b/gaiia/ip_assignments_page1.json @@ -0,0 +1,438 @@ +[ + { + "macAddress": "DC:9F:DB:92:0D:AA", + "ipAddress": "10.10.16.74" + }, + { + "macAddress": "00:27:22:14:A7:F0", + "ipAddress": "10.10.96.80" + }, + { + "macAddress": "DC:9F:DB:1E:D0:24", + "ipAddress": "10.10.96.3" + }, + { + "macAddress": "04:18:D6:44:4A:46", + "ipAddress": "10.10.96.56" + }, + { + "macAddress": "00:27:22:14:2D:BB", + "ipAddress": "10.10.96.76" + }, + { + "macAddress": "00:04:56:61:07:7D", + "ipAddress": "10.10.96.6" + }, + { + "macAddress": "00:01:9F:3B:D5:99", + "ipAddress": "100.64.25.213" + }, + { + "macAddress": "00:01:9F:5C:A0:49", + "ipAddress": "100.64.174.228" + }, + { + "macAddress": "E0:63:DA:90:74:76", + "ipAddress": "10.10.96.96" + }, + { + "macAddress": "E0:63:DA:96:C1:3F", + "ipAddress": "10.10.0.33" + }, + { + "macAddress": "74:AC:B9:7A:17:9A", + "ipAddress": "10.10.96.1" + }, + { + "macAddress": "00:04:56:2F:D8:4D", + "ipAddress": "10.10.128.10" + }, + { + "macAddress": "74:AC:B9:7A:14:01", + "ipAddress": "10.10.96.59" + }, + { + "macAddress": "00:01:9F:5C:A0:61", + "ipAddress": "100.64.28.254" + }, + { + "macAddress": "00:01:9F:6B:0F:29", + "ipAddress": "100.64.19.35" + }, + { + "macAddress": "FC:EC:DA:CE:68:19", + "ipAddress": "10.10.16.70" + }, + { + "macAddress": "FC:EC:DA:CE:69:97", + "ipAddress": "10.10.16.36" + }, + { + "macAddress": "00:04:56:C2:87:DD", + "ipAddress": "10.10.144.3" + }, + { + "macAddress": "00:04:56:C1:BA:BE", + "ipAddress": "10.10.128.5" + }, + { + "macAddress": "00:04:56:FE:42:87", + "ipAddress": "10.10.128.12" + }, + { + "macAddress": "00:04:56:FE:31:A3", + "ipAddress": "10.10.128.40" + }, + { + "macAddress": "00:01:9F:1E:9B:19", + "ipAddress": "100.64.0.11" + }, + { + "macAddress": "00:04:56:FE:47:0F", + "ipAddress": "10.10.128.41" + }, + { + "macAddress": "18:E8:29:38:D3:45", + "ipAddress": "10.10.16.48" + }, + { + "macAddress": "18:E8:29:38:D4:2C", + "ipAddress": "10.10.16.69" + }, + { + "macAddress": "18:E8:29:38:D3:BC", + "ipAddress": "10.10.0.11" + }, + { + "macAddress": "FC:EC:DA:6C:FD:08", + "ipAddress": "10.10.96.68" + }, + { + "macAddress": "74:83:C2:64:4A:22", + "ipAddress": "10.10.16.72" + }, + { + "macAddress": "74:83:C2:64:4A:B2", + "ipAddress": "10.10.96.78" + }, + { + "macAddress": "E4:38:83:A0:56:B7", + "ipAddress": "10.10.0.18" + }, + { + "macAddress": "E4:38:83:A0:56:0A", + "ipAddress": "10.10.16.67" + }, + { + "macAddress": "E4:38:83:A0:57:88", + "ipAddress": "10.10.0.13" + }, + { + "macAddress": "E4:38:83:A0:59:53", + "ipAddress": "10.10.0.58" + }, + { + "macAddress": "D0:21:F9:AF:23:3E", + "ipAddress": "10.10.62.200" + }, + { + "macAddress": "D0:21:F9:F0:9F:A4", + "ipAddress": "10.10.62.203" + }, + { + "macAddress": "58:C1:7A:B3:09:72", + "ipAddress": "10.10.16.59" + }, + { + "macAddress": "D0:21:F9:F0:9F:F4", + "ipAddress": "10.10.62.215" + }, + { + "macAddress": "E4:38:83:AA:19:E6", + "ipAddress": "10.10.0.15" + }, + { + "macAddress": "70:A7:41:4A:3C:55", + "ipAddress": "10.10.160.12" + }, + { + "macAddress": "D0:21:F9:AF:23:02", + "ipAddress": "10.10.62.202" + }, + { + "macAddress": "58:C1:7A:B3:08:B8", + "ipAddress": "10.10.96.91" + }, + { + "macAddress": "00:04:56:DB:7D:86", + "ipAddress": "100.64.174.215" + }, + { + "macAddress": "E0:63:DA:0F:B7:8B", + "ipAddress": "10.10.62.253" + }, + { + "macAddress": "00:01:9F:33:54:E9", + "ipAddress": "100.64.25.32" + }, + { + "macAddress": "E4:38:83:AA:17:36", + "ipAddress": "10.10.160.1" + }, + { + "macAddress": "00:01:9F:33:54:C1", + "ipAddress": "100.64.0.221" + }, + { + "macAddress": "E4:38:83:AA:17:A6", + "ipAddress": "10.10.160.4" + }, + { + "macAddress": "D0:21:F9:F0:9D:E1", + "ipAddress": "10.10.62.212" + }, + { + "macAddress": "70:A7:41:4A:3C:3E", + "ipAddress": "10.10.0.5" + }, + { + "macAddress": "00:01:9F:34:D4:11", + "ipAddress": "100.64.174.207" + }, + { + "macAddress": "D0:21:F9:F0:9F:32", + "ipAddress": "10.10.62.201" + }, + { + "macAddress": "00:04:56:DB:EC:AA", + "ipAddress": "10.10.64.6" + }, + { + "macAddress": "F4:E2:C6:30:3E:1E", + "ipAddress": "10.10.96.61" + }, + { + "macAddress": "F4:E2:C6:30:23:C5", + "ipAddress": "10.10.0.8" + }, + { + "macAddress": "F4:E2:C6:30:2C:4E", + "ipAddress": "10.10.96.12" + }, + { + "macAddress": "F4:E2:C6:30:28:40", + "ipAddress": "10.10.96.20" + }, + { + "macAddress": "F4:E2:C6:30:35:33", + "ipAddress": "10.10.0.1" + }, + { + "macAddress": "F4:E2:C6:30:3E:29", + "ipAddress": "10.10.96.60" + }, + { + "macAddress": "F4:E2:C6:30:34:B5", + "ipAddress": "10.10.96.25" + }, + { + "macAddress": "F4:E2:C6:30:3D:03", + "ipAddress": "10.10.16.60" + }, + { + "macAddress": "F4:E2:C6:30:3D:E4", + "ipAddress": "10.10.96.83" + }, + { + "macAddress": "F0:A7:31:74:A0:14", + "ipAddress": "100.64.25.85" + }, + { + "macAddress": "F4:E2:C6:30:27:A5", + "ipAddress": "10.10.0.2" + }, + { + "macAddress": "F0:A7:31:74:A0:4A", + "ipAddress": "100.64.25.176" + }, + { + "macAddress": "F4:E2:C6:30:35:1D", + "ipAddress": "10.10.96.69" + }, + { + "macAddress": "F4:E2:C6:30:31:14", + "ipAddress": "10.10.0.24" + }, + { + "macAddress": "F4:E2:C6:30:2A:72", + "ipAddress": "10.10.0.22" + }, + { + "macAddress": "E8:DA:00:17:B1:A9", + "ipAddress": "100.64.174.203" + }, + { + "macAddress": "E8:DA:00:17:B1:E5", + "ipAddress": "100.64.25.74" + }, + { + "macAddress": "F4:E2:C6:30:39:C0", + "ipAddress": "10.10.0.70" + }, + { + "macAddress": "F4:E2:C6:30:2E:06", + "ipAddress": "10.10.96.22" + }, + { + "macAddress": "F4:E2:C6:30:3F:F0", + "ipAddress": "10.10.96.69" + }, + { + "macAddress": "00:04:56:DB:EE:F3", + "ipAddress": "10.10.160.5" + }, + { + "macAddress": "F4:E2:C6:30:29:CF", + "ipAddress": "10.10.96.88" + }, + { + "macAddress": "F4:E2:C6:30:31:02", + "ipAddress": "10.10.0.43" + }, + { + "macAddress": "70:A7:41:16:63:D1", + "ipAddress": "10.10.96.71" + }, + { + "macAddress": "F4:E2:C6:30:31:ED", + "ipAddress": "10.10.0.45" + }, + { + "macAddress": "F4:E2:C6:30:24:65", + "ipAddress": "10.10.96.21" + }, + { + "macAddress": "00:04:56:EF:9A:15", + "ipAddress": "100.64.19.57" + }, + { + "macAddress": "00:04:56:60:95:0D", + "ipAddress": "10.10.144.9" + }, + { + "macAddress": "00:04:56:60:8D:FB", + "ipAddress": "10.10.64.6" + }, + { + "macAddress": "00:04:56:F0:36:65", + "ipAddress": "10.10.128.31" + }, + { + "macAddress": "00:04:56:E8:3D:CF", + "ipAddress": "10.10.144.28" + }, + { + "macAddress": "00:04:56:FA:A6:49", + "ipAddress": "10.10.128.39" + }, + { + "macAddress": "78:8A:20:3C:3B:4F", + "ipAddress": "10.10.96.70" + }, + { + "macAddress": "F4:E2:C6:30:48:0E", + "ipAddress": "10.10.0.39" + }, + { + "macAddress": "F4:E2:C6:30:4A:E6", + "ipAddress": "10.10.96.63" + }, + { + "macAddress": "F4:E2:C6:30:47:E8", + "ipAddress": "10.10.96.32" + }, + { + "macAddress": "F4:E2:C6:30:46:EA", + "ipAddress": "10.10.0.52" + }, + { + "macAddress": "F4:E2:C6:30:4A:82", + "ipAddress": "10.10.16.62" + }, + { + "macAddress": "F4:E2:C6:30:4B:56", + "ipAddress": "10.10.0.51" + }, + { + "macAddress": "D8:44:89:A6:FF:01", + "ipAddress": "100.64.25.97" + }, + { + "macAddress": "F4:E2:C6:30:4B:5E", + "ipAddress": "10.10.16.54" + }, + { + "macAddress": "D8:44:89:A7:08:BB", + "ipAddress": "100.64.25.55" + }, + { + "macAddress": "F4:E2:C6:30:3D:42", + "ipAddress": "10.10.0.46" + }, + { + "macAddress": "D8:44:89:A7:0A:29", + "ipAddress": "100.64.25.183" + }, + { + "macAddress": "74:AC:B9:F2:08:EC", + "ipAddress": "10.10.16.45" + }, + { + "macAddress": "F4:92:BF:D8:52:B0", + "ipAddress": "10.10.0.10" + }, + { + "macAddress": "74:AC:B9:F2:09:37", + "ipAddress": "10.10.16.65" + }, + { + "macAddress": "00:04:56:44:E0:32", + "ipAddress": "10.10.144.37" + }, + { + "macAddress": "00:04:56:44:F5:D7", + "ipAddress": "10.10.96.16" + }, + { + "macAddress": "74:AC:B9:F2:08:5C", + "ipAddress": "10.10.16.63" + }, + { + "macAddress": "00:04:56:44:52:2D", + "ipAddress": "10.10.128.23" + }, + { + "macAddress": "00:04:56:2F:CD:D0", + "ipAddress": "10.10.128.26" + }, + { + "macAddress": "00:04:56:44:52:C6", + "ipAddress": "10.10.96.27" + }, + { + "macAddress": "00:04:56:2F:CD:16", + "ipAddress": "10.10.16.39" + }, + { + "macAddress": "00:04:56:2F:DB:53", + "ipAddress": "10.10.128.7" + }, + { + "macAddress": "F4:92:BF:D8:55:4C", + "ipAddress": "10.10.16.66" + }, + { + "macAddress": "00:04:56:44:F6:2E", + "ipAddress": "10.10.128.29" + } +] \ No newline at end of file diff --git a/gaiia/list_ip_assignments.py b/gaiia/list_ip_assignments.py new file mode 100644 index 0000000..1420193 --- /dev/null +++ b/gaiia/list_ip_assignments.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""List all customer inventory items with active IP assignments. +Uses the Gaiia public API - run with GAIIA_KEY set (source .envrc).""" + +from gaiia import GaiiaClient +import json + +def main(): + with GaiiaClient(timezone='America/Chicago') as g: + # Paginate through all inventory items assigned to accounts + all_assignments = [] + after = None + page = 0 + + while True: + page += 1 + query = """ + query GetAssignments($after: String) { + inventoryItems( + filters: {assigneeType: {equals: ACCOUNT}} + first: 250 + after: $after + ) { + nodes { + ipAddressAssignments { + nodes { ipAddress macAddress isActive } + } + } + pageInfo { hasNextPage endCursor } + totalCount + } + } + """ + data = g.query(query, {"after": after} if after else {}) + result = data['inventoryItems'] + + if page == 1: + print(f"Total inventory items assigned to accounts: {result['totalCount']}") + + for item in result['nodes']: + for a in item.get('ipAddressAssignments', {}).get('nodes', []): + if a.get('isActive') != False: # active or None + all_assignments.append({ + 'macAddress': a['macAddress'], + 'ipAddress': a['ipAddress'] + }) + + page_info = result['pageInfo'] + if not page_info.get('hasNextPage'): + break + after = page_info['endCursor'] + + print(f"Active IP assignments found: {len(all_assignments)}") + print() + + # Print first 20 + for a in all_assignments[:20]: + print(f" {a['macAddress']} -> {a['ipAddress']}") + if len(all_assignments) > 20: + print(f" ... and {len(all_assignments) - 20} more") + + # Save to file + with open('/Users/graham/dev/network/gaiia/ip_assignments.json', 'w') as f: + json.dump(all_assignments, f, indent=2) + print(f"\nSaved {len(all_assignments)} assignments to ip_assignments.json") + +if __name__ == '__main__': + main() diff --git a/gaiia/match_radius.py b/gaiia/match_radius.py new file mode 100644 index 0000000..8431c7d --- /dev/null +++ b/gaiia/match_radius.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +""" +Match RADIUS PPPoE usernames to Gaiia accounts by exact name-to-username mapping. +Deterministic only — no fuzzy matching. Leaves ambiguous/unmatched rows blank. + +A Gaiia displayName like "Jose Ortiz" generates candidate usernames: + joseortiz, jortiz, joseo, jose, ortiz +The RADIUS username must match one of these exactly. +""" + +import csv +import json +import re +import subprocess +import sys +from collections import defaultdict + +GAIIA_KEY = "gaiia_sk_live_955b27e3680747a69ad40b403a912155_b0d1be3c" +GAIIA_ENDPOINT = "https://api.gaiia.com/api/v1" + + +def fetch_all_accounts(): + """Fetch all Gaiia accounts with id and displayName.""" + accounts = [] + cursor = None + while True: + after_arg = f', after: "{cursor}"' if cursor else "" + query = f""" + query {{ + accounts(first: 250{after_arg}) {{ + nodes {{ id readableId name displayName }} + pageInfo {{ hasNextPage endCursor }} + }} + }} + """ + result = subprocess.run( + [ + "curl", "-s", + "-X", "POST", GAIIA_ENDPOINT, + "-H", "Content-Type: application/json", + "-H", f"X-Gaiia-Api-Key: {GAIIA_KEY}", + "-H", "x-timezone: America/Chicago", + "-d", json.dumps({"query": query}), + ], + capture_output=True, text=True, timeout=30, + ) + data = json.loads(result.stdout) + nodes = data["data"]["accounts"]["nodes"] + for n in nodes: + name = n.get("displayName") or n.get("name") or "" + if name: + accounts.append((n["id"], name.strip(), n["readableId"])) + page_info = data["data"]["accounts"]["pageInfo"] + if not page_info["hasNextPage"]: + break + cursor = page_info["endCursor"] + return accounts + + +def generate_username_variants(display_name): + """Generate candidate PPPoE-style usernames from a Gaiia display name. + + "Jose Ortiz" -> {joseortiz, jortiz, jose, ortiz} + "Graham McIntire" -> {grahammcintire, gmcintire, graham, mcintire} + "Kory Biggs Shop" -> {korybiggsshop, kbiggs, kory, biggs, ...} + "Mid Valley Pumping LLC" -> {midvalleypumpingllc, midvalley, ...} + """ + name = display_name.lower().strip() + # Remove punctuation + name = re.sub(r'[^\w\s]', '', name) + name = re.sub(r'\s+', ' ', name).strip() + parts = name.split() + + variants = set() + + # Full concatenation: firstlast with no spaces + variants.add("".join(parts)) + + # First initial + last name: e.g. jortiz, gmcintire + if len(parts) >= 2: + variants.add(parts[0][0] + parts[-1]) + # Also first name + last name initial + variants.add(parts[0] + parts[-1][0]) + + # Each individual part + for p in parts: + variants.add(p) + + # First + last (just the two parts concatenated) + if len(parts) >= 2: + variants.add(parts[0] + parts[-1]) + + # Handle numbered suffixes (e.g. "Perla Chavez 2" -> "perlachavez2") + if len(parts) >= 3 and re.match(r'^\d+$', parts[-1]): + variants.add("".join(parts[:-1]) + parts[-1]) + + return variants + + +def parse_radius_input(text): + """Parse the pipe-delimited RADIUS user table.""" + users = [] + pattern = r'\|\s*(\d+)\s*\|\s*(\S+)\s*\|\s*Clear(?:t|T)ext-Password\s*\|\s*:=\s*\|\s*(\S+)\s*\|' + for match in re.finditer(pattern, text): + users.append({ + "radius_id": int(match.group(1)), + "username": match.group(2), + "password": match.group(3), + }) + return users + + +def match_users(radius_users, accounts): + """Match RADIUS users to Gaiia accounts by exact username variant matching.""" + # Build mapping: username_variant -> list of (account_id, account_name, readable_id) + variant_map = defaultdict(list) + for acct_id, acct_name, readable_id in accounts: + for variant in generate_username_variants(acct_name): + variant_map[variant].append((acct_id, acct_name, readable_id)) + + # Track which Gaiia accounts have been used (to avoid double-matching) + used_accounts = set() + + results = [] + for user in radius_users: + username = user["username"].lower() + candidates = variant_map.get(username, []) + + # Filter out already-used accounts + available = [(a, n, r) for a, n, r in candidates if a not in used_accounts] + + if len(available) == 1: + acct_id, acct_name, readable_id = available[0] + used_accounts.add(acct_id) + results.append({ + **user, + "gaiia_id": acct_id, + "gaiia_name": acct_name, + "gaiia_readable_id": readable_id, + "match_type": "exact", + }) + elif len(available) > 1: + # Multiple candidates — leave blank for manual review + results.append({ + **user, + "gaiia_id": "", + "gaiia_name": f"AMBIGUOUS: {', '.join(n for _,n,_ in available)}", + "gaiia_readable_id": "", + "match_type": "ambiguous", + }) + else: + results.append({ + **user, + "gaiia_id": "", + "gaiia_name": "", + "gaiia_readable_id": "", + "match_type": "no_match", + }) + + return results + + +def main(): + text = sys.stdin.read() + + radius_users = parse_radius_input(text) + print(f"Parsed {len(radius_users)} RADIUS users", file=sys.stderr) + + print("Fetching Gaiia accounts...", file=sys.stderr) + accounts = fetch_all_accounts() + print(f"Fetched {len(accounts)} Gaiia accounts with names", file=sys.stderr) + + results = match_users(radius_users, accounts) + + writer = csv.writer(sys.stdout) + writer.writerow(["radius_id", "username", "password", "gaiia_id", "gaiia_name", "gaiia_readable_id", "match_type"]) + for r in results: + writer.writerow([ + r["radius_id"], r["username"], r["password"], + r["gaiia_id"], r["gaiia_name"], r["gaiia_readable_id"], r["match_type"], + ]) + + matched = sum(1 for r in results if r["gaiia_id"]) + ambiguous = sum(1 for r in results if r["match_type"] == "ambiguous") + unmatched = sum(1 for r in results if r["match_type"] == "no_match") + print(f"\nMatched: {matched}, Ambiguous: {ambiguous}, No match: {unmatched} (total: {len(results)})", file=sys.stderr) + + if ambiguous: + print("\nAMBIGUOUS (multiple Gaiia accounts match same username):", file=sys.stderr) + for r in results: + if r["match_type"] == "ambiguous": + print(f" {r['username']}: {r['gaiia_name']}", file=sys.stderr) + + if unmatched: + print("\nNO MATCH:", file=sys.stderr) + for r in results: + if r["match_type"] == "no_match": + print(f" {r['username']}", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/gaiia/output.txt b/gaiia/output.txt new file mode 100644 index 0000000..b867e86 --- /dev/null +++ b/gaiia/output.txt @@ -0,0 +1,101 @@ +Fetching all accounts from Gaiia... + Fetched 50/458 accounts... + Fetched 100/458 accounts... + Fetched 150/458 accounts... + Fetched 200/458 accounts... + Fetched 250/458 accounts... + Fetched 300/458 accounts... + Fetched 350/458 accounts... + Fetched 400/458 accounts... + Fetched 450/458 accounts... + Fetched 458/458 accounts... +Total: 458 accounts + +=========================================================================== +CLIMAX PPPoE → GAIIA ACCOUNT MATCHING +=========================================================================== +PPPoE PPPoE IP Gaiia Name Account ID +------------------------------------------------------------------------------ +charlesboone 100.64.7.247 Charles Boone 62 +amberprater 100.64.7.243 Amber Prater 855 +matthewgoodwin 100.64.7.241 Matthew Goodwin 2453 +robbymccollom 100.64.7.239 Robby McCollom 297 +chasewilliams 100.64.7.235 Chase Williams 2042 +cynthiajones 100.64.7.233 Cynthia Jones 1778 +janicealexander 100.64.7.225 Janice Alexander 165 +michaelray 204.110.188.38 Michael Ray 379 +marysmelser 100.64.7.218 Mary Smelser 2385 +eddieyarbrough 100.64.7.216 Eddie Yarbrough 125 +timgilbert 100.64.7.213 Tim Gilbert 346 +chadwhitsell 100.64.7.211 Chad Whitsell 61 +donnacampbell 100.64.7.209 Donna Campbell 2530 +bryangoulart 100.64.7.205 Brian Goulart 2076 +crystalharney 100.64.7.201 Crystal Harney 2233 +johnvayo 100.64.7.197 John Vayo 2275 +richardbarragan 100.64.7.193 richard barragan 1314 +carolstrickland 100.64.7.189 Carol Strickland 2410 +timbagert 100.64.7.140 Tim Bagert 343 +douggarber 100.64.7.136 Doug Garber 2360 +janetkern 100.64.7.61 Janet Kern 449 +gregmcintire 100.64.7.59 Greg McIntire 380 +jenniferboon 100.64.7.35 Jennifer Boon 2200 +glendabeauchamp 100.64.7.15 Glenda Beauchamp 2133 +jmichaelculverhouse 100.64.7.250 J'michael Culverhouse 1838 +elviraquezada 100.64.7.249 Elvira Quezada 960 +taekim 204.110.188.42 Tae Kim 408 +ruthfengler 100.64.7.246 Ruth Fengler 304 +rhondabolton 100.64.7.231 Rhonda Bolton 295 +gordonhamilton 100.64.7.237 (not in Gaiia) - +tammieventris 100.64.7.232 (not in Gaiia) - +buddyswan 100.64.7.245 (not in Gaiia) - + +Matched: 29 | Not in Gaiia: 3 +Not in Gaiia: + - gordonhamilton (100.64.7.237) + - tammieventris (100.64.7.232) + - buddyswan (100.64.7.245) + +=========================================================================== +IP ADDRESS COMPARISON (PPPoE active IP vs Gaiia inventory item IP) +=========================================================================== +charlesboone 100.64.7.247 MISMATCH (Gaiia has: 10.10.16.73) +amberprater 100.64.7.243 MISMATCH (Gaiia has: 10.10.16.56) +matthewgoodwin 100.64.7.241 MISMATCH (Gaiia has: 10.10.16.34) +robbymccollom 100.64.7.239 MISMATCH (Gaiia has: 10.10.16.69) +chasewilliams 100.64.7.235 MISMATCH (Gaiia has: 10.10.16.66) +cynthiajones 100.64.7.233 MISMATCH (Gaiia has: 10.10.16.72) +janicealexander 100.64.7.225 MISMATCH (Gaiia has: 10.10.16.65) +michaelray 204.110.188.38 (no inventory items in Gaiia) +marysmelser 100.64.7.218 MATCH (item: AC1200MS) +eddieyarbrough 100.64.7.216 NO IP SET (has inventory items but no IPs) +timgilbert 100.64.7.213 MISMATCH (Gaiia has: 10.10.16.71) +chadwhitsell 100.64.7.211 MISMATCH (Gaiia has: 10.10.16.75) +donnacampbell 100.64.7.209 MATCH (item: AC1200-Mesh) +bryangoulart 100.64.7.205 MISMATCH (Gaiia has: 10.10.16.62) +crystalharney 100.64.7.201 MATCH (item: AC1200-Mesh) +johnvayo 100.64.7.197 MATCH (item: AC1200-Mesh) +richardbarragan 100.64.7.193 MATCH (item: AC1200-Mesh) +carolstrickland 100.64.7.189 MISMATCH (Gaiia has: 10.10.16.37) +timbagert 100.64.7.140 MATCH (item: AC1200-Mesh) +douggarber 100.64.7.136 MISMATCH (Gaiia has: 10.10.16.57) +janetkern 100.64.7.61 MISMATCH (Gaiia has: 10.10.16.54) +gregmcintire 100.64.7.59 (no inventory items in Gaiia) +jenniferboon 100.64.7.35 MATCH (item: AC1200-Mesh) +glendabeauchamp 100.64.7.15 MATCH (item: AC1200-Mesh) +jmichaelculverhouse 100.64.7.250 NO IP SET (has inventory items but no IPs) +elviraquezada 100.64.7.249 MISMATCH (Gaiia has: 10.10.16.67) +taekim 204.110.188.42 MISMATCH (Gaiia has: 10.10.16.70) +ruthfengler 100.64.7.246 MISMATCH (Gaiia has: 10.10.16.46) +rhondabolton 100.64.7.231 MISMATCH (Gaiia has: 10.10.16.61) + +--- Summary --- +Accounts in Gaiia: 29/32 +Not in Gaiia: 3 +IP matches PPPoE: 8 +IP mismatch (different IP): 17 +No inventory / no IP in Gaiia: 4 + +Accounts missing from Gaiia (need investigation): + gordonhamilton IP=100.64.7.237 MAC=A0:63:91:3A:29:B1 + tammieventris IP=100.64.7.232 MAC=98:25:4A:04:A5:E1 + buddyswan IP=100.64.7.245 MAC=00:01:9F:34:D2:21 diff --git a/gaiia/pppoe_full_sync_workflow.json b/gaiia/pppoe_full_sync_workflow.json new file mode 100644 index 0000000..e860d0c --- /dev/null +++ b/gaiia/pppoe_full_sync_workflow.json @@ -0,0 +1,198 @@ +{ + "name": "pppoe auth", + "description": "WEBHOOK: PPPoE connect/disconnect → account lookup → Preseem sync. Connect (bound=1): BYOD if needed, assign IP, add MAC to Preseem with all inventoried equipment. Disconnect (bound=0): remove MAC from Preseem.", + "trigger": { + "type": "WEBHOOK", + "inputJsonSchema": { + "type": "object", + "required": ["bound", "callingstationid", "framedip", "username"], + "properties": { + "bound": { "type": "string" }, + "framedip": { "type": "string" }, + "username": { "type": "string" }, + "callingstationid": { "type": "string" } + } + }, + "nextSteps": [{ "slug": "get-account-id" }] + }, + "steps": [ + { + "name": "Get Account ID from RADIUS Username", + "slug": "get-account-id", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "get-account-id-from-radius-username", + "allowSkipExecution": false, + "inputs": [ + { "key": "username", "type": "STATE", "value": "input.username" } + ], + "nextSteps": [{ "slug": "is-connect" }], + "errorSteps": [{ "slug": "end", "errorCodes": ["USERNAME_NOT_FOUND"] }] + }, + { + "name": "Is this a connect?", + "slug": "is-connect", + "type": "CONDITION", + "nodeSlug": "condition", + "condition": "({ secrets, state, utils, variables }) => {\n return state.input.bound === '1';\n}", + "elseSteps": [{ "slug": "expire-ip-assignment" }], + "thenSteps": [{ "slug": "check-inventory" }] + }, + { + "name": "Expire IP assignment on disconnect", + "slug": "expire-ip-assignment", + "type": "GAIIA_API_GATEWAY_V0_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-internal-api-gateway-graphql-request", + "allowSkipExecution": false, + "inputs": [ + { + "key": "query", + "type": "LITERAL", + "value": "string:mutation ExpireIp($input: UpsertMacAddressIpAddressAssignationsInput!) {\n upsertMacAddressIpAddressAssignations(input: $input) {\n assignations { macAddress ipAddress }\n }\n}" + }, + { + "key": "variables", + "type": "FUNCTION", + "value": "({ state }) => {\n const rawMac = state.input.callingstationid;\n const cleanedMac = rawMac.startsWith('1:') ? rawMac.slice(2) : rawMac;\n const formattedMac = cleanedMac\n .split(':')\n .map((segment) => segment.padStart(2, '0'))\n .join(':')\n .toUpperCase();\n return {\n input: {\n assignations: {\n macAddress: formattedMac,\n ipAddress: state.input.framedip,\n expired: true\n }\n }\n };\n}" + } + ], + "nextSteps": [{ "slug": "get-account-macs-and-plan" }] + }, + { + "name": "Check if MAC is already inventoried", + "slug": "check-inventory", + "type": "GAIIA_API_GATEWAY_V0_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-internal-api-gateway-graphql-request", + "allowSkipExecution": false, + "outputMapper": "({ output, state }) => {\n const items = output.response.findInventoryItemsFromDeviceIdentifiers;\n const assigned = items.filter(\n (item) => item.inventoryItem?.assignation?.assignee?.__typename === 'Account'\n );\n return assigned;\n}", + "inputs": [ + { + "key": "query", + "type": "LITERAL", + "value": "string:query FindItem($input: FindInventoryItemsFromDeviceIdentifiersQueryInput!) {\n findInventoryItemsFromDeviceIdentifiers(input: $input) {\n identifier\n inventoryItem {\n id\n model { name }\n assignation {\n assignee {\n __typename\n ... on Account { id name }\n }\n }\n }\n }\n}" + }, + { + "key": "variables", + "type": "FUNCTION", + "value": "({ state }) => {\n const rawMac = state.input.callingstationid;\n const cleanedMac = rawMac.startsWith('1:') ? rawMac.slice(2) : rawMac;\n const formattedMac = cleanedMac\n .split(':')\n .map((segment) => segment.padStart(2, '0'))\n .join(':')\n .toUpperCase();\n return { input: { identifiers: [formattedMac] } };\n}" + } + ], + "nextSteps": [{ "slug": "if-byod-needed" }] + }, + { + "name": "Is BYOD needed?", + "slug": "if-byod-needed", + "type": "CONDITION", + "nodeSlug": "condition", + "condition": "({ secrets, state, utils, variables }) => {\n const found = state.steps['check-inventory'].output;\n return !found || found.length === 0;\n}", + "elseSteps": [{ "slug": "assign-ip-address" }], + "thenSteps": [{ "slug": "find-byod-model" }] + }, + { + "name": "Find Customer Provided Router model", + "slug": "find-byod-model", + "type": "GAIIA_API_GATEWAY_V1_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-public-api-gateway-graphql-request", + "allowSkipExecution": false, + "outputMapper": "({ output }) => {\n const model = output.response.inventoryModels.nodes[0];\n if (!model) {\n workflow.fail('Customer Provided Router model not found', 'MODEL_NOT_FOUND');\n }\n const macField = model.fields.nodes.find(\n (field) => field.name === 'MAC Address'\n );\n if (!macField) {\n workflow.fail('MAC Address field not found on Customer Provided Router model', 'MAC_FIELD_NOT_FOUND');\n }\n return { id: model.id, macFieldId: macField.id };\n}", + "inputs": [ + { + "key": "query", + "type": "LITERAL", + "value": "string:query GetModel($filters: InventoryModelQueryFilters) {\n inventoryModels(filters: $filters) {\n nodes {\n id\n name\n fields { nodes { id name } }\n }\n }\n}" + }, + { + "key": "variables", + "type": "FUNCTION", + "value": "({ secrets, state, utils, variables }) => {\n return { filters: { name: { equals: 'Customer Provided Router' } } };\n}" + } + ], + "nextSteps": [{ "slug": "create-byod" }] + }, + { + "name": "Create BYOD inventory item", + "slug": "create-byod", + "type": "GAIIA_API_GATEWAY_V1_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-public-api-gateway-graphql-request", + "allowSkipExecution": false, + "inputs": [ + { + "key": "query", + "type": "LITERAL", + "value": "string:mutation AddInventoryItems($input: CreateInventoryItemsInput!) {\n addInventoryItems(input: $input) {\n inventoryItemsCreationResult { addedItems { id } }\n errors { code message }\n }\n}" + }, + { + "key": "variables", + "type": "FUNCTION", + "value": "({ state }) => {\n const rawMac = state.input.callingstationid;\n const cleanedMac = rawMac.startsWith('1:') ? rawMac.slice(2) : rawMac;\n const formattedMac = cleanedMac\n .split(':')\n .map((segment) => segment.padStart(2, '0'))\n .join(':');\n const accountId = state.steps['get-account-id'].output.accounts[0].accountId;\n const { id: modelId, macFieldId } = state.steps['find-byod-model'].output;\n return {\n input: {\n items: [{\n fields: [{ modelFieldId: macFieldId, data: formattedMac }],\n modelId: modelId,\n equipmentConditionType: 'NEW',\n purchasePriceInCents: 0,\n status: 'FUNCTIONAL',\n assignation: { assigneeId: accountId, assigneeType: 'ACCOUNT' }\n }]\n }\n };\n}" + } + ], + "nextSteps": [{ "slug": "assign-ip-address" }] + }, + { + "name": "Assign IP address to MAC", + "slug": "assign-ip-address", + "type": "GAIIA_API_GATEWAY_V0_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-internal-api-gateway-graphql-request", + "allowSkipExecution": false, + "inputs": [ + { + "key": "query", + "type": "LITERAL", + "value": "string:mutation UpsertIp($input: UpsertMacAddressIpAddressAssignationsInput!) {\n upsertMacAddressIpAddressAssignations(input: $input) {\n assignations { macAddress ipAddress }\n }\n}" + }, + { + "key": "variables", + "type": "FUNCTION", + "value": "({ state }) => {\n const rawMac = state.input.callingstationid;\n const cleanedMac = rawMac.startsWith('1:') ? rawMac.slice(2) : rawMac;\n const formattedMac = cleanedMac\n .split(':')\n .map((segment) => segment.padStart(2, '0'))\n .join(':')\n .toUpperCase();\n return {\n input: {\n assignations: {\n macAddress: formattedMac,\n ipAddress: state.input.framedip,\n expired: false\n }\n }\n };\n}" + } + ], + "nextSteps": [{ "slug": "get-account-macs-and-plan" }] + }, + { + "name": "Get all inventoried MACs and plan", + "slug": "get-account-macs-and-plan", + "type": "GAIIA_API_GATEWAY_V1_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-public-api-gateway-graphql-request", + "allowSkipExecution": false, + "outputMapper": "({ output, state }) => {\n const account = output.response.account;\n if (!account) {\n workflow.fail('Account not found', 'ACCOUNT_NOT_FOUND');\n }\n const macs = [];\n for (const assign of (account.assignedInventoryItems?.nodes || [])) {\n const inv = assign.inventoryItem || {};\n for (const f of (inv.fields?.nodes || [])) {\n const fieldName = (f.modelField?.name || '').toLowerCase();\n const data = (f.data || '').trim();\n if (fieldName.includes('mac') && data && data !== '00:00:00:00:00:00') {\n const cleaned = data.startsWith('1:') ? data.slice(2) : data;\n const parts = cleaned.split(':');\n if (parts.length === 6) {\n macs.push(parts.map((p) => p.padStart(2, '0').toUpperCase()).join(':'));\n }\n }\n }\n for (const a of (inv.ipAddressAssignments?.nodes || [])) {\n if (a.macAddress) macs.push(a.macAddress.toUpperCase());\n }\n }\n const plan = (account.billingSubscriptions?.nodes || [])\n .map((s) => s.productVersion?.product?.slug)\n .find(Boolean) || '';\n return { macs: [...new Set(macs)], plan, gaiiaId: account.readableId };\n}", + "inputs": [ + { + "key": "query", + "type": "FUNCTION", + "value": "({ state }) => {\n const accountId = state.steps['get-account-id'].output.accounts[0].accountId;\n return `query { account(id: \"${accountId}\") { readableId assignedInventoryItems { nodes { inventoryItem { fields { nodes { data modelField { name } } } ipAddressAssignments { nodes { macAddress } } } } } billingSubscriptions { nodes { productVersion { product { slug } } } } } }`;\n}" + }, + { + "key": "variables", + "type": "FUNCTION", + "value": "({ state }) => ({})" + } + ], + "nextSteps": [{ "slug": "preseem-update" }] + }, + { + "name": "Update Preseem", + "slug": "preseem-update", + "type": "PRESEEM_REST_REQUEST", + "nodeSlug": "preseem-rest-request", + "allowSkipExecution": false, + "inputs": [ + { + "key": "method", + "type": "LITERAL", + "value": "string:PUT" + }, + { + "key": "path", + "type": "FUNCTION", + "value": "({ state }) => {\n const { gaiiaId } = state.steps['get-account-macs-and-plan'].output;\n return `/services/${gaiiaId}`;\n}" + }, + { + "key": "body", + "type": "FUNCTION", + "value": "({ state }) => {\n const { gaiiaId, macs, plan } = state.steps['get-account-macs-and-plan'].output;\n const rawMac = state.input.callingstationid;\n const cleanedMac = rawMac.startsWith('1:') ? rawMac.slice(2) : rawMac;\n const connectingMac = cleanedMac\n .split(':')\n .map((segment) => segment.padStart(2, '0'))\n .join(':')\n .toLowerCase();\n const framedIp = state.input.framedip;\n // On connect (bound=1): include connecting MAC with its IP. On disconnect (bound=0): exclude it.\n const inventoriedMacs = macs.map((m) => m.toLowerCase());\n const attachments = inventoriedMacs\n .filter((m) => state.input.bound === '0' || m !== connectingMac)\n .map((m) => ({ cpe_mac: m }));\n if (state.input.bound === '1') {\n attachments.push({ cpe_mac: connectingMac, network_prefixes: [framedIp] });\n }\n return {\n id: String(gaiiaId),\n attachments,\n account: String(gaiiaId),\n package: plan\n };\n}" + } + ], + "nextSteps": [{ "slug": "end" }] + } + ] +} diff --git a/gaiia/pyproject.toml b/gaiia/pyproject.toml new file mode 100644 index 0000000..c8ce9ff --- /dev/null +++ b/gaiia/pyproject.toml @@ -0,0 +1,63 @@ +[build-system] +requires = ["hatchling>=1.18"] +build-backend = "hatchling.build" + +[project] +name = "gaiia" +version = "0.1.0" +description = "Python client for the gaiia GraphQL API and webhooks." +readme = "README.md" +license = { file = "LICENSE" } +requires-python = ">=3.10" +authors = [{ name = "Graham McIntire", email = "graham@mcintire.me" }] +keywords = ["gaiia", "graphql", "api", "wisp", "isp", "client"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Libraries :: Python Modules", + "Typing :: Typed", +] +dependencies = ["httpx>=0.27"] + +[project.optional-dependencies] +dev = [ + "pytest>=8", + "pytest-asyncio>=0.23", + "respx>=0.21", + "mypy>=1.10", + "ruff>=0.5", +] + +[project.urls] +Homepage = "https://app.gaiia.com/docs/overview" +Documentation = "https://app.gaiia.com/docs/overview" +Source = "https://github.com/grahammcintire/gaiia-py" +Issues = "https://github.com/grahammcintire/gaiia-py/issues" + +[tool.hatch.build.targets.sdist] +include = ["src/gaiia", "README.md", "LICENSE"] + +[tool.hatch.build.targets.wheel] +packages = ["src/gaiia"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" + +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "I", "B", "UP", "SIM"] + +[tool.mypy] +python_version = "3.10" +strict = true +warn_unused_ignores = true diff --git a/gaiia/radius_gaiia_fuzzy.csv b/gaiia/radius_gaiia_fuzzy.csv new file mode 100644 index 0000000..7eaf053 --- /dev/null +++ b/gaiia/radius_gaiia_fuzzy.csv @@ -0,0 +1,68 @@ +radius_id,username,password,gaiia_id,gaiia_name,gaiia_readable_id,match_type +29521,tiffanyyarley-olenak,98HTZ9Q8FARG,account_cwmjmXkrYeba8h8RGqLm6E,Tiffany Yarley-Olenak,2393,fuzzy(sequence: 0.95) +29395,vanessagomez1,J5JEW3HEBVAF,account_gb82EhqTwbNWWEovPu4oKZ,Vanessa Gomez,2264,fuzzy(last+first: gomez+vanessa(fn=1.00)) +29611,brittannieredden1,TYFTUEZ7429,account_bB6RDmNpkzndVbSh9CvgaL,Brittannie Redden,10017,fuzzy(last+first: redden+brittannie(fn=1.00)) +29614,samanthatheis,kf6u8djigk,account_x46hMNq15qHmEvP6fNFdfm,Samatha Theis,10023,fuzzy(last+first: theis+samatha(fn=0.70)) +29216,wanxiaxong,BER4NEK9M7NA,account_tiy5YHD34KpwPzUTXyRVFJ,Wanxia Song,2077,fuzzy(sequence: 0.86) +29215,bryangoulart,DTNQFE4CXSFX,account_9sksoKmcJGxqhz4M4aD169,Brian Goulart,2076,fuzzy(last+first: goulart+brian(fn=0.70)) +29602,tech,brussels-crucial-smashing,account_jUFnZ52urgAMNgp9XhT4bs,Graham McIntire,1,fuzzy(manual_override) +28935,chrislewis1,V9DS4E4Y7UTT,account_osB3NhyGdoWJRtpL5X2bfD,chris lewis,1787,fuzzy(last+first: lewis+chris(fn=1.00)) +28917,crankkeith,kcrank10615,account_iSuzVkNmem52NdXXh1Savu,Keith Crank,209,fuzzy(last+first: crank+keith(fn=1.00)) +28703,grahammcintire2,U5Y3hA3g*14L,account_jUFnZ52urgAMNgp9XhT4bs,Graham McIntire,1,fuzzy(last+first: mcintire+graham(fn=1.00)) +28702,chrissyeagle2,NJPCGPBNFB,account_3uu7k4euycmKnZii9DQAj2,Chrissy Eagle,74,fuzzy(last+first: eagle+chrissy(fn=1.00)) +28671,melodymccarty,U3WLEGEB,account_18HG9hdwwtZM83J9kyGMs6,Melody McCarthy,1397,fuzzy(sequence: 0.93) +28642,barbihardin,ZENETJ75TEVQ,account_bb7iRuPixxutKZxnURfK5U,James Hardin,1475,fuzzy(manual_override) +28628,jackworthy,2VUR7AAFWZZQ,account_hgoipVYXydD1yVSXTD9QGP,John Worthy,1461,fuzzy(last+first: worthy+john(fn=0.70)) +28542,michaelray2,jjPgDcotL6rtY,account_8Rh7edwTjNBYw9FJSq4WRT,Michael Ray,379,fuzzy(last+first: ray+michael(fn=1.00)) +28458,kennethcampbell2,V5TKW8L6RXFF,account_jF69QnP5j9FtfLgdAiUKfE,Kenneth D. Campbell,213,fuzzy(last+first: campbell+kenneth d(fn=1.00)) +28296,cinthiapereira1,9WSGXM9LBXGZ,account_57mDq22QWRV8tdPvk74Uvj,Cinthia Pereira,1102,fuzzy(last+first: pereira+cinthia(fn=1.00)) +29531,daycor,U45YBPSARH7N,account_aZmcKqobMWGKhxagDdLCFA,Cathy Day,755,fuzzy(manual_override) +29620,sayedahaque,YR4Sytbu,account_4hoTCjwTatthCg7zp2Cnfe,Syeda Haque,10048,fuzzy(last+first: haque+syeda(fn=0.70)) +27417,donniecampbell,6a2671bc0c1,account_4ww28eUsFRv8Yh5RkWe9cz,Danny Campbell,92,fuzzy(last+first: campbell+danny(fn=0.70)) +27418,alanwyatt,i1412r53MYr,account_2cnVCpSsYocCYpMEwwBBrY,Allen Wyatt,27,fuzzy(last+first: wyatt+allen(fn=0.70)) +27436,clayrobertson2,C2147288546R,account_jgtX7TysTqcobVVBtSZEc2,Clay Robertson,77,fuzzy(last+first: robertson+clay(fn=1.00)) +27501,jonnytaylor,Zb6677&4!yky,account_1fDruGS6CFTBUCGuzVZYTG,Johnny Taylor,190,fuzzy(last+first: taylor+johnny(fn=0.70)) +27579,donnance,9727346814,account_s9ZW1FpS3wkDoh31CjU1Zw,Dana Nance,89,fuzzy(last+first: nance+dana(fn=0.70)) +27838,consueloluna1830,C8736gfd0,account_jw16PcKtprHpPkSAEhvsaN,Consuelo Luna,81,fuzzy(last+first: luna+consuelo(fn=1.00)) +27912,blancadiaz,B548gdu55D,account_ktdNSpKRV3ja7RMi7pZRBB,Balnca Diaz,433,fuzzy(last+first: diaz+balnca(fn=0.70)) +27639,choonpang,CP2143855677cp,account_o7aKk2FGKJAcmrXxMfUCpX,Geok Choon Pang,146,fuzzy(last+first: pang+geok choon(fn=1.00)) +27618,tammieventris,DR45QC74M,account_fMQ6qiwkPFWP8a86exFw4A,Terry Ventris,340,fuzzy(last+first: ventris+terry(fn=0.70)) +27939,reynamoore,R92nbskba7iM,account_k8gD8YGUQzwWCE7JT9EfYc,Rebekah Moore,386,fuzzy(last+first: moore+rebekah(fn=0.70)) +27781,pambanschbach,Q2p68#*9vwF2,account_6hGXtJ7RV7mqZYfd5AyE1f,Pam Bansbach,383,fuzzy(sequence: 0.88) +28107,jacquelinewilder,HEY36LUGYCUQ,account_hHa9Z64B9okZ3yccQP9WpR,Jacquelin Wilder,892,fuzzy(last+first: wilder+jacquelin(fn=1.00)) +29554,marcuspatton,GEUXX27VLZXK,account_dxY6AZnLdFUWup8Sf6XER1,Marcus Patton,2458,ambiguous_resolved(lowest_id) +29606,mariacastanon,iu297gkid9,account_qh8bFPvWzyAqHqwHMDkThW,Maria Castanon,10007,ambiguous_resolved(lowest_id) +28800,kennethcampbell,!9727342446!,account_jF69QnP5j9FtfLgdAiUKfE,Kenneth D. Campbell,213,ambiguous_resolved(lowest_id) +28358,stephenday,MSVGQ8X38YLN,account_j1UFX8wqzorqSetUje5dTJ,Stephen Day,1168,ambiguous_resolved(lowest_id) +29630,sriahobilamuth,RTCESW24,account_7PgSr8dVuafybJdrXND3V9,Sri Ahobilamuth,113,ambiguous_resolved(lowest_id) +27404,grahammcintire,U5Y3hA3g*14L,account_jUFnZ52urgAMNgp9XhT4bs,Graham McIntire,1,ambiguous_resolved(lowest_id) +29524,chandparvathaneni,TK4QA3XM5XK5,,,,"still_unmatched(best: , score: 0.0)" +29358,radiantlifeministries,12FDIUNF5L7vM,,,,"still_unmatched(best: , score: 0.0)" +29296,midvalleyconcrete,BD58JYT2PJ6A,,,,"still_unmatched(best: , score: 0.0)" +29244,janiscable,JHC7U554JBTR,,,,"still_unmatched(best: last+weakfirst: cable+gary w(fn=0.11), score: 0.611)" +29150,donwilhelmus,2V4NEL87RM39,,,,"still_unmatched(best: , score: 0.0)" +29135,kavalleriefarm,8DFNNTPBPZGY,,,,"still_unmatched(best: , score: 0.0)" +29077,kellygarza,4696591662,,,,"still_unmatched(best: last+weakfirst: garza+esparanza(fn=0.42), score: 0.642)" +29070,1stchoiceliving,YVWJWRPEVYNH,,,,"still_unmatched(best: , score: 0.0)" +28879,kimberlyrichards,7PQA6J8RPZ9Z,,,,"still_unmatched(best: last+weakfirst: kim+tae(fn=0.08), score: 0.608)" +28599,ryanrodriguez,9DFYWJ47YWUP,,,,"still_unmatched(best: last+weakfirst: rodriguez+mike(fn=0.19), score: 0.619)" +28460,saidaacosta,V29897X4,,,,"still_unmatched(best: last+weakfirst: acosta+alma(fn=0.21), score: 0.621)" +28422,jackiehendricks,UQR5HGFCJ4XX,,,,"still_unmatched(best: last+weakfirst: hendricks+sue(fn=0.09), score: 0.609)" +28400,bonniestone,BH8BV44TTWUU,,,,"still_unmatched(best: , score: 0.0)" +28336,buddyswan,N7XUYUD7J9SD,,,,"still_unmatched(best: , score: 0.0)" +28268,alberto,TICOGUILLO,,,,"still_unmatched(best: , score: 0.0)" +27408,penneywarner,b4h53N6c#5wo,,,,"still_unmatched(best: , score: 0.0)" +29626,karengreen,UTKCYI674,,,,"still_unmatched(best: last+weakfirst: green+dominique(fn=0.17), score: 0.617)" +27441,samanthasmith,22jmR9X#93j5,,,,"still_unmatched(best: last+weakfirst: smith+kathy(fn=0.27), score: 0.627)" +27489,jenniferreed,683P7V7R,,,,"still_unmatched(best: , score: 0.0)" +27482,irvinbarojas,yDeEJzYY08,,,,"still_unmatched(best: , score: 0.0)" +27463,dennispereyra,82s62kd9XHhd,,,,"still_unmatched(best: , score: 0.0)" +27709,sensibleheatsystems,SHQWERY23,,,,"still_unmatched(best: , score: 0.0)" +27704,lambandlion,LL9727363567ll,,,,"still_unmatched(best: , score: 0.0)" +27625,mattbud,kjklfda79sd,,,,"still_unmatched(best: , score: 0.0)" +127621,richardhill,RH9729788124rh,,,,"still_unmatched(best: last+weakfirst: hill+ellen(fn=0.20), score: 0.62)" +27955,evlynrichter,FRQD738BGRC,,,,"still_unmatched(best: last+weakfirst: richter+brent(fn=0.19), score: 0.619)" +27755,gordonhamilton,c3495Rpuq5Sh,,,,"still_unmatched(best: last+weakfirst: hamilton+sheryl f(fn=0.16), score: 0.616)" +27793,branchrvpark,XfKxVZIKmm,,,,"still_unmatched(best: , score: 0.0)" +29601,kimberlyrichards2,K84f7r10GT108,,,,"still_unmatched(best: last+weakfirst: kim+tae(fn=0.08), score: 0.608)" +28213,krisdougherty,RWEH75UJ38,,,,"still_unmatched(best: , score: 0.0)" diff --git a/gaiia/radius_gaiia_matched.csv b/gaiia/radius_gaiia_matched.csv new file mode 100644 index 0000000..5f63eaf --- /dev/null +++ b/gaiia/radius_gaiia_matched.csv @@ -0,0 +1,416 @@ +radius_id,username,password,gaiia_id,gaiia_name,gaiia_readable_id,match_type +29581,joseortiz,J2WEYT7782UJ,account_4jKF2XxS1tjfAiTJLsFcGJ,Jose Ortiz,2485,exact +29578,perlachavez2,U4BBTBMCJZST,account_sSL9o2BAvbEHXAkxWtPXxV,Perla Chavez 2,2482,exact +29575,thomashernandez,X6BZLA7SC3XM,account_wL9tsaty83QCJpNGNXeSzZ,Thomas Hernandez,2479,exact +29572,williamarmstead,SAQ4XEKXDC58,account_5Eqdv89tv7SUwTjGqQWJdh,William Armstead,2476,exact +29570,julierose,MLM8R2WAGY52,account_9hvebUFe6UDfaZb9omVkB8,Julie Rose,2474,exact +29571,derrickmccausland,ETNLKZHACGF7,account_89xduFTiVvRoEQpENiaVmT,Derrick Mccausland,2475,exact +29567,sherrifredrick,7P52BVMTSNN6,account_3eF55epzgGSPhDU1DFBm53,Sherri Fredrick,2471,exact +29565,shelbyburris,3YAL9DWFZUPT,account_9GNShE9ayAX3fkjwSryGq7,Shelby Burris,2469,exact +29564,priscillacrenshaw,AXBDLXSGXMLR,account_2MK9ykEGzWyxvg1SZMmagK,Priscilla Crenshaw,2468,exact +29561,korybiggs,4FJC7DXAW5RV,account_ftDbZQGSaf8dowpdjC44sU,Kory Biggs,2465,exact +29563,kristinjimenez,9FAYXMRDU69T,account_bdpYxupJ3cfQ5bmhNroy8i,Kristin Jimenez,2467,exact +29558,jessicaweddle,VV96G36AEEDA,account_khW6HArsQ677gL18hcQnEN,Jessica Weddle,2462,exact +29557,yolandamedrano,R948UL4KZRA4,account_4tZA9NMygTcq3fidSxDz5h,Yolanda Medrano,2461,exact +29555,williambowland,K7HYNWGE25XX,account_6fhZdgAsyd9zJ2FiXURCJ2,William Bowland,2459,exact +29553,shacaciabillings,8AZLJCDFYFYV,account_pDDXT9Lh8QsE2roPyeVfS8,Shacacia Billings,2457,exact +29552,guadalupelugo,DQG8XCMFMVH5,account_haPf3cXEdK4TQMzqZi5LYV,Guadalupe Lugo,2456,exact +29551,brandonhardesty,KMRHVPEXBEZ8,account_izj2Jd1nYA49rAcvNu6TfZ,Brandon Hardesty,2455,exact +29550,nancyavellaneda,CXATYV7NGZN2,account_99jecysxoYGBHUhd97ZYmt,Nancy Avellaneda,2454,exact +29549,matthewgoodwin,VSPM7N9UNU6Y,account_ocezHaQ4JPvu1EbRMk4hu5,Matthew Goodwin,2453,exact +29545,donmckinney,C6AQ6UVD6R7R,account_viJJswZvVXEbxLBikRjGJz,Don Mckinney,2449,exact +29544,natashaelmore,8G5QJ8R7MK8Q,account_bGYACsz7a5kdyDBDecBTZY,Natasha Elmore,2448,exact +29543,shamsbashir,WLBGY4JMN4VA,account_vtGoVXJRkxWw3ZEYDKi9Bu,Shams Bashir,2415,exact +29541,douglasglover,9Y6H9X3HCG3D,account_hzZvaSXgCuhMUTXjpaCQjA,Douglas Glover,2413,exact +29538,carolstrickland,547P6AQ8RCD4,account_u8W2U31Z3LJgzNcD2qNfcX,Carol Strickland,2410,exact +29537,carmenlopez,TPLND3H2FWTQ,account_75ZP8Rhk7DBvwEA83HaWBB,Carmen Lopez,2409,exact +29534,johnlooper,ZPL8LRY2TGRH,account_iijdXYXJAhg1mDm1FRuUrc,John Looper,2406,exact +29535,davidroesch,ZHB2BS968DTU,account_bHVKB5E3eJFmbqnDwXAxFw,David Roesch,2407,exact +29530,derekrodriguez,AP7Z594DMR7E,account_8MmcYW5A3KpMsyBJjiQMfE,Derek Rodriguez,2402,exact +29522,joshuasoliz,PE7MHRZWUEQM,account_fPwqeAaDrYSWtRyVjBk8HD,Joshua Soliz,2394,exact +29514,ericbarrett,5S2CV9FEFMKG,account_fhkdvYwigwTL9hR4Stx8Tf,Eric Barrett,2386,exact +29513,marysmelser,ZS74NS5BBPX3,account_sN8UFBKJy6HHSCXSQp8oTB,Mary Smelser,2385,exact +29506,nicolemaenn,B7KS27WL5WSN,account_pJFWGZXwDitJCT55tcuqeE,Nicole Maenn,2378,exact +29503,yolandajudge,6V8RLNK75LM8,account_cWR4qvSM1SepG3DuoD8rUz,Yolanda Judge,2375,exact +29501,craighovde,AVZXWBX8GQTC,account_q9j2WgU1236uDnx5rrEx8U,Craig Hovde,2373,exact +29498,joselucas,ZHMW9TE8QHRX,account_izByTUMCPK7UT8k6Zncc4e,Jose Lucas,2370,exact +29488,douggarber,WXP9J5SRJWEJ,account_vyxx47PALJmyUZZe3m495N,Doug Garber,2360,exact +29482,carmencleghorn,VH3CBTJZAJ2Y,account_cRZQwgdsQLcok1dzXy6U5c,Carmen Cleghorn,2354,exact +29472,rubenreyez,66384Y7B4WR4,account_aC929RXGkB1nsEHu95kg5v,Ruben Reyez,2344,exact +29464,tiffanystewart,3EGVCS6RMF3Z,account_h3oACsArSoq5fZ6vRyH9Br,Tiffany Stewart,2335,exact +29449,dejadodson,FLR4DJHPX6TG,account_57jxCEcSpkMPUEVxDoZddc,Deja Dodson,2320,exact +29447,coreyball,3JAUWZNAR9MB,account_8fvkQS2hERUdtSF7DXrzpw,Corey Ball,2318,exact +29442,toniyoung,RJ24VTRM7JA9,account_mXgG1ZhfDfrnkU6usym3kG,Toni Young,2312,exact +29434,melanieweddle,XQ5L7PUM9RP6,account_piQoRw8BbqZpckbweTGdDu,Melanie Weddle,2304,exact +29431,zackknuckey,VP5CCX8LTE47,account_uh8Yfj5mdjaQjofM19p9MU,Zack Knuckey,2301,exact +29425,christinehamparyan,WEYX9R5P7J5L,account_aJuvQYQACJ5Ms48bAR4hWY,Christine Hamparyan,2295,exact +29422,joespeciale,UH6ZCM9WLGRQ,account_2BwbPKVKpj7QnFFpdZVqn3,Joe Speciale,2292,exact +29420,rogerbatt,KGDJZ7V66QQK,account_2gaAXfFo32nXEh7qA2yX8M,Roger Batt,2290,exact +29419,kalebjames,KMQDGPD5XB6Z,account_67qbP4FpqwKmqSNjSmtCBv,Kaleb James,2289,exact +29416,stevemolina,BLLHWPY9RCTC,account_c5VgNVVcBTRkHjbzFDEpHk,Steve Molina,2286,exact +29414,mariatrejo,EYPWCQKKWYZH,account_ahaKfCrUTPA2mx7oM2i7Xu,Maria Trejo,2284,exact +29410,kenhall,LX897BGJNMK5,account_gZPLTs7y5We8wW5MZyZjkX,Ken Hall,2280,exact +29407,deewilliams,R8AGX3E8WAHN,account_kjSroty8KmGKCTEArNC9Pa,Dee Williams,2276,exact +29406,johnvayo,DJJNUEEHGAW5,account_7hUzHAkpHagRdjPWYoRP9R,John Vayo,2275,exact +29398,anthonythompson,WB4E4MPJVHTB,account_4CLddQNDWC76dtRwmYRdeZ,Anthony Thompson,2267,exact +29608,stevecombest,STVN23552Wt,account_aM6WQ3aysaRPx4zpUh3zWD,Steve Combest,10015,exact +29607,luisvelira,uygo875491!,account_3wT7DTZj4An5jaitLTTWTA,Luis Velira,10013,exact +29609,keithtucker,14FYJSNUU48,account_7Wjw5Lru3JmVxq4s6cRqvn,Keith Tucker,10016,exact +29610,angelatrevino,KJGU39565,account_4yPBWjMpwT4tVHJyVTDiYX,Angela Trevino,10014,exact +29612,amymontelongo,ig76f7fuvu,account_o7rGD6dKJP27Sf9AUMK2e2,Amy Montelongo,10020,exact +29613,michaelasmith,MS21353GHY,account_t23HG5uEXET828jSbivozo,Michaela Smith,10022,exact +29615,janisstewart,JD834HCUW1C,account_fztV7NVRixwgnD6n392ZfX,Janis Stewart,10024,exact +29616,irenegaribay,uky75dkc,account_ctACFtZH9mDX1E9tVgkpzy,Irene Garibay,10027,exact +29383,leonardocoronel,TXPC494,account_bLMBbmXsvurdQQaSokRzTf,Leonardo Coronel,2248,exact +29374,alejandrolimon,MM4BSQS7FRTD,account_gqFbPtxAGRTSizXXLFB9j3,Alejandro Limon,2239,exact +29368,crystalharney,A7W26H834WLJ,account_bzPJ8YAjKNt5Bn7zhYf8zw,Crystal Harney,2233,exact +29355,olgagutierrez,WERWRMUDBZ85,account_hswERjNGcJtQnPWCkMnEQs,Olga Gutierrez,2220,exact +29353,greghummel,JV84HSVAK6XR,account_myUaLbfFVED7hqEBBY1Pe4,Greg Hummel,2218,exact +29349,bettymitchell,8CHHCNBWQLRV,account_t4wwpPyK98WBZUqLnwXKNU,Betty Mitchell,2214,exact +29341,wandadavis,JXQXXZ9MKFX6,account_iy49cEMsdawi18rTXarw73,Wanda Davis,2206,exact +29338,scottanderson,RJ49A43SG3RA,account_sDPK3iLsxwrLzndTwh2uxb,Scott Anderson,2203,exact +29335,jenniferboon,28ESU8ELVELP,account_3aCW8JeazPNe4UxjtGXzrp,Jennifer Boon,2200,exact +29331,marialopez,P3FNSPT7MEYM,account_mjnZnogDMDws9ueMDxVzaP,Maria Lopez,2196,exact +29329,cynthiasandlin,MCDTLMVFQXAV,account_sFzFdm2TTpyRaur4JfF3bv,Cynthia Sandlin,2194,exact +29328,bethanybell,3UUC5X27PETS,account_wnvmzBJwB3r7pG6a6WNANx,Bethany Bell,2193,exact +29323,garrettojeda,U5PRQYZE2MM3,account_chsr6oKpnEYuEKkMXn7CKF,Garrett Ojeda,2188,exact +29325,rayschaak,XSNLP62EK6VZ,account_qhFf5wpTsv3FySr63biaRp,Ray Schaak,2190,exact +29327,carlaswearingen,E6DQ4B2T9JZD,account_rzEn7BHU6Dmd6PkRwRpNXB,Carla Swearingen,2192,exact +29320,lesliebewley,55KFSJMEDYLK,account_jeuzxdVtC5u4W8utfKf5vc,Leslie Bewley,2185,exact +29317,delainawaite,EM7TWHUS4SYU,account_kvePVE9fNZCokbkkvpBvLK,Delaina Waite,2182,exact +29307,esparanzagarza,VLL9ZNPRSTG5,account_dXq5FqPpKhAwE96NT5D9Jk,Esparanza Garza,2172,exact +29273,glendabeauchamp,RKA3J8WSPYJP,account_fJVr5CSPdsgWtLNRWkidro,Glenda Beauchamp,2133,exact +29256,glindarichter,VXMLMDAFLNJ3,account_2ngDjpr4VqDCPsv2SNMHvS,Glinda Richter,2117,exact +29251,nhumorrison,EC32VDFKB9CB,account_hZpk1bujwvFdyctrbfJtjS,Nhu Morrison,2111,exact +29245,brittanyworley,NBKYERRBSBH4,account_r39MCaQ811VXnw6QbhSG4b,Brittany Worley,2106,exact +29243,dustynfisher,XASFU3DBPZ5M,account_sAYv3pn9D1KCwWr1TzGyeu,Dustyn Fisher,2104,exact +29237,stephenbeegle,RU5GZVTVACRC,account_7RPLicRiJMb3Y1x3C4tEQJ,Stephen Beegle,2098,exact +29229,roseanndellavalle,5GAF6JHUWWYP,account_wTXj1c59SU9iRzeRmgv2dT,Roseann Dellavalle,2090,exact +29231,duanewright,VXK4FBLVFE7F,account_3Hwm7znAWKLPMhmY2avtnG,Duane Wright,2092,exact +29222,pablohernandez,P3YB6A4JGEDF,account_vkMiPuMKu6XmQt1G9CP1op,Pablo Hernandez,2083,exact +29217,pavelavanesov,KP8ZSGC2DG2U,account_kNWT9ewUppfxMtxpcgScKZ,Pavel Avanesov,2078,exact +29204,sandrahoughton,5XR4QMPBXVWY,account_sDBLs44VLbWy9ZRhuDAkpL,Sandra Houghton,2065,exact +29203,adamstaeben,GP7RANJ8UCYP,account_u39DmoPasPkSoYJ3bTG8kz,Adam Staeben,2064,exact +29190,russmott,J8ZN888UQSLR,account_7QghTwmH6MwtZdTrvur8uB,Russ Mott,2051,exact +29189,karenstewart,JSKTFAJ2JYDX,account_wSUPq2ZjUF1Va7S3vap91M,Karen Stewart,2050,exact +29185,heatherwomack,89W7S5Q7UAHP,account_kSE9uAW6WPbt4msXe4mzHs,HEATHER WOMACK,2046,exact +29183,wyndijames,CPSDNRQU95HJ,account_hJwbN2EUqGCtNj45JCyQda,Wyndi James,2044,exact +29182,angelicacuayahuitl,VK8PZW3LK5F3,account_sooioQ729DC21LJa1zKMAN,Angelica Cuayahuitl,2043,exact +29181,chasewilliams,W6AMPQEPY3BC,account_iQqfYFDTb4UxtHcgrGt8EP,Chase Williams,2042,exact +29176,kevinarana,JAJYSEG75UZS,account_oR5nKGJTvRLhkCyUGG1B1L,Kevin Arana,2037,exact +29166,chrisclayton,BFJ462PLHJFV,account_aamYj92hC8r7nxvbAfiHCE,Chris Clayton,2027,exact +29162,billyhorton,MPTX573ZUYYG,account_4DuPXfEqx5pRPftTGKkbhS,Billy Horton,2023,exact +29151,chisediquezada,RJBYQ2NUB829,account_1KjfxaKx8MvMEwb7uU3eSC,Chisedi Quezada,2011,exact +29147,angelmerlo,EWWAPXN9Z2YS,account_deGyZc4zP5TBJGSibkGU3a,Angel Merlo,2007,exact +29113,kerryblack,9D5S9ZD2FCBV,account_qncL4zimMnsq5ViZrpNXr1,Kerry Black,1974,exact +29110,cliffordjennings,5GC7M65H9VGT,account_rknD3GcgUkTd8XB7rZZjND,Clifford Jennings,1971,exact +29098,carlakimberling,SS4KRJYKTV7B,account_2hJ8frAhU5LDVpaoSH27Tx,Carla Kimberling,1959,exact +29088,richardconklin,WEUNG5H3K8BB,account_7aEgkmhMKwDVqsus5Ln8Bd,RICHARD CONKLIN,1949,exact +29089,leandrabeshea,7V8BZU4G83VF,account_doS7HkZWacVd5r8nWxqDFZ,Leandra Beshea,1950,exact +29067,robertfinch,D7FMTT455GBB,account_jJoU6UPYd2ckqbUhynBx7B,Robert Finch,1928,exact +29058,ericcoffman,MMEBXQYLZWNU,account_4mZAzz4GiGtMU8CJBWs1cQ,Eric Coffman,1919,exact +29050,nathanaelfinch,JVNC68FV7N6B,account_t87r46C6jaw8rjJYurmHrY,Nathanael Finch,1911,exact +29047,gabrielasereno,KCH82WCRA2YR,account_99RoiQcJpubSAqajPZjxFf,Gabriela Sereno,1908,exact +29045,georginacovarrubias,8QT57NUV8C7R,account_gERfDmYXtJj5PLCY9ELLS7,Georgina Covarrubias,1906,exact +29035,thomascline,E523BKVU5MJP,account_g8yP63NFmbioePagUH1kQS,Thomas Cline,1894,exact +29034,susanlewis,R7TZFZEGLNRW,account_mqMu7hR16hGpAesQ3ba83B,Susan Lewis,1893,exact +29025,tammywelborn,RMC482WNBS7,account_rjvhtkZxuk442ghJaKsdtG,Tammy Welborn,1878,exact +29023,haleymuela,BS7X3DTKW8TH,account_hmnByz3wfiafSujapZBbi3,HALEY MUELA,1884,exact +29018,christelles,WL2TKLFG8G2H,account_uGdvd35CHuBkiBamMnB8Bj,Chris Telles,1879,exact +29017,mauricioantonio,6QJW7RAPDBWB,account_tX9wv8qaDb4bCFBxKPsMDe,Mauricio Antonio,1877,exact +29013,vincentlimon,YDDXKFABUZY7,account_eZ6nJGAsmsPwR8sYvtXBRw,Vincent Limon,1873,exact +29634,jeffreyharder,YUV565D67RT,account_gWD9gTNmR8RbzQkJmjpRYy,Jeffrey Harder,10073,exact +29635,pablomartinez,2766581BER,account_g2THEPVu4zgnGsTeFh6MpF,Pablo Martinez,10074,exact +29636,krystalmendez,6FTCKU6S56,account_hiH7WbsYQm1HRkpGA3jJ2v,Krystal Mendez,10080,exact +29637,davidvandagriff,UTRC53865F9,account_kAa1VyPLqz5fecRMXpV7d4,David Vandagriff,10082,exact +29638,carolynwalker,trjxtyx75!,account_wsZpXzz2mhYMxMLmiVGNvF,Carolyn Walker,10084,exact +28979,claygilbert,3E6XFDHXCAG7,account_eVxdo9RAAoBbisk1EYCKnc,CLAY GILBERT,1839,exact +28978,jmichaelculverhouse,2147099521,account_q4nxMiXUoEj2DkH3JUspLP,J'michael Culverhouse,1838,exact +28971,diegosereno,2CE9F5YSPQHX,account_vvPvzpGka7xmJQnqE3gMxq,Diego Sereno,1830,exact +28970,ashleysmith,F8D3FVCZB7PD,account_5nSRJ7EkJpz5dpB2QGRYfz,Ashley Smith,1829,exact +28954,dougstowe,5D35LFD6GEAZ,account_3hQJhhHf48sszYWBaMTjpR,Doug Stowe,1807,exact +28936,timbronschidle,3ZVLCWSAE3EW1,account_o6gDBbdWzQYeUbAQL2Zp1s,Tim Bronschidle,1788,exact +28928,cynthiajones,KC7ESYHEZGNR,account_hJ5wonckmH2jkaykKH2g4Q,Cynthia Jones,1778,exact +28867,bobbymack,UDUBTCXXF8VV,account_nNUBZtEptfAD6TpDteqjfM,Bobby Mack,1712,exact +28848,americatrejo,3B63TLTT9WE4,account_gjzpfGyVuYMaQwdYA2Gufy,America Trejo,1693,exact +28832,tjbanschbach,8WTWTXACGG5N,account_hU5voij99AKqckcGpok7Jx,T.J. Banschbach,1676,exact +28811,luiscabrera,RWUQ94XT9A9Z,account_mrPPtWesWBzmcD4Lfvp2hA,luis Cabrera,1650,exact +28810,rosahernandez,JSGUXKWEMSVN,account_qYeNtZSW1nYgukr9kYy5qn,Rosa Hernandez,1649,exact +28807,carlosgomez,J22RS7F3LGG2,account_jNj9rpkTwq7wF3vJbMuAkF,Carlos Gomez,1645,exact +28763,jeridrutledge,GJ3CZTRJJW2M,account_v6odXP1nVxoVvTMUrUn3SS,Jerid Rutledge,1601,exact +28748,timthomas,NY223RQJD7PN,account_m5ZTZYHZbzpVPeNfRhroxn,Tim Thomas,1585,exact +28715,carolbates,J3UBUCMYDX64,account_97wbzzkGPxCPMyfUs5uTTf,Carol Bates,1543,exact +28710,shirleyelmore,D8Y64N9L4R9W,account_gfPawVk3gSuvufuC4zVjx9,Shirley Elmore,1538,exact +28698,swedlund,swedlund494,account_u9nP4wDufL6eQGx3Jnoyn6,Brian Swedlund,357,exact +28695,wendysanders,RYG9L43NWDZV,account_4KPsmHxqdcnsRNXuWB8s2Y,Wendy Sanders,1524,exact +28693,robbymccollom,RMC482WNBS7,account_xfht7KCxBpasoRqtkTojjj,Robby McCollom,297,exact +28664,heatherbrister2,KK37V8W9ZD9Z,account_m1hacYUopGGJrn2UyJvzBu,Heather Brister 2,1495,exact +28663,ryanlloyd,HWEMKNWX55RF,account_pFHZ8RMuvdETPpHr4vyDWi,Ryan Lloyd,1494,exact +28661,amyrodriguez,WX3QRT2U6L4X,account_w1ywM8kUBcURQHGjHQqirn,Amy Rodriguez,1492,exact +28660,thecountrycutter,YXpScFIneePm,account_1uu6CnSCUuCfBabMwfikLz,The Country Cutter,396,exact +28659,bradwilson,ZFAXA9AK63AH,account_ujyp8hfbksvc8EZALQ5gTL,Brad Wilson,1491,exact +28654,edrater,FRY4823N,account_fNtXkw98GQKVvcjcfbeb9F,Ed Rater,126,exact +28637,vikyacosta,Z4SMV49SAH72,account_mma7TzxJUZKUzXYr6YDUmt,Viky Acosta,1470,exact +28633,terrimack,B9HNTSN7ASQ4,account_wU2qgfTxqjaLSfhwWx4Cxm,Terri Mack,1467,exact +28617,luisarellano,GVNWBQMM3S7U,account_t9DUQUCtBAWojjSYtZQusE,Luis Arellano,1450,exact +28609,dorisavalos,DLKMJS88AF2W,account_pMdxeChdNqnVRQs1rSAKBE,Doris Avalos,1441,exact +28597,jackierollier,TSFNN8DVDSPR,account_w5t1gDksfLnZqU35RFxZES,Jackie Rollier,1429,exact +28588,charlesmccreary,88J6XS6TXRMA,account_dQ3cvFTw98ByhcBzDGMsNQ,Charles McCreary,1418,exact +28548,jamessmith,LRVU6RVG5SP8,account_ayvJr4cDCtK3zNPcoVo5vy,James Smith,1373,exact +28546,davidwhitefoot,PYHTZMHJU8R8-,account_sWngcEfTk4Ed83ARWYtZga,David Whitefoot,1371,exact +28536,patriciavasquez,P88256487V,account_6wngyAj64jCwvn3W8T3sdT,Patricia Vasquez,1128,exact +28535,adrianmcdaniel,U4TGDH2PSAB4,account_aScSfG25Wwo7cD4xbNhja1,ADRIAN MCDANIEL,1362,exact +28526,justinvoga,URHVCX7SFSFU,account_cwmXrKm93bSteHQfm3SJtd,Justin Voga,1351,exact +28518,jenniferlittle,8EAUUDQPXSQX,account_oSA465twDMxZedY5zbPLT1,Jennifer Little,1343,exact +28510,bradslate,AF4KVQ2P44M7,account_nhYxp4RV7gpLxWnLL9oWs6,Brad & Sherry Slate,1334,exact +28494,irisdelira,L6XGQA583DG6,account_mVgspPEmufZG8KD83Cnyjh,Iris Delira,1316,exact +28491,richardbarragan,WJBS86MMJBFH,account_hJ3iUD77LRADfhaxwwsqLj,richard barragan,1314,exact +28476,jensenhall,F8XM9U8SC6LL,account_m3356qm8wGGJDqNZCgXRev,Jensen Hall,979,exact +28466,paulcomella,YTZDMBZCMPX4,account_rRKbPsCejKqMnAb29xPPFx,Paul Comella,967,exact +28449,amberkrings,5NWBR3JLV6N8,account_eB2k88UWVdAFKYgcg7iK4h,amber krings,1273,exact +28438,mauriciosoto,DJ7YP7W7AA8M,account_uyp7QDJmZyCeq9BTGnvoZ3,Mauricio Soto,891,exact +28435,jimcrank,PLA7XNX2F4Y3,account_p9AaVzWfCwR74RVK3k6kPa,Jim Crank,1257,exact +28425,summerbeazley,D4S4YRB8Y3KA,account_9AkWnUdPzs2AE9vfqmbz6i,Summer Beazley,1244,exact +28426,davidarmstrong,MPEZGG53KQSZ,account_p1wD82RWMCTi1hRoLHdjHL,David Armstrong,1245,exact +28395,stevenspurgers,YS5Q785J2HGZ,account_nxruUEiwq3NpZTexeEo8jd,Steven Spurgers,1211,exact +28388,connercoleman,6108RANDOMREDDOG,account_6VLrBr4sZCAJwMzzcEkhSE,Conner Coleman,1202,exact +28367,judithfierro,FUZ33LB6SFFC,account_aFYkyi9DGrJBRgQ4jSud96,Judith Fierro,1177,exact +28359,tonyasipes,N5VEZR8Q5M86,account_rKLQuRuTgybF3YMnNt7VY9,Tonya Sipes,1169,exact +28347,kathysmith,SJ53PSYCZLPR,account_3zbkHwfVAegXCPUhc4XmYV,Kathy Smith,1156,exact +28344,cherieeshelman,BRUV99PN6Y7L,account_nwbZPHiXLbR9o6LZppPSoY,Cherie Eshelman,1153,exact +28289,ronberger,Ronb499H7ALW,account_q8QjfTJ9rtgoow3eHLpVYQ,Ron Berger,301,exact +28267,sandracrouch,V4N3764R5J,account_tCddLw595sDcQEYCrRCmFq,Sandra Crouch,311,exact +28263,abbieandrews,YQSJ499H7ALW,account_vHWu6NZDh4Ajh6YqWEXPb4,Abbie Andrews,1081,exact +28253,perlachavez,ZWR9PXVFFYBS,account_tTLx1Zn6YTykzm8quV6ykg,Perla Chavez,1068,exact +28246,susanking,N2KUYLPDNXJP,account_xqwwg1ZAd6SKt4LyrcAebY,Susan King,1058,exact +28241,terrybachran,NG9VMH25LCTC,account_s6ZC2GALaCFUamcQafLyy7,Terry Bachran,1053,exact +28240,lanaygaunce,MJCNZAJHK9H3,account_pUesJC8A4ENdQ5XZTFGdZt,Lanay Gaunce,1052,exact +28190,heatherbrister,6XPLC545G6HW,account_wkn6qdAVF72b7TxwZxvW6D,Heather Brister,997,exact +28184,jenniferdunaway,343V5CT2R7HZ,account_bTsNpp5j2YHU9dKipBm3iP,Jennifer Dunaway,990,exact +28159,elviraquezada,HXAS29VRX33V,account_q5J8BYNnSG5TtFnikCTEmm,Elvira Quezada,960,exact +28154,jordainpatton,J8P4TPF7QS2A,account_p5VqKAXXC8t3tAFSzzr8cC,Jordain Patton,955,exact +28140,mattkosarek,RJ4BQUZJY,account_7eCHrmvA6X16gn1zr5wCo9,Matt Kosarek,932,exact +28117,shannonclark,F5XESMCJ,account_8YqGj24SS5fxoZhPUYBED9,Shannon Clark,908,exact +28098,kevincostanzo,244VGMVMH66D,account_9DuddEuiaH59KtVGReTwJs,Kevin Costanzo,881,exact +28093,kenpatrick,V7U3XBAPTSV3,account_omX4bSM3rXDShahsdc4aHd,Ken Patrick,875,exact +28083,briangallimore,39V285NX,account_k8GTscTwPuAvgtyCbT6vyG,Brian Gallimore,55,exact +28072,amberprater,RP42B7AYDLAP,account_fXqciwzW348S1eYSKbbi8B,Amber Prater,855,exact +28068,marilynfowler,MarilynVG72P8H,account_v9vCsfc4uMAk7LgXyRR9EC,Marilyn Fowler,851,exact +28065,douglaswilson,HP97HG9XPYC3,account_bmXh2S5K97hPyUkkBRj1ub,Douglas Wilson,848,exact +28034,terrygortney,GQUGR3VJUXBR,account_jPuzvdHGq1BXiDALjrVaR7,Terry Gortney,815,exact +28031,gizellebutler,HMZTX66RTPVX,account_fipAsvnLVUkrSmuJ9GYdEb,Gizelle Butler,811,exact +28029,bradgurney,7A6LRZWT82DX,account_aZMRveyhHvJTHTQCT7yYdf,Brad Gurney,809,exact +28024,darrylchavis,TDGUUNXGMYRC,account_oP5ATcm8WwBDBv8t8mh4vU,Darryl Chavis,803,exact +28025,kathymazzola,5MVS8VUF9QL2,account_g5RpJWMv32tFk6c4yy5F5c,Kathy Mazzola,804,exact +27994,austinwatkins,S7M4UD8QUTDP,account_htyg2uThjYmLbHsX5a3RCj,Austin Watkins,769,exact +27988,oscarrodriguez,TGPCF3Y2ABXS,account_fNLTDkPjpbzZ7Eta2ofer4,Oscar Rodriguez,762,exact +27986,erinthompson,69TQZ797,account_eGe4Qkw13xjjwabHBu51F3,Erin Thompson,394,exact +27983,brianhardesty,8D4P34N8,account_7zjMVqCfCV4jZT68myj6p1,Brian Hardesty,20,exact +27976,cathyday,FAXMKENV8FCT,account_aZmcKqobMWGKhxagDdLCFA,Cathy Day,755,exact +27956,fredheckel,HUVZSE8RGD65,account_mx1SSXHEiptCZaz2Gx6MTm,FRED HECKEL,735,exact +27949,charlesboone,Ccbn78wdfksB,account_iNnHAwxaWRUXte6w7ijGV3,Charles Boone,62,exact +27948,rachelguzzardi,R87wgerf8bsG,account_tEZyHoiA8DvmisscRkYy7U,Rachel Guzzardi,287,exact +27936,juanalonzo,745GF0JK5!W,account_itAy96jDYGcvWjcHsy6XcR,Juan Alonzo,198,exact +27930,kristinamurphy,oPXFrylqKf,account_qx5oYHwkMNxoKEXME6zu6w,Kristina Murphy,725,exact +27926,johnnygoble,J829fhf7G,account_1m7oDB9vYUYJQffFHvxQaz,Johnny Goble,416,exact +27923,keithbarber,K73bvd54tB,account_bKdyNcZqZ1h8qaQAcJzTRK,Keith Barber,208,exact +27917,doreengrubb,DOR32SVT1234,account_5n5qjvMULWVYcGGmF7uiER,Doreen Grubb,122,exact +27916,steveraia,DRR648DFTMSE4,account_g7EjAfDhcTH9oxWZGJ4CTH,Steve Raia,291,exact +27896,twcowley,TW486TW,account_h2bYtWBz1nbRRVBiYvi8wQ,T W Cowley,710,exact +27888,joepatton,L7luYzSKSu,account_hWsePAjG5pq8FnnDQm75GD,Joe Patton,701,exact +27873,timfisher,Thd73gdf5eF,account_hMQqEcyWpzLTEtUnCJmKsi,Tim Fisher,345,exact +27847,kileigheven,K9872gdf8E,account_4aPqi935STSGBSMxsLNwSm,Kileigh Even,215,exact +27833,deannecook,Dc872cobnC,account_hqsfU7P5wK2uDjpfVo8zxb,Deanne Cook,110,exact +27830,kacyharrison,7Ozr7el6K1,account_t9Bb9uXEB9NjGoTDvCThi4,Kacy Harrison,643,exact +27822,milliganwater,8NjGMNsTXd,account_7FafmmaEuKaNeaHmFFLSaf,Milligan Water,636,exact +27813,michaelnewby,TM5M3HU877,account_1moqtbSRAJxiVR2pTtH3f9,Michael Newby,623,exact +27808,ladonnaclark,YQReVH9men,account_vPgQBpVJ4y2gkpRgWTksvi,Ladonna Clark,612,exact +27800,jamesmooney,QNUqD2tSzq,account_5C4WjLaQS5tGDxZm4NCZds,James Mooney,603,exact +27783,tamythomas,TT4699520858tt,account_4gF4CqD6W7iS3UesE6js3g,Tamy Thomas,587,exact +27780,josephcruz,Z06oxJXrNd,account_nYfEqA9HUY6R2xi1PvCQVs,Joseph Cruz,583,exact +27761,franceskirby,FK8179391152fk,account_v2eMTZjQzV5Q3B2vLxGCpa,Frances Kirby,568,exact +27759,chadwhitsell,79374B75phGy,account_swiMEEPxqsGukTQMUEP7DR,Chad Whitsell,61,exact +27754,ruthfengler,8q8MX6NQP3b6,account_2ycJgX1XpXSqDiDgNisZbA,Ruth Fengler,304,exact +27751,bobbyfisher,salvagejuggle,account_iuZZQ7KJWWSihZh9mzi2B8,Bobby Fisher,391,exact +27748,eddieyarbrough,89298ChZWzMy,account_6KuhoNq2CptmBHyHVS33cS,Eddie Yarbrough,125,exact +27744,chrissyeagle,9vkX97426Sgt,account_3uu7k4euycmKnZii9DQAj2,Chrissy Eagle,74,exact +27739,victoriaobier,M57B288P9,account_cPSKw37hjbGmmpHzzHUum1,Victoria Obier,371,exact +27736,bernardheer,BH33682019BER,account_fcUL4WRuUX7LrvPhjfQZQt,Bernard Heer,46,exact +27734,garycable,M2w6z5Y73,account_6JkDjyFzfp7pPcvn6tsd9f,Gary W Cable,144,exact +27735,coyungemach,MhS569N84e7a,account_f5ygK53FvcVakZs4YwNVo5,Coy Ungemach,84,exact +27733,michaelmccauley,CPORADIO98,account_ecewjvtr229nzEjFgSEhf5,Michael McCauley,259,exact +27731,lessruble,9U35SXxM2H8y,account_6M1cjjzHDyrtJWimoD6Tgp,Less Ruble,226,exact +27730,marknunnelee,833j2Ymm4yfV,account_mBYcBCnbFTgVxsDNCUrq9z,Mark Nunnelee,243,exact +27729,elizabethchristian,Webg22nj,account_3Xpb7LLq4bEFZxohgYvjUS,Elizabeth Christian,128,exact +27724,mikebell,BHF538HSEBDLY4,account_4vuDbTF964RUD2YKA5hmhQ,Mike Bell,556,exact +27713,krystabates,KB2145445824kb,account_crxqAidS35hAeKLFDHKmiy,Krysta Bates,218,exact +27708,chrisfrancis,DR45QC74M,account_pcYVJfTDAUK9ALviS7XBjW,Chris Francis,388,exact +27701,markfisher,32fd8P&rw8s7,account_hxsSqNHZMtFbXDA8DtvRso,Mark Fisher,242,exact +27696,kirkvanmeter,J2sR9g^83M33,account_bH9zyBQuBcgkiU9EZPYxa5,Kirk Vanmeter,216,exact +27694,dananance,23SP$e!ug232,account_s9ZW1FpS3wkDoh31CjU1Zw,Dana Nance,89,exact +27690,keithcrank,2zt3T5w72T3A,account_iSuzVkNmem52NdXXh1Savu,Keith Crank,209,exact +27687,scottarmstrong,2SV58M55,account_dCvreFY2K3nJW8ZkCPaeyR,Scott Armstrong,315,exact +27683,floydday,Tfdd3254tfD,account_rFM2cz415W3Rqn1McU5gpW,Floyd Day,141,exact +27680,mariacortes,MC2145858276mc,account_t4Qnzxsq3c2WryFDXTSWba,Maria Cortes,435,exact +27668,christianlummus,K6dbr631ddL,account_fWd5egSy6XovRRzTgi5xZk,Christian Lummus,205,exact +27666,marklindsey,ML9727346434ml,account_ayztehQeAayBTQkizozi2B,Mark Lindsey,531,exact +27658,joeywhitfield,BV758qXne446,account_8bxr54hmvSEFRyDLfvCgie,Joey Whitfield,187,exact +27654,taekim,SAN11837TCK,account_wbsKtfE9W1SyPkqk9hFBRp,Tae Kim,408,exact +27652,teresarobinson,T645trdf87fR,account_2SA7BzkR6cqsS2jSQ14nRh,Teresa Robinson,376,exact +27651,nathanmctee,N32462uu8rM,account_2CZKhrkB2TxZDQTWxuJxVz,Nathan McTee,273,exact +27646,rebekahmoore,R756346tfg4M,account_k8gD8YGUQzwWCE7JT9EfYc,Rebekah Moore,386,exact +27643,monicatrevino,MT2145877143mt,account_d3CqUjkDwdiB1pwR6t66XL,Monica Trevino,524,exact +27640,rachelfuller,R8364hbst,account_t7qMv9PTBxtj1rmr4WmEqV,Rachel Fuller,286,exact +27638,shannonbrown,S9725298223b,account_nZ8roCqzpsY5Wv86p8i6Sq,Shannon Brown,318,exact +27637,allentaylor,A6346gge8T1,account_44F55EHwRrLm33EvyuCPgG,Allen Taylor,26,exact +27632,kristenaustin,KA8017916972ka,account_qunWwkhDGN8jxGDMfB9FzA,Kristen Austin,522,exact +27630,colinhopper,TYVU65445IUB,account_jaGX2dnVZVs463ok5kANR5,Colin Hopper,245,exact +27619,srireddy,ycHAvetmF0,account_jS3WVFZmrQu5EoM7UJ4L1N,Sri Reddy,514,exact +27615,almaacosta,ENKC5DGV,account_rv1k2oTH5Dn7sDTJiTxADa,Alma Acosta,28,exact +27614,chrispassonno,C8766thf96P,account_8UMvyYLatGW2qwSqH62KbM,Chris Passonno,73,exact +27613,seanbaxter,SB4696679337sb,account_vFwXqaiaquwHXzgMkdg1mc,Sean Baxter,512,exact +27612,collinhartman,B886tg98jt9L,account_kvBUopRRFzz8B5PgbdjLGG,Collin Hartman,402,exact +27610,michaelhughes,1RKk32MyTK,account_6QT3zjSA88VLdbBJtAt44j,Michael Hughes,510,exact +27608,michaeltalbot,Mjj1276rt99T,account_4bK2RJU3FRwyH4SPmnik1q,Michael Talbot,262,exact +27606,anagonzalez,WNAHDMwWoE,account_3jfw5hMXURMmVuGyafdSPj,Ana Gonzalez,507,exact +27604,leonardlewis,L1877ef32L,account_3TVj7ZpGiTYYt28q8dh9VR,Leonard Lewis,224,exact +27598,jerryjohnson,J177895aa98J,account_fa8emiMvGC9ZkdA6uuwMpF,JERRY L JOHNSON,177,exact +27594,pamtoler,NfxcgYQUbj,account_mnxwgFcvbjvDjnBBdGQzek,Pam Toler,501,exact +27589,jodymccluskey,JM4696883349jm,account_35QGS4ZHr9tSpCVKNHE1ZT,Jody McCluskey,497,exact +27577,chrisvannatta,!4694502484!,account_dyfr7aKWGD5zJ1pdeEyyna,Chris Vannatta,426,exact +27575,mariomerlo,!2149579585!,account_3yQbySMiwrU5kFJRA8itm6,Mario Merlo,239,exact +27572,williamarmstrong,!2145510334!,account_5izwKtCxbvkCupVQJ1KBBE,William Armstrong,362,exact +27571,trecedaschmid,!4692075942!,account_iR4oM82C6m8zkrFDLkSbYZ,Treceda Schmid,355,exact +27570,ronlewis,JDkf1AL942,account_5Y7chdsykaLwBg3s2rqvMD,Ron Lewis,302,exact +27567,sherryerichardson,DR6528BCD3S2,account_oadyDfiynqanaMffoSVM2W,Sherrye Richardson,321,exact +27566,joannarodriguez,394X3YR9,account_gCVrkxTWdSWaYUNtyk1rqr,Joanna Rodriguez,185,exact +27561,dannycampbell,!9726589541!,account_4ww28eUsFRv8Yh5RkWe9cz,Danny Campbell,92,exact +27560,howardcomstock,!3365494230!,account_kZMVM4zfKdgEAyCCvVREU8,Howard Comstock,156,exact +27555,terrymiesen,!4697420828!,account_bXRRk1Yz4nKkCPsdzDJyYa,Terry Miesen,339,exact +27550,michaelray,primitivedefinitionstoredinner,account_8Rh7edwTjNBYw9FJSq4WRT,Michael Ray,379,exact +27545,terrybates,!2145786644!,account_d6m5HYMAczoeH2ovA6vqxj,Terry Bates,490,exact +27538,gregmcintire,v9368$E9mx9m,account_fyH3ZiVVLx9h56vNYVndbK,Greg McIntire,380,exact +27543,beverlyerwin,MOOLAH,account_9kYh3vsMa7mG8JrGaQtF2V,Beverly Erwin,48,exact +27518,helenlumpkin,Heehhejd0N,account_ts53K9Gc61HkXi7uqcaiN6,Helen Lumpkin,477,exact +27508,stevechristiaens,zY272m258*vJ,account_3HSd4Thc3iXfcidraN6ZSV,Steve Christiaens,329,exact +27505,judydevine,OD4697669725od,account_e8ZUaWmpXqA5NTVfBHaYfq,Judy Devine,277,exact +27500,rhondabolton,RB2145786030rb,account_x8xohdUBJBWyd87jTCtP3r,Rhonda Bolton,295,exact +27492,rickbeckham,tVj7ZI5QA0,account_f861cgYw7zxSiyUh3ngUYk,Rick Beckham,464,exact +27490,vancepeltonen,V66rdf43aaP,account_9arNHyqn6bwuUyPm4uWfrx,Vance Peltonen,356,exact +27486,jacobwilliams,jnlka9994jkfldsa,account_bFr4VtSNkJpdZv3K67mP5y,Jacob Williams,159,exact +27478,classicshear,KARENALDERSON,account_tui6mE1CTprghAF31hKWLp,Classic Shear,400,exact +27477,davekiowski,FNW63KXSAPT7,account_xr9LPLBvR6SHF2xmNdrNfN,Dave Kiowski,100,exact +27458,paulsammataro,P2n74V7P8g4S,account_cwYTP5p9ubFdXioqnmuDsy,Paul Sammataro,283,exact +27460,janetkern,j42252^Bhf44,account_skzmJw1vJfndNPQNWsJjRL,Janet Kern,449,exact +27457,carlossalaises,28E358y$JFB9,account_5mD5W5PczTzo9H7it3qxLX,Carlos Salaises,448,exact +27453,obedsandoval,*H78z9xy84C&,account_k6qzzjxrJtH4UT7BerwBzk,Obed Sandoval,275,exact +27455,dianewiley,Diane03t4&adTL,account_n9yXaEBkPmAm5SdrbTwsTw,Diane Wiley,442,exact +27444,tammylove,f65NS4Ct6$29,account_5WZN3aLBoWBUHeEjHYySgw,Tammy Love,335,exact +27438,floydallen,V3864VB2,account_9oLnGJZwNxJTrJRrQgCX59,Floyd Allen,140,exact +27432,hortenciaalvarez,WHTABORG3593JKS,account_oNBXVRFamuurD1rz4bugiE,Hortencia Alvarez,155,exact +27424,timbagert,CF18n42cY7,account_2U67Q5pWZCgbWGX1tLXbXD,Tim Bagert,343,exact +27425,davidbaker,DB764g1d87rtB,account_okpeaMgrsz9nNs1cSMjQv3,David Baker,102,exact +27421,janicealexander,AG57TSH89L9,account_pdQ864pRJ5AbJP4YHtv1S5,Janice Alexander,165,exact +27423,timgilbert,73xi0r600Sz%,account_ezDVFeaw9XwLiVvM9iEDwJ,Tim Gilbert,346,exact +27413,jesusrios,8Di6QMs7%7m1,account_5RA5zrZJXyuBEj4CmuodCh,Jesus Rios,181,exact +27409,veronabaptist,DAY753LIGHT253,account_j9cuBn7ojxwRH3BfRWmDhN,Verona Baptist,382,exact +27406,whiteywhite,45z6r*H4418n,account_32CUFBTH16juiHGzrKPd7m,Whitey White,303,exact +27402,louislafon,RFTRE4825CVKL,account_9ayF1oF9iawPMpCdQSiSqX,Louis Lafon,445,exact +27397,ausencioluna,ABN765MNL,account_7Gj6TBTXHmpu49iZGZBYT4,Ausencio Luna,427,exact +27374,davidvillanueva,DV5489CRPMWBQ,account_d5hcuzxZBgMErVfhrXzQAJ,David Villanueva,109,exact +29621,coryrees,yt5c6ujhvu,account_uRC7LnETNNZdRmSEQeQbwX,cory rees,10049,exact +29622,brianamartinez,ktydr6428,account_gazJNXCsEwG1UJBmYyQ6xB,Briana Martinez,10051,exact +29623,khushbooagarwal,UUTFVUCTC446,account_5xtd1ApTppgpHnRpvmQHmV,Khushboo Agarwal,10052,exact +29624,elisasanders,OYTCu3276,account_hZD7ECmwCWd5vG73hEYDUN,Elisa Sanders,10054,exact +29625,josemedrano,UYVITRUR24,account_3froYZwHV4X7v9wW6TVu8N,Jose Medrano,10055,exact +29627,nicholasterry,VUTYVL746,account_wSzw7Xd3wb871Ug9aaqUcH,Nicholas Terry,10059,exact +29628,leslieperalta,RYC66747,account_dQfUEUmdqUu6rGALsoowVQ,Leslie Peralta,10061,exact +29629,khushbooagarwal2,iytf65gkj,account_j2iH6cW5tFhsSKxjLUvQBb,Khushboo Agarwal 2,10062,exact +29631,royrogers,TYDGVBNC,account_nCAKAgLxtPx5H68AJZMNUA,Roy Rogers,10065,exact +29632,arturoprieto,1653APVN877,account_pR6sTHc4xvLqPfAZhJxrxd,Arturo Prieto,10069,exact +29633,ericamorris,E16488VN387,account_gG5uDHsz8FPiuknRCkpbHQ,Erica Morris,10071,exact +29617,belindamillener,Uiyu7280,account_mXx2EoT7dcv2am6asSEJFr,Belinda Millener,10032,exact +29618,kailynnbarnfield,HAkuguuvoy1,account_hVh2CveE2tBqctZhpLX5Nf,Kailynn Barnfield,10037,exact +29619,thomasgraham,YUiygiiu4,account_fETWiC2bAqbb7hkQ5UvKjj,Thomas Graham,10040,exact +27315,debravega,RCG8618BDUMSR,account_fbgziTjDnsrjH4aA2hMykS,Debra Vega,112,exact +29582,davidlanman,2BMMRYQ2PHEA,account_u9P7hnf3FR5yqm7FFyCnrP,David Lanman,2486,exact +29583,anthonylongo,9MLG8HVME4J3,account_eq2Po3eJPoEyv6RvhhyBMA,Anthony Longo,2487,exact +29584,konradwoelffer,BCQ8NRFVNA3R,account_7B7CchNkRbNa6LLxRBform,Konrad Woelffer,2488,exact +29585,marthaflores,KG2QEG36348V,account_cdbWPX28Kv3YSaYUDf7zj1,Martha Flores,2489,exact +29586,connorspicer,6U2BWJKGCX8G,account_pV5Vstq2xr42fru5zVB28F,Connor Spicer,2490,exact +29588,korybiggsshop,JA4SXBGKHFUL,account_irdUq6W33yA7J9vpQ5t85q,Kory Biggs Shop,2492,exact +29589,ednagriffin,AXA8EZEAN7DJ,account_nnSEFSX86bVogx8KsqANUP,Edna Griffin,2493,exact +29590,richardrosson,9J5L6MEH4Y7Y,account_jL9tR5FgswV7FZwxH3U5Ex,Richard Rosson,2526,exact +29603,allentaylor2,NCJW87FW8723,account_sEcueqC1ysh7Ne2pE9qbg2,Allen Taylor #2,10009,exact +29604,alextovias,7VSgrVO1WX,account_1BvWee8L5krmWQsfVUqCdk,Alex Tovias,476,exact +29605,mikecurrence,MKH23FG79C,account_gbzPYU8zHeCSV7E5c5eksc,Mike Currence,10011,exact +29594,terryrector,QUPP83ZUUSGL,account_bHx8uSNfzCrYkD8JriK1jV,Terry Rector,2529,exact +29595,donnacampbell,KYCNTWS3929N,account_4y4ZEDFYqQhjW99pPJyLnf,Donna Campbell,2530,exact +29600,billmctee,Bdge6388M,account_qdzi3bYpEjEx7crLFY4xCw,Bill McTee,373,exact +29531,daycor,U45YBPSARH7N,account_aZmcKqobMWGKhxagDdLCFA,Cathy Day,755,fuzzy(manual_override) +29521,tiffanyyarley-olenak,98HTZ9Q8FARG,account_cwmjmXkrYeba8h8RGqLm6E,Tiffany Yarley-Olenak,2393,fuzzy(sequence: 0.95) +29395,vanessagomez1,J5JEW3HEBVAF,account_gb82EhqTwbNWWEovPu4oKZ,Vanessa Gomez,2264,fuzzy(last+first: gomez+vanessa(fn=1.00)) +29611,brittannieredden1,TYFTUEZ7429,account_bB6RDmNpkzndVbSh9CvgaL,Brittannie Redden,10017,fuzzy(last+first: redden+brittannie(fn=1.00)) +29614,samanthatheis,kf6u8djigk,account_x46hMNq15qHmEvP6fNFdfm,Samatha Theis,10023,fuzzy(last+first: theis+samatha(fn=0.70)) +29216,wanxiaxong,BER4NEK9M7NA,account_tiy5YHD34KpwPzUTXyRVFJ,Wanxia Song,2077,fuzzy(sequence: 0.86) +29215,bryangoulart,DTNQFE4CXSFX,account_9sksoKmcJGxqhz4M4aD169,Brian Goulart,2076,fuzzy(last+first: goulart+brian(fn=0.70)) +29602,tech,brussels-crucial-smashing,account_jUFnZ52urgAMNgp9XhT4bs,Graham McIntire,1,fuzzy(manual_override) +28935,chrislewis1,V9DS4E4Y7UTT,account_osB3NhyGdoWJRtpL5X2bfD,chris lewis,1787,fuzzy(last+first: lewis+chris(fn=1.00)) +28917,crankkeith,kcrank10615,account_iSuzVkNmem52NdXXh1Savu,Keith Crank,209,fuzzy(last+first: crank+keith(fn=1.00)) +28703,grahammcintire2,U5Y3hA3g*14L,account_jUFnZ52urgAMNgp9XhT4bs,Graham McIntire,1,fuzzy(last+first: mcintire+graham(fn=1.00)) +28702,chrissyeagle2,NJPCGPBNFB,account_3uu7k4euycmKnZii9DQAj2,Chrissy Eagle,74,fuzzy(last+first: eagle+chrissy(fn=1.00)) +28671,melodymccarty,U3WLEGEB,account_18HG9hdwwtZM83J9kyGMs6,Melody McCarthy,1397,fuzzy(sequence: 0.93) +28642,barbihardin,ZENETJ75TEVQ,account_bb7iRuPixxutKZxnURfK5U,James Hardin,1475,fuzzy(manual_override) +28628,jackworthy,2VUR7AAFWZZQ,account_hgoipVYXydD1yVSXTD9QGP,John Worthy,1461,fuzzy(last+first: worthy+john(fn=0.70)) +28542,michaelray2,jjPgDcotL6rtY,account_8Rh7edwTjNBYw9FJSq4WRT,Michael Ray,379,fuzzy(last+first: ray+michael(fn=1.00)) +28458,kennethcampbell2,V5TKW8L6RXFF,account_jF69QnP5j9FtfLgdAiUKfE,Kenneth D. Campbell,213,fuzzy(last+first: campbell+kenneth d(fn=1.00)) +28296,cinthiapereira1,9WSGXM9LBXGZ,account_57mDq22QWRV8tdPvk74Uvj,Cinthia Pereira,1102,fuzzy(last+first: pereira+cinthia(fn=1.00)) +28107,jacquelinewilder,HEY36LUGYCUQ,account_hHa9Z64B9okZ3yccQP9WpR,Jacquelin Wilder,892,fuzzy(last+first: wilder+jacquelin(fn=1.00)) +27939,reynamoore,R92nbskba7iM,account_k8gD8YGUQzwWCE7JT9EfYc,Rebekah Moore,386,fuzzy(last+first: moore+rebekah(fn=0.70)) +27912,blancadiaz,B548gdu55D,account_ktdNSpKRV3ja7RMi7pZRBB,Balnca Diaz,433,fuzzy(last+first: diaz+balnca(fn=0.70)) +27838,consueloluna1830,C8736gfd0,account_jw16PcKtprHpPkSAEhvsaN,Consuelo Luna,81,fuzzy(last+first: luna+consuelo(fn=1.00)) +27781,pambanschbach,Q2p68#*9vwF2,account_6hGXtJ7RV7mqZYfd5AyE1f,Pam Bansbach,383,fuzzy(sequence: 0.88) +27639,choonpang,CP2143855677cp,account_o7aKk2FGKJAcmrXxMfUCpX,Geok Choon Pang,146,fuzzy(last+first: pang+geok choon(fn=1.00)) +27618,tammieventris,DR45QC74M,account_fMQ6qiwkPFWP8a86exFw4A,Terry Ventris,340,fuzzy(last+first: ventris+terry(fn=0.70)) +27579,donnance,9727346814,account_s9ZW1FpS3wkDoh31CjU1Zw,Dana Nance,89,fuzzy(last+first: nance+dana(fn=0.70)) +27501,jonnytaylor,Zb6677&4!yky,account_1fDruGS6CFTBUCGuzVZYTG,Johnny Taylor,190,fuzzy(last+first: taylor+johnny(fn=0.70)) +27436,clayrobertson2,C2147288546R,account_jgtX7TysTqcobVVBtSZEc2,Clay Robertson,77,fuzzy(last+first: robertson+clay(fn=1.00)) +27417,donniecampbell,6a2671bc0c1,account_4ww28eUsFRv8Yh5RkWe9cz,Danny Campbell,92,fuzzy(last+first: campbell+danny(fn=0.70)) +27418,alanwyatt,i1412r53MYr,account_2cnVCpSsYocCYpMEwwBBrY,Allen Wyatt,27,fuzzy(last+first: wyatt+allen(fn=0.70)) +29620,sayedahaque,YR4Sytbu,account_4hoTCjwTatthCg7zp2Cnfe,Syeda Haque,10048,fuzzy(last+first: haque+syeda(fn=0.70)) +29554,marcuspatton,GEUXX27VLZXK,account_dxY6AZnLdFUWup8Sf6XER1,Marcus Patton,2458,ambiguous_resolved(lowest_id) +29606,mariacastanon,iu297gkid9,account_qh8bFPvWzyAqHqwHMDkThW,Maria Castanon,10007,ambiguous_resolved(lowest_id) +28800,kennethcampbell,!9727342446!,account_jF69QnP5j9FtfLgdAiUKfE,Kenneth D. Campbell,213,ambiguous_resolved(lowest_id) +28358,stephenday,MSVGQ8X38YLN,account_j1UFX8wqzorqSetUje5dTJ,Stephen Day,1168,ambiguous_resolved(lowest_id) +27404,grahammcintire,U5Y3hA3g*14L,account_jUFnZ52urgAMNgp9XhT4bs,Graham McIntire,1,ambiguous_resolved(lowest_id) +29630,sriahobilamuth,RTCESW24,account_7PgSr8dVuafybJdrXND3V9,Sri Ahobilamuth,113,ambiguous_resolved(lowest_id) +29524,chandparvathaneni,TK4QA3XM5XK5,,,,"still_unmatched(best: , score: 0.0)" +29358,radiantlifeministries,12FDIUNF5L7vM,,,,"still_unmatched(best: , score: 0.0)" +29296,midvalleyconcrete,BD58JYT2PJ6A,,,,"still_unmatched(best: , score: 0.0)" +29244,janiscable,JHC7U554JBTR,,,,"still_unmatched(best: last+weakfirst: cable+gary w(fn=0.11), score: 0.611)" +29150,donwilhelmus,2V4NEL87RM39,,,,"still_unmatched(best: , score: 0.0)" +29135,kavalleriefarm,8DFNNTPBPZGY,,,,"still_unmatched(best: , score: 0.0)" +29077,kellygarza,4696591662,,,,"still_unmatched(best: last+weakfirst: garza+esparanza(fn=0.42), score: 0.642)" +29070,1stchoiceliving,YVWJWRPEVYNH,,,,"still_unmatched(best: , score: 0.0)" +28879,kimberlyrichards,7PQA6J8RPZ9Z,,,,"still_unmatched(best: last+weakfirst: kim+tae(fn=0.08), score: 0.608)" +28599,ryanrodriguez,9DFYWJ47YWUP,,,,"still_unmatched(best: last+weakfirst: rodriguez+mike(fn=0.19), score: 0.619)" +28460,saidaacosta,V29897X4,,,,"still_unmatched(best: last+weakfirst: acosta+alma(fn=0.21), score: 0.621)" +28422,jackiehendricks,UQR5HGFCJ4XX,,,,"still_unmatched(best: last+weakfirst: hendricks+sue(fn=0.09), score: 0.609)" +28400,bonniestone,BH8BV44TTWUU,,,,"still_unmatched(best: , score: 0.0)" +28336,buddyswan,N7XUYUD7J9SD,,,,"still_unmatched(best: , score: 0.0)" +28268,alberto,TICOGUILLO,,,,"still_unmatched(best: , score: 0.0)" +28213,krisdougherty,RWEH75UJ38,,,,"still_unmatched(best: , score: 0.0)" +27955,evlynrichter,FRQD738BGRC,,,,"still_unmatched(best: last+weakfirst: richter+brent(fn=0.19), score: 0.619)" +27793,branchrvpark,XfKxVZIKmm,,,,"still_unmatched(best: , score: 0.0)" +29601,kimberlyrichards2,K84f7r10GT108,,,,"still_unmatched(best: last+weakfirst: kim+tae(fn=0.08), score: 0.608)" +27755,gordonhamilton,c3495Rpuq5Sh,,,,"still_unmatched(best: last+weakfirst: hamilton+sheryl f(fn=0.16), score: 0.616)" +27709,sensibleheatsystems,SHQWERY23,,,,"still_unmatched(best: , score: 0.0)" +27704,lambandlion,LL9727363567ll,,,,"still_unmatched(best: , score: 0.0)" +27625,mattbud,kjklfda79sd,,,,"still_unmatched(best: , score: 0.0)" +27621,richardhill,RH9729788124rh,,,,no_match +27489,jenniferreed,683P7V7R,,,,"still_unmatched(best: , score: 0.0)" +27482,irvinbarojas,yDeEJzYY08,,,,"still_unmatched(best: , score: 0.0)" +27463,dennispereyra,82s62kd9XHhd,,,,"still_unmatched(best: , score: 0.0)" +27441,samanthasmith,22jmR9X#93j5,,,,"still_unmatched(best: last+weakfirst: smith+kathy(fn=0.27), score: 0.627)" +27408,penneywarner,b4h53N6c#5wo,,,,"still_unmatched(best: , score: 0.0)" +29626,karengreen,UTKCYI674,,,,"still_unmatched(best: last+weakfirst: green+dominique(fn=0.17), score: 0.617)" diff --git a/gaiia/radius_gaiia_unmatched.csv b/gaiia/radius_gaiia_unmatched.csv new file mode 100644 index 0000000..18b4079 --- /dev/null +++ b/gaiia/radius_gaiia_unmatched.csv @@ -0,0 +1,68 @@ +radius_id,username,password,gaiia_id,gaiia_name,gaiia_readable_id,match_type +29554,marcuspatton,GEUXX27VLZXK,,"AMBIGUOUS: Marcus Patton, Marcus Patton",,ambiguous +29524,chandparvathaneni,TK4QA3XM5XK5,,,,no_match +29521,tiffanyyarley-olenak,98HTZ9Q8FARG,,,,no_match +29606,mariacastanon,iu297gkid9,,"AMBIGUOUS: Maria Castanon, Maria Castanon",,ambiguous +29395,vanessagomez1,J5JEW3HEBVAF,,,,no_match +29611,brittannieredden1,TYFTUEZ7429,,,,no_match +29614,samanthatheis,kf6u8djigk,,,,no_match +29358,radiantlifeministries,12FDIUNF5L7vM,,,,no_match +29296,midvalleyconcrete,BD58JYT2PJ6A,,,,no_match +29244,janiscable,JHC7U554JBTR,,,,no_match +29216,wanxiaxong,BER4NEK9M7NA,,,,no_match +29215,bryangoulart,DTNQFE4CXSFX,,,,no_match +29150,donwilhelmus,2V4NEL87RM39,,,,no_match +29135,kavalleriefarm,8DFNNTPBPZGY,,,,no_match +29077,kellygarza,4696591662,,,,no_match +29070,1stchoiceliving,YVWJWRPEVYNH,,,,no_match +29602,tech,brussels-crucial-smashing,,,,no_match +28935,chrislewis1,V9DS4E4Y7UTT,,,,no_match +28917,crankkeith,kcrank10615,,,,no_match +28879,kimberlyrichards,7PQA6J8RPZ9Z,,,,no_match +28800,kennethcampbell,!9727342446!,,"AMBIGUOUS: Kenneth D. Campbell, Kenneth Campbell",,ambiguous +28703,grahammcintire2,U5Y3hA3g*14L,,,,no_match +28702,chrissyeagle2,NJPCGPBNFB,,,,no_match +28671,melodymccarty,U3WLEGEB,,,,no_match +28642,barbihardin,ZENETJ75TEVQ,,,,no_match +28628,jackworthy,2VUR7AAFWZZQ,,,,no_match +28599,ryanrodriguez,9DFYWJ47YWUP,,,,no_match +28542,michaelray2,jjPgDcotL6rtY,,,,no_match +28460,saidaacosta,V29897X4,,,,no_match +28458,kennethcampbell2,V5TKW8L6RXFF,,,,no_match +28422,jackiehendricks,UQR5HGFCJ4XX,,,,no_match +28400,bonniestone,BH8BV44TTWUU,,,,no_match +28358,stephenday,MSVGQ8X38YLN,,"AMBIGUOUS: Stephen Day, Stephen Day",,ambiguous +28336,buddyswan,N7XUYUD7J9SD,,,,no_match +28296,cinthiapereira1,9WSGXM9LBXGZ,,,,no_match +28268,alberto,TICOGUILLO,,,,no_match +29531,daycor,U45YBPSARH7N,,,,no_match +29630,sriahobilamuth,RTCESW24,,"AMBIGUOUS: Sri Ahobilamuth, Sri Ahobilamuth",,ambiguous +27408,penneywarner,b4h53N6c#5wo,,,,no_match +29620,sayedahaque,YR4Sytbu,,,,no_match +29626,karengreen,UTKCYI674,,,,no_match +27404,grahammcintire,U5Y3hA3g*14L,,"AMBIGUOUS: Graham McIntire, Graham McIntire, Graham McIntire",,ambiguous +27417,donniecampbell,6a2671bc0c1,,,,no_match +27418,alanwyatt,i1412r53MYr,,,,no_match +27441,samanthasmith,22jmR9X#93j5,,,,no_match +27436,clayrobertson2,C2147288546R,,,,no_match +27489,jenniferreed,683P7V7R,,,,no_match +27501,jonnytaylor,Zb6677&4!yky,,,,no_match +27482,irvinbarojas,yDeEJzYY08,,,,no_match +27463,dennispereyra,82s62kd9XHhd,,,,no_match +27579,donnance,9727346814,,,,no_match +27838,consueloluna1830,C8736gfd0,,,,no_match +27912,blancadiaz,B548gdu55D,,,,no_match +27709,sensibleheatsystems,SHQWERY23,,,,no_match +27704,lambandlion,LL9727363567ll,,,,no_match +27625,mattbud,kjklfda79sd,,,,no_match +27639,choonpang,CP2143855677cp,,,,no_match +127621,richardhill,RH9729788124rh,,,,no_match +27618,tammieventris,DR45QC74M,,,,no_match +27955,evlynrichter,FRQD738BGRC,,,,no_match +27939,reynamoore,R92nbskba7iM,,,,no_match +27755,gordonhamilton,c3495Rpuq5Sh,,,,no_match +27793,branchrvpark,XfKxVZIKmm,,,,no_match +27781,pambanschbach,Q2p68#*9vwF2,,,,no_match +29601,kimberlyrichards2,K84f7r10GT108,,,,no_match +28213,krisdougherty,RWEH75UJ38,,,,no_match +28107,jacquelinewilder,HEY36LUGYCUQ,,,,no_match diff --git a/gaiia/radius_usernames.json b/gaiia/radius_usernames.json new file mode 100644 index 0000000..760387d --- /dev/null +++ b/gaiia/radius_usernames.json @@ -0,0 +1,2687 @@ +[ + { + "accountId": "account_jUFnZ52urgAMNgp9XhT4bs", + "accountName": "Graham McIntire", + "radiusUsernames": [ + "grahammcintire2", + "grahammcintire" + ] + }, + { + "accountId": "account_7zjMVqCfCV4jZT68myj6p1", + "accountName": "Brian Hardesty", + "radiusUsernames": [ + "brianhardesty" + ] + }, + { + "accountId": "account_44F55EHwRrLm33EvyuCPgG", + "accountName": "Allen Taylor", + "radiusUsernames": [ + "allentaylor" + ] + }, + { + "accountId": "account_2cnVCpSsYocCYpMEwwBBrY", + "accountName": "Allen Wyatt", + "radiusUsernames": [ + "alanwyatt" + ] + }, + { + "accountId": "account_rv1k2oTH5Dn7sDTJiTxADa", + "accountName": "Alma Acosta", + "radiusUsernames": [ + "almaacosta" + ] + }, + { + "accountId": "account_fcUL4WRuUX7LrvPhjfQZQt", + "accountName": "Bernard Heer", + "radiusUsernames": [ + "bernardheer" + ] + }, + { + "accountId": "account_9kYh3vsMa7mG8JrGaQtF2V", + "accountName": "Beverly Erwin", + "radiusUsernames": [ + "beverlyerwin" + ] + }, + { + "accountId": "account_k8GTscTwPuAvgtyCbT6vyG", + "accountName": "Brian Gallimore", + "radiusUsernames": [ + "briangallimore" + ] + }, + { + "accountId": "account_swiMEEPxqsGukTQMUEP7DR", + "accountName": "Chad Whitsell", + "radiusUsernames": [ + "chadwhitsell" + ] + }, + { + "accountId": "account_iNnHAwxaWRUXte6w7ijGV3", + "accountName": "Charles Boone", + "radiusUsernames": [ + "charlesboone" + ] + }, + { + "accountId": "account_8UMvyYLatGW2qwSqH62KbM", + "accountName": "Chris Passonno", + "radiusUsernames": [ + "chrispassonno" + ] + }, + { + "accountId": "account_3uu7k4euycmKnZii9DQAj2", + "accountName": "Chrissy Eagle", + "radiusUsernames": [ + "chrissyeagle", + "chrissyeagle2" + ] + }, + { + "accountId": "account_jgtX7TysTqcobVVBtSZEc2", + "accountName": "Clay Robertson", + "radiusUsernames": [ + "clayrobertson2" + ] + }, + { + "accountId": "account_jw16PcKtprHpPkSAEhvsaN", + "accountName": "Consuelo Luna", + "radiusUsernames": [ + "consueloluna1830" + ] + }, + { + "accountId": "account_f5ygK53FvcVakZs4YwNVo5", + "accountName": "Coy Ungemach", + "radiusUsernames": [ + "coyungemach" + ] + }, + { + "accountId": "account_s9ZW1FpS3wkDoh31CjU1Zw", + "accountName": "Dana Nance", + "radiusUsernames": [ + "dananance", + "donnance" + ] + }, + { + "accountId": "account_4ww28eUsFRv8Yh5RkWe9cz", + "accountName": "Danny Campbell", + "radiusUsernames": [ + "dannycampbell", + "donniecampbell" + ] + }, + { + "accountId": "account_xr9LPLBvR6SHF2xmNdrNfN", + "accountName": "Dave Kiowski", + "radiusUsernames": [ + "davekiowski" + ] + }, + { + "accountId": "account_okpeaMgrsz9nNs1cSMjQv3", + "accountName": "David Baker", + "radiusUsernames": [ + "davidbaker" + ] + }, + { + "accountId": "account_d5hcuzxZBgMErVfhrXzQAJ", + "accountName": "David Villanueva", + "radiusUsernames": [ + "davidvillanueva" + ] + }, + { + "accountId": "account_hqsfU7P5wK2uDjpfVo8zxb", + "accountName": "Deanne Cook", + "radiusUsernames": [ + "deannecook" + ] + }, + { + "accountId": "account_fbgziTjDnsrjH4aA2hMykS", + "accountName": "Debra Vega", + "radiusUsernames": [ + "debravega" + ] + }, + { + "accountId": "account_7PgSr8dVuafybJdrXND3V9", + "accountName": "Sri Ahobilamuth", + "radiusUsernames": [ + "sriahobilamuth" + ] + }, + { + "accountId": "account_5n5qjvMULWVYcGGmF7uiER", + "accountName": "Doreen Grubb", + "radiusUsernames": [ + "doreengrubb" + ] + }, + { + "accountId": "account_6KuhoNq2CptmBHyHVS33cS", + "accountName": "Eddie Yarbrough", + "radiusUsernames": [ + "eddieyarbrough" + ] + }, + { + "accountId": "account_fNtXkw98GQKVvcjcfbeb9F", + "accountName": "Ed Rater", + "radiusUsernames": [ + "edrater" + ] + }, + { + "accountId": "account_3Xpb7LLq4bEFZxohgYvjUS", + "accountName": "Elizabeth Christian", + "radiusUsernames": [ + "elizabethchristian" + ] + }, + { + "accountId": "account_9oLnGJZwNxJTrJRrQgCX59", + "accountName": "Floyd Allen", + "radiusUsernames": [ + "floydallen" + ] + }, + { + "accountId": "account_rFM2cz415W3Rqn1McU5gpW", + "accountName": "Floyd Day", + "radiusUsernames": [ + "floydday", + "daycor" + ] + }, + { + "accountId": "account_6JkDjyFzfp7pPcvn6tsd9f", + "accountName": "Gary W Cable", + "radiusUsernames": [ + "garycable" + ] + }, + { + "accountId": "account_o7aKk2FGKJAcmrXxMfUCpX", + "accountName": "Geok Choon Pang", + "radiusUsernames": [ + "choonpang" + ] + }, + { + "accountId": "account_96PDHh837oys4URb7LZMiR", + "accountName": "Sheryl F. Hamilton", + "radiusUsernames": [ + "gordonhamilton" + ] + }, + { + "accountId": "account_oNBXVRFamuurD1rz4bugiE", + "accountName": "Hortencia Alvarez", + "radiusUsernames": [ + "hortenciaalvarez" + ] + }, + { + "accountId": "account_kZMVM4zfKdgEAyCCvVREU8", + "accountName": "Howard Comstock", + "radiusUsernames": [ + "howardcomstock" + ] + }, + { + "accountId": "account_bFr4VtSNkJpdZv3K67mP5y", + "accountName": "Jacob Williams", + "radiusUsernames": [ + "jacobwilliams" + ] + }, + { + "accountId": "account_pdQ864pRJ5AbJP4YHtv1S5", + "accountName": "Janice Alexander", + "radiusUsernames": [ + "janicealexander" + ] + }, + { + "accountId": "account_fa8emiMvGC9ZkdA6uuwMpF", + "accountName": "JERRY L JOHNSON", + "radiusUsernames": [ + "jerryjohnson" + ] + }, + { + "accountId": "account_5RA5zrZJXyuBEj4CmuodCh", + "accountName": "Jesus Rios", + "radiusUsernames": [ + "jesusrios" + ] + }, + { + "accountId": "account_gCVrkxTWdSWaYUNtyk1rqr", + "accountName": "Joanna Rodriguez", + "radiusUsernames": [ + "joannarodriguez" + ] + }, + { + "accountId": "account_8bxr54hmvSEFRyDLfvCgie", + "accountName": "Joey Whitfield", + "radiusUsernames": [ + "joeywhitfield" + ] + }, + { + "accountId": "account_1fDruGS6CFTBUCGuzVZYTG", + "accountName": "Johnny Taylor", + "radiusUsernames": [ + "jonnytaylor" + ] + }, + { + "accountId": "account_itAy96jDYGcvWjcHsy6XcR", + "accountName": "Juan Alonzo", + "radiusUsernames": [ + "juanalonzo" + ] + }, + { + "accountId": "account_fWd5egSy6XovRRzTgi5xZk", + "accountName": "Christian Lummus", + "radiusUsernames": [ + "christianlummus" + ] + }, + { + "accountId": "account_bKdyNcZqZ1h8qaQAcJzTRK", + "accountName": "Keith Barber", + "radiusUsernames": [ + "keithbarber" + ] + }, + { + "accountId": "account_iSuzVkNmem52NdXXh1Savu", + "accountName": "Keith Crank", + "radiusUsernames": [ + "keithcrank", + "crankkeith" + ] + }, + { + "accountId": "account_jF69QnP5j9FtfLgdAiUKfE", + "accountName": "Kenneth D. Campbell", + "radiusUsernames": [ + "kennethcampbell2", + "kennethcampbell" + ] + }, + { + "accountId": "account_4aPqi935STSGBSMxsLNwSm", + "accountName": "Kileigh Even", + "radiusUsernames": [ + "kileigheven" + ] + }, + { + "accountId": "account_bH9zyBQuBcgkiU9EZPYxa5", + "accountName": "Kirk Vanmeter", + "radiusUsernames": [ + "kirkvanmeter" + ] + }, + { + "accountId": "account_crxqAidS35hAeKLFDHKmiy", + "accountName": "Krysta Bates", + "radiusUsernames": [ + "krystabates" + ] + }, + { + "accountId": "account_3TVj7ZpGiTYYt28q8dh9VR", + "accountName": "Leonard Lewis", + "radiusUsernames": [ + "leonardlewis" + ] + }, + { + "accountId": "account_6M1cjjzHDyrtJWimoD6Tgp", + "accountName": "Less Ruble", + "radiusUsernames": [ + "lessruble" + ] + }, + { + "accountId": "account_3yQbySMiwrU5kFJRA8itm6", + "accountName": "Mario Merlo", + "radiusUsernames": [ + "mariomerlo" + ] + }, + { + "accountId": "account_hxsSqNHZMtFbXDA8DtvRso", + "accountName": "Mark Fisher", + "radiusUsernames": [ + "markfisher" + ] + }, + { + "accountId": "account_mBYcBCnbFTgVxsDNCUrq9z", + "accountName": "Mark Nunnelee", + "radiusUsernames": [ + "marknunnelee" + ] + }, + { + "accountId": "account_jaGX2dnVZVs463ok5kANR5", + "accountName": "Colin Hopper", + "radiusUsernames": [ + "colinhopper" + ] + }, + { + "accountId": "account_hN5EYjS8Fc7wXM2icA9HZq", + "accountName": "Matt Budjenska", + "radiusUsernames": [ + "mattbud" + ] + }, + { + "accountId": "account_ecewjvtr229nzEjFgSEhf5", + "accountName": "Michael McCauley", + "radiusUsernames": [ + "michaelmccauley" + ] + }, + { + "accountId": "account_4bK2RJU3FRwyH4SPmnik1q", + "accountName": "Michael Talbot", + "radiusUsernames": [ + "michaeltalbot" + ] + }, + { + "accountId": "account_2CZKhrkB2TxZDQTWxuJxVz", + "accountName": "Nathan McTee", + "radiusUsernames": [ + "nathanmctee" + ] + }, + { + "accountId": "account_k6qzzjxrJtH4UT7BerwBzk", + "accountName": "Obed Sandoval", + "radiusUsernames": [ + "obedsandoval" + ] + }, + { + "accountId": "account_e8ZUaWmpXqA5NTVfBHaYfq", + "accountName": "Judy Devine", + "radiusUsernames": [ + "judydevine" + ] + }, + { + "accountId": "account_cwYTP5p9ubFdXioqnmuDsy", + "accountName": "Paul Sammataro", + "radiusUsernames": [ + "paulsammataro" + ] + }, + { + "accountId": "account_t7qMv9PTBxtj1rmr4WmEqV", + "accountName": "Rachel Fuller", + "radiusUsernames": [ + "rachelfuller" + ] + }, + { + "accountId": "account_tEZyHoiA8DvmisscRkYy7U", + "accountName": "Rachel Guzzardi", + "radiusUsernames": [ + "rachelguzzardi" + ] + }, + { + "accountId": "account_g7EjAfDhcTH9oxWZGJ4CTH", + "accountName": "Steve Raia", + "radiusUsernames": [ + "steveraia" + ] + }, + { + "accountId": "account_x8xohdUBJBWyd87jTCtP3r", + "accountName": "Rhonda Bolton", + "radiusUsernames": [ + "rhondabolton" + ] + }, + { + "accountId": "account_xfht7KCxBpasoRqtkTojjj", + "accountName": "Robby McCollom", + "radiusUsernames": [ + "robbymccollom" + ] + }, + { + "accountId": "account_q8QjfTJ9rtgoow3eHLpVYQ", + "accountName": "Ron Berger", + "radiusUsernames": [ + "ronberger" + ] + }, + { + "accountId": "account_5Y7chdsykaLwBg3s2rqvMD", + "accountName": "Ron Lewis", + "radiusUsernames": [ + "ronlewis" + ] + }, + { + "accountId": "account_32CUFBTH16juiHGzrKPd7m", + "accountName": "Whitey White", + "radiusUsernames": [ + "whiteywhite" + ] + }, + { + "accountId": "account_2ycJgX1XpXSqDiDgNisZbA", + "accountName": "Ruth Fengler", + "radiusUsernames": [ + "ruthfengler" + ] + }, + { + "accountId": "account_fUUU63KG1Jx4gyjusGuAy6", + "accountName": "Betsy Arceo", + "radiusUsernames": [ + "saidaacosta" + ] + }, + { + "accountId": "account_tCddLw595sDcQEYCrRCmFq", + "accountName": "Sandra Crouch", + "radiusUsernames": [ + "sandracrouch" + ] + }, + { + "accountId": "account_dCvreFY2K3nJW8ZkCPaeyR", + "accountName": "Scott Armstrong", + "radiusUsernames": [ + "scottarmstrong" + ] + }, + { + "accountId": "account_nZ8roCqzpsY5Wv86p8i6Sq", + "accountName": "Shannon Brown", + "radiusUsernames": [ + "shannonbrown" + ] + }, + { + "accountId": "account_oadyDfiynqanaMffoSVM2W", + "accountName": "Sherrye Richardson", + "radiusUsernames": [ + "sherryerichardson" + ] + }, + { + "accountId": "account_3HSd4Thc3iXfcidraN6ZSV", + "accountName": "Steve Christiaens", + "radiusUsernames": [ + "stevechristiaens" + ] + }, + { + "accountId": "account_5WZN3aLBoWBUHeEjHYySgw", + "accountName": "Tammy Love", + "radiusUsernames": [ + "tammylove" + ] + }, + { + "accountId": "account_bXRRk1Yz4nKkCPsdzDJyYa", + "accountName": "Terry Miesen", + "radiusUsernames": [ + "terrymiesen" + ] + }, + { + "accountId": "account_fMQ6qiwkPFWP8a86exFw4A", + "accountName": "Terry Ventris", + "radiusUsernames": [ + "tammieventris" + ] + }, + { + "accountId": "account_2U67Q5pWZCgbWGX1tLXbXD", + "accountName": "Tim Bagert", + "radiusUsernames": [ + "timbagert" + ] + }, + { + "accountId": "account_hMQqEcyWpzLTEtUnCJmKsi", + "accountName": "Tim Fisher", + "radiusUsernames": [ + "timfisher" + ] + }, + { + "accountId": "account_ezDVFeaw9XwLiVvM9iEDwJ", + "accountName": "Tim Gilbert", + "radiusUsernames": [ + "timgilbert" + ] + }, + { + "accountId": "account_iR4oM82C6m8zkrFDLkSbYZ", + "accountName": "Treceda Schmid", + "radiusUsernames": [ + "trecedaschmid" + ] + }, + { + "accountId": "account_9arNHyqn6bwuUyPm4uWfrx", + "accountName": "Vance Peltonen", + "radiusUsernames": [ + "vancepeltonen" + ] + }, + { + "accountId": "account_u9nP4wDufL6eQGx3Jnoyn6", + "accountName": "Brian Swedlund", + "radiusUsernames": [ + "swedlund" + ] + }, + { + "accountId": "account_5izwKtCxbvkCupVQJ1KBBE", + "accountName": "William Armstrong", + "radiusUsernames": [ + "williamarmstrong" + ] + }, + { + "accountId": "account_cPSKw37hjbGmmpHzzHUum1", + "accountName": "Victoria Obier", + "radiusUsernames": [ + "victoriaobier" + ] + }, + { + "accountId": "account_qdzi3bYpEjEx7crLFY4xCw", + "accountName": "Bill McTee", + "radiusUsernames": [ + "billmctee" + ] + }, + { + "accountId": "account_2SA7BzkR6cqsS2jSQ14nRh", + "accountName": "Teresa Robinson", + "radiusUsernames": [ + "teresarobinson" + ] + }, + { + "accountId": "account_8Rh7edwTjNBYw9FJSq4WRT", + "accountName": "Michael Ray", + "radiusUsernames": [ + "michaelray", + "michaelray2" + ] + }, + { + "accountId": "account_fyH3ZiVVLx9h56vNYVndbK", + "accountName": "Greg McIntire", + "radiusUsernames": [ + "gregmcintire" + ] + }, + { + "accountId": "account_j9cuBn7ojxwRH3BfRWmDhN", + "accountName": "Darryl Lewis", + "radiusUsernames": [ + "veronabaptist" + ] + }, + { + "accountId": "account_6hGXtJ7RV7mqZYfd5AyE1f", + "accountName": "Pam Bansbach", + "radiusUsernames": [ + "pambanschbach" + ] + }, + { + "accountId": "account_k8gD8YGUQzwWCE7JT9EfYc", + "accountName": "Rebekah Moore", + "radiusUsernames": [ + "rebekahmoore", + "reynamoore" + ] + }, + { + "accountId": "account_pcYVJfTDAUK9ALviS7XBjW", + "accountName": "Chris Francis", + "radiusUsernames": [ + "chrisfrancis" + ] + }, + { + "accountId": "account_iuZZQ7KJWWSihZh9mzi2B8", + "accountName": "Bobby Fisher", + "radiusUsernames": [ + "bobbyfisher" + ] + }, + { + "accountId": "account_eGe4Qkw13xjjwabHBu51F3", + "accountName": "Erin Thompson", + "radiusUsernames": [ + "erinthompson" + ] + }, + { + "accountId": "account_1uu6CnSCUuCfBabMwfikLz", + "accountName": "Dale Delamar", + "radiusUsernames": [ + "thecountrycutter" + ] + }, + { + "accountId": "account_tui6mE1CTprghAF31hKWLp", + "accountName": "Karen Alderson", + "radiusUsernames": [ + "classicshear" + ] + }, + { + "accountId": "account_kvBUopRRFzz8B5PgbdjLGG", + "accountName": "Collin Hartman", + "radiusUsernames": [ + "collinhartman" + ] + }, + { + "accountId": "account_wbsKtfE9W1SyPkqk9hFBRp", + "accountName": "Tae Kim", + "radiusUsernames": [ + "taekim" + ] + }, + { + "accountId": "account_1m7oDB9vYUYJQffFHvxQaz", + "accountName": "Johnny Goble", + "radiusUsernames": [ + "johnnygoble" + ] + }, + { + "accountId": "account_dyfr7aKWGD5zJ1pdeEyyna", + "accountName": "Chris Vannatta", + "radiusUsernames": [ + "chrisvannatta" + ] + }, + { + "accountId": "account_7Gj6TBTXHmpu49iZGZBYT4", + "accountName": "Ausencio Luna", + "radiusUsernames": [ + "ausencioluna" + ] + }, + { + "accountId": "account_ktdNSpKRV3ja7RMi7pZRBB", + "accountName": "Balnca Diaz", + "radiusUsernames": [ + "blancadiaz" + ] + }, + { + "accountId": "account_t4Qnzxsq3c2WryFDXTSWba", + "accountName": "Maria Cortes", + "radiusUsernames": [ + "mariacortes" + ] + }, + { + "accountId": "account_n9yXaEBkPmAm5SdrbTwsTw", + "accountName": "Diane Wiley", + "radiusUsernames": [ + "dianewiley" + ] + }, + { + "accountId": "account_9ayF1oF9iawPMpCdQSiSqX", + "accountName": "Louis Lafon", + "radiusUsernames": [ + "louislafon" + ] + }, + { + "accountId": "account_5mD5W5PczTzo9H7it3qxLX", + "accountName": "Carlos Salaises", + "radiusUsernames": [ + "carlossalaises" + ] + }, + { + "accountId": "account_skzmJw1vJfndNPQNWsJjRL", + "accountName": "Janet Kern", + "radiusUsernames": [ + "janetkern" + ] + }, + { + "accountId": "account_f861cgYw7zxSiyUh3ngUYk", + "accountName": "Rick Beckham", + "radiusUsernames": [ + "rickbeckham" + ] + }, + { + "accountId": "account_1BvWee8L5krmWQsfVUqCdk", + "accountName": "Alex Tovias", + "radiusUsernames": [ + "alextovias" + ] + }, + { + "accountId": "account_ts53K9Gc61HkXi7uqcaiN6", + "accountName": "Helen Lumpkin", + "radiusUsernames": [ + "helenlumpkin" + ] + }, + { + "accountId": "account_d6m5HYMAczoeH2ovA6vqxj", + "accountName": "Terry Bates", + "radiusUsernames": [ + "terrybates" + ] + }, + { + "accountId": "account_35QGS4ZHr9tSpCVKNHE1ZT", + "accountName": "Jody McCluskey", + "radiusUsernames": [ + "jodymccluskey" + ] + }, + { + "accountId": "account_mnxwgFcvbjvDjnBBdGQzek", + "accountName": "Pam Toler", + "radiusUsernames": [ + "pamtoler" + ] + }, + { + "accountId": "account_3jfw5hMXURMmVuGyafdSPj", + "accountName": "Ana Gonzalez", + "radiusUsernames": [ + "anagonzalez" + ] + }, + { + "accountId": "account_6QT3zjSA88VLdbBJtAt44j", + "accountName": "Michael Hughes", + "radiusUsernames": [ + "michaelhughes" + ] + }, + { + "accountId": "account_vFwXqaiaquwHXzgMkdg1mc", + "accountName": "Sean Baxter", + "radiusUsernames": [ + "seanbaxter" + ] + }, + { + "accountId": "account_jS3WVFZmrQu5EoM7UJ4L1N", + "accountName": "Sri Reddy", + "radiusUsernames": [ + "srireddy" + ] + }, + { + "accountId": "account_qunWwkhDGN8jxGDMfB9FzA", + "accountName": "Kristen Austin", + "radiusUsernames": [ + "kristenaustin" + ] + }, + { + "accountId": "account_d3CqUjkDwdiB1pwR6t66XL", + "accountName": "Monica Trevino", + "radiusUsernames": [ + "monicatrevino" + ] + }, + { + "accountId": "account_ayztehQeAayBTQkizozi2B", + "accountName": "Mark Lindsey", + "radiusUsernames": [ + "marklindsey" + ] + }, + { + "accountId": "account_4vuDbTF964RUD2YKA5hmhQ", + "accountName": "Mike Bell", + "radiusUsernames": [ + "mikebell" + ] + }, + { + "accountId": "account_v2eMTZjQzV5Q3B2vLxGCpa", + "accountName": "Frances Kirby", + "radiusUsernames": [ + "franceskirby" + ] + }, + { + "accountId": "account_nYfEqA9HUY6R2xi1PvCQVs", + "accountName": "Joseph Cruz", + "radiusUsernames": [ + "josephcruz" + ] + }, + { + "accountId": "account_4gF4CqD6W7iS3UesE6js3g", + "accountName": "Tamy Thomas", + "radiusUsernames": [ + "tamythomas" + ] + }, + { + "accountId": "account_5C4WjLaQS5tGDxZm4NCZds", + "accountName": "James Mooney", + "radiusUsernames": [ + "jamesmooney" + ] + }, + { + "accountId": "account_vPgQBpVJ4y2gkpRgWTksvi", + "accountName": "Ladonna Clark", + "radiusUsernames": [ + "ladonnaclark" + ] + }, + { + "accountId": "account_1moqtbSRAJxiVR2pTtH3f9", + "accountName": "Michael Newby", + "radiusUsernames": [ + "michaelnewby" + ] + }, + { + "accountId": "account_7FafmmaEuKaNeaHmFFLSaf", + "accountName": "Candi Milligan Water", + "radiusUsernames": [ + "milliganwater" + ] + }, + { + "accountId": "account_t9Bb9uXEB9NjGoTDvCThi4", + "accountName": "Kacy Harrison", + "radiusUsernames": [ + "kacyharrison" + ] + }, + { + "accountId": "account_hWsePAjG5pq8FnnDQm75GD", + "accountName": "Joe Patton", + "radiusUsernames": [ + "joepatton" + ] + }, + { + "accountId": "account_h2bYtWBz1nbRRVBiYvi8wQ", + "accountName": "T W Cowley", + "radiusUsernames": [ + "twcowley" + ] + }, + { + "accountId": "account_qx5oYHwkMNxoKEXME6zu6w", + "accountName": "Kristina Murphy", + "radiusUsernames": [ + "kristinamurphy" + ] + }, + { + "accountId": "account_mx1SSXHEiptCZaz2Gx6MTm", + "accountName": "FRED HECKEL", + "radiusUsernames": [ + "fredheckel" + ] + }, + { + "accountId": "account_aZmcKqobMWGKhxagDdLCFA", + "accountName": "Cathy Day", + "radiusUsernames": [ + "cathyday" + ] + }, + { + "accountId": "account_fNLTDkPjpbzZ7Eta2ofer4", + "accountName": "Oscar Rodriguez", + "radiusUsernames": [ + "oscarrodriguez" + ] + }, + { + "accountId": "account_htyg2uThjYmLbHsX5a3RCj", + "accountName": "Austin Watkins", + "radiusUsernames": [ + "austinwatkins" + ] + }, + { + "accountId": "account_oP5ATcm8WwBDBv8t8mh4vU", + "accountName": "Darryl Chavis", + "radiusUsernames": [ + "darrylchavis" + ] + }, + { + "accountId": "account_g5RpJWMv32tFk6c4yy5F5c", + "accountName": "Kathy Mazzola", + "radiusUsernames": [ + "kathymazzola" + ] + }, + { + "accountId": "account_aZMRveyhHvJTHTQCT7yYdf", + "accountName": "Brad Gurney", + "radiusUsernames": [ + "bradgurney" + ] + }, + { + "accountId": "account_fipAsvnLVUkrSmuJ9GYdEb", + "accountName": "Gizelle Butler", + "radiusUsernames": [ + "gizellebutler" + ] + }, + { + "accountId": "account_jPuzvdHGq1BXiDALjrVaR7", + "accountName": "Terry Gortney", + "radiusUsernames": [ + "terrygortney" + ] + }, + { + "accountId": "account_bmXh2S5K97hPyUkkBRj1ub", + "accountName": "Douglas Wilson", + "radiusUsernames": [ + "douglaswilson" + ] + }, + { + "accountId": "account_v9vCsfc4uMAk7LgXyRR9EC", + "accountName": "Marilyn Fowler", + "radiusUsernames": [ + "marilynfowler" + ] + }, + { + "accountId": "account_fXqciwzW348S1eYSKbbi8B", + "accountName": "Amber Prater", + "radiusUsernames": [ + "amberprater" + ] + }, + { + "accountId": "account_omX4bSM3rXDShahsdc4aHd", + "accountName": "Ken Patrick", + "radiusUsernames": [ + "kenpatrick" + ] + }, + { + "accountId": "account_9DuddEuiaH59KtVGReTwJs", + "accountName": "Kevin Costanzo", + "radiusUsernames": [ + "kevincostanzo" + ] + }, + { + "accountId": "account_uyp7QDJmZyCeq9BTGnvoZ3", + "accountName": "Mauricio Soto", + "radiusUsernames": [ + "mauriciosoto" + ] + }, + { + "accountId": "account_hHa9Z64B9okZ3yccQP9WpR", + "accountName": "Jacquelin Wilder", + "radiusUsernames": [ + "jacquelinewilder" + ] + }, + { + "accountId": "account_8YqGj24SS5fxoZhPUYBED9", + "accountName": "Shannon Clark", + "radiusUsernames": [ + "shannonclark" + ] + }, + { + "accountId": "account_7eCHrmvA6X16gn1zr5wCo9", + "accountName": "Matt Kosarek", + "radiusUsernames": [ + "mattkosarek" + ] + }, + { + "accountId": "account_p5VqKAXXC8t3tAFSzzr8cC", + "accountName": "Jordain Patton", + "radiusUsernames": [ + "jordainpatton" + ] + }, + { + "accountId": "account_q5J8BYNnSG5TtFnikCTEmm", + "accountName": "Elvira Quezada", + "radiusUsernames": [ + "elviraquezada" + ] + }, + { + "accountId": "account_rRKbPsCejKqMnAb29xPPFx", + "accountName": "Paul Comella", + "radiusUsernames": [ + "paulcomella" + ] + }, + { + "accountId": "account_m3356qm8wGGJDqNZCgXRev", + "accountName": "Jensen Hall", + "radiusUsernames": [ + "jensenhall" + ] + }, + { + "accountId": "account_bTsNpp5j2YHU9dKipBm3iP", + "accountName": "Jennifer Dunaway", + "radiusUsernames": [ + "jenniferdunaway" + ] + }, + { + "accountId": "account_wkn6qdAVF72b7TxwZxvW6D", + "accountName": "Heather Brister", + "radiusUsernames": [ + "heatherbrister" + ] + }, + { + "accountId": "account_pUesJC8A4ENdQ5XZTFGdZt", + "accountName": "Lanay Gaunce", + "radiusUsernames": [ + "lanaygaunce" + ] + }, + { + "accountId": "account_s6ZC2GALaCFUamcQafLyy7", + "accountName": "Terry Bachran", + "radiusUsernames": [ + "terrybachran" + ] + }, + { + "accountId": "account_xqwwg1ZAd6SKt4LyrcAebY", + "accountName": "Susan King", + "radiusUsernames": [ + "susanking" + ] + }, + { + "accountId": "account_tTLx1Zn6YTykzm8quV6ykg", + "accountName": "Perla Chavez", + "radiusUsernames": [ + "perlachavez" + ] + }, + { + "accountId": "account_vHWu6NZDh4Ajh6YqWEXPb4", + "accountName": "Abbie Andrews", + "radiusUsernames": [ + "abbieandrews" + ] + }, + { + "accountId": "account_57mDq22QWRV8tdPvk74Uvj", + "accountName": "Cinthia Pereira", + "radiusUsernames": [ + "cinthiapereira1" + ] + }, + { + "accountId": "account_6wngyAj64jCwvn3W8T3sdT", + "accountName": "Patricia Vasquez", + "radiusUsernames": [ + "patriciavasquez" + ] + }, + { + "accountId": "account_89AnXC3322c22PCkdUDfxQ", + "accountName": "Deborah Heller", + "radiusUsernames": [ + "buddyswan" + ] + }, + { + "accountId": "account_nwbZPHiXLbR9o6LZppPSoY", + "accountName": "Cherie Eshelman", + "radiusUsernames": [ + "cherieeshelman" + ] + }, + { + "accountId": "account_3zbkHwfVAegXCPUhc4XmYV", + "accountName": "Kathy Smith", + "radiusUsernames": [ + "kathysmith" + ] + }, + { + "accountId": "account_j1UFX8wqzorqSetUje5dTJ", + "accountName": "Stephen Day", + "radiusUsernames": [ + "stephenday" + ] + }, + { + "accountId": "account_rKLQuRuTgybF3YMnNt7VY9", + "accountName": "Tonya Sipes", + "radiusUsernames": [ + "tonyasipes" + ] + }, + { + "accountId": "account_aFYkyi9DGrJBRgQ4jSud96", + "accountName": "Judith Fierro", + "radiusUsernames": [ + "judithfierro" + ] + }, + { + "accountId": "account_6VLrBr4sZCAJwMzzcEkhSE", + "accountName": "Conner Coleman", + "radiusUsernames": [ + "connercoleman" + ] + }, + { + "accountId": "account_nxruUEiwq3NpZTexeEo8jd", + "accountName": "Steven Spurgers", + "radiusUsernames": [ + "stevenspurgers" + ] + }, + { + "accountId": "account_9AkWnUdPzs2AE9vfqmbz6i", + "accountName": "Summer Beazley", + "radiusUsernames": [ + "summerbeazley" + ] + }, + { + "accountId": "account_p1wD82RWMCTi1hRoLHdjHL", + "accountName": "David Armstrong", + "radiusUsernames": [ + "davidarmstrong" + ] + }, + { + "accountId": "account_p9AaVzWfCwR74RVK3k6kPa", + "accountName": "Jim Crank", + "radiusUsernames": [ + "jimcrank" + ] + }, + { + "accountId": "account_eB2k88UWVdAFKYgcg7iK4h", + "accountName": "amber krings", + "radiusUsernames": [ + "amberkrings" + ] + }, + { + "accountId": "account_hJ3iUD77LRADfhaxwwsqLj", + "accountName": "richard barragan", + "radiusUsernames": [ + "richardbarragan" + ] + }, + { + "accountId": "account_mVgspPEmufZG8KD83Cnyjh", + "accountName": "Iris Delira", + "radiusUsernames": [ + "irisdelira" + ] + }, + { + "accountId": "account_nhYxp4RV7gpLxWnLL9oWs6", + "accountName": "Brad & Sherry Slate", + "radiusUsernames": [ + "bradslate" + ] + }, + { + "accountId": "account_oSA465twDMxZedY5zbPLT1", + "accountName": "Jennifer Little", + "radiusUsernames": [ + "jenniferlittle" + ] + }, + { + "accountId": "account_cwmXrKm93bSteHQfm3SJtd", + "accountName": "Justin Voga", + "radiusUsernames": [ + "justinvoga" + ] + }, + { + "accountId": "account_aScSfG25Wwo7cD4xbNhja1", + "accountName": "ADRIAN MCDANIEL", + "radiusUsernames": [ + "adrianmcdaniel" + ] + }, + { + "accountId": "account_sWngcEfTk4Ed83ARWYtZga", + "accountName": "David Whitefoot", + "radiusUsernames": [ + "davidwhitefoot" + ] + }, + { + "accountId": "account_ayvJr4cDCtK3zNPcoVo5vy", + "accountName": "James Smith", + "radiusUsernames": [ + "jamessmith" + ] + }, + { + "accountId": "account_18HG9hdwwtZM83J9kyGMs6", + "accountName": "Melody McCarthy", + "radiusUsernames": [ + "melodymccarty" + ] + }, + { + "accountId": "account_dQ3cvFTw98ByhcBzDGMsNQ", + "accountName": "Charles McCreary", + "radiusUsernames": [ + "charlesmccreary" + ] + }, + { + "accountId": "account_w5t1gDksfLnZqU35RFxZES", + "accountName": "Jackie Rollier", + "radiusUsernames": [ + "jackierollier" + ] + }, + { + "accountId": "account_pMdxeChdNqnVRQs1rSAKBE", + "accountName": "Doris Avalos", + "radiusUsernames": [ + "dorisavalos" + ] + }, + { + "accountId": "account_t9DUQUCtBAWojjSYtZQusE", + "accountName": "Luis Arellano", + "radiusUsernames": [ + "luisarellano" + ] + }, + { + "accountId": "account_hgoipVYXydD1yVSXTD9QGP", + "accountName": "John Worthy", + "radiusUsernames": [ + "jackworthy" + ] + }, + { + "accountId": "account_wU2qgfTxqjaLSfhwWx4Cxm", + "accountName": "Terri Mack", + "radiusUsernames": [ + "terrimack" + ] + }, + { + "accountId": "account_mma7TzxJUZKUzXYr6YDUmt", + "accountName": "Viky Acosta", + "radiusUsernames": [ + "vikyacosta" + ] + }, + { + "accountId": "account_bb7iRuPixxutKZxnURfK5U", + "accountName": "James Hardin", + "radiusUsernames": [ + "barbihardin" + ] + }, + { + "accountId": "account_ujyp8hfbksvc8EZALQ5gTL", + "accountName": "Brad Wilson", + "radiusUsernames": [ + "bradwilson" + ] + }, + { + "accountId": "account_w1ywM8kUBcURQHGjHQqirn", + "accountName": "Amy Rodriguez", + "radiusUsernames": [ + "amyrodriguez" + ] + }, + { + "accountId": "account_pFHZ8RMuvdETPpHr4vyDWi", + "accountName": "Ryan Lloyd", + "radiusUsernames": [ + "ryanlloyd" + ] + }, + { + "accountId": "account_m1hacYUopGGJrn2UyJvzBu", + "accountName": "Heather Brister 2", + "radiusUsernames": [ + "heatherbrister2" + ] + }, + { + "accountId": "account_4KPsmHxqdcnsRNXuWB8s2Y", + "accountName": "Wendy Sanders", + "radiusUsernames": [ + "wendysanders" + ] + }, + { + "accountId": "account_gfPawVk3gSuvufuC4zVjx9", + "accountName": "Shirley Elmore", + "radiusUsernames": [ + "shirleyelmore" + ] + }, + { + "accountId": "account_97wbzzkGPxCPMyfUs5uTTf", + "accountName": "Carol Bates", + "radiusUsernames": [ + "carolbates" + ] + }, + { + "accountId": "account_m5ZTZYHZbzpVPeNfRhroxn", + "accountName": "Tim Thomas", + "radiusUsernames": [ + "timthomas" + ] + }, + { + "accountId": "account_v6odXP1nVxoVvTMUrUn3SS", + "accountName": "Jerid Rutledge", + "radiusUsernames": [ + "jeridrutledge" + ] + }, + { + "accountId": "account_jNj9rpkTwq7wF3vJbMuAkF", + "accountName": "Carlos Gomez", + "radiusUsernames": [ + "carlosgomez" + ] + }, + { + "accountId": "account_qYeNtZSW1nYgukr9kYy5qn", + "accountName": "Rosa Hernandez", + "radiusUsernames": [ + "rosahernandez" + ] + }, + { + "accountId": "account_mrPPtWesWBzmcD4Lfvp2hA", + "accountName": "luis Cabrera", + "radiusUsernames": [ + "luiscabrera" + ] + }, + { + "accountId": "account_hU5voij99AKqckcGpok7Jx", + "accountName": "T.J. Banschbach", + "radiusUsernames": [ + "tjbanschbach" + ] + }, + { + "accountId": "account_gjzpfGyVuYMaQwdYA2Gufy", + "accountName": "America Trejo", + "radiusUsernames": [ + "americatrejo" + ] + }, + { + "accountId": "account_nNUBZtEptfAD6TpDteqjfM", + "accountName": "Bobby Mack", + "radiusUsernames": [ + "bobbymack" + ] + }, + { + "accountId": "account_iC8ZrQDmzjw8Ckp3Nea1rh", + "accountName": "Paul Kinser", + "radiusUsernames": [ + "kimberlyrichards2", + "kimberlyrichards" + ] + }, + { + "accountId": "account_hJ5wonckmH2jkaykKH2g4Q", + "accountName": "Cynthia Jones", + "radiusUsernames": [ + "cynthiajones" + ] + }, + { + "accountId": "account_osB3NhyGdoWJRtpL5X2bfD", + "accountName": "chris lewis", + "radiusUsernames": [ + "chrislewis1" + ] + }, + { + "accountId": "account_o6gDBbdWzQYeUbAQL2Zp1s", + "accountName": "Tim Bronschidle", + "radiusUsernames": [ + "timbronschidle" + ] + }, + { + "accountId": "account_3hQJhhHf48sszYWBaMTjpR", + "accountName": "Doug Stowe", + "radiusUsernames": [ + "dougstowe" + ] + }, + { + "accountId": "account_5nSRJ7EkJpz5dpB2QGRYfz", + "accountName": "Ashley Smith", + "radiusUsernames": [ + "ashleysmith" + ] + }, + { + "accountId": "account_vvPvzpGka7xmJQnqE3gMxq", + "accountName": "Diego Sereno", + "radiusUsernames": [ + "diegosereno" + ] + }, + { + "accountId": "account_q4nxMiXUoEj2DkH3JUspLP", + "accountName": "J'michael Culverhouse", + "radiusUsernames": [ + "jmichaelculverhouse" + ] + }, + { + "accountId": "account_eVxdo9RAAoBbisk1EYCKnc", + "accountName": "CLAY GILBERT", + "radiusUsernames": [ + "claygilbert" + ] + }, + { + "accountId": "account_eZ6nJGAsmsPwR8sYvtXBRw", + "accountName": "Vincent Limon", + "radiusUsernames": [ + "vincentlimon" + ] + }, + { + "accountId": "account_tX9wv8qaDb4bCFBxKPsMDe", + "accountName": "Mauricio Antonio", + "radiusUsernames": [ + "mauricioantonio" + ] + }, + { + "accountId": "account_rjvhtkZxuk442ghJaKsdtG", + "accountName": "Tammy Welborn", + "radiusUsernames": [ + "tammywelborn" + ] + }, + { + "accountId": "account_uGdvd35CHuBkiBamMnB8Bj", + "accountName": "Chris Telles", + "radiusUsernames": [ + "christelles" + ] + }, + { + "accountId": "account_hmnByz3wfiafSujapZBbi3", + "accountName": "HALEY MUELA", + "radiusUsernames": [ + "haleymuela" + ] + }, + { + "accountId": "account_mqMu7hR16hGpAesQ3ba83B", + "accountName": "Susan Lewis", + "radiusUsernames": [ + "susanlewis" + ] + }, + { + "accountId": "account_g8yP63NFmbioePagUH1kQS", + "accountName": "Thomas Cline", + "radiusUsernames": [ + "thomascline" + ] + }, + { + "accountId": "account_gERfDmYXtJj5PLCY9ELLS7", + "accountName": "Georgina Covarrubias", + "radiusUsernames": [ + "georginacovarrubias" + ] + }, + { + "accountId": "account_99RoiQcJpubSAqajPZjxFf", + "accountName": "Gabriela Sereno", + "radiusUsernames": [ + "gabrielasereno" + ] + }, + { + "accountId": "account_t87r46C6jaw8rjJYurmHrY", + "accountName": "Nathanael Finch", + "radiusUsernames": [ + "nathanaelfinch" + ] + }, + { + "accountId": "account_4mZAzz4GiGtMU8CJBWs1cQ", + "accountName": "Eric Coffman", + "radiusUsernames": [ + "ericcoffman" + ] + }, + { + "accountId": "account_jJoU6UPYd2ckqbUhynBx7B", + "accountName": "Robert Finch", + "radiusUsernames": [ + "robertfinch" + ] + }, + { + "accountId": "account_7aEgkmhMKwDVqsus5Ln8Bd", + "accountName": "RICHARD CONKLIN", + "radiusUsernames": [ + "richardconklin" + ] + }, + { + "accountId": "account_doS7HkZWacVd5r8nWxqDFZ", + "accountName": "Leandra Beshea", + "radiusUsernames": [ + "leandrabeshea" + ] + }, + { + "accountId": "account_2hJ8frAhU5LDVpaoSH27Tx", + "accountName": "Carla Kimberling", + "radiusUsernames": [ + "carlakimberling" + ] + }, + { + "accountId": "account_rknD3GcgUkTd8XB7rZZjND", + "accountName": "Clifford Jennings", + "radiusUsernames": [ + "cliffordjennings" + ] + }, + { + "accountId": "account_qncL4zimMnsq5ViZrpNXr1", + "accountName": "Kerry Black", + "radiusUsernames": [ + "kerryblack" + ] + }, + { + "accountId": "account_deGyZc4zP5TBJGSibkGU3a", + "accountName": "Angel Merlo", + "radiusUsernames": [ + "angelmerlo" + ] + }, + { + "accountId": "account_1KjfxaKx8MvMEwb7uU3eSC", + "accountName": "Chisedi Quezada", + "radiusUsernames": [ + "chisediquezada" + ] + }, + { + "accountId": "account_4DuPXfEqx5pRPftTGKkbhS", + "accountName": "Billy Horton", + "radiusUsernames": [ + "billyhorton" + ] + }, + { + "accountId": "account_aamYj92hC8r7nxvbAfiHCE", + "accountName": "Chris Clayton", + "radiusUsernames": [ + "chrisclayton" + ] + }, + { + "accountId": "account_oR5nKGJTvRLhkCyUGG1B1L", + "accountName": "Kevin Arana", + "radiusUsernames": [ + "kevinarana" + ] + }, + { + "accountId": "account_iQqfYFDTb4UxtHcgrGt8EP", + "accountName": "Chase Williams", + "radiusUsernames": [ + "chasewilliams" + ] + }, + { + "accountId": "account_sooioQ729DC21LJa1zKMAN", + "accountName": "Angelica Cuayahuitl", + "radiusUsernames": [ + "angelicacuayahuitl" + ] + }, + { + "accountId": "account_hJwbN2EUqGCtNj45JCyQda", + "accountName": "Wyndi James", + "radiusUsernames": [ + "wyndijames" + ] + }, + { + "accountId": "account_kSE9uAW6WPbt4msXe4mzHs", + "accountName": "HEATHER WOMACK", + "radiusUsernames": [ + "heatherwomack" + ] + }, + { + "accountId": "account_wSUPq2ZjUF1Va7S3vap91M", + "accountName": "Karen Stewart", + "radiusUsernames": [ + "karenstewart" + ] + }, + { + "accountId": "account_7QghTwmH6MwtZdTrvur8uB", + "accountName": "Russ Mott", + "radiusUsernames": [ + "russmott" + ] + }, + { + "accountId": "account_u39DmoPasPkSoYJ3bTG8kz", + "accountName": "Adam Staeben", + "radiusUsernames": [ + "adamstaeben" + ] + }, + { + "accountId": "account_sDBLs44VLbWy9ZRhuDAkpL", + "accountName": "Sandra Houghton", + "radiusUsernames": [ + "sandrahoughton" + ] + }, + { + "accountId": "account_9sksoKmcJGxqhz4M4aD169", + "accountName": "Brian Goulart", + "radiusUsernames": [ + "bryangoulart" + ] + }, + { + "accountId": "account_tiy5YHD34KpwPzUTXyRVFJ", + "accountName": "Wanxia Song", + "radiusUsernames": [ + "wanxiaxong" + ] + }, + { + "accountId": "account_kNWT9ewUppfxMtxpcgScKZ", + "accountName": "Pavel Avanesov", + "radiusUsernames": [ + "pavelavanesov" + ] + }, + { + "accountId": "account_vkMiPuMKu6XmQt1G9CP1op", + "accountName": "Pablo Hernandez", + "radiusUsernames": [ + "pablohernandez" + ] + }, + { + "accountId": "account_wTXj1c59SU9iRzeRmgv2dT", + "accountName": "Roseann Dellavalle", + "radiusUsernames": [ + "roseanndellavalle" + ] + }, + { + "accountId": "account_3Hwm7znAWKLPMhmY2avtnG", + "accountName": "Duane Wright", + "radiusUsernames": [ + "duanewright" + ] + }, + { + "accountId": "account_7RPLicRiJMb3Y1x3C4tEQJ", + "accountName": "Stephen Beegle", + "radiusUsernames": [ + "stephenbeegle" + ] + }, + { + "accountId": "account_sAYv3pn9D1KCwWr1TzGyeu", + "accountName": "Dustyn Fisher", + "radiusUsernames": [ + "dustynfisher" + ] + }, + { + "accountId": "account_r39MCaQ811VXnw6QbhSG4b", + "accountName": "Brittany Worley", + "radiusUsernames": [ + "brittanyworley" + ] + }, + { + "accountId": "account_hZpk1bujwvFdyctrbfJtjS", + "accountName": "Nhu Morrison", + "radiusUsernames": [ + "nhumorrison" + ] + }, + { + "accountId": "account_2ngDjpr4VqDCPsv2SNMHvS", + "accountName": "Glinda Richter", + "radiusUsernames": [ + "glindarichter" + ] + }, + { + "accountId": "account_fJVr5CSPdsgWtLNRWkidro", + "accountName": "Glenda Beauchamp", + "radiusUsernames": [ + "glendabeauchamp" + ] + }, + { + "accountId": "account_utCxKZZ979qMutS4mmsgHy", + "accountName": "Angela Smith", + "radiusUsernames": [ + "radiantlifeministries" + ] + }, + { + "accountId": "account_dXq5FqPpKhAwE96NT5D9Jk", + "accountName": "Esparanza Garza", + "radiusUsernames": [ + "esparanzagarza" + ] + }, + { + "accountId": "account_kvePVE9fNZCokbkkvpBvLK", + "accountName": "Delaina Waite", + "radiusUsernames": [ + "delainawaite" + ] + }, + { + "accountId": "account_jeuzxdVtC5u4W8utfKf5vc", + "accountName": "Leslie Bewley", + "radiusUsernames": [ + "lesliebewley" + ] + }, + { + "accountId": "account_chsr6oKpnEYuEKkMXn7CKF", + "accountName": "Garrett Ojeda", + "radiusUsernames": [ + "garrettojeda" + ] + }, + { + "accountId": "account_qhFf5wpTsv3FySr63biaRp", + "accountName": "Ray Schaak", + "radiusUsernames": [ + "rayschaak" + ] + }, + { + "accountId": "account_rzEn7BHU6Dmd6PkRwRpNXB", + "accountName": "Carla Swearingen", + "radiusUsernames": [ + "carlaswearingen" + ] + }, + { + "accountId": "account_wnvmzBJwB3r7pG6a6WNANx", + "accountName": "Bethany Bell", + "radiusUsernames": [ + "bethanybell" + ] + }, + { + "accountId": "account_sFzFdm2TTpyRaur4JfF3bv", + "accountName": "Cynthia Sandlin", + "radiusUsernames": [ + "cynthiasandlin" + ] + }, + { + "accountId": "account_mjnZnogDMDws9ueMDxVzaP", + "accountName": "Maria Lopez", + "radiusUsernames": [ + "marialopez" + ] + }, + { + "accountId": "account_3aCW8JeazPNe4UxjtGXzrp", + "accountName": "Jennifer Boon", + "radiusUsernames": [ + "jenniferboon" + ] + }, + { + "accountId": "account_sDPK3iLsxwrLzndTwh2uxb", + "accountName": "Scott Anderson", + "radiusUsernames": [ + "scottanderson" + ] + }, + { + "accountId": "account_iy49cEMsdawi18rTXarw73", + "accountName": "Wanda Davis", + "radiusUsernames": [ + "wandadavis" + ] + }, + { + "accountId": "account_t4wwpPyK98WBZUqLnwXKNU", + "accountName": "Betty Mitchell", + "radiusUsernames": [ + "bettymitchell" + ] + }, + { + "accountId": "account_myUaLbfFVED7hqEBBY1Pe4", + "accountName": "Greg Hummel", + "radiusUsernames": [ + "greghummel" + ] + }, + { + "accountId": "account_hswERjNGcJtQnPWCkMnEQs", + "accountName": "Olga Gutierrez", + "radiusUsernames": [ + "olgagutierrez" + ] + }, + { + "accountId": "account_bzPJ8YAjKNt5Bn7zhYf8zw", + "accountName": "Crystal Harney", + "radiusUsernames": [ + "crystalharney" + ] + }, + { + "accountId": "account_gqFbPtxAGRTSizXXLFB9j3", + "accountName": "Alejandro Limon", + "radiusUsernames": [ + "alejandrolimon" + ] + }, + { + "accountId": "account_bLMBbmXsvurdQQaSokRzTf", + "accountName": "Leonardo Coronel", + "radiusUsernames": [ + "leonardocoronel" + ] + }, + { + "accountId": "account_gb82EhqTwbNWWEovPu4oKZ", + "accountName": "Vanessa Gomez", + "radiusUsernames": [ + "vanessagomez1" + ] + }, + { + "accountId": "account_4CLddQNDWC76dtRwmYRdeZ", + "accountName": "Anthony Thompson", + "radiusUsernames": [ + "anthonythompson" + ] + }, + { + "accountId": "account_7hUzHAkpHagRdjPWYoRP9R", + "accountName": "John Vayo", + "radiusUsernames": [ + "johnvayo" + ] + }, + { + "accountId": "account_kjSroty8KmGKCTEArNC9Pa", + "accountName": "Dee Williams", + "radiusUsernames": [ + "deewilliams" + ] + }, + { + "accountId": "account_gZPLTs7y5We8wW5MZyZjkX", + "accountName": "Ken Hall", + "radiusUsernames": [ + "kenhall" + ] + }, + { + "accountId": "account_ahaKfCrUTPA2mx7oM2i7Xu", + "accountName": "Maria Trejo", + "radiusUsernames": [ + "mariatrejo", + "kellygarza" + ] + }, + { + "accountId": "account_c5VgNVVcBTRkHjbzFDEpHk", + "accountName": "Steve Molina", + "radiusUsernames": [ + "stevemolina" + ] + }, + { + "accountId": "account_67qbP4FpqwKmqSNjSmtCBv", + "accountName": "Kaleb James", + "radiusUsernames": [ + "kalebjames" + ] + }, + { + "accountId": "account_2gaAXfFo32nXEh7qA2yX8M", + "accountName": "Roger Batt", + "radiusUsernames": [ + "rogerbatt" + ] + }, + { + "accountId": "account_2BwbPKVKpj7QnFFpdZVqn3", + "accountName": "Joe Speciale", + "radiusUsernames": [ + "joespeciale" + ] + }, + { + "accountId": "account_aJuvQYQACJ5Ms48bAR4hWY", + "accountName": "Christine Hamparyan", + "radiusUsernames": [ + "christinehamparyan" + ] + }, + { + "accountId": "account_uh8Yfj5mdjaQjofM19p9MU", + "accountName": "Zack Knuckey", + "radiusUsernames": [ + "zackknuckey" + ] + }, + { + "accountId": "account_piQoRw8BbqZpckbweTGdDu", + "accountName": "Melanie Weddle", + "radiusUsernames": [ + "melanieweddle" + ] + }, + { + "accountId": "account_mXgG1ZhfDfrnkU6usym3kG", + "accountName": "Toni Young", + "radiusUsernames": [ + "toniyoung" + ] + }, + { + "accountId": "account_8fvkQS2hERUdtSF7DXrzpw", + "accountName": "Corey Ball", + "radiusUsernames": [ + "coreyball" + ] + }, + { + "accountId": "account_57jxCEcSpkMPUEVxDoZddc", + "accountName": "Deja Dodson", + "radiusUsernames": [ + "dejadodson" + ] + }, + { + "accountId": "account_h3oACsArSoq5fZ6vRyH9Br", + "accountName": "Tiffany Stewart", + "radiusUsernames": [ + "tiffanystewart" + ] + }, + { + "accountId": "account_aC929RXGkB1nsEHu95kg5v", + "accountName": "Ruben Reyez", + "radiusUsernames": [ + "rubenreyez" + ] + }, + { + "accountId": "account_cRZQwgdsQLcok1dzXy6U5c", + "accountName": "Carmen Cleghorn", + "radiusUsernames": [ + "carmencleghorn" + ] + }, + { + "accountId": "account_vyxx47PALJmyUZZe3m495N", + "accountName": "Doug Garber", + "radiusUsernames": [ + "douggarber" + ] + }, + { + "accountId": "account_izByTUMCPK7UT8k6Zncc4e", + "accountName": "Jose Lucas", + "radiusUsernames": [ + "joselucas" + ] + }, + { + "accountId": "account_q9j2WgU1236uDnx5rrEx8U", + "accountName": "Craig Hovde", + "radiusUsernames": [ + "craighovde" + ] + }, + { + "accountId": "account_cWR4qvSM1SepG3DuoD8rUz", + "accountName": "Yolanda Judge", + "radiusUsernames": [ + "yolandajudge" + ] + }, + { + "accountId": "account_pJFWGZXwDitJCT55tcuqeE", + "accountName": "Nicole Maenn", + "radiusUsernames": [ + "nicolemaenn" + ] + }, + { + "accountId": "account_sN8UFBKJy6HHSCXSQp8oTB", + "accountName": "Mary Smelser", + "radiusUsernames": [ + "marysmelser" + ] + }, + { + "accountId": "account_fhkdvYwigwTL9hR4Stx8Tf", + "accountName": "Eric Barrett", + "radiusUsernames": [ + "ericbarrett" + ] + }, + { + "accountId": "account_cwmjmXkrYeba8h8RGqLm6E", + "accountName": "Tiffany Yarley-Olenak", + "radiusUsernames": [ + "tiffanyyarley-olenak" + ] + }, + { + "accountId": "account_fPwqeAaDrYSWtRyVjBk8HD", + "accountName": "Joshua Soliz", + "radiusUsernames": [ + "joshuasoliz" + ] + }, + { + "accountId": "account_8MmcYW5A3KpMsyBJjiQMfE", + "accountName": "Derek Rodriguez", + "radiusUsernames": [ + "derekrodriguez" + ] + }, + { + "accountId": "account_iijdXYXJAhg1mDm1FRuUrc", + "accountName": "John Looper", + "radiusUsernames": [ + "johnlooper" + ] + }, + { + "accountId": "account_bHVKB5E3eJFmbqnDwXAxFw", + "accountName": "David Roesch", + "radiusUsernames": [ + "davidroesch" + ] + }, + { + "accountId": "account_75ZP8Rhk7DBvwEA83HaWBB", + "accountName": "Carmen Lopez", + "radiusUsernames": [ + "carmenlopez" + ] + }, + { + "accountId": "account_u8W2U31Z3LJgzNcD2qNfcX", + "accountName": "Carol Strickland", + "radiusUsernames": [ + "carolstrickland" + ] + }, + { + "accountId": "account_hzZvaSXgCuhMUTXjpaCQjA", + "accountName": "Douglas Glover", + "radiusUsernames": [ + "douglasglover" + ] + }, + { + "accountId": "account_vtGoVXJRkxWw3ZEYDKi9Bu", + "accountName": "Shams Bashir", + "radiusUsernames": [ + "shamsbashir" + ] + }, + { + "accountId": "account_bGYACsz7a5kdyDBDecBTZY", + "accountName": "Natasha Elmore", + "radiusUsernames": [ + "natashaelmore" + ] + }, + { + "accountId": "account_viJJswZvVXEbxLBikRjGJz", + "accountName": "Don Mckinney", + "radiusUsernames": [ + "donmckinney" + ] + }, + { + "accountId": "account_ocezHaQ4JPvu1EbRMk4hu5", + "accountName": "Matthew Goodwin", + "radiusUsernames": [ + "matthewgoodwin" + ] + }, + { + "accountId": "account_99jecysxoYGBHUhd97ZYmt", + "accountName": "Nancy Avellaneda", + "radiusUsernames": [ + "nancyavellaneda" + ] + }, + { + "accountId": "account_izj2Jd1nYA49rAcvNu6TfZ", + "accountName": "Brandon Hardesty", + "radiusUsernames": [ + "brandonhardesty" + ] + }, + { + "accountId": "account_haPf3cXEdK4TQMzqZi5LYV", + "accountName": "Guadalupe Lugo", + "radiusUsernames": [ + "guadalupelugo" + ] + }, + { + "accountId": "account_pDDXT9Lh8QsE2roPyeVfS8", + "accountName": "Shacacia Billings", + "radiusUsernames": [ + "shacaciabillings" + ] + }, + { + "accountId": "account_dxY6AZnLdFUWup8Sf6XER1", + "accountName": "Marcus Patton", + "radiusUsernames": [ + "marcuspatton" + ] + }, + { + "accountId": "account_6fhZdgAsyd9zJ2FiXURCJ2", + "accountName": "William Bowland", + "radiusUsernames": [ + "williambowland" + ] + }, + { + "accountId": "account_4tZA9NMygTcq3fidSxDz5h", + "accountName": "Yolanda Medrano", + "radiusUsernames": [ + "yolandamedrano" + ] + }, + { + "accountId": "account_khW6HArsQ677gL18hcQnEN", + "accountName": "Jessica Weddle", + "radiusUsernames": [ + "jessicaweddle" + ] + }, + { + "accountId": "account_ftDbZQGSaf8dowpdjC44sU", + "accountName": "Kory Biggs", + "radiusUsernames": [ + "korybiggs" + ] + }, + { + "accountId": "account_bdpYxupJ3cfQ5bmhNroy8i", + "accountName": "Kristin Jimenez", + "radiusUsernames": [ + "kristinjimenez" + ] + }, + { + "accountId": "account_2MK9ykEGzWyxvg1SZMmagK", + "accountName": "Priscilla Crenshaw", + "radiusUsernames": [ + "priscillacrenshaw" + ] + }, + { + "accountId": "account_9GNShE9ayAX3fkjwSryGq7", + "accountName": "Shelby Burris", + "radiusUsernames": [ + "shelbyburris" + ] + }, + { + "accountId": "account_3eF55epzgGSPhDU1DFBm53", + "accountName": "Sherri Fredrick", + "radiusUsernames": [ + "sherrifredrick" + ] + }, + { + "accountId": "account_9hvebUFe6UDfaZb9omVkB8", + "accountName": "Julie Rose", + "radiusUsernames": [ + "julierose" + ] + }, + { + "accountId": "account_89xduFTiVvRoEQpENiaVmT", + "accountName": "Derrick Mccausland", + "radiusUsernames": [ + "derrickmccausland" + ] + }, + { + "accountId": "account_5Eqdv89tv7SUwTjGqQWJdh", + "accountName": "William Armstead", + "radiusUsernames": [ + "williamarmstead" + ] + }, + { + "accountId": "account_wL9tsaty83QCJpNGNXeSzZ", + "accountName": "Thomas Hernandez", + "radiusUsernames": [ + "thomashernandez" + ] + }, + { + "accountId": "account_sSL9o2BAvbEHXAkxWtPXxV", + "accountName": "Perla Chavez 2", + "radiusUsernames": [ + "perlachavez2" + ] + }, + { + "accountId": "account_4jKF2XxS1tjfAiTJLsFcGJ", + "accountName": "Jose Ortiz", + "radiusUsernames": [ + "joseortiz" + ] + }, + { + "accountId": "account_u9P7hnf3FR5yqm7FFyCnrP", + "accountName": "David Lanman", + "radiusUsernames": [ + "davidlanman" + ] + }, + { + "accountId": "account_eq2Po3eJPoEyv6RvhhyBMA", + "accountName": "Anthony Longo", + "radiusUsernames": [ + "anthonylongo" + ] + }, + { + "accountId": "account_7B7CchNkRbNa6LLxRBform", + "accountName": "Konrad Woelffer", + "radiusUsernames": [ + "konradwoelffer" + ] + }, + { + "accountId": "account_cdbWPX28Kv3YSaYUDf7zj1", + "accountName": "Martha Flores", + "radiusUsernames": [ + "marthaflores" + ] + }, + { + "accountId": "account_pV5Vstq2xr42fru5zVB28F", + "accountName": "Connor Spicer", + "radiusUsernames": [ + "connorspicer" + ] + }, + { + "accountId": "account_irdUq6W33yA7J9vpQ5t85q", + "accountName": "Kory Biggs Shop", + "radiusUsernames": [ + "korybiggsshop" + ] + }, + { + "accountId": "account_nnSEFSX86bVogx8KsqANUP", + "accountName": "Edna Griffin", + "radiusUsernames": [ + "ednagriffin" + ] + }, + { + "accountId": "account_jL9tR5FgswV7FZwxH3U5Ex", + "accountName": "Richard Rosson", + "radiusUsernames": [ + "richardrosson" + ] + }, + { + "accountId": "account_bHx8uSNfzCrYkD8JriK1jV", + "accountName": "Terry Rector", + "radiusUsernames": [ + "terryrector" + ] + }, + { + "accountId": "account_4y4ZEDFYqQhjW99pPJyLnf", + "accountName": "Donna Campbell", + "radiusUsernames": [ + "donnacampbell" + ] + }, + { + "accountId": "account_qh8bFPvWzyAqHqwHMDkThW", + "accountName": "Maria Castanon", + "radiusUsernames": [ + "mariacastanon" + ] + }, + { + "accountId": "account_sEcueqC1ysh7Ne2pE9qbg2", + "accountName": "Allen Taylor", + "radiusUsernames": [ + "allentaylor2" + ] + }, + { + "accountId": "account_gbzPYU8zHeCSV7E5c5eksc", + "accountName": "Mike Currence", + "radiusUsernames": [ + "mikecurrence" + ] + }, + { + "accountId": "account_3wT7DTZj4An5jaitLTTWTA", + "accountName": "Luis Velira", + "radiusUsernames": [ + "luisvelira" + ] + }, + { + "accountId": "account_4yPBWjMpwT4tVHJyVTDiYX", + "accountName": "Angela Trevino", + "radiusUsernames": [ + "angelatrevino" + ] + }, + { + "accountId": "account_aM6WQ3aysaRPx4zpUh3zWD", + "accountName": "Steve Combest", + "radiusUsernames": [ + "stevecombest" + ] + }, + { + "accountId": "account_7Wjw5Lru3JmVxq4s6cRqvn", + "accountName": "Keith Tucker", + "radiusUsernames": [ + "keithtucker" + ] + }, + { + "accountId": "account_bB6RDmNpkzndVbSh9CvgaL", + "accountName": "Brittannie Redden", + "radiusUsernames": [ + "brittannieredden1" + ] + }, + { + "accountId": "account_o7rGD6dKJP27Sf9AUMK2e2", + "accountName": "Amy Montelongo", + "radiusUsernames": [ + "amymontelongo" + ] + }, + { + "accountId": "account_t23HG5uEXET828jSbivozo", + "accountName": "Michaela Smith", + "radiusUsernames": [ + "michaelasmith" + ] + }, + { + "accountId": "account_x46hMNq15qHmEvP6fNFdfm", + "accountName": "Samatha Theis", + "radiusUsernames": [ + "samanthatheis" + ] + }, + { + "accountId": "account_fztV7NVRixwgnD6n392ZfX", + "accountName": "Janis Stewart", + "radiusUsernames": [ + "janisstewart" + ] + }, + { + "accountId": "account_ctACFtZH9mDX1E9tVgkpzy", + "accountName": "Irene Garibay", + "radiusUsernames": [ + "irenegaribay" + ] + }, + { + "accountId": "account_mXx2EoT7dcv2am6asSEJFr", + "accountName": "Belinda Millener", + "radiusUsernames": [ + "belindamillener" + ] + }, + { + "accountId": "account_hVh2CveE2tBqctZhpLX5Nf", + "accountName": "Kailynn Barnfield", + "radiusUsernames": [ + "kailynnbarnfield" + ] + }, + { + "accountId": "account_fETWiC2bAqbb7hkQ5UvKjj", + "accountName": "Thomas Graham", + "radiusUsernames": [ + "thomasgraham" + ] + }, + { + "accountId": "account_4hoTCjwTatthCg7zp2Cnfe", + "accountName": "Syeda Haque", + "radiusUsernames": [ + "sayedahaque" + ] + }, + { + "accountId": "account_uRC7LnETNNZdRmSEQeQbwX", + "accountName": "cory rees", + "radiusUsernames": [ + "coryrees" + ] + }, + { + "accountId": "account_gazJNXCsEwG1UJBmYyQ6xB", + "accountName": "Briana Martinez", + "radiusUsernames": [ + "brianamartinez" + ] + }, + { + "accountId": "account_5xtd1ApTppgpHnRpvmQHmV", + "accountName": "Khushboo Agarwal", + "radiusUsernames": [ + "khushbooagarwal" + ] + }, + { + "accountId": "account_hZD7ECmwCWd5vG73hEYDUN", + "accountName": "Elisa Sanders", + "radiusUsernames": [ + "elisasanders" + ] + }, + { + "accountId": "account_3froYZwHV4X7v9wW6TVu8N", + "accountName": "Jose Medrano", + "radiusUsernames": [ + "josemedrano" + ] + }, + { + "accountId": "account_iPnrLc4CzunZuqhRdm3Jwd", + "accountName": "Bradley Merchant", + "radiusUsernames": [ + "karengreen" + ] + }, + { + "accountId": "account_wSzw7Xd3wb871Ug9aaqUcH", + "accountName": "Nicholas Terry", + "radiusUsernames": [ + "nicholasterry" + ] + }, + { + "accountId": "account_dQfUEUmdqUu6rGALsoowVQ", + "accountName": "Leslie Peralta", + "radiusUsernames": [ + "leslieperalta" + ] + }, + { + "accountId": "account_j2iH6cW5tFhsSKxjLUvQBb", + "accountName": "Khushboo Agarwal 2", + "radiusUsernames": [ + "khushbooagarwal2" + ] + }, + { + "accountId": "account_nCAKAgLxtPx5H68AJZMNUA", + "accountName": "Roy Rogers", + "radiusUsernames": [ + "royrogers" + ] + }, + { + "accountId": "account_pR6sTHc4xvLqPfAZhJxrxd", + "accountName": "Arturo Prieto", + "radiusUsernames": [ + "arturoprieto" + ] + }, + { + "accountId": "account_gG5uDHsz8FPiuknRCkpbHQ", + "accountName": "Erica Morris", + "radiusUsernames": [ + "ericamorris" + ] + }, + { + "accountId": "account_gWD9gTNmR8RbzQkJmjpRYy", + "accountName": "Jeffrey Harder", + "radiusUsernames": [ + "jeffreyharder" + ] + }, + { + "accountId": "account_g2THEPVu4zgnGsTeFh6MpF", + "accountName": "Pablo Martinez", + "radiusUsernames": [ + "pablomartinez" + ] + }, + { + "accountId": "account_hiH7WbsYQm1HRkpGA3jJ2v", + "accountName": "Krystal Mendez", + "radiusUsernames": [ + "krystalmendez" + ] + }, + { + "accountId": "account_kAa1VyPLqz5fecRMXpV7d4", + "accountName": "David Vandagriff", + "radiusUsernames": [ + "davidvandagriff" + ] + }, + { + "accountId": "account_wsZpXzz2mhYMxMLmiVGNvF", + "accountName": "Carolyn Walker", + "radiusUsernames": [ + "carolynwalker" + ] + } +] \ No newline at end of file diff --git a/gaiia/radius_users.txt b/gaiia/radius_users.txt new file mode 100644 index 0000000..1d59a07 --- /dev/null +++ b/gaiia/radius_users.txt @@ -0,0 +1,415 @@ +| 29581 | joseortiz | Cleartext-Password | := | J2WEYT7782UJ | +| 29578 | perlachavez2 | Cleartext-Password | := | U4BBTBMCJZST | +| 29575 | thomashernandez | Cleartext-Password | := | X6BZLA7SC3XM | +| 29572 | williamarmstead | Cleartext-Password | := | SAQ4XEKXDC58 | +| 29570 | julierose | Cleartext-Password | := | MLM8R2WAGY52 | +| 29571 | derrickmccausland | Cleartext-Password | := | ETNLKZHACGF7 | +| 29567 | sherrifredrick | Cleartext-Password | := | 7P52BVMTSNN6 | +| 29565 | shelbyburris | Cleartext-Password | := | 3YAL9DWFZUPT | +| 29564 | priscillacrenshaw | Cleartext-Password | := | AXBDLXSGXMLR | +| 29561 | korybiggs | Cleartext-Password | := | 4FJC7DXAW5RV | +| 29563 | kristinjimenez | Cleartext-Password | := | 9FAYXMRDU69T | +| 29558 | jessicaweddle | Cleartext-Password | := | VV96G36AEEDA | +| 29557 | yolandamedrano | Cleartext-Password | := | R948UL4KZRA4 | +| 29555 | williambowland | Cleartext-Password | := | K7HYNWGE25XX | +| 29554 | marcuspatton | Cleartext-Password | := | GEUXX27VLZXK | +| 29553 | shacaciabillings | Cleartext-Password | := | 8AZLJCDFYFYV | +| 29552 | guadalupelugo | Cleartext-Password | := | DQG8XCMFMVH5 | +| 29551 | brandonhardesty | Cleartext-Password | := | KMRHVPEXBEZ8 | +| 29550 | nancyavellaneda | Cleartext-Password | := | CXATYV7NGZN2 | +| 29549 | matthewgoodwin | Cleartext-Password | := | VSPM7N9UNU6Y | +| 29545 | donmckinney | Cleartext-Password | := | C6AQ6UVD6R7R | +| 29544 | natashaelmore | Cleartext-Password | := | 8G5QJ8R7MK8Q | +| 29543 | shamsbashir | Cleartext-Password | := | WLBGY4JMN4VA | +| 29541 | douglasglover | Cleartext-Password | := | 9Y6H9X3HCG3D | +| 29538 | carolstrickland | Cleartext-Password | := | 547P6AQ8RCD4 | +| 29537 | carmenlopez | Cleartext-Password | := | TPLND3H2FWTQ | +| 29534 | johnlooper | Cleartext-Password | := | ZPL8LRY2TGRH | +| 29535 | davidroesch | Cleartext-Password | := | ZHB2BS968DTU | +| 29531 | daycor | Cleartext-Password | := | U45YBPSARH7N | +| 29530 | derekrodriguez | Cleartext-Password | := | AP7Z594DMR7E | +| 29524 | chandparvathaneni | Cleartext-Password | := | TK4QA3XM5XK5 | +| 29522 | joshuasoliz | Cleartext-Password | := | PE7MHRZWUEQM | +| 29521 | tiffanyyarley-olenak | Cleartext-Password | := | 98HTZ9Q8FARG | +| 29514 | ericbarrett | Cleartext-Password | := | 5S2CV9FEFMKG | +| 29513 | marysmelser | Cleartext-Password | := | ZS74NS5BBPX3 | +| 29506 | nicolemaenn | Cleartext-Password | := | B7KS27WL5WSN | +| 29503 | yolandajudge | Cleartext-Password | := | 6V8RLNK75LM8 | +| 29501 | craighovde | Cleartext-Password | := | AVZXWBX8GQTC | +| 29498 | joselucas | Cleartext-Password | := | ZHMW9TE8QHRX | +| 29488 | douggarber | Cleartext-Password | := | WXP9J5SRJWEJ | +| 29482 | carmencleghorn | Cleartext-Password | := | VH3CBTJZAJ2Y | +| 29472 | rubenreyez | Cleartext-Password | := | 66384Y7B4WR4 | +| 29464 | tiffanystewart | Cleartext-Password | := | 3EGVCS6RMF3Z | +| 29449 | dejadodson | Cleartext-Password | := | FLR4DJHPX6TG | +| 29447 | coreyball | Cleartext-Password | := | 3JAUWZNAR9MB | +| 29442 | toniyoung | Cleartext-Password | := | RJ24VTRM7JA9 | +| 29434 | melanieweddle | Cleartext-Password | := | XQ5L7PUM9RP6 | +| 29431 | zackknuckey | Cleartext-Password | := | VP5CCX8LTE47 | +| 29425 | christinehamparyan | Cleartext-Password | := | WEYX9R5P7J5L | +| 29422 | joespeciale | Cleartext-Password | := | UH6ZCM9WLGRQ | +| 29420 | rogerbatt | Cleartext-Password | := | KGDJZ7V66QQK | +| 29419 | kalebjames | Cleartext-Password | := | KMQDGPD5XB6Z | +| 29416 | stevemolina | Cleartext-Password | := | BLLHWPY9RCTC | +| 29414 | mariatrejo | Cleartext-Password | := | EYPWCQKKWYZH | +| 29410 | kenhall | Cleartext-Password | := | LX897BGJNMK5 | +| 29407 | deewilliams | Cleartext-Password | := | R8AGX3E8WAHN | +| 29406 | johnvayo | Cleartext-Password | := | DJJNUEEHGAW5 | +| 29398 | anthonythompson | Cleartext-Password | := | WB4E4MPJVHTB | +| 29608 | stevecombest | ClearText-Password | := | STVN23552Wt | +| 29606 | mariacastanon | ClearText-Password | := | iu297gkid9 | +| 29395 | vanessagomez1 | Cleartext-Password | := | J5JEW3HEBVAF | +| 29607 | luisvelira | ClearText-Password | := | uygo875491! | +| 29609 | keithtucker | ClearText-Password | := | 14FYJSNUU48 | +| 29610 | angelatrevino | ClearText-Password | := | KJGU39565 | +| 29611 | brittannieredden1 | ClearText-Password | := | TYFTUEZ7429 | +| 29612 | amymontelongo | ClearText-Password | := | ig76f7fuvu | +| 29613 | michaelasmith | ClearText-Password | := | MS21353GHY | +| 29614 | samanthatheis | ClearText-Password | := | kf6u8djigk | +| 29615 | janisstewart | ClearText-Password | := | JD834HCUW1C | +| 29616 | irenegaribay | ClearText-Password | := | uky75dkc | +| 29383 | leonardocoronel | Cleartext-Password | := | TXPC494 | +| 29374 | alejandrolimon | Cleartext-Password | := | MM4BSQS7FRTD | +| 29368 | crystalharney | Cleartext-Password | := | A7W26H834WLJ | +| 29358 | radiantlifeministries | Cleartext-Password | := | 12FDIUNF5L7vM | +| 29355 | olgagutierrez | Cleartext-Password | := | WERWRMUDBZ85 | +| 29353 | greghummel | Cleartext-Password | := | JV84HSVAK6XR | +| 29349 | bettymitchell | Cleartext-Password | := | 8CHHCNBWQLRV | +| 29341 | wandadavis | Cleartext-Password | := | JXQXXZ9MKFX6 | +| 29338 | scottanderson | Cleartext-Password | := | RJ49A43SG3RA | +| 29335 | jenniferboon | Cleartext-Password | := | 28ESU8ELVELP | +| 29331 | marialopez | Cleartext-Password | := | P3FNSPT7MEYM | +| 29329 | cynthiasandlin | Cleartext-Password | := | MCDTLMVFQXAV | +| 29328 | bethanybell | Cleartext-Password | := | 3UUC5X27PETS | +| 29323 | garrettojeda | Cleartext-Password | := | U5PRQYZE2MM3 | +| 29325 | rayschaak | Cleartext-Password | := | XSNLP62EK6VZ | +| 29327 | carlaswearingen | Cleartext-Password | := | E6DQ4B2T9JZD | +| 29320 | lesliebewley | Cleartext-Password | := | 55KFSJMEDYLK | +| 29317 | delainawaite | Cleartext-Password | := | EM7TWHUS4SYU | +| 29307 | esparanzagarza | Cleartext-Password | := | VLL9ZNPRSTG5 | +| 29296 | midvalleyconcrete | Cleartext-Password | := | BD58JYT2PJ6A | +| 29273 | glendabeauchamp | Cleartext-Password | := | RKA3J8WSPYJP | +| 29256 | glindarichter | Cleartext-Password | := | VXMLMDAFLNJ3 | +| 29251 | nhumorrison | Cleartext-Password | := | EC32VDFKB9CB | +| 29245 | brittanyworley | Cleartext-Password | := | NBKYERRBSBH4 | +| 29244 | janiscable | Cleartext-Password | := | JHC7U554JBTR | +| 29243 | dustynfisher | Cleartext-Password | := | XASFU3DBPZ5M | +| 29237 | stephenbeegle | Cleartext-Password | := | RU5GZVTVACRC | +| 29229 | roseanndellavalle | Cleartext-Password | := | 5GAF6JHUWWYP | +| 29231 | duanewright | Cleartext-Password | := | VXK4FBLVFE7F | +| 29222 | pablohernandez | Cleartext-Password | := | P3YB6A4JGEDF | +| 29217 | pavelavanesov | Cleartext-Password | := | KP8ZSGC2DG2U | +| 29216 | wanxiaxong | Cleartext-Password | := | BER4NEK9M7NA | +| 29215 | bryangoulart | Cleartext-Password | := | DTNQFE4CXSFX | +| 29204 | sandrahoughton | Cleartext-Password | := | 5XR4QMPBXVWY | +| 29203 | adamstaeben | Cleartext-Password | := | GP7RANJ8UCYP | +| 29190 | russmott | Cleartext-Password | := | J8ZN888UQSLR | +| 29189 | karenstewart | Cleartext-Password | := | JSKTFAJ2JYDX | +| 29185 | heatherwomack | Cleartext-Password | := | 89W7S5Q7UAHP | +| 29183 | wyndijames | Cleartext-Password | := | CPSDNRQU95HJ | +| 29182 | angelicacuayahuitl | Cleartext-Password | := | VK8PZW3LK5F3 | +| 29181 | chasewilliams | Cleartext-Password | := | W6AMPQEPY3BC | +| 29176 | kevinarana | Cleartext-Password | := | JAJYSEG75UZS | +| 29166 | chrisclayton | Cleartext-Password | := | BFJ462PLHJFV | +| 29162 | billyhorton | Cleartext-Password | := | MPTX573ZUYYG | +| 29151 | chisediquezada | Cleartext-Password | := | RJBYQ2NUB829 | +| 29150 | donwilhelmus | Cleartext-Password | := | 2V4NEL87RM39 | +| 29147 | angelmerlo | Cleartext-Password | := | EWWAPXN9Z2YS | +| 29135 | kavalleriefarm | Cleartext-Password | := | 8DFNNTPBPZGY | +| 29113 | kerryblack | Cleartext-Password | := | 9D5S9ZD2FCBV | +| 29110 | cliffordjennings | Cleartext-Password | := | 5GC7M65H9VGT | +| 29098 | carlakimberling | Cleartext-Password | := | SS4KRJYKTV7B | +| 29088 | richardconklin | Cleartext-Password | := | WEUNG5H3K8BB | +| 29089 | leandrabeshea | Cleartext-Password | := | 7V8BZU4G83VF | +| 29077 | kellygarza | Cleartext-Password | := | 4696591662 | +| 29070 | 1stchoiceliving | Cleartext-Password | := | YVWJWRPEVYNH | +| 29067 | robertfinch | Cleartext-Password | := | D7FMTT455GBB | +| 29058 | ericcoffman | Cleartext-Password | := | MMEBXQYLZWNU | +| 29050 | nathanaelfinch | Cleartext-Password | := | JVNC68FV7N6B | +| 29047 | gabrielasereno | Cleartext-Password | := | KCH82WCRA2YR | +| 29045 | georginacovarrubias | Cleartext-Password | := | 8QT57NUV8C7R | +| 29035 | thomascline | Cleartext-Password | := | E523BKVU5MJP | +| 29034 | susanlewis | Cleartext-Password | := | R7TZFZEGLNRW | +| 29025 | tammywelborn | Cleartext-Password | := | RMC482WNBS7 | +| 29023 | haleymuela | Cleartext-Password | := | BS7X3DTKW8TH | +| 29018 | christelles | Cleartext-Password | := | WL2TKLFG8G2H | +| 29017 | mauricioantonio | Cleartext-Password | := | 6QJW7RAPDBWB | +| 29013 | vincentlimon | Cleartext-Password | := | YDDXKFABUZY7 | +| 29602 | tech | ClearText-Password | := | brussels-crucial-smashing | +| 29634 | jeffreyharder | ClearText-Password | := | YUV565D67RT | +| 29635 | pablomartinez | ClearText-Password | := | 2766581BER | +| 29636 | krystalmendez | ClearText-Password | := | 6FTCKU6S56 | +| 29637 | davidvandagriff | Cleartext-Password | := | UTRC53865F9 | +| 29638 | carolynwalker | Cleartext-Password | := | trjxtyx75! | +| 28979 | claygilbert | Cleartext-Password | := | 3E6XFDHXCAG7 | +| 28978 | jmichaelculverhouse | Cleartext-Password | := | 2147099521 | +| 28971 | diegosereno | Cleartext-Password | := | 2CE9F5YSPQHX | +| 28970 | ashleysmith | Cleartext-Password | := | F8D3FVCZB7PD | +| 28954 | dougstowe | Cleartext-Password | := | 5D35LFD6GEAZ | +| 28936 | timbronschidle | ClearText-Password | := | 3ZVLCWSAE3EW1 | +| 28935 | chrislewis1 | Cleartext-Password | := | V9DS4E4Y7UTT | +| 28928 | cynthiajones | Cleartext-Password | := | KC7ESYHEZGNR | +| 28917 | crankkeith | Cleartext-Password | := | kcrank10615 | +| 28879 | kimberlyrichards | Cleartext-Password | := | 7PQA6J8RPZ9Z | +| 28867 | bobbymack | Cleartext-Password | := | UDUBTCXXF8VV | +| 28848 | americatrejo | Cleartext-Password | := | 3B63TLTT9WE4 | +| 28832 | tjbanschbach | Cleartext-Password | := | 8WTWTXACGG5N | +| 28811 | luiscabrera | Cleartext-Password | := | RWUQ94XT9A9Z | +| 28810 | rosahernandez | Cleartext-Password | := | JSGUXKWEMSVN | +| 28807 | carlosgomez | Cleartext-Password | := | J22RS7F3LGG2 | +| 28800 | kennethcampbell | Cleartext-Password | := | !9727342446! | +| 28763 | jeridrutledge | Cleartext-Password | := | GJ3CZTRJJW2M | +| 28748 | timthomas | Cleartext-Password | := | NY223RQJD7PN | +| 28715 | carolbates | Cleartext-Password | := | J3UBUCMYDX64 | +| 28710 | shirleyelmore | Cleartext-Password | := | D8Y64N9L4R9W | +| 28703 | grahammcintire2 | Cleartext-Password | := | U5Y3hA3g*14L | +| 28702 | chrissyeagle2 | Cleartext-Password | := | NJPCGPBNFB | +| 28698 | swedlund | Cleartext-Password | := | swedlund494 | +| 28695 | wendysanders | Cleartext-Password | := | RYG9L43NWDZV | +| 28693 | robbymccollom | Cleartext-Password | := | RMC482WNBS7 | +| 28671 | melodymccarty | Cleartext-Password | := | U3WLEGEB | +| 28664 | heatherbrister2 | Cleartext-Password | := | KK37V8W9ZD9Z | +| 28663 | ryanlloyd | Cleartext-Password | := | HWEMKNWX55RF | +| 28661 | amyrodriguez | Cleartext-Password | := | WX3QRT2U6L4X | +| 28660 | thecountrycutter | Cleartext-Password | := | YXpScFIneePm | +| 28659 | bradwilson | Cleartext-Password | := | ZFAXA9AK63AH | +| 28654 | edrater | Cleartext-Password | := | FRY4823N | +| 28642 | barbihardin | Cleartext-Password | := | ZENETJ75TEVQ | +| 28637 | vikyacosta | Cleartext-Password | := | Z4SMV49SAH72 | +| 28633 | terrimack | Cleartext-Password | := | B9HNTSN7ASQ4 | +| 28628 | jackworthy | Cleartext-Password | := | 2VUR7AAFWZZQ | +| 28617 | luisarellano | Cleartext-Password | := | GVNWBQMM3S7U | +| 28609 | dorisavalos | Cleartext-Password | := | DLKMJS88AF2W | +| 28599 | ryanrodriguez | Cleartext-Password | := | 9DFYWJ47YWUP | +| 28597 | jackierollier | Cleartext-Password | := | TSFNN8DVDSPR | +| 28588 | charlesmccreary | Cleartext-Password | := | 88J6XS6TXRMA | +| 28548 | jamessmith | Cleartext-Password | := | LRVU6RVG5SP8 | +| 28546 | davidwhitefoot | Cleartext-Password | := | PYHTZMHJU8R8- | +| 28542 | michaelray2 | Cleartext-Password | := | jjPgDcotL6rtY | +| 28536 | patriciavasquez | Cleartext-Password | := | P88256487V | +| 28535 | adrianmcdaniel | Cleartext-Password | := | U4TGDH2PSAB4 | +| 28526 | justinvoga | Cleartext-Password | := | URHVCX7SFSFU | +| 28518 | jenniferlittle | Cleartext-Password | := | 8EAUUDQPXSQX | +| 28510 | bradslate | Cleartext-Password | := | AF4KVQ2P44M7 | +| 28494 | irisdelira | Cleartext-Password | := | L6XGQA583DG6 | +| 28491 | richardbarragan | Cleartext-Password | := | WJBS86MMJBFH | +| 28476 | jensenhall | Cleartext-Password | := | F8XM9U8SC6LL | +| 28466 | paulcomella | Cleartext-Password | := | YTZDMBZCMPX4 | +| 28460 | saidaacosta | Cleartext-Password | := | V29897X4 | +| 28458 | kennethcampbell2 | Cleartext-Password | := | V5TKW8L6RXFF | +| 28449 | amberkrings | Cleartext-Password | := | 5NWBR3JLV6N8 | +| 28438 | mauriciosoto | Cleartext-Password | := | DJ7YP7W7AA8M | +| 28435 | jimcrank | Cleartext-Password | := | PLA7XNX2F4Y3 | +| 28425 | summerbeazley | Cleartext-Password | := | D4S4YRB8Y3KA | +| 28426 | davidarmstrong | Cleartext-Password | := | MPEZGG53KQSZ | +| 28422 | jackiehendricks | Cleartext-Password | := | UQR5HGFCJ4XX | +| 28400 | bonniestone | Cleartext-Password | := | BH8BV44TTWUU | +| 28395 | stevenspurgers | Cleartext-Password | := | YS5Q785J2HGZ | +| 28388 | connercoleman | Cleartext-Password | := | 6108RANDOMREDDOG | +| 28367 | judithfierro | Cleartext-Password | := | FUZ33LB6SFFC | +| 28359 | tonyasipes | Cleartext-Password | := | N5VEZR8Q5M86 | +| 28358 | stephenday | Cleartext-Password | := | MSVGQ8X38YLN | +| 28347 | kathysmith | Cleartext-Password | := | SJ53PSYCZLPR | +| 28344 | cherieeshelman | Cleartext-Password | := | BRUV99PN6Y7L | +| 28336 | buddyswan | Cleartext-Password | := | N7XUYUD7J9SD | +| 28296 | cinthiapereira1 | Cleartext-Password | := | 9WSGXM9LBXGZ | +| 28289 | ronberger | Cleartext-Password | := | Ronb499H7ALW | +| 28268 | alberto | Cleartext-Password | := | TICOGUILLO | +| 28267 | sandracrouch | Cleartext-Password | := | V4N3764R5J | +| 28263 | abbieandrews | Cleartext-Password | := | YQSJ499H7ALW | +| 28253 | perlachavez | Cleartext-Password | := | ZWR9PXVFFYBS | +| 28246 | susanking | Cleartext-Password | := | N2KUYLPDNXJP | +| 28241 | terrybachran | Cleartext-Password | := | NG9VMH25LCTC | +| 28240 | lanaygaunce | Cleartext-Password | := | MJCNZAJHK9H3 | +| 28213 | krisdougherty | Cleartext-Password | := | RWEH75UJ38 | +| 28190 | heatherbrister | Cleartext-Password | := | 6XPLC545G6HW | +| 28184 | jenniferdunaway | Cleartext-Password | := | 343V5CT2R7HZ | +| 28159 | elviraquezada | Cleartext-Password | := | HXAS29VRX33V | +| 28154 | jordainpatton | Cleartext-Password | := | J8P4TPF7QS2A | +| 28140 | mattkosarek | Cleartext-Password | := | RJ4BQUZJY | +| 28117 | shannonclark | Cleartext-Password | := | F5XESMCJ | +| 28107 | jacquelinewilder | Cleartext-Password | := | HEY36LUGYCUQ | +| 28098 | kevincostanzo | Cleartext-Password | := | 244VGMVMH66D | +| 28093 | kenpatrick | Cleartext-Password | := | V7U3XBAPTSV3 | +| 28083 | briangallimore | Cleartext-Password | := | 39V285NX | +| 28072 | amberprater | Cleartext-Password | := | RP42B7AYDLAP | +| 28068 | marilynfowler | Cleartext-Password | := | MarilynVG72P8H | +| 28065 | douglaswilson | Cleartext-Password | := | HP97HG9XPYC3 | +| 28034 | terrygortney | Cleartext-Password | := | GQUGR3VJUXBR | +| 28031 | gizellebutler | Cleartext-Password | := | HMZTX66RTPVX | +| 28029 | bradgurney | Cleartext-Password | := | 7A6LRZWT82DX | +| 28024 | darrylchavis | Cleartext-Password | := | TDGUUNXGMYRC | +| 28025 | kathymazzola | Cleartext-Password | := | 5MVS8VUF9QL2 | +| 27994 | austinwatkins | Cleartext-Password | := | S7M4UD8QUTDP | +| 27988 | oscarrodriguez | Cleartext-Password | := | TGPCF3Y2ABXS | +| 27986 | erinthompson | Cleartext-Password | := | 69TQZ797 | +| 27983 | brianhardesty | Cleartext-Password | := | 8D4P34N8 | +| 27976 | cathyday | Cleartext-Password | := | FAXMKENV8FCT | +| 27956 | fredheckel | Cleartext-Password | := | HUVZSE8RGD65 | +| 27955 | evlynrichter | Cleartext-Password | := | FRQD738BGRC | +| 27949 | charlesboone | Cleartext-Password | := | Ccbn78wdfksB | +| 27948 | rachelguzzardi | Cleartext-Password | := | R87wgerf8bsG | +| 27939 | reynamoore | Cleartext-Password | := | R92nbskba7iM | +| 27936 | juanalonzo | Cleartext-Password | := | 745GF0JK5!W | +| 27930 | kristinamurphy | Cleartext-Password | := | oPXFrylqKf | +| 27926 | johnnygoble | Cleartext-Password | := | J829fhf7G | +| 27923 | keithbarber | Cleartext-Password | := | K73bvd54tB | +| 27917 | doreengrubb | Cleartext-Password | := | DOR32SVT1234 | +| 27916 | steveraia | Cleartext-Password | := | DRR648DFTMSE4 | +| 27912 | blancadiaz | Cleartext-Password | := | B548gdu55D | +| 27896 | twcowley | Cleartext-Password | := | TW486TW | +| 27888 | joepatton | Cleartext-Password | := | L7luYzSKSu | +| 27873 | timfisher | Cleartext-Password | := | Thd73gdf5eF | +| 27847 | kileigheven | Cleartext-Password | := | K9872gdf8E | +| 27838 | consueloluna1830 | Cleartext-Password | := | C8736gfd0 | +| 27833 | deannecook | Cleartext-Password | := | Dc872cobnC | +| 27830 | kacyharrison | Cleartext-Password | := | 7Ozr7el6K1 | +| 27822 | milliganwater | Cleartext-Password | := | 8NjGMNsTXd | +| 27813 | michaelnewby | Cleartext-Password | := | TM5M3HU877 | +| 27808 | ladonnaclark | Cleartext-Password | := | YQReVH9men | +| 27800 | jamesmooney | Cleartext-Password | := | QNUqD2tSzq | +| 27793 | branchrvpark | Cleartext-Password | := | XfKxVZIKmm | +| 27783 | tamythomas | Cleartext-Password | := | TT4699520858tt | +| 27781 | pambanschbach | Cleartext-Password | := | Q2p68#*9vwF2 | +| 27780 | josephcruz | Cleartext-Password | := | Z06oxJXrNd | +| 29601 | kimberlyrichards2 | ClearText-Password | := | K84f7r10GT108 | +| 27761 | franceskirby | Cleartext-Password | := | FK8179391152fk | +| 27759 | chadwhitsell | Cleartext-Password | := | 79374B75phGy | +| 27754 | ruthfengler | Cleartext-Password | := | 8q8MX6NQP3b6 | +| 27755 | gordonhamilton | Cleartext-Password | := | c3495Rpuq5Sh | +| 27751 | bobbyfisher | Cleartext-Password | := | salvagejuggle | +| 27748 | eddieyarbrough | Cleartext-Password | := | 89298ChZWzMy | +| 27744 | chrissyeagle | Cleartext-Password | := | 9vkX97426Sgt | +| 27739 | victoriaobier | Cleartext-Password | := | M57B288P9 | +| 27736 | bernardheer | Cleartext-Password | := | BH33682019BER | +| 27734 | garycable | Cleartext-Password | := | M2w6z5Y73 | +| 27735 | coyungemach | Cleartext-Password | := | MhS569N84e7a | +| 27733 | michaelmccauley | Cleartext-Password | := | CPORADIO98 | +| 27731 | lessruble | Cleartext-Password | := | 9U35SXxM2H8y | +| 27730 | marknunnelee | Cleartext-Password | := | 833j2Ymm4yfV | +| 27729 | elizabethchristian | Cleartext-Password | := | Webg22nj | +| 27724 | mikebell | Cleartext-Password | := | BHF538HSEBDLY4 | +| 27713 | krystabates | Cleartext-Password | := | KB2145445824kb | +| 27709 | sensibleheatsystems | Cleartext-Password | := | SHQWERY23 | +| 27708 | chrisfrancis | Cleartext-Password | := | DR45QC74M | +| 27704 | lambandlion | Cleartext-Password | := | LL9727363567ll | +| 27701 | markfisher | Cleartext-Password | := | 32fd8P&rw8s7 | +| 27696 | kirkvanmeter | Cleartext-Password | := | J2sR9g^83M33 | +| 27694 | dananance | Cleartext-Password | := | 23SP$e!ug232 | +| 27690 | keithcrank | Cleartext-Password | := | 2zt3T5w72T3A | +| 27687 | scottarmstrong | Cleartext-Password | := | 2SV58M55 | +| 27683 | floydday | Cleartext-Password | := | Tfdd3254tfD | +| 27680 | mariacortes | Cleartext-Password | := | MC2145858276mc | +| 27668 | christianlummus | ClearText-Password | := | K6dbr631ddL | +| 27666 | marklindsey | Cleartext-Password | := | ML9727346434ml | +| 27658 | joeywhitfield | Cleartext-Password | := | BV758qXne446 | +| 27654 | taekim | Cleartext-Password | := | SAN11837TCK | +| 27652 | teresarobinson | Cleartext-Password | := | T645trdf87fR | +| 27651 | nathanmctee | Cleartext-Password | := | N32462uu8rM | +| 27646 | rebekahmoore | Cleartext-Password | := | R756346tfg4M | +| 27643 | monicatrevino | Cleartext-Password | := | MT2145877143mt | +| 27640 | rachelfuller | Cleartext-Password | := | R8364hbst | +| 27639 | choonpang | Cleartext-Password | := | CP2143855677cp | +| 27638 | shannonbrown | Cleartext-Password | := | S9725298223b | +| 27637 | allentaylor | Cleartext-Password | := | A6346gge8T1 | +| 27632 | kristenaustin | Cleartext-Password | := | KA8017916972ka | +| 27630 | colinhopper | Cleartext-Password | := | TYVU65445IUB | +| 27625 | mattbud | Cleartext-Password | := | kjklfda79sd | +| 27621 | richardhill | Cleartext-Password | := | RH9729788124rh | +| 27619 | srireddy | Cleartext-Password | := | ycHAvetmF0 | +| 27618 | tammieventris | Cleartext-Password | := | DR45QC74M | +| 27615 | almaacosta | Cleartext-Password | := | ENKC5DGV | +| 27614 | chrispassonno | Cleartext-Password | := | C8766thf96P | +| 27613 | seanbaxter | Cleartext-Password | := | SB4696679337sb | +| 27612 | collinhartman | Cleartext-Password | := | B886tg98jt9L | +| 27610 | michaelhughes | Cleartext-Password | := | 1RKk32MyTK | +| 27608 | michaeltalbot | Cleartext-Password | := | Mjj1276rt99T | +| 27606 | anagonzalez | Cleartext-Password | := | WNAHDMwWoE | +| 27604 | leonardlewis | Cleartext-Password | := | L1877ef32L | +| 27598 | jerryjohnson | Cleartext-Password | := | J177895aa98J | +| 27594 | pamtoler | Cleartext-Password | := | NfxcgYQUbj | +| 27589 | jodymccluskey | Cleartext-Password | := | JM4696883349jm | +| 27579 | donnance | Cleartext-Password | := | 9727346814 | +| 27577 | chrisvannatta | Cleartext-Password | := | !4694502484! | +| 27575 | mariomerlo | Cleartext-Password | := | !2149579585! | +| 27572 | williamarmstrong | Cleartext-Password | := | !2145510334! | +| 27571 | trecedaschmid | Cleartext-Password | := | !4692075942! | +| 27570 | ronlewis | Cleartext-Password | := | JDkf1AL942 | +| 27567 | sherryerichardson | Cleartext-Password | := | DR6528BCD3S2 | +| 27566 | joannarodriguez | Cleartext-Password | := | 394X3YR9 | +| 27561 | dannycampbell | Cleartext-Password | := | !9726589541! | +| 27560 | howardcomstock | Cleartext-Password | := | !3365494230! | +| 27555 | terrymiesen | Cleartext-Password | := | !4697420828! | +| 27550 | michaelray | Cleartext-Password | := | primitivedefinitionstoredinner | +| 27545 | terrybates | Cleartext-Password | := | !2145786644! | +| 27538 | gregmcintire | Cleartext-Password | := | v9368$E9mx9m | +| 27543 | beverlyerwin | Cleartext-Password | := | MOOLAH | +| 27518 | helenlumpkin | Cleartext-Password | := | Heehhejd0N | +| 27508 | stevechristiaens | Cleartext-Password | := | zY272m258*vJ | +| 27505 | judydevine | Cleartext-Password | := | OD4697669725od | +| 27501 | jonnytaylor | Cleartext-Password | := | Zb6677&4!yky | +| 27500 | rhondabolton | Cleartext-Password | := | RB2145786030rb | +| 27492 | rickbeckham | Cleartext-Password | := | tVj7ZI5QA0 | +| 27489 | jenniferreed | Cleartext-Password | := | 683P7V7R | +| 27490 | vancepeltonen | Cleartext-Password | := | V66rdf43aaP | +| 27486 | jacobwilliams | Cleartext-Password | := | jnlka9994jkfldsa | +| 27482 | irvinbarojas | Cleartext-Password | := | yDeEJzYY08 | +| 27478 | classicshear | Cleartext-Password | := | KARENALDERSON | +| 27477 | davekiowski | Cleartext-Password | := | FNW63KXSAPT7 | +| 27463 | dennispereyra | Cleartext-Password | := | 82s62kd9XHhd | +| 27458 | paulsammataro | Cleartext-Password | := | P2n74V7P8g4S | +| 27460 | janetkern | Cleartext-Password | := | j42252^Bhf44 | +| 27457 | carlossalaises | Cleartext-Password | := | 28E358y$JFB9 | +| 27453 | obedsandoval | Cleartext-Password | := | *H78z9xy84C& | +| 27455 | dianewiley | Cleartext-Password | := | Diane03t4&adTL | +| 27444 | tammylove | Cleartext-Password | := | f65NS4Ct6$29 | +| 27441 | samanthasmith | Cleartext-Password | := | 22jmR9X#93j5 | +| 27438 | floydallen | Cleartext-Password | := | V3864VB2 | +| 27436 | clayrobertson2 | Cleartext-Password | := | C2147288546R | +| 27432 | hortenciaalvarez | Cleartext-Password | := | WHTABORG3593JKS | +| 27424 | timbagert | Cleartext-Password | := | CF18n42cY7 | +| 27425 | davidbaker | Cleartext-Password | := | DB764g1d87rtB | +| 27421 | janicealexander | Cleartext-Password | := | AG57TSH89L9 | +| 27423 | timgilbert | Cleartext-Password | := | 73xi0r600Sz% | +| 27417 | donniecampbell | Cleartext-Password | := | 6a2671bc0c1 | +| 27418 | alanwyatt | Cleartext-Password | := | i1412r53MYr | +| 27413 | jesusrios | Cleartext-Password | := | 8Di6QMs7%7m1 | +| 27408 | penneywarner | Cleartext-Password | := | b4h53N6c#5wo | +| 27409 | veronabaptist | Cleartext-Password | := | DAY753LIGHT253 | +| 27406 | whiteywhite | Cleartext-Password | := | 45z6r*H4418n | +| 27404 | grahammcintire | Cleartext-Password | := | U5Y3hA3g*14L | +| 27402 | louislafon | Cleartext-Password | := | RFTRE4825CVKL | +| 27397 | ausencioluna | Cleartext-Password | := | ABN765MNL | +| 27374 | davidvillanueva | Cleartext-Password | := | DV5489CRPMWBQ | +| 29620 | sayedahaque | ClearText-Password | := | YR4Sytbu | +| 29621 | coryrees | ClearText-Password | := | yt5c6ujhvu | +| 29622 | brianamartinez | ClearText-Password | := | ktydr6428 | +| 29623 | khushbooagarwal | ClearText-Password | := | UUTFVUCTC446 | +| 29624 | elisasanders | ClearText-Password | := | OYTCu3276 | +| 29625 | josemedrano | ClearText-Password | := | UYVITRUR24 | +| 29626 | karengreen | ClearText-Password | := | UTKCYI674 | +| 29627 | nicholasterry | ClearText-Password | := | VUTYVL746 | +| 29628 | leslieperalta | ClearText-Password | := | RYC66747 | +| 29629 | khushbooagarwal2 | ClearText-Password | := | iytf65gkj | +| 29630 | sriahobilamuth | ClearText-Password | := | RTCESW24 | +| 29631 | royrogers | ClearText-Password | := | TYDGVBNC | +| 29632 | arturoprieto | ClearText-Password | := | 1653APVN877 | +| 29633 | ericamorris | ClearText-Password | := | E16488VN387 | +| 29617 | belindamillener | ClearText-Password | := | Uiyu7280 | +| 29618 | kailynnbarnfield | ClearText-Password | := | HAkuguuvoy1 | +| 29619 | thomasgraham | ClearText-Password | := | YUiygiiu4 | +| 27315 | debravega | Cleartext-Password | := | RCG8618BDUMSR | +| 29582 | davidlanman | Cleartext-Password | := | 2BMMRYQ2PHEA | +| 29583 | anthonylongo | Cleartext-Password | := | 9MLG8HVME4J3 | +| 29584 | konradwoelffer | Cleartext-Password | := | BCQ8NRFVNA3R | +| 29585 | marthaflores | Cleartext-Password | := | KG2QEG36348V | +| 29586 | connorspicer | Cleartext-Password | := | 6U2BWJKGCX8G | +| 29588 | korybiggsshop | Cleartext-Password | := | JA4SXBGKHFUL | +| 29589 | ednagriffin | Cleartext-Password | := | AXA8EZEAN7DJ | +| 29590 | richardrosson | Cleartext-Password | := | 9J5L6MEH4Y7Y | +| 29603 | allentaylor2 | ClearText-Password | := | NCJW87FW8723 | +| 29604 | alextovias | ClearText-Password | := | 7VSgrVO1WX | +| 29605 | mikecurrence | ClearText-Password | := | MKH23FG79C | +| 29594 | terryrector | Cleartext-Password | := | QUPP83ZUUSGL | +| 29595 | donnacampbell | Cleartext-Password | := | KYCNTWS3929N | +| 29600 | billmctee | ClearText-Password | := | Bdge6388M | \ No newline at end of file diff --git a/gaiia/src/gaiia/__init__.py b/gaiia/src/gaiia/__init__.py new file mode 100644 index 0000000..d22f21e --- /dev/null +++ b/gaiia/src/gaiia/__init__.py @@ -0,0 +1,41 @@ +"""gaiia GraphQL API client. + +Quick start: + + from gaiia import GaiiaClient + + with GaiiaClient(api_key="gaiia_sk_live_...") as g: + result = g.query("{ technicians(first: 10) { nodes { id firstName lastName } } }") +""" + +from gaiia.client import AsyncGaiiaClient, GaiiaClient +from gaiia.errors import ( + GaiiaAPIError, + GaiiaError, + GaiiaMutationError, + GaiiaRateLimitedError, + GaiiaTransportError, +) +from gaiia.globalid import decode_global_id, encode_global_id +from gaiia.ratelimit import RateLimitInfo +from gaiia.webhooks import ( + WebhookSignatureError, + verify_webhook_signature, +) + +__all__ = [ + "AsyncGaiiaClient", + "GaiiaAPIError", + "GaiiaClient", + "GaiiaError", + "GaiiaMutationError", + "GaiiaRateLimitedError", + "GaiiaTransportError", + "RateLimitInfo", + "WebhookSignatureError", + "decode_global_id", + "encode_global_id", + "verify_webhook_signature", +] + +__version__ = "0.1.0" diff --git a/gaiia/src/gaiia/client.py b/gaiia/src/gaiia/client.py new file mode 100644 index 0000000..f466ae9 --- /dev/null +++ b/gaiia/src/gaiia/client.py @@ -0,0 +1,392 @@ +"""Sync and async GraphQL clients for the gaiia API.""" + +from __future__ import annotations + +import os +import time +from types import TracebackType +from typing import Any, AsyncIterator, Iterator + +import httpx + +from gaiia.errors import ( + GaiiaAPIError, + GaiiaMutationError, + GaiiaRateLimitedError, + GaiiaTransportError, + GraphQLError, +) +from gaiia.pagination import aiter_nodes, iter_nodes +from gaiia.ratelimit import RateLimitInfo + +DEFAULT_BASE_URL = "https://api.gaiia.com/api/v1" +DEFAULT_TIMEOUT = 30.0 +API_KEY_HEADER = "X-Gaiia-Api-Key" +TIMEZONE_HEADER = "x-timezone" +USER_AGENT = "gaiia-py/0.1.0" + + +def _build_headers( + api_key: str, timezone: str | None, extra: dict[str, str] | None +) -> dict[str, str]: + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": USER_AGENT, + API_KEY_HEADER: api_key, + } + if timezone: + headers[TIMEZONE_HEADER] = timezone + if extra: + headers.update(extra) + return headers + + +def _classify_errors(raw_errors: list[dict[str, Any]]) -> tuple[bool, list[GraphQLError]]: + """Return ``(rate_limited, parsed_errors)`` for top-level GraphQL errors.""" + errors = [GraphQLError.from_dict(e) for e in raw_errors] + rate_limited = any(e.code == "RATE_LIMITED" for e in errors) + return rate_limited, errors + + +def _raise_for_payload(payload: dict[str, Any]) -> None: + raw_errors = payload.get("errors") + if not raw_errors: + return + rate_limited, errors = _classify_errors(raw_errors) + if rate_limited: + ext = errors[0].extensions + retry_at = None + if ext.get("retryAt"): + from datetime import datetime + + try: + retry_at = datetime.fromisoformat(str(ext["retryAt"]).replace("Z", "+00:00")) + except ValueError: + retry_at = None + raise GaiiaRateLimitedError( + errors, + cost=ext.get("cost"), + limit=ext.get("limit"), + used=ext.get("used"), + remaining=ext.get("remaining"), + retry_at=retry_at, + ) + raise GaiiaAPIError(errors, data=payload.get("data")) + + +class _Base: + def __init__( + self, + api_key: str | None = None, + *, + base_url: str = DEFAULT_BASE_URL, + timezone: str | None = None, + timeout: float = DEFAULT_TIMEOUT, + default_headers: dict[str, str] | None = None, + max_retries: int = 0, + ) -> None: + resolved = api_key if api_key is not None else os.environ.get("GAIIA_KEY") + if not resolved: + raise ValueError( + "api_key is required (pass api_key= or set the GAIIA_KEY env var)" + ) + self._api_key = resolved + self._base_url = base_url + self._timezone = timezone + self._timeout = timeout + self._default_headers = default_headers + self._max_retries = max_retries + self.last_rate_limit: RateLimitInfo | None = None + + def _headers(self, extra: dict[str, str] | None = None) -> dict[str, str]: + merged = dict(self._default_headers or {}) + if extra: + merged.update(extra) + return _build_headers(self._api_key, self._timezone, merged) + + +class GaiiaClient(_Base): + """Synchronous gaiia GraphQL client.""" + + def __init__( + self, + api_key: str | None = None, + *, + base_url: str = DEFAULT_BASE_URL, + timezone: str | None = None, + timeout: float = DEFAULT_TIMEOUT, + default_headers: dict[str, str] | None = None, + max_retries: int = 0, + transport: httpx.BaseTransport | None = None, + ) -> None: + super().__init__( + api_key, + base_url=base_url, + timezone=timezone, + timeout=timeout, + default_headers=default_headers, + max_retries=max_retries, + ) + self._http = httpx.Client(timeout=timeout, transport=transport) + + def __enter__(self) -> GaiiaClient: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + self.close() + + def close(self) -> None: + self._http.close() + + def query( + self, + query: str, + variables: dict[str, Any] | None = None, + *, + operation_name: str | None = None, + timezone: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> dict[str, Any]: + """Run a GraphQL query/mutation and return the ``data`` payload. + + Raises :class:`GaiiaAPIError` on top-level errors, + :class:`GaiiaRateLimitedError` on RATE_LIMITED, and + :class:`GaiiaTransportError` on network failures. + """ + body: dict[str, Any] = {"query": query} + if variables is not None: + body["variables"] = variables + if operation_name is not None: + body["operationName"] = operation_name + + per_call_headers = dict(extra_headers or {}) + if timezone is not None: + per_call_headers[TIMEZONE_HEADER] = timezone + headers = self._headers(per_call_headers) + + attempt = 0 + while True: + attempt += 1 + try: + resp = self._http.post(self._base_url, json=body, headers=headers) + except httpx.HTTPError as e: + raise GaiiaTransportError(str(e)) from e + + self.last_rate_limit = RateLimitInfo.from_headers(resp.headers) + try: + payload = resp.json() + except ValueError as e: + raise GaiiaTransportError( + f"non-JSON response from gaiia (HTTP {resp.status_code})" + ) from e + + try: + _raise_for_payload(payload) + except GaiiaRateLimitedError as e: + if attempt > self._max_retries: + raise + _sleep_until(e.retry_at) + continue + + if resp.status_code >= 500: + raise GaiiaTransportError( + f"gaiia returned HTTP {resp.status_code}: {resp.text[:200]}" + ) + return payload.get("data") or {} + + def mutate( + self, + query: str, + mutation_name: str, + variables: dict[str, Any] | None = None, + *, + timezone: str | None = None, + raise_on_payload_errors: bool = True, + ) -> dict[str, Any]: + """Run a mutation and inspect its ``errors`` payload field. + + Returns the mutation's result object (e.g. the contents of + ``data.createTicket``). If ``raise_on_payload_errors`` is true and the + result contains a non-empty ``errors`` list, :class:`GaiiaMutationError` + is raised. + """ + data = self.query(query, variables, timezone=timezone) + result = data.get(mutation_name) + if result is None: + raise GaiiaAPIError( + [GraphQLError(message=f"mutation {mutation_name!r} not in response", code=None)], + data=data, + ) + errors = result.get("errors") or [] + if errors and raise_on_payload_errors: + raise GaiiaMutationError(mutation_name, errors, payload=result) + return result + + def iter_nodes( + self, + query: str, + connection_path: str, + variables: dict[str, Any] | None = None, + *, + cursor_var: str = "after", + timezone: str | None = None, + ) -> Iterator[Any]: + """Iterate every node across all pages of a Connection. + + ``query`` must accept the cursor variable (default ``$after``) and use + it as the ``after:`` argument on the paginated field. + """ + base_vars = dict(variables or {}) + + def fetch_page(cursor: str | None) -> dict[str, Any]: + v = dict(base_vars) + v[cursor_var] = cursor + return self.query(query, v, timezone=timezone) + + yield from iter_nodes(fetch_page, connection_path) + + +class AsyncGaiiaClient(_Base): + """Asynchronous gaiia GraphQL client.""" + + def __init__( + self, + api_key: str | None = None, + *, + base_url: str = DEFAULT_BASE_URL, + timezone: str | None = None, + timeout: float = DEFAULT_TIMEOUT, + default_headers: dict[str, str] | None = None, + max_retries: int = 0, + transport: httpx.AsyncBaseTransport | None = None, + ) -> None: + super().__init__( + api_key, + base_url=base_url, + timezone=timezone, + timeout=timeout, + default_headers=default_headers, + max_retries=max_retries, + ) + self._http = httpx.AsyncClient(timeout=timeout, transport=transport) + + async def __aenter__(self) -> AsyncGaiiaClient: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + await self.aclose() + + async def aclose(self) -> None: + await self._http.aclose() + + async def query( + self, + query: str, + variables: dict[str, Any] | None = None, + *, + operation_name: str | None = None, + timezone: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> dict[str, Any]: + body: dict[str, Any] = {"query": query} + if variables is not None: + body["variables"] = variables + if operation_name is not None: + body["operationName"] = operation_name + + per_call_headers = dict(extra_headers or {}) + if timezone is not None: + per_call_headers[TIMEZONE_HEADER] = timezone + headers = self._headers(per_call_headers) + + attempt = 0 + while True: + attempt += 1 + try: + resp = await self._http.post(self._base_url, json=body, headers=headers) + except httpx.HTTPError as e: + raise GaiiaTransportError(str(e)) from e + + self.last_rate_limit = RateLimitInfo.from_headers(resp.headers) + try: + payload = resp.json() + except ValueError as e: + raise GaiiaTransportError( + f"non-JSON response from gaiia (HTTP {resp.status_code})" + ) from e + + try: + _raise_for_payload(payload) + except GaiiaRateLimitedError as e: + if attempt > self._max_retries: + raise + _sleep_until(e.retry_at) + continue + + if resp.status_code >= 500: + raise GaiiaTransportError( + f"gaiia returned HTTP {resp.status_code}: {resp.text[:200]}" + ) + return payload.get("data") or {} + + async def mutate( + self, + query: str, + mutation_name: str, + variables: dict[str, Any] | None = None, + *, + timezone: str | None = None, + raise_on_payload_errors: bool = True, + ) -> dict[str, Any]: + data = await self.query(query, variables, timezone=timezone) + result = data.get(mutation_name) + if result is None: + raise GaiiaAPIError( + [GraphQLError(message=f"mutation {mutation_name!r} not in response", code=None)], + data=data, + ) + errors = result.get("errors") or [] + if errors and raise_on_payload_errors: + raise GaiiaMutationError(mutation_name, errors, payload=result) + return result + + async def iter_nodes( + self, + query: str, + connection_path: str, + variables: dict[str, Any] | None = None, + *, + cursor_var: str = "after", + timezone: str | None = None, + ) -> AsyncIterator[Any]: + base_vars = dict(variables or {}) + + async def fetch_page(cursor: str | None) -> dict[str, Any]: + v = dict(base_vars) + v[cursor_var] = cursor + return await self.query(query, v, timezone=timezone) + + async for node in aiter_nodes(fetch_page, connection_path): + yield node + + +def _sleep_until(retry_at: Any) -> None: + if not retry_at: + time.sleep(1.0) + return + delta = retry_at.timestamp() - time.time() + if delta > 0: + time.sleep(min(delta, 60.0)) + else: + time.sleep(1.0) diff --git a/gaiia/src/gaiia/errors.py b/gaiia/src/gaiia/errors.py new file mode 100644 index 0000000..68d933c --- /dev/null +++ b/gaiia/src/gaiia/errors.py @@ -0,0 +1,91 @@ +"""Exception types raised by the gaiia client.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any + + +class GaiiaError(Exception): + """Base class for all gaiia client errors.""" + + +class GaiiaTransportError(GaiiaError): + """Network/transport failure talking to the gaiia API.""" + + +@dataclass +class GraphQLError: + message: str + code: str | None = None + path: list[str | int] | None = None + extensions: dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_dict(cls, raw: dict[str, Any]) -> GraphQLError: + extensions = dict(raw.get("extensions") or {}) + return cls( + message=raw.get("message", ""), + code=extensions.get("code"), + path=raw.get("path"), + extensions=extensions, + ) + + +class GaiiaAPIError(GaiiaError): + """One or more top-level GraphQL errors were returned.""" + + def __init__(self, errors: list[GraphQLError], data: Any | None = None) -> None: + self.errors = errors + self.data = data + msg = "; ".join(e.message for e in errors) or "gaiia API error" + super().__init__(msg) + + +class GaiiaMutationError(GaiiaError): + """A mutation returned validation/expected errors under data..errors. + + These are the GraphQL "200 OK with errors in the payload" responses that + represent expected failure modes (validation, business rules) rather than + transport or schema issues. + """ + + def __init__( + self, + mutation: str, + errors: list[dict[str, Any]], + payload: dict[str, Any] | None = None, + ) -> None: + self.mutation = mutation + self.errors = errors + self.payload = payload + codes = [e.get("code") for e in errors if e.get("code")] + msg = ( + f"mutation {mutation!r} returned errors: " + + ", ".join(f"{c}: {e.get('message','')}" for c, e in zip(codes, errors, strict=False)) + if codes + else f"mutation {mutation!r} returned errors" + ) + super().__init__(msg) + + +class GaiiaRateLimitedError(GaiiaAPIError): + """Raised when the API returns a RATE_LIMITED error.""" + + def __init__( + self, + errors: list[GraphQLError], + *, + cost: int | None = None, + limit: int | None = None, + used: int | None = None, + remaining: int | None = None, + retry_at: datetime | None = None, + ) -> None: + super().__init__(errors) + self.cost = cost + self.limit = limit + self.used = used + self.remaining = remaining + self.retry_at = retry_at diff --git a/gaiia/src/gaiia/globalid.py b/gaiia/src/gaiia/globalid.py new file mode 100644 index 0000000..38459ed --- /dev/null +++ b/gaiia/src/gaiia/globalid.py @@ -0,0 +1,65 @@ +"""Encode and decode gaiia GlobalIDs. + +A GlobalID looks like ``account_pwE84wz4Mw15LPX5YE8Z3o``: a snake-cased entity +type, an underscore, and a 22-char Flickr Base-58 encoding of the underlying +128-bit UUID. This matches the ``short-uuid`` library that gaiia uses on the +server side. +""" + +from __future__ import annotations + +import re +from uuid import UUID + +_ALPHABET = "123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ" +_BASE = len(_ALPHABET) +_TARGET_LEN = 22 +_TYPE_RE = re.compile(r"(? str: + return _TYPE_RE.sub("_", name).lower() + + +def encode_global_id(type_name: str, uuid_str: str | UUID) -> str: + """Build a GlobalID from a type name (e.g. ``Account``) and a UUID.""" + if isinstance(uuid_str, UUID): + stripped = uuid_str.hex + else: + stripped = str(uuid_str).replace("-", "").lower() + if len(stripped) != 32 or any(c not in "0123456789abcdef" for c in stripped): + raise ValueError(f"not a valid uuid: {uuid_str!r}") + + num = int(stripped, 16) + chars: list[str] = [] + if num == 0: + chars.append(_ALPHABET[0]) + else: + while num > 0: + num, rem = divmod(num, _BASE) + chars.append(_ALPHABET[rem]) + chars.reverse() + short = "".join(chars).rjust(_TARGET_LEN, _ALPHABET[0]) + return f"{_snake_case(type_name)}_{short}" + + +def decode_global_id(global_id: str) -> tuple[str, str]: + """Split a GlobalID into ``(type_snake_case, uuid_str)``.""" + if "_" not in global_id: + raise ValueError(f"not a global id: {global_id!r}") + type_part, _, short_id = global_id.rpartition("_") + if not type_part or not short_id: + raise ValueError(f"malformed global id: {global_id!r}") + num = 0 + for ch in short_id: + idx = _ALPHABET.find(ch) + if idx < 0: + raise ValueError(f"invalid base58 char {ch!r} in {global_id!r}") + num = num * _BASE + idx + hex_str = format(num, "x").rjust(32, "0") + if len(hex_str) > 32: + raise ValueError(f"global id decodes to oversized uuid: {global_id!r}") + uuid_str = ( + f"{hex_str[0:8]}-{hex_str[8:12]}-{hex_str[12:16]}-{hex_str[16:20]}-{hex_str[20:32]}" + ) + return type_part, uuid_str diff --git a/gaiia/src/gaiia/pagination.py b/gaiia/src/gaiia/pagination.py new file mode 100644 index 0000000..dd1fdee --- /dev/null +++ b/gaiia/src/gaiia/pagination.py @@ -0,0 +1,61 @@ +"""Helpers for cursor-based pagination over gaiia Connection types.""" + +from __future__ import annotations + +from typing import Any, AsyncIterator, Iterator + + +def extract_connection(data: dict[str, Any], path: str) -> dict[str, Any]: + """Walk a dotted path into a GraphQL ``data`` payload and return the Connection node.""" + node: Any = data + for part in path.split("."): + if not isinstance(node, dict): + raise KeyError(f"path {path!r} not found in response") + node = node.get(part) + if node is None: + raise KeyError(f"path {path!r} not found in response (broke at {part!r})") + if not isinstance(node, dict) or "pageInfo" not in node: + raise ValueError(f"path {path!r} did not point to a Connection (no pageInfo)") + return node + + +def iter_nodes( + fetch_page: "Callable[[str | None], dict[str, Any]]", # noqa: F821 + connection_path: str, +) -> Iterator[Any]: + """Yield nodes one by one across all pages of a Connection. + + ``fetch_page`` takes the current ``after`` cursor (``None`` on the first + page) and returns the full GraphQL ``data`` payload for that page. + """ + cursor: str | None = None + while True: + data = fetch_page(cursor) + conn = extract_connection(data, connection_path) + for node in conn.get("nodes") or []: + yield node + page_info = conn.get("pageInfo") or {} + if not page_info.get("hasNextPage"): + return + cursor = page_info.get("endCursor") + if not cursor: + return + + +async def aiter_nodes( + fetch_page: "Callable[[str | None], Awaitable[dict[str, Any]]]", # noqa: F821 + connection_path: str, +) -> AsyncIterator[Any]: + """Async version of :func:`iter_nodes`.""" + cursor: str | None = None + while True: + data = await fetch_page(cursor) + conn = extract_connection(data, connection_path) + for node in conn.get("nodes") or []: + yield node + page_info = conn.get("pageInfo") or {} + if not page_info.get("hasNextPage"): + return + cursor = page_info.get("endCursor") + if not cursor: + return diff --git a/gaiia/src/gaiia/py.typed b/gaiia/src/gaiia/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/gaiia/src/gaiia/ratelimit.py b/gaiia/src/gaiia/ratelimit.py new file mode 100644 index 0000000..d534313 --- /dev/null +++ b/gaiia/src/gaiia/ratelimit.py @@ -0,0 +1,56 @@ +"""Parse gaiia rate-limit response headers.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Mapping + + +@dataclass +class RateLimitInfo: + allowed: bool | None + cost: int | None + limit: int | None + used: int | None + remaining: int | None + retry_at: datetime | None + + @classmethod + def from_headers(cls, headers: Mapping[str, str]) -> RateLimitInfo: + def _get(name: str) -> str | None: + for k, v in headers.items(): + if k.lower() == name.lower(): + return v + return None + + def _int(name: str) -> int | None: + v = _get(name) + try: + return int(v) if v is not None else None + except ValueError: + return None + + def _bool(name: str) -> bool | None: + v = _get(name) + if v is None: + return None + return v.strip().lower() == "true" + + def _dt(name: str) -> datetime | None: + v = _get(name) + if not v: + return None + try: + return datetime.fromisoformat(v.replace("Z", "+00:00")) + except ValueError: + return None + + return cls( + allowed=_bool("X-Rate-Limit-Allowed"), + cost=_int("X-Rate-Limit-Cost"), + limit=_int("X-Rate-Limit-Limit"), + used=_int("X-Rate-Limit-Used"), + remaining=_int("X-Rate-Limit-Remaining"), + retry_at=_dt("X-Rate-Limit-Retry-At") or _dt("X-Rate-Limit-Retry-at"), + ) diff --git a/gaiia/src/gaiia/webhooks.py b/gaiia/src/gaiia/webhooks.py new file mode 100644 index 0000000..13789a8 --- /dev/null +++ b/gaiia/src/gaiia/webhooks.py @@ -0,0 +1,93 @@ +"""Webhook signature verification for incoming gaiia webhooks. + +Each delivery includes the header +``X-Gaiia-Webhook-Signature: t=,v1=``. The signature +is HMAC-SHA256 over ``"${t}.${rawBody}"`` keyed by the endpoint secret. Compare +in constant time and reject if ``t`` is outside your tolerance window. +""" + +from __future__ import annotations + +import hashlib +import hmac +import time +from typing import Iterable + +from gaiia.errors import GaiiaError + +SIGNATURE_HEADER = "X-Gaiia-Webhook-Signature" +DEFAULT_TOLERANCE_SECONDS = 5 * 60 + + +class WebhookSignatureError(GaiiaError): + """Raised when a webhook signature fails verification.""" + + +def _parse_header(header_value: str) -> tuple[str | None, list[str]]: + timestamp: str | None = None + v1_signatures: list[str] = [] + for chunk in header_value.split(","): + if "=" not in chunk: + continue + prefix, _, value = chunk.partition("=") + prefix = prefix.strip() + value = value.strip() + if prefix == "t": + timestamp = value + elif prefix == "v1": + v1_signatures.append(value) + return timestamp, v1_signatures + + +def _compute_signature(secret: str | bytes, timestamp: str, raw_body: str | bytes) -> str: + if isinstance(secret, str): + secret = secret.encode("utf-8") + if isinstance(raw_body, bytes): + body_text = raw_body.decode("utf-8") + else: + body_text = raw_body + payload = f"{timestamp}.{body_text}".encode("utf-8") + return hmac.new(secret, payload, hashlib.sha256).hexdigest() + + +def verify_webhook_signature( + *, + header_value: str, + raw_body: str | bytes, + secret: str | bytes, + tolerance_seconds: int = DEFAULT_TOLERANCE_SECONDS, + now: float | None = None, +) -> None: + """Verify a webhook delivery. Raises :class:`WebhookSignatureError` on failure. + + ``raw_body`` must be the exact bytes/string the server signed — do not + re-serialize a parsed JSON body, since whitespace differences will break + the HMAC. + """ + if not header_value: + raise WebhookSignatureError("missing signature header") + timestamp, candidates = _parse_header(header_value) + if timestamp is None: + raise WebhookSignatureError("signature header missing timestamp (t=...)") + if not candidates: + raise WebhookSignatureError("signature header missing v1 signatures") + + try: + ts_int = int(timestamp) + except ValueError as e: + raise WebhookSignatureError(f"signature timestamp not an integer: {timestamp!r}") from e + + current = time.time() if now is None else now + if abs(current - ts_int) > tolerance_seconds: + raise WebhookSignatureError( + f"signature timestamp outside tolerance " + f"({tolerance_seconds}s): {abs(current - ts_int):.0f}s drift" + ) + + expected = _compute_signature(secret, timestamp, raw_body) + if not _any_match(expected, candidates): + raise WebhookSignatureError("no signature matched expected HMAC") + + +def _any_match(expected: str, candidates: Iterable[str]) -> bool: + return any(hmac.compare_digest(expected, c) for c in candidates) diff --git a/gaiia/sync_all_radius_groups.py b/gaiia/sync_all_radius_groups.py new file mode 100644 index 0000000..c514044 --- /dev/null +++ b/gaiia/sync_all_radius_groups.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +""" +Run 'Update RADIUS Account Groups' for every account in Gaiia. +Status must be UPPERCASE (ACTIVE, SUSPENDED, etc.) + +Usage: + source .envrc + uv run python3 sync_all_radius_groups.py --dry-run (preview) + uv run python3 sync_all_radius_groups.py (execute) +""" + +from gaiia import GaiiaClient, GaiiaAPIError, GaiiaRateLimitedError +import json +import sys +import time + +WF_ID = "workflow_fLXTNCvridPqwEEXxf3CK1" + + +def list_accounts(client): + """Paginate through all accounts, return [{id, name, status, productIds}].""" + accounts = [] + after = None + page = 0 + + while True: + page += 1 + data = client.query(""" + query Q($after: String) { + accounts(first: 250, after: $after) { + nodes { + id + name + status { name } + billingSubscriptions { + nodes { + productVersion { + product { id name slug } + } + } + } + } + pageInfo { hasNextPage endCursor } + totalCount + } + } + """, {"after": after} if after else {}) + + result = data['accounts'] + + for acct in result['nodes']: + status_name = acct['status']['name'] if acct['status'] else '' + products = [] + for sub in (acct.get('billingSubscriptions') or {}).get('nodes', []): + pv = sub.get('productVersion') or {} + p = pv.get('product') if pv else None + if p and p['id'] not in products: + products.append(p['id']) + + accounts.append({ + 'id': acct['id'], + 'name': acct.get('name', '') or '', + 'status': status_name, + 'productIds': products, + }) + + sys.stderr.write(f"\r Page {page}: {len(accounts)} accounts so far...") + sys.stderr.flush() + + page_info = result['pageInfo'] + if not page_info.get('hasNextPage'): + break + after = page_info['endCursor'] + + sys.stderr.write("\n") + return accounts + + +def sync_account(client, acct, dry_run=False): + """Run the Update RADIUS Account Groups workflow for one account.""" + status_upper = acct['status'].upper() + wf_input = { + "accountId": acct['id'], + "status": status_upper, + "productIds": acct['productIds'], + } + + if dry_run: + name = acct['name'] or '(no name)' + print(f" [DRY-RUN] {name} status={status_upper} products={len(acct['productIds'])}") + return True + + try: + result = client.query(""" + mutation Run($input: StartWorkflowExecutionInput!) { + startWorkflowExecution(input: $input) { + workflowExecution { id status } + } + } + """, {"input": {"workflowId": WF_ID, "input": wf_input}}) + + we = result['startWorkflowExecution']['workflowExecution'] + return we is not None + except GaiiaAPIError as e: + print(f" FAILED {acct.get('name','?')}: API error - {e}") + return False + except GaiiaRateLimitedError as e: + print(f" RATE LIMITED - sleeping until {e.retry_at}") + if e.retry_at: + wait = max(0, (e.retry_at.timestamp() - time.time())) + time.sleep(wait + 1) + return False + + +def main(): + dry_run = "--dry-run" in sys.argv + + with GaiiaClient(timezone='America/Chicago') as g: + print("Listing all accounts...") + accounts = list_accounts(g) + print(f"\nFound {len(accounts)} accounts") + + # Stats + statuses = {} + for a in accounts: + s = a['status'].upper() + statuses[s] = statuses.get(s, 0) + 1 + print(f"Statuses: {statuses}") + + if dry_run: + print("\n--dry-run mode: previewing --") + else: + print(f"\nSyncing via workflow {WF_ID}...") + + succeeded = 0 + failed = 0 + for i, a in enumerate(accounts): + name = a['name'] or f"(id={a['id']})" + if not dry_run: + sys.stderr.write(f"\r {i+1}/{len(accounts)}: {name[:40]}...") + sys.stderr.flush() + + if sync_account(g, a, dry_run): + succeeded += 1 + else: + failed += 1 + + if not dry_run: + time.sleep(0.15) # avoid rate limiting (~7/sec) + + if not dry_run: + sys.stderr.write("\n") + + print(f"\nDone: {succeeded} started, {failed} failed") + if dry_run: + print("(nothing executed — remove --dry-run to run)") + + +if __name__ == '__main__': + main() diff --git a/gaiia/sync_all_radius_groups_workflow.json b/gaiia/sync_all_radius_groups_workflow.json new file mode 100644 index 0000000..a400fc7 --- /dev/null +++ b/gaiia/sync_all_radius_groups_workflow.json @@ -0,0 +1,154 @@ +{ + "name": "Sync All RADIUS Groups", + "description": "MANUAL: Syncs RADIUS usergroups for ALL accounts (2 pages x 250 = 500 accounts). Passes actual account status through — ACTIVE, SUSPENDED, INACTIVE, etc.", + "trigger": { + "type": "MANUAL", + "nextSteps": [{ "slug": "fetch-page-1" }] + }, + "steps": [ + { + "name": "Fetch accounts page 1", + "slug": "fetch-page-1", + "type": "GAIIA_API_GATEWAY_V1_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-public-api-gateway-graphql-request", + "allowSkipExecution": false, + "outputMapper": "({ output }) => {\n const data = output.response.accounts;\n return {\n accounts: data.nodes.map((a) => ({\n accountId: a.id,\n status: (a.status?.name || '').toUpperCase(),\n products: (a.billingSubscriptions?.nodes || [])\n .map((s) => s.productVersion?.product?.id)\n .filter(Boolean)\n .filter((id, i, arr) => arr.indexOf(id) === i)\n })),\n hasNextPage: data.pageInfo.hasNextPage,\n endCursor: data.pageInfo.endCursor\n };\n}", + "inputs": [ + { + "key": "query", + "type": "LITERAL", + "value": "string:query {\n accounts(first: 250) {\n nodes {\n id\n status { name }\n billingSubscriptions {\n nodes { productVersion { product { id } } }\n }\n }\n pageInfo { hasNextPage endCursor }\n }\n}" + }, + { + "key": "variables", + "type": "FUNCTION", + "value": "({ secrets, state, utils, variables }) => ({})" + } + ], + "nextSteps": [{ "slug": "sync-page-1" }] + }, + { + "name": "Sync page 1", + "slug": "sync-page-1", + "type": "PARALLEL_LOOP", + "nodeSlug": "parallel-loop", + "maxConcurrentIterations": 10, + "inputs": [ + { + "key": "iterable", + "type": "FUNCTION", + "value": "({ state }) => state.steps['fetch-page-1'].output.accounts" + } + ], + "initialSteps": [{ "slug": "call-update-page1" }], + "nextSteps": [{ "slug": "need-page-2" }] + }, + { + "name": "Call Update RADIUS Account Groups", + "slug": "call-update-page1", + "type": "GAIIA_START_WORKFLOW_EXECUTION", + "nodeSlug": "start-workflow-execution", + "parentParallelLoopSlug": "sync-page-1", + "allowSkipExecution": false, + "inputs": [ + { + "key": "workflowId", + "type": "LITERAL", + "value": "string:workflow_fLXTNCvridPqwEEXxf3CK1" + }, + { + "key": "input", + "type": "FUNCTION", + "value": "({ state }) => {\n const acct = state.currentParallelLoopIterations['sync-page-1'];\n return { accountId: acct.accountId, status: acct.status, productIds: acct.products };\n}" + }, + { + "key": "fireAndForget", + "type": "LITERAL", + "value": "boolean:true" + } + ], + "nextSteps": [{ "slug": "sync-page-1-end" }] + }, + { + "name": "Need page 2?", + "slug": "need-page-2", + "type": "CONDITION", + "nodeSlug": "condition", + "condition": "({ secrets, state, utils, variables }) => {\n return state.steps['fetch-page-1'].output.hasNextPage;\n}", + "elseSteps": [{ "slug": "end" }], + "thenSteps": [{ "slug": "fetch-page-2" }] + }, + { + "name": "Fetch accounts page 2", + "slug": "fetch-page-2", + "type": "GAIIA_API_GATEWAY_V1_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-public-api-gateway-graphql-request", + "allowSkipExecution": false, + "outputMapper": "({ output }) => {\n const nodes = output.response.accounts.nodes;\n if (!nodes || nodes.length === 0) return [];\n return nodes.map((a) => ({\n accountId: a.id,\n status: (a.status?.name || '').toUpperCase(),\n products: (a.billingSubscriptions?.nodes || [])\n .map((s) => s.productVersion?.product?.id)\n .filter(Boolean)\n .filter((id, i, arr) => arr.indexOf(id) === i)\n }));\n}", + "inputs": [ + { + "key": "query", + "type": "LITERAL", + "value": "string:query($after: String) {\n accounts(first: 250, after: $after) {\n nodes {\n id\n status { name }\n billingSubscriptions {\n nodes { productVersion { product { id } } }\n }\n }\n pageInfo { hasNextPage endCursor }\n }\n}" + }, + { + "key": "variables", + "type": "FUNCTION", + "value": "({ state }) => {\n const p1 = state.steps['fetch-page-1'].output;\n return p1.hasNextPage ? { after: p1.endCursor } : {};\n}" + } + ], + "nextSteps": [{ "slug": "if-page-2-has-data" }] + }, + { + "name": "Page 2 has data?", + "slug": "if-page-2-has-data", + "type": "CONDITION", + "nodeSlug": "condition", + "condition": "({ secrets, state, utils, variables }) => {\n const data = state.steps['fetch-page-2'].output;\n return Array.isArray(data) && data.length > 0;\n}", + "elseSteps": [{ "slug": "end" }], + "thenSteps": [{ "slug": "sync-page-2" }] + }, + { + "name": "Sync page 2", + "slug": "sync-page-2", + "type": "PARALLEL_LOOP", + "nodeSlug": "parallel-loop", + "maxConcurrentIterations": 10, + "inputs": [ + { + "key": "iterable", + "type": "FUNCTION", + "value": "({ state }) => state.steps['fetch-page-2'].output" + } + ], + "initialSteps": [{ "slug": "call-update-page2" }], + "nextSteps": [{ "slug": "end" }] + }, + { + "name": "Call Update RADIUS Account Groups (page 2)", + "slug": "call-update-page2", + "type": "GAIIA_START_WORKFLOW_EXECUTION", + "nodeSlug": "start-workflow-execution", + "parentParallelLoopSlug": "sync-page-2", + "allowSkipExecution": false, + "inputs": [ + { + "key": "workflowId", + "type": "LITERAL", + "value": "string:workflow_fLXTNCvridPqwEEXxf3CK1" + }, + { + "key": "input", + "type": "FUNCTION", + "value": "({ state }) => {\n const acct = state.currentParallelLoopIterations['sync-page-2'];\n return { accountId: acct.accountId, status: acct.status, productIds: acct.products };\n}" + }, + { + "key": "fireAndForget", + "type": "LITERAL", + "value": "boolean:true" + } + ], + "nextSteps": [{ "slug": "sync-page-2-end" }] + } + ] +} diff --git a/gaiia/tests/__init__.py b/gaiia/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gaiia/tests/test_client.py b/gaiia/tests/test_client.py new file mode 100644 index 0000000..85e1066 --- /dev/null +++ b/gaiia/tests/test_client.py @@ -0,0 +1,153 @@ +import httpx +import pytest + +from gaiia import ( + GaiiaAPIError, + GaiiaClient, + GaiiaMutationError, + GaiiaRateLimitedError, +) +from gaiia.client import API_KEY_HEADER, TIMEZONE_HEADER + + +def _client(handler): + transport = httpx.MockTransport(handler) + return GaiiaClient( + api_key="gaiia_sk_sbox_test", + timezone="America/Chicago", + transport=transport, + ) + + +def test_query_sends_expected_headers_and_body(): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["headers"] = dict(request.headers) + captured["body"] = request.read() + return httpx.Response(200, json={"data": {"x": 1}}) + + with _client(handler) as g: + out = g.query("{ x }", {"v": 1}) + assert out == {"x": 1} + assert captured["headers"][API_KEY_HEADER.lower()] == "gaiia_sk_sbox_test" + assert captured["headers"][TIMEZONE_HEADER] == "America/Chicago" + import json + body = json.loads(captured["body"]) + assert body["query"] == "{ x }" + assert body["variables"] == {"v": 1} + + +def test_top_level_errors_raise_api_error(): + def handler(request): + return httpx.Response( + 200, + json={"errors": [{"message": "bad", "extensions": {"code": "BAD_USER_INPUT"}}]}, + ) + + with _client(handler) as g, pytest.raises(GaiiaAPIError) as exc: + g.query("{}") + assert exc.value.errors[0].code == "BAD_USER_INPUT" + + +def test_rate_limited_classifies_separately(): + def handler(request): + return httpx.Response( + 200, + json={ + "errors": [ + { + "message": "too many", + "extensions": { + "code": "RATE_LIMITED", + "limit": 500, + "used": 500, + "remaining": 0, + "retryAt": "2026-06-27T19:01:02.000Z", + }, + } + ] + }, + headers={ + "X-Rate-Limit-Allowed": "false", + "X-Rate-Limit-Limit": "500", + "X-Rate-Limit-Used": "500", + "X-Rate-Limit-Remaining": "0", + }, + ) + + with _client(handler) as g, pytest.raises(GaiiaRateLimitedError) as exc: + g.query("{}") + assert exc.value.limit == 500 + assert g.last_rate_limit is not None + assert g.last_rate_limit.remaining == 0 + + +def test_mutate_raises_on_payload_errors(): + def handler(request): + return httpx.Response( + 200, + json={ + "data": { + "createTicket": { + "ticket": None, + "errors": [{"code": "TITLE_TOO_LONG", "message": "long"}], + } + } + }, + ) + + with _client(handler) as g, pytest.raises(GaiiaMutationError) as exc: + g.mutate("mutation { createTicket { ticket { id } errors { code message } } }", "createTicket") + assert exc.value.errors[0]["code"] == "TITLE_TOO_LONG" + + +def test_iter_nodes_paginates_via_cursor(): + calls = [] + + def handler(request): + body = request.read().decode() + calls.append(body) + if '"after":null' in body.replace(" ", ""): + return httpx.Response( + 200, + json={ + "data": { + "things": { + "nodes": [{"id": 1}, {"id": 2}], + "pageInfo": {"hasNextPage": True, "endCursor": "c1"}, + } + } + }, + ) + return httpx.Response( + 200, + json={ + "data": { + "things": { + "nodes": [{"id": 3}], + "pageInfo": {"hasNextPage": False, "endCursor": None}, + } + } + }, + ) + + with _client(handler) as g: + ids = [n["id"] for n in g.iter_nodes( + "query Q($after: String) { things(first: 2, after: $after) { nodes { id } pageInfo { hasNextPage endCursor } } }", + "things", + )] + assert ids == [1, 2, 3] + assert len(calls) == 2 + + +def test_missing_api_key_raises(): + import os + + saved = os.environ.pop("GAIIA_KEY", None) + try: + with pytest.raises(ValueError): + GaiiaClient() + finally: + if saved is not None: + os.environ["GAIIA_KEY"] = saved diff --git a/gaiia/tests/test_globalid.py b/gaiia/tests/test_globalid.py new file mode 100644 index 0000000..f71d5d6 --- /dev/null +++ b/gaiia/tests/test_globalid.py @@ -0,0 +1,45 @@ +from uuid import UUID + +import pytest + +from gaiia import decode_global_id, encode_global_id + + +def test_roundtrip_known_value(): + type_name = "Account" + uuid_str = "3c3b1978-6a68-4a13-bdc2-2d51c8ef7519" + gid = encode_global_id(type_name, uuid_str) + snake, decoded = decode_global_id(gid) + assert snake == "account" + assert decoded == uuid_str + + +def test_camelcase_type_becomes_snake_case(): + gid = encode_global_id("InventoryItem", "00000000-0000-0000-0000-000000000001") + snake, _ = decode_global_id(gid) + assert snake == "inventory_item" + + +def test_accepts_uuid_object(): + u = UUID("3c3b1978-6a68-4a13-bdc2-2d51c8ef7519") + gid = encode_global_id("Account", u) + _, decoded = decode_global_id(gid) + assert decoded == str(u) + + +def test_short_id_always_22_chars(): + gid = encode_global_id("Account", "00000000-0000-0000-0000-000000000001") + _, short = gid.split("_", 1) + assert len(short) == 22 + + +def test_decode_rejects_garbage(): + with pytest.raises(ValueError): + decode_global_id("no-underscore-here") + with pytest.raises(ValueError): + decode_global_id("account_!!!invalid!!!") + + +def test_encode_rejects_bad_uuid(): + with pytest.raises(ValueError): + encode_global_id("Account", "not-a-uuid") diff --git a/gaiia/tests/test_pagination.py b/gaiia/tests/test_pagination.py new file mode 100644 index 0000000..6e87608 --- /dev/null +++ b/gaiia/tests/test_pagination.py @@ -0,0 +1,43 @@ +import pytest + +from gaiia.pagination import extract_connection, iter_nodes + + +def _conn(nodes, has_next=False, end="cur"): + return { + "nodes": nodes, + "pageInfo": {"hasNextPage": has_next, "endCursor": end if has_next else None}, + } + + +def test_extract_connection_walks_dotted_path(): + data = {"a": {"b": _conn([1, 2])}} + conn = extract_connection(data, "a.b") + assert conn["nodes"] == [1, 2] + + +def test_extract_connection_missing_path_raises(): + with pytest.raises(KeyError): + extract_connection({"a": {}}, "a.b") + + +def test_iter_nodes_terminates_on_last_page(): + pages = [ + {"things": _conn(["a", "b"], has_next=True, end="x")}, + {"things": _conn(["c"], has_next=False)}, + ] + calls = [] + + def fetch(cursor): + calls.append(cursor) + return pages.pop(0) + + assert list(iter_nodes(fetch, "things")) == ["a", "b", "c"] + assert calls == [None, "x"] + + +def test_iter_nodes_handles_empty(): + def fetch(cursor): + return {"things": _conn([])} + + assert list(iter_nodes(fetch, "things")) == [] diff --git a/gaiia/tests/test_webhooks.py b/gaiia/tests/test_webhooks.py new file mode 100644 index 0000000..e90ab95 --- /dev/null +++ b/gaiia/tests/test_webhooks.py @@ -0,0 +1,62 @@ +import hashlib +import hmac +import time + +import pytest + +from gaiia import WebhookSignatureError, verify_webhook_signature + + +def _make_header(secret: str, timestamp: int, body: bytes) -> str: + sig = hmac.new( + secret.encode(), f"{timestamp}.{body.decode()}".encode(), hashlib.sha256 + ).hexdigest() + return f"t={timestamp},v1={sig}" + + +def test_valid_signature_passes(): + secret = "whsec_test" + body = b'{"hello":"world"}' + ts = int(time.time()) + header = _make_header(secret, ts, body) + verify_webhook_signature(header_value=header, raw_body=body, secret=secret) + + +def test_tampered_body_fails(): + secret = "whsec_test" + body = b'{"hello":"world"}' + ts = int(time.time()) + header = _make_header(secret, ts, body) + with pytest.raises(WebhookSignatureError): + verify_webhook_signature( + header_value=header, raw_body=b'{"hello":"WORLD"}', secret=secret + ) + + +def test_old_timestamp_rejected(): + secret = "whsec_test" + body = b'{"x":1}' + ts = int(time.time()) - 3600 + header = _make_header(secret, ts, body) + with pytest.raises(WebhookSignatureError): + verify_webhook_signature( + header_value=header, raw_body=body, secret=secret, tolerance_seconds=60 + ) + + +def test_multiple_signatures_one_match(): + secret = "whsec_test" + body = b"abc" + ts = int(time.time()) + real = _make_header(secret, ts, body).split("v1=")[1] + header = f"t={ts},v1=ffffffff,v1={real}" + verify_webhook_signature(header_value=header, raw_body=body, secret=secret) + + +def test_missing_components(): + with pytest.raises(WebhookSignatureError): + verify_webhook_signature(header_value="", raw_body=b"", secret="x") + with pytest.raises(WebhookSignatureError): + verify_webhook_signature(header_value="v1=abc", raw_body=b"", secret="x") + with pytest.raises(WebhookSignatureError): + verify_webhook_signature(header_value="t=123", raw_body=b"", secret="x") diff --git a/gaiia/update_preseem_on_ip_change.json b/gaiia/update_preseem_on_ip_change.json new file mode 100644 index 0000000..b629b29 --- /dev/null +++ b/gaiia/update_preseem_on_ip_change.json @@ -0,0 +1,108 @@ +{ + "steps": [ + { + "name": "Find inventory item", + "slug": "find-inventory-item", + "type": "GAIIA_API_GATEWAY_V1_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-public-api-gateway-graphql-request", + "allowSkipExecution": false, + "outputMapper": "({ output }) => {\n const assignee = output.response.inventoryItem?.assignation?.assignee;\n if (!assignee || assignee.__typename !== 'Account') {\n workflow.fail('Inventory item is not assigned to an account.', 'NO_ACCOUNT');\n }\n return { id: assignee.id, gaiiaId: assignee.readableId };\n}", + "inputs": [ + { + "key": "query", + "type": "LITERAL", + "value": "string:query IpAssignmentWorkflowInventoryItemQuery($id: ID!) {\n inventoryItem(id: $id) {\n id\n assignation {\n assignee {\n ... on Account { id readableId }\n }\n }\n }\n}" + }, + { + "key": "variables", + "type": "FUNCTION", + "value": "({ state }) => ({ id: state.input.objectId })" + } + ], + "nextSteps": [ + { + "slug": "find-account-cpes" + } + ] + }, + { + "name": "Find account CPEs", + "slug": "find-account-cpes", + "type": "GAIIA_FIND_ACCOUNT_CPES", + "nodeSlug": "find-account-cpes", + "allowSkipExecution": false, + "inputs": [ + { + "key": "accountId", + "type": "FUNCTION", + "value": "({ state }) => state.steps['find-inventory-item'].output.id" + } + ], + "nextSteps": [ + { + "slug": "find-account-internet-plans" + } + ] + }, + { + "name": "Find account Internet plans", + "slug": "find-account-internet-plans", + "type": "GAIIA_FIND_ACCOUNT_INTERNET_PLANS", + "nodeSlug": "find-account-internet-plans", + "allowSkipExecution": false, + "inputs": [ + { + "key": "accountId", + "type": "FUNCTION", + "value": "({ state }) => state.steps['find-inventory-item'].output.id" + } + ], + "nextSteps": [ + { + "slug": "preseem-update-service" + } + ] + }, + { + "name": "Preseem update service", + "slug": "preseem-update-service", + "type": "PRESEEM_REST_REQUEST", + "nodeSlug": "preseem-rest-request", + "allowSkipExecution": false, + "inputs": [ + { + "key": "body", + "type": "FUNCTION", + "value": "({ state }) => {\n const { gaiiaId } = state.steps['find-inventory-item'].output;\n const [{ slug }] = state.steps['find-account-internet-plans'].output.internetPlans;\n const cpes = state.steps['find-account-cpes'].output.accountCpe;\n const eventMac = state.input.payload.macAddress;\n const macAddresses = [\n ...new Set([\n ...cpes.map(({ macAddress }) => macAddress),\n eventMac\n ])\n ];\n return {\n id: String(gaiiaId),\n attachments: macAddresses\n .filter(Boolean)\n .map((macAddress) => ({ cpe_mac: macAddress.toLowerCase() })),\n account: String(gaiiaId),\n package: slug\n };\n }" + }, + { + "key": "method", + "type": "LITERAL", + "value": "string:PUT" + }, + { + "key": "path", + "type": "FUNCTION", + "value": "({ state }) => /services/${state.steps['find-inventory-item'].output.gaiiaId}" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ] + } + ], + "trigger": { + "nextSteps": [ + { + "slug": "find-inventory-item" + } + ], + "type": "EVENT", + "events": [ + "inventory_item.ip_address_assigned", + "inventory_item.ip_address_unassigned" + ] + } +} \ No newline at end of file diff --git a/gaiia/uv.lock b/gaiia/uv.lock new file mode 100644 index 0000000..5458895 --- /dev/null +++ b/gaiia/uv.lock @@ -0,0 +1,502 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/9d/912fefab0e30aee6a3af8a62bbea4a81b29afa4ba2c973d31170620a26de/ast_serialize-0.3.0.tar.gz", hash = "sha256:1bc3ca09a63a021376527c4e938deedd11d11d675ce850e6f9c7487f5889992b", size = 60689, upload-time = "2026-04-30T23:24:48.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/57/a54d4de491d6cdd7a4e4b0952cc3ca9f60dcefa7b5fb48d6d492debe1649/ast_serialize-0.3.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:3a867927df59f76a18dc1d874a0b2c079b42c58972dca637905576deb0912e14", size = 1182966, upload-time = "2026-04-30T23:23:57.376Z" }, + { url = "https://files.pythonhosted.org/packages/ee/9e/a5db014bb0f91b209236b57c429389e31290c0093532b8436d577699b2fa/ast_serialize-0.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a6fb063bf040abf8321e7b8113a0554eda445ffc508aa51287f8808886a5ae22", size = 1171316, upload-time = "2026-04-30T23:23:59.63Z" }, + { url = "https://files.pythonhosted.org/packages/15/59/fd55133e478c4326f60a11df02573bf7ccb2ac685810b50f1803d0f68053/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5075cd8482573d743586779e5f9b652a015e37d4e95132d7e5a9bc5c8f483d8f", size = 1232234, upload-time = "2026-04-30T23:24:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/cc/79/0ca1d26357ecb4a697d74d00b73ef3137f24c140424125393a0de820eb09/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:41560b27794f4553b0f77811e9fb325b77db4a2b39018d437e09932275306e66", size = 1233437, upload-time = "2026-04-30T23:24:03.151Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/7078ec94dd6e124b8e028ac77016a4f13c83fa1c145790f2e68f3816998b/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b967c01ca74909c5d90e0fe4393401e2cc5da5ebd9a6262a19e45ffd3757dec8", size = 1440188, upload-time = "2026-04-30T23:24:04.717Z" }, + { url = "https://files.pythonhosted.org/packages/21/16/cca7195ef55a012f8013c3442afa91d287a0a36dcf88b480b262475135b3/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:424ebb8f46cd993f7cec4009d119312d8433dd90e6b0df0499cd2c91bdcc5af9", size = 1254211, upload-time = "2026-04-30T23:24:06.18Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0f/f3d4dfae67dee6580534361a6343367d34217e7d25cff858bd1d8f03b8ed/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d14b1d566b56e2ee70b11fec1de7e0b94ec7cd83717ec7d189967841a361190e", size = 1255973, upload-time = "2026-04-30T23:24:07.772Z" }, + { url = "https://files.pythonhosted.org/packages/14/41/55fbfe02c42f40fbe3e74eda167d977d555ff720ce1abfa08515236efd88/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7ba30b18735f047ec11103d1ab92f4789cf1fea1e0dc89b04a2f5a0632fd79de", size = 1298629, upload-time = "2026-04-30T23:24:09.4Z" }, + { url = "https://files.pythonhosted.org/packages/28/36/7d2501cacc7989fb8504aa9da2a2022a174200a59d4e6639de4367a57fdd/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e6ea0754cb7b0f682ebb005ffb0d18f8d17993490d9c289863cd69cacc4ab8df", size = 1408435, upload-time = "2026-04-30T23:24:11.013Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/54e3b469c3fa0bf9cd532fa643d1d33b73303f8d70beac3e366b68dd64b7/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:a0c5aa1073a5ba7b2abaa4b54abe8b8d75c4d1e2d54a2ff70b0ca6222fea5728", size = 1508174, upload-time = "2026-04-30T23:24:12.635Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/9b9621865b02c60539e26d9b114a312b4fa46aa703e33e79317174bfea21/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4e52650d834c1ea7791969a361de2c54c13b2fb4c519ec79445fa8b9021a147d", size = 1502354, upload-time = "2026-04-30T23:24:14.186Z" }, + { url = "https://files.pythonhosted.org/packages/34/dd/f138bc5c43b0c414fdd12eefe15677839323078b6e75301ad7f96cd26d45/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:15bd6af3f136c61dae27805eb6b8f3269e85a545c4c27ffe9e530ead78d2b36d", size = 1450504, upload-time = "2026-04-30T23:24:16.076Z" }, + { url = "https://files.pythonhosted.org/packages/68/cf/97ef9e1c315601db74365955c8edd3292e3055500d6317602815dbdf08ae/ast_serialize-0.3.0-cp314-cp314t-win32.whl", hash = "sha256:d188bfe37b674b49708497683051d4b571366a668799c9b8e8a94513694969d9", size = 1058662, upload-time = "2026-04-30T23:24:17.535Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d6/e2c3483c31580fdb623f92ad38d2f856cde4b9205a3e6bd84760f3de7d82/ast_serialize-0.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5832c2fdf8f8a6cf682b4cfcf677f5eaf39b4ddbc490f5480cfccdd1e7ce8fa1", size = 1100349, upload-time = "2026-04-30T23:24:18.992Z" }, + { url = "https://files.pythonhosted.org/packages/ab/89/29abcb1fe18a429cda60c6e0bbd1d6e90499339842a2f548d7567542357e/ast_serialize-0.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:670f177188d128fb7f9f15b5ad0e1b553d22c34e3f584dcb83eb8077600437f0", size = 1072895, upload-time = "2026-04-30T23:24:20.706Z" }, + { url = "https://files.pythonhosted.org/packages/bc/93/72abad83966ed6235647c9f956417dc1e17e997696388521910e3d1fa3f4/ast_serialize-0.3.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:2ec2fafa5e4313cc8feed96e436ebe19ac7bc6fa41fbc2827e826c48b9e4c3a9", size = 1190024, upload-time = "2026-04-30T23:24:22.486Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/eb88584b2f0234e581762011208ca203252bf6c98e59b4769daa571f3576/ast_serialize-0.3.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ef6d3c08b7b4cd29b48410338e134764a00e76d25841eb02c1084e868c888ecc", size = 1178633, upload-time = "2026-04-30T23:24:24.35Z" }, + { url = "https://files.pythonhosted.org/packages/56/51/cf1ec1ff3e616373d0dcbd5fad502e0029dc541f13ab642259762a7d127f/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d841424f41b886e98044abc80769c14a956e6e5ccd5fb5b0d9f5ead72be18a4", size = 1241351, upload-time = "2026-04-30T23:24:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/0d/44/68fcf50478cf1093f2d423f034ae06453122c8b415d8e21a44668eca485d/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d21453734ad39367ede5d37efe4f59f830ce1c09f432fc72a90e368f77a4a3e7", size = 1239582, upload-time = "2026-04-30T23:24:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/9d/c1/a6c9fa284eceb5fc6f21347e968445a051d7ca2c4d34e6a04314646dbcee/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5e110cdce2a347e1dd987529c88ef54d26f67848dce3eba1b3b2cc2cf085c94", size = 1448853, upload-time = "2026-04-30T23:24:29.534Z" }, + { url = "https://files.pythonhosted.org/packages/23/5f/8ad3829a09e4e8c5328a53ce7d4711d660944e3e164c5f6abcc2c8f27167/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b6e23a98e57560a055f5c4b68700a0fd5ce483d2814c23140b3638c7f5d1e61", size = 1262204, upload-time = "2026-04-30T23:24:31.482Z" }, + { url = "https://files.pythonhosted.org/packages/25/13/44aa28d97f10e25247e8576b5f6b2795d4fa1a80acc88acc942c508d06f7/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1c9e763d70293d65ce1e1ea8c943140c68d0953f0268c7ee0998f2e07f77dd0", size = 1266458, upload-time = "2026-04-30T23:24:33.088Z" }, + { url = "https://files.pythonhosted.org/packages/d8/58/b3a8be3777cd3744324fd5cec0d80d37cd96fc7cbb0fb010e03dff1e870f/ast_serialize-0.3.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4388a1796c228f1ce5c391426f7d21a0003ad3b47f677dbeded9bd1a85c7209f", size = 1308700, upload-time = "2026-04-30T23:24:34.657Z" }, + { url = "https://files.pythonhosted.org/packages/13/03/f8312d6b57f5471a9dc7946f22b8798a1fc296d38c25766223aacadec42c/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5283cdcc0c64c3d8b9b688dc6aaa012d9c0cf1380a7f774a6bae6a1c01b3205a", size = 1416724, upload-time = "2026-04-30T23:24:36.562Z" }, + { url = "https://files.pythonhosted.org/packages/50/5d/13fc3789a7abac00559da2e2e9f386db4612aa1f84fc53d09bf714c37545/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f5ef88cc5842a5d7a6ac09dc0d5fc2c98f5d276c1f076f866d55047ce886785b", size = 1515441, upload-time = "2026-04-30T23:24:38.018Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b9/7ab43fc7a23b1f970281093228f5f79bed6edeed7a3e672bde6d7a832a58/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cc14bf402bdc0978594ecce783793de2c7470cd4f5cd7eb286ca97ed8ff7cba9", size = 1510522, upload-time = "2026-04-30T23:24:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/56/ec/d75fc2b788d319f1fad77c14156896f31afdfc68af85b505e5bdebcb9592/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11eae0cf1b7b3e0678133cc2daa974ea972caf02eb4b3aa062af6fa9acd52c57", size = 1460917, upload-time = "2026-04-30T23:24:41.305Z" }, + { url = "https://files.pythonhosted.org/packages/95/74/f99c81193a2725911e1911ae567ed27c2f2419332c7f3537366f9d238cac/ast_serialize-0.3.0-cp39-abi3-win32.whl", hash = "sha256:2db3dd99de5e6a5a11d7dda73de8750eb6e5baaf25245adf7bdcfe64b6108ae2", size = 1067804, upload-time = "2026-04-30T23:24:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/16/81/76af00c47daa151e89f98ae21fbbcb2840aaa9f5766579c4da76a3c57188/ast_serialize-0.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:a2cd125adccf7969470621905d302750cd25951f22ea430d9a25b7be031e5549", size = 1105561, upload-time = "2026-04-30T23:24:44.578Z" }, + { url = "https://files.pythonhosted.org/packages/bd/46/d3ec57ad500f598d1554bd14ce4df615960549ab2844961bc4e1f5fbd174/ast_serialize-0.3.0-cp39-abi3-win_arm64.whl", hash = "sha256:0dd00da29985f15f50dc35728b7e1e7c84507bccfea1d9914738530f1c72238a", size = 1077165, upload-time = "2026-04-30T23:24:46.377Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "gaiia" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, +] + +[package.optional-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "respx" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.27" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, + { name = "respx", marker = "extra == 'dev'", specifier = ">=0.21" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.5" }, +] +provides-extras = ["dev"] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/b1/efac073e0c297ecf2fb33c346989a529d4e19164f1759102dee5953ee17e/idna-3.14.tar.gz", hash = "sha256:466d810d7a2cc1022bea9b037c39728d51ae7dad40d480fc9b7d7ecf98ba8ee3", size = 198272, upload-time = "2026-05-10T20:32:15.935Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/3c/3f62dee257eb3d6b2c1ef2a09d36d9793c7111156a73b5654d2c2305e5ce/idna-3.14-py3-none-any.whl", hash = "sha256:e677eaf072e290f7b725f9acf0b3a2bd55f9fd6f7c70abe5f0e34823d0accf69", size = 72184, upload-time = "2026-05-10T20:32:14.295Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "librt" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/10/37fd9e9ba96cb0bd742dfb20fc3d082e54bdbec759d7300df927f360ef07/librt-0.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6e94ebfcfa2d5e9926d6c3b9aa4617ffc42a845b4321fb84021b872358c82a0f", size = 141706, upload-time = "2026-05-10T18:15:16.129Z" }, + { url = "https://files.pythonhosted.org/packages/cf/72/1b1466f358e4a0b728051f69bc27e67b432c6eaa2e05b88db49d3785ae0d/librt-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ae627397a2f351560440d872d6f7c8dbb4072e57868e7b2fc5b8b430fe489d45", size = 142605, upload-time = "2026-05-10T18:15:18.148Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/ed26dd2f6bc9a0baf48306433e579e8d354d70b2bcb78134ed950a5d0e1e/librt-0.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc329359321b67d24efdf4bc69012b0597001649544db662c001db5a0184794c", size = 476555, upload-time = "2026-05-10T18:15:19.569Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/11891191c0e0a3fd617724e891f6e67a71a7658974a892b9a9a97fdb2977/librt-0.11.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:7e82e642ab0f7608ce2fe53d76ca2280a9ee33a1b06556142c7c6fe80a86fc33", size = 468434, upload-time = "2026-05-10T18:15:20.87Z" }, + { url = "https://files.pythonhosted.org/packages/6f/50/5ec949d7f9ce1a07af903aa3e13abb98b717923bdead6e719b2f824ccc07/librt-0.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88145c15c67731d54283d135b03244028c750cc9edc334a96a4f5950ebdb2884", size = 496918, upload-time = "2026-05-10T18:15:22.616Z" }, + { url = "https://files.pythonhosted.org/packages/ea/c4/177336c7524e34875a38bf668e88b193a6723a4eb4045d07f74df6e1506c/librt-0.11.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d36a51b3d93320b686588e27123f4995804dbf1bce81df78c02fc3c6eea9280", size = 490334, upload-time = "2026-05-10T18:15:24.2Z" }, + { url = "https://files.pythonhosted.org/packages/13/1f/da3112f7569eda3b49f9a2629bae1fe059812b6085df16c885f6454dff49/librt-0.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3ac06a2a8b246327f11e186a53a100a4d5c7ed52346367e5ec751d51586c", size = 511287, upload-time = "2026-05-10T18:15:26.226Z" }, + { url = "https://files.pythonhosted.org/packages/fa/94/03fec301522e172d105581431223be56b27594ff46440ebfbb658a3735d5/librt-0.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:461bbceede621f1ffb8839755f8663e886087ee7af16294cab7fb4d782c62eeb", size = 517202, upload-time = "2026-05-10T18:15:27.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/6e/339f6e5a7b413ce014f1917a756dae630fe59cc99f34153205b1cb540901/librt-0.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0cad8a4d6a8ff03c9b76f9414caccd78e7cfbc8a2e12fa334d8e1d9932753783", size = 497517, upload-time = "2026-05-10T18:15:29.614Z" }, + { url = "https://files.pythonhosted.org/packages/cd/43/acdd5ce317cb46e8253ca9bfbdb8b12e68a24d745949336a7f3d5fb79ba0/librt-0.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f37aa505b3cf60701562eddb32df74b12a9e380c207fd8b06dd157a943ac7ea0", size = 538878, upload-time = "2026-05-10T18:15:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/29/b5/7a25bb12e3172839f647f196b3e988318b7bb1ca7501732a225c4dce2ec0/librt-0.11.0-cp310-cp310-win32.whl", hash = "sha256:94663a21534637f0e787ec2a2a756022df6e5b7b2335a5cdd7d8e33d68a2af89", size = 100070, upload-time = "2026-05-10T18:15:32.551Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0d/ebbcf4d77999c02c937b05d2b90ff4cd4dcc7e9a365ba132329ac1fe7a0f/librt-0.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:dec7db73758c2b54953fd8b7fe348c45188fe26b39ee18446196edd08453a5d4", size = 117918, upload-time = "2026-05-10T18:15:33.678Z" }, + { url = "https://files.pythonhosted.org/packages/fe/87/2bf31fe17587b29e3f93ec31421e2b1e1c3e349b8bf6c7c313dbad1d5340/librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29", size = 141092, upload-time = "2026-05-10T18:15:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/cf/08/5c5bf772920b7ebac6e32bc91a643e0ab3870199c0b542356d3baa83970a/librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9", size = 142035, upload-time = "2026-05-10T18:15:36.242Z" }, + { url = "https://files.pythonhosted.org/packages/06/20/662a03d254e5b000d838e8b345d83303ddb768c080fd488e40634c0fa66b/librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5", size = 475022, upload-time = "2026-05-10T18:15:37.56Z" }, + { url = "https://files.pythonhosted.org/packages/de/f3/aa81523e45184c6ec23dc7f63263362ec55f80a09d424c012359ecbe7e35/librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b", size = 467273, upload-time = "2026-05-10T18:15:39.182Z" }, + { url = "https://files.pythonhosted.org/packages/6b/6f/59c74b560ca8853834d5501d589c8a2519f4184f273a085ffd0f37a1cc47/librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89", size = 497083, upload-time = "2026-05-10T18:15:40.634Z" }, + { url = "https://files.pythonhosted.org/packages/fe/7b/5aa4d2c9600a719401160bf7055417df0b2a47439b9d88286ce45e56b65f/librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc", size = 489139, upload-time = "2026-05-10T18:15:41.934Z" }, + { url = "https://files.pythonhosted.org/packages/d6/31/9143803d7da6856a69153785768c4936864430eec0fd9461c3ea527d9922/librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5", size = 508442, upload-time = "2026-05-10T18:15:43.206Z" }, + { url = "https://files.pythonhosted.org/packages/2f/5a/bce08184488426bda4ccc2c4964ac048c8f68ae89bd7120082eef4233cfd/librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7", size = 514230, upload-time = "2026-05-10T18:15:44.761Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/bb5e213d254b7505a0e658da199d8ab719086632ce09eef311ab27976523/librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d", size = 494231, upload-time = "2026-05-10T18:15:46.308Z" }, + { url = "https://files.pythonhosted.org/packages/9d/fb/541cdad5b1ab1300398c74c4c9a497b88e5074c21b1244c8f49731d3a284/librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412", size = 537585, upload-time = "2026-05-10T18:15:47.629Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f2/464bb69295c320cb06bddb4f14a4ec67934ee14b2bffb12b19fb7ab287ba/librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d", size = 100509, upload-time = "2026-05-10T18:15:49.157Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e7/a17ee1788f9e4fbf548c19f4afa07c92089b9e24fef6cb2410863781ef4c/librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73", size = 118628, upload-time = "2026-05-10T18:15:50.345Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/6c766214f9f9903bcfcfbef97d807af8d8f5aa3502d247858ab17582d212/librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c", size = 103122, upload-time = "2026-05-10T18:15:52.068Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" }, + { url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" }, + { url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" }, + { url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" }, + { url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" }, + { url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" }, + { url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" }, + { url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" }, + { url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" }, + { url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" }, + { url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" }, + { url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" }, + { url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" }, + { url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" }, + { url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" }, + { url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" }, + { url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" }, + { url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" }, + { url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" }, + { url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" }, + { url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" }, + { url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" }, + { url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" }, + { url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" }, + { url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" }, + { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, +] + +[[package]] +name = "mypy" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/71/d351dca3e9b30da2328ee9d445c88b8388072808ebfbc49eb69d30b67749/mypy-2.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:11a6beb180257a805961aea9ec591bbd0bd17f1e18d35b8456d57aee5bedfedc", size = 14778792, upload-time = "2026-05-11T18:36:23.605Z" }, + { url = "https://files.pythonhosted.org/packages/2f/45/7d51594b644c17c0bcf74ed8cd5fc33b324276d708e8506f220b70dab9d9/mypy-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ef78c1d306bbf9a8a12f526c44902c9c28dffd6c52c52bf6a72641ce18d3849", size = 13645739, upload-time = "2026-05-11T18:37:22.752Z" }, + { url = "https://files.pythonhosted.org/packages/65/01/455c31b170e9468265074840bf18863a8482a24103fdaabe4e199392aa5f/mypy-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c209a90853081ff01d01ee895cafe10f7db1474e0d95beaeef0f6c1db9119bbd", size = 14074199, upload-time = "2026-05-11T18:35:09.292Z" }, + { url = "https://files.pythonhosted.org/packages/41/5a/93093f0b29a9e982deafde698f740a2eb2e05886e79ccf0594c7fd5413a3/mypy-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47cebf61abde7c088a4e27718a8b13a81655686b2e9c251f5c0915a802248166", size = 14953128, upload-time = "2026-05-11T18:31:57.678Z" }, + { url = "https://files.pythonhosted.org/packages/7f/2f/a196f5331d96170ad3d28f144d2aba690d4b2911381f68d51e489c7ab82a/mypy-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d57a90ae5e872138a425ec328edbc9b235d1934c4377881a33ec05b341acc9a8", size = 15249378, upload-time = "2026-05-11T18:33:00.101Z" }, + { url = "https://files.pythonhosted.org/packages/54/de/94d321cc12da9f71341ac0c270efbed5c725750c7b4c334d957de9a087d9/mypy-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:aea7f7a8a55b459c34275fc468ada6ca7c173a5e43a68f5dbe588a563d8a06b8", size = 11060994, upload-time = "2026-05-11T18:33:18.848Z" }, + { url = "https://files.pythonhosted.org/packages/e1/62/0c27ca55219a7c764a7fb88c7bb2b7b2f9780ade8bbf16bc8ed8400eef6b/mypy-2.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:c989640253f0d76843e9c6c1bbf4bd48c5e85ada61bde4beb37cb3eca035685e", size = 9976743, upload-time = "2026-05-11T18:31:25.554Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a1/639f3024794a2a15899cb90707fe02e044c4412794c39c5769fd3df2e2ef/mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41", size = 14691685, upload-time = "2026-05-11T18:33:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/3b/08/9a585dea4325f20d8b80dc78623fa50d1fd2173b710f6237afd6ba6ab39b/mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca", size = 13555165, upload-time = "2026-05-11T18:32:16.107Z" }, + { url = "https://files.pythonhosted.org/packages/81/dc/7c42cc9c6cb01e8eb09961f1f738741d3e9c7e9d5c5b30ec69222625cd5f/mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538", size = 13994376, upload-time = "2026-05-11T18:32:39.256Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/285946c33bce716e082c11dfeee9ee196eaf1f5042efb3581a31f9f205e4/mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398", size = 14864618, upload-time = "2026-05-11T18:34:49.765Z" }, + { url = "https://files.pythonhosted.org/packages/2b/83/82397f48af6c27e295d57979ded8490c9829040152cf7571b2f026aeb9a0/mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563", size = 15102063, upload-time = "2026-05-11T18:34:05.855Z" }, + { url = "https://files.pythonhosted.org/packages/40/68/b02dec39057b88eb03dc0aa854732e26e8361f34f9d0e20c7614967d1eba/mypy-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389", size = 11060564, upload-time = "2026-05-11T18:35:36.494Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a8/ea3dcbef31f99b634f2ee23bb0321cbc8c1b388b76a861eb849f13c347dc/mypy-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666", size = 9966983, upload-time = "2026-05-11T18:37:14.139Z" }, + { url = "https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381, upload-time = "2026-05-11T18:37:31.784Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501, upload-time = "2026-05-11T18:34:23.063Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750, upload-time = "2026-05-11T18:31:48.151Z" }, + { url = "https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630, upload-time = "2026-05-11T18:37:06.898Z" }, + { url = "https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831, upload-time = "2026-05-11T18:31:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228, upload-time = "2026-05-11T18:34:31.23Z" }, + { url = "https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684, upload-time = "2026-05-11T18:36:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" }, + { url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" }, + { url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" }, + { url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" }, + { url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" }, + { url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" }, + { url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" }, + { url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "respx" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/98/4e55c9c486404ec12373708d015ebce157966965a5ebe7f28ff2c784d41b/respx-0.23.1.tar.gz", hash = "sha256:242dcc6ce6b5b9bf621f5870c82a63997e8e82bc7c947f9ffe272b8f3dd5a780", size = 29243, upload-time = "2026-04-08T14:37:16.008Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, + { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, + { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, + { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, + { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, + { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, + { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] diff --git a/gaiia/workflows_backup_2026-07-10_125815.json b/gaiia/workflows_backup_2026-07-10_125815.json new file mode 100644 index 0000000..174e985 --- /dev/null +++ b/gaiia/workflows_backup_2026-07-10_125815.json @@ -0,0 +1,6980 @@ +{ + "backed_up_at": "2026-07-10T12:58:15.925364", + "workflow_count": 43, + "workflows": { + "Verona Networks PPPoE lease": { + "id": "workflow_2ibweh1uRsDp5M289jEPqa", + "description": null, + "definition": { + "steps": [ + { + "name": "Assign IP address to MAC address", + "slug": "assign-ip-address-to-mac-address", + "type": "GAIIA_API_GATEWAY_V0_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-internal-api-gateway-graphql-request", + "allowSkipExecution": false, + "inputs": [ + { + "key": "query", + "type": "LITERAL", + "value": "string:mutation WorkflowAssignIpAddressToMacAddress($input: AssignIpAddressToMacAddressInput!) {\n assignIpAddressToMacAddress(input: $input) {\n id\n }\n}" + }, + { + "key": "variables", + "type": "FUNCTION", + "value": "({ state }) => ({\n input: {\n ipAddress: state.input.framedip,\n macAddress: state.input.callingstationid,\n accountId: state.steps['get-account-id-from-radius-username'].output?.accounts[0].accountId\n }\n})" + } + ], + "nextSteps": [ + { + "slug": "run-update-preseem-on-pppoe-ip-change" + } + ] + }, + { + "name": "Should assign IP address", + "slug": "should-assign-ip-address", + "type": "CONDITION", + "nodeSlug": "condition", + "condition": "({ secrets, state, utils, variables }) => {\n return state.input.bound === '1';\n}", + "elseSteps": [ + { + "slug": "end" + } + ], + "thenSteps": [ + { + "slug": "get-account-id-from-radius-username" + } + ] + }, + { + "name": "Get Account ID from RADIUS Username", + "slug": "get-account-id-from-radius-username", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "get-account-id-from-radius-username", + "allowSkipExecution": false, + "inputs": [ + { + "key": "username", + "type": "STATE", + "value": "input.username" + } + ], + "nextSteps": [ + { + "slug": "assign-ip-address-to-mac-address" + }, + { + "slug": "assign-ip-address-to-mac-address", + "errorCodes": [ + "USERNAME_NOT_FOUND" + ] + } + ] + }, + { + "allowSkipExecution": false, + "type": "GAIIA_START_WORKFLOW_EXECUTION", + "slug": "run-update-preseem-on-pppoe-ip-change", + "name": "Run Update Preseem on PPPoE IP Change", + "nodeSlug": "start-workflow-execution", + "inputs": [ + { + "key": "workflowId", + "value": "string:workflow_aEcWxEsubpuSebPwajqseP", + "type": "LITERAL" + }, + { + "key": "fireAndForget", + "value": "boolean:true", + "type": "LITERAL" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ] + } + ], + "trigger": { + "nextSteps": [ + { + "slug": "should-assign-ip-address" + } + ], + "type": "WEBHOOK", + "inputJsonSchema": { + "type": "object", + "required": [ + "bound", + "callingstationid", + "framedip", + "username" + ], + "properties": { + "bound": { + "type": "string" + }, + "framedip": { + "type": "string" + }, + "username": { + "type": "string" + }, + "callingstationid": { + "type": "string" + } + } + } + } + } + }, + "Get Config for FreeRADIUS": { + "id": "workflow_1vgYQa82YuccZmRvzW5T3P", + "description": null, + "definition": { + "trigger": { + "nextSteps": [ + { + "slug": "get-free-radius-config" + } + ], + "type": "MANUAL" + }, + "outputMapper": { + "function": "({ state }) => {\n const { instances } = state.steps[\"get-free-radius-config\"].output;\n const groupDetails = state.steps[\"get-attributes-and-fall-through\"].output;\n const groupDetailsMap = new Map();\n for (const detail of groupDetails) {\n groupDetailsMap.set(`${detail.instanceName}:${detail.groupName}`, detail);\n }\n const enrichedInstances = instances.map((instance) => ({\n ...instance,\n groups: instance.groups.map((group) => {\n const key = `${instance.name}:${group.groupName}`;\n const details = groupDetailsMap.get(key);\n return {\n ...group,\n attributes: details?.attributes || [],\n fallThrough: details?.fallThrough ?? false\n };\n })\n }));\n return { instances: enrichedInstances };\n }", + "type": "JSON" + }, + "steps": [ + { + "name": "get-free-radius-config", + "slug": "get-free-radius-config", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:async ({ utils }) => {\n const instancesString = await utils.integration.datastore.getItem(\"instances\");\n if (!instancesString) {\n return { instances: [] };\n }\n const instanceIds = instancesString.split(\",\").filter((id) => id.trim());\n const instances = [];\n for (const id of instanceIds) {\n const name = await utils.integration.datastore.getItem(`instances.${id}.name`);\n const [databaseHost, databasePort, databaseName, databaseUsername, radiusServerHost, coaPort, groups] = await Promise.all([\n utils.integration.datastore.getItem(`instances.${id}.databaseHost`),\n utils.integration.datastore.getItem(`instances.${id}.databasePort`),\n utils.integration.datastore.getItem(`instances.${id}.databaseName`),\n utils.integration.datastore.getItem(`instances.${id}.databaseUsername`),\n utils.integration.datastore.getItem(`instances.${id}.radiusServerHost`),\n utils.integration.datastore.getItem(`instances.${id}.coaPort`),\n utils.integration.datastore.getItem(`instances.${id}.groups`)\n ]);\n instances.push({\n id,\n name: name || \"\",\n databaseHost: databaseHost || \"\",\n databasePort: databasePort ? Number(databasePort) : 0,\n databaseName: databaseName || \"\",\n databaseUsername: databaseUsername || \"\",\n radiusServerHost: radiusServerHost || \"\",\n coaPort: coaPort ? Number(coaPort) : 0,\n groups: groups ? JSON.parse(groups) : []\n });\n }\n return { instances };\n }", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "get-attributes-and-fall-through" + } + ], + "outputMapper": "({ output }) => output" + }, + { + "name": "get-attributes-and-fall-through", + "slug": "get-attributes-and-fall-through", + "nodeSlug": "parallel-loop", + "type": "PARALLEL_LOOP", + "inputs": [ + { + "key": "iterable", + "type": "FUNCTION", + "value": "({ state }) => {\n const { instances } = state.steps[\"get-free-radius-config\"].output;\n return instances.flatMap(\n (instance) => instance.groups.map((group) => ({\n instanceName: instance.name,\n groupName: group.groupName\n }))\n );\n }" + } + ], + "initialSteps": [ + { + "slug": "query-group-details" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ], + "outputMapper": "({ state }) => state.steps[\"query-group-details\"].output" + }, + { + "name": "query-group-details", + "slug": "query-group-details", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "free-radius-mysql-transaction", + "inputs": [ + { + "type": "FUNCTION", + "value": "({ state }) => state.currentParallelLoopIterations[\"get-attributes-and-fall-through\"].instanceName", + "key": "instanceName" + }, + { + "type": "FUNCTION", + "value": "({ state }) => {\n const { groupName } = state.currentParallelLoopIterations[\"get-attributes-and-fall-through\"];\n return [\n {\n query: `SELECT attribute, op, value FROM radgroupreply WHERE groupname = ?`,\n values: [groupName]\n }\n ];\n }", + "key": "queries" + } + ], + "nextSteps": [ + { + "slug": "get-attributes-and-fall-through-end" + } + ], + "outputMapper": "({ state, output }) => {\n const loopItem = state.currentParallelLoopIterations[\"get-attributes-and-fall-through\"];\n const allResults = output.results[0] || [];\n const fallThroughRow = allResults.find((row) => row.attribute === \"Fall-Through\");\n const attributes = allResults.filter((row) => row.attribute !== \"Fall-Through\").map((row) => ({\n name: row.attribute,\n operator: row.op,\n value: row.value\n }));\n const fallThrough = !fallThroughRow || fallThroughRow.value === \"Yes\";\n return {\n groupName: loopItem.groupName,\n instanceName: loopItem.instanceName,\n attributes,\n fallThrough\n };\n }", + "parentParallelLoopSlug": "get-attributes-and-fall-through" + } + ] + } + }, + "Get Config for NetBox": { + "id": "workflow_uncWf2LVGAEGAovxgYDRD8", + "description": null, + "definition": { + "trigger": { + "nextSteps": [ + { + "slug": "get-netbox-config" + } + ], + "type": "MANUAL" + }, + "outputMapper": { + "function": "({ state }) => state.steps[\"get-netbox-config\"].output", + "type": "JSON" + }, + "steps": [ + { + "name": "get-netbox-config", + "slug": "get-netbox-config", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:async ({ utils }) => {\n const instancesString = await utils.integration.datastore.getItem(\"instances\");\n if (!instancesString) {\n return { instances: [] };\n }\n const instanceIds = instancesString.split(\",\").filter((name) => name.trim());\n const instances = [];\n for (const id of instanceIds) {\n const name = await utils.integration.datastore.getItem(`instances.${id}.name`);\n const baseUrl = await utils.integration.datastore.getItem(`instances.${id}.baseUrl`);\n const prefixSynchronizationString = await utils.integration.datastore.getItem(`instances.${id}.prefixSynchronization`);\n const networkSiteSynchronizationString = await utils.integration.datastore.getItem(`instances.${id}.networkSiteSynchronization`);\n let prefixSynchronization = { ipamRoles: [], isEnabled: false };\n if (prefixSynchronizationString) {\n try {\n prefixSynchronization = JSON.parse(prefixSynchronizationString);\n } catch {\n }\n }\n let networkSiteSynchronization = { customFieldMappings: [], isEnabled: false };\n if (networkSiteSynchronizationString) {\n try {\n networkSiteSynchronization = JSON.parse(networkSiteSynchronizationString);\n } catch {\n }\n }\n instances.push({\n id,\n baseUrl: baseUrl || \"\",\n name: name || \"\",\n prefixSynchronization,\n networkSiteSynchronization\n });\n }\n return { instances };\n }", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ], + "outputMapper": "({ output }) => output" + } + ] + } + }, + "Verona Networks DHCP lease": { + "id": "workflow_rpEtJsMPzhNSYoM1H3jGch", + "description": null, + "definition": { + "steps": [ + { + "name": "Find gaiia account by inventory item assignation", + "slug": "find-gaiia-account-by-inventory-item-assignation", + "outputMapper": "({ output, state }) => {\n const assignedInventoryItems = output.response.findInventoryItemsFromDeviceIdentifiers;\n if (assignedInventoryItems.length > 1) {\n workflow.fail(`Many inventory items with MAC '${state.input.mac}' or remote ID '${state.input.remoteId}' assigned to gaiia accounts.`, 'MANY_ASSIGNED_DEVICES');\n }\n return assignedInventoryItems[0]?.inventoryItem.assignation.assignee;\n }", + "type": "GAIIA_API_GATEWAY_V0_GRAPHQL_REQUEST", + "inputs": [ + { + "key": "query", + "type": "LITERAL", + "value": "string:query VeronaWorkflowFindInventoryItemsFromDeviceIdentifiersQuery($input: FindInventoryItemsFromDeviceIdentifiersQueryInput!) {\n findInventoryItemsFromDeviceIdentifiers(input: $input) {\n identifier\n inventoryItem {\n assignation {\n assignee {\n ... on Account {\n id\n }\n }\n }\n }\n }\n}" + }, + { + "key": "variables", + "type": "FUNCTION", + "value": "({ state }) => {\n const inventoryItemMacAddress = state.input.remoteId\n .split(':')\n .map((macAddressPart) => macAddressPart.length === 2 ? macAddressPart.toUpperCase() : `0${macAddressPart}`.toUpperCase())\n .join(':'); // a:0:3e:44:97:6 -> 0A:00:3E:44:97:06\n return {\n input: { identifiers: [inventoryItemMacAddress] }\n };\n }" + } + ], + "nextSteps": [ + { + "slug": "assign-ip-address-to-mac-address-with-account" + } + ], + "nodeSlug": "gaiia-internal-api-gateway-graphql-request" + }, + { + "name": "Assign IP address to MAC address with account", + "slug": "assign-ip-address-to-mac-address-with-account", + "type": "GAIIA_API_GATEWAY_V0_GRAPHQL_REQUEST", + "inputs": [ + { + "key": "query", + "type": "LITERAL", + "value": "string:mutation WorkflowAssignIpAddressToMacAddress($input: AssignIpAddressToMacAddressInput!) {\n assignIpAddressToMacAddress(input: $input) {\n id\n }\n}" + }, + { + "key": "variables", + "type": "FUNCTION", + "value": "({ state }) => ({\n input: {\n ipAddress: state.input.ip,\n macAddress: state.input.mac,\n accountId: state.steps['find-gaiia-account-by-inventory-item-assignation'].output?.id\n }\n })" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ], + "nodeSlug": "gaiia-internal-api-gateway-graphql-request" + }, + { + "name": "Assign IP address to MAC address without account", + "slug": "assign-ip-address-to-mac-address-without-account", + "type": "GAIIA_API_GATEWAY_V0_GRAPHQL_REQUEST", + "inputs": [ + { + "key": "query", + "type": "LITERAL", + "value": "string:mutation WorkflowAssignIpAddressToMacAddress($input: AssignIpAddressToMacAddressInput!) {\n assignIpAddressToMacAddress(input: $input) {\n id\n }\n}" + }, + { + "key": "variables", + "type": "FUNCTION", + "value": "({ state }) => ({\n input: {\n ipAddress: state.input.ip,\n macAddress: state.input.mac\n }\n })" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ], + "nodeSlug": "gaiia-internal-api-gateway-graphql-request" + }, + { + "name": "Has remote ID", + "slug": "has-remote-id", + "condition": "({ secrets, state, utils, variables }) => {\n return state.input.remoteId;\n}", + "elseSteps": [ + { + "slug": "assign-ip-address-to-mac-address-without-account" + } + ], + "thenSteps": [ + { + "slug": "find-gaiia-account-by-inventory-item-assignation" + } + ], + "type": "CONDITION", + "nodeSlug": "condition" + }, + { + "name": "Is IP address bound", + "slug": "is-ip-address-bound", + "condition": "({ secrets, state, utils, variables }) => {\n return state.input.bound === '1';\n}", + "elseSteps": [ + { + "slug": "end" + } + ], + "thenSteps": [ + { + "slug": "has-remote-id" + } + ], + "type": "CONDITION", + "nodeSlug": "condition" + } + ], + "trigger": { + "nextSteps": [ + { + "slug": "is-ip-address-bound" + } + ], + "type": "WEBHOOK", + "inputJsonSchema": { + "type": "object", + "required": [ + "bound", + "mac", + "ip", + "server", + "remoteId" + ], + "properties": { + "ip": { + "type": "string" + }, + "mac": { + "type": "string" + }, + "bound": { + "type": "string" + }, + "server": { + "type": "string" + }, + "remoteId": { + "type": "string" + } + } + } + } + } + }, + "NetBox HTTP Request": { + "id": "workflow_w57zmPhtwUGon3AtuHSL3T", + "description": null, + "definition": { + "trigger": { + "inputJsonSchema": { + "type": "object", + "properties": { + "method": { + "type": "string", + "enum": [ + "GET", + "POST", + "PUT", + "DELETE", + "PATCH" + ], + "description": "The HTTP method to use for the request." + }, + "path": { + "type": "string", + "description": "The URL path for the request." + }, + "body": { + "type": [ + "object", + "array" + ], + "description": "Optional body of the request, typically used for POST and PUT requests." + }, + "params": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Optional query parameters for the request." + }, + "instanceName": { + "type": "string" + } + }, + "required": [ + "method", + "path" + ] + }, + "nextSteps": [ + { + "slug": "fetch-datastore-key" + } + ], + "type": "MANUAL" + }, + "outputMapper": { + "function": "({ state }) => {\n if (\"output\" in state.steps[\"http-request\"]) {\n return state.steps[\"http-request\"].output.data;\n }\n return {};\n }", + "type": "JSON" + }, + "steps": [ + { + "name": "fetch-datastore-key", + "slug": "fetch-datastore-key", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:({ state }) => state.input", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "http-request" + } + ], + "outputMapper": "async ({ state, utils }) => {\n const { instanceName } = state.input;\n let instanceId;\n if (!instanceName) {\n const instancesString = await utils.integration.datastore.getItem(\"instances\");\n if (!instancesString) {\n return utils.fail(\"No instances found in datastore\", \"NO_INSTANCES_FOUND\");\n }\n const instanceIds = instancesString.split(\",\").map((id) => id.trim()).filter(Boolean);\n if (instanceIds.length === 0) {\n utils.fail(\"No instance IDs found\", \"NO_INSTANCE_IDS_FOUND\");\n }\n [instanceId] = instanceIds;\n } else {\n const retrievedInstanceId = await utils.integration.datastore.getItem(`instances.${instanceName}.id`);\n if (!retrievedInstanceId) {\n return utils.fail(\"Invalid instance name provided\", \"INVALID_INSTANCE_NAME\");\n }\n instanceId = retrievedInstanceId;\n }\n const datastoreKey = `instances.${instanceId}`;\n const [baseURL, apiKey] = await Promise.all([\n utils.integration.datastore.getItem(`${datastoreKey}.baseURL`),\n utils.integration.datastore.getItem(`${datastoreKey}.apiKey`)\n ]);\n if (!baseURL)\n return utils.fail(\"Missing baseURL\", \"MISSING_BASE_URL\");\n if (!apiKey)\n return utils.fail(\"Missing API key\", \"MISSING_API_KEY\");\n return {\n baseURL,\n apiKey\n };\n }" + }, + { + "name": "http-request", + "slug": "http-request", + "type": "HTTP_REST_REQUEST", + "nodeSlug": "http-rest-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "({ state }) => state.input.body", + "key": "body" + }, + { + "type": "FUNCTION", + "value": "async ({ state }) => ({\n Authorization: `Token ${state.steps[\"fetch-datastore-key\"].output.apiKey}`,\n \"Content-Type\": \"application/json\"\n })", + "key": "headers" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.input.method", + "key": "method" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.input.params", + "key": "params" + }, + { + "type": "FUNCTION", + "value": "async ({ state }) => {\n const baseUrl = state.steps[\"fetch-datastore-key\"].output.baseURL;\n const baseUrlWithoutTrailingSlash = baseUrl.replace(/\\/$/, \"\");\n const pathWithoutLeadingSlash = state.input.path.replace(/^\\//, \"\");\n return `${baseUrlWithoutTrailingSlash}/${pathWithoutLeadingSlash}`;\n }", + "key": "url" + } + ], + "nextSteps": [ + { + "slug": "handle-http-failure", + "errorCodes": [ + "HTTP_REQUEST_FAILED" + ] + }, + { + "slug": "end" + } + ] + }, + { + "name": "handle-http-failure", + "slug": "handle-http-failure", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:({ state, utils }) => {\n const IP_ADDRESS_NOT_FOUND_MESSAGE = \"Not found.\";\n const ADDRESS_ALREADY_RESERVED_MESSAGE = \"Duplicate IP address found\";\n const getNetboxErrorCode = (detail) => {\n if (detail.toLowerCase() === IP_ADDRESS_NOT_FOUND_MESSAGE.toLowerCase()) {\n return \"IP_ADDRESS_NOT_FOUND\";\n }\n return \"UNEXPECTED_ERROR\";\n };\n const failure = state.steps[\"http-request\"].error;\n const payload = failure.payload;\n if (payload?.detail) {\n return utils.fail(`Netbox returned the following error: ${payload.detail}`, getNetboxErrorCode(payload.detail), payload);\n }\n if (payload?.address && payload.address[0].toLowerCase().includes(ADDRESS_ALREADY_RESERVED_MESSAGE.toLowerCase())) {\n return utils.fail(\n `Netbox returned the following error: ${payload.address.map((message) => `'${message}'`).join(\", \")}`,\n \"ADDRESS_ALREADY_RESERVED\",\n payload\n );\n }\n return utils.fail(failure.message, failure.code, failure.payload);\n }", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ] + } + ] + } + }, + "Add RADIUS Account": { + "id": "workflow_mi1m5xC4nANZnbXyNgXKc4", + "description": null, + "definition": { + "trigger": { + "inputJsonSchema": { + "type": "object", + "properties": { + "accountId": { + "type": "string", + "description": "The account ID in gaiia" + }, + "username": { + "type": "string", + "description": "The RADIUS username", + "maxLength": 64 + }, + "instanceName": { + "type": "string", + "description": "The FreeRADIUS instance name" + }, + "password": { + "type": "string", + "description": "The RADIUS user password", + "maxLength": 253 + }, + "accountDetails": { + "type": "object", + "properties": { + "framedIpAddress": { + "type": "string", + "description": "The Framed-IP-Address attribute" + }, + "framedRoute": { + "type": "string", + "description": "The Framed-Route attribute" + } + }, + "additionalProperties": false + } + }, + "required": [ + "accountId", + "username", + "instanceName", + "password" + ], + "additionalProperties": false + }, + "nextSteps": [ + { + "slug": "validate-input" + } + ], + "type": "MANUAL" + }, + "outputMapper": { + "function": "({ state, utils }) => {\n if (\"handle-error\" in state.steps) {\n utils.fail(state.steps[\"handle-error\"].output.message, state.steps[\"handle-error\"].output.code, state.steps[\"handle-error\"].output.payload);\n }\n return {\n success: true\n };\n }", + "type": "JSON" + }, + "steps": [ + { + "name": "validate-input", + "slug": "validate-input", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:async ({ state, utils }) => {\n const { instanceName, username, password, accountDetails } = state.input;\n const instanceId = await utils.integration.datastore.getItem(`instances.${instanceName}.id`);\n if (!instanceId) {\n return utils.fail(\"Instance not found\", \"INSTANCE_NOT_FOUND\");\n }\n if (username.length > 64) {\n return utils.fail(\"Username is too long. Max length is 64\", \"USERNAME_TOO_LONG\");\n }\n const usernameRegex = /^[\\w +./:@`\\-]+$/;\n if (!usernameRegex.test(username)) {\n return utils.fail(\n \"The username contains invalid characters. Allowed characters are alphanumeric characters, plus: space, +, ., /, :, @, `, -, and _.\",\n \"USERNAME_INVALID_CHARACTERS\"\n );\n }\n if (password.length > 253) {\n return utils.fail(\"Password is too long. Max length is 253\", \"PASSWORD_TOO_LONG\");\n }\n const ipv4Regex = /^(?:(?:25[0-5]|2[0-4]\\d|[01]?\\d{1,2})\\.){3}(?:25[0-5]|2[0-4]\\d|[01]?\\d{1,2})$/;\n if (accountDetails?.framedIpAddress !== void 0 && accountDetails?.framedIpAddress !== null && accountDetails?.framedIpAddress !== \"\" && !ipv4Regex.test(accountDetails.framedIpAddress)) {\n return utils.fail(\"Framed-IP-Address must be a valid IPv4 address\", \"INVALID_FRAMED_IP_ADDRESS\");\n }\n if (accountDetails?.framedRoute !== void 0 && accountDetails?.framedRoute !== null && accountDetails?.framedRoute !== \"\") {\n const ipPart = accountDetails.framedRoute.split(\"/\")[0].trim();\n if (!ipv4Regex.test(ipPart)) {\n return utils.fail(\"Framed-Route must contain a valid IPv4 address\", \"INVALID_FRAMED_ROUTE\");\n }\n }\n return {\n instanceId,\n instanceName\n };\n }", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "check-username-and-attribute-availability" + }, + { + "slug": "handle-error", + "errorCodes": [ + "*" + ] + } + ], + "outputMapper": "({ output }) => output" + }, + { + "name": "check-username-and-attribute-availability", + "slug": "check-username-and-attribute-availability", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "free-radius-mysql-transaction", + "inputs": [ + { + "type": "FUNCTION", + "value": "({ state }) => state.input.instanceName", + "key": "instanceName" + }, + { + "type": "FUNCTION", + "value": "({ state }) => {\n const { username, accountDetails } = state.input;\n const queries = [];\n queries.push({\n query: \"SELECT COUNT(*) as count FROM radcheck WHERE username = ? LIMIT 1\",\n values: [username]\n });\n if (accountDetails?.framedIpAddress) {\n queries.push({\n query: 'SELECT COUNT(*) as count FROM radreply WHERE attribute = \"Framed-IP-Address\" AND value = ?',\n values: [accountDetails.framedIpAddress]\n });\n }\n if (accountDetails?.framedRoute) {\n queries.push({\n query: 'SELECT COUNT(*) as count FROM radreply WHERE attribute = \"Framed-Route\" AND value = ?',\n values: [accountDetails.framedRoute]\n });\n }\n return queries;\n }", + "key": "queries" + } + ], + "nextSteps": [ + { + "slug": "get-account-details" + }, + { + "slug": "handle-error", + "errorCodes": [ + "*" + ] + } + ], + "outputMapper": "({ state, output, utils }) => {\n const { accountDetails } = state.input;\n const [[usernameCount = { count: 0 }], ...restResults] = output.results;\n if (usernameCount.count > 0) {\n return utils.fail(\"Username already exists\", \"USERNAME_ALREADY_EXISTS\");\n }\n if (accountDetails?.framedIpAddress) {\n const [framedIpAddressCount] = restResults.shift();\n if (framedIpAddressCount.count > 0) {\n return utils.fail(\"Framed-IP-Address is not available\", \"FRAMED_IP_ADDRESS_NOT_AVAILABLE\");\n }\n }\n if (accountDetails?.framedRoute) {\n const [framedRouteCount] = restResults.shift();\n if (framedRouteCount.count > 0) {\n return utils.fail(\"Framed-Route is not available\", \"FRAMED_ROUTE_NOT_AVAILABLE\");\n }\n }\n return {\n success: true\n };\n }" + }, + { + "name": "get-account-details", + "slug": "get-account-details", + "type": "GAIIA_API_GATEWAY_V1_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-public-api-gateway-graphql-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => `\n query GetAccountAndProductSubscriptions($accountId: GlobalID!) {\n account(id: $accountId) {\n status {\n type\n }\n billingSubscriptions {\n nodes {\n productVersion {\n product {\n id\n }\n }\n unassignedAt\n }\n }\n }\n }\n `", + "key": "query" + }, + { + "type": "FUNCTION", + "value": "({ state }) => ({\n accountId: state.input.accountId\n })", + "key": "variables" + } + ], + "nextSteps": [ + { + "slug": "create-radius-account" + }, + { + "slug": "handle-error", + "errorCodes": [ + "*" + ] + } + ], + "outputMapper": "async ({ state, output, utils }) => {\n const { instanceId } = state.steps[\"validate-input\"].output;\n const response = output.response;\n const accountStatus = response.account?.status.type;\n if (!response.account) {\n return utils.fail(\"Account not found\", \"ACCOUNT_NOT_FOUND\");\n }\n const productIds = response.account.billingSubscriptions?.nodes.filter((node) => !node.unassignedAt).map((node) => node.productVersion.product.id) || [];\n const groupsData = await utils.integration.datastore.getItem(`instances.${instanceId}.groups`);\n let groups = [];\n if (groupsData) {\n const allGroups = JSON.parse(groupsData);\n const groupsToAssign = allGroups.filter((group) => {\n switch (group.mappingRule) {\n case \"ALL\":\n return true;\n case \"ACTIVE\":\n return accountStatus === \"ACTIVE\";\n case \"INACTIVE\":\n return accountStatus === \"INACTIVE\";\n case \"SUSPENDED\":\n return accountStatus === \"SUSPENDED\";\n case \"MATCH_PRODUCT\":\n const hasMatchingProduct = group.mappedProductIds.some((mappedProductId) => productIds.includes(mappedProductId));\n return hasMatchingProduct;\n }\n return false;\n });\n groups = groupsToAssign.map((group) => ({\n groupName: group.groupName,\n priority: group.priority\n }));\n }\n return {\n accountStatus: response.account.status.type,\n productIds,\n groups\n };\n }" + }, + { + "name": "create-radius-account", + "slug": "create-radius-account", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "free-radius-mysql-transaction", + "inputs": [ + { + "type": "FUNCTION", + "value": "({ state }) => state.input.instanceName", + "key": "instanceName" + }, + { + "type": "FUNCTION", + "value": "({ state }) => {\n const { username, password, accountDetails } = state.input;\n const { groups } = state.steps[\"get-account-details\"].output;\n const queries = [];\n queries.push({\n query: \"INSERT INTO radcheck (username, attribute, op, value) VALUES (?, ?, ?, ?)\",\n values: [username, \"Cleartext-Password\", \":=\", password]\n });\n if (accountDetails?.framedIpAddress) {\n queries.push({\n query: \"INSERT INTO radreply (username, attribute, op, value) VALUES (?, ?, ?, ?)\",\n values: [username, \"Framed-IP-Address\", \"=\", accountDetails.framedIpAddress]\n });\n }\n if (accountDetails?.framedRoute) {\n queries.push({\n query: \"INSERT INTO radreply (username, attribute, op, value) VALUES (?, ?, ?, ?)\",\n values: [username, \"Framed-Route\", \"+=\", accountDetails.framedRoute]\n });\n }\n if (groups.length > 0) {\n queries.push({\n query: `INSERT INTO radusergroup (username, groupname, priority) VALUES ${groups.map(() => \"(?, ?, ?)\").join(\", \")}`,\n values: groups.flatMap((group) => [username, group.groupName, String(group.priority)])\n });\n }\n return queries;\n }", + "key": "queries" + } + ], + "nextSteps": [ + { + "slug": "store-in-datastore" + } + ], + "outputMapper": "() => ({\n success: true\n })" + }, + { + "name": "store-in-datastore", + "slug": "store-in-datastore", + "type": "GAIIA_WRITE_TO_INTEGRATION_DATASTORE", + "nodeSlug": "write-to-integration-datastore", + "inputs": [ + { + "type": "FUNCTION", + "value": "async ({ state, utils }) => {\n const { accountId, username } = state.input;\n const { instanceId } = state.steps[\"validate-input\"].output;\n const existingData = await utils.integration.datastore.getItem(`radiusAccount.${accountId}`);\n const accounts = existingData ? JSON.parse(existingData) : [];\n const newAccount = {\n createdAt: (new Date()).toISOString(),\n instanceId,\n username\n };\n const updatedAccounts = [...accounts, newAccount];\n return [\n {\n key: `radiusAccount.${accountId}`,\n value: JSON.stringify(updatedAccounts)\n },\n {\n key: `radiusUsername.${instanceId}.${username}`,\n value: accountId\n }\n ];\n }", + "key": "values" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ], + "outputMapper": "() => ({\n success: true\n })" + }, + { + "name": "handle-error", + "slug": "handle-error", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:({ state }) => {\n const steps = state.steps;\n return steps[\"check-username-and-attribute-availability\"]?.error || steps[\"get-account-details\"]?.error || steps[\"validate-input\"]?.error;\n }", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ], + "outputMapper": "({ output }) => output" + } + ] + } + }, + "Update RADIUS Account": { + "id": "workflow_iNFhPzVMcuMd3WhXA1KfSE", + "description": null, + "definition": { + "trigger": { + "inputJsonSchema": { + "type": "object", + "properties": { + "instanceName": { + "type": "string", + "description": "The FreeRADIUS instance name" + }, + "accountId": { + "type": "string", + "description": "The account ID in gaiia" + }, + "oldUsername": { + "type": "string", + "description": "The current RADIUS username" + }, + "username": { + "type": "string", + "description": "The new RADIUS username", + "maxLength": 64 + }, + "password": { + "type": "string", + "description": "The new password (if changing)", + "maxLength": 253 + }, + "framedIpAddress": { + "type": "string", + "description": "The Framed-IP-Address attribute" + }, + "framedRoute": { + "type": "string", + "description": "The Framed-Route attribute (without gateway)" + } + }, + "required": [ + "instanceName", + "accountId", + "oldUsername", + "username" + ], + "additionalProperties": false + }, + "nextSteps": [ + { + "slug": "get-instance-coa-config" + } + ], + "type": "MANUAL" + }, + "outputMapper": { + "function": "({ state, utils }) => {\n if (\"handle-error\" in state.steps) {\n const error = state.steps[\"handle-error\"];\n utils.fail(error.output.message, error.output.code, error.output.payload);\n }\n return {\n success: true\n };\n }", + "type": "JSON" + }, + "steps": [ + { + "name": "get-instance-coa-config", + "slug": "get-instance-coa-config", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:async ({ state, utils }) => {\n const { instanceName, username, password, framedIpAddress, framedRoute } = state.input;\n const instanceId = await utils.integration.datastore.getItem(`instances.${instanceName}.id`);\n if (!instanceId) {\n return utils.fail(\"Instance not found\", \"INSTANCE_NOT_FOUND\");\n }\n const [radiusServerHost, coaPort, coaSecret] = await Promise.all([\n utils.integration.datastore.getItem(`instances.${instanceId}.radiusServerHost`),\n utils.integration.datastore.getItem(`instances.${instanceId}.coaPort`),\n utils.integration.datastore.getItem(`instances.${instanceId}.coaSecret`)\n ]);\n if (username.length > 64) {\n return utils.fail(\"Username is too long. Max length is 64\", \"USERNAME_TOO_LONG\");\n }\n const usernameRegex = /^[\\w +./:@`\\-]+$/;\n if (!usernameRegex.test(username)) {\n return utils.fail(\n \"The username contains invalid characters. Allowed characters are alphanumeric characters, plus: space, +, ., /, :, @, `, -, and _.\",\n \"USERNAME_INVALID_CHARACTERS\"\n );\n }\n if (password !== void 0 && password !== null && password.length > 253) {\n return utils.fail(\"Password is too long. Max length is 253\", \"PASSWORD_TOO_LONG\");\n }\n const ipv4Regex = /^(?:(?:25[0-5]|2[0-4]\\d|[01]?\\d{1,2})\\.){3}(?:25[0-5]|2[0-4]\\d|[01]?\\d{1,2})$/;\n if (framedIpAddress !== void 0 && framedIpAddress !== null && framedIpAddress !== \"\" && !ipv4Regex.test(framedIpAddress)) {\n return utils.fail(\"Framed-IP-Address must be a valid IPv4 address\", \"INVALID_FRAMED_IP_ADDRESS\");\n }\n if (framedRoute !== void 0 && framedRoute !== null && framedRoute !== \"\") {\n const ipPart = framedRoute.split(\"/\")[0].trim();\n if (!ipv4Regex.test(ipPart)) {\n return utils.fail(\"Framed-Route must contain a valid IPv4 address\", \"INVALID_FRAMED_ROUTE\");\n }\n }\n return {\n instanceId,\n instanceName,\n radiusServerHost: radiusServerHost || \"\",\n coaPort: coaPort ? Number(coaPort) : 0,\n coaSecret: coaSecret || \"\"\n };\n }", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "fetch-account-state-and-validate-transaction" + }, + { + "slug": "handle-error", + "errorCodes": [ + "*" + ] + } + ], + "outputMapper": "({ output }) => output" + }, + { + "name": "fetch-account-state-and-validate-transaction", + "slug": "fetch-account-state-and-validate-transaction", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "free-radius-mysql-transaction", + "inputs": [ + { + "type": "FUNCTION", + "value": "({ state }) => state.steps[\"get-instance-coa-config\"].output.instanceName", + "key": "instanceName" + }, + { + "type": "FUNCTION", + "value": "({ state }) => {\n const queries = [\n {\n query: `SELECT rc.attribute, rc.value FROM radcheck rc WHERE rc.username = ? AND rc.attribute LIKE ? AND rc.value IS NOT NULL;`,\n values: [state.input.oldUsername, \"%-Password\"]\n },\n {\n query: `SELECT rr.attribute, rr.value FROM radreply rr WHERE rr.username = ? AND rr.value IS NOT NULL;`,\n values: [state.input.oldUsername]\n }\n ];\n if (state.input.oldUsername !== state.input.username) {\n queries.push({\n query: \"SELECT COUNT(*) as count FROM radcheck WHERE username = ? LIMIT 1\",\n values: [state.input.username]\n });\n }\n if (state.input.framedIpAddress) {\n queries.push({\n query: 'SELECT COUNT(*) as count FROM radreply WHERE attribute = \"Framed-IP-Address\" AND value = ? AND username <> ?',\n values: [state.input.framedIpAddress, state.input.oldUsername]\n });\n }\n if (state.input.framedRoute) {\n queries.push({\n query: 'SELECT COUNT(*) as count FROM radreply WHERE attribute = \"Framed-Route\" AND value = ? AND username <> ?',\n values: [`${state.input.framedRoute} 0.0.0.0 1`, state.input.oldUsername]\n });\n }\n return queries;\n }", + "key": "queries" + } + ], + "nextSteps": [ + { + "slug": "update-account-details" + }, + { + "slug": "handle-error", + "errorCodes": [ + "*" + ] + } + ], + "outputMapper": "({ state, output, utils }) => {\n const { oldUsername, username, framedIpAddress, framedRoute } = state.input;\n const [oldPasswordResults, oldUsernameRadreplyResults, ...restResults] = output.results;\n const oldPasswordRow = oldPasswordResults?.find((r) => r.attribute && r.attribute.toLowerCase() === \"cleartext-password\");\n const oldPassword = oldPasswordRow?.value || null;\n const oldFramedIpAddressRow = oldUsernameRadreplyResults?.find((r) => r.attribute === \"Framed-IP-Address\");\n const oldFramedRouteRow = oldUsernameRadreplyResults?.find((r) => r.attribute === \"Framed-Route\");\n const oldFramedIpAddress = oldFramedIpAddressRow?.value || null;\n const oldFramedRoute = oldFramedRouteRow?.value || null;\n if (oldUsername !== username) {\n const [newUsernameExists] = restResults.shift();\n if (newUsernameExists.count > 0) {\n return utils.fail(\"Username already exists\", \"USERNAME_ALREADY_EXISTS\");\n }\n }\n if (framedIpAddress) {\n const [framedIpAddressExists] = restResults.shift();\n if (framedIpAddressExists.count > 0) {\n return utils.fail(\"Framed-IP-Address is not available\", \"FRAMED_IP_ADDRESS_NOT_AVAILABLE\");\n }\n }\n if (framedRoute) {\n const [framedRouteExists] = restResults.shift();\n if (framedRouteExists.count > 0) {\n return utils.fail(\"Framed-Route is not available\", \"FRAMED_ROUTE_NOT_AVAILABLE\");\n }\n }\n return {\n password: oldPassword,\n framedIpAddress: oldFramedIpAddress,\n framedRoute: oldFramedRoute\n };\n }" + }, + { + "name": "update-account-details", + "slug": "update-account-details", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "free-radius-mysql-transaction", + "inputs": [ + { + "type": "FUNCTION", + "value": "({ state }) => state.steps[\"get-instance-coa-config\"].output.instanceName", + "key": "instanceName" + }, + { + "type": "FUNCTION", + "value": "({ state }) => {\n const { oldUsername, username, password, framedIpAddress, framedRoute } = state.input;\n const currentPassword = state.steps[\"fetch-account-state-and-validate-transaction\"].output.password;\n const currentFramedIpAddress = state.steps[\"fetch-account-state-and-validate-transaction\"].output.framedIpAddress;\n const currentFramedRoute = state.steps[\"fetch-account-state-and-validate-transaction\"].output.framedRoute;\n const currentFramedRouteWithGateway = currentFramedRoute ? `${currentFramedRoute} 0.0.0.0 1` : null;\n const queries = [];\n if (oldUsername !== username) {\n queries.push({\n query: `UPDATE radcheck rc\n LEFT JOIN radreply rr ON rc.username = rr.username\n LEFT JOIN radusergroup rug ON rc.username = rug.username\n SET\n rc.username = ?,\n rr.username = ?,\n rug.username = ?\n WHERE\n rc.username = ?\n AND rc.attribute LIKE ?`,\n values: [username, username, username, oldUsername, \"%-Password\"]\n });\n }\n if (password && password !== currentPassword) {\n queries.push({\n query: `UPDATE radcheck rc\n SET\n rc.value = ?,\n rc.attribute = ?\n WHERE\n rc.username = ?\n AND rc.attribute LIKE ?`,\n values: [password, \"Cleartext-Password\", username, \"%-Password\"]\n });\n }\n if (framedIpAddress !== void 0 && framedIpAddress !== currentFramedIpAddress) {\n if (currentFramedIpAddress && !framedIpAddress) {\n queries.push({\n query: `DELETE FROM radreply\n WHERE\n username = ?\n AND attribute = ?\n LIMIT 1`,\n values: [username, \"Framed-IP-Address\"]\n });\n } else if (framedIpAddress) {\n const hasPreviousValue = currentFramedIpAddress !== null;\n if (hasPreviousValue) {\n queries.push({\n query: `UPDATE radreply\n SET value = ?\n WHERE\n username = ?\n AND attribute = ?\n LIMIT 1`,\n values: [framedIpAddress, username, \"Framed-IP-Address\"]\n });\n } else {\n queries.push({\n query: `INSERT INTO radreply(username, attribute, op, value)\n VALUES (?, ?, ?, ?)`,\n values: [username, \"Framed-IP-Address\", \"=\", framedIpAddress]\n });\n }\n }\n }\n if (framedRoute !== void 0 && framedRoute !== currentFramedRoute) {\n const framedRouteWithGateway = framedRoute ? `${framedRoute} 0.0.0.0 1` : null;\n if (currentFramedRouteWithGateway && !framedRouteWithGateway) {\n queries.push({\n query: `DELETE FROM radreply\n WHERE\n username = ?\n AND attribute = ?\n LIMIT 1`,\n values: [username, \"Framed-Route\"]\n });\n } else if (framedRouteWithGateway) {\n const hasPreviousValue = currentFramedRouteWithGateway !== null;\n if (hasPreviousValue) {\n queries.push({\n query: `UPDATE radreply\n SET value = ?\n WHERE\n username = ?\n AND attribute = ?\n LIMIT 1`,\n values: [framedRouteWithGateway, username, \"Framed-Route\"]\n });\n } else {\n queries.push({\n query: `INSERT INTO radreply(username, attribute, op, value)\n VALUES (?, ?, ?, ?)`,\n values: [username, \"Framed-Route\", \"+=\", framedRouteWithGateway]\n });\n }\n }\n }\n queries.push({\n query: `\n SELECT\n acctsessionid as accountSessionId,\n nasipaddress as nasIpAddress,\n callingstationid as callingStationId\n FROM\n radacct\n WHERE\n username = ? AND acctstoptime IS NULL\n LIMIT 1;\n `,\n values: [oldUsername, oldUsername, oldUsername, oldUsername]\n });\n return queries;\n }", + "key": "queries" + } + ], + "nextSteps": [ + { + "slug": "check-active-session" + } + ], + "outputMapper": "({ output }) => {\n const sessionResults = output.results[output.results.length - 1];\n const [session] = sessionResults || [];\n const oldUsername = session?.oldUsername || null;\n const attributes = {\n \"User-Name\": oldUsername || \"\",\n ...session?.nasIpAddress ? { \"NAS-IP-Address\": session.nasIpAddress } : {},\n ...session?.accountSessionId ? { \"Acct-Session-Id\": session.accountSessionId } : {},\n ...session?.callingStationId ? { \"Calling-Station-Id\": session.callingStationId } : {}\n };\n return {\n hasActiveSession: !!session,\n attributes,\n oldUsername\n };\n }" + }, + { + "name": "check-active-session", + "slug": "check-active-session", + "nodeSlug": "condition", + "type": "CONDITION", + "condition": "async ({ state }) => state.steps[\"update-account-details\"].output.hasActiveSession", + "thenSteps": [ + { + "slug": "send-coa-packet" + } + ], + "elseSteps": [ + { + "slug": "update-datastore" + } + ] + }, + { + "name": "send-coa-packet", + "slug": "send-coa-packet", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "free-radius-send-coa-packet", + "inputs": [ + { + "type": "FUNCTION", + "value": "({ state }) => state.steps[\"update-account-details\"].output.attributes", + "key": "attributes" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.steps[\"get-instance-coa-config\"].output.instanceName", + "key": "instanceName" + } + ], + "nextSteps": [ + { + "slug": "update-datastore" + } + ], + "outputMapper": "({ output }) => ({ success: output.success })" + }, + { + "name": "update-datastore", + "slug": "update-datastore", + "type": "GAIIA_WRITE_TO_INTEGRATION_DATASTORE", + "nodeSlug": "write-to-integration-datastore", + "inputs": [ + { + "type": "FUNCTION", + "value": "async ({ state, utils }) => {\n const { accountId, oldUsername, username } = state.input;\n const { instanceId } = state.steps[\"get-instance-coa-config\"].output;\n const existingData = await utils.integration.datastore.getItem(`radiusAccount.${accountId}`);\n if (!existingData) {\n return utils.fail(\"Account not found in datastore\", \"ACCOUNT_NOT_FOUND\");\n }\n const accounts = JSON.parse(existingData);\n const account = accounts.find((account2) => account2.instanceId === instanceId && account2.username === oldUsername);\n if (!account) {\n return utils.fail(\"Account not found in datastore\", \"ACCOUNT_NOT_FOUND\");\n }\n const updatedAccount = {\n ...account,\n username\n };\n const updatedAccounts = accounts.map((a) => a.instanceId === instanceId && a.username === oldUsername ? updatedAccount : a);\n const updates = [\n {\n key: `radiusAccount.${accountId}`,\n value: JSON.stringify(updatedAccounts)\n }\n ];\n if (oldUsername !== username) {\n updates.push(\n {\n key: `radiusUsername.${instanceId}.${oldUsername}`,\n value: \"\"\n // Empty string to delete the reverse mapping\n },\n {\n key: `radiusUsername.${instanceId}.${username}`,\n value: accountId\n }\n );\n }\n return updates;\n }", + "key": "values" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ], + "outputMapper": "() => ({\n success: true\n })" + }, + { + "name": "handle-error", + "slug": "handle-error", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:({ state }) => {\n const steps = state.steps;\n return steps[\"fetch-account-state-and-validate-transaction\"]?.error || steps[\"get-instance-coa-config\"]?.error;\n }", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ], + "outputMapper": "({ output }) => output" + } + ] + } + }, + "Verona Networks activation": { + "id": "workflow_dK9EZgPKnUMQa6RmnqtFny", + "description": null, + "definition": { + "steps": [ + { + "name": "Find gaiia account", + "nextSteps": [ + { + "slug": "find-account-cpes" + } + ], + "nodeSlug": "gaiia-internal-api-gateway-graphql-request", + "slug": "find-gaiia-account", + "type": "GAIIA_API_GATEWAY_V0_GRAPHQL_REQUEST", + "inputs": [ + { + "key": "query", + "type": "LITERAL", + "value": "string:query VeronaActivationWorkflowAccountQuery($id: ID!) {\n account(id: $id) {\n gaiiaId\n primaryContact {\n firstName\n lastName\n }\n activeIpAddresses {\n ipAddress\n macAddress\n }\n }\n}" + }, + { + "key": "variables", + "type": "FUNCTION", + "value": "({ state }) => ({ id: state.accountId })" + } + ], + "outputMapper": "({ output }) => {\n const { account } = output.response;\n if (account.activeIpAddresses.length === 0) {\n workflow.fail(`No active IP address associated to this account.`, 'NO_ACTIVE_IP_ADDRESS');\n }\n return account;\n}" + }, + { + "name": "Find account CPEs", + "nextSteps": [ + { + "slug": "find-account-internet-plans" + } + ], + "nodeSlug": "find-account-cpes", + "slug": "find-account-cpes", + "type": "GAIIA_FIND_ACCOUNT_CPES", + "inputs": [ + { + "key": "accountId", + "type": "STATE", + "value": "accountId" + } + ] + }, + { + "name": "Find account Internet plans", + "nextSteps": [ + { + "slug": "preseem-create-account" + } + ], + "nodeSlug": "find-account-internet-plans", + "slug": "find-account-internet-plans", + "type": "GAIIA_FIND_ACCOUNT_INTERNET_PLANS", + "inputs": [ + { + "key": "accountId", + "type": "STATE", + "value": "accountId" + } + ] + }, + { + "name": "Preseem create account", + "nextSteps": [ + { + "slug": "preseem-create-service" + } + ], + "nodeSlug": "preseem-rest-request", + "slug": "preseem-create-account", + "type": "PRESEEM_REST_REQUEST", + "inputs": [ + { + "key": "method", + "type": "LITERAL", + "value": "string:PUT" + }, + { + "key": "path", + "type": "FUNCTION", + "value": "({ state }) => `/accounts/${state.steps['find-gaiia-account'].output.gaiiaId}`" + }, + { + "key": "body", + "type": "FUNCTION", + "value": "({ state }) => {\n const { gaiiaId, primaryContact: { firstName, lastName } } = state.steps['find-gaiia-account'].output;\n return {\n id: String(gaiiaId),\n name: `${firstName} ${lastName}`\n };\n }" + } + ] + }, + { + "name": "Preseem create service", + "nextSteps": [ + { + "slug": "end" + } + ], + "nodeSlug": "preseem-rest-request", + "slug": "preseem-create-service", + "type": "PRESEEM_REST_REQUEST", + "inputs": [ + { + "key": "method", + "type": "LITERAL", + "value": "string:PUT" + }, + { + "key": "path", + "type": "FUNCTION", + "value": "({ state }) => `/services/${state.steps['find-gaiia-account'].output.gaiiaId}`" + }, + { + "key": "body", + "type": "FUNCTION", + "value": "({ state }) => {\n const { gaiiaId, activeIpAddresses } = state.steps['find-gaiia-account'].output;\n const [{ slug }] = state.steps['find-account-internet-plans'].output.internetPlans;\n const cpes = state.steps['find-account-cpes'].output.accountCpe;\n const macAddresses = [\n ...new Set([...activeIpAddresses.map(({ macAddress }) => macAddress), ...cpes.map(({ macAddress }) => macAddress)])\n ];\n return {\n id: String(gaiiaId),\n attachments: macAddresses.map((macAddress) => ({ cpe_mac: macAddress.toLowerCase() })),\n account: String(gaiiaId),\n package: slug\n };\n }" + } + ] + } + ], + "trigger": { + "nextSteps": [ + { + "slug": "find-gaiia-account" + } + ], + "type": "MANUAL", + "objectType": "ACCOUNT" + } + } + }, + "Delete RADIUS Account": { + "id": "workflow_3JJjHTGECV3bpfXwMEMkqc", + "description": null, + "definition": { + "trigger": { + "inputJsonSchema": { + "type": "object", + "properties": { + "username": { + "type": "string", + "description": "The RADIUS username" + }, + "instanceName": { + "type": "string", + "description": "The FreeRADIUS instance name" + }, + "accountId": { + "type": "string", + "description": "The account ID in gaiia" + } + }, + "required": [ + "username", + "instanceName", + "accountId" + ], + "additionalProperties": false + }, + "nextSteps": [ + { + "slug": "get-instance-coa-config" + } + ], + "type": "MANUAL" + }, + "outputMapper": { + "function": "({ state, utils }) => {\n if (\"handle-error\" in state.steps) {\n const error = state.steps[\"handle-error\"];\n utils.fail(error.output.message, error.output.code, error.output.payload);\n }\n return {\n success: true\n };\n }", + "type": "JSON" + }, + "steps": [ + { + "name": "get-instance-coa-config", + "slug": "get-instance-coa-config", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:async ({ state, utils }) => {\n const { instanceName } = state.input;\n const instanceId = await utils.integration.datastore.getItem(`instances.${instanceName}.id`);\n if (!instanceId) {\n return utils.fail(\"Instance not found\", \"INSTANCE_NOT_FOUND\");\n }\n const [radiusServerHost, coaPort, coaSecret] = await Promise.all([\n utils.integration.datastore.getItem(`instances.${instanceId}.radiusServerHost`),\n utils.integration.datastore.getItem(`instances.${instanceId}.coaPort`),\n utils.integration.datastore.getItem(`instances.${instanceId}.coaSecret`)\n ]);\n return {\n instanceId,\n instanceName,\n radiusServerHost: radiusServerHost || \"\",\n coaPort: coaPort ? Number(coaPort) : 0,\n coaSecret: coaSecret || \"\"\n };\n }", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "get-account-details" + }, + { + "slug": "handle-error", + "errorCodes": [ + "*" + ] + } + ], + "outputMapper": "({ output }) => output" + }, + { + "name": "get-account-details", + "slug": "get-account-details", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "free-radius-mysql-transaction", + "inputs": [ + { + "type": "FUNCTION", + "value": "({ state }) => state.steps[\"get-instance-coa-config\"].output.instanceName", + "key": "instanceName" + }, + { + "type": "FUNCTION", + "value": "({ state }) => {\n const { username } = state.input;\n return [\n {\n query: `\n SELECT\n rc.username,\n rc.attribute,\n rc.value as password\n FROM\n radcheck rc\n WHERE\n rc.username = ?\n AND rc.attribute LIKE '%-Password'\n AND rc.value IS NOT NULL\n LIMIT 1;\n `,\n values: [username]\n },\n {\n query: `\n SELECT\n rug.groupname as groupName,\n rug.priority\n FROM\n radusergroup rug\n WHERE\n rug.username = ?;\n `,\n values: [username]\n },\n {\n query: `\n SELECT\n rr.attribute,\n rr.value\n FROM\n radreply rr\n WHERE\n rr.username = ?\n AND rr.attribute IN ('Framed-IP-Address', 'Framed-Route')\n AND rr.value IS NOT NULL;\n `,\n values: [username]\n }\n ];\n }", + "key": "queries" + } + ], + "nextSteps": [ + { + "slug": "delete-radius-account" + }, + { + "slug": "handle-error", + "errorCodes": [ + "*" + ] + } + ], + "outputMapper": "({ output, utils }) => {\n const accountDetailsResults = output.results[0];\n const passwordRow = accountDetailsResults.find((row) => row.attribute?.toLowerCase().includes(\"password\"));\n if (!passwordRow) {\n utils.fail(\"User not found in RADIUS database\", \"USERNAME_NOT_FOUND\");\n }\n const userGroupsResults = output.results[1];\n const groups = userGroupsResults.map((row) => ({\n groupName: row.groupName,\n priority: row.priority\n }));\n const radreplyResults = output.results[2]?.results || [];\n const framedIpAddress = radreplyResults.find((row) => row.attribute === \"Framed-IP-Address\")?.value;\n const framedRoute = radreplyResults.find((row) => row.attribute === \"Framed-Route\")?.value;\n return {\n groups,\n framedIpAddress: framedIpAddress || \"\",\n framedRoute: framedRoute || \"\",\n hasFramedIpAddress: !!framedIpAddress,\n hasFramedRoute: !!framedRoute\n };\n }" + }, + { + "name": "delete-radius-account", + "slug": "delete-radius-account", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "free-radius-mysql-transaction", + "inputs": [ + { + "type": "FUNCTION", + "value": "({ state }) => state.steps[\"get-instance-coa-config\"].output.instanceName", + "key": "instanceName" + }, + { + "type": "FUNCTION", + "value": "({ state }) => {\n const { username } = state.input;\n const { groups } = state.steps[\"get-account-details\"].output;\n const queries = [];\n queries.push({\n query: `\n SELECT\n acctsessionid as accountSessionId,\n nasipaddress as nasIpAddress,\n callingstationid as callingStationId\n FROM\n radacct\n WHERE\n username = ? AND acctstoptime IS NULL;\n `,\n values: [username]\n });\n if (groups.length > 0) {\n const groupPlaceholders = groups.map(() => \"?\").join(\", \");\n queries.push({\n query: `DELETE FROM radusergroup WHERE username = ? AND groupname IN (${groupPlaceholders})`,\n values: [username, ...groups.map((g) => g.groupName)]\n });\n }\n queries.push(\n // Delete only Framed-IP-Address and Framed-Route from radreply for this username\n {\n query: `DELETE FROM radreply WHERE username = ? AND attribute IN ('Framed-IP-Address', 'Framed-Route')`,\n values: [username]\n },\n // Delete from radcheck (LIMIT 1, matches radiusDeleteRadcheckCredentials)\n {\n query: \"DELETE FROM radcheck WHERE username = ? LIMIT 1\",\n values: [username]\n }\n );\n return queries;\n }", + "key": "queries" + } + ], + "nextSteps": [ + { + "slug": "check-active-session" + } + ], + "outputMapper": "({ output, state }) => {\n const [sessions] = output.results;\n const hasActiveSession2 = sessions && sessions.length > 0;\n const sessionsAttributes = (sessions || []).map((session) => ({\n \"User-Name\": state.input.username,\n \"NAS-IP-Address\": session.nasIpAddress,\n \"Acct-Session-Id\": session.accountSessionId,\n \"Calling-Station-Id\": session.callingStationId\n }));\n return {\n hasActiveSession: hasActiveSession2,\n sessions: sessions || [],\n sessionsAttributes\n };\n }" + }, + { + "name": "check-active-session", + "slug": "check-active-session", + "nodeSlug": "condition", + "type": "CONDITION", + "condition": "async ({ state }) => state.steps[\"delete-radius-account\"].output.hasActiveSession", + "thenSteps": [ + { + "slug": "send-coa-packets" + } + ], + "elseSteps": [ + { + "slug": "delete-from-datastore" + } + ] + }, + { + "name": "send-coa-packets", + "slug": "send-coa-packets", + "nodeSlug": "parallel-loop", + "type": "PARALLEL_LOOP", + "inputs": [ + { + "key": "iterable", + "type": "FUNCTION", + "value": "({ state }) => {\n const { sessionsAttributes } = state.steps[\"delete-radius-account\"].output;\n return sessionsAttributes;\n }" + } + ], + "initialSteps": [ + { + "slug": "send-coa-disconnect" + } + ], + "nextSteps": [ + { + "slug": "delete-from-datastore" + } + ], + "outputMapper": "() => ({ success: true })" + }, + { + "name": "send-coa-disconnect", + "slug": "send-coa-disconnect", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "free-radius-send-coa-packet", + "inputs": [ + { + "type": "FUNCTION", + "value": "({ state }) => state.currentParallelLoopIterations[\"send-coa-packets\"]", + "key": "attributes" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.steps[\"get-instance-coa-config\"].output.instanceName", + "key": "instanceName" + } + ], + "nextSteps": [ + { + "slug": "send-coa-packets-end" + } + ], + "outputMapper": "({ output }) => ({ success: output.success })", + "parentParallelLoopSlug": "send-coa-packets" + }, + { + "name": "delete-from-datastore", + "slug": "delete-from-datastore", + "type": "GAIIA_WRITE_TO_INTEGRATION_DATASTORE", + "nodeSlug": "write-to-integration-datastore", + "inputs": [ + { + "type": "FUNCTION", + "value": "async ({ state, utils }) => {\n const { accountId, username } = state.input;\n const { instanceId } = state.steps[\"get-instance-coa-config\"].output;\n const existingData = await utils.integration.datastore.getItem(`radiusAccount.${accountId}`);\n if (!existingData) {\n return utils.fail(\"Account not found in datastore\", \"ACCOUNT_NOT_FOUND\");\n }\n const accounts = JSON.parse(existingData);\n const updatedAccounts = accounts.filter((account) => !(account.instanceId === instanceId && account.username === username));\n return [\n {\n key: `radiusAccount.${accountId}`,\n value: JSON.stringify(updatedAccounts)\n },\n {\n key: `radiusUsername.${instanceId}.${username}`,\n value: \"\"\n // Empty string to delete the reverse mapping\n }\n ];\n }", + "key": "values" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ], + "outputMapper": "() => ({\n success: true\n })" + }, + { + "name": "handle-error", + "slug": "handle-error", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:({ state }) => {\n const steps = state.steps;\n return steps[\"delete-from-datastore\"]?.error || steps[\"check-active-session\"]?.error || steps[\"send-coa-disconnect\"]?.error;\n }", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ], + "outputMapper": "({ output }) => output" + } + ] + } + }, + "Verona Networks cancellation": { + "id": "workflow_t9iyLCe6xTJUrJALiBjpsL", + "description": null, + "definition": { + "steps": [ + { + "name": "Preseem delete account", + "nextSteps": [ + { + "slug": "create-cancellation-ticket" + } + ], + "nodeSlug": "preseem-rest-request", + "slug": "preseem-delete-account", + "type": "PRESEEM_REST_REQUEST", + "inputs": [ + { + "key": "method", + "type": "LITERAL", + "value": "string:DELETE" + }, + { + "key": "path", + "type": "FUNCTION", + "value": "({ state }) => `/accounts/${state.steps['find-gaiia-account'].output.response.account.gaiiaId}`" + } + ] + }, + { + "name": "Create cancellation ticket", + "nextSteps": [ + { + "slug": "end" + } + ], + "nodeSlug": "gaiia-internal-api-gateway-graphql-request", + "slug": "create-cancellation-ticket", + "type": "GAIIA_API_GATEWAY_V0_GRAPHQL_REQUEST", + "inputs": [ + { + "key": "query", + "type": "LITERAL", + "value": "string:mutation WorkflowCreateTicketMutation($input: CreateTicketInput!) {\n createTicket(input: $input) {\n id\n }\n}" + }, + { + "key": "variables", + "type": "FUNCTION", + "value": "({ state }) => ({\n input: {\n title: `Account ${state.steps['find-gaiia-account'].output.response.account.gaiiaId} | Service Cancellation - Equipment Recovery`,\n typeId: 'e6d7b178-11f1-4ea6-bb20-a5bab438b64f', // Update for e6d7b178-11f1-4ea6-bb20-a5bab438b64f in prod\n priority: 'LOW',\n description: `
Account ${state.steps['find-gaiia-account'].output.response.account.gaiiaId} has cancelled service, please schedule time to go onsite to recover the equipments assigned to the linked account.

Once the equipment has been recovered, please navigate to the account page and unassign the equipment from the account.

-Field Service Team
`,\n entities: [\n {\n entityId: state.accountId,\n type: 'ACCOUNT'\n }\n ]\n }\n })" + } + ] + }, + { + "name": "Find gaiia account", + "nextSteps": [ + { + "slug": "preseem-delete-service" + } + ], + "nodeSlug": "gaiia-internal-api-gateway-graphql-request", + "slug": "find-gaiia-account", + "type": "GAIIA_API_GATEWAY_V0_GRAPHQL_REQUEST", + "inputs": [ + { + "key": "query", + "type": "LITERAL", + "value": "string:query VeronaCancellationWorkflowAccountQuery($id: ID!) {\n account(id: $id) {\n gaiiaId\n }\n}" + }, + { + "key": "variables", + "type": "FUNCTION", + "value": "({ state }) => ({ id: state.accountId })" + } + ] + }, + { + "name": "Preseem delete service", + "nextSteps": [ + { + "slug": "preseem-delete-account" + } + ], + "nodeSlug": "preseem-rest-request", + "slug": "preseem-delete-service", + "type": "PRESEEM_REST_REQUEST", + "inputs": [ + { + "key": "method", + "type": "LITERAL", + "value": "string:DELETE" + }, + { + "key": "path", + "type": "FUNCTION", + "value": "({ state }) => `/services/${state.steps['find-gaiia-account'].output.response.account.gaiiaId}`" + } + ] + } + ], + "trigger": { + "nextSteps": [ + { + "slug": "find-gaiia-account" + } + ], + "type": "MANUAL", + "objectType": "ACCOUNT" + } + } + }, + "Get RADIUS Accounts": { + "id": "workflow_qFHYnp5iEN4XNT9NYs7CSa", + "description": null, + "definition": { + "trigger": { + "inputJsonSchema": { + "type": "object", + "properties": { + "accountId": { + "type": "string", + "description": "The account ID in gaiia" + } + }, + "required": [ + "accountId" + ], + "additionalProperties": false + }, + "nextSteps": [ + { + "slug": "get-account-references" + } + ], + "type": "MANUAL" + }, + "outputMapper": { + "function": "({ state }) => {\n const accounts = state.steps[\"merge-results\"].output;\n return {\n accounts\n };\n }", + "type": "JSON" + }, + "steps": [ + { + "name": "get-account-references", + "slug": "get-account-references", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:async ({ state, utils }) => {\n const { accountId } = state.input;\n const existingData = await utils.integration.datastore.getItem(`radiusAccount.${accountId}`);\n const accounts = existingData ? JSON.parse(existingData) : [];\n const accountsByInstance = new Map();\n for (const account of accounts) {\n const instanceAccounts = accountsByInstance.get(account.instanceId) || [];\n instanceAccounts.push(account);\n accountsByInstance.set(account.instanceId, instanceAccounts);\n }\n const instancesWithAccounts = [];\n for (const [instanceId, instanceAccounts] of accountsByInstance.entries()) {\n const instanceName = await utils.integration.datastore.getItem(`instances.${instanceId}.name`);\n if (!instanceName) {\n continue;\n }\n instancesWithAccounts.push({\n accounts: instanceAccounts,\n instanceId,\n instanceName\n });\n }\n return instancesWithAccounts;\n }", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "fetch-account-details" + } + ], + "outputMapper": "({ output }) => output" + }, + { + "name": "fetch-account-details", + "slug": "fetch-account-details", + "nodeSlug": "parallel-loop", + "type": "PARALLEL_LOOP", + "inputs": [ + { + "key": "iterable", + "type": "FUNCTION", + "value": "({ state }) => state.steps[\"get-account-references\"].output" + } + ], + "initialSteps": [ + { + "slug": "query-account-details" + } + ], + "nextSteps": [ + { + "slug": "merge-results" + } + ], + "outputMapper": "({ state }) => state.steps[\"process-account-details\"].output" + }, + { + "name": "query-account-details", + "slug": "query-account-details", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "free-radius-mysql-transaction", + "inputs": [ + { + "type": "FUNCTION", + "value": "({ state }) => state.currentParallelLoopIterations[\"fetch-account-details\"].instanceName", + "key": "instanceName" + }, + { + "type": "FUNCTION", + "value": "({ state }) => {\n const usernames = state.currentParallelLoopIterations[\"fetch-account-details\"].accounts.map((a) => a.username);\n const placeholders = usernames.map(() => \"?\").join(\",\");\n return [\n {\n query: `SELECT username, attribute, value FROM radcheck WHERE username IN (${placeholders})`,\n values: usernames\n },\n {\n query: `SELECT username, groupname FROM radusergroup WHERE username IN (${placeholders}) ORDER BY username, priority`,\n values: usernames\n },\n {\n query: `SELECT username, attribute, value FROM radreply WHERE username IN (${placeholders}) AND attribute IN ('Framed-IP-Address', 'Framed-Route') AND value IS NOT NULL`,\n values: usernames\n }\n ];\n }", + "key": "queries" + } + ], + "nextSteps": [ + { + "slug": "process-account-details" + } + ], + "outputMapper": "({ output }) => output", + "parentParallelLoopSlug": "fetch-account-details" + }, + { + "name": "process-account-details", + "slug": "process-account-details", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:({ state }) => {\n const { instanceName, accounts } = state.currentParallelLoopIterations[\"fetch-account-details\"];\n const [radcheckResults, radusergroupResults, radreplyResults] = state.steps[\"query-account-details\"].output.results;\n const radcheckByUsername = new Map();\n for (const row of radcheckResults || []) {\n const existing = radcheckByUsername.get(row.username) || [];\n existing.push({ attribute: row.attribute, value: row.value });\n radcheckByUsername.set(row.username, existing);\n }\n const groupsByUsername = new Map();\n for (const row of radusergroupResults || []) {\n const existing = groupsByUsername.get(row.username) || [];\n existing.push(row.groupname);\n groupsByUsername.set(row.username, existing);\n }\n const radreplyByUsername = new Map();\n for (const row of radreplyResults || []) {\n const existing = radreplyByUsername.get(row.username) || [];\n existing.push({ attribute: row.attribute, value: row.value });\n radreplyByUsername.set(row.username, existing);\n }\n const accountDetails = [];\n for (const account of accounts) {\n const radcheckRows = radcheckByUsername.get(account.username) || [];\n const radreplyRows = radreplyByUsername.get(account.username) || [];\n const passwordRow = radcheckRows.find((row) => row.attribute.toLowerCase().includes(\"password\"));\n if (!passwordRow) {\n continue;\n }\n const framedIpAddressRow = radreplyRows.find((row) => row.attribute === \"Framed-IP-Address\");\n const framedRouteRow = radreplyRows.find((row) => row.attribute === \"Framed-Route\");\n const groups = groupsByUsername.get(account.username) || [];\n accountDetails.push({\n ...account,\n instanceName,\n framedIpAddress: framedIpAddressRow?.value || \"\",\n framedRoute: framedRouteRow?.value?.split(\" \")[0] || \"\",\n groups\n });\n }\n return accountDetails;\n }", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "fetch-account-details-end" + } + ], + "outputMapper": "({ output }) => output", + "parentParallelLoopSlug": "fetch-account-details" + }, + { + "name": "merge-results", + "slug": "merge-results", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:({ state }) => {\n const accountDetails = state.steps[\"fetch-account-details\"].output.flat();\n return accountDetails;\n }", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "cleanup-orphaned-records" + } + ], + "outputMapper": "({ output }) => output" + }, + { + "name": "cleanup-orphaned-records", + "slug": "cleanup-orphaned-records", + "type": "GAIIA_WRITE_TO_INTEGRATION_DATASTORE", + "nodeSlug": "write-to-integration-datastore", + "inputs": [ + { + "type": "FUNCTION", + "value": "async ({ state }) => {\n const { accountId } = state.input;\n const instancesWithAccounts = state.steps[\"get-account-references\"].output;\n const accountDetails = state.steps[\"merge-results\"].output;\n const accounts = instancesWithAccounts.flatMap((instance) => instance.accounts.map((account) => ({ ...account, instanceName: instance.instanceName })));\n const orphanedRecords = accounts.filter(\n (account) => !accountDetails.some((detail) => detail.instanceName === account.instanceName && detail.username === account.username)\n );\n if (orphanedRecords.length === 0) {\n return [];\n }\n const updatedAccounts = accounts.filter(\n (account) => !orphanedRecords.some((orphaned) => orphaned.instanceId === account.instanceId && orphaned.username === account.username)\n );\n return [\n {\n key: `radiusAccount.${accountId}`,\n value: JSON.stringify(updatedAccounts)\n }\n ];\n }", + "key": "values" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ], + "outputMapper": "() => ({\n cleaned: true\n })" + } + ] + } + }, + "Verona Networks reactivation": { + "id": "workflow_2XT3DrAtHusRgzfTAky51c", + "description": null, + "definition": { + "steps": [ + { + "name": "Find gaiia account", + "nextSteps": [ + { + "slug": "find-account-cpes" + } + ], + "nodeSlug": "gaiia-internal-api-gateway-graphql-request", + "slug": "find-gaiia-account", + "type": "GAIIA_API_GATEWAY_V0_GRAPHQL_REQUEST", + "inputs": [ + { + "key": "query", + "type": "LITERAL", + "value": "string:query VeronaReactivationWorkflowAccountQuery($id: ID!) {\n account(id: $id) {\n gaiiaId\n activeIpAddresses {\n ipAddress\n macAddress\n }\n }\n}" + }, + { + "key": "variables", + "type": "FUNCTION", + "value": "({ state }) => ({ id: state.accountId })" + } + ], + "outputMapper": "({ output }) => {\n const { account } = output.response;\n if (account.activeIpAddresses.length === 0) {\n workflow.fail(`No active IP address associated to this account.`, 'NO_ACTIVE_IP_ADDRESS');\n }\n return account;\n}" + }, + { + "name": "Find account CPEs", + "nextSteps": [ + { + "slug": "find-account-internet-plans" + } + ], + "nodeSlug": "find-account-cpes", + "slug": "find-account-cpes", + "type": "GAIIA_FIND_ACCOUNT_CPES", + "inputs": [ + { + "key": "accountId", + "type": "STATE", + "value": "accountId" + } + ] + }, + { + "name": "Find account Internet plans", + "nextSteps": [ + { + "slug": "preseem-update-service" + } + ], + "nodeSlug": "find-account-internet-plans", + "slug": "find-account-internet-plans", + "type": "GAIIA_FIND_ACCOUNT_INTERNET_PLANS", + "inputs": [ + { + "key": "accountId", + "type": "STATE", + "value": "accountId" + } + ] + }, + { + "name": "Preseem update service", + "nextSteps": [ + { + "slug": "end" + } + ], + "nodeSlug": "preseem-rest-request", + "slug": "preseem-update-service", + "type": "PRESEEM_REST_REQUEST", + "inputs": [ + { + "key": "method", + "type": "LITERAL", + "value": "string:PUT" + }, + { + "key": "path", + "type": "FUNCTION", + "value": "({ state }) => `/services/${state.steps['find-gaiia-account'].output.gaiiaId}`" + }, + { + "key": "body", + "type": "FUNCTION", + "value": "({ state }) => {\n const { gaiiaId, activeIpAddresses } = state.steps['find-gaiia-account'].output;\n const [{ slug }] = state.steps['find-account-internet-plans'].output.internetPlans;\n const cpes = state.steps['find-account-cpes'].output.accountCpe;\n const macAddresses = [\n ...new Set([...activeIpAddresses.map(({ macAddress }) => macAddress), ...cpes.map(({ macAddress }) => macAddress)])\n ];\n return {\n id: String(gaiiaId),\n attachments: macAddresses.map((macAddress) => ({ cpe_mac: macAddress.toLowerCase() })),\n account: String(gaiiaId),\n package: slug\n };\n }" + } + ] + } + ], + "trigger": { + "nextSteps": [ + { + "slug": "find-gaiia-account" + } + ], + "type": "MANUAL", + "objectType": "ACCOUNT" + } + } + }, + "Get IPAM Roles from NetBox": { + "id": "workflow_hteQnmBpUeVK6tuqYuHmnL", + "description": null, + "definition": { + "trigger": { + "inputJsonSchema": { + "type": "object", + "properties": { + "instanceName": { + "type": "string" + } + }, + "required": [ + "instanceName" + ] + }, + "nextSteps": [ + { + "slug": "get-first-page-of-roles" + } + ], + "type": "MANUAL" + }, + "outputMapper": { + "function": "({ state }) => ({\n roles: state.steps[\"merge-paginated-roles\"].output.roles.map((role) => ({ ...role }))\n })", + "type": "JSON" + }, + "steps": [ + { + "name": "get-first-page-of-roles", + "slug": "get-first-page-of-roles", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "netbox-http-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => \"GET\"", + "key": "method" + }, + { + "type": "FUNCTION", + "value": "() => `/ipam/roles?limit=100&offset=0`", + "key": "path" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.input.instanceName", + "key": "instanceName" + } + ], + "nextSteps": [ + { + "slug": "get-paginated-roles" + } + ], + "outputMapper": "({ output }) => {\n const response = output;\n const numberOfRemainingPages = Math.ceil(response.count / 100) - 1;\n return {\n pagesToFetch: Array.from({ length: numberOfRemainingPages }, (_, index) => index + 1),\n results: response.results\n };\n }" + }, + { + "name": "get-paginated-roles", + "slug": "get-paginated-roles", + "nodeSlug": "parallel-loop", + "type": "PARALLEL_LOOP", + "inputs": [ + { + "key": "iterable", + "type": "FUNCTION", + "value": "({ state }) => state.steps[\"get-first-page-of-roles\"].output.pagesToFetch" + } + ], + "initialSteps": [ + { + "slug": "get-page-of-roles" + } + ], + "nextSteps": [ + { + "slug": "merge-paginated-roles" + } + ], + "outputMapper": "({ state }) => state.steps[\"get-page-of-roles\"].output" + }, + { + "name": "get-page-of-roles", + "slug": "get-page-of-roles", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "netbox-http-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => \"GET\"", + "key": "method" + }, + { + "type": "FUNCTION", + "value": "({ state }) => `/ipam/roles?limit=100&offset=${state.currentParallelLoopIterations[\"get-paginated-roles\"] * 100}`", + "key": "path" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.input.instanceName", + "key": "instanceName" + } + ], + "nextSteps": [ + { + "slug": "get-paginated-roles-end" + } + ], + "outputMapper": "({ output }) => {\n const response = output;\n return response.results;\n }", + "parentParallelLoopSlug": "get-paginated-roles" + }, + { + "name": "merge-paginated-roles", + "slug": "merge-paginated-roles", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:({ state }) => state", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ], + "outputMapper": "({ state }) => {\n const { results } = state.steps[\"get-first-page-of-roles\"].output;\n const paginatedResults = state.steps[\"get-paginated-roles\"].output.flat();\n return {\n roles: [...results, ...paginatedResults]\n };\n }" + } + ] + } + }, + "Get RADIUS Account Password": { + "id": "workflow_fBY1cE4WKFJf2ag2LarQQZ", + "description": null, + "definition": { + "trigger": { + "inputJsonSchema": { + "type": "object", + "properties": { + "username": { + "type": "string", + "description": "The username to fetch the password for" + }, + "instanceName": { + "type": "string", + "description": "The name of the FreeRADIUS instance" + } + }, + "required": [ + "username", + "instanceName" + ], + "additionalProperties": false + }, + "nextSteps": [ + { + "slug": "get-password" + } + ], + "type": "MANUAL" + }, + "outputMapper": { + "function": "({ state, utils }) => {\n const [passwordRow] = state.steps[\"get-password\"].output;\n if (!passwordRow || !passwordRow.value) {\n utils.fail(\"Password not found for the specified username\", \"PASSWORD_NOT_FOUND\");\n }\n return {\n value: passwordRow.value\n };\n }", + "type": "JSON" + }, + "steps": [ + { + "name": "get-password", + "slug": "get-password", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "free-radius-mysql-query", + "inputs": [ + { + "type": "FUNCTION", + "value": "({ state }) => state.input.instanceName", + "key": "instanceName" + }, + { + "type": "FUNCTION", + "value": "() => `SELECT rc.username, rc.attribute, rc.value FROM radcheck rc WHERE rc.username = ? AND rc.attribute LIKE '%-Password' AND rc.value IS NOT NULL LIMIT 1`", + "key": "query" + }, + { + "type": "FUNCTION", + "value": "({ state }) => [state.input.username]", + "key": "values" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ], + "outputMapper": "({ output }) => output.results" + } + ] + } + }, + "Import Network Site Prefixes": { + "id": "workflow_wzxmp5TSvk3FgkNqUN73Bh", + "description": null, + "definition": { + "trigger": { + "skipExecutionMapper": "async ({ state, utils }) => {\n const { instanceId } = state.trigger.queryParameters;\n if (!instanceId) {\n return true;\n }\n const prefixSyncString = await utils.integration.datastore.getItem(`instances.${instanceId}.prefixSynchronization`);\n if (!prefixSyncString) {\n return true;\n }\n let prefixSync;\n try {\n prefixSync = JSON.parse(prefixSyncString);\n } catch (error) {\n return utils.fail(\"Failed to parse prefix synchronization settings\", \"INVALID_JSON_FORMAT\", { error });\n }\n if (!prefixSync.isEnabled) {\n return true;\n }\n if (state.input.model === \"site\") {\n return state.input.event !== \"deleted\";\n }\n if (prefixSync.ipamRoles.length === 0) {\n return false;\n }\n const prefixRoleId = state.input.data?.role?.id;\n if (prefixRoleId != null && prefixSync.ipamRoles.includes(String(prefixRoleId))) {\n return false;\n }\n return true;\n }", + "inputJsonSchema": { + "type": "object", + "required": [ + "model", + "event", + "data" + ], + "properties": { + "model": { + "type": "string", + "enum": [ + "site", + "prefix" + ] + }, + "event": { + "type": "string", + "enum": [ + "created", + "updated", + "deleted" + ] + }, + "data": { + "type": "object", + "properties": { + "prefix": { + "type": "string" + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "role": { + "type": [ + "object", + "null" + ], + "properties": { + "id": { + "type": "number" + } + } + }, + "site": { + "type": [ + "object", + "null" + ], + "properties": { + "id": { + "type": "number" + }, + "name": { + "type": "string" + } + } + }, + "scope_type": { + "type": "string" + }, + "scope_id": { + "type": "number" + }, + "scope": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "name": { + "type": "string" + } + } + } + } + }, + "snapshots": { + "type": "object", + "properties": { + "prechange": { + "type": [ + "object", + "null" + ], + "properties": { + "prefix": { + "type": "string" + }, + "site": { + "type": [ + "number", + "null" + ] + }, + "scope_type": { + "type": [ + "number", + "string", + "null" + ] + }, + "scope_id": { + "type": [ + "number", + "null" + ] + }, + "_site": { + "type": [ + "number", + "null" + ] + } + } + }, + "postchange": { + "type": [ + "object", + "null" + ], + "properties": { + "prefix": { + "type": "string" + }, + "site": { + "type": [ + "number", + "null" + ] + }, + "scope_type": { + "type": [ + "number", + "string", + "null" + ] + }, + "scope_id": { + "type": [ + "number", + "null" + ] + }, + "_site": { + "type": [ + "number", + "null" + ] + } + } + } + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "model": { + "const": "prefix" + } + } + }, + "then": { + "properties": { + "data": { + "required": [ + "prefix" + ] + } + } + } + }, + { + "if": { + "properties": { + "model": { + "const": "site" + } + } + }, + "then": { + "properties": { + "data": { + "required": [ + "name" + ] + } + } + } + }, + { + "if": { + "properties": { + "model": { + "const": "prefix" + }, + "event": { + "const": "updated" + } + } + }, + "then": { + "required": [ + "snapshots" + ], + "properties": { + "snapshots": { + "properties": { + "prechange": { + "required": [ + "prefix" + ] + }, + "postchange": { + "required": [ + "prefix" + ] + } + } + } + } + } + } + ] + }, + "nextSteps": [ + { + "slug": "map-input" + } + ], + "type": "WEBHOOK" + }, + "steps": [ + { + "name": "map-input", + "slug": "map-input", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:async ({ state, utils }) => {\n const { instanceId } = state.trigger.queryParameters;\n if (!instanceId) {\n return utils.fail(\"Missing required instanceId query parameter\", \"NO_INSTANCE_ID\");\n }\n const prechange = state.input.snapshots?.prechange;\n const postchange = state.input.snapshots?.postchange;\n return {\n instanceId,\n instanceName: await utils.integration.datastore.getItem(`instances.${instanceId}.name`),\n prechangeSite: prechange?.site ?? prechange?._site ?? null,\n postchangeSite: postchange?.site ?? postchange?._site ?? null\n };\n }", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "get-prefix-synchronization-settings" + } + ], + "outputMapper": "({ output }) => output" + }, + { + "name": "get-prefix-synchronization-settings", + "slug": "get-prefix-synchronization-settings", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "netbox-get-prefix-synchronization-settings", + "inputs": [ + { + "type": "FUNCTION", + "value": "async ({ state }) => state.steps[\"map-input\"].output.instanceName", + "key": "instanceName" + } + ], + "nextSteps": [ + { + "slug": "has-prefix-site-changed" + } + ], + "outputMapper": "({ output, utils }) => {\n if (!output.isEnabled) {\n return utils.fail(\"NetBox prefix synchronization is disabled for this instance.\", \"PREFIX_SYNCHRONIZATION_DISABLED\");\n }\n return output;\n }" + }, + { + "name": "has-prefix-site-changed", + "slug": "has-prefix-site-changed", + "nodeSlug": "condition", + "type": "CONDITION", + "condition": "({ state }) => {\n const { prechangeSite, postchangeSite } = state.steps[\"map-input\"].output;\n return state.input.model === \"prefix\" && state.input.event === \"updated\" && prechangeSite !== null && prechangeSite !== postchangeSite;\n }", + "thenSteps": [ + { + "slug": "fetch-previous-site" + } + ], + "elseSteps": [ + { + "slug": "get-network-sites" + } + ] + }, + { + "name": "fetch-previous-site", + "slug": "fetch-previous-site", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "netbox-http-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => \"GET\"", + "key": "method" + }, + { + "type": "FUNCTION", + "value": "({ state }) => `/dcim/sites/${state.steps[\"map-input\"].output.prechangeSite}`", + "key": "path" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.steps[\"map-input\"].output.instanceName", + "key": "instanceName" + } + ], + "nextSteps": [ + { + "slug": "get-network-sites" + } + ], + "outputMapper": "({ output }) => output" + }, + { + "name": "get-network-sites", + "slug": "get-network-sites", + "type": "GAIIA_API_GATEWAY_V1_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-public-api-gateway-graphql-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => `\n query NetworkSites($filters: NetworkSiteFilters, $firstIpBlocks: Int!) {\n networkSites(filters: $filters) {\n totalCount\n edges {\n node {\n id\n name\n ipBlocks(first: $firstIpBlocks) {\n totalCount\n nodes {\n id\n block\n description\n integrationId\n }\n }\n }\n }\n }\n }`", + "key": "query" + }, + { + "type": "FUNCTION", + "value": "({ state }) => {\n const { site, scope, name } = state.input.data;\n const fetchedPreviousSite = \"fetch-previous-site\" in state.steps ? state.steps[\"fetch-previous-site\"]?.output : null;\n const siteNames = [site?.name, scope?.name, fetchedPreviousSite?.name];\n if (state.input.model === \"site\") {\n siteNames.push(name);\n }\n return {\n filters: {\n name: {\n in: siteNames.filter((name2) => !!name2)\n }\n },\n firstIpBlocks: 250\n };\n }", + "key": "variables" + } + ], + "nextSteps": [ + { + "slug": "prepare-mutation-payloads" + } + ], + "outputMapper": "({ output }) => {\n const response = output.response;\n return {\n networkSites: response.networkSites.edges.map((edge) => edge.node)\n };\n }" + }, + { + "name": "prepare-mutation-payloads", + "slug": "prepare-mutation-payloads", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:({ state }) => state", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "execute-mutations" + } + ], + "outputMapper": "async ({ state, utils }) => {\n const { model, event, data, snapshots } = state.input;\n const { ipamRoles } = state.steps[\"get-prefix-synchronization-settings\"].output;\n const integrationId = await utils.integration.datastore.getItem(\"integrationId\");\n const fetchedPreviousSite = \"fetch-previous-site\" in state.steps ? state.steps[\"fetch-previous-site\"]?.output : null;\n const gaiiaNetworkSites = state.steps[\"get-network-sites\"].output.networkSites;\n const provisioningServiceMutations = [];\n if (model === \"prefix\" && (ipamRoles.length === 0 || data.role?.id != null && ipamRoles.includes(String(data.role.id)))) {\n const { prefix, site, scope, description } = data;\n const networkSite = site ?? scope;\n const newNetworkSite = gaiiaNetworkSites.find((ns) => ns.name === networkSite?.name);\n const findIpBlock = (networkSite2, block) => networkSite2?.ipBlocks.nodes.find((ipBlock) => ipBlock.block === block);\n switch (event) {\n case \"updated\": {\n const prechangeSite = fetchedPreviousSite ?? networkSite;\n const prechangePrefix = snapshots?.prechange?.prefix || prefix;\n const previousNetworkSite = gaiiaNetworkSites.find((ns) => ns.name === prechangeSite?.name);\n const oldGaiiaIpBlock = findIpBlock(previousNetworkSite, prechangePrefix);\n const isDifferentSite = previousNetworkSite?.id !== newNetworkSite?.id;\n const wasCreatedByWorkflow = oldGaiiaIpBlock?.integrationId === integrationId;\n const existsOnNewSite = !!findIpBlock(newNetworkSite, prefix);\n if (isDifferentSite && oldGaiiaIpBlock && wasCreatedByWorkflow) {\n provisioningServiceMutations.push({\n operation: \"remove\",\n variables: {\n input: {\n networkSiteId: previousNetworkSite?.id,\n ipBlockIds: [oldGaiiaIpBlock.id]\n }\n }\n });\n }\n if (newNetworkSite && (!oldGaiiaIpBlock || isDifferentSite || !wasCreatedByWorkflow) && !existsOnNewSite && !!prefix) {\n provisioningServiceMutations.push({\n operation: \"add\",\n variables: {\n input: {\n integrationId,\n networkSiteId: newNetworkSite.id,\n ipBlocks: [{ block: prefix, description }]\n }\n }\n });\n }\n if (oldGaiiaIpBlock && previousNetworkSite?.id === newNetworkSite?.id && wasCreatedByWorkflow && (oldGaiiaIpBlock.description !== description || oldGaiiaIpBlock.block !== prefix)) {\n provisioningServiceMutations.push({\n operation: \"update\",\n variables: {\n input: {\n id: oldGaiiaIpBlock.id,\n block: prefix,\n description\n }\n }\n });\n }\n break;\n }\n case \"created\": {\n if (!newNetworkSite) {\n break;\n }\n if (!!prefix && !findIpBlock(newNetworkSite, prefix)) {\n provisioningServiceMutations.push({\n operation: \"add\",\n variables: {\n input: {\n integrationId,\n networkSiteId: newNetworkSite.id,\n ipBlocks: [{ block: prefix, description }]\n }\n }\n });\n }\n break;\n }\n case \"deleted\": {\n const gaiiaIpBlock = findIpBlock(newNetworkSite, prefix);\n if (gaiiaIpBlock && gaiiaIpBlock.integrationId === integrationId) {\n provisioningServiceMutations.push({\n operation: \"remove\",\n variables: { input: { networkSiteId: newNetworkSite?.id, ipBlockIds: [gaiiaIpBlock.id] } }\n });\n }\n break;\n }\n }\n } else if (model === \"site\" && event === \"deleted\") {\n const networkSite = gaiiaNetworkSites.find((ns) => ns.name === data.name);\n if (networkSite) {\n const ipBlockIds = networkSite.ipBlocks.nodes.filter((ipBlock) => ipBlock.integrationId === integrationId).map((ipBlock) => ipBlock.id);\n provisioningServiceMutations.push({\n operation: \"remove\",\n variables: { input: { networkSiteId: networkSite.id, ipBlockIds } }\n });\n }\n }\n return provisioningServiceMutations;\n }" + }, + { + "name": "execute-mutations", + "slug": "execute-mutations", + "nodeSlug": "parallel-loop", + "type": "PARALLEL_LOOP", + "inputs": [ + { + "key": "iterable", + "type": "FUNCTION", + "value": "({ state }) => state.steps[\"prepare-mutation-payloads\"].output" + } + ], + "initialSteps": [ + { + "slug": "if-is-addition" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ] + }, + { + "name": "if-is-addition", + "slug": "if-is-addition", + "nodeSlug": "condition", + "type": "CONDITION", + "condition": "({ state }) => state.currentParallelLoopIterations[\"execute-mutations\"].operation === \"add\"", + "thenSteps": [ + { + "slug": "add-ip-blocks-to-network-site" + } + ], + "elseSteps": [ + { + "slug": "if-is-update" + } + ], + "parentParallelLoopSlug": "execute-mutations" + }, + { + "name": "add-ip-blocks-to-network-site", + "slug": "add-ip-blocks-to-network-site", + "type": "GAIIA_API_GATEWAY_V1_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-public-api-gateway-graphql-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => `\n mutation AddIpBlocksToNetworkSite($input: AddIpBlocksToNetworkSiteInput!) {\n addIpBlocksToNetworkSite(input: $input) {\n networkSite {\n id\n }\n errors {\n code\n message\n }\n }\n }`", + "key": "query" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.currentParallelLoopIterations[\"execute-mutations\"].variables", + "key": "variables" + } + ], + "nextSteps": [ + { + "slug": "execute-mutations-end" + } + ], + "outputMapper": "({ output, utils }) => {\n const response = output.response;\n if (response.addIpBlocksToNetworkSite.errors?.length) {\n return utils.fail(\"Failed to add IP blocks to Network Site.\", \"ADD_IP_BLOCKS_FAILED\", response.addIpBlocksToNetworkSite.errors);\n }\n return response.addIpBlocksToNetworkSite.networkSite;\n }", + "parentParallelLoopSlug": "execute-mutations" + }, + { + "name": "if-is-update", + "slug": "if-is-update", + "nodeSlug": "condition", + "type": "CONDITION", + "condition": "({ state }) => state.currentParallelLoopIterations[\"execute-mutations\"].operation === \"update\"", + "thenSteps": [ + { + "slug": "update-ip-block-on-network-site" + } + ], + "elseSteps": [ + { + "slug": "remove-ip-blocks-from-network-site" + } + ], + "parentParallelLoopSlug": "execute-mutations" + }, + { + "name": "update-ip-block-on-network-site", + "slug": "update-ip-block-on-network-site", + "type": "GAIIA_API_GATEWAY_V1_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-public-api-gateway-graphql-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => `\n mutation UpdateNetworkSiteIpBlock($input: UpdateNetworkSiteIpBlockInput!) {\n updateNetworkSiteIpBlock(input: $input) {\n networkSiteIpBlock {\n id\n }\n errors {\n code\n message\n }\n }\n }`", + "key": "query" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.currentParallelLoopIterations[\"execute-mutations\"].variables", + "key": "variables" + } + ], + "nextSteps": [ + { + "slug": "execute-mutations-end" + } + ], + "outputMapper": "({ output, utils }) => {\n const response = output.response;\n if (response.updateNetworkSiteIpBlock.errors?.length) {\n return utils.fail(\"Failed to update IP blocks on Network Site.\", \"UPDATE_IP_BLOCKS_FAILED\", response.updateNetworkSiteIpBlock.errors);\n }\n return response.updateNetworkSiteIpBlock.networkSiteIpBlock;\n }", + "parentParallelLoopSlug": "execute-mutations" + }, + { + "name": "remove-ip-blocks-from-network-site", + "slug": "remove-ip-blocks-from-network-site", + "type": "GAIIA_API_GATEWAY_V1_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-public-api-gateway-graphql-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => `\n mutation removeIpBlockFromNetworkSite($input: RemoveIpBlocksFromNetworkSiteInput!) {\n removeIpBlocksFromNetworkSite(input: $input) {\n networkSite {\n id\n }\n errors {\n code\n message\n }\n }\n }`", + "key": "query" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.currentParallelLoopIterations[\"execute-mutations\"].variables", + "key": "variables" + } + ], + "nextSteps": [ + { + "slug": "execute-mutations-end" + } + ], + "outputMapper": "({ output, utils }) => {\n const response = output.response;\n if (response.removeIpBlocksFromNetworkSite.errors?.length) {\n return utils.fail(\"Failed to remove IP blocks from Network Site.\", \"REMOVE_IP_BLOCKS_FAILED\", response.removeIpBlocksFromNetworkSite.errors);\n }\n return response.removeIpBlocksFromNetworkSite.networkSite;\n }", + "parentParallelLoopSlug": "execute-mutations" + } + ] + } + }, + "Verona Networks speed modification": { + "id": "workflow_36ncWKmYofXn6q1bpm1HJp", + "description": null, + "definition": { + "steps": [ + { + "name": "Find gaiia account", + "nextSteps": [ + { + "slug": "find-account-cpes" + } + ], + "nodeSlug": "gaiia-internal-api-gateway-graphql-request", + "slug": "find-gaiia-account", + "type": "GAIIA_API_GATEWAY_V0_GRAPHQL_REQUEST", + "inputs": [ + { + "key": "query", + "type": "LITERAL", + "value": "string:query VeronaSpeedModificationWorkflowAccountQuery($id: ID!) {\n account(id: $id) {\n gaiiaId\n activeIpAddresses {\n ipAddress\n macAddress\n }\n }\n}" + }, + { + "key": "variables", + "type": "FUNCTION", + "value": "({ state }) => ({ id: state.accountId })" + } + ], + "outputMapper": "({ output }) => {\n const { account } = output.response;\n if (account.activeIpAddresses.length === 0) {\n workflow.fail(`No active IP address associated to this account.`, 'NO_ACTIVE_IP_ADDRESS');\n }\n return account;\n}" + }, + { + "name": "Find account CPEs", + "nextSteps": [ + { + "slug": "find-new-gaiia-product" + } + ], + "nodeSlug": "find-account-cpes", + "slug": "find-account-cpes", + "type": "GAIIA_FIND_ACCOUNT_CPES", + "inputs": [ + { + "key": "accountId", + "type": "STATE", + "value": "accountId" + } + ] + }, + { + "name": "Find new gaiia product", + "nextSteps": [ + { + "slug": "preseem-update-service" + } + ], + "nodeSlug": "gaiia-internal-api-gateway-graphql-request", + "slug": "find-new-gaiia-product", + "type": "GAIIA_API_GATEWAY_V0_GRAPHQL_REQUEST", + "inputs": [ + { + "key": "query", + "type": "LITERAL", + "value": "string:query ProductByIdWorkflowQuery($id: String!) {\n productById(id: $id) {\n slug\n }\n}" + }, + { + "key": "variables", + "type": "FUNCTION", + "value": "({ state }) => ({ id: state.input.productId })" + } + ] + }, + { + "name": "Preseem update service", + "nextSteps": [ + { + "slug": "end" + } + ], + "nodeSlug": "preseem-rest-request", + "slug": "preseem-update-service", + "type": "PRESEEM_REST_REQUEST", + "inputs": [ + { + "key": "method", + "type": "LITERAL", + "value": "string:PUT" + }, + { + "key": "path", + "type": "FUNCTION", + "value": "({ state }) => `/services/${state.steps['find-gaiia-account'].output.gaiiaId}`" + }, + { + "key": "body", + "type": "FUNCTION", + "value": "({ state }) => {\n const { gaiiaId, activeIpAddresses } = state.steps['find-gaiia-account'].output;\n const { slug } = state.steps['find-new-gaiia-product'].output.response.productById;\n const cpes = state.steps['find-account-cpes'].output.accountCpe;\n const macAddresses = [\n ...new Set([...activeIpAddresses.map(({ macAddress }) => macAddress), ...cpes.map(({ macAddress }) => macAddress)])\n ];\n return {\n id: String(gaiiaId),\n attachments: macAddresses.map((macAddress) => ({ cpe_mac: macAddress.toLowerCase() })),\n account: String(gaiiaId),\n package: slug\n };\n }" + } + ] + } + ], + "trigger": { + "nextSteps": [ + { + "slug": "find-gaiia-account" + } + ], + "type": "MANUAL", + "objectType": "ACCOUNT", + "inputJsonSchema": { + "type": "object", + "required": [ + "productId" + ], + "properties": { + "productId": { + "type": "string" + } + } + } + } + } + }, + "Add RADIUS Group": { + "id": "workflow_4o9WMZDJjipHE6SpdWiy9L", + "description": null, + "definition": { + "trigger": { + "inputJsonSchema": { + "type": "object", + "properties": { + "instanceName": { + "type": "string", + "description": "The name of the FreeRADIUS instance" + }, + "groupName": { + "type": "string", + "description": "The name of the RADIUS group" + }, + "priority": { + "type": "number", + "description": "The priority of the group" + }, + "mappingRule": { + "type": "string", + "enum": [ + "ALL", + "MATCH_PRODUCT", + "INACTIVE", + "SUSPENDED", + "ACTIVE" + ], + "description": "The mapping rule for automatic group assignment" + }, + "mappedProductIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Product IDs for MATCH_PRODUCT mapping rule" + }, + "attributes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "operator": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "description": "RADIUS attributes for the group" + }, + "fallThrough": { + "type": "boolean", + "description": "Whether to continue evaluating other groups" + } + }, + "required": [ + "instanceName", + "groupName", + "priority", + "mappingRule", + "mappedProductIds", + "attributes", + "fallThrough" + ] + }, + "nextSteps": [ + { + "slug": "validate-group-input" + } + ], + "type": "MANUAL" + }, + "outputMapper": { + "function": "({ state, utils }) => {\n if (\"handle-error\" in state.steps) {\n utils.fail(state.steps[\"handle-error\"].output.message, state.steps[\"handle-error\"].output.code, state.steps[\"handle-error\"].output.payload);\n }\n return {\n success: true\n };\n }", + "type": "JSON" + }, + "steps": [ + { + "name": "validate-group-input", + "slug": "validate-group-input", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:({ state, utils }) => {\n const { groupName, priority, attributes, mappingRule, mappedProductIds } = state.input;\n if (groupName.length > 64) {\n return utils.fail(\"Group name is too long. Max length is 64\", \"GROUP_NAME_TOO_LONG\");\n }\n if (priority < 0) {\n return utils.fail(\"Priority must be greater than or equal to 0\", \"PRIORITY_INVALID\");\n }\n if (mappingRule === \"MATCH_PRODUCT\" && (!mappedProductIds || mappedProductIds.length === 0)) {\n return utils.fail(\"MATCH_PRODUCT mapping rule requires at least one mapped product ID\", \"MAPPING_RULE_INVALID\");\n }\n const RADUSERGROUP_ATTRIBUTE_NAME_MAX_LENGTH = 64;\n const RADUSERGROUP_ATTRIBUTE_OPERATOR_MAX_LENGTH = 2;\n const RADUSERGROUP_ATTRIBUTE_VALUE_MAX_LENGTH = 253;\n for (const attribute of attributes) {\n if (!attribute.name || attribute.name.length > RADUSERGROUP_ATTRIBUTE_NAME_MAX_LENGTH) {\n return utils.fail(`Attribute name is too long. Max length is ${RADUSERGROUP_ATTRIBUTE_NAME_MAX_LENGTH}`, \"ATTRIBUTE_NAME_TOO_LONG\");\n }\n if (!attribute.operator || attribute.operator.length > RADUSERGROUP_ATTRIBUTE_OPERATOR_MAX_LENGTH) {\n return utils.fail(`Attribute operator is too long. Max length is ${RADUSERGROUP_ATTRIBUTE_OPERATOR_MAX_LENGTH}`, \"ATTRIBUTE_OPERATOR_TOO_LONG\");\n }\n if (!attribute.value || attribute.value.length > RADUSERGROUP_ATTRIBUTE_VALUE_MAX_LENGTH) {\n return utils.fail(`Attribute value is too long. Max length is ${RADUSERGROUP_ATTRIBUTE_VALUE_MAX_LENGTH}`, \"ATTRIBUTE_VALUE_TOO_LONG\");\n }\n }\n return {\n validated: true\n };\n }", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "insert-group-with-attributes" + }, + { + "slug": "handle-error", + "errorCodes": [ + "*" + ] + } + ], + "outputMapper": "({ output }) => output" + }, + { + "name": "insert-group-with-attributes", + "slug": "insert-group-with-attributes", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "free-radius-mysql-transaction", + "inputs": [ + { + "type": "FUNCTION", + "value": "({ state }) => state.input.instanceName", + "key": "instanceName" + }, + { + "type": "FUNCTION", + "value": "({ state }) => {\n const { groupName, attributes, fallThrough } = state.input;\n const queries = [];\n for (const attribute of attributes) {\n queries.push({\n query: \"INSERT INTO radgroupreply (groupname, attribute, op, value) VALUES (?, ?, ?, ?)\",\n values: [groupName, attribute.name, attribute.operator, attribute.value]\n });\n }\n queries.push({\n query: \"INSERT INTO radgroupreply (groupname, attribute, op, value) VALUES (?, ?, ?, ?)\",\n values: [groupName, \"Fall-Through\", \"=\", fallThrough ? \"Yes\" : \"No\"]\n });\n return queries;\n }", + "key": "queries" + } + ], + "nextSteps": [ + { + "slug": "store-in-datastore" + }, + { + "slug": "handle-error", + "errorCodes": [ + "*" + ] + } + ], + "outputMapper": "({ output }) => ({\n created: true,\n affectedRows: output.results?.length || 0\n })" + }, + { + "name": "store-in-datastore", + "slug": "store-in-datastore", + "type": "GAIIA_WRITE_TO_INTEGRATION_DATASTORE", + "nodeSlug": "write-to-integration-datastore", + "inputs": [ + { + "type": "FUNCTION", + "value": "async ({ state, utils }) => {\n const { instanceName, groupName, priority, mappingRule, mappedProductIds } = state.input;\n const instanceId = await utils.integration.datastore.getItem(`instances.${instanceName}.id`);\n if (!instanceId) {\n return utils.fail(\"Instance not found\", \"INSTANCE_NOT_FOUND\");\n }\n const existingData = await utils.integration.datastore.getItem(`instances.${instanceId}.groups`);\n const groups = existingData ? JSON.parse(existingData) : [];\n const groupData = {\n groupName,\n mappedProductIds: mappedProductIds || [],\n mappingRule,\n priority\n };\n const updatedGroups = [...groups, groupData];\n return [\n {\n key: `instances.${instanceId}.groups`,\n value: JSON.stringify(updatedGroups)\n }\n ];\n }", + "key": "values" + } + ], + "nextSteps": [ + { + "slug": "handle-error", + "errorCodes": [ + "*" + ] + }, + { + "slug": "end" + } + ], + "outputMapper": "() => ({\n success: true\n })" + }, + { + "name": "handle-error", + "slug": "handle-error", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:({ state }) => {\n const steps = state.steps;\n return steps[\"store-in-datastore\"]?.error || steps[\"insert-group-with-attributes\"]?.error || steps[\"validate-group-input\"]?.error;\n }", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ], + "outputMapper": "({ output }) => output" + } + ] + } + }, + "Import Network Site Prefixes - Full Sync": { + "id": "workflow_wx3LgtSVmosH2DtrC41B5q", + "description": null, + "definition": { + "trigger": { + "inputJsonSchema": { + "type": "object", + "properties": { + "instanceName": { + "type": "string" + } + }, + "required": [ + "instanceName" + ] + }, + "nextSteps": [ + { + "slug": "get-prefix-synchronization-settings" + } + ], + "type": "MANUAL" + }, + "steps": [ + { + "name": "get-prefix-synchronization-settings", + "slug": "get-prefix-synchronization-settings", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "netbox-get-prefix-synchronization-settings", + "inputs": [ + { + "type": "FUNCTION", + "value": "async ({ state }) => state.input.instanceName", + "key": "instanceName" + } + ], + "nextSteps": [ + { + "slug": "get-first-page-of-prefixes" + } + ], + "outputMapper": "({ output, utils }) => {\n if (!output.isEnabled) {\n return utils.fail(\"NetBox prefix synchronization is disabled for this instance.\", \"PREFIX_SYNCHRONIZATION_DISABLED\");\n }\n return output;\n }" + }, + { + "name": "get-first-page-of-prefixes", + "slug": "get-first-page-of-prefixes", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "netbox-http-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => \"GET\"", + "key": "method" + }, + { + "type": "FUNCTION", + "value": "({ state }) => {\n const { ipamRoles } = state.steps[\"get-prefix-synchronization-settings\"].output;\n const filter = ipamRoles.length > 0 ? `&role_id=${ipamRoles.join(\"&role_id=\")}` : \"\";\n return `/ipam/prefixes?site__n=null&limit=100&offset=0${filter}`;\n }", + "key": "path" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.input.instanceName", + "key": "instanceName" + } + ], + "nextSteps": [ + { + "slug": "get-paginated-prefixes" + } + ], + "outputMapper": "({ output }) => {\n const response = output;\n const numberOfRemainingPages = Math.ceil(response.count / 100) - 1;\n return {\n pagesToFetch: Array.from({ length: numberOfRemainingPages }, (_, index) => index + 1),\n results: response.results\n };\n }" + }, + { + "name": "get-paginated-prefixes", + "slug": "get-paginated-prefixes", + "nodeSlug": "parallel-loop", + "type": "PARALLEL_LOOP", + "inputs": [ + { + "key": "iterable", + "type": "FUNCTION", + "value": "({ state }) => state.steps[\"get-first-page-of-prefixes\"].output.pagesToFetch" + } + ], + "initialSteps": [ + { + "slug": "get-page-of-prefixes" + } + ], + "nextSteps": [ + { + "slug": "merge-paginated-prefixes" + } + ], + "outputMapper": "({ state }) => state.steps[\"get-page-of-prefixes\"].output" + }, + { + "name": "get-page-of-prefixes", + "slug": "get-page-of-prefixes", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "netbox-http-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => \"GET\"", + "key": "method" + }, + { + "type": "FUNCTION", + "value": "({ state }) => {\n const { ipamRoles } = state.steps[\"get-prefix-synchronization-settings\"].output;\n const filter = ipamRoles?.length > 0 ? `&role_id=${ipamRoles.join(\"&role_id=\")}` : \"\";\n return `/ipam/prefixes?site__n=null&limit=100&offset=${state.currentParallelLoopIterations[\"get-paginated-prefixes\"] * 100}${filter}`;\n }", + "key": "path" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.input.instanceName", + "key": "instanceName" + } + ], + "nextSteps": [ + { + "slug": "get-paginated-prefixes-end" + } + ], + "outputMapper": "({ output }) => {\n const response = output;\n return response.results;\n }", + "parentParallelLoopSlug": "get-paginated-prefixes" + }, + { + "name": "merge-paginated-prefixes", + "slug": "merge-paginated-prefixes", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:({ state }) => state", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "get-all-network-sites" + } + ], + "outputMapper": "({ state }) => {\n const { results } = state.steps[\"get-first-page-of-prefixes\"].output;\n const paginatedResults = state.steps[\"get-paginated-prefixes\"].output.flat();\n return {\n prefixes: [...results, ...paginatedResults]\n };\n }" + }, + { + "name": "get-all-network-sites", + "slug": "get-all-network-sites", + "nodeSlug": "while-loop", + "type": "WHILE_LOOP", + "initialSteps": [ + { + "slug": "get-network-sites" + } + ], + "nextSteps": [ + { + "slug": "map-prefixes-to-network-sites" + } + ], + "condition": "({ state }) => {\n const whileLoop = state.currentWhileLoopIterations[\"get-all-network-sites\"];\n return whileLoop.isFirstIteration || whileLoop.previousIteration.steps[\"get-network-sites\"].output.networkSites.pageInfo.hasNextPage;\n }", + "iterationOutputMapper": "({ state }) => state.steps[\"get-network-sites\"].output", + "outputMapper": "({ output }) => {\n const responses = output;\n return {\n networkSites: responses.flatMap((response) => response.networkSites.edges.map((edge) => edge.node))\n };\n }" + }, + { + "name": "get-network-sites", + "slug": "get-network-sites", + "type": "GAIIA_API_GATEWAY_V1_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-public-api-gateway-graphql-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => `\n query NetworkSites($first: Int, $after: String, $filters: NetworkSiteFilters, $firstIpBlocks: Int) {\n networkSites(first: $first, after: $after, filters: $filters) {\n totalCount\n edges {\n node {\n id\n name\n ipBlocks(first: $firstIpBlocks) {\n totalCount\n nodes {\n id\n block\n description\n integrationId\n }\n }\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }`", + "key": "query" + }, + { + "type": "FUNCTION", + "value": "({ state }) => {\n const previousResponse = state.currentWhileLoopIterations[\"get-all-network-sites\"].previousIteration?.output;\n return {\n first: 250,\n after: previousResponse?.networkSites.pageInfo.endCursor,\n firstIpBlocks: 250\n };\n }", + "key": "variables" + } + ], + "nextSteps": [ + { + "slug": "get-all-network-sites-end" + } + ], + "outputMapper": "({ output }) => {\n const response = output.response;\n return response;\n }", + "parentWhileLoopSlug": "get-all-network-sites" + }, + { + "name": "map-prefixes-to-network-sites", + "slug": "map-prefixes-to-network-sites", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:({ state }) => state", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "add-ip-blocks-to-network-sites" + } + ], + "outputMapper": "async ({ state, utils }) => {\n const { prefixes } = state.steps[\"merge-paginated-prefixes\"].output;\n const { networkSites } = state.steps[\"get-all-network-sites\"].output;\n const integrationId = await utils.integration.datastore.getItem(\"integrationId\");\n return networkSites.map((site) => {\n const existingBlocksOnSite = new Set(site.ipBlocks.nodes.map((block) => block.block));\n return {\n networkSiteId: site.id,\n integrationId,\n ipBlocks: prefixes.filter(\n (prefix) => (\n // support for both v4.1.x (site.name) and v4.2.x (scope.name) of NetBox\n (prefix.site?.name === site.name || prefix.scope?.name === site.name) && !existingBlocksOnSite.has(prefix.prefix)\n )\n ).map((block) => ({\n block: block.prefix,\n description: block.description\n }))\n };\n }).filter((site) => site.ipBlocks.length > 0);\n }" + }, + { + "name": "add-ip-blocks-to-network-sites", + "slug": "add-ip-blocks-to-network-sites", + "nodeSlug": "parallel-loop", + "type": "PARALLEL_LOOP", + "inputs": [ + { + "key": "iterable", + "type": "FUNCTION", + "value": "({ state }) => state.steps[\"map-prefixes-to-network-sites\"].output" + } + ], + "initialSteps": [ + { + "slug": "add-ip-blocks-to-network-site" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ] + }, + { + "name": "add-ip-blocks-to-network-site", + "slug": "add-ip-blocks-to-network-site", + "type": "GAIIA_API_GATEWAY_V1_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-public-api-gateway-graphql-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => `\n mutation AddIpBlocksToNetworkSite($input: AddIpBlocksToNetworkSiteInput!) {\n addIpBlocksToNetworkSite(input: $input) {\n networkSite {\n id\n }\n errors {\n code\n message\n }\n }\n }`", + "key": "query" + }, + { + "type": "FUNCTION", + "value": "({ state }) => ({\n input: state.currentParallelLoopIterations[\"add-ip-blocks-to-network-sites\"]\n })", + "key": "variables" + } + ], + "nextSteps": [ + { + "slug": "add-ip-blocks-to-network-sites-end" + } + ], + "outputMapper": "({ output, utils }) => {\n const response = output.response;\n if (response.addIpBlocksToNetworkSite.errors?.length) {\n return utils.fail(\"Failed to add IP blocks to Network Site.\", \"ADD_IP_BLOCKS_FAILED\", response.addIpBlocksToNetworkSite.errors);\n }\n return response.addIpBlocksToNetworkSite.networkSite;\n }", + "parentParallelLoopSlug": "add-ip-blocks-to-network-sites" + } + ] + } + }, + "Verona Networks suspension": { + "id": "workflow_bcdU9jvaxRXbYJW2S7DHHH", + "description": null, + "definition": { + "steps": [ + { + "name": "Find gaiia account", + "nextSteps": [ + { + "slug": "find-account-cpes" + } + ], + "nodeSlug": "gaiia-internal-api-gateway-graphql-request", + "slug": "find-gaiia-account", + "type": "GAIIA_API_GATEWAY_V0_GRAPHQL_REQUEST", + "inputs": [ + { + "key": "query", + "type": "LITERAL", + "value": "string:query VeronaSuspensionWorkflowAccountQuery($id: ID!) {\n account(id: $id) {\n gaiiaId\n activeIpAddresses {\n ipAddress\n macAddress\n }\n }\n}" + }, + { + "key": "variables", + "type": "FUNCTION", + "value": "({ state }) => ({ id: state.accountId })" + } + ], + "outputMapper": "({ output }) => {\n const { account } = output.response;\n if (account.activeIpAddresses.length === 0) {\n workflow.fail(`No active IP address associated to this account.`, 'NO_ACTIVE_IP_ADDRESS');\n }\n return account;\n}" + }, + { + "name": "Find account CPEs", + "nextSteps": [ + { + "slug": "preseem-update-service" + } + ], + "nodeSlug": "find-account-cpes", + "slug": "find-account-cpes", + "type": "GAIIA_FIND_ACCOUNT_CPES", + "inputs": [ + { + "key": "accountId", + "type": "STATE", + "value": "accountId" + } + ] + }, + { + "name": "Preseem update service", + "nextSteps": [ + { + "slug": "end" + } + ], + "nodeSlug": "preseem-rest-request", + "slug": "preseem-update-service", + "type": "PRESEEM_REST_REQUEST", + "inputs": [ + { + "key": "method", + "type": "LITERAL", + "value": "string:PUT" + }, + { + "key": "path", + "type": "FUNCTION", + "value": "({ state }) => `/services/${state.steps['find-gaiia-account'].output.gaiiaId}`" + }, + { + "key": "body", + "type": "FUNCTION", + "value": "({ state }) => {\n const { gaiiaId, activeIpAddresses } = state.steps['find-gaiia-account'].output;\n const cpes = state.steps['find-account-cpes'].output.accountCpe;\n const macAddresses = [\n ...new Set([...activeIpAddresses.map(({ macAddress }) => macAddress), ...cpes.map(({ macAddress }) => macAddress)])\n ];\n return {\n id: String(gaiiaId),\n attachments: macAddresses.map((macAddress) => ({ cpe_mac: macAddress.toLowerCase() })),\n account: String(gaiiaId),\n package: 'suspended'\n };\n }" + } + ] + } + ], + "trigger": { + "nextSteps": [ + { + "slug": "find-gaiia-account" + } + ], + "type": "MANUAL", + "objectType": "ACCOUNT" + } + } + }, + "Update RADIUS Group": { + "id": "workflow_jxJRrNcmd6DuZVX9EBN8Q9", + "description": null, + "definition": { + "trigger": { + "inputJsonSchema": { + "type": "object", + "properties": { + "instanceName": { + "type": "string", + "description": "The name of the FreeRADIUS instance" + }, + "oldGroupName": { + "type": "string", + "description": "The current name of the RADIUS group" + }, + "groupName": { + "type": "string", + "description": "The new name of the RADIUS group" + }, + "priority": { + "type": "number", + "description": "The priority of the group" + }, + "mappingRule": { + "type": "string", + "enum": [ + "ALL", + "MATCH_PRODUCT", + "INACTIVE", + "SUSPENDED", + "ACTIVE" + ], + "description": "The mapping rule for automatic group assignment" + }, + "mappedProductIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Product IDs for MATCH_PRODUCT mapping rule" + }, + "attributes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "operator": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "name", + "operator", + "value" + ] + }, + "description": "RADIUS attributes for the group" + }, + "fallThrough": { + "type": "boolean", + "description": "Whether to continue evaluating other groups" + } + }, + "required": [ + "instanceName", + "oldGroupName", + "groupName", + "priority", + "mappingRule", + "mappedProductIds", + "attributes", + "fallThrough" + ] + }, + "nextSteps": [ + { + "slug": "validate-group-input" + } + ], + "type": "MANUAL" + }, + "outputMapper": { + "function": "({ state, utils }) => {\n if (\"handle-error\" in state.steps) {\n utils.fail(state.steps[\"handle-error\"].output.message, state.steps[\"handle-error\"].output.code, state.steps[\"handle-error\"].output.payload);\n }\n return {\n success: true\n };\n }", + "type": "JSON" + }, + "steps": [ + { + "name": "validate-group-input", + "slug": "validate-group-input", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:async ({ state, utils }) => {\n const { instanceName, oldGroupName, groupName, priority, attributes, mappingRule, mappedProductIds } = state.input;\n if (groupName.length > 64) {\n return utils.fail(\"Group name is too long. Max length is 64\", \"GROUP_NAME_TOO_LONG\");\n }\n if (priority < 0) {\n return utils.fail(\"Priority must be greater than or equal to 0\", \"PRIORITY_INVALID\");\n }\n if (mappingRule === \"MATCH_PRODUCT\" && (!mappedProductIds || mappedProductIds.length === 0)) {\n return utils.fail(\"MATCH_PRODUCT mapping rule requires at least one mapped product ID\", \"MAPPING_RULE_INVALID\");\n }\n const RADUSERGROUP_ATTRIBUTE_NAME_MAX_LENGTH = 64;\n const RADUSERGROUP_ATTRIBUTE_OPERATOR_MAX_LENGTH = 2;\n const RADUSERGROUP_ATTRIBUTE_VALUE_MAX_LENGTH = 253;\n for (const attribute of attributes) {\n if (!attribute.name || attribute.name.length > RADUSERGROUP_ATTRIBUTE_NAME_MAX_LENGTH) {\n return utils.fail(`Attribute name is too long. Max length is ${RADUSERGROUP_ATTRIBUTE_NAME_MAX_LENGTH}`, \"ATTRIBUTE_NAME_TOO_LONG\");\n }\n if (!attribute.operator || attribute.operator.length > RADUSERGROUP_ATTRIBUTE_OPERATOR_MAX_LENGTH) {\n return utils.fail(`Attribute operator is too long. Max length is ${RADUSERGROUP_ATTRIBUTE_OPERATOR_MAX_LENGTH}`, \"ATTRIBUTE_OPERATOR_TOO_LONG\");\n }\n if (!attribute.value || attribute.value.length > RADUSERGROUP_ATTRIBUTE_VALUE_MAX_LENGTH) {\n return utils.fail(`Attribute value is too long. Max length is ${RADUSERGROUP_ATTRIBUTE_VALUE_MAX_LENGTH}`, \"ATTRIBUTE_VALUE_TOO_LONG\");\n }\n }\n const instanceId = await utils.integration.datastore.getItem(`instances.${instanceName}.id`);\n if (!instanceId) {\n return utils.fail(\"Instance not found\", \"INSTANCE_NOT_FOUND\");\n }\n const existingData = await utils.integration.datastore.getItem(`instances.${instanceId}.groups`);\n if (!existingData) {\n return utils.fail(\"Groups not found in datastore\", \"GROUPS_NOT_FOUND\");\n }\n const groups = JSON.parse(existingData);\n const group = groups.find((g) => g.groupName === oldGroupName);\n if (!group) {\n return utils.fail(\"Group not found in datastore\", \"GROUP_NOT_FOUND\");\n }\n return {\n group: {\n groupName: group.groupName,\n mappedProductIds: group.mappedProductIds,\n mappingRule: group.mappingRule,\n priority: group.priority\n },\n groups,\n instanceId,\n validated: true\n };\n }", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "update-group-attributes-only" + }, + { + "slug": "handle-error", + "errorCodes": [ + "*" + ] + } + ], + "outputMapper": "({ output }) => output" + }, + { + "name": "update-group-attributes-only", + "slug": "update-group-attributes-only", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "free-radius-mysql-transaction", + "inputs": [ + { + "type": "FUNCTION", + "value": "({ state }) => state.input.instanceName", + "key": "instanceName" + }, + { + "type": "FUNCTION", + "value": "({ state }) => {\n const { oldGroupName, groupName, attributes, fallThrough } = state.input;\n const queries = [];\n queries.push({\n query: \"DELETE FROM radgroupreply WHERE groupname = ?\",\n values: [oldGroupName]\n });\n if (oldGroupName !== groupName) {\n queries.push({\n query: \"UPDATE radusergroup SET groupname = ? WHERE groupname = ?\",\n values: [groupName, oldGroupName]\n });\n }\n for (const attribute of attributes) {\n queries.push({\n query: \"INSERT INTO radgroupreply (groupname, attribute, op, value) VALUES (?, ?, ?, ?)\",\n values: [groupName, attribute.name, attribute.operator, attribute.value]\n });\n }\n queries.push({\n query: \"INSERT INTO radgroupreply (groupname, attribute, op, value) VALUES (?, ?, ?, ?)\",\n values: [groupName, \"Fall-Through\", \"=\", fallThrough === false ? \"No\" : \"Yes\"]\n });\n return queries;\n }", + "key": "queries" + } + ], + "nextSteps": [ + { + "slug": "update-datastore" + } + ], + "outputMapper": "({ output }) => ({\n updated: true,\n renamed: false,\n affectedRows: output.results?.length || 0\n })" + }, + { + "name": "update-datastore", + "slug": "update-datastore", + "type": "GAIIA_WRITE_TO_INTEGRATION_DATASTORE", + "nodeSlug": "write-to-integration-datastore", + "inputs": [ + { + "type": "FUNCTION", + "value": "({ state }) => {\n const { groupName, priority, mappingRule, mappedProductIds } = state.input;\n const { instanceId, groups, group } = state.steps[\"validate-group-input\"].output;\n const updatedGroup = {\n ...group,\n groupName,\n priority,\n mappingRule,\n mappedProductIds\n };\n const updatedGroups = groups.map((g) => g.groupName === group.groupName ? updatedGroup : g);\n return [\n {\n key: `instances.${instanceId}.groups`,\n value: JSON.stringify(updatedGroups)\n }\n ];\n }", + "key": "values" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ], + "outputMapper": "() => ({\n success: true\n })" + }, + { + "name": "handle-error", + "slug": "handle-error", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:({ state }) => {\n const steps = state.steps;\n return steps[\"validate-group-input\"]?.error;\n }", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ], + "outputMapper": "({ output }) => output" + } + ] + } + }, + "Create NetBox Prefix Update Webhook": { + "id": "workflow_ioh3osUxKLwCZZZZBtvQyG", + "description": null, + "definition": { + "trigger": { + "inputJsonSchema": { + "type": "object", + "properties": { + "instanceName": { + "type": "string" + } + }, + "required": [ + "instanceName" + ] + }, + "nextSteps": [ + { + "slug": "list-custom-fields" + } + ], + "type": "MANUAL" + }, + "outputMapper": { + "function": "({ state }) => state.steps[\"create-event-rule\"].output", + "type": "JSON" + }, + "steps": [ + { + "name": "list-custom-fields", + "slug": "list-custom-fields", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "netbox-http-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => \"GET\"", + "key": "method" + }, + { + "type": "FUNCTION", + "value": "() => \"/extras/custom-fields/\"", + "key": "path" + }, + { + "type": "FUNCTION", + "value": "() => ({\n name: [\"gaiia_tenant_id\", \"gaiia_instance_id\"]\n })", + "key": "body" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.input.instanceName", + "key": "instanceName" + } + ], + "nextSteps": [ + { + "slug": "check-tenant-id-field-exists" + } + ], + "outputMapper": "({ output }) => {\n const results = output.results;\n return results.map((field) => field.name);\n }" + }, + { + "name": "check-tenant-id-field-exists", + "slug": "check-tenant-id-field-exists", + "nodeSlug": "condition", + "type": "CONDITION", + "condition": "({ state }) => state.steps[\"list-custom-fields\"].output.includes(\"gaiia_tenant_id\")", + "thenSteps": [ + { + "slug": "check-instance-id-field-exists" + } + ], + "elseSteps": [ + { + "slug": "create-tenant-id-custom-field" + } + ] + }, + { + "name": "create-tenant-id-custom-field", + "slug": "create-tenant-id-custom-field", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "netbox-http-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => \"POST\"", + "key": "method" + }, + { + "type": "FUNCTION", + "value": "() => \"/extras/custom-fields/\"", + "key": "path" + }, + { + "type": "FUNCTION", + "value": "() => ({\n label: \"gaiia tenant ID\",\n name: \"gaiia_tenant_id\",\n type: \"text\",\n object_types: [\"extras.webhook\", \"extras.eventrule\"],\n required: false\n })", + "key": "body" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.input.instanceName", + "key": "instanceName" + } + ], + "nextSteps": [ + { + "slug": "check-instance-id-field-exists" + } + ], + "outputMapper": "({ output }) => output" + }, + { + "name": "check-instance-id-field-exists", + "slug": "check-instance-id-field-exists", + "nodeSlug": "condition", + "type": "CONDITION", + "condition": "({ state }) => state.steps[\"list-custom-fields\"].output.includes(\"gaiia_instance_id\")", + "thenSteps": [ + { + "slug": "get-webhook-workflow" + } + ], + "elseSteps": [ + { + "slug": "create-instance-id-custom-field" + } + ] + }, + { + "name": "create-instance-id-custom-field", + "slug": "create-instance-id-custom-field", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "netbox-http-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => \"POST\"", + "key": "method" + }, + { + "type": "FUNCTION", + "value": "() => \"/extras/custom-fields/\"", + "key": "path" + }, + { + "type": "FUNCTION", + "value": "() => ({\n label: \"gaiia instance ID\",\n name: \"gaiia_instance_id\",\n type: \"text\",\n object_types: [\"extras.webhook\", \"extras.eventrule\"],\n required: false\n })", + "key": "body" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.input.instanceName", + "key": "instanceName" + } + ], + "nextSteps": [ + { + "slug": "get-webhook-workflow" + } + ], + "outputMapper": "({ output }) => output" + }, + { + "name": "get-webhook-workflow", + "slug": "get-webhook-workflow", + "type": "GAIIA_API_GATEWAY_V1_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-public-api-gateway-graphql-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => {\n // -----------------\n // WorkflowsDocument\n // -----------------\n const WorkflowsDocument = {\"kind\":\"Document\",\"definitions\":[{\"kind\":\"OperationDefinition\",\"operation\":\"query\",\"name\":{\"kind\":\"Name\",\"value\":\"Workflows\"},\"variableDefinitions\":[{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"filter\"}},\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"WorkflowFilter\"}},\"directives\":[]}],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"workflows\"},\"arguments\":[{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"filter\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"filter\"}}}],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"nodes\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"id\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"latestDefinition\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"webhookUrl\"},\"arguments\":[],\"directives\":[]}]}}]}}]}}]}}],\"loc\":{\"start\":0,\"end\":168}};\n // ------------------------------------------------------------\n\n return WorkflowsDocument;\n}", + "key": "query" + }, + { + "type": "FUNCTION", + "value": "() => ({\n filter: {\n name: {\n equals: \"Import Network Site Prefixes\"\n }\n }\n })", + "key": "variables" + } + ], + "nextSteps": [ + { + "slug": "create-webhook" + } + ], + "outputMapper": "({ output, utils }) => {\n const response = output.response;\n if (response.workflows.nodes.length === 0) {\n return utils.fail('No workflow found with the name \"Import Network Site Prefixes\"', \"NO_WORKFLOW_FOUND\");\n }\n if (response.workflows.nodes.length > 1) {\n return utils.fail('Multiple workflows found with the name \"Import Network Site Prefixes\"', \"MULTIPLE_WORKFLOWS_FOUND\");\n }\n const webhookUrl = response.workflows.nodes[0].latestDefinition?.webhookUrl;\n if (!webhookUrl) {\n return utils.fail('The workflow \"Import Network Site Prefixes\" does not have a webhook URL defined', \"NO_WEBHOOK_URL_DEFINED\");\n }\n return response.workflows.nodes[0];\n }" + }, + { + "name": "create-webhook", + "slug": "create-webhook", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "netbox-http-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => \"POST\"", + "key": "method" + }, + { + "type": "FUNCTION", + "value": "() => \"/extras/webhooks/\"", + "key": "path" + }, + { + "type": "FUNCTION", + "value": "async ({ state, utils }) => {\n const instanceId = await utils.integration.datastore.getItem(`instances.${state.input.instanceName}.id`);\n return {\n name: `${state.context.tenant.name} - Prefix Update Webhook`,\n payload_url: `${state.steps[\"get-webhook-workflow\"].output.latestDefinition?.webhookUrl}?instanceId=${instanceId}`,\n custom_fields: {\n gaiia_tenant_id: state.context.tenant.id,\n gaiia_instance_id: instanceId\n }\n };\n }", + "key": "body" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.input.instanceName", + "key": "instanceName" + } + ], + "nextSteps": [ + { + "slug": "create-event-rule" + } + ], + "outputMapper": "({ output }) => output" + }, + { + "name": "create-event-rule", + "slug": "create-event-rule", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "netbox-http-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => \"POST\"", + "key": "method" + }, + { + "type": "FUNCTION", + "value": "() => \"/extras/event-rules/\"", + "key": "path" + }, + { + "type": "FUNCTION", + "value": "async ({ state, utils }) => ({\n name: `${state.context.tenant.name} - Prefix Update Event Rule`,\n event_types: [\"object_updated\", \"object_created\", \"object_deleted\"],\n object_types: [\"dcim.site\", \"ipam.prefix\"],\n type_create: true,\n type_update: true,\n type_delete: true,\n action_type: \"webhook\",\n action_object_type: \"extras.webhook\",\n action_object_id: state.steps[\"create-webhook\"].output.id,\n custom_fields: {\n gaiia_tenant_id: state.context.tenant.id,\n gaiia_instance_id: await utils.integration.datastore.getItem(`instances.${state.input.instanceName}.id`)\n }\n })", + "key": "body" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.input.instanceName", + "key": "instanceName" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ], + "outputMapper": "({ output }) => output" + } + ] + } + }, + "Update Preseem on IP Change": { + "id": "workflow_3PWGkYj2un5VAQUGB3pamU", + "description": null, + "definition": { + "steps": [ + { + "name": "Find inventory item", + "slug": "find-inventory-item", + "type": "GAIIA_API_GATEWAY_V0_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-internal-api-gateway-graphql-request", + "allowSkipExecution": false, + "outputMapper": "({ output }) => {\n const { account } = output.response.inventoryItem;\n if (!account) {\n workflow.fail('Inventory item is not assigned to an account.', 'NO_ACCOUNT');\n }\n return account;\n}", + "inputs": [ + { + "key": "query", + "type": "LITERAL", + "value": "string:query IpAssignmentWorkflowInventoryItemQuery($id: ID!) {\n inventoryItem(id: $id) {\n id\n account {\n id\n gaiiaId\n }\n }\n}" + }, + { + "key": "variables", + "type": "FUNCTION", + "value": "({ state }) => ({ id: state.input.objectId })" + } + ], + "nextSteps": [ + { + "slug": "find-account-cpes" + } + ] + }, + { + "name": "Find account CPEs", + "slug": "find-account-cpes", + "type": "GAIIA_FIND_ACCOUNT_CPES", + "nodeSlug": "find-account-cpes", + "allowSkipExecution": false, + "inputs": [ + { + "key": "accountId", + "type": "FUNCTION", + "value": "({ state }) => state.steps['find-inventory-item'].output.id" + } + ], + "nextSteps": [ + { + "slug": "find-account-internet-plans" + } + ] + }, + { + "name": "Find account Internet plans", + "slug": "find-account-internet-plans", + "type": "GAIIA_FIND_ACCOUNT_INTERNET_PLANS", + "nodeSlug": "find-account-internet-plans", + "allowSkipExecution": false, + "inputs": [ + { + "key": "accountId", + "type": "FUNCTION", + "value": "({ state }) => state.steps['find-inventory-item'].output.id" + } + ], + "nextSteps": [ + { + "slug": "preseem-update-service" + } + ] + }, + { + "name": "Preseem update service", + "slug": "preseem-update-service", + "type": "PRESEEM_REST_REQUEST", + "nodeSlug": "preseem-rest-request", + "allowSkipExecution": false, + "inputs": [ + { + "key": "body", + "type": "FUNCTION", + "value": "({ state }) => {\n const { gaiiaId } = state.steps['find-inventory-item'].output;\n const [{ slug }] = state.steps['find-account-internet-plans'].output.internetPlans;\n const cpes = state.steps['find-account-cpes'].output.accountCpe;\n const eventMac = state.input.payload.macAddress;\n const macAddresses = [\n ...new Set([\n ...cpes.map(({ macAddress }) => macAddress),\n eventMac\n ])\n ];\n return {\n id: String(gaiiaId),\n attachments: macAddresses\n .filter(Boolean)\n .map((macAddress) => ({ cpe_mac: macAddress.toLowerCase() })),\n account: String(gaiiaId),\n package: slug\n };\n }" + }, + { + "key": "method", + "type": "LITERAL", + "value": "string:PUT" + }, + { + "key": "path", + "type": "FUNCTION", + "value": "({ state }) => /services/${state.steps['find-inventory-item'].output.gaiiaId}" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ] + } + ], + "trigger": { + "nextSteps": [ + { + "slug": "find-inventory-item" + } + ], + "type": "EVENT", + "events": [ + "inventory_item.ip_address_assigned", + "inventory_item.ip_address_unassigned" + ] + } + } + }, + "Delete RADIUS Group": { + "id": "workflow_n3M2RXK57eKaiZMsrHfEtD", + "description": null, + "definition": { + "trigger": { + "inputJsonSchema": { + "type": "object", + "properties": { + "instanceName": { + "type": "string", + "description": "The name of the FreeRADIUS instance" + }, + "groupName": { + "type": "string", + "description": "The name of the RADIUS group to delete" + } + }, + "required": [ + "instanceName", + "groupName" + ] + }, + "nextSteps": [ + { + "slug": "validate-instance-name" + } + ], + "type": "MANUAL" + }, + "outputMapper": { + "function": "({ state, utils }) => {\n if (\"handle-error\" in state.steps) {\n const error = state.steps[\"handle-error\"].output;\n utils.fail(error.message, error.code, error.payload);\n }\n return {\n success: true\n };\n }", + "type": "JSON" + }, + "steps": [ + { + "name": "validate-instance-name", + "slug": "validate-instance-name", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:async ({ state, utils }) => {\n const { instanceName } = state.input;\n const instanceId = await utils.integration.datastore.getItem(`instances.${instanceName}.id`);\n if (!instanceId) {\n return utils.fail(\"Instance not found\", \"INSTANCE_NOT_FOUND\");\n }\n return {\n instanceId,\n instanceName\n };\n }", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "delete-group-transaction" + }, + { + "slug": "handle-error", + "errorCodes": [ + "*" + ] + } + ], + "outputMapper": "({ output }) => output" + }, + { + "name": "delete-group-transaction", + "slug": "delete-group-transaction", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "free-radius-mysql-transaction", + "inputs": [ + { + "type": "FUNCTION", + "value": "({ state }) => state.input.instanceName", + "key": "instanceName" + }, + { + "type": "FUNCTION", + "value": "({ state }) => {\n const { groupName } = state.input;\n return [\n {\n query: \"DELETE FROM radusergroup WHERE groupname = ?\",\n values: [groupName]\n },\n {\n query: \"DELETE FROM radgroupreply WHERE groupname = ?\",\n values: [groupName]\n }\n ];\n }", + "key": "queries" + } + ], + "nextSteps": [ + { + "slug": "delete-from-datastore" + } + ], + "outputMapper": "({ output }) => ({\n deleted: true,\n affectedRows: output.results?.length || 0\n })" + }, + { + "name": "delete-from-datastore", + "slug": "delete-from-datastore", + "type": "GAIIA_WRITE_TO_INTEGRATION_DATASTORE", + "nodeSlug": "write-to-integration-datastore", + "inputs": [ + { + "type": "FUNCTION", + "value": "async ({ state, utils }) => {\n const { groupName } = state.input;\n const { instanceId } = state.steps[\"validate-instance-name\"].output;\n const existingData = await utils.integration.datastore.getItem(`instances.${instanceId}.groups`);\n const groups = JSON.parse(existingData);\n const updatedGroups = groups.filter((g) => g.groupName !== groupName);\n return [\n {\n key: `instances.${instanceId}.groups`,\n value: JSON.stringify(updatedGroups)\n }\n ];\n }", + "key": "values" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ], + "outputMapper": "() => ({\n success: true\n })" + }, + { + "name": "handle-error", + "slug": "handle-error", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:({ state }) => {\n const steps = state.steps;\n return steps[\"validate-instance-name\"]?.error;\n }", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ], + "outputMapper": "({ output }) => output" + } + ] + } + }, + "Remove NetBox Prefix Update Webhook": { + "id": "workflow_w7atgmGFDwaEnthsSg7WGk", + "description": null, + "definition": { + "trigger": { + "inputJsonSchema": { + "type": "object", + "properties": { + "instanceName": { + "type": "string" + } + }, + "required": [ + "instanceName" + ] + }, + "nextSteps": [ + { + "slug": "list-event-rules" + } + ], + "type": "MANUAL" + }, + "steps": [ + { + "name": "list-event-rules", + "slug": "list-event-rules", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "netbox-http-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => \"GET\"", + "key": "method" + }, + { + "type": "FUNCTION", + "value": "async ({ state, utils }) => {\n const instanceId = await utils.integration.datastore.getItem(`instances.${state.input.instanceName}.id`);\n return `/extras/event-rules?cf_gaiia_tenant_id=${state.context.tenant.id}&cf_gaiia_instance_id=${instanceId}`;\n }", + "key": "path" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.input.instanceName", + "key": "instanceName" + } + ], + "nextSteps": [ + { + "slug": "delete-event-rules" + } + ], + "outputMapper": "({ output }) => {\n const response = output;\n return response;\n }" + }, + { + "name": "delete-event-rules", + "slug": "delete-event-rules", + "nodeSlug": "parallel-loop", + "type": "PARALLEL_LOOP", + "inputs": [ + { + "key": "iterable", + "type": "FUNCTION", + "value": "({ state }) => {\n const eventRuleName = `${state.context.tenant.name} - Prefix Update Event Rule`;\n return state.steps[\"list-event-rules\"].output.results.filter((rule) => rule.name === eventRuleName);\n }" + } + ], + "initialSteps": [ + { + "slug": "delete-event-rule" + } + ], + "nextSteps": [ + { + "slug": "list-webhooks" + } + ] + }, + { + "name": "delete-event-rule", + "slug": "delete-event-rule", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "netbox-http-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => \"DELETE\"", + "key": "method" + }, + { + "type": "FUNCTION", + "value": "({ state }) => `extras/event-rules/${state.currentParallelLoopIterations[\"delete-event-rules\"].id}`", + "key": "path" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.input.instanceName", + "key": "instanceName" + } + ], + "nextSteps": [ + { + "slug": "delete-event-rules-end" + } + ], + "outputMapper": "({ output }) => output", + "parentParallelLoopSlug": "delete-event-rules" + }, + { + "name": "list-webhooks", + "slug": "list-webhooks", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "netbox-http-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => \"GET\"", + "key": "method" + }, + { + "type": "FUNCTION", + "value": "async ({ state, utils }) => {\n const instanceId = await utils.integration.datastore.getItem(`instances.${state.input.instanceName}.id`);\n return `/extras/webhooks?cf_gaiia_tenant_id=${state.context.tenant.id}&cf_gaiia_instance_id=${instanceId}`;\n }", + "key": "path" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.input.instanceName", + "key": "instanceName" + } + ], + "nextSteps": [ + { + "slug": "delete-webhooks" + } + ], + "outputMapper": "({ output }) => {\n const response = output;\n return response;\n }" + }, + { + "name": "delete-webhooks", + "slug": "delete-webhooks", + "nodeSlug": "parallel-loop", + "type": "PARALLEL_LOOP", + "inputs": [ + { + "key": "iterable", + "type": "FUNCTION", + "value": "({ state }) => {\n const webhookName = `${state.context.tenant.name} - Prefix Update Webhook`;\n return state.steps[\"list-webhooks\"].output.results.filter((webhook) => webhook.name === webhookName);\n }" + } + ], + "initialSteps": [ + { + "slug": "delete-webhook" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ] + }, + { + "name": "delete-webhook", + "slug": "delete-webhook", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "netbox-http-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => \"DELETE\"", + "key": "method" + }, + { + "type": "FUNCTION", + "value": "({ state }) => `extras/webhooks/${state.currentParallelLoopIterations[\"delete-webhooks\"].id}`", + "key": "path" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.input.instanceName", + "key": "instanceName" + } + ], + "nextSteps": [ + { + "slug": "delete-webhooks-end" + } + ], + "outputMapper": "({ output }) => output", + "parentParallelLoopSlug": "delete-webhooks" + } + ] + } + }, + "FreeRADIUS MySQL Query": { + "id": "workflow_aG62TB7ci2bVVEKkvv6vpp", + "description": null, + "definition": { + "trigger": { + "inputJsonSchema": { + "type": "object", + "properties": { + "instanceName": { + "type": "string", + "description": "The name of the FreeRADIUS instance to use. If not provided, uses the first instance." + }, + "query": { + "type": "string", + "description": "The SQL query to execute" + }, + "values": { + "type": "array", + "description": "The values to use in the prepared statement", + "items": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + } + } + }, + "required": [ + "query" + ], + "additionalProperties": false + }, + "nextSteps": [ + { + "slug": "fetch-instance-database-config" + } + ], + "type": "MANUAL" + }, + "outputMapper": { + "function": "({ state }) => state.steps[\"execute-mysql-query\"].output", + "type": "JSON" + }, + "steps": [ + { + "name": "fetch-instance-database-config", + "slug": "fetch-instance-database-config", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:async ({ state, utils }) => {\n const { instanceName } = state.input;\n let instanceId;\n if (!instanceName) {\n const instancesId = await utils.integration.datastore.getItem(\"instances\");\n if (!instancesId) {\n return utils.fail(\"No instances found in datastore\", \"NO_INSTANCES_FOUND\");\n }\n const instanceIds = instancesId.split(\",\");\n if (instanceIds.length === 0) {\n return utils.fail(\"No instance ids found\", \"NO_INSTANCE_IDS_FOUND\");\n }\n instanceId = instanceIds[0]?.trim();\n } else {\n instanceId = await utils.integration.datastore.getItem(`instances.${instanceName}.id`);\n if (!instanceId) {\n return utils.fail(\"Invalid instance name provided\", \"INVALID_INSTANCE_NAME\");\n }\n }\n const [databaseHost, databasePort, databaseName, databaseUsername, databasePassword] = await Promise.all([\n utils.integration.datastore.getItem(`instances.${instanceId}.databaseHost`),\n utils.integration.datastore.getItem(`instances.${instanceId}.databasePort`),\n utils.integration.datastore.getItem(`instances.${instanceId}.databaseName`),\n utils.integration.datastore.getItem(`instances.${instanceId}.databaseUsername`),\n utils.integration.datastore.getItem(`instances.${instanceId}.databasePassword`)\n ]);\n if (!databaseHost || !databasePort || !databaseName || !databaseUsername || !databasePassword) {\n return utils.fail(\"Incomplete instance configuration\", \"INCOMPLETE_INSTANCE_CONFIG\");\n }\n return {\n databaseHost,\n databasePort: Number(databasePort),\n databaseName,\n databaseUsername,\n databasePassword\n };\n }", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "execute-mysql-query" + } + ], + "outputMapper": "({ output }) => output" + }, + { + "name": "execute-mysql-query", + "slug": "execute-mysql-query", + "type": "MYSQL_QUERY", + "nodeSlug": "mysql-query", + "inputs": [ + { + "type": "FUNCTION", + "value": "({ state }) => state.steps[\"fetch-instance-database-config\"].output.databaseHost", + "key": "host" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.steps[\"fetch-instance-database-config\"].output.databasePort", + "key": "port" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.steps[\"fetch-instance-database-config\"].output.databaseName", + "key": "database" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.steps[\"fetch-instance-database-config\"].output.databaseUsername", + "key": "user" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.steps[\"fetch-instance-database-config\"].output.databasePassword", + "key": "password" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.input.query", + "key": "query" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.input.values", + "key": "values" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ], + "outputMapper": "({ output }) => output" + } + ] + } + }, + "Update Preseem on PPPoE IP Change": { + "id": "workflow_aEcWxEsubpuSebPwajqseP", + "description": null, + "definition": { + "steps": [ + { + "name": "Find gaiia account", + "slug": "find-gaiia-account", + "type": "GAIIA_API_GATEWAY_V0_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-internal-api-gateway-graphql-request", + "allowSkipExecution": false, + "outputMapper": "({ output }) => output.response.account", + "inputs": [ + { + "key": "query", + "type": "LITERAL", + "value": "string:query AccountQuery($id: ID!) {\n account(id: $id) {\n gaiiaId\n }\n}" + }, + { + "key": "variables", + "type": "FUNCTION", + "value": "({ state }) => ({ id: state.accountId })" + } + ], + "nextSteps": [ + { + "slug": "find-account-cpes" + } + ] + }, + { + "name": "Find account CPEs", + "slug": "find-account-cpes", + "type": "GAIIA_FIND_ACCOUNT_CPES", + "nodeSlug": "find-account-cpes", + "allowSkipExecution": false, + "inputs": [ + { + "key": "accountId", + "type": "STATE", + "value": "accountId" + } + ], + "nextSteps": [ + { + "slug": "find-account-internet-plans" + } + ] + }, + { + "name": "Find account Internet plans", + "slug": "find-account-internet-plans", + "type": "GAIIA_FIND_ACCOUNT_INTERNET_PLANS", + "nodeSlug": "find-account-internet-plans", + "allowSkipExecution": false, + "inputs": [ + { + "key": "accountId", + "type": "STATE", + "value": "accountId" + } + ], + "nextSteps": [ + { + "slug": "preseem-update-service" + } + ] + }, + { + "name": "Preseem update service", + "slug": "preseem-update-service", + "type": "PRESEEM_REST_REQUEST", + "nodeSlug": "preseem-rest-request", + "allowSkipExecution": false, + "inputs": [ + { + "key": "body", + "type": "FUNCTION", + "value": "({ state }) => {\n const { gaiiaId } = state.steps['find-gaiia-account'].output;\n const [{ slug }] = state.steps['find-account-internet-plans'].output.internetPlans;\n const cpes = state.steps['find-account-cpes'].output.accountCpe;\n const macAddresses = [\n ...new Set([\n ...cpes.map(({ macAddress }) => macAddress),\n state.macAddress\n ])\n ];\n return {\n id: String(gaiiaId),\n attachments: macAddresses\n .filter(Boolean)\n .map((m) => ({ cpe_mac: m.toLowerCase() })),\n account: String(gaiiaId),\n package: slug\n };\n}" + }, + { + "key": "method", + "type": "LITERAL", + "value": "string:PUT" + }, + { + "key": "path", + "type": "FUNCTION", + "value": "({ state }) => /services/${state.steps['find-gaiia-account'].output.gaiiaId}" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ] + } + ], + "trigger": { + "nextSteps": [ + { + "slug": "find-gaiia-account" + } + ], + "type": "MANUAL", + "inputJsonSchema": { + "type": "object", + "required": [ + "accountId", + "macAddress" + ], + "properties": { + "accountId": { + "type": "string" + }, + "macAddress": { + "type": "string" + } + } + }, + "objectType": "ACCOUNT" + } + } + }, + "freeRADIUS IP Sync": { + "id": "workflow_v8dpzZ88Pj1roMCmF9YFwT", + "description": "This workflow will walk the RADIUS radacct table and identify active/inactive sessions and learn the Framed IP and sync to the corresponding gaiia Account.", + "definition": { + "steps": [ + { + "name": "Assign IP address to devices", + "slug": "assign-ip-address-to-devices", + "type": "GAIIA_API_GATEWAY_V0_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-internal-api-gateway-graphql-request", + "parentParallelLoopSlug": "assign-ips-to-macs", + "allowSkipExecution": false, + "inputs": [ + { + "key": "query", + "type": "LITERAL", + "value": "string:mutation UpsertMacAddressIpAddressAssignations($input: UpsertMacAddressIpAddressAssignationsInput!) {\n upsertMacAddressIpAddressAssignations(input: $input) {\n assignations {\n macAddress\n ipAddress\n }\n }\n}" + }, + { + "key": "variables", + "type": "FUNCTION", + "value": "({ state }) => {\n const assignation = state.currentParallelLoopIterations['assign-ips-to-macs'];\n const rawMac = assignation.callingstationid;\n // Remove \"1:\" prefix if present. Example \"1:00:31:92:47:71:dc\" -> \"00:31:92:47:71:dc\"\n const cleanedMac = rawMac.startsWith('1:') ? rawMac.slice(2) : rawMac;\n // Pad each segment to 2 digits. Example \"0:31:92:47:71:dc\" -> \"00:31:92:47:71:dc\"\n const formattedMac = cleanedMac\n .split(':')\n .map((segment) => segment.padStart(2, '0'))\n .join(':')\n .toUpperCase();\n\n return {\n input: {\n assignations: {\n macAddress: formattedMac,\n ipAddress: assignation.framedipaddress,\n expired: assignation.expired,\n \"requireMacMatch\": true\n }\n }\n };\n}" + } + ], + "nextSteps": [ + { + "slug": "assign-ips-to-macs-end" + } + ] + }, + { + "name": "Assign IPs to MACs", + "slug": "assign-ips-to-macs", + "type": "PARALLEL_LOOP", + "nodeSlug": "parallel-loop", + "parentParallelLoopSlug": "process-radius-sessions", + "initialSteps": [ + { + "slug": "assign-ip-address-to-devices" + } + ], + "nextSteps": [ + { + "slug": "process-radius-sessions-end" + } + ], + "inputs": [ + { + "key": "iterable", + "type": "FUNCTION", + "value": "({ secrets, state, utils, variables }) => {\n const devices = state.steps[\"find-inventory-by-mac\"].output;\n return [...devices.byodDevicesToCreate, ...devices.devicesToSync]\n}" + } + ] + }, + { + "name": "Create BYOD Devices", + "slug": "create-byod-devices", + "type": "PARALLEL_LOOP", + "nodeSlug": "parallel-loop", + "parentParallelLoopSlug": "process-radius-sessions", + "initialSteps": [ + { + "slug": "get-account-id-from-radius-username" + } + ], + "nextSteps": [ + { + "slug": "assign-ips-to-macs" + } + ], + "inputs": [ + { + "key": "iterable", + "type": "FUNCTION", + "value": "({ secrets, state, utils, variables }) => {\n return state.steps[\"find-inventory-by-mac\"].output.byodDevicesToCreate;\n}" + } + ] + }, + { + "name": "Create BYOD using MAC", + "slug": "create-byod-using-mac", + "type": "GAIIA_API_GATEWAY_V1_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-public-api-gateway-graphql-request", + "parentParallelLoopSlug": "create-byod-devices", + "allowSkipExecution": false, + "inputs": [ + { + "key": "query", + "type": "LITERAL", + "value": "string:mutation AddInventoryItems($input: CreateInventoryItemsInput!) {\n addInventoryItems(input: $input) {\n inventoryItemsCreationResult {\n addedItems {\n id\n }\n }\n errors {\n code\n message\n payload {\n item {\n fields {\n data\n modelFieldId\n }\n }\n validationFailures {\n errorCode\n message\n modelFieldId\n }\n }\n }\n }\n}" + }, + { + "key": "variables", + "type": "FUNCTION", + "value": "({ secrets, state, utils, variables }) => {\n const { accountId } = state.steps['get-account-id-from-radius-username'].output.accounts[0];\n const model = state.steps['find-customer-provided-router-model'].output;\n const macAddressField = model.fields.nodes.find((field) => field.name === 'MAC Address');\n const radiusSession = state.currentParallelLoopIterations['create-byod-devices'];\n const macAddress = radiusSession.callingstationid\n const cleanedMac = macAddress.startsWith('1:') ? macAddress.slice(2) : macAddress;\n // Pad each segment to 2 digits. Example \"0:31:92:47:71:dc\" -> \"00:31:92:47:71:dc\"\n const formattedMac = cleanedMac\n .split(':')\n .map((segment) => segment.padStart(2, '0'))\n .join(':');\n\n return {\n input: {\n items: [\n {\n fields: [\n {\n modelFieldId: macAddressField.id,\n data: formattedMac\n }\n ]\n }\n ],\n modelId: model.id,\n equipmentConditionType: 'NEW',\n purchasePriceInCents: 0,\n status: 'FUNCTIONAL',\n assignation: {\n assigneeId: accountId,\n assigneeType: 'ACCOUNT'\n }\n }\n };\n}" + } + ], + "nextSteps": [ + { + "slug": "create-byod-devices-end" + } + ] + }, + { + "name": "Find all Active RADIUS Sessions", + "slug": "find-all-active-radius-sessions", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "free-radius-mysql-query", + "allowSkipExecution": false, + "outputMapper": "({ output, secrets, state, utils, variables }) => {\n return output.results;\n}", + "inputs": [ + { + "key": "query", + "type": "LITERAL", + "value": "string:SELECT username, callingstationid, framedipaddress FROM radacct WHERE callingstationid != '' AND acctstoptime IS NULL AND username != '' GROUP BY username, callingstationid, framedipaddress" + }, + { + "key": "values", + "type": "FUNCTION", + "value": "({ secrets, state, utils, variables }) => {\n return [];\n}" + } + ], + "nextSteps": [ + { + "slug": "find-all-expired-radius-sessions" + } + ] + }, + { + "name": "Find all Expired RADIUS sessions", + "slug": "find-all-expired-radius-sessions", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "free-radius-mysql-query", + "allowSkipExecution": false, + "outputMapper": "({ output, secrets, state, utils, variables }) => {\n return output.results;\n}", + "inputs": [ + { + "key": "instanceName", + "type": "LITERAL", + "value": "string:Default" + }, + { + "key": "query", + "type": "LITERAL", + "value": "string:SELECT username, callingstationid, framedipaddress FROM radacct WHERE callingstationid != '' AND acctstoptime > DATE_SUB(NOW(),INTERVAL 1 DAY) AND username != '' GROUP BY username,callingstationid,framedipaddress" + }, + { + "key": "values", + "type": "FUNCTION", + "value": "({ secrets, state, utils, variables }) => {\n return [];\n}" + } + ], + "nextSteps": [ + { + "slug": "process-radius-sessions" + } + ] + }, + { + "name": "Find Customer Provided Router Model", + "slug": "find-customer-provided-router-model", + "type": "GAIIA_API_GATEWAY_V1_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-public-api-gateway-graphql-request", + "parentParallelLoopSlug": "process-radius-sessions", + "allowSkipExecution": false, + "outputMapper": "({ output, secrets, state, utils, variables }) => {\n return output.response.inventoryModels.nodes[0];\n}", + "inputs": [ + { + "key": "query", + "type": "LITERAL", + "value": "string:query GetInventoryModels($filters: InventoryModelQueryFilters) {\n inventoryModels(filters: $filters) {\n nodes {\n id\n name\n fields {\n nodes {\n id\n name\n }\n }\n }\n }\n}" + }, + { + "key": "variables", + "type": "FUNCTION", + "value": "({ secrets, state, utils, variables }) => {\n return {\n filters: {\n name: {\n equals: \"Customer Provided Router\"\n }\n }\n };\n}" + } + ], + "nextSteps": [ + { + "slug": "create-byod-devices" + } + ] + }, + { + "name": "Find Inventory by MAC", + "slug": "find-inventory-by-mac", + "type": "GAIIA_API_GATEWAY_V1_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-public-api-gateway-graphql-request", + "parentParallelLoopSlug": "process-radius-sessions", + "allowSkipExecution": false, + "outputMapper": "({ output, secrets, state, utils, variables }) => {\n const radiusSessions = state.currentParallelLoopIterations['process-radius-sessions'];\n const normalizeMac = (callingstationid: string): string =>\n callingstationid\n .replace(/^1:/, '')\n .split(':')\n .map((segment) => segment.padStart(2, '0'))\n .join(':')\n .toUpperCase();\n\n const inventoryItems = output.response.inventoryItems.nodes.map((item) => {\n const fields = item.fields.nodes;\n const serialNumber = fields.find((f) => f.modelField.name.toUpperCase() === 'S/N')?.data ?? '';\n const macAddress = fields.find((f) => f.modelField.name.toUpperCase() === 'PORT 1 MAC')?.data ?? '';\n\n return {\n modelName: item.model.name,\n manufacturerName: item.model.manufacturer.name,\n assignation: item.assignation.assignee,\n serialNumber,\n macAddress\n };\n });\n //const assignedInventoryItems = inventoryItems.filter((item) => item.assignation.__typename.toLowerCase() === 'account');\n const macAddresses = new Set(inventoryItems.map((item) => item.macAddress.toUpperCase()));\n\n // Get the list of BYOD Devices from the current chunk\n const byodDevicesToCreateWithDuplicates = radiusSessions.filter((session) => {\n return !macAddresses.has(normalizeMac(session.callingstationid));\n });\n const byodDevicesToCreate = [\n ...new Map(byodDevicesToCreateWithDuplicates.map((device) => [`${device.username}-${device.callingstationid}-${device.framedipaddress}`, device])).values()\n ];\n\n // Get the list of pre-existing devices in Gaiia\n const devicesToSyncWithDuplicates = radiusSessions\n .filter((session) => {\n return macAddresses.has(normalizeMac(session.callingstationid));\n })\n .map((session) => {\n const inventoryItem = inventoryItems.find((item) => item.macAddress === normalizeMac(session.callingstationid));\n if (!inventoryItem) {\n utils.fail(`No inventory item found for MAC: ${session.callingstationid}`, 'INVENTORY_ITEM_NOT_FOUND');\n}\n return {\n callingstationid: inventoryItem?.macAddress,\n //accountId: inventoryItem?.assignation['id'],\n framedipaddress: session.framedipaddress,\n expired: session.expired\n };\n });\n const devicesToSync = [...new Map(devicesToSyncWithDuplicates.map((device) => [`${device.callingstationid}-${device.framedipaddress}`, device])).values()];\n\n return {\n byodDevicesToCreate,\n devicesToSync\n };\n}", + "inputs": [ + { + "key": "query", + "type": "LITERAL", + "value": "string:query Nodes($filters: InventoryItemsQueryFilters) {\n inventoryItems(filters: $filters) {\n nodes {\n id\n model {\n name\n category {\n name\n }\n manufacturer {\n name\n }\n }\n fields {\n nodes {\n data\n modelField {\n name\n }\n id\n }\n }\n assignation {\n id\n assignee {\n __typename\n ... on Account {\n id\n name\n }\n }\n }\n }\n }\n}" + }, + { + "key": "variables", + "type": "FUNCTION", + "value": "({ secrets, state, utils, variables }) => {\n const macAddresses = state.currentParallelLoopIterations['process-radius-sessions'].map((account) => account.username);;\n return {\n filters: {\n fieldData: {\n in: macAddresses\n }\n }\n };\n}" + } + ], + "nextSteps": [ + { + "slug": "if-there-are-byod-devices-to-create" + } + ] + }, + { + "name": "Get Account ID from RADIUS Username", + "slug": "get-account-id-from-radius-username", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "get-account-id-from-radius-username", + "parentParallelLoopSlug": "create-byod-devices", + "allowSkipExecution": false, + "inputs": [ + { + "key": "username", + "type": "FUNCTION", + "value": "({ secrets, state, utils, variables }) => {\n return state.currentParallelLoopIterations['create-byod-devices'].username;\n}" + } + ], + "nextSteps": [ + { + "slug": "create-byod-devices-end", + "errorCodes": [ + "USERNAME_NOT_FOUND" + ] + }, + { + "slug": "create-byod-using-mac" + } + ] + }, + { + "name": "If there are BYOD devices to create", + "slug": "if-there-are-byod-devices-to-create", + "type": "CONDITION", + "nodeSlug": "condition", + "parentParallelLoopSlug": "process-radius-sessions", + "condition": "({ secrets, state, utils, variables }) => {\n return state.steps['find-inventory-by-mac'].output.byodDevicesToCreate.length > 0;\n}", + "elseSteps": [ + { + "slug": "assign-ips-to-macs" + } + ], + "thenSteps": [ + { + "slug": "find-customer-provided-router-model" + } + ] + }, + { + "name": "Process RADIUS sessions", + "slug": "process-radius-sessions", + "type": "PARALLEL_LOOP", + "nodeSlug": "parallel-loop", + "initialSteps": [ + { + "slug": "find-inventory-by-mac" + } + ], + "maxConcurrentIterations": 5, + "nextSteps": [ + { + "slug": "end" + } + ], + "outputMapper": "({ state }) => state.steps['find-inventory-by-mac'].output", + "inputs": [ + { + "key": "iterable", + "type": "FUNCTION", + "value": "({ secrets, state, utils, variables }) => {\n const CHUNK_SIZE = 1000;\n const activeSessions = state.steps['find-all-active-radius-sessions'].output;\n const expiredSessions = state.steps['find-all-expired-radius-sessions'].output;\n // remove expired sessions that are still active for the same MAC/IP/username\n const expiredInactiveSessions = expiredSessions.filter(\n (expiredSession) =>\n !activeSessions.some(\n (activeSession) =>\n activeSession.callingstationid === expiredSession.callingstationid &&\n activeSession.framedipaddress === expiredSession.framedipaddress &&\n activeSession.username === expiredSession.username\n )\n );\n const allSessions = [\n ...activeSessions.map((session) => ({\n ...session,\n expired: false\n })),\n ...expiredInactiveSessions.map((session) => ({\n ...session,\n expired: true\n }))\n ];\n const chunkedSessions: {\n username: string;\n callingstationid: string;\n framedipaddress: string;\n expired: boolean;\n }[][] = [];\n for (let index = 0; index < allSessions.length; index += CHUNK_SIZE) {\n chunkedSessions.push(allSessions.slice(index, index + CHUNK_SIZE));\n }\n return chunkedSessions;\n}" + } + ] + } + ], + "trigger": { + "nextSteps": [ + { + "slug": "find-all-active-radius-sessions" + } + ], + "type": "MANUAL" + } + } + }, + "Get NetBox prefix synchronization settings": { + "id": "workflow_vWK4ZwVfC2DVfrNdvdGVdk", + "description": null, + "definition": { + "trigger": { + "inputJsonSchema": { + "type": "object", + "properties": { + "instanceName": { + "type": "string" + } + }, + "required": [ + "instanceName" + ] + }, + "nextSteps": [ + { + "slug": "get-prefix-synchronization-settings" + } + ], + "type": "MANUAL" + }, + "outputMapper": { + "function": "({ state }) => ({ ...state.steps[\"get-prefix-synchronization-settings\"].output })", + "type": "JSON" + }, + "steps": [ + { + "name": "get-prefix-synchronization-settings", + "slug": "get-prefix-synchronization-settings", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:({ state }) => state", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ], + "outputMapper": "async ({ state, utils }) => {\n const instanceId = await utils.integration.datastore.getItem(`instances.${state.input.instanceName}.id`);\n const prefixSynchronizationSettingsString = await utils.integration.datastore.getItem(`instances.${instanceId}.prefixSynchronization`);\n if (prefixSynchronizationSettingsString) {\n try {\n return JSON.parse(prefixSynchronizationSettingsString);\n } catch (error) {\n return utils.fail(\"Failed to parse prefix synchronization settings\", \"INVALID_JSON_FORMAT\", { error });\n }\n }\n return { ipamRoles: [], isEnabled: false };\n }" + } + ] + } + }, + "FreeRADIUS MySQL Transaction": { + "id": "workflow_i8Y7d5CWeVzmuEANTatCDe", + "description": null, + "definition": { + "trigger": { + "inputJsonSchema": { + "type": "object", + "properties": { + "instanceName": { + "type": "string", + "description": "The name of the FreeRADIUS instance to use. If not provided, uses the first instance." + }, + "queries": { + "type": "array", + "description": "Array of queries to execute in the transaction", + "items": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The SQL query to execute" + }, + "values": { + "type": "array", + "description": "The values to use in the prepared statement", + "items": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + } + } + }, + "required": [ + "query" + ], + "additionalProperties": false + } + } + }, + "required": [ + "queries" + ], + "additionalProperties": false + }, + "nextSteps": [ + { + "slug": "fetch-instance-database-config" + } + ], + "type": "MANUAL" + }, + "outputMapper": { + "function": "({ state }) => state.steps[\"execute-mysql-transaction\"].output", + "type": "JSON" + }, + "steps": [ + { + "name": "fetch-instance-database-config", + "slug": "fetch-instance-database-config", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:async ({ state, utils }) => {\n const { instanceName } = state.input;\n let instanceId;\n if (!instanceName) {\n const instancesId = await utils.integration.datastore.getItem(\"instances\");\n if (!instancesId) {\n return utils.fail(\"No instances found in datastore\", \"NO_INSTANCES_FOUND\");\n }\n const instanceIds = instancesId.split(\",\");\n if (instanceIds.length === 0) {\n return utils.fail(\"No instance ids found\", \"NO_INSTANCE_IDS_FOUND\");\n }\n instanceId = instanceIds[0]?.trim();\n } else {\n instanceId = await utils.integration.datastore.getItem(`instances.${instanceName}.id`);\n if (!instanceId) {\n return utils.fail(\"Invalid instance name provided\", \"INVALID_INSTANCE_NAME\");\n }\n }\n const [databaseHost, databasePort, databaseName, databaseUsername, databasePassword] = await Promise.all([\n utils.integration.datastore.getItem(`instances.${instanceId}.databaseHost`),\n utils.integration.datastore.getItem(`instances.${instanceId}.databasePort`),\n utils.integration.datastore.getItem(`instances.${instanceId}.databaseName`),\n utils.integration.datastore.getItem(`instances.${instanceId}.databaseUsername`),\n utils.integration.datastore.getItem(`instances.${instanceId}.databasePassword`)\n ]);\n if (!databaseHost || !databasePort || !databaseName || !databaseUsername || !databasePassword) {\n return utils.fail(\"Incomplete instance configuration\", \"INCOMPLETE_INSTANCE_CONFIG\");\n }\n return {\n databaseHost,\n databasePort: Number(databasePort),\n databaseName,\n databaseUsername,\n databasePassword\n };\n }", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "execute-mysql-transaction" + } + ], + "outputMapper": "({ output }) => output" + }, + { + "name": "execute-mysql-transaction", + "slug": "execute-mysql-transaction", + "type": "MYSQL_TRANSACTION", + "nodeSlug": "mysql-transaction", + "inputs": [ + { + "type": "FUNCTION", + "value": "({ state }) => state.steps[\"fetch-instance-database-config\"].output.databaseHost", + "key": "host" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.steps[\"fetch-instance-database-config\"].output.databasePort", + "key": "port" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.steps[\"fetch-instance-database-config\"].output.databaseName", + "key": "database" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.steps[\"fetch-instance-database-config\"].output.databaseUsername", + "key": "user" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.steps[\"fetch-instance-database-config\"].output.databasePassword", + "key": "password" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.input.queries", + "key": "queries" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ], + "outputMapper": "({ output }) => output" + } + ] + } + }, + "Test Workflow": { + "id": "workflow_jxqCqU1D4Uwr2HgnRm7Bbt", + "description": null, + "definition": { + "steps": [ + { + "name": "Get Account ID from RADIUS Username", + "slug": "get-account-id-from-radius-username", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "get-account-id-from-radius-username", + "allowSkipExecution": false, + "inputs": [ + { + "key": "username", + "type": "LITERAL", + "value": "string:jenniferb" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ] + } + ], + "trigger": { + "nextSteps": [ + { + "slug": "get-account-id-from-radius-username" + } + ], + "type": "MANUAL" + } + } + }, + "FreeRADIUS Send COA Packet": { + "id": "workflow_ai7uktpC7Q7JA4BHnQvq7P", + "description": null, + "definition": { + "trigger": { + "inputJsonSchema": { + "type": "object", + "properties": { + "attributes": { + "type": "object", + "description": "The RADIUS attributes to include in the packet", + "additionalProperties": { + "type": "string" + } + }, + "instanceName": { + "type": "string", + "description": "The name of the FreeRADIUS instance to use" + } + }, + "required": [ + "attributes", + "instanceName" + ], + "additionalProperties": false + }, + "nextSteps": [ + { + "slug": "get-instance-config" + } + ], + "type": "MANUAL" + }, + "outputMapper": { + "function": "({ state }) => ({\n success: state.steps[\"send-coa-packet\"].output.success\n })", + "type": "JSON" + }, + "steps": [ + { + "name": "get-instance-config", + "slug": "get-instance-config", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:async ({ state, utils }) => {\n const { instanceName } = state.input;\n let instanceId;\n if (!instanceName) {\n const instancesId = await utils.integration.datastore.getItem(\"instances\");\n if (!instancesId) {\n return utils.fail(\"No instances found in datastore\", \"NO_INSTANCES_FOUND\");\n }\n const instanceIds = instancesId.split(\",\");\n if (instanceIds.length === 0) {\n return utils.fail(\"No instance ids found\", \"NO_INSTANCE_IDS_FOUND\");\n }\n instanceId = instanceIds[0]?.trim();\n } else {\n instanceId = await utils.integration.datastore.getItem(`instances.${instanceName}.id`);\n if (!instanceId) {\n return utils.fail(\"Invalid instance name provided\", \"INVALID_INSTANCE_NAME\");\n }\n }\n const [radiusServerHost, coaPort, coaSecret] = await Promise.all([\n utils.integration.datastore.getItem(`instances.${instanceId}.radiusServerHost`),\n utils.integration.datastore.getItem(`instances.${instanceId}.coaPort`),\n utils.integration.datastore.getItem(`instances.${instanceId}.coaSecret`)\n ]);\n if (!radiusServerHost || !coaPort || !coaSecret) {\n return utils.fail(\"Incomplete COA configuration\", \"INCOMPLETE_COA_CONFIG\");\n }\n return {\n radiusServerHost,\n coaPort: Number(coaPort),\n coaSecret\n };\n }", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "send-coa-packet" + } + ], + "outputMapper": "({ output }) => output" + }, + { + "name": "send-coa-packet", + "slug": "send-coa-packet", + "type": "UDP4_REQUEST", + "nodeSlug": "udp4-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => \"base64\"", + "key": "encoding" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.steps[\"get-instance-config\"].output.radiusServerHost", + "key": "host" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.steps[\"get-instance-config\"].output.coaPort", + "key": "port" + }, + { + "type": "FUNCTION", + "value": "({ state }) => {\n const attributes = Object.entries(state.input.attributes);\n const { coaSecret: secret } = state.steps[\"get-instance-config\"].output;\n const DISCONNECT_REQUEST_CODE = 40;\n const AUTH_LENGTH = 16;\n const ATTRIBUTES = {\n \"User-Name\": { id: 1, name: \"User-Name\", type: \"string\" },\n \"NAS-IP-Address\": { id: 4, name: \"NAS-IP-Address\", type: \"ipaddr\" },\n \"NAS-Port\": { id: 5, name: \"NAS-Port\", type: \"integer\" },\n \"Framed-IP-Address\": { id: 8, name: \"Framed-IP-Address\", type: \"ipaddr\" },\n \"Called-Station-Id\": { id: 30, name: \"Called-Station-Id\", type: \"string\" },\n \"Calling-Station-Id\": { id: 31, name: \"Calling-Station-Id\", type: \"string\" },\n \"NAS-Identifier\": { id: 32, name: \"NAS-Identifier\", type: \"string\" },\n \"Acct-Session-Id\": { id: 44, name: \"Acct-Session-Id\", type: \"string\" },\n \"Acct-Multi-Session-Id\": { id: 50, name: \"Acct-Multi-Session-Id\", type: \"string\" },\n \"Event-Timestamp\": { id: 55, name: \"Event-Timestamp\", type: \"date\" },\n \"NAS-Port-Type\": {\n id: 61,\n name: \"NAS-Port-Type\",\n type: \"integer\",\n enumValues: {\n Async: 0,\n Sync: 1,\n ISDN: 2,\n \"ISDN-V120\": 3,\n \"ISDN-V110\": 4,\n Virtual: 5,\n PIAFS: 6,\n \"HDLC-Clear-Channel\": 7,\n \"X.25\": 8,\n \"X.75\": 9,\n \"G.3-Fax\": 10,\n SDSL: 11,\n \"ADSL-CAP\": 12,\n \"ADSL-DMT\": 13,\n IDSL: 14,\n Ethernet: 15,\n xDSL: 16,\n Cable: 17,\n \"Wireless-Other\": 18,\n \"Wireless-802.11\": 19,\n \"Token-Ring\": 20,\n FDDI: 21\n }\n },\n \"Message-Authenticator\": { id: 80, name: \"Message-Authenticator\", type: \"octets\" },\n \"NAS-Port-Id\": { id: 87, name: \"NAS-Port-Id\", type: \"string\" },\n \"Chargeable-User-Identity\": { id: 89, name: \"Chargeable-User-Identity\", type: \"string\" }\n };\n const stringToUint8Array = (string_) => new TextEncoder().encode(string_);\n const concatUint8Arrays = (...arrays) => {\n const totalLength = arrays.reduce((accumulator, array) => accumulator + array.length, 0);\n const result2 = new Uint8Array(totalLength);\n let offset2 = 0;\n for (const array of arrays) {\n result2.set(array, offset2);\n offset2 += array.length;\n }\n return result2;\n };\n const md5 = (data) => {\n const K = new Uint32Array([\n 3614090360,\n 3905402710,\n 606105819,\n 3250441966,\n 4118548399,\n 1200080426,\n 2821735955,\n 4249261313,\n 1770035416,\n 2336552879,\n 4294925233,\n 2304563134,\n 1804603682,\n 4254626195,\n 2792965006,\n 1236535329,\n 4129170786,\n 3225465664,\n 643717713,\n 3921069994,\n 3593408605,\n 38016083,\n 3634488961,\n 3889429448,\n 568446438,\n 3275163606,\n 4107603335,\n 1163531501,\n 2850285829,\n 4243563512,\n 1735328473,\n 2368359562,\n 4294588738,\n 2272392833,\n 1839030562,\n 4259657740,\n 2763975236,\n 1272893353,\n 4139469664,\n 3200236656,\n 681279174,\n 3936430074,\n 3572445317,\n 76029189,\n 3654602809,\n 3873151461,\n 530742520,\n 3299628645,\n 4096336452,\n 1126891415,\n 2878612391,\n 4237533241,\n 1700485571,\n 2399980690,\n 4293915773,\n 2240044497,\n 1873313359,\n 4264355552,\n 2734768916,\n 1309151649,\n 4149444226,\n 3174756917,\n 718787259,\n 3951481745\n ]);\n const S = [\n 7,\n 12,\n 17,\n 22,\n 7,\n 12,\n 17,\n 22,\n 7,\n 12,\n 17,\n 22,\n 7,\n 12,\n 17,\n 22,\n 5,\n 9,\n 14,\n 20,\n 5,\n 9,\n 14,\n 20,\n 5,\n 9,\n 14,\n 20,\n 5,\n 9,\n 14,\n 20,\n 4,\n 11,\n 16,\n 23,\n 4,\n 11,\n 16,\n 23,\n 4,\n 11,\n 16,\n 23,\n 4,\n 11,\n 16,\n 23,\n 6,\n 10,\n 15,\n 21,\n 6,\n 10,\n 15,\n 21,\n 6,\n 10,\n 15,\n 21,\n 6,\n 10,\n 15,\n 21\n ];\n const originalLength = data.length;\n const bitLength = BigInt(originalLength * 8);\n let paddingLength = 64 - (originalLength + 8) % 64;\n if (paddingLength === 0)\n paddingLength = 64;\n const paddedData = new Uint8Array(originalLength + paddingLength + 8);\n paddedData.set(data);\n paddedData[originalLength] = 128;\n const view = new DataView(paddedData.buffer);\n view.setUint32(paddedData.length - 8, Number(bitLength & BigInt(4294967295)), true);\n view.setUint32(paddedData.length - 4, Number(bitLength >> BigInt(32) & BigInt(4294967295)), true);\n let a0 = 1732584193;\n let b0 = 4023233417;\n let c0 = 2562383102;\n let d0 = 271733878;\n const rotateLeft = (x, n) => (x << n | x >>> 32 - n) >>> 0;\n for (let index = 0; index < paddedData.length; index += 64) {\n const M = new Uint32Array(16);\n for (let index_ = 0; index_ < 16; index_++) {\n M[index_] = view.getUint32(index + index_ * 4, true);\n }\n let A = a0, B = b0, C = c0, D = d0;\n for (let index2 = 0; index2 < 64; index2++) {\n let F, g;\n if (index2 < 16) {\n F = B & C | ~B & D;\n g = index2;\n } else if (index2 < 32) {\n F = D & B | ~D & C;\n g = (5 * index2 + 1) % 16;\n } else if (index2 < 48) {\n F = B ^ C ^ D;\n g = (3 * index2 + 5) % 16;\n } else {\n F = C ^ (B | ~D);\n g = 7 * index2 % 16;\n }\n F = F + A + K[index2] + M[g] >>> 0;\n A = D;\n D = C;\n C = B;\n B = B + rotateLeft(F, S[index2]) >>> 0;\n }\n a0 = a0 + A >>> 0;\n b0 = b0 + B >>> 0;\n c0 = c0 + C >>> 0;\n d0 = d0 + D >>> 0;\n }\n const result2 = new Uint8Array(16);\n const resultView = new DataView(result2.buffer);\n resultView.setUint32(0, a0, true);\n resultView.setUint32(4, b0, true);\n resultView.setUint32(8, c0, true);\n resultView.setUint32(12, d0, true);\n return result2;\n };\n const hmacMd5 = (key, message) => {\n const blockSize = 64;\n if (key.length > blockSize) {\n key = md5(key);\n }\n const paddedKey = new Uint8Array(blockSize);\n paddedKey.set(key);\n const ipad = new Uint8Array(blockSize);\n const opad = new Uint8Array(blockSize);\n for (let index = 0; index < blockSize; index++) {\n ipad[index] = paddedKey[index] ^ 54;\n opad[index] = paddedKey[index] ^ 92;\n }\n const innerHash = md5(concatUint8Arrays(ipad, message));\n return md5(concatUint8Arrays(opad, innerHash));\n };\n const encodeAttribute = (attribute) => {\n const [nameOrId, value] = attribute;\n if (typeof nameOrId === \"number\") {\n const rawValue = value;\n const result3 = new Uint8Array(2 + rawValue.length);\n result3[0] = nameOrId;\n result3[1] = 2 + rawValue.length;\n result3.set(rawValue, 2);\n return result3;\n }\n const attrDef = ATTRIBUTES[nameOrId];\n if (!attrDef) {\n throw new Error(`Unknown attribute: ${nameOrId}`);\n }\n let encodedValue;\n switch (attrDef.type) {\n case \"string\":\n case \"text\":\n encodedValue = stringToUint8Array(value);\n break;\n case \"ipaddr\": {\n const parts = value.split(\".\").map(Number);\n if (parts.length !== 4 || parts.some((p) => Number.isNaN(p) || p < 0 || p > 255)) {\n throw new Error(`Invalid IP address: ${value}`);\n }\n encodedValue = new Uint8Array(parts);\n break;\n }\n case \"integer\": {\n let numberValue;\n numberValue = typeof value === \"string\" && attrDef.enumValues?.[value] !== void 0 ? attrDef.enumValues[value] : value;\n encodedValue = new Uint8Array(4);\n new DataView(encodedValue.buffer).setUint32(0, numberValue, false);\n break;\n }\n case \"date\": {\n const timestamp = Math.floor(value.getTime() / 1e3);\n encodedValue = new Uint8Array(4);\n new DataView(encodedValue.buffer).setUint32(0, timestamp, false);\n break;\n }\n case \"octets\":\n encodedValue = value;\n break;\n default:\n throw new Error(`Unsupported attribute type: ${attrDef.type}`);\n }\n const result2 = new Uint8Array(2 + encodedValue.length);\n result2[0] = attrDef.id;\n result2[1] = 2 + encodedValue.length;\n result2.set(encodedValue, 2);\n return result2;\n };\n const packet = new Uint8Array(4096);\n let offset = 0;\n packet[offset++] = DISCONNECT_REQUEST_CODE;\n const identifier = Math.floor(Math.random() * 256);\n if (identifier > 255) {\n throw new Error(\"Identifier must be 0-255\");\n }\n packet[offset++] = identifier;\n offset += 2;\n const authenticatorOffset = offset;\n offset += AUTH_LENGTH;\n let messageAuthenticatorOffset = -1;\n for (const attribute of attributes) {\n if (attribute[0] === \"Message-Authenticator\" || attribute[0] === 80) {\n messageAuthenticatorOffset = offset + 2;\n }\n const encoded = encodeAttribute(attribute);\n packet.set(encoded, offset);\n offset += encoded.length;\n }\n new DataView(packet.buffer).setUint16(2, offset, false);\n const result = packet.slice(0, offset);\n const secretBytes = stringToUint8Array(secret);\n const authenticator = md5(concatUint8Arrays(result, secretBytes));\n for (const [index, byte] of authenticator.entries()) {\n result[authenticatorOffset + index] = byte;\n }\n if (messageAuthenticatorOffset !== -1) {\n const messageAuthenticator = hmacMd5(secretBytes, result);\n for (const [index, byte] of messageAuthenticator.entries()) {\n result[messageAuthenticatorOffset + index] = byte;\n }\n }\n return Buffer.from(result).toString(\"base64\");\n }", + "key": "message" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ], + "outputMapper": "() => ({\n success: true\n })" + } + ] + } + }, + "Update RADIUS Account Fields": { + "id": "workflow_4NGHRz53T8XpEDPxCuCvrb", + "description": null, + "definition": { + "trigger": { + "inputJsonSchema": { + "type": "object", + "properties": { + "instanceName": { + "type": "string", + "description": "The FreeRADIUS instance name" + }, + "username": { + "type": "string", + "description": "The RADIUS username" + }, + "framedIpAddress": { + "type": [ + "string", + "null" + ], + "description": "The Framed-IP-Address attribute (IPv4 address). Set to null to remove." + }, + "framedRoute": { + "type": [ + "string", + "null" + ], + "description": "The Framed-Route attribute (without gateway). Set to null to remove." + } + }, + "required": [ + "instanceName", + "username" + ], + "additionalProperties": false + }, + "nextSteps": [ + { + "slug": "validate-input" + } + ], + "type": "MANUAL" + }, + "outputMapper": { + "function": "({ state, utils }) => {\n if (\"handle-error\" in state.steps) {\n const error = state.steps[\"handle-error\"];\n utils.fail(error.output.message, error.output.code, error.output.payload);\n } else if (\"update-account-fields\" in state.steps) {\n const {\n output: { updatedFields }\n } = state.steps[\"update-account-fields\"];\n return {\n success: true,\n updatedFields\n };\n }\n return {\n success: true,\n updatedFields: []\n };\n }", + "type": "JSON" + }, + "steps": [ + { + "name": "validate-input", + "slug": "validate-input", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:({ state, utils }) => {\n const { framedIpAddress, framedRoute } = state.input;\n const ipv4Regex = /^(?:(?:25[0-5]|2[0-4]\\d|[01]?\\d{1,2})\\.){3}(?:25[0-5]|2[0-4]\\d|[01]?\\d{1,2})$/;\n if (framedIpAddress !== void 0 && framedIpAddress !== null && framedIpAddress !== \"\" && !ipv4Regex.test(framedIpAddress)) {\n return utils.fail(\"Framed-IP-Address must be a valid IPv4 address\", \"INVALID_FRAMED_IP_ADDRESS\");\n }\n if (framedRoute !== void 0 && framedRoute !== null && framedRoute !== \"\") {\n const ipPart = framedRoute.split(\"/\")[0].trim();\n if (!ipv4Regex.test(ipPart)) {\n return utils.fail(\"Framed-Route must contain a valid IPv4 address\", \"INVALID_FRAMED_ROUTE\");\n }\n }\n return {\n validated: true\n };\n }", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "fetch-current-and-check-availability" + }, + { + "slug": "handle-error", + "errorCodes": [ + "*" + ] + } + ], + "outputMapper": "({ output }) => output" + }, + { + "name": "fetch-current-and-check-availability", + "slug": "fetch-current-and-check-availability", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "free-radius-mysql-transaction", + "inputs": [ + { + "type": "FUNCTION", + "value": "({ state }) => state.input.instanceName", + "key": "instanceName" + }, + { + "type": "FUNCTION", + "value": "({ state }) => {\n const { username, framedIpAddress, framedRoute } = state.input;\n const queries = [\n {\n query: `SELECT rr.attribute, rr.value FROM radreply rr WHERE rr.username = ? AND rr.attribute IN ('Framed-IP-Address', 'Framed-Route') AND rr.value IS NOT NULL;`,\n values: [username]\n }\n ];\n if (framedIpAddress !== void 0 && framedIpAddress !== null && framedIpAddress !== \"\") {\n queries.push({\n query: 'SELECT COUNT(*) as count FROM radreply WHERE attribute = \"Framed-IP-Address\" AND value = ? AND username <> ?',\n values: [framedIpAddress, username]\n });\n }\n if (framedRoute !== void 0 && framedRoute !== null && framedRoute !== \"\") {\n queries.push({\n query: 'SELECT COUNT(*) as count FROM radreply WHERE attribute = \"Framed-Route\" AND value = ? AND username <> ?',\n values: [`${framedRoute} 0.0.0.0 1`, username]\n });\n }\n return queries;\n }", + "key": "queries" + } + ], + "nextSteps": [ + { + "slug": "update-account-fields" + }, + { + "slug": "handle-error", + "errorCodes": [ + "*" + ] + } + ], + "outputMapper": "({ state, output, utils }) => {\n const { framedIpAddress, framedRoute } = state.input;\n const [radreplyResults, ...availabilityResults] = output.results;\n const currentFramedIpAddressRow = radreplyResults?.find((r) => r.attribute === \"Framed-IP-Address\");\n const currentFramedRouteRow = radreplyResults?.find((r) => r.attribute === \"Framed-Route\");\n const currentFramedIpAddress = currentFramedIpAddressRow?.value || null;\n const currentFramedRoute = currentFramedRouteRow?.value || null;\n const currentFramedRouteWithoutGateway = currentFramedRoute ? currentFramedRoute.split(\" \")[0] : null;\n if (framedIpAddress !== void 0 && framedIpAddress !== null && framedIpAddress !== \"\") {\n const [framedIpAddressExists] = availabilityResults.shift();\n if (framedIpAddressExists?.count > 0) {\n return utils.fail(\"Framed-IP-Address is not available\", \"FRAMED_IP_ADDRESS_NOT_AVAILABLE\");\n }\n }\n if (framedRoute !== void 0 && framedRoute !== null && framedRoute !== \"\") {\n const [framedRouteExists] = availabilityResults.shift();\n if (framedRouteExists?.count > 0) {\n return utils.fail(\"Framed-Route is not available\", \"FRAMED_ROUTE_NOT_AVAILABLE\");\n }\n }\n return {\n currentFramedIpAddress,\n currentFramedRoute: currentFramedRouteWithoutGateway\n };\n }" + }, + { + "name": "update-account-fields", + "slug": "update-account-fields", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "free-radius-mysql-transaction", + "inputs": [ + { + "type": "FUNCTION", + "value": "({ state }) => state.input.instanceName", + "key": "instanceName" + }, + { + "type": "FUNCTION", + "value": "({ state }) => {\n const { username, framedIpAddress, framedRoute } = state.input;\n const {\n output: { currentFramedIpAddress, currentFramedRoute }\n } = state.steps[\"fetch-current-and-check-availability\"];\n const currentFramedRouteWithGateway = currentFramedRoute ? `${currentFramedRoute} 0.0.0.0 1` : null;\n const queries = [];\n if (framedIpAddress !== void 0 && framedIpAddress !== currentFramedIpAddress) {\n if (currentFramedIpAddress && (framedIpAddress === null || framedIpAddress === \"\")) {\n queries.push({\n query: `DELETE FROM radreply\n WHERE\n username = ?\n AND attribute = ?\n LIMIT 1`,\n values: [username, \"Framed-IP-Address\"]\n });\n } else if (framedIpAddress && framedIpAddress !== \"\") {\n if (currentFramedIpAddress !== null) {\n queries.push({\n query: `UPDATE radreply\n SET value = ?\n WHERE\n username = ?\n AND attribute = ?\n LIMIT 1`,\n values: [framedIpAddress, username, \"Framed-IP-Address\"]\n });\n } else {\n queries.push({\n query: `INSERT INTO radreply(username, attribute, op, value)\n VALUES (?, ?, ?, ?)`,\n values: [username, \"Framed-IP-Address\", \"=\", framedIpAddress]\n });\n }\n }\n }\n if (framedRoute !== void 0 && framedRoute !== currentFramedRoute) {\n const framedRouteWithGateway = framedRoute && framedRoute !== \"\" ? `${framedRoute} 0.0.0.0 1` : null;\n if (currentFramedRouteWithGateway && !framedRouteWithGateway) {\n queries.push({\n query: `DELETE FROM radreply\n WHERE\n username = ?\n AND attribute = ?\n LIMIT 1`,\n values: [username, \"Framed-Route\"]\n });\n } else if (framedRouteWithGateway) {\n if (currentFramedRouteWithGateway !== null) {\n queries.push({\n query: `UPDATE radreply\n SET value = ?\n WHERE\n username = ?\n AND attribute = ?\n LIMIT 1`,\n values: [framedRouteWithGateway, username, \"Framed-Route\"]\n });\n } else {\n queries.push({\n query: `INSERT INTO radreply(username, attribute, op, value)\n VALUES (?, ?, ?, ?)`,\n values: [username, \"Framed-Route\", \"+=\", framedRouteWithGateway]\n });\n }\n }\n }\n return queries;\n }", + "key": "queries" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ], + "outputMapper": "({ state }) => {\n const { framedIpAddress, framedRoute } = state.input;\n const {\n output: { currentFramedIpAddress, currentFramedRoute }\n } = state.steps[\"fetch-current-and-check-availability\"];\n const updatedFields = [];\n if (framedIpAddress !== void 0 && framedIpAddress !== currentFramedIpAddress) {\n updatedFields.push(\"framedIpAddress\");\n }\n if (framedRoute !== void 0 && framedRoute !== currentFramedRoute) {\n updatedFields.push(\"framedRoute\");\n }\n return {\n updatedFields\n };\n }" + }, + { + "name": "handle-error", + "slug": "handle-error", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:({ state }) => {\n const steps = state.steps;\n return steps[\"fetch-current-and-check-availability\"]?.error || steps[\"validate-input\"]?.error;\n }", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ], + "outputMapper": "({ output }) => output" + } + ] + } + }, + "Import Network Sites": { + "id": "workflow_a2yvCkM3eAEiEfYjaWmjLk", + "description": null, + "definition": { + "trigger": { + "skipExecutionMapper": "async ({ state, utils }) => {\n const { instanceId } = state.trigger.queryParameters;\n if (!instanceId) {\n return true;\n }\n const networkSiteSyncString = await utils.integration.datastore.getItem(`instances.${instanceId}.networkSiteSynchronization`);\n if (!networkSiteSyncString) {\n return true;\n }\n try {\n const networkSiteSync = JSON.parse(networkSiteSyncString);\n if (!networkSiteSync.isEnabled) {\n return true;\n }\n } catch (error) {\n return utils.fail(\"Failed to parse network site synchronization settings\", \"INVALID_JSON_FORMAT\", { error });\n }\n if (state.input.event === \"deleted\") {\n return true;\n }\n return false;\n }", + "inputJsonSchema": { + "type": "object", + "required": [ + "model", + "event", + "data" + ], + "properties": { + "model": { + "type": "string", + "const": "site" + }, + "event": { + "type": "string", + "enum": [ + "created", + "updated", + "deleted" + ] + }, + "data": { + "type": "object", + "required": [ + "id", + "name", + "slug", + "url", + "status", + "custom_fields" + ], + "properties": { + "id": { + "type": "number" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "url": { + "type": "string" + }, + "custom_fields": { + "type": "object" + }, + "physical_address": { + "type": [ + "string", + "null" + ] + }, + "latitude": { + "type": [ + "number", + "null" + ] + }, + "longitude": { + "type": [ + "number", + "null" + ] + }, + "status": { + "type": "object", + "required": [ + "label", + "value" + ], + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + } + } + } + } + } + } + }, + "nextSteps": [ + { + "slug": "get-instance-name-from-id" + } + ], + "type": "WEBHOOK" + }, + "steps": [ + { + "name": "get-instance-name-from-id", + "slug": "get-instance-name-from-id", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:async ({ state, utils }) => {\n const { instanceId } = state.trigger.queryParameters;\n const instanceName = await utils.integration.datastore.getItem(`instances.${instanceId}.name`);\n if (!instanceName) {\n return utils.fail(`Instance with ID ${instanceId} not found`, \"INSTANCE_NOT_FOUND\");\n }\n return instanceName;\n }", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "get-network-site-synchronization-settings" + } + ], + "outputMapper": "({ output }) => output" + }, + { + "name": "get-network-site-synchronization-settings", + "slug": "get-network-site-synchronization-settings", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "netbox-get-network-site-synchronization-settings", + "inputs": [ + { + "type": "FUNCTION", + "value": "({ state }) => state.steps[\"get-instance-name-from-id\"].output", + "key": "instanceName" + } + ], + "nextSteps": [ + { + "slug": "get-network-site-custom-field-definitions" + } + ], + "outputMapper": "({ output, utils }) => {\n if (!output.isEnabled) {\n return utils.fail(\"NetBox network site synchronization is disabled for this instance.\", \"NETWORK_SITE_SYNC_DISABLED\");\n }\n return output;\n }" + }, + { + "name": "get-network-site-custom-field-definitions", + "slug": "get-network-site-custom-field-definitions", + "type": "GAIIA_API_GATEWAY_V1_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-public-api-gateway-graphql-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => {\n // --------------------------------------------------\n // PaginatedNetworkSiteCustomFieldDefinitionsDocument\n // --------------------------------------------------\n const PaginatedNetworkSiteCustomFieldDefinitionsDocument = {\"kind\":\"Document\",\"definitions\":[{\"kind\":\"OperationDefinition\",\"operation\":\"query\",\"name\":{\"kind\":\"Name\",\"value\":\"PaginatedNetworkSiteCustomFieldDefinitions\"},\"variableDefinitions\":[{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"first\"}},\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"Int\"}},\"directives\":[]},{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"after\"}},\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"String\"}},\"directives\":[]},{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"filters\"}},\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"NetworkSiteCustomFieldDefinitionFilters\"}},\"directives\":[]}],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"networkSiteCustomFieldDefinitions\"},\"arguments\":[{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"first\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"first\"}}},{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"after\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"after\"}}},{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"filters\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"filters\"}}}],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"pageInfo\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"hasNextPage\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"endCursor\"},\"arguments\":[],\"directives\":[]}]}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"nodes\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"id\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"name\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"required\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"type\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"createdAt\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"archivedAt\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"options\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"id\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"option\"},\"arguments\":[],\"directives\":[]}]}}]}}]}}]}}],\"loc\":{\"start\":0,\"end\":449}};\n // ------------------------------------------------------------\n\n return PaginatedNetworkSiteCustomFieldDefinitionsDocument;\n}", + "key": "query" + }, + { + "type": "FUNCTION", + "value": "() => ({\n first: 250,\n filters: {\n isArchived: { equals: false }\n }\n })", + "key": "variables" + } + ], + "nextSteps": [ + { + "slug": "get-network-site-custom-statuses" + } + ], + "outputMapper": "({ output }) => {\n const response = output.response;\n return {\n customFieldDefinitions: response.networkSiteCustomFieldDefinitions.nodes\n };\n }" + }, + { + "name": "get-network-site-custom-statuses", + "slug": "get-network-site-custom-statuses", + "type": "GAIIA_API_GATEWAY_V1_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-public-api-gateway-graphql-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => {\n // ---------------------------------\n // NetworkSiteCustomStatusesDocument\n // ---------------------------------\n const NetworkSiteCustomStatusesDocument = {\"kind\":\"Document\",\"definitions\":[{\"kind\":\"OperationDefinition\",\"operation\":\"query\",\"name\":{\"kind\":\"Name\",\"value\":\"NetworkSiteCustomStatuses\"},\"variableDefinitions\":[{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"first\"}},\"type\":{\"kind\":\"NonNullType\",\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"Int\"}}},\"directives\":[]}],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"networkSiteCustomStatuses\"},\"arguments\":[{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"first\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"first\"}}}],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"nodes\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"id\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"name\"},\"arguments\":[],\"directives\":[]}]}}]}}]}}],\"loc\":{\"start\":0,\"end\":146}};\n // ------------------------------------------------------------\n\n return NetworkSiteCustomStatusesDocument;\n}", + "key": "query" + }, + { + "type": "FUNCTION", + "value": "() => ({\n first: 250\n })", + "key": "variables" + } + ], + "nextSteps": [ + { + "slug": "check-datastore-for-site-mapping" + } + ], + "outputMapper": "({ output }) => {\n const response = output.response;\n const statusMap = response.networkSiteCustomStatuses.nodes.reduce(\n (map, status) => ({\n ...map,\n [status.name.toLowerCase()]: status.id\n }),\n {}\n );\n return { statusMap };\n }" + }, + { + "name": "check-datastore-for-site-mapping", + "slug": "check-datastore-for-site-mapping", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:async ({ state }) => state", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "check-has-mapping" + } + ], + "outputMapper": "async ({ state, utils }) => {\n const { integrationId } = state.steps[\"get-network-site-synchronization-settings\"].output;\n const netboxSiteId = state.input.data.id;\n const gaiiaSiteId = await utils.integration.datastore.getItem(`netbox-site-to-gaiia.${integrationId}.${netboxSiteId}`);\n return { gaiiaSiteId };\n }" + }, + { + "name": "check-has-mapping", + "slug": "check-has-mapping", + "nodeSlug": "condition", + "type": "CONDITION", + "condition": "({ state }) => !!state.steps[\"check-datastore-for-site-mapping\"].output.gaiiaSiteId", + "thenSteps": [ + { + "slug": "get-gaiia-network-site-by-id" + } + ], + "elseSteps": [ + { + "slug": "get-gaiia-network-site-by-name" + } + ] + }, + { + "name": "get-gaiia-network-site-by-id", + "slug": "get-gaiia-network-site-by-id", + "type": "GAIIA_API_GATEWAY_V1_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-public-api-gateway-graphql-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => {\n // -------------------\n // NetworkSiteDocument\n // -------------------\n const NetworkSiteDocument = {\"kind\":\"Document\",\"definitions\":[{\"kind\":\"OperationDefinition\",\"operation\":\"query\",\"name\":{\"kind\":\"Name\",\"value\":\"NetworkSite\"},\"variableDefinitions\":[{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"id\"}},\"type\":{\"kind\":\"NonNullType\",\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"GlobalID\"}}},\"directives\":[]},{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"firstFields\"}},\"type\":{\"kind\":\"NonNullType\",\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"Int\"}}},\"directives\":[]}],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"networkSite\"},\"arguments\":[{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"id\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"id\"}}}],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"customStatus\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"id\"},\"arguments\":[],\"directives\":[]}]}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"externalSourceLink\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"externalSourceId\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"integration\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"id\"},\"arguments\":[],\"directives\":[]}]}}]}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"fields\"},\"arguments\":[{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"first\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"firstFields\"}}}],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"nodes\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"data\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"definition\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"id\"},\"arguments\":[],\"directives\":[]}]}}]}}]}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"id\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"name\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"address\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"country\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"line1\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"locality\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"postalCode\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"region\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"latitude\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"longitude\"},\"arguments\":[],\"directives\":[]}]}}]}}]}}],\"loc\":{\"start\":0,\"end\":480}};\n // ------------------------------------------------------------\n\n return NetworkSiteDocument;\n}", + "key": "query" + }, + { + "type": "FUNCTION", + "value": "({ state }) => ({\n id: state.steps[\"check-datastore-for-site-mapping\"].output.gaiiaSiteId,\n firstFields: 250\n })", + "key": "variables" + } + ], + "nextSteps": [ + { + "slug": "get-gaiia-network-site-by-name", + "errorCodes": [ + "STALE_MAPPING" + ] + }, + { + "slug": "merge-network-site-lookup" + } + ], + "outputMapper": "({ output, state, utils }) => {\n const response = output.response;\n const { integrationId } = state.steps[\"get-network-site-synchronization-settings\"].output;\n const netboxSiteId = String(state.input.data.id);\n if (response.networkSite?.externalSourceLink?.integration.id === integrationId && response.networkSite.externalSourceLink.externalSourceId === netboxSiteId) {\n return { existingSite: response.networkSite };\n }\n return utils.fail(\"Datastore mapping exists but site external link does not match\", \"STALE_MAPPING\");\n }" + }, + { + "name": "get-gaiia-network-site-by-name", + "slug": "get-gaiia-network-site-by-name", + "type": "GAIIA_API_GATEWAY_V1_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-public-api-gateway-graphql-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => {\n // ------------------------------------\n // NetworkSitesWithExternalLinkDocument\n // ------------------------------------\n const NetworkSitesWithExternalLinkDocument = {\"kind\":\"Document\",\"definitions\":[{\"kind\":\"OperationDefinition\",\"operation\":\"query\",\"name\":{\"kind\":\"Name\",\"value\":\"NetworkSitesWithExternalLink\"},\"variableDefinitions\":[{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"first\"}},\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"Int\"}},\"directives\":[]},{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"after\"}},\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"String\"}},\"directives\":[]},{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"filters\"}},\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"NetworkSiteFilters\"}},\"directives\":[]},{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"firstFields\"}},\"type\":{\"kind\":\"NonNullType\",\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"Int\"}}},\"directives\":[]}],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"networkSites\"},\"arguments\":[{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"first\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"first\"}}},{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"after\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"after\"}}},{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"filters\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"filters\"}}}],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"edges\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"node\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"customStatus\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"id\"},\"arguments\":[],\"directives\":[]}]}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"externalSourceLink\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"externalSourceId\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"integration\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"id\"},\"arguments\":[],\"directives\":[]}]}}]}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"fields\"},\"arguments\":[{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"first\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"firstFields\"}}}],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"nodes\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"data\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"definition\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"id\"},\"arguments\":[],\"directives\":[]}]}}]}}]}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"id\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"name\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"address\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"country\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"line1\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"locality\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"postalCode\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"region\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"latitude\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"longitude\"},\"arguments\":[],\"directives\":[]}]}}]}}]}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"pageInfo\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"endCursor\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"hasNextPage\"},\"arguments\":[],\"directives\":[]}]}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"totalCount\"},\"arguments\":[],\"directives\":[]}]}}]}}],\"loc\":{\"start\":0,\"end\":802}};\n // ------------------------------------------------------------\n\n return NetworkSitesWithExternalLinkDocument;\n}", + "key": "query" + }, + { + "type": "FUNCTION", + "value": "({ state }) => ({\n filters: {\n name: { equals: state.input.data.name }\n },\n first: 10,\n firstFields: 250\n })", + "key": "variables" + } + ], + "nextSteps": [ + { + "slug": "merge-network-site-lookup" + } + ], + "outputMapper": "({ output, state }) => {\n const response = output.response;\n const netboxSiteName = state.input.data.name;\n const existingSite = response.networkSites.edges.find(\n (edge) => !edge.node.externalSourceLink && edge.node.name.toLowerCase() === netboxSiteName.toLowerCase()\n )?.node;\n return {\n existingSite: existingSite || null\n };\n }" + }, + { + "name": "merge-network-site-lookup", + "slug": "merge-network-site-lookup", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:({ state }) => state", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "check-site-exists" + } + ], + "outputMapper": "({ state }) => {\n const { steps } = state;\n if (\"get-gaiia-network-site-by-id\" in steps && \"output\" in steps[\"get-gaiia-network-site-by-id\"]) {\n return { existingSite: steps[\"get-gaiia-network-site-by-id\"].output.existingSite };\n }\n if (\"get-gaiia-network-site-by-name\" in steps) {\n return { existingSite: steps[\"get-gaiia-network-site-by-name\"].output.existingSite };\n }\n return {\n existingSite: null\n };\n }" + }, + { + "name": "check-site-exists", + "slug": "check-site-exists", + "nodeSlug": "condition", + "type": "CONDITION", + "condition": "({ state }) => !!state.steps[\"merge-network-site-lookup\"].output.existingSite", + "thenSteps": [ + { + "slug": "prepare-network-site-data-for-update" + } + ], + "elseSteps": [ + { + "slug": "prepare-network-site-data-for-create" + } + ] + }, + { + "name": "prepare-network-site-data-for-create", + "slug": "prepare-network-site-data-for-create", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:({ state }) => state", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "create-network-site" + } + ], + "outputMapper": "({ state, utils }) => {\n // ---------------\n // parseUSAddress\n // ---------------\n const parseUSAddress = (netboxSite) => {\n const { physical_address, latitude, longitude } = netboxSite;\n if (!physical_address) {\n return null;\n }\n const postalCodeMatch = physical_address.match(/\\b(\\d{5})(?:-\\d{4})?\\b/);\n if (!postalCodeMatch) {\n return null;\n }\n const stateMatch = physical_address.match(\n /\\b(AL|AK|AZ|AR|CA|CO|CT|DE|FL|GA|HI|ID|IL|IN|IA|KS|KY|LA|ME|MD|MA|MI|MN|MS|MO|MT|NE|NV|NH|NJ|NM|NY|NC|ND|OH|OK|OR|PA|RI|SC|SD|TN|TX|UT|VT|VA|WA|WV|WI|WY|DC|AS|GU|MP|PR|VI)\\b(?=\\s*\\d{5}|\\s*$)/\n );\n if (!stateMatch) {\n return null;\n }\n const cityMatch = physical_address.match(/,\\s*([^,]+?)\\s*,?\\s*[A-Z]{2}\\b/);\n if (!cityMatch?.[1]?.trim()) {\n return null;\n }\n const line1 = physical_address.slice(0, physical_address.indexOf(\",\")).trim();\n if (!line1) {\n return null;\n }\n return {\n country: \"US\",\n line1,\n locality: cityMatch[1].trim(),\n region: stateMatch[1],\n postalCode: postalCodeMatch[1],\n ...typeof latitude === \"number\" && { latitude },\n ...typeof longitude === \"number\" && { longitude }\n };\n };\n\n // ---------------------\n // mapNetBoxCustomFields\n // ---------------------\n const mapNetBoxCustomFields = (netBoxCustomFields, customFieldMappings, customFieldDefinitions, parseGlobalId) => customFieldMappings.map((mapping) => {\n const value = netBoxCustomFields[mapping.netBoxCustomFieldName];\n if (value === void 0) {\n return null;\n }\n const customFieldDefinition = customFieldDefinitions.find((definition) => definition.id === mapping.networkSiteCustomFieldDefinitionId);\n if (!customFieldDefinition) {\n return null;\n }\n switch (customFieldDefinition.type) {\n case \"DROPDOWN\": {\n const optionGlobalId = customFieldDefinition.options?.find((option) => option.option === value)?.id;\n return {\n customFieldDefinitionId: mapping.networkSiteCustomFieldDefinitionId,\n data: optionGlobalId ? parseGlobalId(optionGlobalId).id : null\n };\n }\n case \"NUMBER\": {\n const parsed = typeof value === \"number\" ? value : Number(value);\n return {\n customFieldDefinitionId: mapping.networkSiteCustomFieldDefinitionId,\n data: Number.isNaN(parsed) ? null : parsed\n };\n }\n case \"NUMBER_LIST\": {\n const parsed = typeof value === \"number\" ? value : Number(value);\n return {\n customFieldDefinitionId: mapping.networkSiteCustomFieldDefinitionId,\n data: Number.isNaN(parsed) ? [] : [parsed]\n };\n }\n case \"TEXT\": {\n return {\n customFieldDefinitionId: mapping.networkSiteCustomFieldDefinitionId,\n data: value != null ? String(value) : null\n };\n }\n case \"TEXT_LIST\": {\n return {\n customFieldDefinitionId: mapping.networkSiteCustomFieldDefinitionId,\n data: value != null ? [String(value)] : []\n };\n }\n case \"TOGGLE\": {\n return {\n customFieldDefinitionId: mapping.networkSiteCustomFieldDefinitionId,\n data: value === true || value === \"true\"\n };\n }\n case \"CHECKBOX\": {\n return {\n customFieldDefinitionId: mapping.networkSiteCustomFieldDefinitionId,\n data: value === true || value === \"true\"\n };\n }\n default:\n return null;\n }\n }).filter((field) => field !== null);\n // ------------------------------------------------------------\n\n const netboxSite = state.input.data;\n const { customFieldMappings } = state.steps[\"get-network-site-synchronization-settings\"].output;\n const { customFieldDefinitions } = state.steps[\"get-network-site-custom-field-definitions\"].output;\n const parsedAddress = parseUSAddress(netboxSite);\n const mappedFields = mapNetBoxCustomFields(netboxSite.custom_fields, customFieldMappings, customFieldDefinitions, utils.parseGlobalId);\n return {\n parsedAddress,\n mappedFields\n };\n}" + }, + { + "name": "prepare-network-site-data-for-update", + "slug": "prepare-network-site-data-for-update", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:({ state }) => state", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "update-network-site" + } + ], + "outputMapper": "({ state, utils }) => {\n // ---------------\n // parseUSAddress\n // ---------------\n const parseUSAddress = (netboxSite) => {\n const { physical_address, latitude, longitude } = netboxSite;\n if (!physical_address) {\n return null;\n }\n const postalCodeMatch = physical_address.match(/\\b(\\d{5})(?:-\\d{4})?\\b/);\n if (!postalCodeMatch) {\n return null;\n }\n const stateMatch = physical_address.match(\n /\\b(AL|AK|AZ|AR|CA|CO|CT|DE|FL|GA|HI|ID|IL|IN|IA|KS|KY|LA|ME|MD|MA|MI|MN|MS|MO|MT|NE|NV|NH|NJ|NM|NY|NC|ND|OH|OK|OR|PA|RI|SC|SD|TN|TX|UT|VT|VA|WA|WV|WI|WY|DC|AS|GU|MP|PR|VI)\\b(?=\\s*\\d{5}|\\s*$)/\n );\n if (!stateMatch) {\n return null;\n }\n const cityMatch = physical_address.match(/,\\s*([^,]+?)\\s*,?\\s*[A-Z]{2}\\b/);\n if (!cityMatch?.[1]?.trim()) {\n return null;\n }\n const line1 = physical_address.slice(0, physical_address.indexOf(\",\")).trim();\n if (!line1) {\n return null;\n }\n return {\n country: \"US\",\n line1,\n locality: cityMatch[1].trim(),\n region: stateMatch[1],\n postalCode: postalCodeMatch[1],\n ...typeof latitude === \"number\" && { latitude },\n ...typeof longitude === \"number\" && { longitude }\n };\n };\n\n // ---------------------\n // mapNetBoxCustomFields\n // ---------------------\n const mapNetBoxCustomFields = (netBoxCustomFields, customFieldMappings, customFieldDefinitions, parseGlobalId) => customFieldMappings.map((mapping) => {\n const value = netBoxCustomFields[mapping.netBoxCustomFieldName];\n if (value === void 0) {\n return null;\n }\n const customFieldDefinition = customFieldDefinitions.find((definition) => definition.id === mapping.networkSiteCustomFieldDefinitionId);\n if (!customFieldDefinition) {\n return null;\n }\n switch (customFieldDefinition.type) {\n case \"DROPDOWN\": {\n const optionGlobalId = customFieldDefinition.options?.find((option) => option.option === value)?.id;\n return {\n customFieldDefinitionId: mapping.networkSiteCustomFieldDefinitionId,\n data: optionGlobalId ? parseGlobalId(optionGlobalId).id : null\n };\n }\n case \"NUMBER\": {\n const parsed = typeof value === \"number\" ? value : Number(value);\n return {\n customFieldDefinitionId: mapping.networkSiteCustomFieldDefinitionId,\n data: Number.isNaN(parsed) ? null : parsed\n };\n }\n case \"NUMBER_LIST\": {\n const parsed = typeof value === \"number\" ? value : Number(value);\n return {\n customFieldDefinitionId: mapping.networkSiteCustomFieldDefinitionId,\n data: Number.isNaN(parsed) ? [] : [parsed]\n };\n }\n case \"TEXT\": {\n return {\n customFieldDefinitionId: mapping.networkSiteCustomFieldDefinitionId,\n data: value != null ? String(value) : null\n };\n }\n case \"TEXT_LIST\": {\n return {\n customFieldDefinitionId: mapping.networkSiteCustomFieldDefinitionId,\n data: value != null ? [String(value)] : []\n };\n }\n case \"TOGGLE\": {\n return {\n customFieldDefinitionId: mapping.networkSiteCustomFieldDefinitionId,\n data: value === true || value === \"true\"\n };\n }\n case \"CHECKBOX\": {\n return {\n customFieldDefinitionId: mapping.networkSiteCustomFieldDefinitionId,\n data: value === true || value === \"true\"\n };\n }\n default:\n return null;\n }\n }).filter((field) => field !== null);\n // ------------------------------------------------------------\n\n const netboxSite = state.input.data;\n const { customFieldMappings } = state.steps[\"get-network-site-synchronization-settings\"].output;\n const { customFieldDefinitions } = state.steps[\"get-network-site-custom-field-definitions\"].output;\n const parsedAddress = parseUSAddress(netboxSite);\n const mappedFields = mapNetBoxCustomFields(netboxSite.custom_fields, customFieldMappings, customFieldDefinitions, utils.parseGlobalId);\n return {\n parsedAddress,\n mappedFields\n };\n}" + }, + { + "name": "create-network-site", + "slug": "create-network-site", + "type": "GAIIA_API_GATEWAY_V1_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-public-api-gateway-graphql-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => {\n // -------------------------\n // CreateNetworkSiteDocument\n // -------------------------\n const CreateNetworkSiteDocument = {\"kind\":\"Document\",\"definitions\":[{\"kind\":\"OperationDefinition\",\"operation\":\"mutation\",\"name\":{\"kind\":\"Name\",\"value\":\"CreateNetworkSite\"},\"variableDefinitions\":[{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"input\"}},\"type\":{\"kind\":\"NonNullType\",\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"CreateNetworkSiteInput\"}}},\"directives\":[]}],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"createNetworkSite\"},\"arguments\":[{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"input\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"input\"}}}],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"errors\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"code\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"message\"},\"arguments\":[],\"directives\":[]}]}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"networkSite\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"id\"},\"arguments\":[],\"directives\":[]}]}}]}}]}}],\"loc\":{\"start\":0,\"end\":191}};\n // ------------------------------------------------------------\n\n return CreateNetworkSiteDocument;\n}", + "key": "query" + }, + { + "type": "FUNCTION", + "value": "async ({ state, utils }) => {\n // ----------------------------\n // buildNetBoxExternalSourceUrl\n // ----------------------------\n const buildNetBoxExternalSourceUrl = async (netboxUrl, instanceName, datastore, utils) => {\n const externalSourceUrl = netboxUrl.replace(/\\/api\\//, \"/\");\n if (externalSourceUrl.startsWith(\"http://\") || externalSourceUrl.startsWith(\"https://\")) {\n return externalSourceUrl;\n }\n const instanceId = await datastore.getItem(`instances.${instanceName}.id`);\n if (!instanceId) {\n return utils.fail(\"Failed to retrieve instance ID\", \"MISSING_INSTANCE_ID\");\n }\n const baseUrl = await datastore.getItem(`instances.${instanceId}.baseURL`);\n if (!baseUrl) {\n return utils.fail(\"Failed to retrieve base URL for external source link\", \"MISSING_BASE_URL\");\n }\n const baseUrlWithoutTrailingSlash = baseUrl.replace(/\\/api\\/?$/, \"\").replace(/\\/$/, \"\");\n const pathWithoutLeadingSlash = externalSourceUrl.replace(/^\\//, \"\");\n return `${baseUrlWithoutTrailingSlash}/${pathWithoutLeadingSlash}`;\n };\n // ------------------------------------------------------------\n\n const netboxSite = state.input.data;\n const { integrationId } = state.steps[\"get-network-site-synchronization-settings\"].output;\n const { statusMap } = state.steps[\"get-network-site-custom-statuses\"].output;\n const { parsedAddress, mappedFields } = state.steps[\"prepare-network-site-data-for-create\"].output;\n if (!parsedAddress) {\n return utils.fail(\"Failed to parse address for site creation\", \"ADDRESS_PARSE_FAILED\");\n }\n const customStatusId = statusMap[netboxSite.status.label.toLowerCase()];\n return {\n input: {\n address: parsedAddress,\n ...customStatusId && { customStatusId },\n externalSourceLink: {\n externalSourceId: String(netboxSite.id),\n externalSourceSlug: netboxSite.slug,\n externalSourceUrl: await buildNetBoxExternalSourceUrl(\n netboxSite.url,\n state.steps[\"get-instance-name-from-id\"].output,\n utils.integration.datastore,\n utils\n ),\n integrationId\n },\n fields: mappedFields,\n name: netboxSite.name\n }\n };\n}", + "key": "variables" + } + ], + "nextSteps": [ + { + "slug": "store-created-site-mapping" + } + ], + "outputMapper": "({ output, utils }) => {\n const response = output.response;\n const { networkSite, errors } = response.createNetworkSite;\n if (errors?.length || !networkSite) {\n return utils.fail(\"Failed to create network site\", \"CREATE_NETWORK_SITE_FAILED\", errors);\n }\n return networkSite;\n }" + }, + { + "name": "store-created-site-mapping", + "slug": "store-created-site-mapping", + "type": "GAIIA_WRITE_TO_INTEGRATION_DATASTORE", + "nodeSlug": "write-to-integration-datastore", + "inputs": [ + { + "type": "FUNCTION", + "value": "({ state }) => {\n const { integrationId } = state.steps[\"get-network-site-synchronization-settings\"].output;\n const netboxSiteId = state.input.data.id;\n const networkSite = state.steps[\"create-network-site\"].output;\n return [\n {\n key: `netbox-site-to-gaiia.${integrationId}.${netboxSiteId}`,\n value: networkSite.id\n }\n ];\n }", + "key": "values" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ] + }, + { + "name": "update-network-site", + "slug": "update-network-site", + "type": "GAIIA_API_GATEWAY_V1_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-public-api-gateway-graphql-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => {\n // -------------------------\n // UpdateNetworkSiteDocument\n // -------------------------\n const UpdateNetworkSiteDocument = {\"kind\":\"Document\",\"definitions\":[{\"kind\":\"OperationDefinition\",\"operation\":\"mutation\",\"name\":{\"kind\":\"Name\",\"value\":\"UpdateNetworkSite\"},\"variableDefinitions\":[{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"input\"}},\"type\":{\"kind\":\"NonNullType\",\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"UpdateNetworkSiteInput\"}}},\"directives\":[]}],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"updateNetworkSite\"},\"arguments\":[{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"input\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"input\"}}}],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"errors\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"code\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"message\"},\"arguments\":[],\"directives\":[]}]}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"networkSite\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"id\"},\"arguments\":[],\"directives\":[]}]}}]}}]}}],\"loc\":{\"start\":0,\"end\":191}};\n // ------------------------------------------------------------\n\n return UpdateNetworkSiteDocument;\n}", + "key": "query" + }, + { + "type": "FUNCTION", + "value": "async ({ state, utils }) => {\n // ----------------------------\n // buildNetBoxExternalSourceUrl\n // ----------------------------\n const buildNetBoxExternalSourceUrl = async (netboxUrl, instanceName, datastore, utils) => {\n const externalSourceUrl = netboxUrl.replace(/\\/api\\//, \"/\");\n if (externalSourceUrl.startsWith(\"http://\") || externalSourceUrl.startsWith(\"https://\")) {\n return externalSourceUrl;\n }\n const instanceId = await datastore.getItem(`instances.${instanceName}.id`);\n if (!instanceId) {\n return utils.fail(\"Failed to retrieve instance ID\", \"MISSING_INSTANCE_ID\");\n }\n const baseUrl = await datastore.getItem(`instances.${instanceId}.baseURL`);\n if (!baseUrl) {\n return utils.fail(\"Failed to retrieve base URL for external source link\", \"MISSING_BASE_URL\");\n }\n const baseUrlWithoutTrailingSlash = baseUrl.replace(/\\/api\\/?$/, \"\").replace(/\\/$/, \"\");\n const pathWithoutLeadingSlash = externalSourceUrl.replace(/^\\//, \"\");\n return `${baseUrlWithoutTrailingSlash}/${pathWithoutLeadingSlash}`;\n };\n // ------------------------------------------------------------\n\n const netboxSite = state.input.data;\n const gaiiaSite = state.steps[\"merge-network-site-lookup\"].output.existingSite;\n const { integrationId, customFieldMappings } = state.steps[\"get-network-site-synchronization-settings\"].output;\n const { statusMap } = state.steps[\"get-network-site-custom-statuses\"].output;\n const { parsedAddress, mappedFields } = state.steps[\"prepare-network-site-data-for-update\"].output;\n const customStatusId = statusMap[netboxSite.status.label.toLowerCase()];\n const syncedFieldDefinitionIds = new Set(customFieldMappings.map((mapping) => mapping.networkSiteCustomFieldDefinitionId));\n const nonSyncedFields = gaiiaSite.fields.nodes.filter((field) => !syncedFieldDefinitionIds.has(field.definition.id)).map((field) => ({\n customFieldDefinitionId: field.definition.id,\n data: field.data\n }));\n const allFields = [...mappedFields, ...nonSyncedFields];\n return {\n input: {\n ...parsedAddress && { address: parsedAddress },\n ...customStatusId && { customStatusId },\n externalSourceLink: {\n externalSourceId: String(netboxSite.id),\n externalSourceSlug: netboxSite.slug,\n externalSourceUrl: await buildNetBoxExternalSourceUrl(\n netboxSite.url,\n state.steps[\"get-instance-name-from-id\"].output,\n utils.integration.datastore,\n utils\n ),\n integrationId\n },\n fields: allFields,\n id: gaiiaSite.id,\n name: netboxSite.name\n }\n };\n}", + "key": "variables" + } + ], + "nextSteps": [ + { + "slug": "store-updated-site-mapping" + } + ], + "outputMapper": "({ output, utils }) => {\n const response = output.response;\n const { networkSite, errors } = response.updateNetworkSite;\n if (errors?.length || !networkSite) {\n return utils.fail(\"Failed to update network site\", \"UPDATE_NETWORK_SITE_FAILED\", errors);\n }\n return networkSite;\n }" + }, + { + "name": "store-updated-site-mapping", + "slug": "store-updated-site-mapping", + "type": "GAIIA_WRITE_TO_INTEGRATION_DATASTORE", + "nodeSlug": "write-to-integration-datastore", + "inputs": [ + { + "type": "FUNCTION", + "value": "({ state }) => {\n const { integrationId } = state.steps[\"get-network-site-synchronization-settings\"].output;\n const netboxSiteId = state.input.data.id;\n const networkSite = state.steps[\"update-network-site\"].output;\n return [\n {\n key: `netbox-site-to-gaiia.${integrationId}.${netboxSiteId}`,\n value: networkSite.id\n }\n ];\n }", + "key": "values" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ] + } + ] + } + }, + "Update RADIUS Account Groups": { + "id": "workflow_fLXTNCvridPqwEEXxf3CK1", + "description": null, + "definition": { + "trigger": { + "inputJsonSchema": { + "type": "object", + "properties": { + "accountId": { + "type": "string", + "description": "The account ID in gaiia" + }, + "status": { + "type": "string", + "enum": [ + "ACTIVE", + "INACTIVE", + "SUSPENDED", + "PENDING", + "LEAD" + ], + "description": "The status of the account" + }, + "productIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The product IDs of the account" + } + }, + "required": [ + "accountId", + "status", + "productIds" + ], + "additionalProperties": false + }, + "nextSteps": [ + { + "slug": "get-account-references" + } + ], + "type": "MANUAL" + }, + "outputMapper": { + "function": "({ state, utils }) => {\n if (\"handle-error\" in state.steps) {\n const error = state.steps[\"handle-error\"];\n utils.fail(error.output.message, error.output.code, error.output.payload);\n }\n if (\"update-account-groups\" in state.steps) {\n const parallelResults = state.steps[\"update-account-groups\"];\n return {\n success: true,\n updatedAccounts: parallelResults.output.flat()\n };\n }\n return {\n success: true,\n updatedAccounts: []\n };\n }", + "type": "JSON" + }, + "steps": [ + { + "name": "get-account-references", + "slug": "get-account-references", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:async ({ state, utils }) => {\n const { accountId } = state.input;\n const existingData = await utils.integration.datastore.getItem(`radiusAccount.${accountId}`);\n const accounts = existingData ? JSON.parse(existingData) : [];\n if (accounts.length === 0) {\n return utils.fail(\"No RADIUS accounts found for this account ID\", \"NO_ACCOUNTS_FOUND\");\n }\n const instanceMap = new Map();\n for (const account of accounts) {\n const instanceName = await utils.integration.datastore.getItem(`instances.${account.instanceId}.name`);\n if (!instanceName) {\n continue;\n }\n if (!instanceMap.has(instanceName)) {\n instanceMap.set(instanceName, {\n instanceName,\n instanceId: account.instanceId,\n accounts: []\n });\n }\n instanceMap.get(instanceName).accounts.push(account);\n }\n return [...instanceMap.values()];\n }", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "update-account-groups" + }, + { + "slug": "handle-error", + "errorCodes": [ + "*" + ] + } + ], + "outputMapper": "({ output }) => output" + }, + { + "name": "update-account-groups", + "slug": "update-account-groups", + "nodeSlug": "parallel-loop", + "type": "PARALLEL_LOOP", + "inputs": [ + { + "key": "iterable", + "type": "FUNCTION", + "value": "({ state }) => state.steps[\"get-account-references\"].output" + } + ], + "initialSteps": [ + { + "slug": "get-current-groups-and-determine-expected" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ], + "outputMapper": "({ state }) => {\n if (\"update-account-groups-transaction\" in state.steps) {\n return state.steps[\"update-account-groups-transaction\"].output;\n }\n return [];\n }" + }, + { + "name": "get-current-groups-and-determine-expected", + "slug": "get-current-groups-and-determine-expected", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "free-radius-mysql-query", + "inputs": [ + { + "type": "FUNCTION", + "value": "({ state }) => state.currentParallelLoopIterations[\"update-account-groups\"].instanceName", + "key": "instanceName" + }, + { + "type": "FUNCTION", + "value": "({ state }) => {\n const { accounts } = state.currentParallelLoopIterations[\"update-account-groups\"];\n return `SELECT groupname, priority, username FROM radusergroup WHERE username IN (${accounts.map((account) => account.username).map(() => \"?\").join(\", \")}) ORDER BY username, priority`;\n }", + "key": "query" + }, + { + "type": "FUNCTION", + "value": "({ state }) => {\n const { accounts } = state.currentParallelLoopIterations[\"update-account-groups\"];\n return accounts.map((account) => account.username);\n }", + "key": "values" + } + ], + "nextSteps": [ + { + "slug": "needs-update" + } + ], + "outputMapper": "async ({ state, output, utils }) => {\n const instance = state.currentParallelLoopIterations[\"update-account-groups\"];\n const { instanceId, instanceName, accounts } = instance;\n const { status, productIds } = state.input;\n const currentGroupsResult = output.results;\n const currentGroupsByUsername = new Map();\n if (currentGroupsResult && currentGroupsResult.length > 0) {\n for (const row of currentGroupsResult) {\n if (!currentGroupsByUsername.has(row.username)) {\n currentGroupsByUsername.set(row.username, []);\n }\n currentGroupsByUsername.get(row.username).push({\n groupname: row.groupname,\n priority: row.priority\n });\n }\n }\n const groupsData = await utils.integration.datastore.getItem(`instances.${instanceId}.groups`);\n let allExpectedGroups = [];\n if (groupsData) {\n const allGroups = JSON.parse(groupsData);\n const groupsToAssign = allGroups.filter((group) => {\n switch (group.mappingRule) {\n case \"ALL\":\n return true;\n case \"ACTIVE\":\n return status === \"ACTIVE\";\n case \"INACTIVE\":\n return status === \"INACTIVE\";\n case \"SUSPENDED\":\n return status === \"SUSPENDED\";\n case \"MATCH_PRODUCT\":\n const hasMatchingProduct = group.mappedProductIds.some((mappedProductId) => productIds.includes(mappedProductId));\n return hasMatchingProduct;\n }\n return false;\n });\n allExpectedGroups = groupsToAssign.map((group) => ({\n groupName: group.groupName,\n priority: group.priority\n }));\n }\n const accountResults = accounts.map((account) => {\n const { username } = account;\n const currentGroups = currentGroupsByUsername.get(username) || [];\n const currentGroupsSet = new Set(currentGroups.map((g) => g.groupname));\n const expectedGroupsSet = new Set(allExpectedGroups.map((g) => g.groupName));\n const needsUpdate = currentGroups.length !== allExpectedGroups.length || allExpectedGroups.some((g) => !currentGroupsSet.has(g.groupName)) || currentGroups.some((g) => !expectedGroupsSet.has(g.groupname));\n return {\n username,\n instanceName,\n instanceId,\n currentGroups,\n expectedGroups: allExpectedGroups,\n needsUpdate\n };\n });\n return {\n instanceName,\n instanceId,\n accounts: accountResults\n };\n }", + "parentParallelLoopSlug": "update-account-groups" + }, + { + "name": "needs-update", + "slug": "needs-update", + "nodeSlug": "condition", + "type": "CONDITION", + "condition": "({ state }) => state.steps[\"get-current-groups-and-determine-expected\"].output.accounts.some((account) => account.needsUpdate)", + "thenSteps": [ + { + "slug": "update-account-groups-transaction" + } + ], + "elseSteps": [ + { + "slug": "update-account-groups-end" + } + ], + "parentParallelLoopSlug": "update-account-groups" + }, + { + "name": "update-account-groups-transaction", + "slug": "update-account-groups-transaction", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "free-radius-mysql-transaction", + "inputs": [ + { + "type": "FUNCTION", + "value": "({ state }) => state.steps[\"get-current-groups-and-determine-expected\"].output.instanceName", + "key": "instanceName" + }, + { + "type": "FUNCTION", + "value": "({ state }) => {\n const { accounts } = state.steps[\"get-current-groups-and-determine-expected\"].output;\n const queries = [];\n for (const account of accounts) {\n const { username, expectedGroups, needsUpdate } = account;\n if (!needsUpdate) {\n continue;\n }\n queries.push({\n query: \"DELETE FROM radusergroup WHERE username = ?\",\n values: [username]\n });\n if (expectedGroups.length > 0) {\n queries.push({\n query: `INSERT INTO radusergroup (username, groupname, priority) VALUES ${expectedGroups.map(() => \"(?, ?, ?)\").join(\", \")}`,\n values: expectedGroups.flatMap((group) => [username, group.groupName, String(group.priority)])\n });\n }\n }\n return queries;\n }", + "key": "queries" + } + ], + "nextSteps": [ + { + "slug": "update-account-groups-end" + } + ], + "outputMapper": "({ state }) => {\n const { instanceName, accounts } = state.steps[\"get-current-groups-and-determine-expected\"].output;\n return accounts.map((account) => ({\n groups: account.expectedGroups.map((g) => g.groupName),\n instanceName,\n username: account.username\n }));\n }", + "parentParallelLoopSlug": "update-account-groups" + }, + { + "name": "handle-error", + "slug": "handle-error", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:({ state }) => {\n const steps = state.steps;\n return steps[\"get-account-references\"]?.error;\n }", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ], + "outputMapper": "({ output }) => output" + } + ] + } + }, + "Import Network Sites - Full Sync": { + "id": "workflow_pGwirTKW5riKBk9GwiaY1W", + "description": null, + "definition": { + "trigger": { + "inputJsonSchema": { + "type": "object", + "properties": { + "instanceName": { + "type": "string" + }, + "siteIds": { + "type": "array", + "items": { + "type": "number" + }, + "description": "List of NetBox site IDs to sync (optional - if not provided, all sites will be synced)" + } + }, + "required": [ + "instanceName" + ] + }, + "nextSteps": [ + { + "slug": "get-network-site-synchronization-settings" + } + ], + "type": "MANUAL" + }, + "outputMapper": { + "function": "({ state, utils }) => {\n const createErrors = state.steps[\"create-network-sites\"].output.flatMap((result) => result.errors);\n const updateErrors = state.steps[\"update-network-sites\"].output.flatMap((result) => result.errors);\n const allErrors = [...createErrors, ...updateErrors];\n if (allErrors.length > 0) {\n return utils.fail(\"Some network sites failed to sync\", \"PARTIAL_SYNC_FAILURE\", allErrors);\n }\n const { toCreate, toUpdate, skipped } = state.steps[\"match-netbox-sites-to-gaiia-sites\"].output;\n return {\n created: toCreate.length,\n updated: toUpdate.length,\n skipped: skipped.length\n };\n }", + "type": "JSON" + }, + "steps": [ + { + "name": "get-network-site-synchronization-settings", + "slug": "get-network-site-synchronization-settings", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "netbox-get-network-site-synchronization-settings", + "inputs": [ + { + "type": "FUNCTION", + "value": "async ({ state }) => state.input.instanceName", + "key": "instanceName" + } + ], + "nextSteps": [ + { + "slug": "get-network-site-custom-field-definitions" + } + ], + "outputMapper": "({ output, utils }) => {\n if (!output.isEnabled) {\n return utils.fail(\"NetBox network site synchronization is disabled for this instance.\", \"NETWORK_SITE_SYNC_DISABLED\");\n }\n return output;\n }" + }, + { + "name": "get-network-site-custom-field-definitions", + "slug": "get-network-site-custom-field-definitions", + "type": "GAIIA_API_GATEWAY_V1_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-public-api-gateway-graphql-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => {\n // --------------------------------------------------\n // PaginatedNetworkSiteCustomFieldDefinitionsDocument\n // --------------------------------------------------\n const PaginatedNetworkSiteCustomFieldDefinitionsDocument = {\"kind\":\"Document\",\"definitions\":[{\"kind\":\"OperationDefinition\",\"operation\":\"query\",\"name\":{\"kind\":\"Name\",\"value\":\"PaginatedNetworkSiteCustomFieldDefinitions\"},\"variableDefinitions\":[{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"first\"}},\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"Int\"}},\"directives\":[]},{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"after\"}},\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"String\"}},\"directives\":[]},{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"filters\"}},\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"NetworkSiteCustomFieldDefinitionFilters\"}},\"directives\":[]}],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"networkSiteCustomFieldDefinitions\"},\"arguments\":[{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"first\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"first\"}}},{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"after\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"after\"}}},{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"filters\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"filters\"}}}],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"pageInfo\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"hasNextPage\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"endCursor\"},\"arguments\":[],\"directives\":[]}]}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"nodes\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"id\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"name\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"required\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"type\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"createdAt\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"archivedAt\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"options\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"id\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"option\"},\"arguments\":[],\"directives\":[]}]}}]}}]}}]}}],\"loc\":{\"start\":0,\"end\":449}};\n // ------------------------------------------------------------\n\n return PaginatedNetworkSiteCustomFieldDefinitionsDocument;\n}", + "key": "query" + }, + { + "type": "FUNCTION", + "value": "() => ({\n first: 250,\n filters: {\n isArchived: { equals: false }\n }\n })", + "key": "variables" + } + ], + "nextSteps": [ + { + "slug": "get-network-site-custom-statuses" + } + ], + "outputMapper": "({ output }) => {\n const response = output.response;\n return {\n customFieldDefinitions: response.networkSiteCustomFieldDefinitions.nodes\n };\n }" + }, + { + "name": "get-network-site-custom-statuses", + "slug": "get-network-site-custom-statuses", + "type": "GAIIA_API_GATEWAY_V1_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-public-api-gateway-graphql-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => {\n // ---------------------------------\n // NetworkSiteCustomStatusesDocument\n // ---------------------------------\n const NetworkSiteCustomStatusesDocument = {\"kind\":\"Document\",\"definitions\":[{\"kind\":\"OperationDefinition\",\"operation\":\"query\",\"name\":{\"kind\":\"Name\",\"value\":\"NetworkSiteCustomStatuses\"},\"variableDefinitions\":[{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"first\"}},\"type\":{\"kind\":\"NonNullType\",\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"Int\"}}},\"directives\":[]}],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"networkSiteCustomStatuses\"},\"arguments\":[{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"first\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"first\"}}}],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"nodes\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"id\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"name\"},\"arguments\":[],\"directives\":[]}]}}]}}]}}],\"loc\":{\"start\":0,\"end\":146}};\n // ------------------------------------------------------------\n\n return NetworkSiteCustomStatusesDocument;\n}", + "key": "query" + }, + { + "type": "FUNCTION", + "value": "() => ({\n first: 250\n })", + "key": "variables" + } + ], + "nextSteps": [ + { + "slug": "get-first-page-of-sites" + } + ], + "outputMapper": "({ output }) => {\n const response = output.response;\n const statusMap = response.networkSiteCustomStatuses.nodes.reduce(\n (map, status) => ({\n ...map,\n [status.name.toLowerCase()]: status.id\n }),\n {}\n );\n return { statusMap };\n }" + }, + { + "name": "get-first-page-of-sites", + "slug": "get-first-page-of-sites", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "netbox-http-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => \"GET\"", + "key": "method" + }, + { + "type": "FUNCTION", + "value": "({ state }) => {\n const parameters = new URLSearchParams({\n limit: \"100\",\n offset: \"0\"\n });\n if (state.input.siteIds && state.input.siteIds.length > 0) {\n parameters.set(\"id\", state.input.siteIds.join(\",\"));\n }\n return `/dcim/sites/?${parameters.toString()}`;\n }", + "key": "path" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.input.instanceName", + "key": "instanceName" + } + ], + "nextSteps": [ + { + "slug": "get-paginated-sites" + } + ], + "outputMapper": "({ output }) => {\n const response = output;\n const numberOfRemainingPages = Math.max(0, Math.ceil(response.count / 100) - 1);\n return {\n pagesToFetch: Array.from({ length: numberOfRemainingPages }, (_, index) => index + 1),\n results: response.results\n };\n }" + }, + { + "name": "get-paginated-sites", + "slug": "get-paginated-sites", + "nodeSlug": "parallel-loop", + "type": "PARALLEL_LOOP", + "inputs": [ + { + "key": "iterable", + "type": "FUNCTION", + "value": "({ state }) => state.steps[\"get-first-page-of-sites\"].output.pagesToFetch" + } + ], + "initialSteps": [ + { + "slug": "get-page-of-sites" + } + ], + "nextSteps": [ + { + "slug": "merge-paginated-sites" + } + ], + "outputMapper": "({ state }) => state.steps[\"get-page-of-sites\"].output" + }, + { + "name": "get-page-of-sites", + "slug": "get-page-of-sites", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "netbox-http-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => \"GET\"", + "key": "method" + }, + { + "type": "FUNCTION", + "value": "({ state }) => {\n const parameters = new URLSearchParams({\n limit: \"100\",\n offset: String(state.currentParallelLoopIterations[\"get-paginated-sites\"] * 100)\n });\n if (state.input.siteIds && state.input.siteIds.length > 0) {\n parameters.set(\"id\", state.input.siteIds.join(\",\"));\n }\n return `/dcim/sites/?${parameters.toString()}`;\n }", + "key": "path" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.input.instanceName", + "key": "instanceName" + } + ], + "nextSteps": [ + { + "slug": "get-paginated-sites-end" + } + ], + "outputMapper": "({ output }) => {\n const response = output;\n return response.results;\n }", + "parentParallelLoopSlug": "get-paginated-sites" + }, + { + "name": "merge-paginated-sites", + "slug": "merge-paginated-sites", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:({ state }) => state", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "get-all-gaiia-network-sites" + } + ], + "outputMapper": "({ state }) => {\n const { results } = state.steps[\"get-first-page-of-sites\"].output;\n const paginatedResults = state.steps[\"get-paginated-sites\"].output.flat();\n return {\n sites: [...results, ...paginatedResults]\n };\n }" + }, + { + "name": "get-all-gaiia-network-sites", + "slug": "get-all-gaiia-network-sites", + "nodeSlug": "while-loop", + "type": "WHILE_LOOP", + "initialSteps": [ + { + "slug": "get-gaiia-network-sites" + } + ], + "nextSteps": [ + { + "slug": "match-netbox-sites-to-gaiia-sites" + } + ], + "condition": "({ state }) => {\n const whileLoop = state.currentWhileLoopIterations[\"get-all-gaiia-network-sites\"];\n return whileLoop.isFirstIteration || whileLoop.previousIteration.steps[\"get-gaiia-network-sites\"].output.networkSites.pageInfo.hasNextPage;\n }", + "iterationOutputMapper": "({ state }) => state.steps[\"get-gaiia-network-sites\"].output", + "outputMapper": "({ output }) => {\n const responses = output;\n return {\n networkSites: responses.flatMap((response) => response.networkSites.edges.map((edge) => edge.node))\n };\n }" + }, + { + "name": "get-gaiia-network-sites", + "slug": "get-gaiia-network-sites", + "type": "GAIIA_API_GATEWAY_V1_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-public-api-gateway-graphql-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => {\n // ------------------------------------\n // NetworkSitesWithExternalLinkDocument\n // ------------------------------------\n const NetworkSitesWithExternalLinkDocument = {\"kind\":\"Document\",\"definitions\":[{\"kind\":\"OperationDefinition\",\"operation\":\"query\",\"name\":{\"kind\":\"Name\",\"value\":\"NetworkSitesWithExternalLink\"},\"variableDefinitions\":[{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"first\"}},\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"Int\"}},\"directives\":[]},{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"after\"}},\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"String\"}},\"directives\":[]},{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"filters\"}},\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"NetworkSiteFilters\"}},\"directives\":[]},{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"firstFields\"}},\"type\":{\"kind\":\"NonNullType\",\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"Int\"}}},\"directives\":[]}],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"networkSites\"},\"arguments\":[{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"first\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"first\"}}},{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"after\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"after\"}}},{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"filters\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"filters\"}}}],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"edges\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"node\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"customStatus\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"id\"},\"arguments\":[],\"directives\":[]}]}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"externalSourceLink\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"externalSourceId\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"integration\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"id\"},\"arguments\":[],\"directives\":[]}]}}]}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"fields\"},\"arguments\":[{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"first\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"firstFields\"}}}],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"nodes\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"data\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"definition\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"id\"},\"arguments\":[],\"directives\":[]}]}}]}}]}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"id\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"name\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"address\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"country\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"line1\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"locality\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"postalCode\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"region\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"latitude\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"longitude\"},\"arguments\":[],\"directives\":[]}]}}]}}]}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"pageInfo\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"endCursor\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"hasNextPage\"},\"arguments\":[],\"directives\":[]}]}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"totalCount\"},\"arguments\":[],\"directives\":[]}]}}]}}],\"loc\":{\"start\":0,\"end\":802}};\n // ------------------------------------------------------------\n\n return NetworkSitesWithExternalLinkDocument;\n}", + "key": "query" + }, + { + "type": "FUNCTION", + "value": "({ state }) => {\n const previousResponse = state.currentWhileLoopIterations[\"get-all-gaiia-network-sites\"].previousIteration?.output;\n return {\n first: 250,\n after: previousResponse?.networkSites.pageInfo.endCursor,\n firstFields: 250\n };\n }", + "key": "variables" + } + ], + "nextSteps": [ + { + "slug": "get-all-gaiia-network-sites-end" + } + ], + "outputMapper": "({ output }) => {\n const response = output.response;\n return response;\n }", + "parentWhileLoopSlug": "get-all-gaiia-network-sites" + }, + { + "name": "match-netbox-sites-to-gaiia-sites", + "slug": "match-netbox-sites-to-gaiia-sites", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:({ state }) => state", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "create-network-sites" + } + ], + "outputMapper": "async ({ state, utils }) => {\n // ---------------\n // parseUSAddress\n // ---------------\n const parseUSAddress = (netboxSite) => {\n const { physical_address, latitude, longitude } = netboxSite;\n if (!physical_address) {\n return null;\n }\n const postalCodeMatch = physical_address.match(/\\b(\\d{5})(?:-\\d{4})?\\b/);\n if (!postalCodeMatch) {\n return null;\n }\n const stateMatch = physical_address.match(\n /\\b(AL|AK|AZ|AR|CA|CO|CT|DE|FL|GA|HI|ID|IL|IN|IA|KS|KY|LA|ME|MD|MA|MI|MN|MS|MO|MT|NE|NV|NH|NJ|NM|NY|NC|ND|OH|OK|OR|PA|RI|SC|SD|TN|TX|UT|VT|VA|WA|WV|WI|WY|DC|AS|GU|MP|PR|VI)\\b(?=\\s*\\d{5}|\\s*$)/\n );\n if (!stateMatch) {\n return null;\n }\n const cityMatch = physical_address.match(/,\\s*([^,]+?)\\s*,?\\s*[A-Z]{2}\\b/);\n if (!cityMatch?.[1]?.trim()) {\n return null;\n }\n const line1 = physical_address.slice(0, physical_address.indexOf(\",\")).trim();\n if (!line1) {\n return null;\n }\n return {\n country: \"US\",\n line1,\n locality: cityMatch[1].trim(),\n region: stateMatch[1],\n postalCode: postalCodeMatch[1],\n ...typeof latitude === \"number\" && { latitude },\n ...typeof longitude === \"number\" && { longitude }\n };\n };\n\n // ---------------------\n // mapNetBoxCustomFields\n // ---------------------\n const mapNetBoxCustomFields = (netBoxCustomFields, customFieldMappings, customFieldDefinitions, parseGlobalId) => customFieldMappings.map((mapping) => {\n const value = netBoxCustomFields[mapping.netBoxCustomFieldName];\n if (value === void 0) {\n return null;\n }\n const customFieldDefinition = customFieldDefinitions.find((definition) => definition.id === mapping.networkSiteCustomFieldDefinitionId);\n if (!customFieldDefinition) {\n return null;\n }\n switch (customFieldDefinition.type) {\n case \"DROPDOWN\": {\n const optionGlobalId = customFieldDefinition.options?.find((option) => option.option === value)?.id;\n return {\n customFieldDefinitionId: mapping.networkSiteCustomFieldDefinitionId,\n data: optionGlobalId ? parseGlobalId(optionGlobalId).id : null\n };\n }\n case \"NUMBER\": {\n const parsed = typeof value === \"number\" ? value : Number(value);\n return {\n customFieldDefinitionId: mapping.networkSiteCustomFieldDefinitionId,\n data: Number.isNaN(parsed) ? null : parsed\n };\n }\n case \"NUMBER_LIST\": {\n const parsed = typeof value === \"number\" ? value : Number(value);\n return {\n customFieldDefinitionId: mapping.networkSiteCustomFieldDefinitionId,\n data: Number.isNaN(parsed) ? [] : [parsed]\n };\n }\n case \"TEXT\": {\n return {\n customFieldDefinitionId: mapping.networkSiteCustomFieldDefinitionId,\n data: value != null ? String(value) : null\n };\n }\n case \"TEXT_LIST\": {\n return {\n customFieldDefinitionId: mapping.networkSiteCustomFieldDefinitionId,\n data: value != null ? [String(value)] : []\n };\n }\n case \"TOGGLE\": {\n return {\n customFieldDefinitionId: mapping.networkSiteCustomFieldDefinitionId,\n data: value === true || value === \"true\"\n };\n }\n case \"CHECKBOX\": {\n return {\n customFieldDefinitionId: mapping.networkSiteCustomFieldDefinitionId,\n data: value === true || value === \"true\"\n };\n }\n default:\n return null;\n }\n }).filter((field) => field !== null);\n // ------------------------------------------------------------\n\n const { sites } = state.steps[\"merge-paginated-sites\"].output;\n const { networkSites } = state.steps[\"get-all-gaiia-network-sites\"].output;\n const { integrationId, customFieldMappings } = state.steps[\"get-network-site-synchronization-settings\"].output;\n const { customFieldDefinitions } = state.steps[\"get-network-site-custom-field-definitions\"].output;\n const toCreate = [];\n const toUpdate = [];\n const skipped = [];\n for (const netboxSite of sites) {\n const datastoreKey = `netbox-site-to-gaiia.${integrationId}.${netboxSite.id}`;\n const gaiiaSiteId = await utils.integration.datastore.getItem(datastoreKey);\n const matchedByDatastore = gaiiaSiteId ? networkSites.find(\n (site) => site.id === gaiiaSiteId && site.externalSourceLink?.integration.id === integrationId && site.externalSourceLink.externalSourceId === String(netboxSite.id)\n ) : void 0;\n const matchedByName = networkSites.find((site) => !site.externalSourceLink && site.name.toLowerCase() === netboxSite.name.toLowerCase());\n const matchedSite = matchedByDatastore ?? matchedByName;\n const parsedAddress = parseUSAddress(netboxSite);\n const mappedFields = mapNetBoxCustomFields(netboxSite.custom_fields, customFieldMappings, customFieldDefinitions, utils.parseGlobalId);\n if (matchedSite) {\n toUpdate.push({\n gaiia: matchedSite,\n netbox: netboxSite,\n parsedAddress: parsedAddress ?? void 0,\n mappedFields\n });\n } else {\n if (!parsedAddress) {\n skipped.push({ site: netboxSite, reason: \"address_parse_failed\" });\n continue;\n }\n toCreate.push({\n site: netboxSite,\n parsedAddress,\n mappedFields\n });\n }\n }\n return {\n toCreate,\n toUpdate,\n skipped\n };\n}" + }, + { + "name": "create-network-sites", + "slug": "create-network-sites", + "nodeSlug": "parallel-loop", + "type": "PARALLEL_LOOP", + "inputs": [ + { + "key": "iterable", + "type": "FUNCTION", + "value": "({ state }) => state.steps[\"match-netbox-sites-to-gaiia-sites\"].output.toCreate" + } + ], + "initialSteps": [ + { + "slug": "create-network-site" + } + ], + "nextSteps": [ + { + "slug": "update-network-sites" + } + ], + "outputMapper": "({ state }) => {\n const step = state.steps[\"create-network-site\"];\n return {\n errors: \"error\" in step ? [step.error] : []\n };\n }", + "maxConcurrentIterations": 1 + }, + { + "name": "create-network-site", + "slug": "create-network-site", + "type": "GAIIA_API_GATEWAY_V1_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-public-api-gateway-graphql-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => {\n // -------------------------\n // CreateNetworkSiteDocument\n // -------------------------\n const CreateNetworkSiteDocument = {\"kind\":\"Document\",\"definitions\":[{\"kind\":\"OperationDefinition\",\"operation\":\"mutation\",\"name\":{\"kind\":\"Name\",\"value\":\"CreateNetworkSite\"},\"variableDefinitions\":[{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"input\"}},\"type\":{\"kind\":\"NonNullType\",\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"CreateNetworkSiteInput\"}}},\"directives\":[]}],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"createNetworkSite\"},\"arguments\":[{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"input\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"input\"}}}],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"errors\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"code\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"message\"},\"arguments\":[],\"directives\":[]}]}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"networkSite\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"id\"},\"arguments\":[],\"directives\":[]}]}}]}}]}}],\"loc\":{\"start\":0,\"end\":191}};\n // ------------------------------------------------------------\n\n return CreateNetworkSiteDocument;\n}", + "key": "query" + }, + { + "type": "FUNCTION", + "value": "async ({ state, utils }) => {\n // ----------------------------\n // buildNetBoxExternalSourceUrl\n // ----------------------------\n const buildNetBoxExternalSourceUrl = async (netboxUrl, instanceName, datastore, utils) => {\n const externalSourceUrl = netboxUrl.replace(/\\/api\\//, \"/\");\n if (externalSourceUrl.startsWith(\"http://\") || externalSourceUrl.startsWith(\"https://\")) {\n return externalSourceUrl;\n }\n const instanceId = await datastore.getItem(`instances.${instanceName}.id`);\n if (!instanceId) {\n return utils.fail(\"Failed to retrieve instance ID\", \"MISSING_INSTANCE_ID\");\n }\n const baseUrl = await datastore.getItem(`instances.${instanceId}.baseURL`);\n if (!baseUrl) {\n return utils.fail(\"Failed to retrieve base URL for external source link\", \"MISSING_BASE_URL\");\n }\n const baseUrlWithoutTrailingSlash = baseUrl.replace(/\\/api\\/?$/, \"\").replace(/\\/$/, \"\");\n const pathWithoutLeadingSlash = externalSourceUrl.replace(/^\\//, \"\");\n return `${baseUrlWithoutTrailingSlash}/${pathWithoutLeadingSlash}`;\n };\n // ------------------------------------------------------------\n\n const { site: netboxSite, parsedAddress, mappedFields } = state.currentParallelLoopIterations[\"create-network-sites\"];\n const { integrationId } = state.steps[\"get-network-site-synchronization-settings\"].output;\n const { statusMap } = state.steps[\"get-network-site-custom-statuses\"].output;\n const customStatusId = statusMap[netboxSite.status.label.toLowerCase()];\n return {\n input: {\n address: parsedAddress,\n ...customStatusId && { customStatusId },\n externalSourceLink: {\n externalSourceId: String(netboxSite.id),\n externalSourceSlug: netboxSite.slug,\n externalSourceUrl: await buildNetBoxExternalSourceUrl(netboxSite.url, state.input.instanceName, utils.integration.datastore, utils),\n integrationId\n },\n fields: mappedFields,\n name: netboxSite.name\n }\n };\n}", + "key": "variables" + } + ], + "nextSteps": [ + { + "slug": "store-created-site-mapping" + }, + { + "slug": "create-network-sites-end", + "errorCodes": [ + "CREATE_NETWORK_SITE_FAILED" + ] + } + ], + "outputMapper": "({ output, utils }) => {\n const response = output.response;\n const { networkSite, errors } = response.createNetworkSite;\n if (errors?.length || !networkSite) {\n return utils.fail(\"Failed to create network site\", \"CREATE_NETWORK_SITE_FAILED\", errors);\n }\n return networkSite;\n }", + "parentParallelLoopSlug": "create-network-sites" + }, + { + "name": "store-created-site-mapping", + "slug": "store-created-site-mapping", + "type": "GAIIA_WRITE_TO_INTEGRATION_DATASTORE", + "nodeSlug": "write-to-integration-datastore", + "inputs": [ + { + "type": "FUNCTION", + "value": "({ state }) => {\n const { integrationId } = state.steps[\"get-network-site-synchronization-settings\"].output;\n const { site: netboxSite } = state.currentParallelLoopIterations[\"create-network-sites\"];\n const networkSite = state.steps[\"create-network-site\"].output;\n return [\n {\n key: `netbox-site-to-gaiia.${integrationId}.${netboxSite.id}`,\n value: networkSite.id\n }\n ];\n }", + "key": "values" + } + ], + "nextSteps": [ + { + "slug": "create-network-sites-end" + } + ], + "parentParallelLoopSlug": "create-network-sites" + }, + { + "name": "update-network-sites", + "slug": "update-network-sites", + "nodeSlug": "parallel-loop", + "type": "PARALLEL_LOOP", + "inputs": [ + { + "key": "iterable", + "type": "FUNCTION", + "value": "({ state }) => {\n const { toUpdate } = state.steps[\"match-netbox-sites-to-gaiia-sites\"].output;\n const { integrationId } = state.steps[\"get-network-site-synchronization-settings\"].output;\n const { statusMap } = state.steps[\"get-network-site-custom-statuses\"].output;\n return toUpdate.filter(({ gaiia, netbox, parsedAddress, mappedFields }) => {\n const hasExternalSourceLink = gaiia.externalSourceLink?.integration.id === integrationId && gaiia.externalSourceLink.externalSourceId === String(netbox.id);\n const nameChanged = gaiia.name !== netbox.name;\n const netboxStatusId = statusMap[netbox.status.label.toLowerCase()];\n const statusChanged = netboxStatusId && gaiia.customStatus?.id !== netboxStatusId;\n const addressChanged = parsedAddress ? !gaiia.address || gaiia.address.country !== parsedAddress.country || gaiia.address.line1 !== parsedAddress.line1 || gaiia.address.locality !== parsedAddress.locality || gaiia.address.postalCode !== parsedAddress.postalCode || gaiia.address.region !== parsedAddress.region || gaiia.address.latitude !== parsedAddress.latitude || gaiia.address.longitude !== parsedAddress.longitude : false;\n const fieldsChanged = mappedFields.some((mappedField) => {\n const existingField = gaiia.fields.nodes.find((f) => f.definition.id === mappedField.customFieldDefinitionId);\n return !existingField || JSON.stringify(existingField.data) !== JSON.stringify(mappedField.data);\n });\n return !hasExternalSourceLink || nameChanged || statusChanged || addressChanged || fieldsChanged;\n });\n }" + } + ], + "initialSteps": [ + { + "slug": "update-network-site" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ], + "outputMapper": "({ state }) => {\n const step = state.steps[\"update-network-site\"];\n return {\n errors: \"error\" in step ? [step.error] : []\n };\n }", + "maxConcurrentIterations": 1 + }, + { + "name": "update-network-site", + "slug": "update-network-site", + "type": "GAIIA_API_GATEWAY_V1_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-public-api-gateway-graphql-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => {\n // -------------------------\n // UpdateNetworkSiteDocument\n // -------------------------\n const UpdateNetworkSiteDocument = {\"kind\":\"Document\",\"definitions\":[{\"kind\":\"OperationDefinition\",\"operation\":\"mutation\",\"name\":{\"kind\":\"Name\",\"value\":\"UpdateNetworkSite\"},\"variableDefinitions\":[{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"input\"}},\"type\":{\"kind\":\"NonNullType\",\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"UpdateNetworkSiteInput\"}}},\"directives\":[]}],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"updateNetworkSite\"},\"arguments\":[{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"input\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"input\"}}}],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"errors\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"code\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"message\"},\"arguments\":[],\"directives\":[]}]}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"networkSite\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"id\"},\"arguments\":[],\"directives\":[]}]}}]}}]}}],\"loc\":{\"start\":0,\"end\":191}};\n // ------------------------------------------------------------\n\n return UpdateNetworkSiteDocument;\n}", + "key": "query" + }, + { + "type": "FUNCTION", + "value": "async ({ state, utils }) => {\n // ----------------------------\n // buildNetBoxExternalSourceUrl\n // ----------------------------\n const buildNetBoxExternalSourceUrl = async (netboxUrl, instanceName, datastore, utils) => {\n const externalSourceUrl = netboxUrl.replace(/\\/api\\//, \"/\");\n if (externalSourceUrl.startsWith(\"http://\") || externalSourceUrl.startsWith(\"https://\")) {\n return externalSourceUrl;\n }\n const instanceId = await datastore.getItem(`instances.${instanceName}.id`);\n if (!instanceId) {\n return utils.fail(\"Failed to retrieve instance ID\", \"MISSING_INSTANCE_ID\");\n }\n const baseUrl = await datastore.getItem(`instances.${instanceId}.baseURL`);\n if (!baseUrl) {\n return utils.fail(\"Failed to retrieve base URL for external source link\", \"MISSING_BASE_URL\");\n }\n const baseUrlWithoutTrailingSlash = baseUrl.replace(/\\/api\\/?$/, \"\").replace(/\\/$/, \"\");\n const pathWithoutLeadingSlash = externalSourceUrl.replace(/^\\//, \"\");\n return `${baseUrlWithoutTrailingSlash}/${pathWithoutLeadingSlash}`;\n };\n // ------------------------------------------------------------\n\n const { gaiia, netbox, parsedAddress, mappedFields } = state.currentParallelLoopIterations[\"update-network-sites\"];\n const { integrationId, customFieldMappings } = state.steps[\"get-network-site-synchronization-settings\"].output;\n const { statusMap } = state.steps[\"get-network-site-custom-statuses\"].output;\n const customStatusId = statusMap[netbox.status.label.toLowerCase()];\n const syncedFieldDefinitionIds = new Set(customFieldMappings.map((mapping) => mapping.networkSiteCustomFieldDefinitionId));\n const nonSyncedFields = gaiia.fields.nodes.filter((field) => !syncedFieldDefinitionIds.has(field.definition.id)).map((field) => ({\n customFieldDefinitionId: field.definition.id,\n data: field.data\n }));\n const allFields = [...mappedFields, ...nonSyncedFields];\n return {\n input: {\n ...parsedAddress && { address: parsedAddress },\n ...customStatusId && { customStatusId },\n externalSourceLink: {\n externalSourceId: String(netbox.id),\n externalSourceSlug: netbox.slug,\n externalSourceUrl: await buildNetBoxExternalSourceUrl(netbox.url, state.input.instanceName, utils.integration.datastore, utils),\n integrationId\n },\n fields: allFields,\n id: gaiia.id,\n name: netbox.name\n }\n };\n}", + "key": "variables" + } + ], + "nextSteps": [ + { + "slug": "store-updated-site-mapping" + }, + { + "slug": "update-network-sites-end", + "errorCodes": [ + "UPDATE_NETWORK_SITE_FAILED" + ] + } + ], + "outputMapper": "({ output, utils }) => {\n const response = output.response;\n const { networkSite, errors } = response.updateNetworkSite;\n if (errors?.length || !networkSite) {\n return utils.fail(\"Failed to update network site\", \"UPDATE_NETWORK_SITE_FAILED\", errors);\n }\n return networkSite;\n }", + "parentParallelLoopSlug": "update-network-sites" + }, + { + "name": "store-updated-site-mapping", + "slug": "store-updated-site-mapping", + "type": "GAIIA_WRITE_TO_INTEGRATION_DATASTORE", + "nodeSlug": "write-to-integration-datastore", + "inputs": [ + { + "type": "FUNCTION", + "value": "({ state }) => {\n const { integrationId } = state.steps[\"get-network-site-synchronization-settings\"].output;\n const { netbox } = state.currentParallelLoopIterations[\"update-network-sites\"];\n const networkSite = state.steps[\"update-network-site\"].output;\n return [\n {\n key: `netbox-site-to-gaiia.${integrationId}.${netbox.id}`,\n value: networkSite.id\n }\n ];\n }", + "key": "values" + } + ], + "nextSteps": [ + { + "slug": "update-network-sites-end" + } + ], + "parentParallelLoopSlug": "update-network-sites" + } + ] + } + }, + "Create NetBox Site Update Webhook": { + "id": "workflow_eZLp5qqnQxEKy3wDLjPvB6", + "description": null, + "definition": { + "trigger": { + "inputJsonSchema": { + "type": "object", + "properties": { + "instanceName": { + "type": "string" + } + }, + "required": [ + "instanceName" + ] + }, + "nextSteps": [ + { + "slug": "get-network-site-synchronization-settings" + } + ], + "type": "MANUAL" + }, + "outputMapper": { + "function": "({ state }) => state.steps[\"create-event-rule\"].output", + "type": "JSON" + }, + "steps": [ + { + "name": "get-network-site-synchronization-settings", + "slug": "get-network-site-synchronization-settings", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "netbox-get-network-site-synchronization-settings", + "inputs": [ + { + "type": "FUNCTION", + "value": "async ({ state }) => state.input.instanceName", + "key": "instanceName" + } + ], + "nextSteps": [ + { + "slug": "list-custom-fields" + } + ], + "outputMapper": "({ output, utils }) => {\n if (!output.isEnabled) {\n return utils.fail(\"NetBox network site synchronization is disabled for this instance.\", \"NETWORK_SITE_SYNC_DISABLED\");\n }\n return output;\n }" + }, + { + "name": "list-custom-fields", + "slug": "list-custom-fields", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "netbox-http-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => \"GET\"", + "key": "method" + }, + { + "type": "FUNCTION", + "value": "() => \"/extras/custom-fields/\"", + "key": "path" + }, + { + "type": "FUNCTION", + "value": "() => ({\n name: [\"gaiia_tenant_id\", \"gaiia_instance_id\"]\n })", + "key": "body" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.input.instanceName", + "key": "instanceName" + } + ], + "nextSteps": [ + { + "slug": "check-tenant-id-field-exists" + } + ], + "outputMapper": "({ output }) => {\n const results = output.results;\n return results.map((field) => field.name);\n }" + }, + { + "name": "check-tenant-id-field-exists", + "slug": "check-tenant-id-field-exists", + "nodeSlug": "condition", + "type": "CONDITION", + "condition": "({ state }) => state.steps[\"list-custom-fields\"].output.includes(\"gaiia_tenant_id\")", + "thenSteps": [ + { + "slug": "check-instance-id-field-exists" + } + ], + "elseSteps": [ + { + "slug": "create-tenant-id-custom-field" + } + ] + }, + { + "name": "create-tenant-id-custom-field", + "slug": "create-tenant-id-custom-field", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "netbox-http-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => \"POST\"", + "key": "method" + }, + { + "type": "FUNCTION", + "value": "() => \"/extras/custom-fields/\"", + "key": "path" + }, + { + "type": "FUNCTION", + "value": "() => ({\n label: \"gaiia tenant ID\",\n name: \"gaiia_tenant_id\",\n type: \"text\",\n object_types: [\"extras.webhook\", \"extras.eventrule\"],\n required: false\n })", + "key": "body" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.input.instanceName", + "key": "instanceName" + } + ], + "nextSteps": [ + { + "slug": "check-instance-id-field-exists" + } + ], + "outputMapper": "({ output }) => output" + }, + { + "name": "check-instance-id-field-exists", + "slug": "check-instance-id-field-exists", + "nodeSlug": "condition", + "type": "CONDITION", + "condition": "({ state }) => state.steps[\"list-custom-fields\"].output.includes(\"gaiia_instance_id\")", + "thenSteps": [ + { + "slug": "get-webhook-workflow" + } + ], + "elseSteps": [ + { + "slug": "create-instance-id-custom-field" + } + ] + }, + { + "name": "create-instance-id-custom-field", + "slug": "create-instance-id-custom-field", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "netbox-http-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => \"POST\"", + "key": "method" + }, + { + "type": "FUNCTION", + "value": "() => \"/extras/custom-fields/\"", + "key": "path" + }, + { + "type": "FUNCTION", + "value": "() => ({\n label: \"gaiia instance ID\",\n name: \"gaiia_instance_id\",\n type: \"text\",\n object_types: [\"extras.webhook\", \"extras.eventrule\"],\n required: false\n })", + "key": "body" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.input.instanceName", + "key": "instanceName" + } + ], + "nextSteps": [ + { + "slug": "get-webhook-workflow" + } + ], + "outputMapper": "({ output }) => output" + }, + { + "name": "get-webhook-workflow", + "slug": "get-webhook-workflow", + "type": "GAIIA_API_GATEWAY_V1_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-public-api-gateway-graphql-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => {\n // -----------------\n // WorkflowsDocument\n // -----------------\n const WorkflowsDocument = {\"kind\":\"Document\",\"definitions\":[{\"kind\":\"OperationDefinition\",\"operation\":\"query\",\"name\":{\"kind\":\"Name\",\"value\":\"Workflows\"},\"variableDefinitions\":[{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"filter\"}},\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"WorkflowFilter\"}},\"directives\":[]}],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"workflows\"},\"arguments\":[{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"filter\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"filter\"}}}],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"nodes\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"id\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"latestDefinition\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"webhookUrl\"},\"arguments\":[],\"directives\":[]}]}}]}}]}}]}}],\"loc\":{\"start\":0,\"end\":168}};\n // ------------------------------------------------------------\n\n return WorkflowsDocument;\n}", + "key": "query" + }, + { + "type": "FUNCTION", + "value": "() => ({\n filter: {\n name: {\n equals: \"Import Network Sites\"\n }\n }\n })", + "key": "variables" + } + ], + "nextSteps": [ + { + "slug": "create-webhook" + } + ], + "outputMapper": "({ output, utils }) => {\n const response = output.response;\n if (response.workflows.nodes.length === 0) {\n return utils.fail('No workflow found with the name \"Import Network Sites\"', \"NO_WORKFLOW_FOUND\");\n }\n if (response.workflows.nodes.length > 1) {\n return utils.fail('Multiple workflows found with the name \"Import Network Sites\"', \"MULTIPLE_WORKFLOWS_FOUND\");\n }\n const webhookUrl = response.workflows.nodes[0].latestDefinition?.webhookUrl;\n if (!webhookUrl) {\n return utils.fail('The workflow \"Import Network Sites\" does not have a webhook URL defined', \"NO_WEBHOOK_URL_DEFINED\");\n }\n return response.workflows.nodes[0];\n }" + }, + { + "name": "create-webhook", + "slug": "create-webhook", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "netbox-http-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => \"POST\"", + "key": "method" + }, + { + "type": "FUNCTION", + "value": "() => \"/extras/webhooks/\"", + "key": "path" + }, + { + "type": "FUNCTION", + "value": "async ({ state, utils }) => {\n const instanceId = await utils.integration.datastore.getItem(`instances.${state.input.instanceName}.id`);\n return {\n name: `${state.context.tenant.name} - Site Update Webhook`,\n payload_url: `${state.steps[\"get-webhook-workflow\"].output.latestDefinition?.webhookUrl}?instanceId=${instanceId}`,\n custom_fields: {\n gaiia_tenant_id: state.context.tenant.id,\n gaiia_instance_id: instanceId\n }\n };\n }", + "key": "body" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.input.instanceName", + "key": "instanceName" + } + ], + "nextSteps": [ + { + "slug": "create-event-rule" + } + ], + "outputMapper": "({ output }) => output" + }, + { + "name": "create-event-rule", + "slug": "create-event-rule", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "netbox-http-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => \"POST\"", + "key": "method" + }, + { + "type": "FUNCTION", + "value": "() => \"/extras/event-rules/\"", + "key": "path" + }, + { + "type": "FUNCTION", + "value": "async ({ state, utils }) => ({\n name: `${state.context.tenant.name} - Site Update Event Rule`,\n event_types: [\"object_updated\", \"object_created\", \"object_deleted\"],\n object_types: [\"dcim.site\"],\n type_create: true,\n type_update: true,\n type_delete: true,\n action_type: \"webhook\",\n action_object_type: \"extras.webhook\",\n action_object_id: state.steps[\"create-webhook\"].output.id,\n custom_fields: {\n gaiia_tenant_id: state.context.tenant.id,\n gaiia_instance_id: await utils.integration.datastore.getItem(`instances.${state.input.instanceName}.id`)\n }\n })", + "key": "body" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.input.instanceName", + "key": "instanceName" + } + ], + "nextSteps": [ + { + "slug": "create-external-source-claim" + } + ], + "outputMapper": "({ output }) => output" + }, + { + "name": "create-external-source-claim", + "slug": "create-external-source-claim", + "type": "GAIIA_API_GATEWAY_V1_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-public-api-gateway-graphql-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => {\n // ----------------------------------\n // CreateExternalSourceClaimsDocument\n // ----------------------------------\n const CreateExternalSourceClaimsDocument = {\"kind\":\"Document\",\"definitions\":[{\"kind\":\"OperationDefinition\",\"operation\":\"mutation\",\"name\":{\"kind\":\"Name\",\"value\":\"CreateExternalSourceClaims\"},\"variableDefinitions\":[{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"input\"}},\"type\":{\"kind\":\"NonNullType\",\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"CreateExternalSourceClaimsInput\"}}},\"directives\":[]}],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"createExternalSourceClaims\"},\"arguments\":[{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"input\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"input\"}}}],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"errors\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"code\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"message\"},\"arguments\":[],\"directives\":[]}]}}]}}]}}],\"loc\":{\"start\":0,\"end\":185}};\n // ------------------------------------------------------------\n\n return CreateExternalSourceClaimsDocument;\n}", + "key": "query" + }, + { + "type": "FUNCTION", + "value": "() => ({\n input: {\n claims: [\n {\n claimType: \"OBJECT\",\n entityType: \"NETWORK_SITE\"\n }\n ]\n }\n })", + "key": "variables" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ], + "outputMapper": "({ output, utils }) => {\n const response = output.response;\n if (response.createExternalSourceClaims.errors?.length) {\n return utils.fail(\"Failed to create external source claim\", \"CREATE_EXTERNAL_SOURCE_CLAIM_FAILED\", response.createExternalSourceClaims.errors);\n }\n return response.createExternalSourceClaims;\n }" + } + ] + } + }, + "Remove NetBox Site Update Webhook": { + "id": "workflow_cBNb5ana2UuvrE5XKYgrbQ", + "description": null, + "definition": { + "trigger": { + "inputJsonSchema": { + "type": "object", + "properties": { + "instanceName": { + "type": "string" + } + }, + "required": [ + "instanceName" + ] + }, + "nextSteps": [ + { + "slug": "list-event-rules" + } + ], + "type": "MANUAL" + }, + "steps": [ + { + "name": "list-event-rules", + "slug": "list-event-rules", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "netbox-http-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => \"GET\"", + "key": "method" + }, + { + "type": "FUNCTION", + "value": "async ({ state, utils }) => {\n const instanceId = await utils.integration.datastore.getItem(`instances.${state.input.instanceName}.id`);\n return `/extras/event-rules?cf_gaiia_tenant_id=${state.context.tenant.id}&cf_gaiia_instance_id=${instanceId}`;\n }", + "key": "path" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.input.instanceName", + "key": "instanceName" + } + ], + "nextSteps": [ + { + "slug": "delete-event-rules" + } + ], + "outputMapper": "({ output }) => {\n const response = output;\n return response;\n }" + }, + { + "name": "delete-event-rules", + "slug": "delete-event-rules", + "nodeSlug": "parallel-loop", + "type": "PARALLEL_LOOP", + "inputs": [ + { + "key": "iterable", + "type": "FUNCTION", + "value": "({ state }) => {\n const eventRuleName = `${state.context.tenant.name} - Site Update Event Rule`;\n return state.steps[\"list-event-rules\"].output.results.filter((rule) => rule.name === eventRuleName);\n }" + } + ], + "initialSteps": [ + { + "slug": "delete-event-rule" + } + ], + "nextSteps": [ + { + "slug": "list-webhooks" + } + ] + }, + { + "name": "delete-event-rule", + "slug": "delete-event-rule", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "netbox-http-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => \"DELETE\"", + "key": "method" + }, + { + "type": "FUNCTION", + "value": "({ state }) => `extras/event-rules/${state.currentParallelLoopIterations[\"delete-event-rules\"].id}`", + "key": "path" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.input.instanceName", + "key": "instanceName" + } + ], + "nextSteps": [ + { + "slug": "delete-event-rules-end" + } + ], + "outputMapper": "({ output }) => output", + "parentParallelLoopSlug": "delete-event-rules" + }, + { + "name": "list-webhooks", + "slug": "list-webhooks", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "netbox-http-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => \"GET\"", + "key": "method" + }, + { + "type": "FUNCTION", + "value": "async ({ state, utils }) => {\n const instanceId = await utils.integration.datastore.getItem(`instances.${state.input.instanceName}.id`);\n return `/extras/webhooks?cf_gaiia_tenant_id=${state.context.tenant.id}&cf_gaiia_instance_id=${instanceId}`;\n }", + "key": "path" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.input.instanceName", + "key": "instanceName" + } + ], + "nextSteps": [ + { + "slug": "delete-webhooks" + } + ], + "outputMapper": "({ output }) => {\n const response = output;\n return response;\n }" + }, + { + "name": "delete-webhooks", + "slug": "delete-webhooks", + "nodeSlug": "parallel-loop", + "type": "PARALLEL_LOOP", + "inputs": [ + { + "key": "iterable", + "type": "FUNCTION", + "value": "({ state }) => {\n const webhookName = `${state.context.tenant.name} - Site Update Webhook`;\n return state.steps[\"list-webhooks\"].output.results.filter((webhook) => webhook.name === webhookName);\n }" + } + ], + "initialSteps": [ + { + "slug": "delete-webhook" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ] + }, + { + "name": "delete-webhook", + "slug": "delete-webhook", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "netbox-http-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => \"DELETE\"", + "key": "method" + }, + { + "type": "FUNCTION", + "value": "({ state }) => `extras/webhooks/${state.currentParallelLoopIterations[\"delete-webhooks\"].id}`", + "key": "path" + }, + { + "type": "FUNCTION", + "value": "({ state }) => state.input.instanceName", + "key": "instanceName" + } + ], + "nextSteps": [ + { + "slug": "delete-webhooks-end" + } + ], + "outputMapper": "({ output }) => output", + "parentParallelLoopSlug": "delete-webhooks" + } + ] + } + }, + "Update Network Site Custom Field Claims": { + "id": "workflow_dznKDNB7pHL75PAoAPh1Jd", + "description": null, + "definition": { + "trigger": { + "inputJsonSchema": { + "type": "object", + "properties": { + "instanceName": { + "type": "string" + } + }, + "required": [ + "instanceName" + ] + }, + "nextSteps": [ + { + "slug": "get-network-site-synchronization-settings" + } + ], + "type": "MANUAL" + }, + "steps": [ + { + "name": "get-network-site-synchronization-settings", + "slug": "get-network-site-synchronization-settings", + "type": "CUSTOM_WORKFLOW", + "nodeSlug": "netbox-get-network-site-synchronization-settings", + "inputs": [ + { + "type": "FUNCTION", + "value": "async ({ state }) => state.input.instanceName", + "key": "instanceName" + } + ], + "nextSteps": [ + { + "slug": "update-custom-field-claims" + } + ], + "outputMapper": "({ output }) => output" + }, + { + "name": "update-custom-field-claims", + "slug": "update-custom-field-claims", + "type": "GAIIA_API_GATEWAY_V1_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-public-api-gateway-graphql-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => {\n // ----------------------------------\n // CreateExternalSourceClaimsDocument\n // ----------------------------------\n const CreateExternalSourceClaimsDocument = {\"kind\":\"Document\",\"definitions\":[{\"kind\":\"OperationDefinition\",\"operation\":\"mutation\",\"name\":{\"kind\":\"Name\",\"value\":\"CreateExternalSourceClaims\"},\"variableDefinitions\":[{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"input\"}},\"type\":{\"kind\":\"NonNullType\",\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"CreateExternalSourceClaimsInput\"}}},\"directives\":[]}],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"createExternalSourceClaims\"},\"arguments\":[{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"input\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"input\"}}}],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"errors\"},\"arguments\":[],\"directives\":[],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"code\"},\"arguments\":[],\"directives\":[]},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"message\"},\"arguments\":[],\"directives\":[]}]}}]}}]}}],\"loc\":{\"start\":0,\"end\":185}};\n // ------------------------------------------------------------\n\n return CreateExternalSourceClaimsDocument;\n}", + "key": "query" + }, + { + "type": "FUNCTION", + "value": "({ state }) => {\n const { customFieldMappings } = state.steps[\"get-network-site-synchronization-settings\"].output;\n return {\n input: {\n claims: customFieldMappings.map((mapping) => ({\n claimType: \"CUSTOM_FIELD\",\n entityType: \"NETWORK_SITE\",\n customFieldDefinitionId: mapping.networkSiteCustomFieldDefinitionId\n }))\n }\n };\n }", + "key": "variables" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ], + "outputMapper": "({ output, utils }) => {\n const response = output.response;\n if (response.createExternalSourceClaims.errors?.length) {\n return utils.fail(\"Failed to create custom field claims\", \"CREATE_CUSTOM_FIELD_CLAIMS_FAILED\", response.createExternalSourceClaims.errors);\n }\n return response.createExternalSourceClaims;\n }" + } + ] + } + }, + "Get NetBox network site synchronization settings": { + "id": "workflow_mDtdvEiFAYpNFrgmhN9xqr", + "description": null, + "definition": { + "trigger": { + "inputJsonSchema": { + "type": "object", + "properties": { + "instanceName": { + "type": "string" + } + }, + "required": [ + "instanceName" + ] + }, + "nextSteps": [ + { + "slug": "get-network-site-synchronization-settings" + } + ], + "type": "MANUAL" + }, + "outputMapper": { + "function": "({ state }) => ({ ...state.steps[\"get-network-site-synchronization-settings\"].output })", + "type": "JSON" + }, + "steps": [ + { + "name": "get-network-site-synchronization-settings", + "slug": "get-network-site-synchronization-settings", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:({ state }) => state", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ], + "outputMapper": "async ({ state, utils }) => {\n const instanceId = await utils.integration.datastore.getItem(`instances.${state.input.instanceName}.id`);\n if (!instanceId) {\n return utils.fail(`No instance found with name ${state.input.instanceName}`, \"INSTANCE_NOT_FOUND\");\n }\n const integrationId = await utils.integration.datastore.getItem(\"integrationId\");\n if (!integrationId) {\n return utils.fail(\"Integration ID not found in datastore\", \"INTEGRATION_ID_NOT_FOUND\");\n }\n const networkSiteSynchronizationSettingsString = await utils.integration.datastore.getItem(`instances.${instanceId}.networkSiteSynchronization`);\n if (networkSiteSynchronizationSettingsString) {\n try {\n const parsed = JSON.parse(networkSiteSynchronizationSettingsString);\n return {\n ...parsed,\n instanceId,\n integrationId\n };\n } catch (error) {\n return utils.fail(\"Failed to parse network site synchronization settings\", \"INVALID_JSON_FORMAT\", { error });\n }\n }\n return {\n customFieldMappings: [],\n instanceId,\n integrationId,\n isEnabled: false\n };\n }" + } + ] + } + }, + "Get Account ID from RADIUS Username": { + "id": "workflow_4aLJbygeEqSwVnq9h95mzp", + "description": null, + "definition": { + "trigger": { + "inputJsonSchema": { + "type": "object", + "properties": { + "username": { + "type": "string", + "description": "The RADIUS username to search for" + } + }, + "required": [ + "username" + ], + "additionalProperties": false + }, + "nextSteps": [ + { + "slug": "get-all-instances" + } + ], + "type": "MANUAL" + }, + "outputMapper": { + "function": "({ state }) => ({\n accounts: state.steps[\"collect-accounts\"].output\n })", + "type": "JSON" + }, + "steps": [ + { + "name": "get-all-instances", + "slug": "get-all-instances", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:async ({ utils }) => {\n const instancesId = await utils.integration.datastore.getItem(\"instances\");\n if (!instancesId) {\n return utils.fail(\"No instances found in datastore\", \"NO_INSTANCES_FOUND\");\n }\n const instanceIds = instancesId.split(\",\").map((id) => id.trim());\n if (instanceIds.length === 0) {\n return utils.fail(\"No instance ids found\", \"NO_INSTANCE_IDS_FOUND\");\n }\n const instances = [];\n for (const instanceId of instanceIds) {\n const name = await utils.integration.datastore.getItem(`instances.${instanceId}.name`);\n if (name) {\n instances.push({ instanceId, instanceName: name });\n }\n }\n if (instances.length === 0) {\n return utils.fail(\"No valid instances found\", \"NO_VALID_INSTANCES\");\n }\n return instances;\n }", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "check-instances" + } + ], + "outputMapper": "({ output }) => output" + }, + { + "name": "check-instances", + "slug": "check-instances", + "nodeSlug": "parallel-loop", + "type": "PARALLEL_LOOP", + "inputs": [ + { + "key": "iterable", + "type": "FUNCTION", + "value": "({ state }) => state.steps[\"get-all-instances\"].output" + } + ], + "initialSteps": [ + { + "slug": "check-instance-mapping" + } + ], + "nextSteps": [ + { + "slug": "collect-accounts" + } + ], + "outputMapper": "({ state }) => state.steps[\"check-instance-mapping\"].output" + }, + { + "name": "check-instance-mapping", + "slug": "check-instance-mapping", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:async ({ state, utils }) => {\n const { instanceId, instanceName } = state.currentParallelLoopIterations[\"check-instances\"];\n const { username } = state.input;\n const accountId = await utils.integration.datastore.getItem(`radiusUsername.${instanceId}.${username}`);\n if (accountId) {\n return {\n accountId,\n instanceName\n };\n }\n return false;\n }", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "check-instances-end" + } + ], + "outputMapper": "({ output }) => output", + "parentParallelLoopSlug": "check-instances" + }, + { + "name": "collect-accounts", + "slug": "collect-accounts", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:({ state, utils }) => {\n const { username } = state.input;\n const results = state.steps[\"check-instances\"].output;\n const accounts = results.filter((result) => result !== false).map((result) => ({\n accountId: result.accountId,\n instanceName: result.instanceName\n }));\n if (accounts.length === 0) {\n return utils.fail(\n `RADIUS username \"${username}\" not found in any instance. The reverse index keys \"radiusUsername.{instanceId}.${username}\" are maintained by the addRadiusAccount and deleteRadiusAccount workflows. Run the backfillRadiusUsernameMapping.ts script to create mappings for existing accounts.`,\n \"USERNAME_NOT_FOUND\"\n );\n }\n return accounts;\n }", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ], + "outputMapper": "({ output }) => output" + } + ] + } + }, + "List Accounts With RADIUS Accounts": { + "id": "workflow_fUsK1CmtC4mcrDB38TyR1K", + "description": null, + "definition": { + "trigger": { + "inputJsonSchema": { + "type": "object", + "properties": { + "maxExpectedAccounts": { + "type": "number", + "description": "Maximum expected number of accounts to paginate through (default: 10000)" + } + }, + "additionalProperties": false + }, + "nextSteps": [ + { + "slug": "prepare-page-array" + } + ], + "type": "MANUAL" + }, + "outputMapper": { + "function": "({ state }) => {\n const results = state.steps[\"get-radius-accounts-loop\"].output;\n const accountsWithRadius = results.filter((result) => result.radiusUsernames.length > 0).map((result) => ({\n accountId: result.account.id,\n accountName: `${result.account.primaryContact.firstName} ${result.account.primaryContact.lastName}`.trim(),\n radiusUsernames: result.radiusUsernames\n }));\n return { accounts: accountsWithRadius };\n }", + "type": "JSON" + }, + "steps": [ + { + "name": "prepare-page-array", + "slug": "prepare-page-array", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:({ state }) => {\n const PAGE_SIZE = 250;\n const maxExpectedAccounts = state.input.maxExpectedAccounts ?? 1e4;\n const totalPages = Math.ceil(maxExpectedAccounts / PAGE_SIZE);\n return Array.from({ length: totalPages }, (_, index) => index);\n }", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "fetch-accounts-paginated" + } + ], + "outputMapper": "({ output }) => output" + }, + { + "name": "fetch-accounts-paginated", + "slug": "fetch-accounts-paginated", + "nodeSlug": "parallel-loop", + "type": "PARALLEL_LOOP", + "inputs": [ + { + "key": "iterable", + "type": "FUNCTION", + "value": "({ state }) => state.steps[\"prepare-page-array\"].output" + } + ], + "initialSteps": [ + { + "slug": "read-cursor" + } + ], + "nextSteps": [ + { + "slug": "flatten-accounts" + } + ], + "outputMapper": "({ state }) => state.steps[\"fetch-accounts-batch\"].output.accounts", + "maxConcurrentIterations": 1 + }, + { + "name": "read-cursor", + "slug": "read-cursor", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:async ({ utils }) => {\n const DATASTORE_KEY = \"_temp.listAccounts.cursor\";\n const cursor = await utils.integration.datastore.getItem(DATASTORE_KEY);\n if (cursor === \"__DONE__\") {\n return { cursor: null, done: true };\n }\n return { cursor: cursor || null, done: false };\n }", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "fetch-accounts-batch" + } + ], + "outputMapper": "({ output }) => output", + "parentParallelLoopSlug": "fetch-accounts-paginated" + }, + { + "name": "fetch-accounts-batch", + "slug": "fetch-accounts-batch", + "type": "GAIIA_API_GATEWAY_V1_GRAPHQL_REQUEST", + "nodeSlug": "gaiia-public-api-gateway-graphql-request", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => `\n query ListAccounts($first: Int!, $after: String) {\n accounts(first: $first, after: $after) {\n pageInfo {\n hasNextPage\n endCursor\n }\n nodes {\n id\n primaryContact {\n firstName\n lastName\n }\n }\n }\n }\n `", + "key": "query" + }, + { + "type": "FUNCTION", + "value": "({ state }) => ({\n first: 250,\n after: state.steps[\"read-cursor\"].output.done ? null : state.steps[\"read-cursor\"].output.cursor\n })", + "key": "variables" + } + ], + "nextSteps": [ + { + "slug": "store-cursor" + } + ], + "outputMapper": "({ state, output }) => {\n if (state.steps[\"read-cursor\"].output.done) {\n return {\n accounts: [],\n hasNextPage: false,\n endCursor: null\n };\n }\n const response = output.response;\n return {\n accounts: response.accounts.nodes,\n hasNextPage: response.accounts.pageInfo.hasNextPage,\n endCursor: response.accounts.pageInfo.endCursor\n };\n }", + "parentParallelLoopSlug": "fetch-accounts-paginated" + }, + { + "name": "store-cursor", + "slug": "store-cursor", + "type": "GAIIA_WRITE_TO_INTEGRATION_DATASTORE", + "nodeSlug": "write-to-integration-datastore", + "inputs": [ + { + "type": "FUNCTION", + "value": "({ state }) => {\n const DATASTORE_KEY = \"_temp.listAccounts.cursor\";\n const { done } = state.steps[\"read-cursor\"].output;\n if (done) {\n return [];\n }\n const { hasNextPage, endCursor } = state.steps[\"fetch-accounts-batch\"].output;\n return [\n {\n key: DATASTORE_KEY,\n value: hasNextPage ? endCursor ?? \"__DONE__\" : \"__DONE__\"\n }\n ];\n }", + "key": "values" + } + ], + "nextSteps": [ + { + "slug": "fetch-accounts-paginated-end" + } + ], + "outputMapper": "() => ({ stored: true })", + "parentParallelLoopSlug": "fetch-accounts-paginated" + }, + { + "name": "flatten-accounts", + "slug": "flatten-accounts", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:({ state }) => {\n const paginatedResults = state.steps[\"fetch-accounts-paginated\"].output;\n return paginatedResults.flat();\n }", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "get-radius-accounts-loop" + } + ], + "outputMapper": "({ output }) => output" + }, + { + "name": "get-radius-accounts-loop", + "slug": "get-radius-accounts-loop", + "nodeSlug": "parallel-loop", + "type": "PARALLEL_LOOP", + "inputs": [ + { + "key": "iterable", + "type": "FUNCTION", + "value": "({ state }) => state.steps[\"flatten-accounts\"].output" + } + ], + "initialSteps": [ + { + "slug": "get-radius-accounts" + } + ], + "nextSteps": [ + { + "slug": "cleanup-temp-cursor" + } + ], + "outputMapper": "({ state }) => state.steps[\"get-radius-accounts\"].output", + "maxConcurrentIterations": 5 + }, + { + "name": "get-radius-accounts", + "slug": "get-radius-accounts", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:async ({ state, utils }) => {\n const account = state.currentParallelLoopIterations[\"get-radius-accounts-loop\"];\n const existingData = await utils.integration.datastore.getItem(`radiusAccount.${account.id}`);\n const accounts = existingData ? JSON.parse(existingData) : [];\n return {\n account,\n radiusUsernames: accounts.map((a) => a.username)\n };\n }", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "get-radius-accounts-loop-end" + } + ], + "outputMapper": "({ output }) => output", + "parentParallelLoopSlug": "get-radius-accounts-loop" + }, + { + "name": "cleanup-temp-cursor", + "slug": "cleanup-temp-cursor", + "type": "GAIIA_WRITE_TO_INTEGRATION_DATASTORE", + "nodeSlug": "write-to-integration-datastore", + "inputs": [ + { + "type": "FUNCTION", + "value": "() => [\n {\n key: \"_temp.listAccounts.cursor\",\n value: \"\"\n }\n ]", + "key": "values" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ], + "outputMapper": "() => ({ cleaned: true })" + } + ] + } + }, + "Get RADIUS Mappings by Product ID": { + "id": "workflow_uKTVe9vpHC1ZWi4KpqZQHD", + "description": null, + "definition": { + "trigger": { + "inputJsonSchema": { + "type": "object", + "properties": { + "productId": { + "type": "string", + "description": "The product ID to look up RADIUS mappings for" + } + }, + "required": [ + "productId" + ], + "additionalProperties": false + }, + "nextSteps": [ + { + "slug": "get-mappings" + } + ], + "type": "MANUAL" + }, + "outputMapper": { + "function": "({ state }) => ({\n groups: state.steps[\"get-mappings\"].output\n })", + "type": "JSON" + }, + "steps": [ + { + "name": "get-mappings", + "slug": "get-mappings", + "type": "TYPESCRIPT_CODE", + "nodeSlug": "execute-typescript-code", + "inputs": [ + { + "type": "LITERAL", + "value": "function:({ state }) => state", + "key": "function" + } + ], + "nextSteps": [ + { + "slug": "end" + } + ], + "outputMapper": "async ({ state, utils }) => {\n const { productId } = state.input;\n const instancesString = await utils.integration.datastore.getItem(\"instances\");\n if (!instancesString) {\n return [];\n }\n const instanceIds = instancesString.split(\",\").map((id) => id.trim()).filter((id) => id.length > 0);\n const mappings = [];\n for (const instanceId of instanceIds) {\n const instanceName = await utils.integration.datastore.getItem(`instances.${instanceId}.name`);\n if (!instanceName) {\n continue;\n }\n const groupsData = await utils.integration.datastore.getItem(`instances.${instanceId}.groups`);\n if (!groupsData) {\n continue;\n }\n const groups = JSON.parse(groupsData);\n for (const group of groups) {\n if (group.mappingRule !== \"MATCH_PRODUCT\") {\n continue;\n }\n if (!group.mappedProductIds?.includes(productId)) {\n continue;\n }\n mappings.push({\n instanceId,\n instanceName,\n groupName: group.groupName,\n priority: group.priority,\n mappingRule: group.mappingRule,\n mappedProductIds: group.mappedProductIds\n });\n }\n }\n return mappings;\n }" + } + ] + } + } + } +} \ No newline at end of file diff --git a/mikrotik-tool/494.rsc b/mikrotik-tool/494.rsc index 2acbb32..e9d1583 100644 --- a/mikrotik-tool/494.rsc +++ b/mikrotik-tool/494.rsc @@ -1,4 +1,4 @@ -# 2026-05-14 13:40:52 by RouterOS 7.21.4 +# 2026-07-10 10:54:21 by RouterOS 7.21.4 # software id = K4QG-8NQV # # model = RB5009UG+S+ @@ -218,15 +218,15 @@ add add-arp=yes address-pool=mgmt interface=management lease-script="{\ /ip smb users set [ find default=yes ] disabled=yes /ipv6 dhcp-server option -add code=23 name=v6-dns value="'2606:1c80::240''2606:1c80::250'" +add code=23 name=v6-dns /ipv6 pool add name=494-cust-pd-1 prefix=2606:1c80:1a01::/48 prefix-length=56 /ppp profile set *0 dns-server=204.110.191.240,204.110.191.250 local-address=\ 204.110.188.94 remote-address=public use-upnp=no add change-tcp-mss=yes dhcpv6-pd-pool=494-cust-pd-1 dns-server=\ - 204.110.191.240,204.110.191.20 local-address=100.64.175.254 name=494 \ - on-down="{\ + 204.110.191.240,204.110.191.250 idle-timeout=1h local-address=\ + 100.64.175.254 name=494 on-down="{\ \n:local url \"https://xtjlpnubrg.execute-api.us-east-1.amazonaws.com/prod\ uction/on-start-workflow-execution/6f19a48f-04f7-40c1-a4e0-8cb66df99213/0a\ 7f4443-fc8a-4fe0-807c-f535d2aa1865\"\ @@ -334,8 +334,9 @@ add change-tcp-mss=yes dhcpv6-pd-pool=494-cust-pd-1 dns-server=\ \n }\ \n} while ( \$attempts < \$max )\ \n}\ - \n" remote-address=cgnat remote-ipv6-prefix-pool=494-cust-pd-1 use-upnp=\ - no + \n" only-one=yes remote-address=cgnat remote-ipv6-prefix-pool=\ + 494-cust-pd-1 use-upnp=no +set *FFFFFFFE dns-server=204.110.191.240,204.110.191.250 /queue type add kind=fq-codel name=fq-codel /queue interface @@ -355,10 +356,10 @@ add as=393837 name=bgp-instance-1 router-id=10.254.254.111 add disabled=no id=10.254.254.111 name=id-1 select-dynamic-id=any \ select-from-vrf=main /routing ospf instance -add disabled=no in-filter-chain=ospf-in name=ospf-instance-1 \ - originate-default=never out-filter-chain=ospf-out router-id=id-1 +add in-filter-chain=ospf-in name=default-v2 originate-default=never \ + out-filter-chain=ospf-out redistribute=connected router-id=id-1 /routing ospf area -add disabled=no instance=ospf-instance-1 name=ospf-area-1 +add instance=default-v2 name=backbone-v2 /snmp community set [ find default=yes ] addresses=204.110.188.0/22,10.0.0.0/8 name=\ kdyyJrT0Mm @@ -389,8 +390,8 @@ set tcp-syncookies=yes /ipv6 settings set accept-router-advertisements=no /interface pppoe-server server -add default-profile=494 disabled=no interface=494 max-mru=1492 max-mtu=1492 \ - one-session-per-host=yes service-name=494 +add authentication=mschap2 default-profile=494 disabled=no interface=494 \ + max-mru=1492 max-mtu=1492 one-session-per-host=yes service-name=494 /ip address add address=10.254.254.111 comment=Loopback interface=lo network=\ 10.254.254.111 @@ -403,22 +404,35 @@ add address=100.64.175.254/20 interface=494 network=100.64.160.0 add interface=ether1 # Interface not active add interface=ether3 +/ip dhcp-server alert +add disabled=no interface=494 on-alert=rogue-dhcp valid-server=\ + DC:2C:6E:DD:87:58 /ip dhcp-server lease add address=10.10.175.10 mac-address=78:8A:20:AC:C5:32 add address=10.10.175.11 mac-address=58:C1:7A:75:4D:F2 /ip dhcp-server network -add address=10.10.160.0/20 dns-server=204.110.191.240,204.110.191.20 domain=\ - vntx.net gateway=10.10.175.254 ntp-server=204.110.191.19 -add address=100.64.160.0/20 dns-server=204.110.191.240,204.110.191.20 domain=\ - vntx.net gateway=100.64.175.254 ntp-server=204.110.191.19 +add address=10.10.160.0/20 dns-server=204.110.191.240,204.110.191.250 domain=\ + vntx.net gateway=10.10.175.254 ntp-server=\ + 23.150.40.242,108.61.215.221,162.254.225.152 +add address=100.64.160.0/20 dns-server=204.110.191.240,204.110.191.250 \ + domain=vntx.net gateway=100.64.175.254 ntp-server=\ + 23.150.40.242,108.61.215.221,162.254.225.152 /ip dns -set servers=9.9.9.9,1.1.1.1,2606:1c80::240,2606:1c80::250 +set servers=204.110.191.240,204.110.191.250 /ip firewall address-list add address=154.66.115.255 list=CPF add address=10.0.0.0/8 list=CPF add address=192.168.0.0/16 list=CPF add address=172.16.0.0/12 list=CPF +add address=204.110.188.0/22 comment="VNTX public IPs" list=trusted +add address=100.64.0.0/10 comment="CGNAT pools" list=trusted +add address=10.0.0.0/8 comment="RFC 1918 private" list=trusted +add address=192.168.0.0/16 comment="RFC 1918 private" list=trusted /ip firewall filter +add action=accept chain=input comment="allow trusted to management services" \ + dst-port=8728-8729,8291,22 protocol=tcp src-address-list=trusted +add action=drop chain=input comment="drop untrusted to management services" \ + dst-port=8728-8729,8291,22 protocol=tcp add action=accept chain=forward comment=\ "bypass fasttrack for MPLS spine (out)" out-interface=ether2-climax add action=accept chain=forward comment=\ @@ -502,12 +516,12 @@ set propagate-ttl=no /ppp aaa set interim-update=15m use-radius=yes /radius -add address=204.110.191.248 require-message-auth=no service=ppp src-address=\ - 204.110.188.94 timeout=300ms -add address=204.110.191.2 require-message-auth=no service=ppp src-address=\ - 204.110.188.94 timeout=300ms -add accounting-backup=yes address=45.76.56.5 disabled=yes \ - require-message-auth=no service=ppp src-address=204.110.188.94 timeout=\ +add address=204.110.191.248 secret=dyhhtcUft3CJtDfYFG1S6 service=ppp \ + src-address=204.110.188.94 timeout=2s +add address=204.110.191.2 secret=dyhhtcUft3CJtDfYFG1S6 service=ppp \ + src-address=204.110.188.94 timeout=2s +add accounting-backup=yes address=45.76.56.5 disabled=yes secret=\ + dyhhtcUft3CJtDfYFG1S6 service=ppp src-address=204.110.188.94 timeout=\ 300ms /routing bfd configuration add disabled=no interfaces=ether2-climax min-rx=200ms min-tx=200ms \ @@ -529,12 +543,11 @@ add chain=v6-ibgp-out disabled=no rule="if (dst==2606:1c80:1A00::/44 || dst==2\ 606:1c80:0:10::111/128 || (dst-len==64 && dst==2606:1c80:0:17::/64)) { acc\ ept; } else { reject; }" /routing ospf interface-template -add area=ospf-area-1 auth=sha512 auth-id=1 disabled=no interfaces=\ - ether2-climax type=ptp use-bfd=no -add area=ospf-area-1 disabled=no passive +add area=backbone-v2 cost=10 interfaces=ether2-climax priority=1 type=ptp \ + use-bfd=no +add area=backbone-v2 passive /routing ospf static-neighbor -add address=10.250.1.70%ether2-climax area=ospf-area-1 disabled=no \ - poll-interval=10s +add address=10.250.1.70%ether2-climax area=*1 disabled=no poll-interval=10s /snmp set contact=graham@vntx.net enabled=yes location="33.208204, -96.462530" /system clock @@ -559,3 +572,7 @@ set auto-upgrade=yes add name=reboot on-event="/system reboot" policy=\ ftp,reboot,read,write,policy,test,password,sniff,sensitive,romon \ start-date=2025-02-16 start-time=03:40:00 +/system script +add dont-require-permissions=no name=rogue-dhcp owner=admin policy=\ + ftp,reboot,read,write,policy,test,password,sniff,sensitive,romon source=\ + ":log warning message=\"Rogue DHCP server detected!\"" diff --git a/mikrotik-tool/982.rsc b/mikrotik-tool/982.rsc index 41a7b70..c3f378d 100644 --- a/mikrotik-tool/982.rsc +++ b/mikrotik-tool/982.rsc @@ -1,4 +1,4 @@ -# 2026-05-14 13:40:53 by RouterOS 7.21.4 +# 2026-07-10 10:54:21 by RouterOS 7.21.4 # software id = FUVS-HCM5 # # model = CCR1009-7G-1C-1S+ @@ -117,16 +117,18 @@ add address-pool=mgmt interface=mgmt lease-script="{\ \n }\ \n} while ( \$attempts < \$max )\ \n}\ - \n" lease-time=10m name=mgmt + \n" lease-time=1h name=mgmt /ip smb users set [ find default=yes ] disabled=yes /ipv6 dhcp-server option -add code=23 name=v6-dns value="'2606:1c80::240''2606:1c80::250'" +add code=23 name=v6-dns /ipv6 pool add name=982-cust-pd-1 prefix=2606:1c80:1301::/48 prefix-length=56 /ppp profile -add dhcpv6-pd-pool=982-cust-pd-1 local-address=100.64.63.253 name=982 \ - on-down="{\ +set *0 dns-server=204.110.191.240,204.110.191.250 +add change-tcp-mss=yes dhcpv6-pd-pool=982-cust-pd-1 dns-server=\ + 204.110.191.240,204.110.191.250 idle-timeout=1h local-address=\ + 100.64.63.253 name=982 on-down="{\ \n:local url \"https://xtjlpnubrg.execute-api.us-east-1.amazonaws.com/prod\ uction/on-start-workflow-execution/6f19a48f-04f7-40c1-a4e0-8cb66df99213/0a\ 7f4443-fc8a-4fe0-807c-f535d2aa1865\"\ @@ -234,7 +236,9 @@ add dhcpv6-pd-pool=982-cust-pd-1 local-address=100.64.63.253 name=982 \ \n }\ \n} while ( \$attempts < \$max )\ \n}\ - \n" remote-address=cgnat remote-ipv6-prefix-pool=982-cust-pd-1 + \n" only-one=yes remote-address=cgnat remote-ipv6-prefix-pool=\ + 982-cust-pd-1 +set *FFFFFFFE dns-server=204.110.191.240,204.110.191.250 /queue type add kind=fq-codel name=fq-codel /queue interface @@ -290,16 +294,13 @@ add bridge=mgmt ingress-filtering=no interface=vlan10_ether6 \ internal-path-cost=10 path-cost=10 add bridge=982 interface=combo1 /ip firewall connection tracking -# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings set icmp-timeout=30s tcp-close-wait-timeout=1m tcp-established-timeout=4h \ tcp-fin-wait-timeout=2m tcp-last-ack-timeout=30s \ tcp-syn-received-timeout=1m tcp-syn-sent-timeout=2m \ tcp-time-wait-timeout=2m udp-stream-timeout=2m /ip settings -# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings set tcp-syncookies=yes /ipv6 settings -# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings set accept-router-advertisements=no /interface pppoe-server server add authentication=mschap2 default-profile=982 disabled=no interface=982 \ @@ -311,6 +312,9 @@ add address=10.10.63.254/20 interface=mgmt network=10.10.48.0 add address=204.110.188.126/27 interface=982 network=204.110.188.96 add address=100.64.63.254/20 comment="New CGNAT subnet" interface=982 \ network=100.64.48.0 +/ip dhcp-server alert +add disabled=no interface=mgmt on-alert=rogue-dhcp valid-server=\ + DC:2C:6E:66:50:BF /ip dhcp-server lease add address=10.10.63.2 mac-address=F4:92:BF:2F:29:7E add address=10.10.63.3 mac-address=F4:92:BF:2F:29:93 @@ -320,12 +324,14 @@ add address=10.10.63.6 mac-address=F4:92:BF:2F:08:38 add address=10.10.63.1 client-id=1:d0:21:f9:f0:f5:23 mac-address=\ D0:21:F9:F0:F5:23 /ip dhcp-server network -add address=10.10.48.0/20 dns-server=204.110.191.240,204.110.191.20 domain=\ - vntx.net gateway=10.10.63.254 ntp-server=204.110.191.19 -add address=100.64.28.0/22 dns-server=204.110.191.240,204.110.191.20 domain=\ - vntx.net gateway=100.64.31.254 ntp-server=204.110.191.19 +add address=10.10.48.0/20 dns-server=204.110.191.240,204.110.191.250 domain=\ + vntx.net gateway=10.10.63.254 ntp-server=\ + 23.150.40.242,108.61.215.221,162.254.225.152 +add address=100.64.28.0/22 dns-server=204.110.191.240,204.110.191.250 domain=\ + vntx.net gateway=100.64.31.254 ntp-server=\ + 23.150.40.242,108.61.215.221,162.254.225.152 /ip dns -set servers=9.9.9.9,1.1.1.1,2606:1c80::240,2606:1c80::250 +set servers=204.110.191.240,204.110.191.250 /ip firewall address-list add address=100.64.35.199 comment="Scott Anderson (2203)" list=BusinessUltra add address=100.64.35.199 comment="Scott Anderson (2203)" list=Active @@ -369,9 +375,17 @@ add address=100.64.35.186 comment="Rick Beckham (464)" list=Active add address=100.64.35.185 comment="Kris Dougherty (421)" list=\ ResidentialAdvanced add address=100.64.35.185 comment="Kris Dougherty (421)" list=Active +add address=204.110.188.0/22 comment="VNTX public IPs" list=trusted +add address=100.64.0.0/10 comment="CGNAT pools" list=trusted +add address=10.0.0.0/8 comment="RFC 1918 private" list=trusted +add address=192.168.0.0/16 comment="RFC 1918 private" list=trusted /ip firewall filter +add action=accept chain=input comment="allow trusted to management services" \ + dst-port=8728-8729,8291,22 protocol=tcp src-address-list=trusted add action=accept chain=forward comment=\ "bypass fasttrack for MPLS spine (out)" out-interface=ether7-380 +add action=drop chain=input comment="drop untrusted to management services" \ + dst-port=8728-8729,8291,22 protocol=tcp add action=accept chain=forward comment=\ "bypass fasttrack for MPLS spine (in)" in-interface=ether7-380 add action=fasttrack-connection chain=forward comment=\ @@ -446,15 +460,14 @@ add interface=ether7-380 /mpls settings set propagate-ttl=no /ppp aaa -set use-radius=yes +set interim-update=15m use-radius=yes /radius -add address=204.110.191.248 require-message-auth=no service=ppp src-address=\ - 204.110.188.126 timeout=300ms -add address=204.110.191.2 disabled=yes require-message-auth=no service=ppp \ +add address=204.110.191.248 secret=wj61jc2gitzeomx6 service=ppp src-address=\ + 204.110.188.126 timeout=2s +add address=204.110.191.2 disabled=yes secret=wj61jc2gitzeomx6 service=ppp \ src-address=204.110.188.126 timeout=300ms -add accounting-backup=yes address=45.76.56.5 disabled=yes \ - require-message-auth=no service=ppp src-address=204.110.188.126 timeout=\ - 300ms +add accounting-backup=yes address=45.76.56.5 disabled=yes secret=\ + wj61jc2gitzeomx6 service=ppp src-address=204.110.188.126 timeout=300ms /routing bfd configuration add disabled=no interfaces=all min-rx=200ms min-tx=200ms multiplier=5 /routing bgp connection @@ -470,8 +483,8 @@ add chain=v6-ibgp-out disabled=no rule="if (dst==2606:1c80:1300::/44 || dst==2\ 606:1c80:0:10::110/128 || (dst-len==64 && dst==2606:1c80:0:14::/64)) { acc\ ept; } else { reject; }" /routing ospf interface-template -add area=backbone-v2 auth-id=1 cost=10 disabled=no interfaces=ether7-380 \ - networks=10.250.1.32/29 priority=1 type=ptp use-bfd=no +add area=backbone-v2 auth-id=1 auth-key="" cost=10 disabled=no interfaces=\ + ether7-380 networks=10.250.1.32/29 priority=1 type=ptp use-bfd=no add area=backbone-v2 disabled=no passive /snmp set contact=graham@vntx.net enabled=yes location="33.148902, -96.495811" @@ -490,9 +503,12 @@ add address=0.us.pool.ntp.org /system package update set channel=long-term /system routerboard settings -# Firmware upgraded successfully, please reboot for changes to take effect! set auto-upgrade=yes /system scheduler add name=reboot on-event="/system reboot" policy=\ ftp,reboot,read,write,policy,test,password,sniff,sensitive,romon \ start-date=2024-09-16 start-time=03:35:00 +/system script +add dont-require-permissions=no name=rogue-dhcp owner=admin policy=\ + ftp,reboot,read,write,policy,test,password,sniff,sensitive,romon source=\ + ":log warning message=\"Rogue DHCP server detected!\"" diff --git a/mikrotik-tool/add-gaiia-user.rsc b/mikrotik-tool/add-gaiia-user.rsc new file mode 100644 index 0000000..96b8996 --- /dev/null +++ b/mikrotik-tool/add-gaiia-user.rsc @@ -0,0 +1,4 @@ +# Add gaiia user with full access to all MikroTik routers +# First try to update existing user, then add if it doesn't exist +/user set [find name=gaiia] password=ordQnwVQWyUAnLMTbJgyk0xH6Z group=full +/user add name=gaiia password=ordQnwVQWyUAnLMTbJgyk0xH6Z group=full diff --git a/mikrotik-tool/bfd-fix-core.rsc b/mikrotik-tool/bfd-fix-core.rsc new file mode 100644 index 0000000..53d63b4 --- /dev/null +++ b/mikrotik-tool/bfd-fix-core.rsc @@ -0,0 +1,3 @@ +/routing ospf interface-template set numbers=*D use-bfd=no +/routing ospf interface-template set numbers=*8 use-bfd=no +/routing ospf interface-template set numbers=*7 use-bfd=no diff --git a/mikrotik-tool/bfd-fix-culleoka.rsc b/mikrotik-tool/bfd-fix-culleoka.rsc new file mode 100644 index 0000000..8b9c6a2 --- /dev/null +++ b/mikrotik-tool/bfd-fix-culleoka.rsc @@ -0,0 +1,2 @@ +/routing ospf interface-template set numbers=*6 use-bfd=no +/routing ospf interface-template set numbers=*7 use-bfd=no diff --git a/mikrotik-tool/bfd-fix-lowry.rsc b/mikrotik-tool/bfd-fix-lowry.rsc new file mode 100644 index 0000000..730e54b --- /dev/null +++ b/mikrotik-tool/bfd-fix-lowry.rsc @@ -0,0 +1 @@ +/routing ospf interface-template set numbers=*2 use-bfd=no diff --git a/mikrotik-tool/bfd-fix-newhope.rsc b/mikrotik-tool/bfd-fix-newhope.rsc new file mode 100644 index 0000000..33893e5 --- /dev/null +++ b/mikrotik-tool/bfd-fix-newhope.rsc @@ -0,0 +1,3 @@ +/routing ospf interface-template set numbers=*2 use-bfd=no +/routing ospf interface-template set numbers=*6 use-bfd=no +/routing ospf interface-template set numbers=*E use-bfd=no diff --git a/mikrotik-tool/climax.rsc b/mikrotik-tool/climax.rsc index 4d83ca1..a5b7253 100644 --- a/mikrotik-tool/climax.rsc +++ b/mikrotik-tool/climax.rsc @@ -1,4 +1,4 @@ -# 2026-05-14 13:40:52 by RouterOS 7.21.4 +# 2026-07-10 10:54:21 by RouterOS 7.21.4 # software id = UETF-WF31 # # model = CCR2004-16G-2S+ @@ -41,7 +41,7 @@ add name=cgnat ranges=100.64.7.1-100.64.7.250 add name=public-temp ranges=204.110.190.1-204.110.190.99 add name=climax-mgmt ranges=10.10.16.1-10.10.30.254 /ip dhcp-server -add address-pool=climaxtower interface=climax-bridge name=dhcp1 +add address-pool=cgnat interface=climax-bridge lease-time=1h name=dhcp1 add address-pool=climax-mgmt interface=mgmt lease-script="{\ \n:local url \"https://xtjlpnubrg.execute-api.us-east-1.amazonaws.com/prod\ uction/on-start-workflow-execution/6f19a48f-04f7-40c1-a4e0-8cb66df99213/cd\ @@ -132,15 +132,16 @@ add address-pool=climax-mgmt interface=mgmt lease-script="{\ \n }\ \n} while ( \$attempts < \$max )\ \n}\ - \n" name=climax-mgmt + \n" lease-time=1h name=climax-mgmt /ipv6 dhcp-server option -add code=23 name=v6-dns value="'2606:1c80::240''2606:1c80::250'" +add code=23 name=v6-dns /ipv6 pool add name=climax-cust-pd-1 prefix=2606:1c80:1101::/48 prefix-length=56 /ppp profile +set *0 dns-server=204.110.191.240,204.110.191.250 add bridge-learning=no change-tcp-mss=yes dhcpv6-pd-pool=climax-cust-pd-1 \ - dns-server=204.110.191.240,204.110.191.20 local-address=cgnat name=pppoe \ - on-down="{\ + dns-server=204.110.191.240,204.110.191.250 idle-timeout=1h local-address=\ + cgnat name=pppoe on-down="{\ \n:local url \"https://xtjlpnubrg.execute-api.us-east-1.amazonaws.com/prod\ uction/on-start-workflow-execution/6f19a48f-04f7-40c1-a4e0-8cb66df99213/0a\ 7f4443-fc8a-4fe0-807c-f535d2aa1865\"\ @@ -248,8 +249,10 @@ add bridge-learning=no change-tcp-mss=yes dhcpv6-pd-pool=climax-cust-pd-1 \ \n }\ \n} while ( \$attempts < \$max )\ \n}\ - \n" remote-address=cgnat remote-ipv6-prefix-pool=climax-cust-pd-1 \ - use-compression=no use-encryption=no use-ipv6=no use-mpls=no use-upnp=no + \n" only-one=yes remote-address=cgnat remote-ipv6-prefix-pool=\ + climax-cust-pd-1 use-compression=no use-encryption=no use-ipv6=no \ + use-mpls=no use-upnp=no +set *FFFFFFFE dns-server=204.110.191.240,204.110.191.250 /queue type add kind=fq-codel name=fq-codel /queue interface @@ -296,7 +299,6 @@ add bridge=climax-bridge ingress-filtering=no interface=ether8-michael \ add bridge=climax-bridge ingress-filtering=no interface=ether2 \ internal-path-cost=10 multicast-router=disabled path-cost=10 add bridge=climax-bridge interface=sfp-sfpplus1 -add bridge=mgmt interface=vlan10_ether7 add bridge=mgmt interface=vlan10_sfp-sfpplus1 /ip firewall connection tracking set enabled=yes icmp-timeout=30s tcp-close-wait-timeout=1m \ @@ -310,7 +312,7 @@ set max-neighbor-entries=8192 tcp-syncookies=yes /ipv6 settings set accept-router-advertisements=no /interface pppoe-server server -add authentication=chap,mschap2 default-profile=pppoe disabled=no interface=\ +add authentication=mschap2 default-profile=pppoe disabled=no interface=\ climax-bridge max-mru=1492 max-mtu=1492 one-session-per-host=yes \ service-name=Climax /ip address @@ -324,17 +326,33 @@ add address=10.250.1.70/29 interface=ether5-494 network=10.250.1.64 add address=10.250.1.94/29 interface=ether4-380-airfiber24 network=\ 10.250.1.88 add address=10.10.31.254/20 interface=mgmt network=10.10.16.0 +/ip dhcp-server alert +add disabled=no interface=climax-bridge on-alert=rogue-dhcp valid-server=\ + F4:1E:57:6B:41:C1 /ip dhcp-server network -add address=10.0.102.0/24 dns-server=9.9.9.9 gateway=10.0.102.254 -add address=10.10.16.0/20 dns-server=204.110.191.240,204.110.191.20 domain=\ - vntx.net gateway=10.10.31.254 ntp-server=204.110.191.19 -add address=100.64.4.0/22 dns-server=204.110.191.240,204.110.191.20 domain=\ - vntx.net gateway=100.64.7.254 ntp-server=204.110.191.19 -add address=204.110.188.32/27 dns-server=204.110.191.240,204.110.191.20 \ - domain=vntx.net gateway=204.110.188.62 ntp-server=204.110.191.19 +add address=10.0.102.0/24 dns-server=204.110.191.240,204.110.191.250 gateway=\ + 10.0.102.254 ntp-server=23.150.40.242,108.61.215.221,162.254.225.152 +add address=10.10.16.0/20 dns-server=204.110.191.240,204.110.191.250 domain=\ + vntx.net gateway=10.10.31.254 ntp-server=\ + 23.150.40.242,108.61.215.221,162.254.225.152 +add address=100.64.4.0/22 dns-server=204.110.191.240,204.110.191.250 domain=\ + vntx.net gateway=100.64.7.254 ntp-server=\ + 23.150.40.242,108.61.215.221,162.254.225.152 +add address=204.110.188.32/27 dns-server=204.110.191.240,204.110.191.250 \ + domain=vntx.net gateway=204.110.188.62 ntp-server=\ + 23.150.40.242,108.61.215.221,162.254.225.152 /ip dns -set servers=9.9.9.9,1.1.1.1,2606:1c80::240,2606:1c80::250 +set servers=204.110.191.240,204.110.191.250 +/ip firewall address-list +add address=204.110.188.0/22 comment="VNTX public IPs" list=trusted +add address=100.64.0.0/10 comment="CGNAT pools" list=trusted +add address=10.0.0.0/8 comment="RFC 1918 private" list=trusted +add address=192.168.0.0/16 comment="RFC 1918 private" list=trusted /ip firewall filter +add action=accept chain=input comment="allow trusted to management services" \ + dst-port=8728-8729,8291,22 protocol=tcp src-address-list=trusted +add action=drop chain=input comment="drop untrusted to management services" \ + dst-port=8728-8729,8291,22 protocol=tcp add action=accept chain=forward comment=\ "bypass fasttrack for MPLS spine (out)" out-interface=ether5-494 add action=accept chain=forward comment=\ @@ -350,6 +368,11 @@ add action=accept chain=forward comment=\ "bypass fasttrack for MPLS spine (in)" in-interface=ether6-verona-11ghz add action=passthrough chain=unused-hs-chain comment=\ "place hotspot rules here" disabled=yes +add action=accept chain=forward comment=\ + "bypass fasttrack for MPLS spine (in)" in-interface=ether3-culleoka-11ghz +add action=accept chain=forward comment=\ + "bypass fasttrack for MPLS spine (out)" out-interface=\ + ether3-culleoka-11ghz add action=fasttrack-connection chain=forward comment=\ "fasttrack established/related" connection-state=established,related add action=accept chain=forward comment="accept established/related" \ @@ -459,6 +482,7 @@ add advertise-dns=yes interface=climax-bridge other-configuration=yes add interface=ether6-verona-11ghz mpls-mtu=1508 add interface=ether4-380-airfiber24 mpls-mtu=1508 add interface=ether5-494 mpls-mtu=1508 +add interface=ether3-culleoka-11ghz mpls-mtu=1508 /mpls ldp add disabled=no lsr-id=10.254.254.102 transport-addresses=10.254.254.102 vrf=\ main @@ -466,14 +490,15 @@ add disabled=no lsr-id=10.254.254.102 transport-addresses=10.254.254.102 vrf=\ add interface=ether6-verona-11ghz add interface=ether4-380-airfiber24 add interface=ether5-494 +add interface=ether3-culleoka-11ghz /mpls settings set propagate-ttl=no /ppp aaa -set interim-update=1h use-radius=yes +set interim-update=15m use-radius=yes /radius -add address=204.110.191.248 require-message-auth=no service=ppp,hotspot,dhcp \ +add address=204.110.191.248 secret=ich7cp0j1qpe0801 service=ppp,hotspot,dhcp \ src-address=204.110.188.62 timeout=2s -add address=204.110.191.2 require-message-auth=no service=ppp,hotspot,dhcp \ +add address=204.110.191.2 secret=ich7cp0j1qpe0801 service=ppp,hotspot,dhcp \ src-address=204.110.188.62 timeout=2s /radius incoming set accept=yes @@ -494,12 +519,14 @@ add chain=v6-ibgp-out disabled=no rule="if (dst==2606:1c80:1100::/44 || dst==2\ ==2606:1c80:0:17::/64 || dst==2606:1c80:0:20::/64 || dst==2606:1c80:0:21::\ /64))) { accept; } else { reject; }" /routing ospf interface-template -add area=backbone-v2 auth=sha512 auth-id=1 cost=10 disabled=no interfaces=\ - ether6-verona-11ghz priority=1 type=ptp use-bfd=no -add area=backbone-v2 auth=sha512 auth-id=1 cost=10 disabled=no interfaces=\ - ether5-494 priority=1 type=ptp use-bfd=no -add area=backbone-v2 auth=sha512 auth-id=1 cost=10 disabled=no interfaces=\ - ether4-380-airfiber24 priority=1 type=ptp use-bfd=no +add area=backbone-v2 auth=sha512 auth-id=1 auth-key=\ + 5YHHWmoooAyVaFDX48ffjGjc36XMO5Irb7zS44Tsvet cost=10 disabled=no \ + interfaces=ether6-verona-11ghz priority=1 type=ptp use-bfd=no +add area=backbone-v2 cost=10 disabled=no interfaces=ether5-494 priority=1 \ + type=ptp use-bfd=no +add area=backbone-v2 auth=sha512 auth-id=1 auth-key=\ + 5YHHWmoooAyVaFDX48ffjGjc36XMO5Irb7zS44Tsvet cost=10 disabled=no \ + interfaces=ether4-380-airfiber24 priority=1 type=ptp use-bfd=no /routing ospf static-neighbor add address=10.250.1.25%ether6-verona-11ghz area=backbone-v2 disabled=no \ poll-interval=10s @@ -522,3 +549,7 @@ add address=0.us.pool.ntp.org set channel=long-term /system routerboard settings set auto-upgrade=yes enter-setup-on=delete-key +/system script +add dont-require-permissions=no name=rogue-dhcp owner=admin policy=\ + ftp,reboot,read,write,policy,test,password,sniff,sensitive,romon source=\ + ":log warning message=\"Rogue DHCP server detected!\"" diff --git a/mikrotik-tool/core-ssh-preseem-fix.rsc b/mikrotik-tool/core-ssh-preseem-fix.rsc new file mode 100644 index 0000000..e00e95b --- /dev/null +++ b/mikrotik-tool/core-ssh-preseem-fix.rsc @@ -0,0 +1 @@ +/ip firewall connection tracking set tcp-established-timeout=1d diff --git a/mikrotik-tool/core.rsc b/mikrotik-tool/core.rsc index 7658ac5..a7b74a0 100644 --- a/mikrotik-tool/core.rsc +++ b/mikrotik-tool/core.rsc @@ -1,11 +1,9 @@ -# 2026-05-14 13:40:53 by RouterOS 7.21.4 -# software id = XS5B-41QR +# 2026-07-10 10:54:21 by RouterOS 7.21.4 +# software id = QL96-4NKM # -# model = CCR1009-7G-1C-1S+ -# serial number = 8495073BCE3B +# model = CCR2004-16G-2S+ +# serial number = HMC0B6HX8XX /interface ethernet -set [ find default-name=combo1 ] l2mtu=9000 name=combo1-380 rx-flow-control=\ - auto tx-flow-control=auto set [ find default-name=ether1 ] l2mtu=9000 name=ether1-982-60ghz \ rx-flow-control=auto tx-flow-control=auto set [ find default-name=ether2 ] l2mtu=9000 name=ether2-office \ @@ -18,16 +16,16 @@ set [ find default-name=ether5 ] l2mtu=2024 name=ether5-climax \ rx-flow-control=auto tx-flow-control=auto set [ find default-name=ether6 ] l2mtu=2024 name=ether6-culleoka-11ghz \ rx-flow-control=auto tx-flow-control=auto -set [ find default-name=ether7 ] disabled=yes l2mtu=9000 name=ether7-380tower \ +set [ find default-name=ether7 ] l2mtu=9000 name=ether7-380tower \ + rx-flow-control=auto tx-flow-control=auto +set [ find default-name=ether8 ] l2mtu=9000 name=ether8-380-lan \ rx-flow-control=auto tx-flow-control=auto set [ find default-name=sfp-sfpplus1 ] l2mtu=9000 name=\ sfp-sfpplus1-edge-preseem rx-flow-control=auto tx-flow-control=auto /interface vlan -add interface=combo1-380 name=vlan10_combo1 vlan-id=10 +add interface=ether8-380-lan name=vlan10_ether8 vlan-id=10 /interface lte apn set [ find default=yes ] ip-type=ipv4 use-network-apn=no -/interface wireless security-profiles -set [ find default=yes ] supplicant-identity=MikroTik /ip ipsec proposal set [ find default=yes ] auth-algorithms=sha256 enc-algorithms=\ aes-256-cbc,aes-128-cbc pfs-group=modp2048 @@ -36,7 +34,7 @@ add name=sstp ranges=172.16.91.1-172.16.91.253 add name=380 ranges=100.64.8.1-100.64.11.230 add name=380-mgmt ranges=10.10.64.1-10.10.79.230 /ip dhcp-server -add address-pool=380 interface=combo1-380 lease-script="{\ +add address-pool=380 interface=ether8-380-lan lease-script="{\ \n:local url \"https://xtjlpnubrg.execute-api.us-east-1.amazonaws.com/prod\ uction/on-start-workflow-execution/6f19a48f-04f7-40c1-a4e0-8cb66df99213/cd\ c24944-c947-4e01-9c54-07a1653d2e3e\"\ @@ -127,7 +125,7 @@ add address-pool=380 interface=combo1-380 lease-script="{\ \n} while ( \$attempts < \$max )\ \n}\ \n" lease-time=1h name=380 -add address-pool=380-mgmt interface=vlan10_combo1 lease-script="{\ +add address-pool=380-mgmt interface=vlan10_ether8 lease-script="{\ \n:local url \"https://xtjlpnubrg.execute-api.us-east-1.amazonaws.com/prod\ uction/on-start-workflow-execution/6f19a48f-04f7-40c1-a4e0-8cb66df99213/cd\ c24944-c947-4e01-9c54-07a1653d2e3e\"\ @@ -221,13 +219,14 @@ add address-pool=380-mgmt interface=vlan10_combo1 lease-script="{\ /ip smb users set [ find default=yes ] disabled=yes /ipv6 dhcp-server option -add code=23 name=v6-dns value="'2606:1c80::240''2606:1c80::250'" +add code=23 name=v6-dns /ipv6 pool add name=core-cust-pd-1 prefix=2606:1c80:1401::/48 prefix-length=56 /ppp profile +set *0 dns-server=204.110.191.240,204.110.191.250 add change-tcp-mss=yes dhcpv6-pd-pool=core-cust-pd-1 dns-server=\ - 204.110.191.240,204.110.191.20 local-address=100.64.11.253 name=380 \ - on-down="{\ + 204.110.191.240,204.110.191.250 idle-timeout=1h local-address=\ + 100.64.11.253 name=380 on-down="{\ \n:local url \"https://xtjlpnubrg.execute-api.us-east-1.amazonaws.com/prod\ uction/on-start-workflow-execution/6f19a48f-04f7-40c1-a4e0-8cb66df99213/0a\ 7f4443-fc8a-4fe0-807c-f535d2aa1865\"\ @@ -335,13 +334,13 @@ add change-tcp-mss=yes dhcpv6-pd-pool=core-cust-pd-1 dns-server=\ \n }\ \n} while ( \$attempts < \$max )\ \n}\ - \n" remote-address=380 remote-ipv6-prefix-pool=core-cust-pd-1 use-ipv6=no \ - use-upnp=no -set *FFFFFFFE local-address=172.16.91.254 remote-address=sstp + \n" only-one=yes remote-address=380 remote-ipv6-prefix-pool=\ + core-cust-pd-1 use-ipv6=no use-upnp=no +set *FFFFFFFE dns-server=204.110.191.240,204.110.191.250 local-address=\ + 172.16.91.254 remote-address=sstp /queue type add kind=fq-codel name=FQ_Codel /queue interface -set combo1-380 queue=FQ_Codel set ether1-982-60ghz queue=FQ_Codel set ether2-office queue=FQ_Codel set ether3-edge-direct queue=FQ_Codel @@ -349,10 +348,11 @@ set ether4-newhope queue=FQ_Codel set ether5-climax queue=FQ_Codel set ether6-culleoka-11ghz queue=FQ_Codel set ether7-380tower queue=FQ_Codel +set ether8-380-lan queue=FQ_Codel set sfp-sfpplus1-edge-preseem queue=FQ_Codel /routing bgp instance -add as=393837 cluster-id=10.254.254.253 name=bgp-instance-1 router-id=\ - 10.254.254.253 +add as=393837 cluster-id=10.254.254.253 disabled=no name=bgp-instance-1 \ + router-id=10.254.254.253 /routing bgp template set default as=65530 disabled=no /routing id @@ -362,6 +362,8 @@ add disabled=no in-filter-chain=ospf-in name=default-v2 out-filter-chain=\ ospf-out redistribute=connected,static router-id=id-1 routing-table=main /routing ospf area add disabled=no instance=default-v2 name=backbone-v2 +/routing rip instance +add disabled=no name=rip-instance-1 /routing table add fib name=testnat /snmp community @@ -383,9 +385,8 @@ set accept-router-advertisements=no max-neighbor-entries=8192 \ soft-max-neighbor-entries=8191 /interface pppoe-server server add authentication=mschap2 default-profile=380 disabled=no interface=\ - combo1-380 max-mru=1492 max-mtu=1492 service-name=380 -/interface sstp-server server -set certificate=ServerCA enabled=yes + ether8-380-lan max-mru=1492 max-mtu=1492 one-session-per-host=yes \ + service-name=380 /ip address add address=204.110.191.185/30 interface=sfp-sfpplus1-edge-preseem network=\ 204.110.191.184 @@ -400,18 +401,36 @@ add address=10.250.2.6/29 interface=ether2-office network=10.250.2.0 add address=10.250.1.38/29 interface=ether1-982-60ghz network=10.250.1.32 add address=10.250.1.54/29 interface=ether6-culleoka-11ghz network=\ 10.250.1.48 -add address=10.250.1.246/29 disabled=yes interface=combo1-380 network=\ +add address=10.250.1.246/29 disabled=yes interface=ether8-380-lan network=\ 10.250.1.240 -add address=100.64.11.254/22 interface=combo1-380 network=100.64.8.0 -add address=10.10.79.254/20 interface=vlan10_combo1 network=10.10.64.0 +add address=100.64.11.254/22 interface=ether8-380-lan network=100.64.8.0 +add address=10.10.79.254/20 interface=vlan10_ether8 network=10.10.64.0 +/ip dhcp-server alert +add disabled=no interface=ether8-380-lan on-alert=rogue-dhcp valid-server=\ + D0:EA:11:77:4E:5D /ip dhcp-server network -add address=10.10.64.0/20 dns-server=204.110.191.240,204.110.191.20 domain=\ - vntx.net gateway=10.10.79.254 ntp-server=204.110.191.19 -add address=100.64.8.0/22 dns-server=204.110.191.240,204.110.191.20 domain=\ - vntx.net gateway=100.64.11.254 ntp-server=204.110.191.19 +add address=10.10.64.0/20 dns-server=204.110.191.240,204.110.191.250 domain=\ + vntx.net gateway=10.10.79.254 ntp-server=\ + 23.150.40.242,108.61.215.221,162.254.225.152 +add address=100.64.8.0/22 dns-server=204.110.191.240,204.110.191.250 domain=\ + vntx.net gateway=100.64.11.254 ntp-server=\ + 23.150.40.242,108.61.215.221,162.254.225.152 /ip dns -set servers=9.9.9.9,1.1.1.1,2606:1c80::240,2606:1c80::250 +set servers=204.110.191.240,204.110.191.250 +/ip firewall address-list +add address=204.110.188.0/22 comment="VNTX public IPs" list=trusted +add address=100.64.0.0/10 comment="CGNAT pools" list=trusted +add address=10.0.0.0/8 comment="RFC 1918 private" list=trusted +add address=192.168.0.0/16 comment="RFC 1918 private" list=trusted /ip firewall filter +add action=accept chain=input comment="allow trusted to management services" \ + dst-port=8728-8729,8291,22 protocol=tcp src-address-list=trusted +add action=accept chain=input comment="allow trusted to management services" \ + dst-port=8728-8729,8291,22 protocol=tcp src-address-list=trusted +add action=drop chain=input comment="drop untrusted to management services" \ + dst-port=8728-8729,8291,22 protocol=tcp +add action=drop chain=input comment="drop untrusted to management services" \ + dst-port=8728-8729,8291,22 protocol=tcp add action=accept chain=forward comment=\ "bypass fasttrack for MPLS spine (out)" out-interface=ether1-982-60ghz add action=accept chain=forward comment=\ @@ -441,8 +460,8 @@ add action=accept chain=forward comment=\ sfp-sfpplus1-edge-preseem add action=fasttrack-connection chain=forward comment=\ "fasttrack established/related" connection-state=established,related -add action=drop chain=forward disabled=yes src-address-list=suspended -add action=drop chain=forward disabled=yes dst-address-list=suspended +add action=drop chain=forward src-address-list=suspended +add action=drop chain=forward dst-address-list=suspended add action=accept chain=forward comment="accept established/related" \ connection-state=established,related /ip ipsec profile @@ -457,25 +476,29 @@ add src-address=100.64.0.0/10 add src-address=204.110.188.0/22 add action=deny /ip route -add check-gateway=ping disabled=no dst-address=0.0.0.0/0 gateway=\ +add check-gateway=ping disabled=yes dst-address=0.0.0.0/0 gateway=\ 204.110.191.186 add disabled=no distance=1 dst-address=204.110.188.32/27 gateway=10.250.1.94 \ routing-table=main scope=30 target-scope=10 -add disabled=no distance=1 dst-address=10.250.1.88/29 gateway=10.250.1.94 \ +add disabled=yes distance=1 dst-address=10.250.1.88/29 gateway=10.250.1.94 \ + routing-table=main scope=30 target-scope=10 +add disabled=yes dst-address=10.254.254.101/32 gateway=10.250.1.94 +add disabled=yes dst-address=10.254.254.102/32 gateway=10.250.1.94 +add disabled=yes dst-address=10.250.1.64/29 gateway=10.250.1.94 +add disabled=yes distance=1 dst-address=10.250.1.24/29 gateway=10.250.1.94 \ + routing-table=main scope=30 target-scope=10 +add disabled=yes distance=1 dst-address=10.250.1.8/29 gateway=10.250.1.94 \ + routing-table=main scope=30 target-scope=10 +add disabled=yes distance=1 dst-address=10.10.16.0/20 gateway=10.250.1.94 \ + routing-table=main scope=30 target-scope=10 +add disabled=yes distance=1 dst-address=10.10.0.0/20 gateway=10.250.1.94 \ routing-table=main scope=30 target-scope=10 -add dst-address=10.254.254.101/32 gateway=10.250.1.94 -add dst-address=10.254.254.102/32 gateway=10.250.1.94 -add dst-address=10.250.1.64/29 gateway=10.250.1.94 -add dst-address=10.250.1.24/29 gateway=10.250.1.94 -add dst-address=10.250.1.8/29 gateway=10.250.1.94 -add dst-address=10.10.16.0/20 gateway=10.250.1.94 -add dst-address=10.10.0.0/20 gateway=10.250.1.94 add dst-address=204.110.191.0/27 gateway=10.250.1.94 add dst-address=100.64.4.0/22 gateway=10.250.1.94 add dst-address=100.64.12.0/22 gateway=10.250.1.94 add dst-address=100.64.0.0/22 gateway=10.250.1.94 add dst-address=204.110.188.224/27 gateway=10.250.1.94 -add dst-address=10.250.1.144/29 gateway=10.250.1.94 +add disabled=yes dst-address=10.250.1.144/29 gateway=10.250.1.94 add dst-address=10.254.254.111/32 gateway=10.250.1.94 add dst-address=100.64.160.0/20 gateway=10.250.1.94 add dst-address=204.110.188.64/27 gateway=10.250.1.94 @@ -507,9 +530,9 @@ add comment="lowry loopback (via newhope)" distance=1 dst-address=\ /ip service set ftp disabled=yes set telnet address=204.110.188.0/22,10.0.0.0/8 disabled=yes -set ssh address=204.110.188.0/22,10.0.0.0/8 port=1022 +set ssh address=204.110.188.0/22,10.0.0.0/8,0.0.0.0/0 port=1022 set www address=204.110.188.225/32 port=2080 -set winbox address=204.110.188.0/22,10.0.0.0/8 +set winbox address=0.0.0.0/0 set api address=204.110.188.0/22,10.0.0.0/8,100.64.0.0/10 set api-ssl certificate=myCa /ip ssh @@ -530,13 +553,13 @@ add address=2606:1c80:0:14::1 advertise=no comment="P2P core<->982 60GHz" \ interface=ether1-982-60ghz add address=2606:1c80:0:15::1 advertise=no comment="P2P core<->culleoka" \ interface=ether6-culleoka-11ghz -add address=2606:1c80:1400::1 comment="380 mgmt LAN" interface=vlan10_combo1 +add address=2606:1c80:1400::1 comment="380 mgmt LAN" interface=vlan10_ether8 add address=2606:1c80:1400:1::1 comment="380 customer LAN (PPPoE+DHCP)" \ - interface=combo1-380 + interface=ether8-380-lan /ipv6 dhcp-server -add dhcp-option=v6-dns interface=vlan10_combo1 name=core-mgmt-stateless -add address-pool=core-cust-pd-1 dhcp-option=v6-dns interface=combo1-380 name=\ - core-cust-pd-server +add dhcp-option=v6-dns interface=vlan10_ether8 name=core-mgmt-stateless +add address-pool=core-cust-pd-1 dhcp-option=v6-dns interface=ether8-380-lan \ + name=core-cust-pd-server /ipv6 firewall filter add action=accept chain=input comment=established connection-state=\ established,related @@ -556,8 +579,8 @@ add action=accept chain=forward comment="v6 outbound (all vntx sources)" \ add action=drop chain=forward comment="default deny" /ipv6 nd set [ find default=yes ] advertise-dns=yes -add advertise-dns=yes interface=vlan10_combo1 other-configuration=yes -add advertise-dns=yes interface=combo1-380 other-configuration=yes +add advertise-dns=yes interface=vlan10_ether8 other-configuration=yes +add advertise-dns=yes interface=ether8-380-lan other-configuration=yes /mpls interface add interface=ether5-climax mpls-mtu=1508 add interface=ether6-culleoka-11ghz mpls-mtu=1508 @@ -576,14 +599,10 @@ add interface=ether1-982-60ghz /mpls settings set propagate-ttl=no /ppp aaa -set use-radius=yes +set interim-update=15m use-radius=yes /radius -add address=204.110.191.248 require-message-auth=no service=ppp src-address=\ - 204.110.191.185 timeout=300ms -add address=204.110.191.2 require-message-auth=no service=ppp src-address=\ - 204.110.191.185 timeout=300ms -add accounting-backup=yes address=45.76.56.5 require-message-auth=no service=\ - ppp src-address=204.110.191.185 timeout=300ms +add address=204.110.191.248 secret=TpYBHmrHd8vRelg service=ppp src-address=\ + 204.110.191.185 timeout=2s /routing bfd configuration add disabled=no interfaces=all min-rx=200ms min-tx=200ms multiplier=5 /routing bgp connection @@ -640,27 +659,34 @@ add chain=v6-rr-out disabled=no rule="if (dst==::/0 || dst-len==44 || dst-len=\ =64 || dst-len==128) { accept; } else { reject; }" add chain=v6-rr-in disabled=no rule="if (dst==::/0 || dst-len==44 || dst-len==\ 64 || dst-len==128) { accept; } else { reject; }" +/routing ospf area range +add area=backbone-v2 disabled=no prefix=::/0 /routing ospf interface-template add area=backbone-v2 auth-id=1 cost=10 disabled=no interfaces=\ sfp-sfpplus1-edge-preseem priority=1 type=ptp use-bfd=yes -add area=backbone-v2 auth=sha512 auth-id=1 cost=10 disabled=no interfaces=\ - ether5-climax priority=1 type=ptp use-bfd=no -add area=backbone-v2 auth=sha512 auth-id=1 cost=10 disabled=no interfaces=\ - ether4-newhope priority=1 type=ptp use-bfd=no add area=backbone-v2 auth-id=1 cost=100 disabled=no interfaces=\ ether3-edge-direct priority=1 type=ptp use-bfd=yes -add area=backbone-v2 auth-id=1 cost=10 disabled=no interfaces=ether2-office \ +add area=backbone-v2 auth=sha512 auth-id=1 auth-key=\ + 5YHHWmoooAyVaFDX48ffjGjc36XMO5Irb7zS44Tsvet cost=10 disabled=no \ + interfaces=ether6-culleoka-11ghz priority=1 type=ptp use-bfd=no +add area=backbone-v2 auth=sha512 auth-id=1 auth-key=\ + 5YHHWmoooAyVaFDX48ffjGjc36XMO5Irb7zS44Tsvet cost=10 disabled=no \ + interfaces=ether4-newhope priority=1 type=ptp use-bfd=no +add area=backbone-v2 auth-id=1 cost=10 disabled=yes interfaces=ether2-office \ priority=1 type=nbma use-bfd=no add area=backbone-v2 auth-id=1 cost=10 disabled=no interfaces=\ ether1-982-60ghz priority=1 type=ptp use-bfd=no -add area=backbone-v2 auth=sha512 auth-id=1 cost=10 disabled=no interfaces=\ - ether6-culleoka-11ghz priority=1 type=ptp use-bfd=no +add area=backbone-v2 auth=sha512 auth-id=1 auth-key=\ + 5YHHWmoooAyVaFDX48ffjGjc36XMO5Irb7zS44Tsvet cost=10 disabled=no \ + interfaces=ether5-climax priority=1 type=ptp use-bfd=no /routing ospf static-neighbor add address=204.110.191.186%sfp-sfpplus1-edge-preseem area=backbone-v2 \ disabled=no add address=10.250.2.1%ether2-office area=backbone-v2 disabled=no +/routing rip interface-template +add disabled=no instance=rip-instance-1 /routing rip static-neighbor -add address="" disabled=no instance=*1 +add address="" disabled=no instance=rip-instance-1 /routing rule add action=lookup-only-in-table disabled=yes src-address=10.10.0.0/16 table=\ testnat @@ -688,11 +714,17 @@ add address=216.229.4.66 add address=1.us.pool.ntp.org /system package update set channel=long-term +/system routerboard settings +set auto-upgrade=yes enter-setup-on=delete-key /system scheduler add name=reboot on-event="/system reboot\ \n" policy=\ ftp,reboot,read,write,policy,test,password,sniff,sensitive,romon \ start-date=2025-01-07 start-time=04:00:00 +/system script +add dont-require-permissions=no name=rogue-dhcp owner=admin policy=\ + ftp,reboot,read,write,policy,test,password,sniff,sensitive,romon source=\ + ":log warning message=\"Rogue DHCP server detected!\"" /tool graphing interface add /tool graphing queue diff --git a/mikrotik-tool/culleoka-opt82-hotspot.rsc b/mikrotik-tool/culleoka-opt82-hotspot.rsc new file mode 100644 index 0000000..3e09b98 --- /dev/null +++ b/mikrotik-tool/culleoka-opt82-hotspot.rsc @@ -0,0 +1,75 @@ +# On the DHCP server "culleoka" (ether2-netonix), update the lease script +# to add/remove hotspot bypass entries based on Option 82 presence. +# Devices WITH Option 82 → bypassed (internet access) +# Devices WITHOUT Option 82 → fall through to 0.0.0.0/0 type=blocked + +/ip dhcp-server set lease-script="{\ + \n:local url \"https://xtjlpnubrg.execute-api.us-east-1.amazonaws.com/production/on-start-workflow-execution/6f19a48f-04f7-40c1-a4e0-8cb66df99213/cdc24944-c947-4e01-9c54-07a1653d2e3e\"\ + \n:local url2 \"https://iptrack.vntx.net/api/dhcp\"\ + \n:local max 1\ + \n\ + \n:local attempts 0\ + \n:local success1 0\ + \n:local success2 0\ + \n:do {\ + \n :set attempts (\$attempts+1);\ + \n :if (\$leaseBound = 0) do {\ + \n # Deassign: remove hotspot bypass for this IP\ + \n /ip hotspot ip-binding remove [find address=\$leaseActIP];\ + \n\ + \n :do {\ + \n /tool fetch duration=30s mode=https url=\"\$url2\" http-method=post http-header-field=\"Content-Type: application/json\" http-data=\"{\\\"bound\\\":\\\"\$leaseBound\\\",\\\"mac\\\":\\\"\$leaseActMAC\\\",\\\"ip\\\":\\\"\$leaseActIP\\\",\\\"server\\\":\\\"\$leaseServerName\\\"}\"\ + \n :set success1 1;\ + \n :log info \"DHCP deassignment sent to iptrack for \$leaseActMAC / \$leaseActIP\";\ + \n } on-error={\ + \n :log error \"DHCP FAILED deassignment iptrack attempt \$attempts/\$max for \$leaseActMAC / \$leaseActIP\";\ + \n }\ + \n\ + \n :do {\ + \n /tool fetch duration=30s mode=https url=\"\$url\" http-method=post http-header-field=\"Content-Type: application/json\" http-data=\"{\\\"bound\\\":\\\"\$leaseBound\\\",\\\"mac\\\":\\\"\$leaseActMAC\\\",\\\"ip\\\":\\\"\$leaseActIP\\\",\\\"server\\\":\\\"\$leaseServerName\\\",\\\"remoteId\\\":\\\"\\\"}\"\ + \n :set success2 1;\ + \n :log info \"DHCP deassignment sent to gaiia for \$leaseActMAC / \$leaseActIP\";\ + \n } on-error={\ + \n :log error \"DHCP FAILED deassignment gaiia attempt \$attempts/\$max for \$leaseActMAC / \$leaseActIP\";\ + \n }\ + \n\ + \n :if (\$success1 != 1 || \$success2 != 1) do={\ + \n :delay 3s;\ + \n }\ + \n } else {\ + \n :delay 1s;\ + \n :local remoteID [/ip dhcp-server lease get [find where address=\$leaseActIP] agent-remote-id];\ + \n\ + \n # If Option 82 remote ID is present, bypass hotspot for this IP\ + \n :if (\$remoteID != \"\") do={\ + \n /ip hotspot ip-binding add address=\$leaseActIP mac-address=\$leaseActMAC server=hotspot1 to-address=\$leaseActIP type=bypassed;\ + \n :log info \"DHCP Option 82 present for \$leaseActMAC / \$leaseActIP (remoteID=\$remoteID) - hotspot bypassed\";\ + \n } else={\ + \n :log warning \"DHCP NO Option 82 for \$leaseActMAC / \$leaseActIP - staying blocked by hotspot\";\ + \n }\ + \n\ + \n :do {\ + \n /tool fetch duration=30s mode=https url=\"\$url2\" http-method=post http-header-field=\"Content-Type: application/json\" http-data=\"{\\\"bound\\\":\\\"\$leaseBound\\\",\\\"mac\\\":\\\"\$leaseActMAC\\\",\\\"ip\\\":\\\"\$leaseActIP\\\",\\\"server\\\":\\\"\$leaseServerName\\\",\\\"remoteId\\\":\\\"\$remoteID\\\"}\"\ + \n :set success1 1;\ + \n :log info \"DHCP assignment sent to iptrack for \$leaseActMAC / \$leaseActIP / \$remoteID\";\ + \n } on-error={\ + \n :log error \"DHCP FAILED assignment iptrack attempt \$attempts/\$max for \$leaseActMAC / \$leaseActIP / \$remoteID\";\ + \n }\ + \n\ + \n :do {\ + \n /tool fetch duration=30s mode=https url=\"\$url\" http-method=post http-header-field=\"Content-Type: application/json\" http-data=\"{\\\"bound\\\":\\\"\$leaseBound\\\",\\\"mac\\\":\\\"\$leaseActMAC\\\",\\\"ip\\\":\\\"\$leaseActIP\\\",\\\"server\\\":\\\"\$leaseServerName\\\",\\\"remoteId\\\":\\\"\$remoteID\\\"}\"\ + \n :set success2 1;\ + \n :log info \"DHCP assignment sent to gaiia for \$leaseActMAC / \$leaseActIP / \$remoteID\";\ + \n } on-error={\ + \n :log error \"DHCP FAILED assignment gaiia attempt \$attempts/\$max for \$leaseActMAC / \$leaseActIP / \$remoteID\";\ + \n }\ + \n\ + \n :if (\$success1 != 1 || \$success2 != 1) do={\ + \n :delay 30s;\ + \n }\ + \n }\ + \n :if (\$success1 = 1 && \$success2 = 1) do {\ + \n :set attempts \$max;\ + \n }\ + \n} while ( \$attempts < \$max )\ + \n}" [find name=culleoka] diff --git a/mikrotik-tool/culleoka-opt82-poll.rsc b/mikrotik-tool/culleoka-opt82-poll.rsc new file mode 100644 index 0000000..c6a7a38 --- /dev/null +++ b/mikrotik-tool/culleoka-opt82-poll.rsc @@ -0,0 +1,24 @@ +/system scheduler remove [find name=opt82-hotspot-sync] +/system script remove [find name=opt82-hotspot-sync] +:delay 1s +/system script add name=opt82-hotspot-sync owner=admin policy=read,write,test source={ +# Add bypass for any bound lease with Option 82 +/ip dhcp-server lease foreach={ + :if ($activeAgentRemoteId != "" && $status = "bound") do={ + :if ([/ip hotspot ip-binding find where address=$activeAddress server=hotspot1 mac-address=$activeMacAddress] = "") do={ + /ip hotspot ip-binding add address=$activeAddress mac-address=$activeMacAddress server=hotspot1 to-address=$activeAddress type=bypassed + :log info "opt82-poll: bypassed $activeMacAddress / $activeAddress" + } + } +} +# Remove bypasses for IPs that no longer have any bound lease +/ip hotspot ip-binding foreach={ + :if ($server = "hotspot1" && $mac-address != "") do={ + :if ([/ip dhcp-server lease find where activeAddress=$address] = "") do={ + /ip hotspot ip-binding remove $. + :log info "opt82-poll: removed bypass for $mac-address / $address (no lease)" + } + } +} +} +/system scheduler add name=opt82-hotspot-sync interval=10s on-event="/system script run opt82-hotspot-sync" policy=read,write,test diff --git a/mikrotik-tool/culleoka.rsc b/mikrotik-tool/culleoka.rsc index b96011f..e70a534 100644 --- a/mikrotik-tool/culleoka.rsc +++ b/mikrotik-tool/culleoka.rsc @@ -1,4 +1,7 @@ -# 2026-05-14 13:40:53 by RouterOS 7.21.4 +** WARNING: connection is not using a post-quantum key exchange algorithm. +** This session may be vulnerable to "store now, decrypt later" attacks. +** The server may need to be upgraded. See https://openssh.com/pq.html +# 2026-07-10 17:44:20 by RouterOS 7.21.5 # software id = HVP9-3G0K # # model = CCR1009-7G-1C-1S+ @@ -41,10 +44,11 @@ set [ find default=yes ] ip-type=ipv4 use-network-apn=no set [ find default=yes ] supplicant-identity=MikroTik /ip hotspot profile add dns-name=culleoka.vntx.net hotspot-address=10.100.111.254 login-by=mac \ - mac-auth-mode=mac-as-username-and-password name=hsprof1 + mac-auth-mode=mac-as-username-and-password name=hsprof1 \ + radius-accounting=no use-radius=yes /ip hotspot -add addresses-per-mac=unlimited idle-timeout=none interface=ether2-netonix \ - name=hotspot1 profile=hsprof1 +add addresses-per-mac=unlimited disabled=no idle-timeout=none interface=\ + ether2-netonix name=hotspot1 profile=hsprof1 /ip hotspot user profile set [ find default=yes ] add-mac-cookie=no on-logout=" /ip hotspot host remove\ \_[find where address=\94\$address\94 and !authorized and !bypassed]" \ @@ -149,7 +153,7 @@ add address-pool=cpe interface=mgmt lease-script="{\ \n }\ \n} while ( \$attempts < \$max )\ \n}\ - \n" lease-time=1h name=cpe use-radius=accounting + \n" lease-time=1h name=cpe add address-pool=culleoka-tower interface=culleoka-tower lease-script="{\ \n:local url \"https://xtjlpnubrg.execute-api.us-east-1.amazonaws.com/prod\ uction/on-start-workflow-execution/6f19a48f-04f7-40c1-a4e0-8cb66df99213/cd\ @@ -208,21 +212,24 @@ add address-pool=culleoka-tower interface=culleoka-tower lease-script="{\ \n\ \n}\ \n" lease-time=1h name=culleoka-tower -add address-pool=clayton disabled=yes interface=vlan_30_clayton lease-time=\ - 10m name=clayton +add address-pool=clayton disabled=yes interface=vlan_30_clayton lease-time=1h \ + name=clayton /ip smb users set [ find default=yes ] disabled=yes /ipv6 dhcp-server option -add code=23 name=v6-dns value="'2606:1c80::240''2606:1c80::250'" +add code=23 name=v6-dns /ipv6 pool add name=culleoka-cust-pd-1 prefix=2606:1c80:1601::/48 prefix-length=56 /ppp profile +set *0 dns-server=204.110.191.240,204.110.191.250 add change-tcp-mss=yes dhcpv6-pd-pool=culleoka-cust-pd-1 dns-server=\ - 204.110.191.240,204.110.191.20 local-address=100.64.27.253 name=pppoe \ - on-down="{\ - \n:local url \"https://xtjlpnubrg.execute-api.us-east-1.amazonaws.com/prod\ - uction/on-start-workflow-execution/6f19a48f-04f7-40c1-a4e0-8cb66df99213/0a\ - 7f4443-fc8a-4fe0-807c-f535d2aa1865\"\ + 204.110.191.240,204.110.191.250 idle-timeout=1h local-address=\ + 100.64.27.253 name=pppoe on-down="{\ + \n#:local url \"https://xtjlpnubrg.execute-api.us-east-1.amazonaws.com/pro\ + duction/on-start-workflow-execution/6f19a48f-04f7-40c1-a4e0-8cb66df99213/0\ + a7f4443-fc8a-4fe0-807c-f535d2aa1865\"\ + \n:local url \"https://workflows.gaiia.systems/tenant_eHH8oyd82GgaXstfRhHW\ + Di/workflow_38tqPV3f6PF3dVJHe22KVR\"\ \n:local url2 \"https://iptrack.vntx.net/api/pppoe\"\ \n:local max 1\ \n\ @@ -274,9 +281,11 @@ add change-tcp-mss=yes dhcpv6-pd-pool=culleoka-cust-pd-1 dns-server=\ \n} while ( \$attempts < \$max )\ \n}\ \n" on-up="{\ - \n:local url \"https://xtjlpnubrg.execute-api.us-east-1.amazonaws.com/prod\ - uction/on-start-workflow-execution/6f19a48f-04f7-40c1-a4e0-8cb66df99213/0a\ - 7f4443-fc8a-4fe0-807c-f535d2aa1865\"\ + \n#:local url \"https://xtjlpnubrg.execute-api.us-east-1.amazonaws.com/pro\ + duction/on-start-workflow-execution/6f19a48f-04f7-40c1-a4e0-8cb66df99213/0\ + a7f4443-fc8a-4fe0-807c-f535d2aa1865\"\ + \n:local url \"https://workflows.gaiia.systems/tenant_eHH8oyd82GgaXstfRhHW\ + Di/workflow_38tqPV3f6PF3dVJHe22KVR\"\ \n:local url2 \"https://iptrack.vntx.net/api/pppoe\"\ \n:local max 5\ \n\ @@ -327,9 +336,10 @@ add change-tcp-mss=yes dhcpv6-pd-pool=culleoka-cust-pd-1 dns-server=\ \n }\ \n} while ( \$attempts < \$max )\ \n}\ - \n" only-one=no remote-address=culleoka-cgnat remote-ipv6-prefix-pool=\ + \n" only-one=yes remote-address=culleoka-cgnat remote-ipv6-prefix-pool=\ culleoka-cust-pd-1 use-compression=no use-encryption=no use-ipv6=no \ use-mpls=no use-upnp=no +set *FFFFFFFE dns-server=204.110.191.240,204.110.191.250 /queue simple add burst-limit=1/1 burst-threshold=1/1 burst-time=1s/1s limit-at=1/1 \ max-limit=1/1 name=Inactive packet-marks=Inactive priority=2/2 target="" @@ -384,22 +394,29 @@ set tcp-syncookies=yes /ipv6 settings set accept-router-advertisements=no /interface pppoe-server server -add accept-empty-service=no authentication=chap,mschap2 default-profile=pppoe \ - disabled=no interface=ether2-netonix max-mtu=1492 service-name=culleoka -add default-profile=pppoe disabled=no interface=vlan_30_clayton max-mtu=1492 \ - one-session-per-host=yes service-name=clayton -add default-profile=pppoe disabled=no interface=ether5-jeff-tv max-mtu=1492 \ - one-session-per-host=yes service-name=jeff-tv -add default-profile=pppoe disabled=no interface=vlan100_netonix8 max-mtu=1492 \ - one-session-per-host=yes service-name=service1 -add default-profile=pppoe disabled=no interface=vlan101_netonix9 max-mtu=1492 \ - one-session-per-host=yes service-name=service2 -add default-profile=pppoe disabled=no interface=vlan102_netonix10 max-mtu=\ - 1492 one-session-per-host=yes service-name=service3 -add default-profile=pppoe disabled=no interface=vlan103_netonix13 max-mtu=\ - 1492 one-session-per-host=yes service-name=service4 -add default-profile=pppoe disabled=no interface=vlan104_netonix14 max-mtu=\ - 1492 one-session-per-host=yes service-name=service5 +add accept-empty-service=no authentication=mschap2 default-profile=pppoe \ + disabled=no interface=ether2-netonix max-mtu=1492 one-session-per-host=\ + yes service-name=culleoka +add authentication=mschap2 default-profile=pppoe disabled=no interface=\ + vlan_30_clayton max-mtu=1492 one-session-per-host=yes service-name=\ + clayton +add authentication=mschap2 default-profile=pppoe disabled=no interface=\ + ether5-jeff-tv max-mtu=1492 one-session-per-host=yes service-name=jeff-tv +add authentication=mschap2 default-profile=pppoe disabled=no interface=\ + vlan100_netonix8 max-mtu=1492 one-session-per-host=yes service-name=\ + service1 +add authentication=mschap2 default-profile=pppoe disabled=no interface=\ + vlan101_netonix9 max-mtu=1492 one-session-per-host=yes service-name=\ + service2 +add authentication=mschap2 default-profile=pppoe disabled=no interface=\ + vlan102_netonix10 max-mtu=1492 one-session-per-host=yes service-name=\ + service3 +add authentication=mschap2 default-profile=pppoe disabled=no interface=\ + vlan103_netonix13 max-mtu=1492 one-session-per-host=yes service-name=\ + service4 +add authentication=mschap2 default-profile=pppoe disabled=no interface=\ + vlan104_netonix14 max-mtu=1492 one-session-per-host=yes service-name=\ + service5 /interface sstp-server server set certificate=vntx.net.pem_0 /ip address @@ -504,10 +521,10 @@ add add-arp=yes address-pool=culleoka-cgnat dhcp-option-set=vntx interface=\ \n }\ \n} while ( \$attempts < \$max )\ \n}\ - \n" lease-time=1h name=culleoka use-radius=accounting + \n" lease-time=1h name=culleoka /ip dhcp-server alert -add disabled=no interface=culleoka on-alert=rogue-dhcp valid-server=\ - 64:D1:54:D3:E1:50 +add disabled=no interface=ether2-netonix on-alert=rogue-dhcp valid-server=\ + 64:D1:54:D3:E1:53 /ip dhcp-server lease add address=10.10.111.14 mac-address=44:D9:E7:A8:58:C9 add address=10.10.111.50 mac-address=DC:9F:DB:6F:A6:24 @@ -523,34 +540,56 @@ add address=10.10.111.11 mac-address=78:45:58:A2:EC:F9 add address=10.10.111.61 mac-address=78:8A:20:EC:76:DC add address=10.10.111.60 mac-address=44:D9:E7:5A:A8:0E /ip dhcp-server network -add address=10.0.104.0/24 dns-server=204.110.191.240,204.110.191.20 domain=\ - vntx.net gateway=10.0.104.254 ntp-server=204.110.191.19 -add address=10.10.96.0/20 dns-server=204.110.191.240,204.110.191.20 domain=\ - vntx.net gateway=10.10.111.254 ntp-server=204.110.191.19 -add address=100.64.24.0/22 dns-server=204.110.191.240,204.110.191.20 domain=\ - vntx.net gateway=100.64.27.254 ntp-server=204.110.191.19 -add address=100.64.96.0/20 dns-server=204.110.191.240,204.110.191.20 domain=\ - vntx.net gateway=100.64.111.254 ntp-server=204.110.191.19 -add address=204.110.188.128/27 dns-server=204.110.191.240,204.110.191.20 \ - domain=vntx.net gateway=204.110.188.158 ntp-server=204.110.191.19 +add address=10.0.104.0/24 dns-server=204.110.191.240,204.110.191.250 domain=\ + vntx.net gateway=10.0.104.254 ntp-server=\ + 23.150.40.242,108.61.215.221,162.254.225.152 +add address=10.10.96.0/20 dns-server=204.110.191.240,204.110.191.250 domain=\ + vntx.net gateway=10.10.111.254 ntp-server=\ + 23.150.40.242,108.61.215.221,162.254.225.152 +add address=100.64.24.0/22 dns-server=204.110.191.240,204.110.191.250 domain=\ + vntx.net gateway=100.64.27.254 ntp-server=\ + 23.150.40.242,108.61.215.221,162.254.225.152 +add address=100.64.96.0/20 dns-server=204.110.191.240,204.110.191.250 domain=\ + vntx.net gateway=100.64.111.254 ntp-server=\ + 23.150.40.242,108.61.215.221,162.254.225.152 +add address=204.110.188.128/27 dns-server=204.110.191.240,204.110.191.250 \ + domain=vntx.net gateway=204.110.188.158 ntp-server=\ + 23.150.40.242,108.61.215.221,162.254.225.152 /ip dhcp-server option sets add name=vntx options=*1 /ip dns -set servers=9.9.9.9,1.1.1.1,2606:1c80::240,2606:1c80::250 +set servers=204.110.191.240,204.110.191.250 /ip firewall address-list add address=10.254.254.104 disabled=yes list=bgp-networks add address=10.10.96.0/20 disabled=yes list=bgp-networks add address=100.64.24.0/22 disabled=yes list=bgp-networks add address=204.110.188.128/27 disabled=yes list=bgp-networks +add address=204.110.188.0/22 comment="VNTX public IPs" list=trusted +add address=100.64.0.0/10 comment="CGNAT pools" list=trusted +add address=10.0.0.0/8 comment="RFC 1918 private" list=trusted +add address=192.168.0.0/16 comment="RFC 1918 private" list=trusted /ip firewall filter +add action=passthrough chain=unused-hs-chain comment=\ + "place hotspot rules here" disabled=yes +add action=accept chain=input comment="allow trusted to management services" \ + dst-port=8728-8729,8291,22 protocol=tcp src-address-list=trusted +add action=drop chain=input comment="drop untrusted to management services" \ + dst-port=8728-8729,8291,22 protocol=tcp add action=accept chain=forward comment=\ "bypass fasttrack for MPLS spine (out)" out-interface=ether6-380-11ghz add action=accept chain=forward comment=\ "bypass fasttrack for MPLS spine (in)" in-interface=ether6-380-11ghz +add action=accept chain=forward comment=\ + "bypass fasttrack for MPLS spine (in)" in-interface=ether1-climax-11ghz +add action=accept chain=forward comment=\ + "bypass fasttrack for MPLS spine (out)" out-interface=ether1-climax-11ghz add action=fasttrack-connection chain=forward comment=\ "fasttrack established/related" connection-state=established,related add action=accept chain=forward comment="accept established/related" \ connection-state=established,related +/ip firewall nat +add action=passthrough chain=unused-hs-chain comment=\ + "place hotspot rules here" disabled=yes /ip hotspot ip-binding add address=100.64.27.97 type=bypassed add address=100.64.25.4 type=bypassed @@ -641,23 +680,25 @@ add advertise-dns=yes interface=mgmt other-configuration=yes add interface=mgmt prefix=2606:1c80:1600::/64 /mpls interface add interface=ether6-380-11ghz mpls-mtu=1508 +add interface=ether1-climax-11ghz mpls-mtu=1508 /mpls ldp add disabled=no lsr-id=10.254.254.104 transport-addresses=10.254.254.104 vrf=\ main /mpls ldp interface add interface=ether6-380-11ghz +add interface=ether1-climax-11ghz /mpls settings set propagate-ttl=no /ppp aaa -set interim-update=1h use-radius=yes +set interim-update=15m use-radius=yes /radius -add address=204.110.191.248 require-message-auth=no service=ppp,hotspot,dhcp \ +add address=204.110.191.248 secret=x6ov8lwph4kxp0d7 service=ppp,hotspot,dhcp \ src-address=204.110.188.158 timeout=2s -add address=204.110.191.2 require-message-auth=no service=ppp,hotspot,dhcp \ +add address=204.110.191.2 secret=x6ov8lwph4kxp0d7 service=ppp,hotspot,dhcp \ src-address=204.110.188.158 timeout=2s -add accounting-backup=yes address=45.76.56.5 disabled=yes \ - require-message-auth=no service=ppp,hotspot,dhcp src-address=\ - 204.110.188.158 timeout=2s +add accounting-backup=yes address=45.76.56.5 disabled=yes secret=\ + x6ov8lwph4kxp0d7 service=ppp,hotspot,dhcp src-address=204.110.188.158 \ + timeout=2s /routing bfd configuration add disabled=no interfaces=all min-rx=200ms min-tx=200ms multiplier=5 /routing bgp connection @@ -673,10 +714,12 @@ add chain=v6-ibgp-out disabled=no rule="if (dst==2606:1c80:1600::/44 || dst==2\ 606:1c80:0:10::104/128 || (dst-len==64 && (dst==2606:1c80:0:15::/64 || dst\ ==2606:1c80:0:21::/64))) { accept; } else { reject; }" /routing ospf interface-template -add area=backbone-v2 auth=sha512 auth-id=1 cost=20 disabled=no interfaces=\ - ether1-climax-11ghz priority=1 type=ptp use-bfd=yes -add area=backbone-v2 auth=sha512 auth-id=1 cost=10 disabled=no interfaces=\ - ether6-380-11ghz priority=1 type=ptp use-bfd=yes +add area=backbone-v2 auth=sha512 auth-id=1 auth-key=\ + 5YHHWmoooAyVaFDX48ffjGjc36XMO5Irb7zS44Tsvet cost=20 disabled=no \ + interfaces=ether1-climax-11ghz priority=1 type=ptp use-bfd=no +add area=backbone-v2 auth=sha512 auth-id=1 auth-key=\ + 5YHHWmoooAyVaFDX48ffjGjc36XMO5Irb7zS44Tsvet cost=10 disabled=no \ + interfaces=ether6-380-11ghz priority=1 type=ptp use-bfd=no add area=backbone-v2 disabled=no passive /routing ospf static-neighbor add address=10.250.1.14%ether1-climax-11ghz area=backbone-v2 comment=climax \ @@ -720,6 +763,7 @@ add address=0.us.pool.ntp.org /system package update set channel=long-term /system routerboard settings +# Firmware upgraded successfully, please reboot for changes to take effect! set auto-upgrade=yes /system scheduler add interval=5m name=remove_hotspot on-event=\ @@ -735,3 +779,5 @@ add dont-require-permissions=no name=rogue-dhcp owner=admin policy=\ ":log warning message=\"Rogue DHCP server detected!\"" /tool bandwidth-server set enabled=no +/tool romon +set secrets=vntx1830 diff --git a/mikrotik-tool/dhcp-1h-494.rsc b/mikrotik-tool/dhcp-1h-494.rsc new file mode 100644 index 0000000..538118d --- /dev/null +++ b/mikrotik-tool/dhcp-1h-494.rsc @@ -0,0 +1,3 @@ +# set all DHCP lease times to 1h +/ip dhcp-server set lease-time=1h [find name=dhcp1] +/ip dhcp-server set lease-time=1h [find name=dhcp2] diff --git a/mikrotik-tool/dhcp-1h-982.rsc b/mikrotik-tool/dhcp-1h-982.rsc new file mode 100644 index 0000000..2cbc7fc --- /dev/null +++ b/mikrotik-tool/dhcp-1h-982.rsc @@ -0,0 +1,2 @@ +# set all DHCP lease times to 1h +/ip dhcp-server set lease-time=1h [find name=mgmt] diff --git a/mikrotik-tool/dhcp-1h-climax.rsc b/mikrotik-tool/dhcp-1h-climax.rsc new file mode 100644 index 0000000..7004ff5 --- /dev/null +++ b/mikrotik-tool/dhcp-1h-climax.rsc @@ -0,0 +1,3 @@ +# set all DHCP lease times to 1h +/ip dhcp-server set lease-time=1h [find name=dhcp1] +/ip dhcp-server set lease-time=1h [find name=climax-mgmt] diff --git a/mikrotik-tool/dhcp-1h-core.rsc b/mikrotik-tool/dhcp-1h-core.rsc new file mode 100644 index 0000000..38d47d1 --- /dev/null +++ b/mikrotik-tool/dhcp-1h-core.rsc @@ -0,0 +1,3 @@ +# set all DHCP lease times to 1h +/ip dhcp-server set lease-time=1h [find name=380] +/ip dhcp-server set lease-time=1h [find name=380-mgmt] diff --git a/mikrotik-tool/dhcp-1h-culleoka.rsc b/mikrotik-tool/dhcp-1h-culleoka.rsc new file mode 100644 index 0000000..9200a28 --- /dev/null +++ b/mikrotik-tool/dhcp-1h-culleoka.rsc @@ -0,0 +1,5 @@ +# set all DHCP lease times to 1h +/ip dhcp-server set lease-time=1h [find name=culleoka] +/ip dhcp-server set lease-time=1h [find name=cpe] +/ip dhcp-server set lease-time=1h [find name=culleoka-tower] +/ip dhcp-server set lease-time=1h [find name=clayton] diff --git a/mikrotik-tool/dhcp-1h-lowry.rsc b/mikrotik-tool/dhcp-1h-lowry.rsc new file mode 100644 index 0000000..250dbbe --- /dev/null +++ b/mikrotik-tool/dhcp-1h-lowry.rsc @@ -0,0 +1,3 @@ +# set all DHCP lease times to 1h +/ip dhcp-server set lease-time=1h [find name=dhcp1] +/ip dhcp-server set lease-time=1h [find name=mgmt] diff --git a/mikrotik-tool/dhcp-1h-newhope.rsc b/mikrotik-tool/dhcp-1h-newhope.rsc new file mode 100644 index 0000000..2a52f3e --- /dev/null +++ b/mikrotik-tool/dhcp-1h-newhope.rsc @@ -0,0 +1,4 @@ +# set all DHCP lease times to 1h +/ip dhcp-server set lease-time=1h [find name=newhope] +/ip dhcp-server set lease-time=1h [find name=management] +/ip dhcp-server set lease-time=1h [find name=newhope-tower] diff --git a/mikrotik-tool/dhcp-1h-verona.rsc b/mikrotik-tool/dhcp-1h-verona.rsc new file mode 100644 index 0000000..261f1e4 --- /dev/null +++ b/mikrotik-tool/dhcp-1h-verona.rsc @@ -0,0 +1,5 @@ +# set all DHCP lease times to 1h +/ip dhcp-server set lease-time=1h [find name=verona-cpe] +/ip dhcp-server set lease-time=1h [find name=altoga-cpe] +/ip dhcp-server set lease-time=1h [find name=verona-tower] +/ip dhcp-server set lease-time=1h [find name=verona] diff --git a/mikrotik-tool/dhcp-set-server-all.rsc b/mikrotik-tool/dhcp-set-server-all.rsc new file mode 100644 index 0000000..03d05a5 --- /dev/null +++ b/mikrotik-tool/dhcp-set-server-all.rsc @@ -0,0 +1 @@ +/ip dhcp-server lease set [find where dynamic=no] server=all diff --git a/mikrotik-tool/dhcp-use-radius-no.rsc b/mikrotik-tool/dhcp-use-radius-no.rsc new file mode 100644 index 0000000..b13892f --- /dev/null +++ b/mikrotik-tool/dhcp-use-radius-no.rsc @@ -0,0 +1 @@ +/ip dhcp-server set use-radius=no [find] diff --git a/mikrotik-tool/diag.rsc b/mikrotik-tool/diag.rsc new file mode 100644 index 0000000..ff5a723 --- /dev/null +++ b/mikrotik-tool/diag.rsc @@ -0,0 +1,5 @@ +/routing ospf neighbor print +/interface print +/routing ospf interface-template print +/routing ospf interface print +/ip address print diff --git a/mikrotik-tool/dns-local-resolvers.rsc b/mikrotik-tool/dns-local-resolvers.rsc new file mode 100644 index 0000000..cfcb909 --- /dev/null +++ b/mikrotik-tool/dns-local-resolvers.rsc @@ -0,0 +1,3 @@ +/ip dns set servers=204.110.191.240,204.110.191.250 +/ip dhcp-server network set [find] dns-server=204.110.191.240,204.110.191.250 +/ppp profile set [find] dns-server=204.110.191.240,204.110.191.250 diff --git a/mikrotik-tool/dns-quad9.rsc b/mikrotik-tool/dns-quad9.rsc new file mode 100644 index 0000000..b6fff1e --- /dev/null +++ b/mikrotik-tool/dns-quad9.rsc @@ -0,0 +1,6 @@ +# Set fleet-wide DNS to Quad9 (9.9.9.9 and 149.112.112.112) +# Applies to: /ip dns, all DHCP networks, all PPPoE profiles + +/ip dns set servers=9.9.9.9,149.112.112.112 +/ip dhcp-server network set [find] dns-server=9.9.9.9,149.112.112.112 +/ppp profile set [find] dns-server=9.9.9.9,149.112.112.112 diff --git a/mikrotik-tool/dns-update-edge.rsc b/mikrotik-tool/dns-update-edge.rsc new file mode 100644 index 0000000..6e6f327 --- /dev/null +++ b/mikrotik-tool/dns-update-edge.rsc @@ -0,0 +1,3 @@ +/ppp profile set [find] dns-server=204.110.191.240,204.110.191.250 +/ip dhcp-server network set [find] dns-server=204.110.191.240,204.110.191.250 +/ip dns set servers=204.110.191.240,204.110.191.250 diff --git a/mikrotik-tool/dns-update.rsc b/mikrotik-tool/dns-update.rsc new file mode 100644 index 0000000..682d750 --- /dev/null +++ b/mikrotik-tool/dns-update.rsc @@ -0,0 +1,2 @@ +/ppp profile set [find] dns-server=204.110.191.240,204.110.191.250 +/ip dhcp-server network set [find] dns-server=204.110.191.240,204.110.191.250 diff --git a/mikrotik-tool/edge-block-winbox.rsc b/mikrotik-tool/edge-block-winbox.rsc new file mode 100644 index 0000000..ba77d08 --- /dev/null +++ b/mikrotik-tool/edge-block-winbox.rsc @@ -0,0 +1,16 @@ +# Block incoming winbox from non-trusted IPs +# Run: ./mikrotik-tool script edge edge-block-winbox.rsc + +# Add missing RFC 1918 private ranges to trusted list +/ip firewall address-list +add address=172.16.0.0/12 list=trusted comment="RFC 1918 private" +add address=192.168.0.0/16 list=trusted comment="RFC 1918 private" + +# Restrict winbox service to trusted networks only +/ip service +set winbox address=204.110.188.0/22,10.0.0.0/8,100.64.0.0/10,172.16.0.0/12,192.168.0.0/16 + +# Firewall drop for winbox from non-trusted sources (belt and suspenders) +# Place before "Established Input" to catch new connection attempts early +/ip firewall filter +add chain=input action=drop dst-port=8291 protocol=tcp src-address-list=!trusted comment="block winbox from non-trusted" place-before="Established Input" diff --git a/mikrotik-tool/edge-ssh-preseem-fix.rsc b/mikrotik-tool/edge-ssh-preseem-fix.rsc new file mode 100644 index 0000000..e00e95b --- /dev/null +++ b/mikrotik-tool/edge-ssh-preseem-fix.rsc @@ -0,0 +1 @@ +/ip firewall connection tracking set tcp-established-timeout=1d diff --git a/mikrotik-tool/edge.rsc b/mikrotik-tool/edge.rsc index d2d6767..d61f0b1 100644 --- a/mikrotik-tool/edge.rsc +++ b/mikrotik-tool/edge.rsc @@ -1,4 +1,4 @@ -# may/14/2026 13:40:52 by RouterOS 6.49.18 +# jul/10/2026 10:54:22 by RouterOS 6.49.18 # software id = 8XZE-R7EJ # # model = CCR2004-1G-12S+2XS @@ -31,7 +31,9 @@ add name=servers-public ranges=204.110.191.193-204.110.191.217 add address-pool=380building disabled=no interface=sfp-sfpplus8-server-switch \ name=380-building /ppp profile -add name=RWB_sstp_profile +set *0 dns-server=204.110.191.240,204.110.191.250 +add dns-server=204.110.191.240,204.110.191.250 name=RWB_sstp_profile +set *FFFFFFFE dns-server=204.110.191.240,204.110.191.250 /queue simple add burst-limit=1M/1M burst-threshold=1M/1M burst-time=1s/1s disabled=yes \ limit-at=1M/1M max-limit=1M/1M name=Delinquent packet-marks=Delenquent \ @@ -205,6 +207,8 @@ add address=204.110.190.251/25 interface=cgnat network=204.110.190.128 add address=204.110.190.252/25 interface=cgnat network=204.110.190.128 add address=204.110.190.253/25 interface=cgnat network=204.110.190.128 add address=204.110.190.254/25 interface=cgnat network=204.110.190.128 +add address=192.168.0.5/24 interface=sfp-sfpplus8-server-switch network=\ + 192.168.0.0 /ip dhcp-server lease add address=10.0.0.249 client-id=1:0:21:9b:91:5c:b6 mac-address=\ 00:21:9B:91:5C:B6 server=380-building @@ -219,10 +223,10 @@ add address=10.0.0.252 client-id=1:bc:24:11:f5:81:d6 mac-address=\ add address=10.0.0.251 client-id=1:bc:24:11:6b:73:53 mac-address=\ BC:24:11:6B:73:53 server=380-building /ip dhcp-server network -add address=10.0.0.0/24 dns-server=9.9.9.9,1.1.1.1 domain=vntx.net gateway=\ - 10.0.0.254 ntp-server=23.150.41.122 -add address=204.110.191.192/26 dns-server=\ - 204.110.191.240,204.110.191.250,1.1.1.1 gateway=204.110.191.254 +add address=10.0.0.0/24 dns-server=204.110.191.240,204.110.191.250 domain=\ + vntx.net gateway=10.0.0.254 ntp-server=23.150.41.122 +add address=204.110.191.192/26 dns-server=204.110.191.240,204.110.191.250 \ + gateway=204.110.191.254 /ip dns set servers=204.110.191.240,204.110.191.250 /ip firewall address-list @@ -267,6 +271,8 @@ add address=204.110.191.252 comment="Virtual RouterOS" list=BGP-NEIGHBORS add address=204.110.188.0/22 list=bgp-networks add address=151.243.11.0/24 list=abusive add address=172.245.56.83 list=trusted +add address=172.16.0.0/12 comment="RFC 1918 private" list=trusted +add address=192.168.0.0/16 comment="RFC 1918 private" list=trusted /ip firewall filter add action=accept chain=input comment="BGP Input" dst-port=179 protocol=tcp add action=accept chain=output comment="BGP Output" protocol=tcp src-port=179 @@ -277,6 +283,10 @@ add action=accept chain=forward comment="BGP Forward Reply" protocol=tcp \ add action=accept chain=input comment="OSPF Input" protocol=ospf add action=accept chain=output comment="OSPF Output" protocol=ospf add action=accept chain=forward comment="OSPF Forward" protocol=ospf +add action=drop chain=input comment="block winbox from non-trusted" dst-port=\ + 8291 protocol=tcp src-address-list=!trusted +add action=drop chain=input comment="block winbox from non-trusted" dst-port=\ + 8291 protocol=tcp src-address-list=!trusted add action=accept chain=input comment="Established Input" connection-state=\ established,related add action=accept chain=forward comment="Established Forward" \ @@ -530,6 +540,8 @@ set ftp disabled=yes set www address=204.110.188.0/22 port=2080 set ssh address=204.110.188.0/22,10.0.0.0/8,100.64.0.0/10 port=1022 set api address=204.110.188.0/22,10.0.0.0/8,100.64.0.0/10 +set winbox address=\ + 204.110.188.0/22,10.0.0.0/8,100.64.0.0/10,172.16.0.0/12,192.168.0.0/16 set api-ssl address=0.0.0.0/0 certificate=server /ip ssh set always-allow-password-login=yes forwarding-enabled=remote strong-crypto=\ diff --git a/mikrotik-tool/fix-ospf.rsc b/mikrotik-tool/fix-ospf.rsc new file mode 100644 index 0000000..9166783 --- /dev/null +++ b/mikrotik-tool/fix-ospf.rsc @@ -0,0 +1,3 @@ +/routing ospf interface-template set [find where interfaces=ether4-newhope] auth=sha512 authentication-key=5YHHWmoooAyVaFDX48ffjGjc36XMO5Irb7zS44Tsvet use-bfd=no +/routing ospf interface-template set [find where interfaces=ether6-culleoka-11ghz] auth=sha512 authentication-key=5YHHWmoooAyVaFDX48ffjGjc36XMO5Irb7zS44Tsvet use-bfd=no +/routing ospf interface-template add area=backbone-v2 auth=sha512 auth-id=1 authentication-key=5YHHWmoooAyVaFDX48ffjGjc36XMO5Irb7zS44Tsvet cost=10 disabled=no interfaces=ether5-climax priority=1 type=ptp use-bfd=no diff --git a/mikrotik-tool/home-recursive-failover.rsc b/mikrotik-tool/home-recursive-failover.rsc new file mode 100644 index 0000000..162a931 --- /dev/null +++ b/mikrotik-tool/home-recursive-failover.rsc @@ -0,0 +1,38 @@ +# home-recursive-failover.rsc +# Replace stateful netwatch enable/disable failover with stateless recursive +# check-gateway failover. Root cause of the "VNTX default stuck disabled" +# incident: netwatch down-script persists disabled=yes (survives reboots), +# and its probe to 4.2.2.2 is only glued to the VNTX path while the probe-pin +# route is active — so the check can die permanently (verona hotspot-binding +# loss) or leak out another WAN (TMO netwatch reads "up" today with the TMO +# interface physically down, answered via VNTX). +# +# New model (standard ROS recursive failover): +# - probe pin: 4.2.2.x/32 -> WAN gw %interface, scope=10, NO check-gateway. +# Stays active whenever the interface has link, so probes always egress +# the WAN under test regardless of any default-route state. +# - default: 0.0.0.0/0 gw=4.2.2.x target-scope=11 check-gateway=ping. +# check-gateway pings 4.2.2.x THROUGH the pin; 2 misses (~20s) -> route +# inactive (not disabled), first success -> active again. Nothing latches. +# - Detection is 10s-cadence/2-miss instead of netwatch's 2s/1s single +# packet — deliberate: the AF11 backbone has documented >1s RF bursts +# (same reason BFD was turned off fleet-wide 2026-05-09). + +/ip route +# pins: remove check-gateway (the check belongs on the recursive default) +set [find comment="TMO probe pin"] check-gateway=none +set [find comment="VNTX probe pin"] check-gateway=none +set [find comment="Starlink probe pin"] check-gateway=none +# defaults: recurse through the probe IP, carry the check +set [find comment="Default via TMO (primary)"] gateway=4.2.2.1 check-gateway=ping +set [find comment="Default via VNTX (secondary)"] gateway=4.2.2.2 check-gateway=ping +# Starlink default already gateway=4.2.2.3 + check-gateway=ping (correct form) +# tmo table default (eweka policy routing) already recurses via 4.2.2.1; +# add the check so it goes inactive (falls back to main) when TMO upstream dies +set [find comment="tmo table default"] check-gateway=ping + +# netwatch scripts are the latch — remove them +/tool netwatch +remove [find comment="TMO path"] +remove [find comment="VNTX path"] +remove [find comment="Starlink path"] diff --git a/mikrotik-tool/home.rsc b/mikrotik-tool/home.rsc index 714bb06..5044b25 100644 --- a/mikrotik-tool/home.rsc +++ b/mikrotik-tool/home.rsc @@ -1,4 +1,4 @@ -# 2026-05-14 13:40:52 by RouterOS 7.22.3 +# 2026-07-17 08:45:20 by RouterOS 7.23.2 # software id = ZGNY-ZJW7 # # model = RB5009UG+S+ @@ -9,7 +9,6 @@ add admin-mac=74:4D:28:1A:67:0A auto-mac=no comment=defconf mtu=1500 name=\ add name=containers add mtu=1500 name=docker port-cost-mode=short add mtu=1500 name=dockers port-cost-mode=short -add disabled=yes mtu=1500 name=public /interface ethernet set [ find default-name=ether1 ] l2mtu=9578 set [ find default-name=ether2 ] l2mtu=9578 @@ -20,12 +19,14 @@ set [ find default-name=ether6 ] l2mtu=9578 name=ether6-tmobile set [ find default-name=ether7 ] l2mtu=9578 name=ether7-starlink set [ find default-name=ether8 ] disabled=yes l2mtu=9578 set [ find default-name=sfp-sfpplus1 ] l2mtu=9586 -/interface pppoe-client -add interface=ether8 max-mtu=1500 name=pppoe-out1 use-peer-dns=yes user=\ - grahammcintire /interface veth add address=172.17.0.2/16 container-mac-address=4C:B3:A4:3A:BC:FF dhcp=no \ gateway=172.17.0.1 gateway6="" mac-address=4C:B3:A4:3A:BC:FE name=veth1 +/container +add envlists=tailscale interface=veth1 layer-dir="" logging=yes name=\ + tailscale-mikrotik:latest remote-image=\ + fluent-networks/tailscale-mikrotik:latest root-dir=\ + /disk1/containers/tailscale start-on-boot=yes workdir=/ /interface list add comment=defconf name=WAN add comment=defconf name=LAN @@ -40,12 +41,11 @@ add add-arp=yes address-pool=home bootp-lease-time=lease-time bootp-support=\ add address-pool=dhcp_pool1 interface=ether3-servers name=servers /ipv6 dhcp-server option add code=23 name=v6-dns value="'2606:4700:4700::1111''2606:4700:4700::1001'" -/ipv6 pool -add name=tunnerbroker prefix=2001:470:ba50::/48 prefix-length=48 /port set 0 baud-rate=9600 -/interface ppp-client -add apn=internet name=ppp-out1 port=usb1 +/ppp profile +set *0 dns-server=204.110.191.240,204.110.191.250 +set *FFFFFFFE dns-server=204.110.191.240,204.110.191.250 /queue type set 0 kind=fq-codel /routing table @@ -55,8 +55,9 @@ add comment="vntx v6 /56 egress via verona" disabled=no fib name=v6-verona /snmp community set [ find default=yes ] addresses=10.0.16.0/22,10.0.0.0/8,204.110.188.0/22 \ name=kdyyJrT0Mm -add addresses=::/0 authentication-protocol=SHA1 encryption-protocol=AES name=\ - testtest security=private +add addresses=::/0 authentication-password=testtest authentication-protocol=\ + SHA1 encryption-password=testtest encryption-protocol=AES name=testtest \ + security=private add addresses=10.0.16.0/22 name=testlocal /system script add dont-require-permissions=no name=api-ssl-certgen owner=admin policy=\ @@ -72,11 +73,33 @@ add dont-require-permissions=no name=api-ssl-certgen owner=admin policy=\ ys-valid=825; /certificate sign \$certName ca=\$caName; /certificate set \ \$certName trusted=yes; /ip/service set api-ssl certificate=\$certName dis\ abled=no; /ip/service set api disabled=yes;" -/container -add envlists=tailscale interface=veth1 layer-dir="" logging=yes name=\ - tailscale-mikrotik:latest remote-image=\ - fluent-networks/tailscale-mikrotik:latest root-dir=\ - /disk1/containers/tailscale start-on-boot=yes workdir=/ +/zerotier +set zt1 identity="a5f13ab687:0:487e3dd9982223e1aa0b13752a061609180dd3926b46be2\ + 7527a299db6fb1959bc79f486a0e8e70a50a1c0c81c5048ced502f97a4db2cac167551339d\ + 137d3c2:8620c195d88a8ccd49c6264419b9bfcb9af6456cb1c0a35c83f7ed3396375fe446\ + e18897162866ebcadb374304f379a5ab006e4f0c0e3bb59438fccfcccef0c1" +/app +set HA-otbr-matter required-hw-devices=ttyACM0:usb1:default:/dev/ttyACM0 +set cinny firewall-redirects=8094:80:tcp:web +set goaway container-command-lines=goaway:none:docker.io/pommee/goaway:latest +set home-assistant container-command-lines=\ + home-assistant:none:lscr.io/linuxserver/homeassistant +set lorawan-stack secrets="lorawan-stack__admin_password:=" +set n8n firewall-redirects=5678:5678:tcp:web +set nextcloud container-command-lines="db:none:docker.io/postgres:17,redis:non\ + e:docker.io/valkey/valkey:/bin/sh -c 'valkey-server --port 6379 --appendon\ + ly yes --requirepass \$VALKEY_PASSWORD',server:none:docker.io/nextcloud:ap\ + ache" +set otbr required-hw-devices=ttyACM0:usb1:default:/dev/ttyACM0 +set pihole environment="pihole:FTLCONF_dns_listeningMode=all,pihole:FTLCONF_we\ + bserver_api_password=password" +set redlib firewall-redirects=8087:8080:tcp:web +set solr container-command-lines=solr:none:docker.io/solr:latest +set uptime-kuma container-command-lines=\ + uptime-kuma:none:docker.io/louislam/uptime-kuma:1 +set zulip secrets="zulip__postgres_password:=,zulip__memcached_password:=,zuli\ + p__rabbitmq_password:=,zulip__redis_password:=,zulip__secret_key:=,zulip__\ + email_password:=" /container config set registry-url=https://ghcr.io tmpdir=/disk1/pull /container envs @@ -122,16 +145,16 @@ add address=204.110.191.1/27 interface=ether5-vntx-static network=\ add address=10.0.19.254/22 interface=bridge network=10.0.16.0 add address=10.0.15.254/21 interface=ether3-servers network=10.0.8.0 /ip dhcp-client +# Interface not active add add-default-route=no interface=ether6-tmobile name=client1 use-peer-dns=\ no use-peer-ntp=no +# Interface not active add add-default-route=no interface=ether7-starlink name=client2 use-peer-dns=\ no use-peer-ntp=no /ip dhcp-server lease add address=10.0.16.2 client-id=\ ff:85:d2:82:8a:0:2:0:0:ab:11:cb:63:d8:6c:a1:65:c1:58 comment=unifi \ mac-address=74:83:C2:1D:4C:51 server=server1 -add address=10.0.16.251 client-id=1:34:98:b5:ae:bc:e3 mac-address=\ - 34:98:B5:AE:BC:E3 server=server1 add address=10.0.16.1 client-id=1:c8:7f:54:d0:4:2f mac-address=\ C8:7F:54:D0:04:2F server=server1 add address=10.0.19.250 client-id=\ @@ -167,20 +190,18 @@ add address=10.0.15.253 client-id=1:78:9a:18:3f:cb:fe mac-address=\ add address=10.0.19.241 client-id=1:f4:92:bf:91:8a:61 mac-address=\ F4:92:BF:91:8A:61 server=server1 add address=10.0.15.21 mac-address=BC:24:11:98:1C:19 server=servers -add address=10.0.17.185 client-id=1:74:4d:bd:c5:87:cc mac-address=\ +add address=10.0.16.11 client-id=1:74:4d:bd:c5:87:cc mac-address=\ 74:4D:BD:C5:87:CC server=server1 -add address=10.0.17.17 client-id=1:f0:24:f9:55:b8:94 mac-address=\ +add address=10.0.16.14 client-id=1:f0:24:f9:55:b8:94 mac-address=\ F0:24:F9:55:B8:94 server=server1 -add address=10.0.17.184 mac-address=50:02:91:38:EB:98 server=server1 add address=10.0.16.3 client-id=1:52:54:0:5c:f7:36 mac-address=\ 52:54:00:5C:F7:36 server=server1 -add address=10.0.17.25 client-id=1:20:f8:3b:9:49:cd mac-address=\ +add address=10.0.16.15 client-id=1:20:f8:3b:9:49:cd mac-address=\ 20:F8:3B:09:49:CD server=server1 -add address=10.0.17.51 mac-address=40:F5:20:C5:9B:EE server=server1 add address=10.0.16.5 client-id=\ ff:ef:a8:c2:c6:0:1:0:1:31:4c:1f:7:b0:dc:ef:a8:c2:c6 mac-address=\ B0:DC:EF:A8:C2:C6 server=server1 -add address=10.0.17.22 mac-address=EC:94:CB:AA:56:A3 server=server1 +add address=10.0.16.13 mac-address=EC:94:CB:AA:56:A3 server=server1 add address=10.0.15.24 client-id=\ ff:23:7c:24:3e:0:2:0:0:ab:11:ad:b5:e:a0:e1:d9:51:1a mac-address=\ F6:86:CC:17:A7:F9 server=servers @@ -191,12 +212,21 @@ add address=10.0.15.30 mac-address=94:C6:91:A4:19:80 server=servers add address=10.0.15.31 client-id=\ ff:a:12:17:11:0:1:0:1:31:8f:72:ff:7c:d3:a:12:17:11 mac-address=\ 7C:D3:0A:12:17:11 server=servers +add address=10.0.16.10 mac-address=40:F5:20:C5:C7:5E server=server1 +add address=10.0.16.12 client-id=1:40:4c:ca:69:9:6c mac-address=\ + 40:4C:CA:69:09:6C server=server1 +add address=10.0.16.6 client-id=1:e8:f6:a:e0:c4:4 mac-address=\ + E8:F6:0A:E0:C4:04 server=server1 +add address=10.0.15.25 mac-address=BC:24:11:FB:42:B0 server=servers +add address=10.0.19.253 client-id=1:b8:69:f4:9f:b7:12 mac-address=\ + B8:69:F4:9F:B7:12 server=server1 /ip dhcp-server network -add address=10.0.8.0/21 domain=mcintire.me gateway=10.0.15.254 -add address=10.0.16.0/22 dns-server=10.0.19.250,9.9.9.9 domain=w5isp.com \ +add address=10.0.8.0/21 dns-server=204.110.191.240,204.110.191.250 domain=\ + mcintire.me gateway=10.0.15.254 +add address=10.0.16.0/22 dns-server=1.1.1.1,204.110.191.250 domain=w5isp.com \ gateway=10.0.19.254 /ip dns -set servers=9.9.9.9,149.112.112.112 +set servers=9.9.9.9,204.110.191.250 /ip dns static add address=192.168.88.1 comment=defconf name=router.lan type=A add address=10.0.16.31 comment=dhcp-lease-script_server1_lease-hostname name=\ @@ -279,7 +309,15 @@ add address=10.0.17.189 list=iot-blocked add address=news.eweka.nl list=eweka-tmo add address=185.90.196.0/22 comment="eweka range" list=eweka-tmo add address=81.171.92.0/23 comment="eweka range" list=eweka-tmo +add address=204.110.188.0/22 comment="VNTX public IPs" list=trusted +add address=100.64.0.0/10 comment="CGNAT pools" list=trusted +add address=10.0.0.0/8 comment="RFC 1918 private" list=trusted +add address=192.168.0.0/16 comment="RFC 1918 private" list=trusted /ip firewall filter +add action=accept chain=input comment="allow trusted to management services" \ + dst-port=8728-8729,8291,22 protocol=tcp src-address-list=trusted +add action=drop chain=input comment="drop untrusted to management services" \ + dst-port=8728-8729,8291,22 protocol=tcp add action=accept chain=input dst-address=0.0.0.0 protocol=udp src-address=\ 104.238.146.79 add action=accept chain=forward dst-port=25565 in-interface=\ @@ -388,7 +426,7 @@ add action=dst-nat chain=dstnat disabled=yes dst-address=204.110.191.1 \ add action=dst-nat chain=dstnat disabled=yes dst-address=204.110.191.1 \ dst-port=8920 protocol=tcp to-addresses=10.0.16.1 to-ports=8920 add action=masquerade chain=srcnat src-address=172.17.0.0/24 -add action=masquerade chain=srcnat out-interface=ether7-starlink +add action=masquerade chain=srcnat disabled=yes out-interface=ether7-starlink /ip proxy set port=8198 src-address=10.0.19.254 /ip route @@ -420,27 +458,25 @@ add disabled=no dst-address=204.110.188.0/22 gateway=204.110.191.30 \ routing-table=main add disabled=no dst-address=100.64.0.0/16 gateway=204.110.191.30 \ routing-table=main -add disabled=no dst-address=10.43.0.0/16 gateway=204.110.191.2 routing-table=\ - main add comment=wigle.net disabled=no distance=1 dst-address=54.70.85.50/32 \ gateway=204.110.191.30 routing-table=main scope=30 target-scope=10 add dst-address=100.64.0.0/10 gateway=172.17.0.2 -add comment="Default via TMO (primary)" disabled=no distance=1 dst-address=\ - 0.0.0.0/0 gateway=192.168.12.1 target-scope=11 -add comment="Default via VNTX (secondary)" disabled=no distance=2 \ - dst-address=0.0.0.0/0 gateway=204.110.191.30 target-scope=11 +add check-gateway=ping comment="Default via TMO (primary)" disabled=no \ + distance=1 dst-address=0.0.0.0/0 gateway=4.2.2.1 target-scope=11 +add check-gateway=ping comment="Default via VNTX (secondary)" disabled=no \ + distance=2 dst-address=0.0.0.0/0 gateway=4.2.2.2 target-scope=11 add check-gateway=ping comment="Default via Starlink (last\ - \n resort)" distance=3 dst-address=0.0.0.0/0 gateway=4.2.2.3 \ - target-scope=11 -add comment="tmo table default" disabled=no dst-address=0.0.0.0/0 gateway=\ - 4.2.2.1 routing-table=tmo -add check-gateway=ping comment="TMO probe pin" distance=1 dst-address=\ + \n resort)" disabled=yes distance=3 dst-address=0.0.0.0/0 gateway=\ + 4.2.2.3 target-scope=11 +add check-gateway=ping comment="tmo table default" disabled=no dst-address=\ + 0.0.0.0/0 gateway=4.2.2.1 routing-table=tmo +add check-gateway=none comment="TMO probe pin" distance=1 dst-address=\ 4.2.2.1/32 gateway=192.168.12.1%ether6-tmobile scope=10 -add check-gateway=ping comment="VNTX probe pin" distance=1 dst-address=\ +add check-gateway=none comment="VNTX probe pin" distance=1 dst-address=\ 4.2.2.2/32 gateway=204.110.191.30%ether5-vntx-static scope=10 -add check-gateway=ping comment="Starlink probe pin" distance=1 dst-address=\ - 4.2.2.3/32 gateway=192.168.1.1%ether7-starlink scope=10 -add disabled=no distance=1 dst-address=192.168.100.0/24 gateway=\ +add check-gateway=none comment="Starlink probe pin" disabled=yes distance=1 \ + dst-address=4.2.2.3/32 gateway=192.168.1.1%ether7-starlink scope=10 +add disabled=yes distance=1 dst-address=192.168.100.0/24 gateway=\ ether7-starlink routing-table=main scope=30 target-scope=10 /ip service set ftp disabled=yes @@ -526,17 +562,3 @@ add allow-address=10.0.16.0/24 set allowed-interface-list=LAN /tool mac-server mac-winbox set allowed-interface-list=LAN -/tool netwatch -add comment="TMO path" down-script="/ip route disable [find comment=\"Default \ - via TMO (primary)\"]; /ip route disable [find comment=\"tmo table default\ - \"]" host=4.2.2.1 interval=2s timeout=1s type=simple up-script="/ip route \ - enable [find comment=\"Default via TMO (primary)\"]; /ip route enable [fin\ - d comment=\"tmo table default\"]" -add comment="VNTX path" down-script=\ - "/ip route disable [find comment=\"Default via VNTX (secondary)\"]" host=\ - 4.2.2.2 interval=2s timeout=1s type=simple up-script=\ - "/ip route enable [find comment=\"Default via VNTX (secondary)\"]" -add comment="Starlink path" down-script=\ - "/ip route disable [find comment=\"Default via Starlink (last resort)\"]" \ - host=4.2.2.3 interval=2s timeout=1s type=simple up-script=\ - "/ip route enable [find comment=\"Default via Starlink (last resort)\"]" diff --git a/mikrotik-tool/lowry.rsc b/mikrotik-tool/lowry.rsc index 8afe298..f8b1a0c 100644 --- a/mikrotik-tool/lowry.rsc +++ b/mikrotik-tool/lowry.rsc @@ -1,4 +1,4 @@ -# 2026-05-14 13:40:53 by RouterOS 7.21.4 +# 2026-07-10 10:54:21 by RouterOS 7.21.4 # software id = 2I9X-PQZP # # model = CCR1009-7G-1C-1S+ @@ -35,8 +35,7 @@ add name=video_http regexp="http/(0\\.9|1\\.0|1\\.1)[\\x20][1-5][0-9][0-9].*[\ \\x09-\\x0d].*[\\x09-\\x0d]content-type:.video/" /ip hotspot profile add dns-name=lowrycrossing.vntx.net hotspot-address=100.64.23.254 login-by=\ - mac,http-chap mac-auth-mode=mac-as-username-and-password name=hsprof1 \ - radius-interim-update=1h use-radius=yes + mac,http-chap mac-auth-mode=mac-as-username-and-password name=hsprof1 /ip hotspot add idle-timeout=none interface=lowrycrossing name=hotspot1 profile=hsprof1 /ip hotspot user profile @@ -144,13 +143,14 @@ add address-pool=mgmt interface=management lease-script="{\ /ip smb users set [ find default=yes ] disabled=yes /ipv6 dhcp-server option -add code=23 name=v6-dns value="'2606:1c80::240''2606:1c80::250'" +add code=23 name=v6-dns /ipv6 pool add name=lowry-cust-pd-1 prefix=2606:1c80:1901::/48 prefix-length=56 /ppp profile +set *0 dns-server=204.110.191.240,204.110.191.250 add change-tcp-mss=yes dhcpv6-pd-pool=lowry-cust-pd-1 dns-server=\ - 204.110.191.20,204.110.191.240 local-address=100.64.159.254 name=pppoe \ - on-down="{\ + 204.110.191.240,204.110.191.250 idle-timeout=1h local-address=\ + 100.64.159.254 name=pppoe on-down="{\ \n:local url \"https://xtjlpnubrg.execute-api.us-east-1.amazonaws.com/prod\ uction/on-start-workflow-execution/6f19a48f-04f7-40c1-a4e0-8cb66df99213/0a\ 7f4443-fc8a-4fe0-807c-f535d2aa1865\"\ @@ -261,6 +261,7 @@ add change-tcp-mss=yes dhcpv6-pd-pool=lowry-cust-pd-1 dns-server=\ \n" only-one=yes remote-address=cgnat remote-ipv6-prefix-pool=\ lowry-cust-pd-1 use-compression=no use-encryption=no use-ipv6=no \ use-upnp=no +set *FFFFFFFE dns-server=204.110.191.240,204.110.191.250 /queue type add kind=pcq name=delinquent_download pcq-classifier=dst-address \ pcq-dst-address6-mask=64 pcq-rate=64k pcq-src-address6-mask=64 \ @@ -374,8 +375,9 @@ set tcp-syncookies=yes /ipv6 settings set accept-router-advertisements=no /interface pppoe-server server -add default-profile=pppoe disabled=no interface=lowrycrossing max-mtu=1492 \ - mrru=1600 one-session-per-host=yes service-name=lowrycrossing +add authentication=mschap2 default-profile=pppoe disabled=no interface=\ + lowrycrossing max-mtu=1492 one-session-per-host=yes service-name=\ + lowrycrossing /interface sstp-server server set certificate=vntx.net.pem_0 /ip address @@ -487,6 +489,9 @@ add add-arp=yes address-pool=cgnat dhcp-option-set=vntx interface=\ \n} while ( \$attempts < \$max )\ \n}\ \n" lease-time=1h name=dhcp1 +/ip dhcp-server alert +add disabled=no interface=lowrycrossing on-alert=rogue-dhcp valid-server=\ + 64:D1:54:D3:E2:1F /ip dhcp-server lease add address=10.10.159.13 mac-address=58:C1:7A:75:4D:66 add address=10.10.159.11 mac-address=00:04:56:C4:FE:9D @@ -494,16 +499,19 @@ add address=10.10.144.34 mac-address=00:04:56:60:49:43 add address=10.10.159.12 mac-address=58:C1:7A:71:A7:A8 add address=10.10.159.10 mac-address=00:04:56:D7:62:F6 /ip dhcp-server network -add address=10.10.144.0/20 dns-server=204.110.191.240,204.110.191.20 domain=\ - vntx.net gateway=10.10.159.254 ntp-server=204.110.191.19 -add address=100.64.144.0/20 dns-server=204.110.191.240,204.110.191.20 domain=\ - vntx.net gateway=100.64.159.254 ntp-server=204.110.191.19 -add address=204.110.188.192/27 dns-server=204.110.191.240,204.110.191.20 \ - domain=vntx.net gateway=204.110.188.222 ntp-server=204.110.191.19 +add address=10.10.144.0/20 dns-server=204.110.191.240,204.110.191.250 domain=\ + vntx.net gateway=10.10.159.254 ntp-server=\ + 23.150.40.242,108.61.215.221,162.254.225.152 +add address=100.64.144.0/20 dns-server=204.110.191.240,204.110.191.250 \ + domain=vntx.net gateway=100.64.159.254 ntp-server=\ + 23.150.40.242,108.61.215.221,162.254.225.152 +add address=204.110.188.192/27 dns-server=204.110.191.240,204.110.191.250 \ + domain=vntx.net gateway=204.110.188.222 ntp-server=\ + 23.150.40.242,108.61.215.221,162.254.225.152 /ip dhcp-server option sets add name=vntx options=*1 /ip dns -set servers=9.9.9.9,1.1.1.1,2606:1c80::240,2606:1c80::250 +set servers=204.110.191.240,204.110.191.250 /ip firewall address-list add address=204.110.191.229 list=whitelist add address=portal.vntx.net list=whitelist @@ -1222,7 +1230,15 @@ add address=100.64.23.25 comment="Abbie Andrews (1081)" list=Active add address=100.64.23.12 comment="Nhu Morrison (2111)" list=\ ResidentialAdvanced add address=100.64.23.12 comment="Nhu Morrison (2111)" list=Active +add address=204.110.188.0/22 comment="VNTX public IPs" list=trusted +add address=100.64.0.0/10 comment="CGNAT pools" list=trusted +add address=10.0.0.0/8 comment="RFC 1918 private" list=trusted +add address=192.168.0.0/16 comment="RFC 1918 private" list=trusted /ip firewall filter +add action=accept chain=input comment="allow trusted to management services" \ + dst-port=8728-8729,8291,22 protocol=tcp src-address-list=trusted +add action=drop chain=input comment="drop untrusted to management services" \ + dst-port=8728-8729,8291,22 protocol=tcp add action=accept chain=forward comment=\ "bypass fasttrack for MPLS spine (out)" out-interface=ether1-newhope add action=accept chain=forward comment=\ @@ -1241,7 +1257,7 @@ add address=10.100.0.144-10.100.158.254 add address=10.100.159.0/24 type=bypassed add address=0.0.0.0/0 type=blocked /ip hotspot user -add name=admin +add name=admin password=h8xd9tkryg /ip hotspot walled-garden add comment="place hotspot rules here" disabled=yes /ip hotspot walled-garden ip @@ -1320,13 +1336,13 @@ add interface=ether1-newhope /mpls settings set propagate-ttl=no /ppp aaa -set interim-update=1h use-radius=yes +set interim-update=15m use-radius=yes /radius -add address=204.110.191.248 require-message-auth=no service=ppp,hotspot,dhcp \ +add address=204.110.191.248 secret=260oy5od2x2ltubh service=ppp,hotspot,dhcp \ src-address=204.110.188.222 timeout=3s -add address=204.110.191.2 require-message-auth=no service=ppp,hotspot,dhcp \ +add address=204.110.191.2 secret=260oy5od2x2ltubh service=ppp,hotspot,dhcp \ src-address=204.110.188.222 timeout=3s -add accounting-backup=yes address=45.76.56.5 require-message-auth=no service=\ +add accounting-backup=yes address=45.76.56.5 secret=260oy5od2x2ltubh service=\ ppp,hotspot,dhcp src-address=204.110.188.222 timeout=3s /radius incoming set accept=yes @@ -1345,8 +1361,9 @@ add chain=v6-ibgp-out disabled=no rule="if (dst==2606:1c80:1900::/44 || dst==2\ 606:1c80:0:10::109/128 || (dst-len==64 && dst==2606:1c80:0:18::/64)) { acc\ ept; } else { reject; }" /routing ospf interface-template -add area=backbone-v2 auth=sha512 auth-id=1 cost=10 disabled=no interfaces=\ - ether1-newhope priority=1 type=ptp use-bfd=yes +add area=backbone-v2 auth=sha512 auth-id=1 auth-key=\ + 5YHHWmoooAyVaFDX48ffjGjc36XMO5Irb7zS44Tsvet cost=10 disabled=no \ + interfaces=ether1-newhope priority=1 type=ptp use-bfd=no add area=backbone-v2 disabled=no passive /snmp set contact=graham@vntx.net enabled=yes location="33.160873, -96.540479" @@ -1417,5 +1434,7 @@ add dont-require-permissions=no name=rogue-dhcp owner=admin policy=\ ftp,reboot,read,write,policy,test,password,sniff,sensitive,romon source=\ ":log warning message=\"Rogue DHCP server detected!\"" /tool e-mail -set certificate-verification=no from= server=\ - smtp.sendgrid.net tls=starttls user=veronanetworks +set certificate-verification=no from= password=\ + F9K2B2yhvE6YUnv server=smtp.sendgrid.net tls=starttls user=veronanetworks +/tool romon +set secrets=vntx1830 diff --git a/mikrotik-tool/main.go b/mikrotik-tool/main.go index f02aee1..c599a2d 100644 --- a/mikrotik-tool/main.go +++ b/mikrotik-tool/main.go @@ -404,13 +404,21 @@ func exportRouter(cfg *Config, r Router) error { return err } - log.Printf("[%s] ssh %s:%d /export", r.Name, r.Host, sshPort) - body, err := runOverSSH(r, sshPort, "/export") + // Try show-sensitive first (ROS 7); fall back to plain export for ROS 6. + cmd := "/export show-sensitive" + log.Printf("[%s] ssh %s:%d %s", r.Name, r.Host, sshPort, cmd) + body, err := runOverSSH(r, sshPort, cmd) if err != nil { - return err + log.Printf("[%s] show-sensitive failed (%v), falling back to /export", r.Name, err) + cmd = "/export" + log.Printf("[%s] ssh %s:%d %s", r.Name, r.Host, sshPort, cmd) + body, err = runOverSSH(r, sshPort, cmd) + if err != nil { + return err + } } if len(body) == 0 { - return fmt.Errorf("/export returned no output") + return fmt.Errorf("%s returned no output", cmd) } out := r.Name + ".rsc" diff --git a/mikrotik-tool/mikrotik-tool b/mikrotik-tool/mikrotik-tool index b659c31..da137da 100755 Binary files a/mikrotik-tool/mikrotik-tool and b/mikrotik-tool/mikrotik-tool differ diff --git a/mikrotik-tool/newhope.rsc b/mikrotik-tool/newhope.rsc index 6274c69..946ef85 100644 --- a/mikrotik-tool/newhope.rsc +++ b/mikrotik-tool/newhope.rsc @@ -1,4 +1,4 @@ -# 2026-05-14 13:40:53 by RouterOS 7.21.4 +# 2026-07-10 10:54:21 by RouterOS 7.21.4 # software id = 5HTF-YFWV # # model = CCR1009-7G-1C-1S+ @@ -34,8 +34,7 @@ set [ find default=yes ] ip-type=ipv4 use-network-apn=no set [ find default=yes ] supplicant-identity=MikroTik /ip hotspot profile add dns-name=newhope.vntx.net hotspot-address=100.64.19.254 login-by=\ - mac,http-chap mac-auth-mode=mac-as-username-and-password name=hsprof1 \ - use-radius=yes + mac,http-chap mac-auth-mode=mac-as-username-and-password name=hsprof1 /ip hotspot add addresses-per-mac=unlimited idle-timeout=none interface=\ sfp-sfpplus1-edgepoint name=hotspot1 profile=hsprof1 @@ -151,14 +150,14 @@ add address-pool=dhcp_pool6 interface=ether7-tower lease-time=1h name=\ /ip smb users set [ find default=yes ] disabled=yes /ipv6 dhcp-server option -add code=23 name=v6-dns value="'2606:1c80::240''2606:1c80::250'" +add code=23 name=v6-dns /ipv6 pool add name=newhope-cust-pd-1 prefix=2606:1c80:1801::/48 prefix-length=56 /ppp profile -set *0 remote-address=dhcp_pool3 +set *0 dns-server=204.110.191.240,204.110.191.250 remote-address=dhcp_pool3 add change-tcp-mss=yes dhcpv6-pd-pool=newhope-cust-pd-1 dns-server=\ - 204.110.191.240,204.110.191.20 local-address=100.64.19.253 name=newhope \ - on-down="{\ + 204.110.191.240,204.110.191.250 idle-timeout=1h local-address=\ + 100.64.19.253 name=newhope on-down="{\ \n:local url \"https://xtjlpnubrg.execute-api.us-east-1.amazonaws.com/prod\ uction/on-start-workflow-execution/6f19a48f-04f7-40c1-a4e0-8cb66df99213/0a\ 7f4443-fc8a-4fe0-807c-f535d2aa1865\"\ @@ -266,8 +265,9 @@ add change-tcp-mss=yes dhcpv6-pd-pool=newhope-cust-pd-1 dns-server=\ \n }\ \n} while ( \$attempts < \$max )\ \n}\ - \n" remote-address=cgnat remote-ipv6-prefix-pool=newhope-cust-pd-1 \ - use-upnp=no + \n" only-one=yes remote-address=cgnat remote-ipv6-prefix-pool=\ + newhope-cust-pd-1 use-upnp=no +set *FFFFFFFE dns-server=204.110.191.240,204.110.191.250 /queue type add kind=pcq name=delinquent_download pcq-classifier=dst-address \ pcq-dst-address6-mask=64 pcq-rate=64k pcq-src-address6-mask=64 \ @@ -295,9 +295,11 @@ set default as=393837 disabled=no output.network=bgp-networks .redistribute=\ add disabled=no id=10.254.254.108 name=id-1 select-dynamic-id="" /routing ospf instance add disabled=no in-filter-chain=ospf-in name=default-v2 out-filter-chain=\ - ospf-out redistribute=connected,rip router-id=id-1 + ospf-out redistribute=connected router-id=id-1 /routing ospf area add disabled=no instance=default-v2 name=backbone-v2 +/routing rip instance +add name=rip-instance-1 /snmp community set [ find default=yes ] addresses=204.110.188.0/22,10.0.0.0/8 name=\ kdyyJrT0Mm @@ -314,16 +316,13 @@ add bridge=newhope ingress-filtering=no interface=combo1 internal-path-cost=\ add bridge=newhope interface=ether5-switch add bridge=newhope interface=sfp-sfpplus1-edgepoint /ip firewall connection tracking -# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings set icmp-timeout=30s tcp-close-wait-timeout=1m tcp-established-timeout=4h \ tcp-fin-wait-timeout=2m tcp-last-ack-timeout=30s \ tcp-syn-received-timeout=1m tcp-syn-sent-timeout=2m \ tcp-time-wait-timeout=2m udp-stream-timeout=2m /ip settings -# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings set tcp-syncookies=yes /ipv6 settings -# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings set accept-router-advertisements=no /interface pppoe-server server add authentication=mschap2 default-profile=newhope disabled=no interface=\ @@ -439,25 +438,40 @@ add address-pool=cgnat dhcp-option-set=vntx interface=newhope lease-script="{\ \n} while ( \$attempts < \$max )\ \n}\ \n" lease-time=1h name=newhope +/ip dhcp-server alert +add disabled=no interface=newhope on-alert=rogue-dhcp valid-server=\ + 6C:3B:6B:E1:5D:46 /ip dhcp-server lease add address=100.64.19.250 mac-address=0C:73:EB:B0:BB:5B server=newhope add address=100.64.19.252 client-id=1:dc:2c:6e:b4:77:0 mac-address=\ DC:2C:6E:B4:77:00 server=newhope add address=100.64.19.251 mac-address=74:AC:B9:A3:E0:5C /ip dhcp-server network -add address=10.0.108.0/24 dns-server=204.110.191.240,204.110.191.20 gateway=\ - 10.0.108.254 ntp-server=204.110.191.19 -add address=10.10.128.0/20 dns-server=204.110.191.240,204.110.191.20 domain=\ - vntx.net gateway=10.10.143.254 ntp-server=204.110.191.19 -add address=100.64.16.0/22 dns-server=204.110.191.240,204.110.191.20 domain=\ - vntx.net gateway=100.64.19.254 ntp-server=204.110.191.19 -add address=204.110.188.160/27 dns-server=204.110.191.240,204.110.191.20 \ - domain=vntx.net gateway=204.110.188.190 ntp-server=204.110.191.19 +add address=10.0.108.0/24 dns-server=204.110.191.240,204.110.191.250 gateway=\ + 10.0.108.254 ntp-server=23.150.40.242,108.61.215.221,162.254.225.152 +add address=10.10.128.0/20 dns-server=204.110.191.240,204.110.191.250 domain=\ + vntx.net gateway=10.10.143.254 ntp-server=\ + 23.150.40.242,108.61.215.221,162.254.225.152 +add address=100.64.16.0/22 dns-server=204.110.191.240,204.110.191.250 domain=\ + vntx.net gateway=100.64.19.254 ntp-server=\ + 23.150.40.242,108.61.215.221,162.254.225.152 +add address=204.110.188.160/27 dns-server=204.110.191.240,204.110.191.250 \ + domain=vntx.net gateway=204.110.188.190 ntp-server=\ + 23.150.40.242,108.61.215.221,162.254.225.152 /ip dhcp-server option sets add name=vntx options=*1 /ip dns -set servers=9.9.9.9,1.1.1.1,2606:1c80::240,2606:1c80::250 +set servers=204.110.191.240,204.110.191.250 +/ip firewall address-list +add address=204.110.188.0/22 comment="VNTX public IPs" list=trusted +add address=100.64.0.0/10 comment="CGNAT pools" list=trusted +add address=10.0.0.0/8 comment="RFC 1918 private" list=trusted +add address=192.168.0.0/16 comment="RFC 1918 private" list=trusted /ip firewall filter +add action=accept chain=input comment="allow trusted to management services" \ + dst-port=8728-8729,8291,22 protocol=tcp src-address-list=trusted +add action=drop chain=input comment="drop untrusted to management services" \ + dst-port=8728-8729,8291,22 protocol=tcp add action=accept chain=forward comment=\ "bypass fasttrack for MPLS spine (out)" out-interface=\ ether6-lowrycrossing @@ -484,7 +498,7 @@ add address=10.100.128.1-10.100.142.254 add address=10.100.143.0/24 type=bypassed add address=0.0.0.0/0 type=blocked /ip hotspot user -add name=admin +add name=admin password=h8xd9tkryg /ip hotspot walled-garden add comment="place hotspot rules here" disabled=yes add comment="place hotspot rules here" disabled=yes @@ -580,13 +594,13 @@ add interface=ether6-lowrycrossing /mpls settings set propagate-ttl=no /ppp aaa -set interim-update=1h use-radius=yes +set interim-update=15m use-radius=yes /radius -add address=204.110.191.248 require-message-auth=no service=ppp,hotspot,dhcp \ +add address=204.110.191.248 secret=ivcgfagb6f0t64ws service=ppp,hotspot,dhcp \ src-address=204.110.188.190 timeout=3s -add address=204.110.191.2 require-message-auth=no service=ppp,hotspot,dhcp \ +add address=204.110.191.2 secret=ivcgfagb6f0t64ws service=ppp,hotspot,dhcp \ src-address=204.110.188.190 timeout=3s -add accounting-backup=yes address=45.76.56.5 require-message-auth=no service=\ +add accounting-backup=yes address=45.76.56.5 secret=ivcgfagb6f0t64ws service=\ ppp,hotspot,dhcp src-address=204.110.188.190 timeout=3s /radius incoming set accept=yes @@ -607,15 +621,20 @@ add chain=v6-ibgp-out disabled=no rule="if (dst==2606:1c80:1800::/44 || dst==2\ 606:1c80:0:10::108/128 || (dst-len==64 && (dst==2606:1c80:0:13::/64 || dst\ ==2606:1c80:0:18::/64))) { accept; } else { reject; }" /routing ospf interface-template -add area=backbone-v2 auth=sha512 auth-id=1 cost=10 disabled=no interfaces=\ - ether4 priority=1 type=ptp use-bfd=yes -add area=backbone-v2 auth=sha512 auth-id=1 cost=10 disabled=no interfaces=\ - ether6-lowrycrossing priority=1 type=ptp use-bfd=yes -add area=backbone-v2 auth=sha512 auth-id=1 cost=10 disabled=no interfaces=\ - ether2-380 priority=1 type=ptp use-bfd=yes +add area=backbone-v2 auth=sha512 auth-id=1 auth-key=\ + 5YHHWmoooAyVaFDX48ffjGjc36XMO5Irb7zS44Tsvet cost=10 disabled=no \ + interfaces=ether4 priority=1 type=ptp use-bfd=no +add area=backbone-v2 auth=sha512 auth-id=1 auth-key=\ + 5YHHWmoooAyVaFDX48ffjGjc36XMO5Irb7zS44Tsvet cost=10 disabled=no \ + interfaces=ether6-lowrycrossing priority=1 type=ptp use-bfd=no +add area=backbone-v2 auth=sha512 auth-id=1 auth-key=\ + 5YHHWmoooAyVaFDX48ffjGjc36XMO5Irb7zS44Tsvet cost=10 disabled=no \ + interfaces=ether2-380 priority=1 type=ptp use-bfd=no add area=backbone-v2 disabled=no passive +/routing rip interface-template +add instance=rip-instance-1 /routing rip static-neighbor -add address="" disabled=no instance=*1 +add address="" disabled=no instance=rip-instance-1 /snmp set contact=graham@vntx.net enabled=yes location="33.207391, -96.537840" /system clock @@ -652,7 +671,6 @@ add address=0.us.pool.ntp.org /system package update set channel=long-term /system routerboard settings -# Firmware upgraded successfully, please reboot for changes to take effect! set auto-upgrade=yes /system scheduler add interval=5m name=remove_hotspot on-event=\ @@ -685,11 +703,13 @@ set auto-send-supout=yes ping-start-after-boot=1h send-email-from=\ support@vntx.net send-email-to=graham@vntx.net send-smtp-server=\ 204.110.191.236 watchdog-timer=no /tool e-mail -set certificate-verification=no from=NewHope@vntx.net server=\ - smtp.sendgrid.net tls=starttls user=veronanetworks +set certificate-verification=no from=NewHope@vntx.net password=\ + F9K2B2yhvE6YUnv server=smtp.sendgrid.net tls=starttls user=veronanetworks /tool graphing interface add /tool graphing queue add /tool graphing resource add +/tool romon +set secrets=vntx1830 diff --git a/mikrotik-tool/newrouter.rsc b/mikrotik-tool/newrouter.rsc new file mode 100644 index 0000000..0559eae --- /dev/null +++ b/mikrotik-tool/newrouter.rsc @@ -0,0 +1,717 @@ +# Translated from core.rsc (CCR1009-7G-1C-1S+) for CCR2004-16G-2S+ +# Port mapping: +# core combo1 → new ether8 (customer LAN: PPPoE+DHCP, mgmt vlan10) +# core ether1..ether7 → new ether1..ether7 (unchanged) +# core sfp-sfpplus1 → new sfp-sfpplus1 (edge via Preseem, unchanged) +# new sfp-sfpplus2 → unused +# new ether9..ether16 → unused +/interface ethernet +set [ find default-name=ether1 ] l2mtu=9000 name=ether1-982-60ghz \ + rx-flow-control=auto tx-flow-control=auto +set [ find default-name=ether2 ] l2mtu=9000 name=ether2-office \ + rx-flow-control=auto tx-flow-control=auto +set [ find default-name=ether3 ] l2mtu=9000 name=ether3-edge-direct \ + rx-flow-control=auto tx-flow-control=auto +set [ find default-name=ether4 ] l2mtu=9000 name=ether4-newhope \ + rx-flow-control=auto tx-flow-control=auto +set [ find default-name=ether5 ] l2mtu=2024 name=ether5-climax \ + rx-flow-control=auto tx-flow-control=auto +set [ find default-name=ether6 ] l2mtu=2024 name=ether6-culleoka-11ghz \ + rx-flow-control=auto tx-flow-control=auto +set [ find default-name=ether7 ] disabled=yes l2mtu=9000 name=ether7-380tower \ + rx-flow-control=auto tx-flow-control=auto +set [ find default-name=ether8 ] l2mtu=9000 name=ether8-380-lan \ + rx-flow-control=auto tx-flow-control=auto +set [ find default-name=sfp-sfpplus1 ] l2mtu=9000 name=\ + sfp-sfpplus1-edge-preseem rx-flow-control=auto tx-flow-control=auto +/interface vlan +add interface=ether8-380-lan name=vlan10_ether8 vlan-id=10 +/interface lte apn +set [ find default=yes ] ip-type=ipv4 use-network-apn=no +/interface wireless security-profiles +set [ find default=yes ] supplicant-identity=MikroTik +/ip ipsec proposal +set [ find default=yes ] auth-algorithms=sha256 enc-algorithms=\ + aes-256-cbc,aes-128-cbc pfs-group=modp2048 +/ip pool +add name=sstp ranges=172.16.91.1-172.16.91.253 +add name=380 ranges=100.64.8.1-100.64.11.230 +add name=380-mgmt ranges=10.10.64.1-10.10.79.230 +/ip dhcp-server +add address-pool=380 interface=ether8-380-lan lease-script="{\ + \n:local url \"https://xtjlpnubrg.execute-api.us-east-1.amazonaws.com/prod\ + uction/on-start-workflow-execution/6f19a48f-04f7-40c1-a4e0-8cb66df99213/cd\ + c24944-c947-4e01-9c54-07a1653d2e3e\"\ + \n:local url2 \"https://iptrack.vntx.net/api/dhcp\"\ + \n:local max 1\ + \n\ + \n:local attempts 0\ + \n:local success1 0\ + \n:local success2 0\ + \n:do {\ + \n :set attempts (\$attempts+1);\ + \n :if (\$leaseBound = 0) do {\ + \n # Try url2 (iptrack.vntx.net) - deassignment\ + \n :do {\ + \n /tool fetch duration=30s mode=https url=\"\$url2\" http-method=pos\ + t http-header-field=\"Content-Type: application/json\" http-data=\"{\\\"bo\ + und\\\":\\\"\$leaseBound\\\",\\\"mac\\\":\\\"\$leaseActMAC\\\",\\\"ip\\\":\ + \\\"\$leaseActIP\\\",\\\"server\\\":\\\"\$leaseServerName\\\"}\"\ + \n :set success1 1;\ + \n :log info \"DHCP deassignment successfully sent to iptrack.vntx.ne\ + t for \$leaseActMAC / \$leaseActIP\";\ + \n } on-error={\ + \n :log error \"DHCP FAILED to send deassignment to iptrack.vntx.net \ + on attempt \$attempts/\$max for \$leaseActMAC / \$leaseActIP\";\ + \n }\ + \n\ + \n # Try url (gaiia) - deassignment\ + \n :do {\ + \n /tool fetch duration=30s mode=https url=\"\$url\" http-method=post\ + \_http-header-field=\"Content-Type: application/json\" http-data=\"{\\\"bo\ + und\\\":\\\"\$leaseBound\\\",\\\"mac\\\":\\\"\$leaseActMAC\\\",\\\"ip\\\":\ + \\\"\$leaseActIP\\\",\\\"server\\\":\\\"\$leaseServerName\\\",\\\"remoteId\ + \\\":\\\"\\\"}\"\ + \n :set success2 1;\ + \n :log info \"DHCP deassignment successfully sent to gaiia AWS API f\ + or \$leaseActMAC / \$leaseActIP\";\ + \n } on-error={\ + \n :log error \"DHCP FAILED to send deassignment to gaiia AWS API on \ + attempt \$attempts/\$max for \$leaseActMAC / \$leaseActIP\";\ + \n }\ + \n\ + \n :if (\$success1 != 1 || \$success2 != 1) do={\ + \n :delay 3s;\ + \n }\ + \n } else {\ + \n :delay 1s;\ + \n :local remoteID [/ip dhcp-server lease get [find where address=\$lea\ + seActIP] agent-remote-id];\ + \n\ + \n # Try url2 (iptrack.vntx.net) - assignment\ + \n :do {\ + \n /tool fetch duration=30s mode=https url=\"\$url2\" http-method=pos\ + t http-header-field=\"Content-Type: application/json\" http-data=\"{\\\"bo\ + und\\\":\\\"\$leaseBound\\\",\\\"mac\\\":\\\"\$leaseActMAC\\\",\\\"ip\\\":\ + \\\"\$leaseActIP\\\",\\\"server\\\":\\\"\$leaseServerName\\\",\\\"remoteId\ + \\\":\\\"\$remoteID\\\"}\"\ + \n :set success1 1;\ + \n :log info \"DHCP assignment successfully sent to iptrack.vntx.net \ + for \$leaseActMAC / \$leaseActIP / \$remoteID\";\ + \n } on-error={\ + \n :log error \"DHCP FAILED to send assignment to iptrack.vntx.net on\ + \_attempt \$attempts/\$max for \$leaseActMAC / \$leaseActIP / \$remoteID\"\ + ;\ + \n }\ + \n\ + \n # Try url (gaiia) - assignment\ + \n :do {\ + \n /tool fetch duration=30s mode=https url=\"\$url\" http-method=post\ + \_http-header-field=\"Content-Type: application/json\" http-data=\"{\\\"bo\ + und\\\":\\\"\$leaseBound\\\",\\\"mac\\\":\\\"\$leaseActMAC\\\",\\\"ip\\\":\ + \\\"\$leaseActIP\\\",\\\"server\\\":\\\"\$leaseServerName\\\",\\\"remoteId\ + \\\":\\\"\$remoteID\\\"}\"\ + \n :set success2 1;\ + \n :log info \"DHCP assignment successfully sent to gaiia AWS API for\ + \_\$leaseActMAC / \$leaseActIP / \$remoteID\";\ + \n } on-error={\ + \n :log error \"DHCP FAILED to send assignment to gaiia AWS API on at\ + tempt \$attempts/\$max for \$leaseActMAC / \$leaseActIP / \$remoteID\";\ + \n }\ + \n\ + \n :if (\$success1 != 1 || \$success2 != 1) do={\ + \n :delay 30s;\ + \n }\ + \n }\ + \n :if (\$success1 = 1 && \$success2 = 1) do {\ + \n :set attempts \$max;\ + \n }\ + \n} while ( \$attempts < \$max )\ + \n}\ + \n" lease-time=1h name=380 +add address-pool=380-mgmt interface=vlan10_ether8 lease-script="{\ + \n:local url \"https://xtjlpnubrg.execute-api.us-east-1.amazonaws.com/prod\ + uction/on-start-workflow-execution/6f19a48f-04f7-40c1-a4e0-8cb66df99213/cd\ + c24944-c947-4e01-9c54-07a1653d2e3e\"\ + \n:local url2 \"https://iptrack.vntx.net/api/dhcp\"\ + \n:local max 1\ + \n\ + \n:local attempts 0\ + \n:local success1 0\ + \n:local success2 0\ + \n:do {\ + \n :set attempts (\$attempts+1);\ + \n :if (\$leaseBound = 0) do {\ + \n # Try url2 (iptrack.vntx.net) - deassignment\ + \n :do {\ + \n /tool fetch duration=30s mode=https url=\"\$url2\" http-method=pos\ + t http-header-field=\"Content-Type: application/json\" http-data=\"{\\\"bo\ + und\\\":\\\"\$leaseBound\\\",\\\"mac\\\":\\\"\$leaseActMAC\\\",\\\"ip\\\":\ + \\\"\$leaseActIP\\\",\\\"server\\\":\\\"\$leaseServerName\\\"}\"\ + \n :set success1 1;\ + \n :log info \"DHCP deassignment successfully sent to iptrack.vntx.ne\ + t for \$leaseActMAC / \$leaseActIP\";\ + \n } on-error={\ + \n :log error \"DHCP FAILED to send deassignment to iptrack.vntx.net \ + on attempt \$attempts/\$max for \$leaseActMAC / \$leaseActIP\";\ + \n }\ + \n\ + \n # Try url (gaiia) - deassignment\ + \n :do {\ + \n /tool fetch duration=30s mode=https url=\"\$url\" http-method=post\ + \_http-header-field=\"Content-Type: application/json\" http-data=\"{\\\"bo\ + und\\\":\\\"\$leaseBound\\\",\\\"mac\\\":\\\"\$leaseActMAC\\\",\\\"ip\\\":\ + \\\"\$leaseActIP\\\",\\\"server\\\":\\\"\$leaseServerName\\\",\\\"remoteId\ + \\\":\\\"\\\"}\"\ + \n :set success2 1;\ + \n :log info \"DHCP deassignment successfully sent to gaiia AWS API f\ + or \$leaseActMAC / \$leaseActIP\";\ + \n } on-error={\ + \n :log error \"DHCP FAILED to send deassignment to gaiia AWS API on \ + attempt \$attempts/\$max for \$leaseActMAC / \$leaseActIP\";\ + \n }\ + \n\ + \n :if (\$success1 != 1 || \$success2 != 1) do={\ + \n :delay 3s;\ + \n }\ + \n } else {\ + \n :delay 1s;\ + \n :local remoteID [/ip dhcp-server lease get [find where address=\$lea\ + seActIP] agent-remote-id];\ + \n\ + \n # Try url2 (iptrack.vntx.net) - assignment\ + \n :do {\ + \n /tool fetch duration=30s mode=https url=\"\$url2\" http-method=pos\ + t http-header-field=\"Content-Type: application/json\" http-data=\"{\\\"bo\ + und\\\":\\\"\$leaseBound\\\",\\\"mac\\\":\\\"\$leaseActMAC\\\",\\\"ip\\\":\ + \\\"\$leaseActIP\\\",\\\"server\\\":\\\"\$leaseServerName\\\",\\\"remoteId\ + \\\":\\\"\$remoteID\\\"}\"\ + \n :set success1 1;\ + \n :log info \"DHCP assignment successfully sent to iptrack.vntx.net \ + for \$leaseActMAC / \$leaseActIP / \$remoteID\";\ + \n } on-error={\ + \n :log error \"DHCP FAILED to send assignment to iptrack.vntx.net on\ + \_attempt \$attempts/\$max for \$leaseActMAC / \$leaseActIP / \$remoteID\"\ + ;\ + \n }\ + \n\ + \n # Try url (gaiia) - assignment\ + \n :do {\ + \n /tool fetch duration=30s mode=https url=\"\$url\" http-method=post\ + \_http-header-field=\"Content-Type: application/json\" http-data=\"{\\\"bo\ + und\\\":\\\"\$leaseBound\\\",\\\"mac\\\":\\\"\$leaseActMAC\\\",\\\"ip\\\":\ + \\\"\$leaseActIP\\\",\\\"server\\\":\\\"\$leaseServerName\\\",\\\"remoteId\ + \\\":\\\"\$remoteID\\\"}\"\ + \n :set success2 1;\ + \n :log info \"DHCP assignment successfully sent to gaiia AWS API for\ + \_\$leaseActMAC / \$leaseActIP / \$remoteID\";\ + \n } on-error={\ + \n :log error \"DHCP FAILED to send assignment to gaiia AWS API on at\ + tempt \$attempts/\$max for \$leaseActMAC / \$leaseActIP / \$remoteID\";\ + \n }\ + \n\ + \n :if (\$success1 != 1 || \$success2 != 1) do={\ + \n :delay 30s;\ + \n }\ + \n }\ + \n :if (\$success1 = 1 && \$success2 = 1) do {\ + \n :set attempts \$max;\ + \n }\ + \n} while ( \$attempts < \$max )\ + \n}\ + \n" lease-time=1h name=380-mgmt +/ip smb users +set [ find default=yes ] disabled=yes +/ipv6 dhcp-server option +add code=23 name=v6-dns value="'2606:1c80::240''2606:1c80::250'" +/ipv6 pool +add name=core-cust-pd-1 prefix=2606:1c80:1401::/48 prefix-length=56 +/ppp profile +set *0 dns-server=1.1.1.1,9.9.9.9 +add change-tcp-mss=yes dhcpv6-pd-pool=core-cust-pd-1 dns-server=\ + 1.1.1.1,9.9.9.9 local-address=100.64.11.253 name=380 on-down="{\ + \n:local url \"https://xtjlpnubrg.execute-api.us-east-1.amazonaws.com/prod\ + uction/on-start-workflow-execution/6f19a48f-04f7-40c1-a4e0-8cb66df99213/0a\ + 7f4443-fc8a-4fe0-807c-f535d2aa1865\"\ + \n:local url2 \"https://iptrack.vntx.net/api/pppoe\"\ + \n:local max 1\ + \n\ + \n:local localAddr \$\"local-address\"\ + \n:local remoteAddr \$\"remote-address\"\ + \n:local callerId \$\"caller-id\"\ + \n:local calledId \$\"called-id\"\ + \n:local interfaceName [/interface get \$interface name]\ + \n\ + \n:local attempts 0\ + \n:local success1 0\ + \n:local success2 0\ + \n:do {\ + \n :set attempts (\$attempts+1);\ + \n\ + \n # Try url2 (iptrack.vntx.net)\ + \n :do {\ + \n /tool fetch duration=30s mode=https url=\"\$url2\" http-method=post \ + http-header-field=\"Content-Type: application/json\" http-data=\"{\\\"boun\ + d\\\":\\\"0\\\",\\\"username\\\":\\\"\$user\\\",\\\"callingstationid\\\":\ + \\\"\$callerId\\\",\\\"framedip\\\":\\\"\$remoteAddr\\\"}\"\ + \n :set success1 1;\ + \n :log info \"PPPoE deassignment successfully sent to iptrack.vntx.net\ + \_for \$user / \$remoteAddr\";\ + \n } on-error={\ + \n :log error \"PPPoE FAILED to send deassignment to iptrack.vntx.net o\ + n attempt \$attempts out of \$max for \$user / \$remoteAddr\";\ + \n }\ + \n\ + \n # Try url (AWS API)\ + \n :do {\ + \n /tool fetch duration=30s mode=https url=\"\$url\" http-method=post h\ + ttp-header-field=\"Content-Type: application/json\" http-data=\"{\\\"bound\ + \\\":\\\"0\\\",\\\"username\\\":\\\"\$user\\\",\\\"callingstationid\\\":\\\ + \"\$callerId\\\",\\\"framedip\\\":\\\"\$remoteAddr\\\"}\"\ + \n :set success2 1;\ + \n :log info \"PPPoE deassignment successfully sent to gaiia AWS API fo\ + r \$user / \$remoteAddr\";\ + \n } on-error={\ + \n :log error \"PPPoPoE FAILED to send deassignment to gaiia AWS API on\ + \_attempt \$attempts out of \$max for \$user / \$remoteAddr\";\ + \n }\ + \n\ + \n :if (\$success1 = 1 && \$success2 = 1) do {\ + \n :set attempts \$max;\ + \n } else {\ + \n :delay 3s;\ + \n }\ + \n} while ( \$attempts < \$max )\ + \n}\ + \n" on-up="{\ + \n:local url \"https://xtjlpnubrg.execute-api.us-east-1.amazonaws.com/prod\ + uction/on-start-workflow-execution/6f19a48f-04f7-40c1-a4e0-8cb66df99213/0a\ + 7f4443-fc8a-4fe0-807c-f535d2aa1865\"\ + \n:local url2 \"https://iptrack.vntx.net/api/pppoe\"\ + \n:local max 5\ + \n\ + \n:local localAddr \$\"local-address\"\ + \n:local remoteAddr \$\"remote-address\"\ + \n:local callerId \$\"caller-id\"\ + \n:local calledId \$\"called-id\"\ + \n:local interfaceName [/interface get \$interface name]\ + \n\ + \n:local attempts 0\ + \n:local success1 0\ + \n:local success2 0\ + \n:do {\ + \n :set attempts (\$attempts+1);\ + \n\ + \n # Try url2 (iptrack.vntx.net)\ + \n :do {\ + \n /tool fetch duration=30s mode=https url=\"\$url2\" http-method=post \ + http-header-field=\"Content-Type: application/json\" http-data=\"{\\\"boun\ + d\\\":\\\"1\\\",\\\"username\\\":\\\"\$user\\\",\\\"callingstationid\\\":\ + \\\"\$callerId\\\",\\\"framedip\\\":\\\"\$remoteAddr\\\"}\"\ + \n :set success1 1;\ + \n :log info \"PPPoE assignment successfully sent to iptrack.vntx.net f\ + or \$user / \$remoteAddr\";\ + \n } on-error={\ + \n :log error \"PPPoE FAILED to send assignment to iptrack.vntx.net on \ + attempt \$attempts out of \$max for \$user / \$remoteAddr\";\ + \n }\ + \n\ + \n # Try url (gaiia)\ + \n :do {\ + \n /tool fetch duration=30s mode=https url=\"\$url\" http-method=post h\ + ttp-header-field=\"Content-Type: application/json\" http-data=\"{\\\"bound\ + \\\":\\\"1\\\",\\\"username\\\":\\\"\$user\\\",\\\"callingstationid\\\":\\\ + \"\$callerId\\\",\\\"framedip\\\":\\\"\$remoteAddr\\\"}\"\ + \n :set success2 1;\ + \n :log info \"PPPoE assignment successfully sent to gaiia AWS API for \ + \$user / \$remoteAddr\";\ + \n } on-error={\ + \n :log error \"PPPoE FAILED to send assignment to gaiia AWS API on att\ + empt \$attempts out of \$max for \$user / \$remoteAddr\";\ + \n }\ + \n\ + \n :if (\$success1 = 1 && \$success2 = 1) do {\ + \n :set attempts \$max;\ + \n } else {\ + \n :delay 3s;\ + \n }\ + \n} while ( \$attempts < \$max )\ + \n}\ + \n" remote-address=380 remote-ipv6-prefix-pool=core-cust-pd-1 use-ipv6=no \ + use-upnp=no +set *FFFFFFFE dns-server=1.1.1.1,9.9.9.9 local-address=172.16.91.254 \ + remote-address=sstp +/queue type +add kind=fq-codel name=FQ_Codel +/queue interface +set ether1-982-60ghz queue=FQ_Codel +set ether2-office queue=FQ_Codel +set ether3-edge-direct queue=FQ_Codel +set ether4-newhope queue=FQ_Codel +set ether5-climax queue=FQ_Codel +set ether6-culleoka-11ghz queue=FQ_Codel +set ether7-380tower queue=FQ_Codel +set ether8-380-lan queue=FQ_Codel +set sfp-sfpplus1-edge-preseem queue=FQ_Codel +/routing bgp instance +add as=393837 cluster-id=10.254.254.253 disabled=no name=bgp-instance-1 \ + router-id=10.254.254.253 +/routing bgp template +set default as=65530 disabled=no +/routing id +add disabled=no id=10.254.254.253 name=id-1 select-dynamic-id="" +/routing ospf instance +add disabled=no in-filter-chain=ospf-in name=default-v2 out-filter-chain=\ + ospf-out redistribute=connected,static router-id=id-1 routing-table=main +/routing ospf area +add disabled=no instance=default-v2 name=backbone-v2 +/routing table +add fib name=testnat +/snmp community +set [ find default=yes ] addresses=204.110.188.0/22,10.0.0.0/8 name=\ + kdyyJrT0Mm +/system logging action +add name=logs remote=204.110.191.229 src-address=10.254.254.253 target=remote +/ip smb +set enabled=no +/ip firewall connection tracking +set tcp-established-timeout=4h tcp-fin-wait-timeout=2m tcp-time-wait-timeout=\ + 2m +/ip neighbor discovery-settings +set discover-interface-list=!dynamic +/ip settings +set max-neighbor-entries=8192 tcp-syncookies=yes +/ipv6 settings +set accept-router-advertisements=no max-neighbor-entries=8192 \ + soft-max-neighbor-entries=8191 +/interface pppoe-server server +add authentication=mschap2 default-profile=380 disabled=no interface=\ + ether8-380-lan max-mru=1492 max-mtu=1492 service-name=380 +/interface sstp-server server +set certificate=ServerCA enabled=yes +/ip address +add address=204.110.191.185/30 interface=sfp-sfpplus1-edge-preseem network=\ + 204.110.191.184 +add address=10.0.0.254/24 disabled=yes interface=ether7-380tower network=\ + 10.0.0.0 +add address=10.250.1.89/29 interface=ether5-climax network=10.250.1.88 +add address=10.250.1.62/29 interface=ether4-newhope network=10.250.1.56 +add address=204.110.191.181/30 interface=ether3-edge-direct network=\ + 204.110.191.180 +add address=10.254.254.253 interface=lo network=10.254.254.253 +add address=10.250.2.6/29 interface=ether2-office network=10.250.2.0 +add address=10.250.1.38/29 interface=ether1-982-60ghz network=10.250.1.32 +add address=10.250.1.54/29 interface=ether6-culleoka-11ghz network=\ + 10.250.1.48 +add address=10.250.1.246/29 disabled=yes interface=ether8-380-lan network=\ + 10.250.1.240 +add address=100.64.11.254/22 interface=ether8-380-lan network=100.64.8.0 +add address=10.10.79.254/20 interface=vlan10_ether8 network=10.10.64.0 +/ip dhcp-server network +add address=10.10.64.0/20 dns-server=1.1.1.1,9.9.9.9 domain=vntx.net gateway=\ + 10.10.79.254 ntp-server=23.150.40.242,108.61.215.221,162.254.225.152 +add address=100.64.8.0/22 dns-server=1.1.1.1,9.9.9.9 domain=vntx.net gateway=\ + 100.64.11.254 ntp-server=23.150.40.242,108.61.215.221,162.254.225.152 +/ip dns +set servers=1.1.1.1,9.9.9.9 +/ip firewall address-list +add address=204.110.188.0/22 comment="VNTX public IPs" list=trusted +add address=100.64.0.0/10 comment="CGNAT pools" list=trusted +add address=10.0.0.0/8 comment="RFC 1918 private" list=trusted +add address=192.168.0.0/16 comment="RFC 1918 private" list=trusted +/ip firewall filter +add action=accept chain=input comment="allow trusted to management services" \ + dst-port=8728-8729,8291,22 protocol=tcp src-address-list=trusted +add action=accept chain=input comment="allow trusted to management services" \ + dst-port=8728-8729,8291,22 protocol=tcp src-address-list=trusted +add action=drop chain=input comment="drop untrusted to management services" \ + dst-port=8728-8729,8291,22 protocol=tcp +add action=drop chain=input comment="drop untrusted to management services" \ + dst-port=8728-8729,8291,22 protocol=tcp +add action=accept chain=forward comment=\ + "bypass fasttrack for MPLS spine (out)" out-interface=ether1-982-60ghz +add action=accept chain=forward comment=\ + "bypass fasttrack for MPLS spine (in)" in-interface=ether1-982-60ghz +add action=accept chain=forward comment=\ + "bypass fasttrack for MPLS spine (out)" out-interface=ether4-newhope +add action=accept chain=forward comment=\ + "bypass fasttrack for MPLS spine (in)" in-interface=ether4-newhope +add action=accept chain=forward comment=\ + "bypass fasttrack for MPLS spine (out)" out-interface=\ + ether6-culleoka-11ghz +add action=accept chain=forward comment=\ + "bypass fasttrack for MPLS spine (in)" in-interface=ether6-culleoka-11ghz +add action=accept chain=forward comment=\ + "bypass fasttrack for MPLS spine (out)" out-interface=ether5-climax +add action=accept chain=forward comment=\ + "bypass fasttrack for MPLS spine (in)" in-interface=ether5-climax +add action=accept chain=forward comment=\ + "bypass fasttrack for MPLS spine (in)" in-interface=ether3-edge-direct +add action=accept chain=forward comment=\ + "bypass fasttrack for MPLS spine (out)" out-interface=ether3-edge-direct +add action=accept chain=forward comment=\ + "bypass fasttrack for MPLS spine (in)" in-interface=\ + sfp-sfpplus1-edge-preseem +add action=accept chain=forward comment=\ + "bypass fasttrack for MPLS spine (out)" out-interface=\ + sfp-sfpplus1-edge-preseem +add action=fasttrack-connection chain=forward comment=\ + "fasttrack established/related" connection-state=established,related +add action=drop chain=forward disabled=yes src-address-list=suspended +add action=drop chain=forward disabled=yes dst-address-list=suspended +add action=accept chain=forward comment="accept established/related" \ + connection-state=established,related +/ip ipsec profile +set [ find default=yes ] dh-group=modp2048 dpd-interval=2m \ + dpd-maximum-failures=5 enc-algorithm=aes-256,aes-128 hash-algorithm=\ + sha256 +/ip proxy +set port=29347 +/ip proxy access +add src-address=10.0.0.0/8 +add src-address=100.64.0.0/10 +add src-address=204.110.188.0/22 +add action=deny +/ip route +add check-gateway=ping disabled=yes dst-address=0.0.0.0/0 gateway=\ + 204.110.191.186 +add disabled=no distance=1 dst-address=204.110.188.32/27 gateway=10.250.1.94 \ + routing-table=main scope=30 target-scope=10 +add disabled=no distance=1 dst-address=10.250.1.88/29 gateway=10.250.1.94 \ + routing-table=main scope=30 target-scope=10 +add dst-address=10.254.254.101/32 gateway=10.250.1.94 +add dst-address=10.254.254.102/32 gateway=10.250.1.94 +add dst-address=10.250.1.64/29 gateway=10.250.1.94 +add dst-address=10.250.1.24/29 gateway=10.250.1.94 +add dst-address=10.250.1.8/29 gateway=10.250.1.94 +add dst-address=10.10.16.0/20 gateway=10.250.1.94 +add dst-address=10.10.0.0/20 gateway=10.250.1.94 +add dst-address=204.110.191.0/27 gateway=10.250.1.94 +add dst-address=100.64.4.0/22 gateway=10.250.1.94 +add dst-address=100.64.12.0/22 gateway=10.250.1.94 +add dst-address=100.64.0.0/22 gateway=10.250.1.94 +add dst-address=204.110.188.224/27 gateway=10.250.1.94 +add dst-address=10.250.1.144/29 gateway=10.250.1.94 +add dst-address=10.254.254.111/32 gateway=10.250.1.94 +add dst-address=100.64.160.0/20 gateway=10.250.1.94 +add dst-address=204.110.188.64/27 gateway=10.250.1.94 +/ipv6 route +add comment="default via preseem (primary)" distance=1 dst-address=::/0 \ + gateway=2606:1c80:0:1002::1 +add comment="default via direct (backup)" distance=2 dst-address=::/0 \ + gateway=2606:1c80:0:11::1 +add comment="edge loopback via preseem" distance=1 dst-address=\ + 2606:1c80:0:10::254/128 gateway=2606:1c80:0:1002::1 +add comment="edge loopback via direct" distance=2 dst-address=\ + 2606:1c80:0:10::254/128 gateway=2606:1c80:0:11::1 +add blackhole comment="core /44 aggregate (iBGP origin)" distance=200 \ + dst-address=2606:1c80:1400::/44 +add comment="verona loopback via climax" dst-address=2606:1c80:0:10::101/128 \ + gateway=2606:1c80:0:12::2 +add comment="climax loopback via climax P2P" dst-address=\ + 2606:1c80:0:10::102/128 gateway=2606:1c80:0:12::2 +add comment="494 loopback (iBGP bootstrap, via climax)" distance=1 \ + dst-address=2606:1c80:0:10::111/128 gateway=2606:1c80:0:12::2 +add comment="982 loopback (iBGP bootstrap)" distance=1 dst-address=\ + 2606:1c80:0:10::110/128 gateway=2606:1c80:0:14::2 +add comment="culleoka loopback (iBGP bootstrap)" distance=1 dst-address=\ + 2606:1c80:0:10::104/128 gateway=2606:1c80:0:15::2 +add comment="newhope loopback (iBGP bootstrap)" distance=1 dst-address=\ + 2606:1c80:0:10::108/128 gateway=2606:1c80:0:13::2 +add comment="lowry loopback (via newhope)" distance=1 dst-address=\ + 2606:1c80:0:10::109/128 gateway=2606:1c80:0:13::2 +/ip service +set ftp disabled=yes +set telnet address=204.110.188.0/22,10.0.0.0/8 disabled=yes +set ssh address=204.110.188.0/22,10.0.0.0/8 port=1022 +set www address=204.110.188.225/32 port=2080 +set winbox address=204.110.188.0/22,10.0.0.0/8 +set api address=204.110.188.0/22,10.0.0.0/8,100.64.0.0/10 +set api-ssl certificate=myCa +/ip ssh +set host-key-type=ed25519 password-authentication=yes strong-crypto=yes +/ipv6 address +add address=2606:1c80:0:10::253/128 advertise=no comment=loopback interface=\ + lo +add address=2606:1c80:0:11::2 advertise=no comment=\ + "P2P core<->edge direct (backup)" interface=ether3-edge-direct +add address=2606:1c80:0:1002::2 advertise=no comment=\ + "P2P core<->edge via preseem (primary)" interface=\ + sfp-sfpplus1-edge-preseem +add address=2606:1c80:0:12::1 advertise=no comment="P2P core<->climax" \ + interface=ether5-climax +add address=2606:1c80:0:13::1 advertise=no comment="P2P core<->newhope" \ + interface=ether4-newhope +add address=2606:1c80:0:14::1 advertise=no comment="P2P core<->982 60GHz" \ + interface=ether1-982-60ghz +add address=2606:1c80:0:15::1 advertise=no comment="P2P core<->culleoka" \ + interface=ether6-culleoka-11ghz +add address=2606:1c80:1400::1 comment="380 mgmt LAN" interface=vlan10_ether8 +add address=2606:1c80:1400:1::1 comment="380 customer LAN (PPPoE+DHCP)" \ + interface=ether8-380-lan +/ipv6 dhcp-server +add dhcp-option=v6-dns interface=vlan10_ether8 name=core-mgmt-stateless +add address-pool=core-cust-pd-1 dhcp-option=v6-dns interface=ether8-380-lan \ + name=core-cust-pd-server +/ipv6 firewall filter +add action=accept chain=input comment=established connection-state=\ + established,related +add action=drop chain=input comment=invalid connection-state=invalid +add action=accept chain=input comment=icmpv6 protocol=icmpv6 +add action=accept chain=input comment=link-local src-address=fe80::/10 +add action=accept chain=input comment=internal src-address=2606:1c80::/32 +add action=drop chain=input comment="default deny" +add action=accept chain=forward comment=established connection-state=\ + established,related +add action=drop chain=forward comment=invalid connection-state=invalid +add action=accept chain=forward comment=PMTUD hop-limit=equal:1 protocol=\ + icmpv6 +add action=accept chain=forward comment=icmpv6 protocol=icmpv6 +add action=accept chain=forward comment="v6 outbound (all vntx sources)" \ + src-address=2606:1c80::/32 +add action=drop chain=forward comment="default deny" +/ipv6 nd +set [ find default=yes ] advertise-dns=yes +add advertise-dns=yes interface=vlan10_ether8 other-configuration=yes +add advertise-dns=yes interface=ether8-380-lan other-configuration=yes +/mpls interface +add interface=ether5-climax mpls-mtu=1508 +add interface=ether6-culleoka-11ghz mpls-mtu=1508 +add interface=ether4-newhope mpls-mtu=1508 +add interface=ether1-982-60ghz mpls-mtu=1508 +add interface=ether3-edge-direct mpls-mtu=1508 +add interface=sfp-sfpplus1-edge-preseem mpls-mtu=1508 +/mpls ldp +add disabled=no lsr-id=10.254.254.253 transport-addresses=10.254.254.253 vrf=\ + main +/mpls ldp interface +add interface=ether5-climax +add interface=ether6-culleoka-11ghz +add interface=ether4-newhope +add interface=ether1-982-60ghz +/mpls settings +set propagate-ttl=no +/ppp aaa +set use-radius=yes +/radius +add address=204.110.191.248 require-message-auth=no service=ppp src-address=\ + 204.110.191.185 timeout=300ms +add address=204.110.191.2 require-message-auth=no service=ppp src-address=\ + 204.110.191.185 timeout=300ms +add accounting-backup=yes address=45.76.56.5 require-message-auth=no service=\ + ppp src-address=204.110.191.185 timeout=300ms +/routing bfd configuration +add disabled=no interfaces=all min-rx=200ms min-tx=200ms multiplier=5 +/routing bgp connection +add afi=ip,ipv6 as=393837 comment="iBGP to edge, v4 transport / v6 NLRI" \ + input.filter=v6-rr-in instance=bgp-instance-1 local.address=\ + 2606:1c80:0:10::253 .role=ibgp-rr multihop=yes name=ibgp-rr-edge \ + nexthop-choice=default output.filter-chain=v6-rr-out .redistribute=\ + connected,static remote.address=2606:1c80:0:10::254 .as=393837 templates=\ + default +add afi=ip,ipv6 as=393837 comment="RR client: climax" input.filter=v6-rr-in \ + instance=bgp-instance-1 local.address=2606:1c80:0:10::253 .role=ibgp-rr \ + multihop=yes name=ibgp-rr-climax nexthop-choice=default \ + output.filter-chain=v6-rr-out .redistribute=connected,static \ + remote.address=2606:1c80:0:10::102 .as=393837 templates=default +add afi=ip,ipv6 as=393837 comment="RR client: culleoka" input.filter=v6-rr-in \ + instance=bgp-instance-1 local.address=2606:1c80:0:10::253 .role=ibgp-rr \ + multihop=yes name=ibgp-rr-culleoka nexthop-choice=default \ + output.filter-chain=v6-rr-out .redistribute=connected,static \ + remote.address=2606:1c80:0:10::104 .as=393837 templates=default +add afi=ip,ipv6 as=393837 comment="RR client: newhope" input.filter=v6-rr-in \ + instance=bgp-instance-1 local.address=2606:1c80:0:10::253 .role=ibgp-rr \ + multihop=yes name=ibgp-rr-newhope nexthop-choice=default \ + output.filter-chain=v6-rr-out .redistribute=connected,static \ + remote.address=2606:1c80:0:10::108 .as=393837 templates=default +add afi=ip,ipv6 as=393837 comment="RR client: lowry" input.filter=v6-rr-in \ + instance=bgp-instance-1 local.address=2606:1c80:0:10::253 .role=ibgp-rr \ + multihop=yes name=ibgp-rr-lowry nexthop-choice=default \ + output.filter-chain=v6-rr-out .redistribute=connected,static \ + remote.address=2606:1c80:0:10::109 .as=393837 templates=default +add afi=ip,ipv6 as=393837 comment="RR client: 982" input.filter=v6-rr-in \ + instance=bgp-instance-1 local.address=2606:1c80:0:10::253 .role=ibgp-rr \ + multihop=yes name=ibgp-rr-982 nexthop-choice=default output.filter-chain=\ + v6-rr-out .redistribute=connected,static remote.address=\ + 2606:1c80:0:10::110 .as=393837 templates=default +add afi=ip,ipv6 as=393837 comment="RR client: 494" input.filter=v6-rr-in \ + instance=bgp-instance-1 local.address=2606:1c80:0:10::253 .role=ibgp-rr \ + multihop=yes name=ibgp-rr-494 nexthop-choice=default output.filter-chain=\ + v6-rr-out .redistribute=connected,static remote.address=\ + 2606:1c80:0:10::111 .as=393837 templates=default +add afi=ip,ipv6 as=393837 comment="RR client: verona" input.filter=v6-rr-in \ + instance=bgp-instance-1 local.address=2606:1c80:0:10::253 .role=ibgp-rr \ + multihop=yes name=ibgp-rr-verona nexthop-choice=default \ + output.filter-chain=v6-rr-out .redistribute=connected,static \ + remote.address=2606:1c80:0:10::101 .as=393837 templates=default +/routing filter rule +add chain=ospf-in disabled=no rule="accept;" +add chain=ospf-out disabled=no rule="accept;" +add chain=v6-ibgp-out disabled=no rule="if (dst==2606:1c80:1400::/44 || dst==2\ + 606:1c80:0:10::253/128 || (dst-len==64 && (dst==2606:1c80:0:11::/64 || dst\ + ==2606:1c80:0:12::/64 || dst==2606:1c80:0:13::/64 || dst==2606:1c80:0:14::\ + /64 || dst==2606:1c80:0:15::/64 || dst==2606:1c80:0:1002::/64))) { accept;\ + \_} else { reject; }" +add chain=v6-rr-out disabled=no rule="if (dst==::/0 || dst-len==44 || dst-len=\ + =64 || dst-len==128) { accept; } else { reject; }" +add chain=v6-rr-in disabled=no rule="if (dst==::/0 || dst-len==44 || dst-len==\ + 64 || dst-len==128) { accept; } else { reject; }" +/routing ospf interface-template +add area=backbone-v2 auth-id=1 cost=10 disabled=no interfaces=\ + sfp-sfpplus1-edge-preseem priority=1 type=ptp use-bfd=yes +add area=backbone-v2 auth=sha512 auth-id=1 cost=10 disabled=no interfaces=\ + ether5-climax priority=1 type=ptp use-bfd=no +add area=backbone-v2 auth=sha512 auth-id=1 cost=10 disabled=no interfaces=\ + ether4-newhope priority=1 type=ptp use-bfd=no +add area=backbone-v2 auth-id=1 cost=100 disabled=no interfaces=\ + ether3-edge-direct priority=1 type=ptp use-bfd=yes +add area=backbone-v2 auth-id=1 cost=10 disabled=no interfaces=ether2-office \ + priority=1 type=nbma use-bfd=no +add area=backbone-v2 auth-id=1 cost=10 disabled=no interfaces=\ + ether1-982-60ghz priority=1 type=ptp use-bfd=no +add area=backbone-v2 auth=sha512 auth-id=1 cost=10 disabled=no interfaces=\ + ether6-culleoka-11ghz priority=1 type=ptp use-bfd=no +/routing ospf static-neighbor +add address=204.110.191.186%sfp-sfpplus1-edge-preseem area=backbone-v2 \ + disabled=no +add address=10.250.2.1%ether2-office area=backbone-v2 disabled=no +/routing rip static-neighbor +add address="" disabled=no instance=*1 +/routing rule +add action=lookup-only-in-table disabled=yes src-address=10.10.0.0/16 table=\ + testnat +add action=lookup-only-in-table disabled=yes src-address=100.64.0.0/22 table=\ + testnat +add action=lookup-only-in-table disabled=yes src-address=100.64.0.0/10 table=\ + testnat +/snmp +set contact=graham@vntx.net enabled=yes location="33.174156, -96.491941" \ + trap-generators=temp-exception,interfaces,start-trap trap-interfaces=all \ + trap-target=204.110.191.234 +/system clock +set time-zone-name=America/Chicago +/system identity +set name=Core +/system logging +add action=logs topics=info +add action=logs disabled=yes prefix=ospf topics=ospf +/system ntp client +set enabled=yes +/system ntp client servers +add address=204.110.191.19 +add address=0.us.pool.ntp.org +add address=216.229.4.66 +add address=1.us.pool.ntp.org +/system package update +set channel=long-term +/system scheduler +add name=reboot on-event="/system reboot\ + \n" policy=\ + ftp,reboot,read,write,policy,test,password,sniff,sensitive,romon \ + start-date=2025-01-07 start-time=04:00:00 +/tool graphing interface +add +/tool graphing queue +add +/tool graphing resource +add diff --git a/mikrotik-tool/opt82-sync.rsc b/mikrotik-tool/opt82-sync.rsc new file mode 100644 index 0000000..e7a66f0 --- /dev/null +++ b/mikrotik-tool/opt82-sync.rsc @@ -0,0 +1,20 @@ +:global opt82sync do={ + # Add bypass for any bound lease with Option 82 + /ip dhcp-server lease foreach={ + :if ($activeAgentRemoteId != "" && $status = "bound") do={ + :if ([/ip hotspot ip-binding find where address=$activeAddress server=hotspot1 mac-address=$activeMacAddress] = "") do={ + /ip hotspot ip-binding add address=$activeAddress mac-address=$activeMacAddress server=hotspot1 to-address=$activeAddress type=bypassed + :log info "opt82-poll: bypassed $activeMacAddress / $activeAddress" + } + } + } + # Remove bypasses for IPs that no longer have any bound lease + /ip hotspot ip-binding foreach={ + :if ($server = "hotspot1" && $mac-address != "") do={ + :if ([/ip dhcp-server lease find where activeAddress=$address] = "") do={ + /ip hotspot ip-binding remove $. + :log info "opt82-poll: removed bypass for $mac-address / $address (no lease)" + } + } + } +} diff --git a/mikrotik-tool/pppoe-fix-494.rsc b/mikrotik-tool/pppoe-fix-494.rsc new file mode 100644 index 0000000..33770c3 --- /dev/null +++ b/mikrotik-tool/pppoe-fix-494.rsc @@ -0,0 +1,18 @@ +# Fix 5+6: Rebuild OSPF with standard naming + add redistribute=connected +# Remove old OSPF config (templates first, then area, then instance) +/routing ospf interface-template remove numbers=*5 +/routing ospf interface-template remove numbers=*4 +/routing ospf area remove numbers=*1 +/routing ospf instance remove numbers=*0 +# Recreate with standard naming and redistribute +/routing ospf instance add name=default-v2 router-id=id-1 originate-default=never redistribute=connected in-filter-chain=ospf-in out-filter-chain=ospf-out version=2 vrf=main +/routing ospf area add name=backbone-v2 area-id=0.0.0.0 instance=default-v2 type=default +/routing ospf interface-template add area=backbone-v2 interfaces=ether2-climax cost=10 type=ptp use-bfd=no dead-interval=40s hello-interval=10s priority=1 +/routing ospf interface-template add area=backbone-v2 passive type=broadcast cost=1 + +# Fix 9: Add idle timeout to PPPoE profile +/ppp profile set [find name="494"] idle-timeout=1h + +# Fix 10: Increase RADIUS timeout from 300ms to 2s +/radius set [find address=204.110.191.248] timeout=2s +/radius set [find address=204.110.191.2] timeout=2s diff --git a/mikrotik-tool/pppoe-fix-982.rsc b/mikrotik-tool/pppoe-fix-982.rsc new file mode 100644 index 0000000..5607bc8 --- /dev/null +++ b/mikrotik-tool/pppoe-fix-982.rsc @@ -0,0 +1,5 @@ +# Fix 9: Add idle timeout to PPPoE profile +/ppp profile set [find name="982"] idle-timeout=1h + +# Fix 10: Increase RADIUS timeout from 300ms to 2s +/radius set [find address=204.110.191.248] timeout=2s diff --git a/mikrotik-tool/pppoe-fix-climax.rsc b/mikrotik-tool/pppoe-fix-climax.rsc new file mode 100644 index 0000000..7d1697c --- /dev/null +++ b/mikrotik-tool/pppoe-fix-climax.rsc @@ -0,0 +1,2 @@ +# Fix 9: Add idle timeout to PPPoE profile +/ppp profile set [find name="pppoe"] idle-timeout=1h diff --git a/mikrotik-tool/pppoe-fix-core.rsc b/mikrotik-tool/pppoe-fix-core.rsc new file mode 100644 index 0000000..200b7b3 --- /dev/null +++ b/mikrotik-tool/pppoe-fix-core.rsc @@ -0,0 +1,5 @@ +# Fix 9: Add idle timeout to PPPoE profile +/ppp profile set [find name="380"] idle-timeout=1h + +# Fix 10: Increase RADIUS timeout from 300ms to 2s +/radius set [find address=204.110.191.248] timeout=2s diff --git a/mikrotik-tool/pppoe-fix-culleoka.rsc b/mikrotik-tool/pppoe-fix-culleoka.rsc new file mode 100644 index 0000000..7d1697c --- /dev/null +++ b/mikrotik-tool/pppoe-fix-culleoka.rsc @@ -0,0 +1,2 @@ +# Fix 9: Add idle timeout to PPPoE profile +/ppp profile set [find name="pppoe"] idle-timeout=1h diff --git a/mikrotik-tool/pppoe-fix-lowry-mrru.rsc b/mikrotik-tool/pppoe-fix-lowry-mrru.rsc new file mode 100644 index 0000000..2796519 --- /dev/null +++ b/mikrotik-tool/pppoe-fix-lowry-mrru.rsc @@ -0,0 +1 @@ +/interface pppoe-server server set [find interface=lowrycrossing] mrru=disabled diff --git a/mikrotik-tool/pppoe-fix-lowry.rsc b/mikrotik-tool/pppoe-fix-lowry.rsc new file mode 100644 index 0000000..7d1697c --- /dev/null +++ b/mikrotik-tool/pppoe-fix-lowry.rsc @@ -0,0 +1,2 @@ +# Fix 9: Add idle timeout to PPPoE profile +/ppp profile set [find name="pppoe"] idle-timeout=1h diff --git a/mikrotik-tool/pppoe-fix-newhope.rsc b/mikrotik-tool/pppoe-fix-newhope.rsc new file mode 100644 index 0000000..33604eb --- /dev/null +++ b/mikrotik-tool/pppoe-fix-newhope.rsc @@ -0,0 +1,5 @@ +# Fix 7: Remove RIP from OSPF redistribute +/routing ospf instance set [find name=default-v2] redistribute=connected + +# Fix 9: Add idle timeout to PPPoE profile +/ppp profile set [find name="newhope"] idle-timeout=1h diff --git a/mikrotik-tool/pppoe-fix-verona.rsc b/mikrotik-tool/pppoe-fix-verona.rsc new file mode 100644 index 0000000..0a46e27 --- /dev/null +++ b/mikrotik-tool/pppoe-fix-verona.rsc @@ -0,0 +1,2 @@ +# Fix 11: Remove disabled test PPPoE server on ether1 +/interface pppoe-server server remove [find interface=ether1] diff --git a/mikrotik-tool/radios.yaml b/mikrotik-tool/radios.yaml index 00a879a..e673f0c 100644 --- a/mikrotik-tool/radios.yaml +++ b/mikrotik-tool/radios.yaml @@ -121,6 +121,7 @@ sites: backhaul_radios: - {ip: 10.250.1.29, mac: "1A:E8:29:1E:52:16", name: "climax to verona", link: "verona<->climax AF11"} - {ip: 10.250.1.93, mac: "82:2A:A8:CF:A6:6A", name: "Climax-380 AF24", link: "climax<->core AF24"} + - {ip: 10.250.1.69, mac: "F4:92:BF:BA:BF:76", name: "Climax to 494", link: "climax<->494 5GHz airMAX PtP (AP, PBE-5AC-500)"} culleoka: router: 10.254.254.104 mgmt_subnet: 10.10.96.0/20 @@ -296,6 +297,8 @@ sites: access_points: - {ip: 10.10.175.10, mac: "78:8A:20:AC:C5:32", name: "494-Rocket 2AC Prism"} - {ip: 10.10.175.11, mac: "58:C1:7A:75:4D:F2", name: "494 ePMP Omni"} + backhaul_radios: + - {ip: 10.250.1.66, mac: "F4:92:BF:B4:EB:87", name: "494 to Climax", link: "climax<->494 5GHz airMAX PtP (station, PBE-5AC-400)"} core: router: 10.254.254.253 mgmt_subnet: 10.10.64.0/20 diff --git a/mikrotik-tool/radius-secrets.md b/mikrotik-tool/radius-secrets.md new file mode 100644 index 0000000..2c4a44c --- /dev/null +++ b/mikrotik-tool/radius-secrets.md @@ -0,0 +1,16 @@ +# RADIUS NAS Secrets +Source: Preseem RADIUS NAS table (provided 2026-06-26) + +| NAS IP | Router | Secret | +|--------|--------|--------| +| 204.110.188.31 | 380 (old core) | fkvpmovu8lzmymjr | +| 204.110.188.254 | Verona | rq3p2p59jfwy5zvy | +| 204.110.188.62 | Climax | ich7cp0j1qpe0801 | +| 204.110.188.158 | Culleoka | x6ov8lwph4kxp0d7 | +| 204.110.188.190 | NewHope | ivcgfagb6f0t64ws | +| 204.110.188.222 | LowryCrossing | 260oy5od2x2ltubh | +| 204.110.188.126 | 982 | wj61jc2gitzeomx6 | +| 204.110.188.94 | 494 | dyhhtcUft3CJtDfYFG1S6 | +| 204.110.191.185 | Core | TpYBHmrHd8vRelg | +| 204.110.191.190 | yorkshire | lpp1xdp1ese8msvn | +| 204.110.191.252 | Concatenator | 8ChdyTrdCZ7BLhFqEN | diff --git a/mikrotik-tool/routers-home.yaml b/mikrotik-tool/routers-home.yaml new file mode 100644 index 0000000..d10de63 --- /dev/null +++ b/mikrotik-tool/routers-home.yaml @@ -0,0 +1,7 @@ +defaults: + username: graham + password: ctg5qyn3uhe*UBP8rmw + port: 8729 +routers: + - name: home + host: 10.0.19.254 diff --git a/mikrotik-tool/verona-dns.rsc b/mikrotik-tool/verona-dns.rsc new file mode 100644 index 0000000..682d750 --- /dev/null +++ b/mikrotik-tool/verona-dns.rsc @@ -0,0 +1,2 @@ +/ppp profile set [find] dns-server=204.110.191.240,204.110.191.250 +/ip dhcp-server network set [find] dns-server=204.110.191.240,204.110.191.250 diff --git a/mikrotik-tool/verona.rsc b/mikrotik-tool/verona.rsc index 144f0c3..5e984da 100644 --- a/mikrotik-tool/verona.rsc +++ b/mikrotik-tool/verona.rsc @@ -1,4 +1,4 @@ -# 2026-05-14 13:40:52 by RouterOS 7.21.4 +# 2026-07-10 10:54:21 by RouterOS 7.21.5 # software id = Y1CT-1WB1 # # model = CCR2004-16G-2S+ @@ -7,6 +7,7 @@ add fast-forward=no name=cpe_vlan_10 port-cost-mode=short add fast-forward=no mtu=1500 name=public_vlan_100 port-cost-mode=short \ protocol-mode=none +add name=temp add add-dhcp-option82=yes dhcp-snooping=yes mtu=1500 name=verona \ port-cost-mode=short protocol-mode=none /interface ethernet @@ -30,6 +31,9 @@ set [ find default-name=ether16 ] l2mtu=9582 set [ find default-name=sfp-sfpplus1 ] l2mtu=9586 name=\ sfp-sfpplus1-verona-tower-switch set [ find default-name=sfp-sfpplus2 ] l2mtu=9586 name=sfp-sfpplus2-switch +/interface eoip +add mac-address=02:00:21:C1:75:88 name=eoip-tunnel-temp remote-address=\ + 10.254.254.102 tunnel-id=10201 /interface vlan add interface=sfp-sfpplus1-verona-tower-switch name=vlan9_sfpplus1 vlan-id=9 add interface=ether15_wave_n name=vlan10_ether15 vlan-id=10 @@ -168,18 +172,19 @@ add add-arp=yes address-pool=verona-cgnat interface=verona lease-script="{\ \n }\ \n} while ( \$attempts < \$max )\ \n}\ - \n" lease-time=1h name="verona cgnat" use-radius=accounting + \n" lease-time=1w name="verona cgnat" /ip hotspot add address-pool=verona-cgnat addresses-per-mac=unlimited interface=verona \ name=hotspot1 profile=hsprof1 /ip smb users set [ find default=yes ] disabled=yes /ipv6 dhcp-server option -add code=23 name=v6-dns value="'2606:1c80::240''2606:1c80::250'" +add code=23 name=v6-dns /ipv6 pool add name=verona-cust-pd-1 prefix=2606:1c80:1001::/48 prefix-length=56 add name=altoga-cust-pd-1 prefix=2606:1c80:1501::/48 prefix-length=56 /ppp profile +set *0 dns-server=204.110.191.240,204.110.191.250 add change-tcp-mss=yes dhcpv6-pd-pool=verona-cust-pd-1 dns-server=\ 204.110.191.240,204.110.191.250 idle-timeout=1h local-address=\ 100.64.15.253 name=pppoe-verona on-down="{\ @@ -290,8 +295,8 @@ add change-tcp-mss=yes dhcpv6-pd-pool=verona-cust-pd-1 dns-server=\ \n }\ \n} while ( \$attempts < \$max )\ \n}\ - \n" remote-address=verona-cgnat remote-ipv6-prefix-pool=verona-cust-pd-1 \ - use-upnp=no + \n" only-one=yes remote-address=verona-cgnat remote-ipv6-prefix-pool=\ + verona-cust-pd-1 use-upnp=no add change-tcp-mss=yes dhcpv6-pd-pool=altoga-cust-pd-1 dns-server=\ 204.110.191.240,204.110.191.250 idle-timeout=1h local-address=\ 100.64.15.253 name=pppoe-altoga on-down="{\ @@ -402,8 +407,9 @@ add change-tcp-mss=yes dhcpv6-pd-pool=altoga-cust-pd-1 dns-server=\ \n }\ \n} while ( \$attempts < \$max )\ \n}\ - \n" remote-address=altoga-cgnat remote-ipv6-prefix-pool=altoga-cust-pd-1 \ - use-upnp=no + \n" only-one=yes remote-address=altoga-cgnat remote-ipv6-prefix-pool=\ + altoga-cust-pd-1 use-upnp=no +set *FFFFFFFE dns-server=204.110.191.240,204.110.191.250 /queue type add kind=fq-codel name=FQ_Codel /queue interface @@ -452,9 +458,10 @@ add bridge=cpe_vlan_10 interface=vlan_10_ether6 internal-path-cost=10 \ path-cost=10 add bridge=cpe_vlan_10 interface=vlan10_sfpplus2 internal-path-cost=10 \ path-cost=10 -add bridge=*1F interface=*23 internal-path-cost=10 path-cost=10 add bridge=cpe_vlan_10 interface=vlan10_ether15 add bridge=cpe_vlan_10 interface=ether10-powerswitch +add bridge=temp interface=eoip-tunnel-temp +add bridge=temp interface=ether14 /ip firewall connection tracking set icmp-timeout=30s tcp-close-wait-timeout=1m tcp-established-timeout=4h \ tcp-fin-wait-timeout=2m tcp-last-ack-timeout=30s \ @@ -467,13 +474,12 @@ set tcp-syncookies=yes /ipv6 settings set accept-router-advertisements=no /interface pppoe-server server -add default-profile=pppoe-verona disabled=no interface=verona max-mru=1500 \ - max-mtu=1500 one-session-per-host=yes service-name=verona -add default-profile=pppoe-altoga disabled=no interface=vlan_19_ether6 \ - max-mru=1500 max-mtu=1500 one-session-per-host=yes service-name=altoga -add authentication=mschap2 default-profile=pppoe-verona interface=ether1 \ - max-mru=1492 max-mtu=1492 one-session-per-host=yes service-name=\ - veronatest +add authentication=mschap2 default-profile=pppoe-verona disabled=no \ + interface=verona max-mru=1500 max-mtu=1500 one-session-per-host=yes \ + service-name=verona +add authentication=mschap2 default-profile=pppoe-altoga disabled=no \ + interface=vlan_19_ether6 max-mru=1500 max-mtu=1500 one-session-per-host=\ + yes service-name=altoga /ip address add address=10.250.1.25/29 interface=ether3-climax-11ghz network=10.250.1.24 add address=10.254.254.101 interface=lo network=10.254.254.101 @@ -579,7 +585,12 @@ add add-arp=yes address-pool=verona-cpe authoritative=after-2sec-delay \ \n }\ \n} while ( \$attempts < \$max )\ \n}\ - \n" lease-time=1h name=verona-cpe use-radius=accounting + \n" lease-time=1h name=verona-cpe +/ip dhcp-server alert +add disabled=no interface=verona on-alert=rogue-dhcp valid-server=\ + 78:9A:18:52:B1:C2 +add disabled=no interface=cpe_vlan_10 on-alert=rogue-dhcp valid-server=\ + 78:9A:18:52:B1:C6 /ip dhcp-server config set interim-update=5m /ip dhcp-server lease @@ -591,25 +602,33 @@ add address=100.64.3.250 client-id=1:48:a9:8a:9d:9b:8a mac-address=\ 48:A9:8A:9D:9B:8A server="verona cgnat" add address=10.0.101.12 client-id=1:c4:ad:34:1a:ca:96 mac-address=\ C4:AD:34:1A:CA:96 server=verona-tower +add address=10.0.101.10 client-id=1:c4:ad:34:1a:ca:97 mac-address=\ + C4:AD:34:1A:CA:97 server=verona-tower /ip dhcp-server network add address=10.0.101.0/24 dns-server=204.110.191.240,204.110.191.250 gateway=\ - 10.0.101.254 ntp-server=204.110.191.19 -add address=10.10.0.0/20 dns-server=204.110.191.240,204.110.191.20 domain=\ - vntx.net gateway=10.10.15.254 ntp-server=204.110.191.19 + 10.0.101.254 ntp-server=23.150.40.242,108.61.215.221,162.254.225.152 +add address=10.10.0.0/20 dns-server=204.110.191.240,204.110.191.250 domain=\ + vntx.net gateway=10.10.15.254 ntp-server=\ + 23.150.40.242,108.61.215.221,162.254.225.152 add address=10.10.80.0/20 dns-server=204.110.191.240,204.110.191.250 domain=\ - vntx.net gateway=10.10.95.254 ntp-server=204.110.191.19 -add address=100.64.0.0/22 dns-server=204.110.191.240,204.110.191.20 domain=\ - vntx.net gateway=100.64.3.254 ntp-server=204.110.191.19 + vntx.net gateway=10.10.95.254 ntp-server=\ + 23.150.40.242,108.61.215.221,162.254.225.152 +add address=100.64.0.0/22 dns-server=204.110.191.240,204.110.191.250 domain=\ + vntx.net gateway=100.64.3.254 ntp-server=\ + 23.150.40.242,108.61.215.221,162.254.225.152 add address=100.64.12.0/22 dns-server=204.110.191.240,204.110.191.250 domain=\ - vntx.net gateway=100.64.15.254 ntp-server=204.110.191.19 + vntx.net gateway=100.64.15.254 ntp-server=\ + 23.150.40.242,108.61.215.221,162.254.225.152 add address=192.168.99.0/24 dns-server=204.110.191.240,204.110.191.250 \ - gateway=192.168.99.254 + gateway=192.168.99.254 ntp-server=\ + 23.150.40.242,108.61.215.221,162.254.225.152 add address=204.110.188.224/27 dns-server=204.110.191.240,204.110.191.250 \ - domain=vntx.net gateway=204.110.188.254 ntp-server=204.110.191.19 + domain=vntx.net gateway=204.110.188.254 ntp-server=\ + 23.150.40.242,108.61.215.221,162.254.225.152 /ip dhcp-server option sets add name=vntx options=*1 /ip dns -set servers=9.9.9.9,1.1.1.1,2606:1c80::240,2606:1c80::250 +set servers=204.110.191.240,204.110.191.250 /ip firewall address-list add address=204.110.188.225 comment="Graham McIntire (1)" list=VeronaEmployee add address=204.110.188.231 comment="James Hardin (1475)" list=VeronaEmployee @@ -815,7 +834,15 @@ add address=100.64.0.34 comment="America Trejo (1693)" list=Active add address=100.64.0.15 comment="Bill McTee (373)" list=Active add address=100.64.0.35 comment="Judy Devine (277)" list=ResidentialBasic add address=100.64.0.35 comment="Judy Devine (277)" list=Active +add address=204.110.188.0/22 comment="VNTX public IPs" list=trusted +add address=100.64.0.0/10 comment="CGNAT pools" list=trusted +add address=10.0.0.0/8 comment="RFC 1918 private" list=trusted +add address=192.168.0.0/16 comment="RFC 1918 private" list=trusted /ip firewall filter +add action=accept chain=input comment="allow trusted to management services" \ + dst-port=8728-8729,8291,22 protocol=tcp src-address-list=trusted +add action=drop chain=input comment="drop untrusted to management services" \ + dst-port=8728-8729,8291,22 protocol=tcp add action=accept chain=forward comment=\ "bypass fasttrack for MPLS spine (out)" out-interface=ether3-climax-11ghz add action=accept chain=forward comment=\ @@ -955,12 +982,12 @@ set propagate-ttl=no /ppp aaa set interim-update=15m use-radius=yes /radius -add address=204.110.191.248 require-message-auth=no service=ppp,hotspot,dhcp \ +add address=204.110.191.248 secret=rq3p2p59jfwy5zvy service=ppp,hotspot,dhcp \ src-address=204.110.188.254 timeout=3s -add accounting-backup=yes address=104.238.144.172 disabled=yes \ - require-message-auth=no service=ppp,hotspot,dhcp src-address=\ - 204.110.188.254 timeout=3s -add address=204.110.191.2 disabled=yes require-message-auth=no service=\ +add accounting-backup=yes address=104.238.144.172 disabled=yes secret=\ + rq3p2p59jfwy5zvy service=ppp,hotspot,dhcp src-address=204.110.188.254 \ + timeout=3s +add address=204.110.191.2 disabled=yes secret=rq3p2p59jfwy5zvy service=\ ppp,hotspot,dhcp src-address=204.110.188.254 timeout=3s /radius incoming set accept=yes @@ -980,8 +1007,9 @@ add chain=v6-ibgp-out disabled=no rule="if (dst==2606:1c80:1000::/44 || dst==2\ ==2606:1c80:0:20::/64 || dst==2606:1c80:0:30::/64))) { accept; } else { re\ ject; }" /routing ospf interface-template -add area=backbone-v2 auth=sha512 auth-id=1 cost=10 disabled=no interfaces=\ - ether3-climax-11ghz priority=1 type=ptp use-bfd=no +add area=backbone-v2 auth=sha512 auth-id=1 auth-key=\ + 5YHHWmoooAyVaFDX48ffjGjc36XMO5Irb7zS44Tsvet cost=10 disabled=no \ + interfaces=ether3-climax-11ghz priority=1 type=ptp use-bfd=no add area=backbone-v2 disabled=no passive /routing ospf static-neighbor add address=10.250.1.30%ether3-climax-11ghz area=backbone-v2 disabled=no \ @@ -1007,11 +1035,16 @@ add address=0.us.pool.ntp.org /system package update set channel=long-term /system routerboard settings +# Firmware upgraded successfully, please reboot for changes to take effect! set auto-upgrade=yes enter-setup-on=delete-key /system scheduler add name=reboot on-event="/system reboot" policy=\ ftp,reboot,read,write,policy,test,password,sniff,sensitive,romon \ start-date=2025-02-16 start-time=03:00:00 +/system script +add dont-require-permissions=no name=rogue-dhcp owner=admin policy=\ + ftp,reboot,read,write,policy,test,password,sniff,sensitive,romon source=\ + ":log warning message=\"Rogue DHCP server detected!\"" /tool e-mail set certificate-verification=no from=mikrotik@vntx.net server=10.0.0.250 /tool sniffer diff --git a/no_response.csv b/no_response.csv new file mode 100644 index 0000000..5f004f8 --- /dev/null +++ b/no_response.csv @@ -0,0 +1,2 @@ +"MAC Address" +"58:C1:7A:B3:08:B8" diff --git a/preseem/.envrc b/preseem/.envrc new file mode 100644 index 0000000..cda8c71 --- /dev/null +++ b/preseem/.envrc @@ -0,0 +1 @@ +export PRESEEM_KEY=7549e1f79f07e9bd2180c052597a4e52a38db110e27c75941d78ec3e151673b8 diff --git a/preseem/README.md b/preseem/README.md new file mode 100644 index 0000000..5573d86 --- /dev/null +++ b/preseem/README.md @@ -0,0 +1,63 @@ +# preseem + +Small Go CLI for the [Preseem Model API](https://api.preseem.com/model/v1). +Read-only fan-out across every resource in `preseem.yaml` (the bundled +OpenAPI spec). + +## Build + +```bash +go build . +go test ./... +``` + +## Auth + +```bash +export PRESEEM_KEY= # required +export PRESEEM_URL=https://api.preseem.com # optional override +``` + +Add to `.envrc` for direnv auto-load. The key is sent as HTTP Basic +username with an empty password, per the Preseem spec. + +## Usage + +``` +preseem [args] [flags] +``` + +Resources: `accounts`, `ap`, `ap-config`, `ap-scores`, `cpe-scores`, +`packages`, `services`, `routers`, `olts`, `sites`. + +List flags: `--json`, `--page N`, `--limit 100..1000`. Without +`--page`, list endpoints auto-paginate; with `--json`, the merged +output keeps the API's `{data, paginator}` envelope. + +```bash +preseem accounts list +preseem accounts get 100 +preseem ap list +preseem ap get 123 +preseem ap-config get 123 +preseem ap-scores +preseem cpe-scores +preseem packages list +preseem services list +preseem services get 23 +preseem routers list +preseem olts list +preseem sites list + +preseem accounts list --json | jq '.data | length' +preseem ap list --page 1 --limit 100 +``` + +IDs containing `/` are URL-encoded automatically +(`accounts/john/doe` → `accounts/john%2Fdoe`). + +## Scope + +Read-only (GET) for the whole spec. PUT/DELETE wrappers are +intentionally not implemented — the spec is small and stable, so add +them in `cmd_*.go` when an actual write workflow shows up. diff --git a/preseem/client.go b/preseem/client.go new file mode 100644 index 0000000..9048791 --- /dev/null +++ b/preseem/client.go @@ -0,0 +1,177 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" +) + +const ( + defaultBaseURL = "https://api.preseem.com" + apiPrefix = "/model/v1" + minLimit = 100 + maxLimit = 1000 +) + +type Client struct { + BaseURL string + Key string + HTTP *http.Client +} + +func NewClient() (*Client, error) { + key := os.Getenv("PRESEEM_KEY") + if key == "" { + return nil, errors.New("PRESEEM_KEY env var is not set") + } + base := os.Getenv("PRESEEM_URL") + if base == "" { + base = defaultBaseURL + } + return &Client{ + BaseURL: strings.TrimRight(base, "/"), + Key: key, + HTTP: &http.Client{Timeout: 60 * time.Second}, + }, nil +} + +// get performs GET against an API path (must start with /model/v1/...). +// The path should already be URL-encoded — callers building paths with +// user-supplied IDs must use pathID(). +// +// If raw is non-nil it receives the raw response body. If out is non-nil +// it is JSON-decoded. +func (c *Client) get(path string, out any, raw *[]byte) error { + u := c.BaseURL + path + req, err := http.NewRequest(http.MethodGet, u, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Basic "+basicAuth(c.Key)) + req.Header.Set("Accept", "application/json") + resp, err := c.HTTP.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + data, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 400 { + return fmt.Errorf("GET %s: %s: %s", u, resp.Status, strings.TrimSpace(truncate(string(data), 400))) + } + if raw != nil { + *raw = data + } + if out != nil && len(data) > 0 { + if err := json.Unmarshal(data, out); err != nil { + return fmt.Errorf("decode %s: %w (body: %s)", u, err, truncate(string(data), 200)) + } + } + return nil +} + +// del performs DELETE against an API path. +func (c *Client) del(path string) error { + u := c.BaseURL + path + req, err := http.NewRequest(http.MethodDelete, u, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Basic "+basicAuth(c.Key)) + resp, err := c.HTTP.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + data, _ := io.ReadAll(resp.Body) + return fmt.Errorf("DELETE %s: %s: %s", u, resp.Status, strings.TrimSpace(truncate(string(data), 400))) + } + return nil +} + +// listPaged fetches a single page of a list endpoint. base is e.g. +// "/accounts" (no /model/v1 prefix, no leading query). page/limit are +// added as query params; if limit<=0 the server default is used. +func (c *Client) listPaged(base string, page, limit int, out any, raw *[]byte) error { + q := url.Values{} + if page > 0 { + q.Set("page", strconv.Itoa(page)) + } + if limit > 0 { + q.Set("limit", strconv.Itoa(limit)) + } + path := apiPrefix + base + if s := q.Encode(); s != "" { + path += "?" + s + } + return c.get(path, out, raw) +} + +// listAll auto-paginates a list endpoint into one merged result. It +// repeatedly invokes fetchPage(page) until the returned paginator says +// we've passed page_count. The collector appends each page's data +// items; the final paginator is returned for synthetic display. +func (c *Client) listAll(base string, limit int, decode func([]byte) ([]json.RawMessage, Paginator, error)) ([]json.RawMessage, Paginator, error) { + var all []json.RawMessage + var last Paginator + page := 1 + for { + var raw []byte + if err := c.listPaged(base, page, limit, nil, &raw); err != nil { + return nil, Paginator{}, err + } + items, p, err := decode(raw) + if err != nil { + return nil, Paginator{}, fmt.Errorf("decode page %d: %w", page, err) + } + all = append(all, items...) + last = p + // Stop if server reports no further pages, or returned empty. + if len(items) == 0 || p.PageCount == 0 || page >= p.PageCount { + break + } + page++ + } + // Synthetic paginator for display: collapsed to one logical page. + last.Page = 1 + last.PageCount = 1 + last.Limit = len(all) + last.TotalCount = len(all) + return all, last, nil +} + +func basicAuth(key string) string { + // Username = key, password empty, per Preseem spec. + return base64.StdEncoding.EncodeToString([]byte(key + ":")) +} + +// pathID URL-encodes an ID for inclusion in a path segment. Per spec, +// IDs may contain '/', which must become '%2F'. +func pathID(id string) string { + return url.PathEscape(id) +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} + +func validateLimit(limit int) error { + if limit == 0 { + return nil + } + if limit < minLimit || limit > maxLimit { + return fmt.Errorf("--limit must be between %d and %d (got %d)", minLimit, maxLimit, limit) + } + return nil +} diff --git a/preseem/client_test.go b/preseem/client_test.go new file mode 100644 index 0000000..9424557 --- /dev/null +++ b/preseem/client_test.go @@ -0,0 +1,156 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "testing" +) + +func newTestClient(t *testing.T, h http.HandlerFunc) (*Client, *httptest.Server) { + t.Helper() + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + c := &Client{ + BaseURL: srv.URL, + Key: "test-key", + HTTP: srv.Client(), + } + return c, srv +} + +func TestBasicAuthHeader(t *testing.T) { + want := "Basic " + base64.StdEncoding.EncodeToString([]byte("test-key:")) + var got string + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + got = r.Header.Get("Authorization") + w.WriteHeader(200) + fmt.Fprint(w, `{}`) + }) + if err := c.get(apiPrefix+"/accounts/1", nil, nil); err != nil { + t.Fatalf("get: %v", err) + } + if got != want { + t.Errorf("Authorization header:\n got=%q\nwant=%q", got, want) + } +} + +func TestGetErrorFormatting(t *testing.T) { + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(401) + fmt.Fprint(w, `forbidden`) + }) + err := c.get(apiPrefix+"/accounts/1", nil, nil) + if err == nil { + t.Fatal("expected error") + } + msg := err.Error() + for _, want := range []string{"GET ", "401", "forbidden", apiPrefix + "/accounts/1"} { + if !strings.Contains(msg, want) { + t.Errorf("error %q missing %q", msg, want) + } + } +} + +// TestPathIDEncoding verifies IDs containing '/' are encoded per spec +// (e.g. john/doe -> john%2Fdoe) and the server sees the encoded form. +func TestPathIDEncoding(t *testing.T) { + var sawPath string + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + sawPath = r.URL.EscapedPath() + w.WriteHeader(200) + fmt.Fprint(w, `{"id":"john/doe","name":"x"}`) + }) + path := apiPrefix + "/accounts/" + pathID("john/doe") + if err := c.get(path, nil, nil); err != nil { + t.Fatalf("get: %v", err) + } + want := apiPrefix + "/accounts/john%2Fdoe" + if sawPath != want { + t.Errorf("server saw path %q, want %q", sawPath, want) + } +} + +// TestListAllPaginates walks 3 pages and concatenates rows. +func TestListAllPaginates(t *testing.T) { + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + q, _ := url.ParseQuery(r.URL.RawQuery) + page, _ := strconv.Atoi(q.Get("page")) + if page == 0 { + page = 1 + } + // 3 pages, 2 items each. + const pageCount = 3 + if page > pageCount { + fmt.Fprintf(w, `{"data":[],"paginator":{"page":%d,"page_count":%d,"limit":2,"total_count":6}}`, page, pageCount) + return + } + a := (page-1)*2 + 1 + b := a + 1 + fmt.Fprintf(w, `{"data":[{"id":"%d","name":"n%d"},{"id":"%d","name":"n%d"}],"paginator":{"page":%d,"page_count":%d,"limit":2,"total_count":6}}`, + a, a, b, b, page, pageCount) + }) + items, p, err := c.listAll("/accounts", 0, decodeListPage) + if err != nil { + t.Fatalf("listAll: %v", err) + } + if len(items) != 6 { + t.Errorf("got %d items, want 6", len(items)) + } + // Verify last item is what we expect and ordering is preserved. + var last Account + if err := json.Unmarshal(items[5], &last); err != nil { + t.Fatalf("decode last: %v", err) + } + if last.ID != "6" || last.Name != "n6" { + t.Errorf("last item = %+v, want id=6 name=n6", last) + } + if p.TotalCount != 6 { + t.Errorf("synthetic paginator total_count = %d, want 6", p.TotalCount) + } +} + +func TestListAllStopsOnEmptyPage(t *testing.T) { + calls := 0 + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + // page_count=0 means we should stop immediately on empty data. + fmt.Fprint(w, `{"data":[],"paginator":{"page":1,"page_count":0,"limit":0,"total_count":0}}`) + }) + items, _, err := c.listAll("/accounts", 0, decodeListPage) + if err != nil { + t.Fatalf("listAll: %v", err) + } + if len(items) != 0 { + t.Errorf("got %d items, want 0", len(items)) + } + if calls != 1 { + t.Errorf("hit server %d times, want 1", calls) + } +} + +func TestValidateLimit(t *testing.T) { + cases := []struct { + limit int + wantErr bool + }{ + {0, false}, + {100, false}, + {500, false}, + {1000, false}, + {99, true}, + {1001, true}, + {-1, true}, + } + for _, tc := range cases { + err := validateLimit(tc.limit) + if (err != nil) != tc.wantErr { + t.Errorf("validateLimit(%d) err=%v, wantErr=%v", tc.limit, err, tc.wantErr) + } + } +} diff --git a/preseem/cmd_accounts.go b/preseem/cmd_accounts.go new file mode 100644 index 0000000..a3ada6f --- /dev/null +++ b/preseem/cmd_accounts.go @@ -0,0 +1,64 @@ +package main + +import ( + "encoding/json" + "fmt" +) + +func cmdAccounts(args []string) error { + if len(args) == 0 { + return fmt.Errorf("accounts: expected 'list' or 'get'") + } + c, err := NewClient() + if err != nil { + return err + } + switch args[0] { + case "list", "ls": + return cmdAccountsList(c, args[1:]) + case "get": + return cmdAccountsGet(c, args[1:]) + default: + return fmt.Errorf("accounts: unknown verb %q (want: list, get)", args[0]) + } +} + +func cmdAccountsList(c *Client, args []string) error { + o, _, err := parseListFlags("accounts list", args) + if err != nil { + return err + } + items, p, raw, err := c.fetchList("/accounts", o) + if err != nil { + return err + } + return emitList(items, p, raw, o, func(items []json.RawMessage) error { + tw := newTab() + fmt.Fprintln(tw, "ID\tNAME") + for _, rm := range items { + var a Account + _ = json.Unmarshal(rm, &a) + fmt.Fprintf(tw, "%s\t%s\n", a.ID, a.Name) + } + return tw.Flush() + }) +} + +func cmdAccountsGet(c *Client, args []string) error { + jsonMode, id, err := parseGetFlags("accounts get", args) + if err != nil { + return err + } + var a Account + var raw []byte + if err := c.get(apiPrefix+"/accounts/"+pathID(id), &a, &raw); err != nil { + return err + } + if jsonMode { + return printJSON(raw) + } + tw := newTab() + fmt.Fprintln(tw, "ID\tNAME") + fmt.Fprintf(tw, "%s\t%s\n", a.ID, a.Name) + return tw.Flush() +} diff --git a/preseem/cmd_ap.go b/preseem/cmd_ap.go new file mode 100644 index 0000000..b2e6877 --- /dev/null +++ b/preseem/cmd_ap.go @@ -0,0 +1,141 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" +) + +func cmdAP(args []string) error { + if len(args) == 0 { + return fmt.Errorf("ap: expected 'list' or 'get'") + } + c, err := NewClient() + if err != nil { + return err + } + switch args[0] { + case "list", "ls": + return cmdAPList(c, args[1:]) + case "get": + return cmdAPGet(c, args[1:]) + default: + return fmt.Errorf("ap: unknown verb %q (want: list, get)", args[0]) + } +} + +func cmdAPList(c *Client, args []string) error { + o, _, err := parseListFlags("ap list", args) + if err != nil { + return err + } + items, p, raw, err := c.fetchList("/access_points", o) + if err != nil { + return err + } + return emitList(items, p, raw, o, func(items []json.RawMessage) error { + tw := newTab() + fmt.Fprintln(tw, "ID\tNAME\tTOWER\tIP_ADDRESS") + for _, rm := range items { + var a AccessPoint + _ = json.Unmarshal(rm, &a) + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", a.ID, a.Name, a.Tower, a.IPAddress) + } + return tw.Flush() + }) +} + +func cmdAPGet(c *Client, args []string) error { + jsonMode, id, err := parseGetFlags("ap get", args) + if err != nil { + return err + } + var a AccessPoint + var raw []byte + if err := c.get(apiPrefix+"/access_points/"+pathID(id), &a, &raw); err != nil { + return err + } + if jsonMode { + return printJSON(raw) + } + tw := newTab() + fmt.Fprintln(tw, "ID\tNAME\tTOWER\tIP_ADDRESS") + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", a.ID, a.Name, a.Tower, a.IPAddress) + return tw.Flush() +} + +func cmdAPConfig(args []string) error { + if len(args) == 0 || args[0] != "get" { + return fmt.Errorf("ap-config: expected 'get '") + } + c, err := NewClient() + if err != nil { + return err + } + jsonMode, id, err := parseGetFlags("ap-config get", args[1:]) + if err != nil { + return err + } + var cfg AccessPointConfig + var raw []byte + if err := c.get(apiPrefix+"/access_points/"+pathID(id)+"/config", &cfg, &raw); err != nil { + return err + } + if jsonMode { + return printJSON(raw) + } + // Config is heterogeneous; pretty-print key fields and dump JSON. + tw := newTab() + if cfg.ShaperRate != nil { + fmt.Fprintf(tw, "shaper.up_speed\t%s\n", fmtSpeed(cfg.ShaperRate.UpSpeed)) + fmt.Fprintf(tw, "shaper.down_speed\t%s\n", fmtSpeed(cfg.ShaperRate.DownSpeed)) + fmt.Fprintf(tw, "shaper.activate\t%v\n", cfg.ShaperRate.Activate) + } + if cfg.SNMPCommunity != "" { + fmt.Fprintf(tw, "snmp_community\t%s\n", cfg.SNMPCommunity) + } + if cfg.SNMP3 != nil { + fmt.Fprintf(tw, "snmp3.security_name\t%s\n", cfg.SNMP3.SecurityName) + fmt.Fprintf(tw, "snmp3.auth_protocol\t%s\n", cfg.SNMP3.AuthProtocol) + fmt.Fprintf(tw, "snmp3.priv_protocol\t%s\n", cfg.SNMP3.PrivProtocol) + } + return tw.Flush() +} + +// Score endpoints don't paginate (per spec, single response body). +func cmdAPScores(args []string) error { + fs := flag.NewFlagSet("ap-scores", flag.ExitOnError) + jsonMode := fs.Bool("json", false, "emit raw API JSON") + if err := fs.Parse(args); err != nil { + return err + } + c, err := NewClient() + if err != nil { + return err + } + var raw []byte + if err := c.get(apiPrefix+"/access_point_scores", nil, &raw); err != nil { + return err + } + if *jsonMode { + return printJSON(raw) + } + // Spec is ambiguous; try array, fall back to single object. + var arr []AccessPointScore + if err := json.Unmarshal(raw, &arr); err != nil { + var one AccessPointScore + if err2 := json.Unmarshal(raw, &one); err2 == nil { + arr = []AccessPointScore{one} + } else { + return fmt.Errorf("decode ap-scores: %w (body: %s)", err, truncate(string(raw), 200)) + } + } + tw := newTab() + fmt.Fprintln(tw, "NAME\tSTATUS\tSITE\tRF\tHEALTH\tCONN\tLATENCY_MS") + for _, s := range arr { + fmt.Fprintf(tw, "%s\t%s\t%s\t%.1f\t%.1f\t%d\t%.1f\n", + s.Name, s.Status, s.SiteName, + s.RFScore, s.APHealthScore, int(s.Connections), s.Latency) + } + return tw.Flush() +} diff --git a/preseem/cmd_clear.go b/preseem/cmd_clear.go new file mode 100644 index 0000000..1d172dc --- /dev/null +++ b/preseem/cmd_clear.go @@ -0,0 +1,107 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "log" + "strings" +) + +func cmdClearServices(args []string) error { + fs := flag.NewFlagSet("clear-services", flag.ExitOnError) + dryRun := fs.Bool("dry-run", false, "list what would be deleted without deleting") + confirm := fs.Bool("confirm", false, "actually delete (required)") + accountFilter := fs.String("account", "", "only clear services for this account ID") + if err := fs.Parse(args); err != nil { + return err + } + + if !*dryRun && !*confirm { + return fmt.Errorf("clear-services requires --confirm to actually delete, or --dry-run to preview") + } + + c, err := NewClient() + if err != nil { + return err + } + + // Fetch all services. + items, _, err := c.listAll("/services", 1000, decodeListPage) + if err != nil { + return fmt.Errorf("list services: %w", err) + } + + // Filter by account if requested. + var toDelete []Service + for _, rm := range items { + var s Service + if err := json.Unmarshal(rm, &s); err != nil { + log.Printf("skip unparseable service: %v", err) + continue + } + if *accountFilter != "" && s.Account != *accountFilter { + continue + } + toDelete = append(toDelete, s) + } + + if len(toDelete) == 0 { + fmt.Println("no services to delete") + return nil + } + + fmt.Printf("%d services to delete", len(toDelete)) + if *accountFilter != "" { + fmt.Printf(" for account %q", *accountFilter) + } + fmt.Println() + + if *dryRun { + for _, s := range toDelete { + macs := attachmentMACs(s) + fmt.Printf(" %s account=%s macs=%s up=%d down=%d\n", + s.ID, s.Account, strings.Join(macs, ","), s.UpSpeed, s.DownSpeed) + } + fmt.Println("--dry-run: no services were deleted") + return nil + } + + // Actually delete. + var deleted, failed int + for _, s := range toDelete { + path := apiPrefix + "/services/" + pathID(s.ID) + if err := c.del(path); err != nil { + log.Printf("FAIL %s: %v", s.ID, err) + failed++ + } else { + macs := attachmentMACs(s) + fmt.Printf("deleted %s account=%s macs=%s\n", s.ID, s.Account, strings.Join(macs, ",")) + deleted++ + } + } + + fmt.Printf("\n%d deleted, %d failed\n", deleted, failed) + if failed > 0 { + return fmt.Errorf("%d deletions failed", failed) + } + return nil +} + +func attachmentMACs(s Service) []string { + var macs []string + for _, a := range s.Attachments { + if a.CPEMac != "" { + macs = append(macs, a.CPEMac) + } else if a.CPESerial != "" { + macs = append(macs, "sn:"+a.CPESerial) + } else if a.CPEIMSI != "" { + macs = append(macs, "imsi:"+a.CPEIMSI) + } else if a.Username != "" { + macs = append(macs, "u:"+a.Username) + } else if len(a.NetworkPrefixes) > 0 { + macs = append(macs, "pfx:"+a.NetworkPrefixes[0]) + } + } + return macs +} diff --git a/preseem/cmd_common.go b/preseem/cmd_common.go new file mode 100644 index 0000000..582a982 --- /dev/null +++ b/preseem/cmd_common.go @@ -0,0 +1,84 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" +) + +type listOpts struct { + jsonMode bool + page int + limit int +} + +func parseListFlags(name string, args []string) (*listOpts, []string, error) { + fs := flag.NewFlagSet(name, flag.ExitOnError) + o := &listOpts{} + fs.BoolVar(&o.jsonMode, "json", false, "emit raw API JSON") + fs.IntVar(&o.page, "page", 0, "fetch a single page (no auto-pagination)") + fs.IntVar(&o.limit, "limit", 0, "items per page (100-1000)") + if err := fs.Parse(args); err != nil { + return nil, nil, err + } + if err := validateLimit(o.limit); err != nil { + return nil, nil, err + } + return o, fs.Args(), nil +} + +// fetchList fetches one or all pages of a list endpoint. +// On single-page (`--page` set), singleRaw holds the unmodified body +// so --json can echo it byte-for-byte. +func (c *Client) fetchList(base string, o *listOpts) (items []json.RawMessage, p Paginator, singleRaw []byte, err error) { + if o.page > 0 { + err = c.listPaged(base, o.page, o.limit, nil, &singleRaw) + if err != nil { + return + } + items, p, err = decodeListPage(singleRaw) + return + } + items, p, err = c.listAll(base, o.limit, decodeListPage) + return +} + +func decodeListPage(raw []byte) ([]json.RawMessage, Paginator, error) { + var resp struct { + Data []json.RawMessage `json:"data"` + Paginator Paginator `json:"paginator"` + } + if err := json.Unmarshal(raw, &resp); err != nil { + return nil, Paginator{}, err + } + return resp.Data, resp.Paginator, nil +} + +// emitList prints either raw JSON (single-page or merged) or invokes +// the table renderer over decoded rows. +func emitList(items []json.RawMessage, p Paginator, singleRaw []byte, o *listOpts, table func([]json.RawMessage) error) error { + if o.jsonMode { + if singleRaw != nil { + return printJSON(singleRaw) + } + return printMergedJSON(items, p) + } + return table(items) +} + +// parseGetFlags parses `get` subcommand flags and requires a single ID arg. +func parseGetFlags(name string, args []string) (jsonMode bool, id string, err error) { + fs := flag.NewFlagSet(name, flag.ExitOnError) + fs.BoolVar(&jsonMode, "json", false, "emit raw API JSON") + if e := fs.Parse(args); e != nil { + err = e + return + } + rest := fs.Args() + if len(rest) != 1 { + err = fmt.Errorf("%s: expected exactly one ID argument", name) + return + } + id = rest[0] + return +} diff --git a/preseem/cmd_cpe.go b/preseem/cmd_cpe.go new file mode 100644 index 0000000..3515460 --- /dev/null +++ b/preseem/cmd_cpe.go @@ -0,0 +1,43 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" +) + +func cmdCPEScores(args []string) error { + fs := flag.NewFlagSet("cpe-scores", flag.ExitOnError) + jsonMode := fs.Bool("json", false, "emit raw API JSON") + if err := fs.Parse(args); err != nil { + return err + } + c, err := NewClient() + if err != nil { + return err + } + var raw []byte + if err := c.get(apiPrefix+"/cpe_radio_scores", nil, &raw); err != nil { + return err + } + if *jsonMode { + return printJSON(raw) + } + var arr []CPERadioScore + if err := json.Unmarshal(raw, &arr); err != nil { + var one CPERadioScore + if err2 := json.Unmarshal(raw, &one); err2 == nil { + arr = []CPERadioScore{one} + } else { + return fmt.Errorf("decode cpe-scores: %w (body: %s)", err, truncate(string(raw), 200)) + } + } + tw := newTab() + fmt.Fprintln(tw, "NAME\tSTATUS\tAP\tSITE\tRF\tBV\tFW") + for _, s := range arr { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%.1f\t%.1f\t%s\n", + s.Name, s.Status, s.APName, s.APSiteName, + s.RFScore, s.BusinessValue, s.Firmware) + } + return tw.Flush() +} diff --git a/preseem/cmd_olts.go b/preseem/cmd_olts.go new file mode 100644 index 0000000..6a5db77 --- /dev/null +++ b/preseem/cmd_olts.go @@ -0,0 +1,64 @@ +package main + +import ( + "encoding/json" + "fmt" +) + +func cmdOLTs(args []string) error { + if len(args) == 0 { + return fmt.Errorf("olts: expected 'list' or 'get'") + } + c, err := NewClient() + if err != nil { + return err + } + switch args[0] { + case "list", "ls": + return cmdOLTsList(c, args[1:]) + case "get": + return cmdOLTsGet(c, args[1:]) + default: + return fmt.Errorf("olts: unknown verb %q (want: list, get)", args[0]) + } +} + +func cmdOLTsList(c *Client, args []string) error { + o, _, err := parseListFlags("olts list", args) + if err != nil { + return err + } + items, p, raw, err := c.fetchList("/olts", o) + if err != nil { + return err + } + return emitList(items, p, raw, o, func(items []json.RawMessage) error { + tw := newTab() + fmt.Fprintln(tw, "ID\tNAME\tSITE\tHOST") + for _, rm := range items { + var x OLT + _ = json.Unmarshal(rm, &x) + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", x.ID, x.Name, x.Site, x.Host) + } + return tw.Flush() + }) +} + +func cmdOLTsGet(c *Client, args []string) error { + jsonMode, id, err := parseGetFlags("olts get", args) + if err != nil { + return err + } + var x OLT + var raw []byte + if err := c.get(apiPrefix+"/olts/"+pathID(id), &x, &raw); err != nil { + return err + } + if jsonMode { + return printJSON(raw) + } + tw := newTab() + fmt.Fprintln(tw, "ID\tNAME\tSITE\tHOST") + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", x.ID, x.Name, x.Site, x.Host) + return tw.Flush() +} diff --git a/preseem/cmd_packages.go b/preseem/cmd_packages.go new file mode 100644 index 0000000..93c1eab --- /dev/null +++ b/preseem/cmd_packages.go @@ -0,0 +1,64 @@ +package main + +import ( + "encoding/json" + "fmt" +) + +func cmdPackages(args []string) error { + if len(args) == 0 { + return fmt.Errorf("packages: expected 'list' or 'get'") + } + c, err := NewClient() + if err != nil { + return err + } + switch args[0] { + case "list", "ls": + return cmdPackagesList(c, args[1:]) + case "get": + return cmdPackagesGet(c, args[1:]) + default: + return fmt.Errorf("packages: unknown verb %q (want: list, get)", args[0]) + } +} + +func cmdPackagesList(c *Client, args []string) error { + o, _, err := parseListFlags("packages list", args) + if err != nil { + return err + } + items, p, raw, err := c.fetchList("/packages", o) + if err != nil { + return err + } + return emitList(items, p, raw, o, func(items []json.RawMessage) error { + tw := newTab() + fmt.Fprintln(tw, "ID\tNAME\tUP_KBPS\tDOWN_KBPS") + for _, rm := range items { + var pk Package + _ = json.Unmarshal(rm, &pk) + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", pk.ID, pk.Name, fmtSpeed(pk.UpSpeed), fmtSpeed(pk.DownSpeed)) + } + return tw.Flush() + }) +} + +func cmdPackagesGet(c *Client, args []string) error { + jsonMode, id, err := parseGetFlags("packages get", args) + if err != nil { + return err + } + var pk Package + var raw []byte + if err := c.get(apiPrefix+"/packages/"+pathID(id), &pk, &raw); err != nil { + return err + } + if jsonMode { + return printJSON(raw) + } + tw := newTab() + fmt.Fprintln(tw, "ID\tNAME\tUP_KBPS\tDOWN_KBPS") + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", pk.ID, pk.Name, fmtSpeed(pk.UpSpeed), fmtSpeed(pk.DownSpeed)) + return tw.Flush() +} diff --git a/preseem/cmd_routers.go b/preseem/cmd_routers.go new file mode 100644 index 0000000..2b23aa1 --- /dev/null +++ b/preseem/cmd_routers.go @@ -0,0 +1,64 @@ +package main + +import ( + "encoding/json" + "fmt" +) + +func cmdRouters(args []string) error { + if len(args) == 0 { + return fmt.Errorf("routers: expected 'list' or 'get'") + } + c, err := NewClient() + if err != nil { + return err + } + switch args[0] { + case "list", "ls": + return cmdRoutersList(c, args[1:]) + case "get": + return cmdRoutersGet(c, args[1:]) + default: + return fmt.Errorf("routers: unknown verb %q (want: list, get)", args[0]) + } +} + +func cmdRoutersList(c *Client, args []string) error { + o, _, err := parseListFlags("routers list", args) + if err != nil { + return err + } + items, p, raw, err := c.fetchList("/routers", o) + if err != nil { + return err + } + return emitList(items, p, raw, o, func(items []json.RawMessage) error { + tw := newTab() + fmt.Fprintln(tw, "ID\tNAME\tSITE\tHOST") + for _, rm := range items { + var r Router + _ = json.Unmarshal(rm, &r) + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", r.ID, r.Name, r.Site, r.Host) + } + return tw.Flush() + }) +} + +func cmdRoutersGet(c *Client, args []string) error { + jsonMode, id, err := parseGetFlags("routers get", args) + if err != nil { + return err + } + var r Router + var raw []byte + if err := c.get(apiPrefix+"/routers/"+pathID(id), &r, &raw); err != nil { + return err + } + if jsonMode { + return printJSON(raw) + } + tw := newTab() + fmt.Fprintln(tw, "ID\tNAME\tSITE\tHOST") + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", r.ID, r.Name, r.Site, r.Host) + return tw.Flush() +} diff --git a/preseem/cmd_services.go b/preseem/cmd_services.go new file mode 100644 index 0000000..1f14794 --- /dev/null +++ b/preseem/cmd_services.go @@ -0,0 +1,93 @@ +package main + +import ( + "encoding/json" + "fmt" + "strings" +) + +func cmdServices(args []string) error { + if len(args) == 0 { + return fmt.Errorf("services: expected 'list' or 'get'") + } + c, err := NewClient() + if err != nil { + return err + } + switch args[0] { + case "list", "ls": + return cmdServicesList(c, args[1:]) + case "get": + return cmdServicesGet(c, args[1:]) + default: + return fmt.Errorf("services: unknown verb %q (want: list, get)", args[0]) + } +} + +func attachmentSummary(s Service) string { + if len(s.Attachments) == 0 { + return "-" + } + parts := make([]string, 0, len(s.Attachments)) + for _, a := range s.Attachments { + switch { + case a.CPEMac != "": + parts = append(parts, a.CPEMac) + case a.CPESerial != "": + parts = append(parts, "sn:"+a.CPESerial) + case a.CPEIMSI != "": + parts = append(parts, "imsi:"+a.CPEIMSI) + case a.Username != "": + parts = append(parts, "u:"+a.Username) + case len(a.NetworkPrefixes) > 0: + parts = append(parts, a.NetworkPrefixes[0]) + } + } + return strings.Join(parts, ",") +} + +func cmdServicesList(c *Client, args []string) error { + o, _, err := parseListFlags("services list", args) + if err != nil { + return err + } + items, p, raw, err := c.fetchList("/services", o) + if err != nil { + return err + } + return emitList(items, p, raw, o, func(items []json.RawMessage) error { + tw := newTab() + fmt.Fprintln(tw, "ID\tACCOUNT\tPACKAGE\tUP_KBPS\tDOWN_KBPS\tPARENT\tATTACHMENTS") + for _, rm := range items { + var s Service + _ = json.Unmarshal(rm, &s) + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + s.ID, s.Account, s.Package, + fmtSpeed(s.UpSpeed), fmtSpeed(s.DownSpeed), + s.ParentDeviceID, attachmentSummary(s)) + } + return tw.Flush() + }) +} + +func cmdServicesGet(c *Client, args []string) error { + jsonMode, id, err := parseGetFlags("services get", args) + if err != nil { + return err + } + var s Service + var raw []byte + if err := c.get(apiPrefix+"/services/"+pathID(id), &s, &raw); err != nil { + return err + } + if jsonMode { + return printJSON(raw) + } + tw := newTab() + fmt.Fprintln(tw, "ID\tACCOUNT\tPACKAGE\tUP_KBPS\tDOWN_KBPS\tPARENT\tATTACHMENTS") + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + s.ID, s.Account, s.Package, + fmtSpeed(s.UpSpeed), fmtSpeed(s.DownSpeed), + s.ParentDeviceID, attachmentSummary(s)) + return tw.Flush() +} diff --git a/preseem/cmd_sites.go b/preseem/cmd_sites.go new file mode 100644 index 0000000..8779ea1 --- /dev/null +++ b/preseem/cmd_sites.go @@ -0,0 +1,72 @@ +package main + +import ( + "encoding/json" + "fmt" + "strings" +) + +func cmdSites(args []string) error { + if len(args) == 0 { + return fmt.Errorf("sites: expected 'list' or 'get'") + } + c, err := NewClient() + if err != nil { + return err + } + switch args[0] { + case "list", "ls": + return cmdSitesList(c, args[1:]) + case "get": + return cmdSitesGet(c, args[1:]) + default: + return fmt.Errorf("sites: unknown verb %q (want: list, get)", args[0]) + } +} + +func sitePrefixes(s Site) string { + if s.Attachment == nil || len(s.Attachment.NetworkPrefixes) == 0 { + return "-" + } + return strings.Join(s.Attachment.NetworkPrefixes, ",") +} + +func cmdSitesList(c *Client, args []string) error { + o, _, err := parseListFlags("sites list", args) + if err != nil { + return err + } + items, p, raw, err := c.fetchList("/sites", o) + if err != nil { + return err + } + return emitList(items, p, raw, o, func(items []json.RawMessage) error { + tw := newTab() + fmt.Fprintln(tw, "ID\tNAME\tNETWORK_PREFIXES") + for _, rm := range items { + var s Site + _ = json.Unmarshal(rm, &s) + fmt.Fprintf(tw, "%s\t%s\t%s\n", s.ID, s.Name, sitePrefixes(s)) + } + return tw.Flush() + }) +} + +func cmdSitesGet(c *Client, args []string) error { + jsonMode, id, err := parseGetFlags("sites get", args) + if err != nil { + return err + } + var s Site + var raw []byte + if err := c.get(apiPrefix+"/sites/"+pathID(id), &s, &raw); err != nil { + return err + } + if jsonMode { + return printJSON(raw) + } + tw := newTab() + fmt.Fprintln(tw, "ID\tNAME\tNETWORK_PREFIXES") + fmt.Fprintf(tw, "%s\t%s\t%s\n", s.ID, s.Name, sitePrefixes(s)) + return tw.Flush() +} diff --git a/preseem/go.mod b/preseem/go.mod new file mode 100644 index 0000000..0fe8f24 --- /dev/null +++ b/preseem/go.mod @@ -0,0 +1,3 @@ +module github.com/graham/network/preseem + +go 1.25.0 diff --git a/preseem/main.go b/preseem/main.go new file mode 100644 index 0000000..aba496c --- /dev/null +++ b/preseem/main.go @@ -0,0 +1,85 @@ +package main + +import ( + "fmt" + "os" +) + +func usage() { + fmt.Fprint(os.Stderr, `preseem - CLI for the Preseem Model API at $PRESEEM_URL (default `+defaultBaseURL+`) + +Auth: export PRESEEM_KEY= (HTTP Basic, key as username, empty password) + +Resources (read-only): + accounts list | get + ap list | get (access points) + ap-config get + ap-scores + cpe-scores + packages list | get + services list | get + routers list | get + olts list | get + sites list | get + +Write operations: + clear-services [--dry-run | --confirm] [--account ] + delete all services (MACs) from all accounts + --dry-run: preview only; --confirm: actually delete + --account: limit to a single account ID + +Common flags: + list: [--json] [--page N] [--limit 100..1000] + (no --page = auto-paginate; --json + auto = merged data envelope) + get: [--json] + +Examples: + preseem accounts list + preseem ap list --json | jq '.data[].name' + preseem ap get 123 + preseem ap-scores + preseem sites list --page 1 --limit 100 +`) +} + +func main() { + if len(os.Args) < 2 { + usage() + os.Exit(2) + } + var err error + switch os.Args[1] { + case "accounts": + err = cmdAccounts(os.Args[2:]) + case "ap": + err = cmdAP(os.Args[2:]) + case "ap-config": + err = cmdAPConfig(os.Args[2:]) + case "ap-scores": + err = cmdAPScores(os.Args[2:]) + case "cpe-scores": + err = cmdCPEScores(os.Args[2:]) + case "packages": + err = cmdPackages(os.Args[2:]) + case "services": + err = cmdServices(os.Args[2:]) + case "routers": + err = cmdRouters(os.Args[2:]) + case "olts": + err = cmdOLTs(os.Args[2:]) + case "sites": + err = cmdSites(os.Args[2:]) + case "clear-services": + err = cmdClearServices(os.Args[2:]) + case "-h", "--help", "help": + usage() + return + default: + usage() + os.Exit(2) + } + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } +} diff --git a/preseem/output.go b/preseem/output.go new file mode 100644 index 0000000..0123b69 --- /dev/null +++ b/preseem/output.go @@ -0,0 +1,60 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "text/tabwriter" +) + +// newTab returns a tabwriter on stdout configured to match uisp/main.go. +func newTab() *tabwriter.Writer { + return tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) +} + +// printJSON writes raw bytes (already JSON) verbatim followed by a +// newline. Used by --json mode to preserve the API's exact response. +func printJSON(raw []byte) error { + _, err := os.Stdout.Write(raw) + if err != nil { + return err + } + if len(raw) == 0 || raw[len(raw)-1] != '\n' { + _, err = io.WriteString(os.Stdout, "\n") + } + return err +} + +// printMergedJSON emits a synthetic paginated envelope around items +// gathered across multiple pages by listAll. The shape mirrors the +// per-page response shape so downstream `jq` scripts keep working. +func printMergedJSON(items []json.RawMessage, p Paginator) error { + out := struct { + Data []json.RawMessage `json:"data"` + Paginator Paginator `json:"paginator"` + }{Data: items, Paginator: p} + b, err := json.Marshal(out) + if err != nil { + return err + } + return printJSON(b) +} + +// printSingleJSON marshals an arbitrary value as JSON. Used by `get` +// subcommands when --json is set. +func printSingleJSON(v any) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + return printJSON(b) +} + +// fmtSpeed renders kbps as "-" if 0, else the integer. +func fmtSpeed(kbps int64) string { + if kbps == 0 { + return "-" + } + return fmt.Sprintf("%d", kbps) +} diff --git a/preseem/preseem b/preseem/preseem new file mode 100755 index 0000000..dab49a6 Binary files /dev/null and b/preseem/preseem differ diff --git a/preseem/preseem.yaml b/preseem/preseem.yaml new file mode 100644 index 0000000..ab645f9 --- /dev/null +++ b/preseem/preseem.yaml @@ -0,0 +1,1716 @@ +openapi: 3.0.0 +info: + description: | + # Overview + + **HTTP Methods** + + This API supports the following HTTP methods: `DELETE`, `GET`, `PUT` + The documentation below provide details on when and how to use these methods. + + **Content-Type** + + For `PUT` requests, you must include `Content-Type` in the HTTP Header and it should be set to `application/json`, otherwise a 400 (`Bad Request`) is returned. + The API only supports `JSON` + + **API Key Permissions** + + The permissions required for this API are: + - `network-metadata-read` + - `network-metadata-write` + + `network-metadata-read` is needed for `GET` HTTP request + + `network-metadata-write` is needed for `DELETE` and `PUT` HTTP requests + + **Encoding** + + URI parameters must be URL encoded. + + For example to get an account with the id of `john/doe`, `/model/v1/accounts/john/doe` will fail and return a `404` error. + The correct way will be `/model/v1/accounts/john%2Fdoe`. Notice `/` in the id is replaced with the encoded form `%2F` + + **Note** + + All IDs are case-sensitive, `John Doe` and `John doe` could be the IDs of two different accounts + + + # Authentication + + All requests are secured using basic auth. Your API key is the username, the password is empty. + + # Examples + + The sample requests below will use the following: + + - **API key** 12ce9ffa5104885bedb99a75baff2c21e6d21a25ad351f0eff65240784818d19 + + *Replace API Key* + + The API key and the colon must be base64 encoded. + + A sample HTTP Header for the API key above (*after base64 encoding*) is `Authorization: Basic MTJjZTlmZmE1MTA0ODg1YmVkYjk5YTc1YmFmZjJjMjFlNmQyMWEyNWFkMzUxZjBlZmY2NTI0MDc4NDgxOGQxOTo=` + + ## Get all access points + + ``` + bash$ curl -u 12ce9ffa5104885bedb99a75baff2c21e6d21a25ad351f0eff65240784818d19: https://api.preseem.com/model/v1/access_points + + {"data":[{"id": "100","name": "AP-1","tower":"Mt. Tower","ip_address":"192.168.0.1"}],"paginator":{"page":1,"page_count":1,"limit":100,"total_count":1}} + ``` + + version: 1.0.0 + title: Preseem Model API Documentation +servers: + - url: https://api.preseem.com +tags: +- name: Access Points + description: This represents an Access Point in the system, or any representation + of the two-layer topology +- name: Access Point Configs + description: This represents the config of an Access Point in the system, or any representation + of the two-layer topology. Note, the parent Access Point must exist before you can perform any operation on its config.

+ SNMP configuration per AP is optional; if the SNMP credentials are not set in this API, the configuration for the network poller will be used. +- name: CPE Radios + description: This represents a CPE Radio in the system +- name: Accounts + description: This represents a customer/subscriber/account who pays for Internet + access. +- name: Packages + description: This represents a plan/tariff/service definition in the system. +- name: Services + description: A service describes an attachment to the network that receives Internet service and is owned by an Account. +- name: Routers + description: This represents a router in the system, which facilitates the routing of network traffic between different network segments or devices. +- name: OLTs + description: This represents an OLT (optical line terminal) in the system. +- name: Sites + description: A site represents a network site (such as a PoP or Tower or Headend) that contains network equipment. +paths: + /model/v1/access_points: + get: + tags: + - Access Points + parameters: + - in: query + name: page + schema: + type: integer + description: Page number. Default is 1. If `page` is greater than the number of pages available, `data` field in the response will be an empty array + - in: query + name: limit + schema: + type: integer + description: Specifies the maximum number of access points to be returned per page. Default is 500, *mininum* is 100, *maximum* is 1000 + summary: List Access Points + description: Returns a list of all Access Points. + operationId: getAccessPoints + responses: + '200': + description: Access points returned + content: + application/json: + schema: + $ref: '#/components/schemas/AccessPointList' + '401': + description: API key does not have the required permission + '500': + description: Server cannot process request + /model/v1/access_point_scores: + get: + tags: + - Access Points + summary: List Access Point Scores + description: Returns a list of all Access Points along with their scores and severities. See the Access Point table help in the Preseem App for more details. + operationId: getAccessPointScores + responses: + '200': + description: Access point scores returned + content: + application/json: + schema: + $ref: '#/components/schemas/AccessPointScoreInfo' + '401': + description: API key does not have the required permission + '500': + description: Server cannot process request + /model/v1/access_points/{id}: + put: + tags: + - Access Points + summary: Create Access Point + description: This creates an Access Point. + operationId: createAccessPoint + requestBody: + required: true + content: + application/json: + schema: + "$ref": "#/components/schemas/AccessPoint" + parameters: + - name: id + in: path + description: Unique ID of Access point to be added + required: true + schema: + type: string + responses: + '200': + description: Access point added + '400': + description: Required json field missing in the PUT request ***OR*** + ID in the URI does not match the id in json ***OR*** + Bad json + '401': + description: API key does not have the required permission + '500': + description: Server cannot process request + delete: + tags: + - Access Points + summary: Delete Access Point + description: This deletes an Access Point. Note that the Access Point config will also be deleted. + operationId: deleteAccessPoint + parameters: + - name: id + in: path + description: Unique ID of Access point to be deleted + required: true + schema: + type: string + responses: + '200': + description: Access point deleted + '400': + description: Missing ID in URI + '401': + description: API key does not have the required permission + '404': + description: Access Point does not exist + '500': + description: Server cannot process request + get: + tags: + - Access Points + summary: Get Access Point + description: This gets an Access Point. + operationId: getAccessPoint + parameters: + - name: id + in: path + description: Unique ID of Access point + required: true + schema: + type: string + responses: + '200': + description: Access Point returned + content: + application/json: + schema: + $ref: '#/components/schemas/AccessPoint' + '400': + description: Missing ID in the URI + '401': + description: API key does not have the required permission + '404': + description: Access Point not found + '500': + description: Server cannot process request + /model/v1/access_points/{id}/config: + put: + tags: + - Access Point Configs + summary: Create Access Point config + description: This creates an Access Point config. + operationId: createAccessPointConfig + requestBody: + required: true + content: + application/json: + schema: + "$ref": "#/components/schemas/AccessPointConfig" + parameters: + - name: id + in: path + description: Unique ID of the Access point + required: true + schema: + type: string + responses: + '200': + description: Access point config added + '400': + description: Required json field missing in the PUT request ***OR*** + ID in the URI does not match the id in json ***OR*** + Bad json + '401': + description: API key does not have the required permission + '404': + description: Access Point does not exist + '500': + description: Server cannot process request + delete: + tags: + - Access Point Configs + summary: Delete Access Point config + description: This deletes an Access Point config. + operationId: deleteAccessPointConfig + parameters: + - name: id + in: path + description: Unique ID of Access point to be deleted + required: true + schema: + type: string + responses: + '200': + description: Access point config deleted + '400': + description: Missing ID in URI ***OR*** Access Point does not exist + '401': + description: API key does not have the required permission + '404': + description: Access Point config does not exist + '500': + description: Server cannot process request + get: + tags: + - Access Point Configs + summary: Get Access Point config + description: This gets an Access Point config. + operationId: getAccessPointConfig + parameters: + - name: id + in: path + description: Unique ID of Access point + required: true + schema: + type: string + responses: + '200': + description: Access Point config returned + content: + application/json: + schema: + $ref: '#/components/schemas/AccessPointConfig' + '400': + description: Missing ID in the URI ***OR*** Access Point does not exist + '401': + description: API key does not have the required permission + '404': + description: Access Point config does not exist + '500': + description: Server cannot process request + /model/v1/cpe_radio_scores: + get: + tags: + - CPE Radios + summary: List CPE Radio Scores + description: Returns a list of all CPE Radios along with their scores and severities. See the CPE Radios table help in the Preseem App for more details. + operationId: getCPERadioScores + responses: + '200': + description: CPE Radio scores returned + content: + application/json: + schema: + $ref: '#/components/schemas/CPERadioScoreInfo' + '401': + description: API key does not have the required permission + '500': + description: Server cannot process request + /model/v1/accounts: + get: + tags: + - Accounts + parameters: + - in: query + name: page + schema: + type: integer + description: Page number. Default is 1. If `page` is greater than the number of pages available, `data` field in the response will be an empty array + - in: query + name: limit + schema: + type: integer + description: Specifies the maximum number of results to be returned per page. Default is 500, *mininum* is 100, *maximum* is 1000 + summary: List Accounts + description: Returns a list of all Accounts. + operationId: getAccounts + responses: + '200': + description: Accounts returned + content: + application/json: + schema: + $ref: '#/components/schemas/AccountList' + '401': + description: API key does not have the required permission + '500': + description: Server cannot process request + /model/v1/accounts/{id}: + put: + tags: + - Accounts + summary: Create Account + description: This creates an Account. + operationId: createAccount + requestBody: + required: true + content: + application/json: + schema: + "$ref": "#/components/schemas/Account" + parameters: + - name: id + in: path + description: Unique ID of Account to be added + required: true + schema: + type: string + responses: + '200': + description: Account added + '400': + description: Required json field missing in the PUT request ***OR*** + ID in the URI does not match the id in json ***OR*** + Bad json + '401': + description: API key does not have the required permission + '500': + description: Server cannot process request + delete: + tags: + - Accounts + summary: Delete Account + description: This deletes an Account. + operationId: deleteAccount + parameters: + - name: id + in: path + description: Unique ID of Account to be deleted + required: true + schema: + type: string + responses: + '200': + description: Account deleted + '400': + description: Missing ID in URI + '401': + description: API key does not have the required permission + '404': + description: Account does not exist + '500': + description: Server cannot process request + get: + tags: + - Accounts + summary: Get Account + description: This gets an Account. + operationId: getAccount + parameters: + - name: id + in: path + description: Unique ID of Account to be added + required: true + schema: + type: string + responses: + '200': + description: Account returned + content: + application/json: + schema: + $ref: "#/components/schemas/Account" + '400': + description: Missing ID in the URI + '401': + description: API key does not have the required permission + '404': + description: Account not found + '500': + description: Server cannot process request + /model/v1/packages: + get: + tags: + - Packages + parameters: + - in: query + name: page + schema: + type: integer + description: Page number. Default is 1. If `page` is greater than the number of pages available, `data` field in the response will be an empty array + - in: query + name: limit + schema: + type: integer + description: Specifies the maximum number of results to be returned per page. Default is 500, *mininum* is 100, *maximum* is 1000 + summary: List Packages + description: Returns a list of all Packages. + operationId: getPackages + responses: + '200': + description: Packages returned + content: + application/json: + schema: + $ref: '#/components/schemas/PackageList' + '401': + description: API key does not have the required permission + '500': + description: Server cannot process request + /model/v1/packages/{id}: + put: + tags: + - Packages + summary: Create Package + description: This creates an Account. + operationId: createPackage + requestBody: + required: true + content: + application/json: + schema: + "$ref": "#/components/schemas/Package" + parameters: + - name: id + in: path + description: Unique ID of Package to be added + required: true + schema: + type: string + responses: + '200': + description: Package added + '400': + description: | + Required json field missing in the PUT request ***OR*** + ID in the URI does not match the id in json ***OR*** + Bad json + '401': + description: API key does not have the required permission + '500': + description: Server cannot process request + delete: + tags: + - Packages + summary: Delete Package + description: This deletes a Package. + operationId: deletePackage + parameters: + - name: id + in: path + description: Unique ID of Package to be deleted + required: true + schema: + type: string + responses: + '200': + description: Package deleted + '400': + description: Missing ID in URI + '401': + description: API key does not have the required permission + '404': + description: Package does not exist + '500': + description: Server cannot process request + get: + tags: + - Packages + summary: Get Package + description: This gets a Package. + operationId: getPackage + parameters: + - name: id + in: path + description: Unique ID of Package to be added + required: true + schema: + type: string + responses: + '200': + description: Package returned + content: + application/json: + schema: + $ref: "#/components/schemas/Package" + '400': + description: Missing ID in the URI + '401': + description: API key does not have the required permission + '404': + description: Package not found + '500': + description: Server cannot process request + /model/v1/services: + get: + tags: + - Services + parameters: + - in: query + name: page + schema: + type: integer + description: Page number. Default is 1. If `page` is greater than the number of pages available, `data` field in the response will be an empty array + - in: query + name: limit + schema: + type: integer + description: Specifies the maximum number of results to be returned per page. Default is 500, *mininum* is 100, *maximum* is 1000 + summary: List Services + description: Returns a list of all Services. + operationId: getServices + responses: + '200': + description: Services returned + content: + application/json: + schema: + $ref: '#/components/schemas/ServiceList' + '401': + description: API key does not have the required permission + '500': + description: Server cannot process request + /model/v1/services/{id}: + put: + tags: + - Services + summary: Create Service + description: This creates a Service + operationId: createService + requestBody: + required: true + content: + application/json: + schema: + "$ref": "#/components/schemas/Service" + examples: + MultipleAttachments: + summary: Multiple Attachments Example + value: + id: '23' + attachments: + - cpe_mac: '00:10:0b:6e:4c:45' + network_prefixes: ['10.10.10.0/24', '192.168.100.100', '2001:cafe:456:dfe1:63:0:0:fefb', '2001:f0:3238:dfe1::/64'] + - cpe_mac: '00:17:2c:6e:9b:15' + - network_prefixes: ['12.12.12.12'] + up_speed: 25000 + down_speed: 5000 + account: "100" + package: "10" + parent_device_id: "AP1" + SingleAttachmentSerialNumber: + summary: Single Attachment - Serial Number Example + value: + id: '23' + attachments: + - cpe_serial: '20130420AB' + cpe_mac: '00:10:0b:6e:4c:48' + network_prefixes: ['10.10.10.1/24', '192.168.100.101', '2002:cafe:456:dfe1:63:0:0:fefb', '2002:f0:3238:dfe1::/64'] + up_speed: 25000 + down_speed: 5000 + account: "100" + package: "10" + parent_device_id: "AP1" + SingleAttachmentIMSI: + summary: Single Attachment - IMSI Example + value: + id: '23' + attachments: + - cpe_imsi: '313460000000001' + cpe_mac: '00:10:0b:6e:4c:48' + network_prefixes: ['10.10.10.1/24', '192.168.100.101', '2002:cafe:456:dfe1:63:0:0:fefb', '2002:f0:3238:dfe1::/64'] + up_speed: 25000 + down_speed: 5000 + account: "100" + package: "10" + parent_device_id: "AP1" + SingleAttachmentUsername: + summary: Single Attachment - Username Example + value: + id: '23' + attachments: + - username: 'userid@isp-name' + cpe_mac: '00:10:0b:6e:4c:48' + network_prefixes: ['10.10.10.1/24', '192.168.100.101', '2002:cafe:456:dfe1:63:0:0:fefb', '2002:f0:3238:dfe1::/64'] + up_speed: 25000 + down_speed: 5000 + account: "100" + package: "10" + parent_device_id: "AP1" + parameters: + - name: id + in: path + description: Unique ID of Service to be added + required: true + schema: + type: string + responses: + '200': + description: Service added + '400': + description: Required json field missing in the PUT request ***OR*** + ID in the URI does not match the id in json ***OR*** + Bad json ***OR*** multiple attachments for the service that may contain any of the following cpe_serial, cpe_imsi, and username. + '401': + description: API key does not have the required permission + '500': + description: Server cannot process request + delete: + tags: + - Services + summary: Delete Service + description: This deletes a Service. + operationId: deleteService + parameters: + - name: id + in: path + description: Unique ID of Service to be deleted + required: true + schema: + type: string + responses: + '200': + description: Service deleted + '400': + description: Missing ID in URI + '401': + description: API key does not have the required permission + '404': + description: Service does not exist + '500': + description: Server cannot process request + get: + tags: + - Services + summary: Get Service + description: This gets a Service. + operationId: getService + parameters: + - name: id + in: path + description: Unique ID of Service to be added + required: true + schema: + type: string + responses: + '200': + description: Service returned + content: + application/json: + schema: + $ref: "#/components/schemas/Service" + '400': + description: Missing ID in the URI + '401': + description: API key does not have the required permission + '404': + description: Service not found + '500': + description: Server cannot process request + /model/v1/routers: + get: + tags: + - Routers + parameters: + - in: query + name: page + schema: + type: integer + description: Page number. Default is 1. If `page` is greater than the number of pages available, `data` field in the response will be an empty array + - in: query + name: limit + schema: + type: integer + description: Specifies the maximum number of routers to be returned per page. Default is 500, *mininum* is 100, *maximum* is 1000 + summary: List Routers + description: Returns a list of all Routers. + operationId: getRouters + responses: + '200': + description: Routers returned + content: + application/json: + schema: + $ref: '#/components/schemas/RouterList' + '401': + description: API key does not have the required permission + '500': + description: Server cannot process request + /model/v1/routers/{id}: + put: + tags: + - Routers + summary: Create Router + description: This creates an Router. + operationId: createRouter + requestBody: + required: true + content: + application/json: + schema: + "$ref": "#/components/schemas/Router" + parameters: + - name: id + in: path + description: Unique ID of Router to be added + required: true + schema: + type: string + responses: + '200': + description: Router added + '400': + description: Required json field missing in the PUT request ***OR*** + ID in the URI does not match the id in json ***OR*** + Bad json + '401': + description: API key does not have the required permission + '500': + description: Server cannot process request + delete: + tags: + - Routers + summary: Delete Router + description: This deletes an Router. + operationId: deleteRouter + parameters: + - name: id + in: path + description: Unique ID of Router to be deleted + required: true + schema: + type: string + responses: + '200': + description: Router deleted + '400': + description: Missing ID in URI + '401': + description: API key does not have the required permission + '404': + description: Router does not exist + '500': + description: Server cannot process request + get: + tags: + - Routers + summary: Get Router + description: This gets an Router. + operationId: getRouter + parameters: + - name: id + in: path + description: Unique ID of Router + required: true + schema: + type: string + responses: + '200': + description: Router returned + content: + application/json: + schema: + $ref: '#/components/schemas/Router' + '400': + description: Missing ID in the URI + '401': + description: API key does not have the required permission + '404': + description: Router not found + '500': + description: Server cannot process request + /model/v1/olts: + get: + tags: + - OLTs + parameters: + - in: query + name: page + schema: + type: integer + description: Page number. Default is 1. If `page` is greater than the number of pages available, `data` field in the response will be an empty array + - in: query + name: limit + schema: + type: integer + description: Specifies the maximum number of OLTs to be returned per page. Default is 500, *mininum* is 100, *maximum* is 1000 + summary: List OLTs + description: Returns a list of all OLTs. + operationId: getOLTs + responses: + '200': + description: OLTs returned + content: + application/json: + schema: + $ref: '#/components/schemas/OLTList' + '401': + description: API key does not have the required permission + '500': + description: Server cannot process request + /model/v1/olts/{id}: + put: + tags: + - OLTs + summary: Create OLT + description: This creates an OLT. + operationId: createOLT + requestBody: + required: true + content: + application/json: + schema: + "$ref": "#/components/schemas/OLT" + parameters: + - name: id + in: path + description: Unique ID of OLT to be added + required: true + schema: + type: string + responses: + '200': + description: OLT added + '400': + description: Required json field missing in the PUT request ***OR*** + ID in the URI does not match the id in json ***OR*** + Bad json + '401': + description: API key does not have the required permission + '500': + description: Server cannot process request + delete: + tags: + - OLTs + summary: Delete OLT + description: This deletes an OLT. + operationId: deleteOLT + parameters: + - name: id + in: path + description: Unique ID of OLT to be deleted + required: true + schema: + type: string + responses: + '200': + description: OLT deleted + '400': + description: Missing ID in URI + '401': + description: API key does not have the required permission + '404': + description: OLT does not exist + '500': + description: Server cannot process request + get: + tags: + - OLTs + summary: Get OLT + description: This gets an OLT. + operationId: getOLT + parameters: + - name: id + in: path + description: Unique ID of OLT + required: true + schema: + type: string + responses: + '200': + description: OLT returned + content: + application/json: + schema: + $ref: '#/components/schemas/OLT' + '400': + description: Missing ID in the URI + '401': + description: API key does not have the required permission + '404': + description: OLT not found + '500': + description: Server cannot process request + /model/v1/sites: + get: + tags: + - Sites + parameters: + - in: query + name: page + schema: + type: integer + description: Page number. Default is 1. If `page` is greater than the number of pages available, `data` field in the response will be an empty array + - in: query + name: limit + schema: + type: integer + description: Specifies the maximum number of results to be returned per page. Default is 500, *mininum* is 100, *maximum* is 1000 + summary: List Sites + description: Returns a list of all Sites. + operationId: getSites + responses: + '200': + description: Sites returned + content: + application/json: + schema: + $ref: '#/components/schemas/SiteList' + '401': + description: API key does not have the required permission + '500': + description: Server cannot process request + /model/v1/sites/{id}: + put: + tags: + - Sites + summary: Create Site + description: This creates a Site + operationId: createSite + requestBody: + required: true + content: + application/json: + schema: + "$ref": "#/components/schemas/Site" + parameters: + - name: id + in: path + description: Unique ID of Site to be added + required: true + schema: + type: string + responses: + '200': + description: Site added + '400': + description: Required json field missing in the PUT request ***OR*** + ID in the URI does not match the id in json ***OR*** + Bad json + '401': + description: API key does not have the required permission + '500': + description: Server cannot process request + delete: + tags: + - Sites + summary: Delete Site + description: This deletes a Site. + operationId: deleteSite + parameters: + - name: id + in: path + description: Unique ID of Site to be deleted + required: true + schema: + type: string + responses: + '200': + description: Site deleted + '400': + description: Missing ID in URI + '401': + description: API key does not have the required permission + '404': + description: Site does not exist + '500': + description: Server cannot process request + get: + tags: + - Sites + summary: Get Site + description: This gets a Site. + operationId: getSite + parameters: + - name: id + in: path + description: Unique ID of Site to be added. Note that a site cannot share the same id with an access point object as they share the same namespace + required: true + schema: + type: string + responses: + '200': + description: Site returned + content: + application/json: + schema: + $ref: "#/components/schemas/Site" + '400': + description: Missing ID in the URI + '401': + description: API key does not have the required permission + '404': + description: Site not found + '500': + description: Server cannot process request +components: + securitySchemes: + APIKeyAuth: + type: http + scheme: basic + schemas: + AccessPoint: + type: object + required: + - id + - name + - tower + - ip_address + properties: + id: + type: string + description: Unique id for access point + example: '123' + name: + type: string + description: Access point name + example: AP-1 + tower: + type: string + description: Tower name + example: Mt. Tower + ip_address: + type: string + description: Management ip address for Access Point + example: 192.168.0.1 + AccessPointScoreInfo: + type: object + properties: + company_uuid: + type: string + description: The company's UUID + example: 'f80dfce4-0fee-4cf4-b830-88ed5cf921a7' + element_uuid: + type: string + description: The access point's UUID + example: '6fe2ac5a-be30-4363-7790-01fcdcef4784' + connections: + type: integer + description: The number of connection to the access point + example: 10 + latency: + type: integer + description: The access point's latency + example: 76 + throughput_tx: + type: integer + description: The access point's TX throughput + example: 3.7 + throughput_rx: + type: integer + description: The access point's RX throughput + example: 4.6 + frequency: + type: integer + description: The access point's frequency + example: 5000 + channel_width: + type: integer + description: The access point's channel width + example: 20 + firmware: + type: string + description: The access point's firmware + example: '4.6.2' + name: + type: string + description: The name of the access point + example: 'AP-1' + system_mac_address: + type: string + description: The system MAC address of the access point + example: 00-B0-D0-63-C2-26 + management_ip: + type: string + description: The management IP of the access point + example: 192.168.0.1 + status: + type: string + enum: [Invalid, Online, Warning, Offline, Unknown, Error] + description: The status of the access point + example: "Online" + manufacturer_uuid: + type: string + description: The manufacturer UUID of the access point + example: '1bbfa69b-f399-4d68-9572-5772074795f7' + manufacturer_name: + type: string + description: The manufacturer name of the access point + example: 'Manufacturer Name' + model_uuid: + type: string + description: The model UUID of the access point + example: 'e31dd2f3-f30b-46b1-8f9d-ef59b923070d' + model_name: + type: string + description: The model name of the access point + example: 'Model Name' + site_uuid: + type: string + description: The site UUID of the access point + example: '14004472-9617-4fa7-a2da-915633301d27' + site_name: + type: string + description: The site name of the access point + example: 'Site Name' + system_name: + type: string + description: The system name of the access point + example: 'System Name' + model_support_level: + type: string + enum: [Invalid, None, Topology, Test, Full, Basic] + description: The model support level of the access point + example: "Full" + element_id: + type: integer + description: The ID of the access point + example: 123 + model_auto_shape: + type: string + enum: [Invalid, Not Supported, Supported] + description: The access point's auto shape model + example: "Supported" + serial_number: + type: string + description: The access point's serial number + example: '123456' + source: + type: string + description: The name of the system of record that this access point was provisioned from + example: 'api' + source_id: + type: string + description: A unique identifier for this access point in the system of record + example: '2346' + slice_condition: + type: string + enum: [Invalid, Good, Caution, Warning, Drop] + description: The slice condition of the access point + example: "Good" + latency_severity: + type: string + enum: [Invalid, Unknown, Critical, Warning, Ok, Excellent] + description: The severity of the access point's latency + example: "Warning" + rf_score: + type: integer + description: The access point's RF score + example: 5.6 + rf_score_severity: + type: integer + enum: [Invalid, Unknown, Critical, Warning, Ok, Excellent] + description: The severity of the access point's RF score + example: 3 + ap_health_score: + type: integer + description: The access point's health score + example: 6.1 + ap_health_score_severity: + type: string + enum: [Invalid, Unknown, Critical, Warning, Ok, Excellent] + description: The access point's health score severity + example: "Critical" + airtime_remaining_tx: + type: integer + description: The access point's TX airtime remaining + example: 60 + airtime_remaining_severity_tx: + type: string + enum: [Invalid, Unknown, Critical, Warning, Ok, Excellent] + description: The access point's TX airtime remaining severity + example: "Excellent" + subscriber_capacity: + type: integer + description: The access point's subscriber capacity + example: 12 + subscriber_capacity_severity: + type: string + enum: [Invalid, Unknown, Critical, Warning, Ok, Excellent] + description: The access point's subscriber capacity severity + example: "Excellent" + rf_score_trend: + type: string + enum: [Invalid, Unknown Trend, Up Fast, Up, Stable, Down Fast, Down] + description: The access point's RF score trend + example: "Unknown Trend" + ap_health_score_trend: + type: string + enum: [Invalid, Unknown Trend, Up Fast, Up, Stable, Down Fast, Down] + description: The access point's health score trend + example: "Stable" + subscriber_capacity_trend: + type: string + enum: [Invalid, Unknown Trend, Up Fast, Up, Stable, Down Fast, Down] + description: The access point's subscriber capacity trend + example: "Up" + AccessPointConfig: + type: object + properties: + shaper_rate: + $ref: '#/components/schemas/APShaperRate' + snmp_community: + type: string + description: SNMP community for SNMPv1 or SNMPv2c + example: public + snmp3: + $ref: '#/components/schemas/SNMP3' + CPERadioScoreInfo: + type: object + properties: + company_uuid: + type: string + description: The company's UUID + example: 'f80dfce4-0fee-4cf4-b830-88ed5cf921a7' + element_uuid: + type: string + description: The CPE radio's UUID + example: '6fe2ac5a-be30-4393-7790-01fcdcef4784' + ap_uuid: + type: string + description: The associated AP's UUID + example: '5hr3bn7a-kg58-8579-2648-32dfgrey4522' + firmware: + type: string + description: The CPE radio's firmware + example: '4.6.2' + frequency: + type: integer + description: The CPE radio's frequency + example: 8000 + channel_width: + type: integer + description: The CPE radio's channel width + example: 20 + ap_name: + type: string + description: The CPE radio's associated AP's name + example: "ap name" + name: + type: string + description: The name of the CPE radio + example: 'CPE-1' + system_mac_address: + type: string + description: The system MAC address of the CPE radio + example: 00-B0-D0-63-C2-26 + management_ip: + type: string + description: The management IP of the CPE radio + example: 192.168.0.1 + status: + type: string + enum: [Invalid, Online, Warning, Offline, Unknown, Error] + description: The status of the CPE radio + example: "Online" + manufacturer_uuid: + type: string + description: The manufacturer UUID of the CPE radio + example: '1bbfa69b-f399-4d68-9572-5772074795f7' + manufacturer_name: + type: string + description: The manufacturer name of the CPE radio + example: 'Manufacturer Name' + model_uuid: + type: string + description: The model UUID of the CPE radio + example: 'e31dd2f3-f30b-46b1-8f9d-ef59b923070d' + model_name: + type: string + description: The model name of the CPE radio + example: 'Model Name' + system_name: + type: string + description: The system name of the CPE radio + example: 'System Name' + model_support_level: + type: string + enum: [Invalid, None, Topology, Test, Full, Basic] + description: The model support level of the CPE radio + example: "Topology" + element_id: + type: integer + description: The ID of the CPE radio + example: 123 + ap_id: + type: integer + description: The ID of the CPE radio's associated AP + example: 546 + ap_system_name: + type: string + description: The system name of the associated AP + example: "AP System Name" + ap_site_uuid: + type: string + description: The site UUID of the associated AP + example: '14004472-9617-4fa7-a2da-915633301d27' + ap_site_name: + type: string + description: The site name of the associated AP + example: 'AP Site Name' + serial_number: + type: string + description: The CPE radio's serial number + example: '123456' + slice_condition: + type: string + enum: [Invalid, Good, Caution, Warning, Drop] + description: The CPE radio's slice condition + example: "Good" + business_value_score: + type: integer + description: The CPE radio's business value score + example: 3 + business_value_score_severity: + type: string + enum: [Invalid, Unknown, Critical, Warning, Ok, Excellent] + description: The CPE radio's business value score severity + example: "Ok" + business_value_score_trend: + type: string + enum: [Invalid, Unknown Trend, Up Fast, Up, Stable, Down Fast, Down] + description: The trend of the CPE radio's business value score + example: "Stable" + rf_score: + type: integer + description: The CPE radio's RF score + example: 5.6 + rf_score_severity: + type: string + enum: [Invalid, Unknown, Critical, Warning, Ok, Excellent] + description: The CPE radio's RF score severity + example: "Unknown" + rf_score_trend: + type: string + enum: [Invalid, Unknown Trend, Up Fast, Up, Stable, Down Fast, Down] + description: The trend of the CPE radio's RF score + example: "Up" + APShaperRate: + type: object + properties: + up_speed: + type: integer + format: int64 + description: The upstream rate limit, in Kbps. A value of 0 is treated as not set. If not set, this field is omitted in the returned json. A negative speed is converted to 0 + example: 5000 + down_speed: + type: integer + format: int64 + description: The downstream rate limit, in Kbps. A value of 0 is treated as not set. If not set, this field is omitted in the returned json. A negative speed is converted to 0 + example: 5000 + activate: + type: boolean + description: A value of true enforces the shaper rates + example: true + SNMP3: + type: object + required: + - security_name + - auth_key + - auth_protocol + - priv_key + - priv_protocol + properties: + security_name: + type: string + description: SNMPv3 security name / username + example: preseem + auth_key: + type: string + description: SNMPv3 authentication key + example: asdf! + auth_protocol: + type: string + enum: [MD5, SHA] + description: SNMPv3 authentication type + example: MD5 + priv_key: + type: string + description: SNMPv3 privacy key + example: asdf! + priv_protocol: + type: string + enum: [AES, DES] + description: SNMPv3 privacy type + example: AES + Account: + type: object + required: + - id + - name + properties: + id: + type: string + description: Unique id for account + example: '100' + name: + type: string + description: Name of Account owner + example: Jonathon Fernando + Package: + type: object + required: + - id + - name + properties: + id: + type: string + description: Unique id for package + example: '10' + name: + type: string + description: Package name + example: Family Premium + up_speed: + type: integer + format: int64 + description: The upstream rate limit, in Kbps. A value of 0 is treated as not set. If not set, this field is omitted in the returned json. A negative speed is converted to 0 + example: 25000 + down_speed: + type: integer + format: int64 + description: The downstream rate limit, in Kbps. A value of 0 is treated as not set. If not set, this field is omitted in the returned json. A negative speed is converted to 0 + example: 5000 + Service: + type: object + required: + - id + - name + - account + properties: + id: + type: string + description: Unique id for service + example: '23' + attachments: + $ref: '#/components/schemas/ServiceAttachment' + up_speed: + type: integer + format: int64 + description: The upstream rate limit, in Kbps. A value of 0 is treated as not set. If not set, this field is omitted in the returned json. A negative speed is converted to 0 + example: 25000 + down_speed: + type: integer + format: int64 + description: The downstream rate limit, in Kbps. A value of 0 is treated as not set. If not set, this field is omitted in the returned json. A negative speed is converted to 0 + example: 5000 + account: + type: string + description: Account id for the service. This id is just a reference to an account, the account doesn't have to exist, but when it does, the service gets attached to the account + example: '100' + package: + type: string + description: Package id of the service + example: '10' + parent_device_id: + type: string + description: This is the unique id for the parent device. E.g. An access point + example: 'AP1' + Router: + type: object + required: + - id + - name + - site + - host + properties: + id: + type: string + description: Unique id for router + example: '123' + name: + type: string + description: Router name + example: Router-1 + site: + type: string + description: Name of the site or tower where the router is deployed + example: Mt. Site + host: + type: string + description: Management ip address for Router + example: 192.0.2.1 + OLT: + type: object + required: + - id + - name + - site + - host + properties: + id: + type: string + description: Unique ID for OLT + example: '123' + name: + type: string + description: OLT name + example: OLT-1 + site: + type: string + description: Name of the site or tower where the OLT is deployed + example: Mt. Site + host: + type: string + description: Management ip address for OLT + example: 192.0.2.1 + Site: + type: object + required: + - id + - name + - ip_address + properties: + id: + type: string + description: Unique id for Site. Note that a site and access point objects can't have the same ID as they share the same namespace. + example: '123' + name: + type: string + description: Site name + example: Site-1 + attachment: + $ref: '#/components/schemas/SiteAttachment' + SiteAttachment: + type: object + properties: + network_prefixes: + type: array + items: + type: string + example: ['10.10.10.0/24', '192.168.100.100', '2001:cafe:456:dfe1:63:0:0:fefb', '2001:f0:3238:dfe1::/64'] + ServiceAttachment: + type: array + description: Attachment must be unique across services. If two or more services are assigned the same attachment, only the last assignment will remain. + items: + type: object + properties: + cpe_mac: + type: string + description: Optional + network_prefixes: + type: array + description: Optional + items: + type: string + cpe_serial: + type: string + description: Optional. The serial number of the CPE device assigned to this service. The presence of a serial number enforces a single-attachment restriction, implying that any service with a serial number is limited to having just one attachment. This field is used to map Tarana RN devices to the service. + items: + type: string + cpe_imsi: + type: string + description: Optional. The IMSI (International Mobile Subscriber Identity) of the CPE device assigned to this service. The presence of an IMSI enforces a single-attachment restriction, implying that any service with IMSI is limited to having just one attachment. + items: + type: string + username: + type: string + description: Optional. Represents the RADIUS username associated with a service, for PPPoE based deployments. The presence of a username enforces a single-attachment restriction, implying that any service with a username is limited to having just one attachment. + items: + type: string + example: + - cpe_mac: '00:10:0b:6e:4c:45' + network_prefixes: ['10.10.10.0/24', '192.168.100.100', '2001:cafe:456:dfe1:63:0:0:fefb', '2001:f0:3238:dfe1::/64'] + - cpe_mac: '00:17:2c:6e:9b:15' + - network_prefixes: ['12.12.12.12'] + Paginator: + type: object + description: Page details + properties: + page: + type: integer + description: Page number for the list of results returned + example: 1 + page_count: + type: integer + description: Total number of pages available + example: 1 + limit: + type: integer + description: The number of results per page. The number of results on the last page can be less than `limit` + minimum: 100 + maximum: 1000 + example: 100 + total_count: + type: integer + description: Sum of results across all pages + example: 1 + AccessPointList: + type: object + properties: + data: + type: array + description: A list of access points + items: + $ref: '#/components/schemas/AccessPoint' + paginator: + $ref: '#/components/schemas/Paginator' + AccountList: + type: object + properties: + data: + type: array + description: A list of accounts + items: + $ref: '#/components/schemas/Account' + paginator: + $ref: '#/components/schemas/Paginator' + PackageList: + type: object + properties: + data: + type: array + description: A list of packages + items: + $ref: '#/components/schemas/Package' + paginator: + $ref: '#/components/schemas/Paginator' + ServiceList: + type: object + properties: + data: + type: array + description: A list of services + items: + $ref: '#/components/schemas/Service' + paginator: + $ref: '#/components/schemas/Paginator' + RouterList: + type: object + properties: + data: + type: array + description: A list of routers + items: + $ref: '#/components/schemas/Router' + paginator: + $ref: '#/components/schemas/Paginator' + OLTList: + type: object + properties: + data: + type: array + description: A list of OLTs + items: + $ref: '#/components/schemas/OLT' + paginator: + $ref: '#/components/schemas/Paginator' + SiteList: + type: object + properties: + data: + type: array + description: A list of sites + items: + $ref: '#/components/schemas/Site' + paginator: + $ref: '#/components/schemas/Paginator' +security: + - APIKeyAuth: [] +externalDocs: + description: Find out more about Preseem + url: https://preseem.com diff --git a/preseem/types.go b/preseem/types.go new file mode 100644 index 0000000..a8bce4b --- /dev/null +++ b/preseem/types.go @@ -0,0 +1,189 @@ +package main + +// Types transcribed from preseem/preseem.yaml (components.schemas). +// Only fields useful for read flows are modeled; the raw JSON body is +// always available via --json passthrough for anything not listed here. + +type Paginator struct { + Page int `json:"page"` + PageCount int `json:"page_count"` + Limit int `json:"limit"` + TotalCount int `json:"total_count"` +} + +type AccessPoint struct { + ID string `json:"id"` + Name string `json:"name"` + Tower string `json:"tower"` + IPAddress string `json:"ip_address"` +} + +type AccessPointList struct { + Data []AccessPoint `json:"data"` + Paginator Paginator `json:"paginator"` +} + +type APShaperRate struct { + UpSpeed int64 `json:"up_speed,omitempty"` + DownSpeed int64 `json:"down_speed,omitempty"` + Activate bool `json:"activate,omitempty"` +} + +type SNMP3 struct { + SecurityName string `json:"security_name,omitempty"` + AuthKey string `json:"auth_key,omitempty"` + AuthProtocol string `json:"auth_protocol,omitempty"` + PrivKey string `json:"priv_key,omitempty"` + PrivProtocol string `json:"priv_protocol,omitempty"` +} + +type AccessPointConfig struct { + ShaperRate *APShaperRate `json:"shaper_rate,omitempty"` + SNMPCommunity string `json:"snmp_community,omitempty"` + SNMP3 *SNMP3 `json:"snmp3,omitempty"` +} + +// Score endpoints are field-heavy and don't paginate per spec — they +// return a top-level array. +// NB: the spec types numerics as `integer` but the live API returns +// floats for latency/throughput/scores. Use float64 throughout so +// decoding never breaks; printers round for display. +type AccessPointScore struct { + Name string `json:"name"` + Status string `json:"status"` + SiteName string `json:"site_name"` + ManufacturerName string `json:"manufacturer_name"` + ModelName string `json:"model_name"` + ManagementIP string `json:"management_ip"` + SystemMacAddress string `json:"system_mac_address"` + Connections float64 `json:"connections"` + Latency float64 `json:"latency"` + ThroughputTX float64 `json:"throughput_tx"` + ThroughputRX float64 `json:"throughput_rx"` + Frequency float64 `json:"frequency"` + ChannelWidth float64 `json:"channel_width"` + Firmware string `json:"firmware"` + RFScore float64 `json:"rf_score"` + RFScoreSeverity any `json:"rf_score_severity"` + APHealthScore float64 `json:"ap_health_score"` + APHealthSeverity string `json:"ap_health_score_severity"` + SubscriberCap float64 `json:"subscriber_capacity"` + SubscriberCapSev string `json:"subscriber_capacity_severity"` + SliceCondition string `json:"slice_condition"` + LatencySeverity string `json:"latency_severity"` + ModelSupportLvl string `json:"model_support_level"` + AirtimeRemTX float64 `json:"airtime_remaining_tx"` + AirtimeRemSevTX string `json:"airtime_remaining_severity_tx"` + RFScoreTrend string `json:"rf_score_trend"` + APHealthTrend string `json:"ap_health_score_trend"` + SubscriberCapTrnd string `json:"subscriber_capacity_trend"` + SerialNumber string `json:"serial_number"` + ElementID int `json:"element_id"` +} + +type CPERadioScore struct { + Name string `json:"name"` + Status string `json:"status"` + APName string `json:"ap_name"` + APSiteName string `json:"ap_site_name"` + ManufacturerName string `json:"manufacturer_name"` + ModelName string `json:"model_name"` + ManagementIP string `json:"management_ip"` + SystemMacAddress string `json:"system_mac_address"` + Firmware string `json:"firmware"` + Frequency float64 `json:"frequency"` + ChannelWidth float64 `json:"channel_width"` + RFScore float64 `json:"rf_score"` + RFScoreSeverity any `json:"rf_score_severity"` + RFScoreTrend string `json:"rf_score_trend"` + BusinessValue float64 `json:"business_value_score"` + BusinessValueSev string `json:"business_value_score_severity"` + SliceCondition string `json:"slice_condition"` + SerialNumber string `json:"serial_number"` + ElementID int `json:"element_id"` + APID int `json:"ap_id"` +} + +type Account struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type AccountList struct { + Data []Account `json:"data"` + Paginator Paginator `json:"paginator"` +} + +type Package struct { + ID string `json:"id"` + Name string `json:"name"` + UpSpeed int64 `json:"up_speed,omitempty"` + DownSpeed int64 `json:"down_speed,omitempty"` +} + +type PackageList struct { + Data []Package `json:"data"` + Paginator Paginator `json:"paginator"` +} + +type ServiceAttachment struct { + CPEMac string `json:"cpe_mac,omitempty"` + NetworkPrefixes []string `json:"network_prefixes,omitempty"` + CPESerial string `json:"cpe_serial,omitempty"` + CPEIMSI string `json:"cpe_imsi,omitempty"` + Username string `json:"username,omitempty"` +} + +type Service struct { + ID string `json:"id"` + Attachments []ServiceAttachment `json:"attachments,omitempty"` + UpSpeed int64 `json:"up_speed,omitempty"` + DownSpeed int64 `json:"down_speed,omitempty"` + Account string `json:"account"` + Package string `json:"package,omitempty"` + ParentDeviceID string `json:"parent_device_id,omitempty"` +} + +type ServiceList struct { + Data []Service `json:"data"` + Paginator Paginator `json:"paginator"` +} + +type Router struct { + ID string `json:"id"` + Name string `json:"name"` + Site string `json:"site"` + Host string `json:"host"` +} + +type RouterList struct { + Data []Router `json:"data"` + Paginator Paginator `json:"paginator"` +} + +type OLT struct { + ID string `json:"id"` + Name string `json:"name"` + Site string `json:"site"` + Host string `json:"host"` +} + +type OLTList struct { + Data []OLT `json:"data"` + Paginator Paginator `json:"paginator"` +} + +type SiteAttachment struct { + NetworkPrefixes []string `json:"network_prefixes,omitempty"` +} + +type Site struct { + ID string `json:"id"` + Name string `json:"name"` + Attachment *SiteAttachment `json:"attachment,omitempty"` +} + +type SiteList struct { + Data []Site `json:"data"` + Paginator Paginator `json:"paginator"` +} diff --git a/scripts/__pycache__/annotate_cpes.cpython-313.pyc b/scripts/__pycache__/annotate_cpes.cpython-313.pyc new file mode 100644 index 0000000..cc36320 Binary files /dev/null and b/scripts/__pycache__/annotate_cpes.cpython-313.pyc differ diff --git a/scripts/__pycache__/cpe_mac_audit.cpython-313.pyc b/scripts/__pycache__/cpe_mac_audit.cpython-313.pyc new file mode 100644 index 0000000..c80c2ac Binary files /dev/null and b/scripts/__pycache__/cpe_mac_audit.cpython-313.pyc differ diff --git a/scripts/airos-ssh.sh b/scripts/airos-ssh.sh new file mode 100755 index 0000000..8c8944a --- /dev/null +++ b/scripts/airos-ssh.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Run a command on a Ubiquiti airOS radio over SSH (password auth). +# +# Usage: +# AIROS_PASSWORDS='pw1,pw2' ./airos-ssh.sh "" +# +# Env: +# AIROS_USER login user (default: ubnt) +# AIROS_PASSWORDS comma-separated passwords, tried in order (required) +# +# Handles the legacy KEX/hostkey algos old airOS dropbear builds need and +# never writes to known_hosts. Exits with the remote command's status, or +# 255 if every password is rejected. +set -u + +if [ $# -lt 2 ]; then + echo "Usage: AIROS_PASSWORDS='pw1,pw2' $0 \"\"" >&2 + exit 1 +fi + +IP="$1"; shift +CMD="$*" +USER="${AIROS_USER:-ubnt}" +IFS=',' read -ra PWS <<< "${AIROS_PASSWORDS:?set AIROS_PASSWORDS (comma-separated)}" + +for PW in "${PWS[@]}"; do + OUT=$(AIP="$IP" AUSER="$USER" APASS="$PW" ACMD="$CMD" expect <<'EOF' +set timeout 30 +set ip $env(AIP) +set user $env(AUSER) +set pass $env(APASS) +set cmd $env(ACMD) +spawn ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -o LogLevel=ERROR -o ConnectTimeout=8 -o NumberOfPasswordPrompts=1 \ + -o PreferredAuthentications=password \ + -o HostKeyAlgorithms=+ssh-rsa -o KexAlgorithms=+diffie-hellman-group14-sha1 \ + "$user@$ip" $cmd +expect { + -re {(?i)password:} { send -- "$pass\r"; exp_continue } + eof +} +catch wait result +exit [lindex $result 3] +EOF +) + RC=$? + if [ $RC -eq 0 ] || ! grep -q "Permission denied" <<<"$OUT"; then + # Drop the echoed password-prompt line, keep real output. + printf '%s\n' "$OUT" | sed '/[Pp]assword:/d' + exit $RC + fi +done + +echo "airos-ssh: all passwords failed for $USER@$IP" >&2 +exit 255 diff --git a/scripts/annotate_cpes.py b/scripts/annotate_cpes.py new file mode 100755 index 0000000..85ea390 --- /dev/null +++ b/scripts/annotate_cpes.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +"""Annotate cpes.txt with SNMP-discovered device models. + +Queries each IP via SNMPv1 (community kdyyJrT0Mm) for: + - sysDescr / sysObjectID / product code / firmware version + - Falls back to MAC OUI for devices that don't respond to SNMP + +Output: clean table with MAC, device type, account info. + +Usage: + python3 scripts/annotate_cpes.py [cpes.txt] > annotated.txt +""" +from __future__ import annotations + +import argparse +import os +import re +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import Any + +SNMP_COMMUNITY = "kdyyJrT0Mm" + +# Ubiquiti product codes from 17713.21.1.1.2.0 → model name +_PRODUCT_MAP: dict[int, str] = { + 8: "NanoStation M5 / NS loco M5", + 35: "NanoBeam M5 / LiteBeam M5", + 55: "PowerBeam M5 / Loco M5", +} + +# sysObjectID suffix → model hint +_SYSOID_MAP: dict[str, str] = { + "17713.21.9.35": "AirOS 5.x (NanoBeam/LiteBeam)", + "17713.21.9.55": "AirOS 5.x (PowerBeam/Loco)", + "10002.1": "AirOS (legacy MIB)", + "41112.1.4": "airOS 8.x", + "netSnmpAgentOIDs.10": "NET-SNMP agent (likely AirOS)", +} + + +def parse_cpes(path: str) -> list[dict[str, str]]: + entries: list[dict[str, str]] = [] + pending: dict[str, str] | None = None + with open(path) as f: + for line in f: + line = line.rstrip() + # Account detail line (indented, comes after the IP/MAC line) + m_acct = re.search(r"account=#(\d+)", line) + if m_acct and pending: + pending["readable"] = m_acct.group(1) + acct_m = re.search(r"name='([^']+)'", line) + pending["name"] = acct_m.group(1) if acct_m else "" + entries.append(pending) + pending = None + continue + + # IP/MAC line + m = re.search(r"ip=(\S+)", line) + if not m: + continue + ip = m.group(1) + mac_m = re.search(r"mac=(\S+)", line) + mac = mac_m.group(1) if mac_m else "" + pending = {"ip": ip, "mac": mac, "name": "", "readable": ""} + return entries + + +def snmp_get(ip: str, oid: str, timeout: float = 8) -> str | None: + try: + p = subprocess.run( + ["snmpget", "-v1", "-c", SNMP_COMMUNITY, "-Oqv", ip, oid], + capture_output=True, text=True, timeout=timeout, + ) + if p.returncode == 0: + return p.stdout.strip().strip('"') + except Exception: + pass + return None + + +def query_device(entry: dict[str, str]) -> dict[str, str]: + ip = entry["ip"] + result = {**entry, "model": "unknown"} + + sys_oid = snmp_get(ip, "1.3.6.1.2.1.1.2.0") + sys_descr = snmp_get(ip, "1.3.6.1.2.1.1.1.0") + prod_code = snmp_get(ip, "1.3.6.1.4.1.17713.21.1.1.2.0") + fw = snmp_get(ip, "1.3.6.1.4.1.17713.21.1.1.1.0") + snmp_mac = snmp_get(ip, "1.3.6.1.4.1.17713.21.1.1.5.0") + + # Build model string from available info + parts: list[str] = [] + + # 1. Product code (most specific) + if prod_code: + try: + code = int(prod_code) + if code in _PRODUCT_MAP: + parts.append(_PRODUCT_MAP[code]) + else: + parts.append(f"product={code}") + except ValueError: + parts.append(f"product={prod_code}") + + # 2. sysObjectID hint + if sys_oid and not parts: + for key, hint in _SYSOID_MAP.items(): + if key in sys_oid: + parts.append(hint) + break + else: + parts.append(sys_oid.split("::")[-1] if "::" in sys_oid else sys_oid) + + # 3. sysDescr (sometimes contains model) + if sys_descr and sys_descr not in ("vntx", "unknown"): + if not parts or sys_descr != "vntx": + if sys_descr not in ("vntx",): + pass # sysDescr is usually just "vntx" or the host name, not useful + + # 4. MAC OUI fallback + if not parts: + mac = entry["mac"] + if mac: + oui = mac[:8].upper() + parts.append(f"OUI {oui}") + else: + parts.append("unknown") + + # 5. Firmware version + if fw: + parts.append(f"fw={fw}") + + # 6. SNMP MAC verification + if snmp_mac: + snmp_mac_norm = re.sub(r"[^0-9a-f]", "", snmp_mac.lower()) + entry_mac_norm = re.sub(r"[^0-9a-f]", "", entry["mac"].lower()) + if snmp_mac_norm != entry_mac_norm: + parts.append(f"(SNMP MAC: {snmp_mac})") + + result["model"] = " | ".join(parts) if parts else "no SNMP response" + if sys_oid: + result["sys_oid"] = sys_oid.split("::")[-1] if "::" in sys_oid else sys_oid + if prod_code: + result["prod_code"] = prod_code + if fw: + result["fw"] = fw + + return result + + +def main(): + ap = argparse.ArgumentParser(description="Annotate cpes.txt with SNMP device models") + ap.add_argument("input", nargs="?", default="cpes.txt", + help="path to cpes.txt (default: cpes.txt)") + ap.add_argument("-w", "--workers", type=int, default=20, + help="parallel SNMP queries (default: 20)") + args = ap.parse_args() + + if not os.path.exists(args.input): + sys.exit(f"{args.input} not found") + + entries = parse_cpes(args.input) + print(f"Querying {len(entries)} devices via SNMP...", file=sys.stderr) + + results: list[dict[str, str]] = [] + with ThreadPoolExecutor(max_workers=args.workers) as ex: + futs = {ex.submit(query_device, e): e for e in entries} + for i, f in enumerate(as_completed(futs), 1): + results.append(f.result()) + if i % 20 == 0: + print(f" {i}/{len(entries)}", file=sys.stderr) + print(f" {len(entries)}/{len(entries)} done", file=sys.stderr) + + # Sort by account name + results.sort(key=lambda r: r["name"].lower()) + + # Output + print(f"{'MAC':17} {'Model':50} {'Account':30} IP") + print("-" * 120) + for r in results: + print(f"{r['mac']:17} {r['model']:50} #{r['readable']:5} {r['name']:24} {r['ip']}") + + +if __name__ == "__main__": + main() diff --git a/scripts/complete_pre_2026_work_orders.py b/scripts/complete_pre_2026_work_orders.py new file mode 100644 index 0000000..d0a3af8 --- /dev/null +++ b/scripts/complete_pre_2026_work_orders.py @@ -0,0 +1,87 @@ +"""Mark every non-completed work order created before 2026-01-01 UTC as COMPLETED. + +Usage: GAIIA_KEY=... uv run --project /Users/graham/dev/network/gaiia \ + python /Users/graham/dev/network/scripts/complete_pre_2026_work_orders.py +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from gaiia import GaiiaClient +from gaiia.errors import GaiiaMutationError + +CUTOFF = datetime(2026, 1, 1, tzinfo=timezone.utc) + +LIST_Q = """ +query Q($after: String) { + workOrders( + first: 100 + after: $after + filter: { status: { notIn: [COMPLETED] } } + ) { + nodes { id readableId status createdAt workType { name } } + pageInfo { hasNextPage endCursor } + } +} +""" + +UPDATE_M = """ +mutation U($i: UpdateWorkOrderInput!) { + updateWorkOrder(input: $i) { + workOrder { id status } + errors { code message } + } +} +""" + + +def parse_dt(s: str) -> datetime: + # Gaiia returns ISO 8601 UTC, e.g. "2025-08-12T14:33:01.123Z" + return datetime.fromisoformat(s.replace("Z", "+00:00")) + + +def main() -> None: + with GaiiaClient(timezone="America/Chicago") as g: + targets: list[dict] = [] + skipped_repo = 0 + for wo in g.iter_nodes(LIST_Q, "workOrders"): + if parse_dt(wo["createdAt"]) >= CUTOFF: + continue + wt = (wo.get("workType") or {}).get("name") or "" + if "repo" in wt.lower(): + skipped_repo += 1 + print(f" SKIP repo #{wo['readableId']:<8} workType={wt!r} ({wo['createdAt']})") + continue + targets.append(wo) + + print(f"Found {len(targets)} non-completed work orders created before {CUTOFF.date()} " + f"(skipped {skipped_repo} repo-typed).") + + ok = 0 + failed: list[tuple[dict, str]] = [] + for wo in targets: + try: + res = g.mutate( + UPDATE_M, + "updateWorkOrder", + {"i": {"id": wo["id"], "status": "COMPLETED"}}, + ) + new_status = res["workOrder"]["status"] + ok += 1 + print(f" #{wo['readableId']:<8} {wo['status']:<12} -> {new_status} ({wo['createdAt']})") + except GaiiaMutationError as e: + failed.append((wo, str(e))) + print(f" #{wo['readableId']:<8} FAILED: {e}") + except Exception as e: # noqa: BLE001 + failed.append((wo, repr(e))) + print(f" #{wo['readableId']:<8} ERROR: {e!r}") + + print(f"\nDone. {ok}/{len(targets)} updated. {len(failed)} failed.") + if failed: + for wo, err in failed: + print(f" #{wo['readableId']} {wo['id']} {err}") + + +if __name__ == "__main__": + main() diff --git a/scripts/cpe_mac_audit.py b/scripts/cpe_mac_audit.py new file mode 100755 index 0000000..d56f4f6 --- /dev/null +++ b/scripts/cpe_mac_audit.py @@ -0,0 +1,774 @@ +#!/usr/bin/env python3 +"""Audit CPE MAC addresses: compare Gaiia assignments against live DHCP leases. + +For every active (paying) customer in Gaiia, find their assigned CPE MAC(s), +then cross-reference against live DHCP leases on all tower routers. Flags: + + MAC_MISMATCH -- DHCP host-name matches a Gaiia account, but the lease MAC + belongs to a *different* account in Gaiia (or no account). + MAC_UNKNOWN -- DHCP lease MAC not found in Gaiia inventory at all, but + host-name fuzzy-matches an active account (inventory gap). + GAIIA_ORPHAN -- Active customer's CPE MAC is in Gaiia but NOT in any DHCP + lease (device offline / replaced / MAC wrong in Gaiia). + +Usage: + GAIIA_KEY=... ./scripts/cpe_mac_audit.py + GAIIA_KEY=... ./scripts/cpe_mac_audit.py --routers 982,core + GAIIA_KEY=... ./scripts/cpe_mac_audit.py --json > audit.json + GAIIA_KEY=... ./scripts/cpe_mac_audit.py --verbose # also show OK entries +""" +from __future__ import annotations + +import argparse +import json +import os +import ipaddress +import re +import subprocess +import sys +import time +from concurrent.futures import ThreadPoolExecutor +from typing import Any + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(REPO, "gaiia", "src")) +from gaiia import GaiiaClient, GaiiaTransportError # noqa: E402 + +TOWER_ROUTERS = ["verona", "climax", "culleoka", "newhope", "lowry", "982", "494", "core"] +MTT = os.path.join(REPO, "mikrotik-tool", "mikrotik-tool") + +CPE_NET = ipaddress.IPv4Network("10.10.0.0/16") +PAYING = {"ACTIVE", "TRIAL"} + +# Indoor routers / WiFi APs — not CPE radios, skip them. +_INDOOR_PATTERNS = ("AC1200", "AC1000", "Vilo", "hc220", "GWN") + +# MAC OUI → vendor / device type hints for "needs creation" reporting. +# First 3 bytes of the MAC (upper case, colon-separated). +_OUI_HINTS: dict[str, str] = {} +for _prefix, _hint in [ + # Ubiquiti + ("00:04:56", "Ubiquiti (legacy)"), + ("00:27:22", "Ubiquiti (legacy)"), + ("00:01:9F", "Ubiquiti AC1000/AC1200"), + ("04:18:D6", "Ubiquiti Loco M5"), + ("24:A4:3C", "Ubiquiti Loco M5"), + ("24:2F:D0", "Ubiquiti AC1200-Mesh"), + ("24:5A:4C", "Ubiquiti"), + ("5C:62:88", "Ubiquiti"), + ("68:72:51", "Ubiquiti"), + ("68:D7:9A", "Ubiquiti Power Beam AC 2.4"), + ("70:A7:41", "Ubiquiti Nanobeam AC 2.4"), + ("74:83:C2", "Ubiquiti Loco M5"), + ("74:AC:B9", "Ubiquiti PowerBeam M5"), + ("74:FE:CE", "Ubiquiti AC1200-Mesh"), + ("78:45:58", "Ubiquiti PowerBeam AC Gen2"), + ("78:8A:20", "Ubiquiti Loco M5"), + ("80:2A:A8", "Ubiquiti Loco M5"), + ("8C:90:2D", "Ubiquiti AC1200-Mesh"), + ("98:03:8E", "Ubiquiti AC1200-Mesh"), + ("98:25:4A", "Ubiquiti AC1200-Mesh"), + ("A8:5E:45", "Ubiquiti"), + ("B0:19:21", "Ubiquiti AC1200-Mesh"), + ("B4:FB:E4", "Ubiquiti Loco M5"), + ("CC:2D:E0", "Ubiquiti wAP 60G"), + ("D0:21:F9", "Ubiquiti"), + ("D8:44:89", "Ubiquiti AC1200-Mesh"), + ("DC:9F:DB", "Ubiquiti"), + ("E0:63:DA", "Ubiquiti PowerBeam M5 / Loco M5"), + ("E4:38:83", "Ubiquiti PowerBeam / LiteBeam"), + ("E8:DA:00", "Ubiquiti"), + ("EC:74:D7", "Ubiquiti GWN"), + ("F0:9F:C2", "Ubiquiti"), + ("F0:A7:31", "Ubiquiti AC1200-Mesh"), + ("F4:92:BF", "Ubiquiti Loco M5 / PowerBeam"), + ("F4:E2:C6", "Ubiquiti PowerBeam AC Gen2"), + ("FC:EC:DA", "Ubiquiti LiteBeam / Loco"), + # Cambium + ("58:C1:7A", "Cambium ePMP"), + # MikroTik + ("18:E8:29", "MikroTik"), + ("00:15:6D", "Ubiquiti NanoBridge M900"), +]: + _OUI_HINTS[_prefix] = _hint + + +def guess_device(mac: str) -> str: + """Best-effort device type from MAC OUI prefix.""" + n = fmt_mac(mac) + if not n: + return "unknown" + prefix = n[:8] # first 3 bytes: AA:BB:CC + return _OUI_HINTS.get(prefix, n[:8]) + + +# ---------- helpers ---------- + +def norm_mac(s: str) -> str: + return re.sub(r"[^0-9a-f]", "", (s or "").lower()) + + +def fmt_mac(s: str) -> str: + n = norm_mac(s) + return ":".join(n[i:i+2] for i in range(0, 12, 2)).upper() if len(n) == 12 else s + + +def name_tokens(s: str) -> set[str]: + return {w for w in re.split(r"[^a-z0-9]+", s.lower()) if len(w) >= 3} + + +def parse_kv_line(line: str) -> dict[str, str]: + kv: dict[str, str] = {} + for part in re.split(r" +", line.strip()): + if "=" in part: + k, v = part.split("=", 1) + kv[k] = v + return kv + + +# ---------- MikroTik ---------- + +def fetch_dhcp_leases(router: str) -> tuple[list[dict[str, Any]], bool]: + """Return (leases, reachable). reachable=False means the router timed out or errored.""" + p = subprocess.run( + [MTT, "api", router, "/ip/dhcp-server/lease/print"], + capture_output=True, text=True, timeout=90, + cwd=os.path.join(REPO, "mikrotik-tool"), + ) + if p.returncode != 0: + print(f"!! {router}: {p.stderr.strip()[:200]}", file=sys.stderr) + return [], False + out: list[dict[str, Any]] = [] + for line in p.stdout.splitlines(): + if not line.startswith(".id="): + continue + kv = parse_kv_line(line) + if kv.get("status") != "bound": + continue + mac = kv.get("active-mac-address") or kv.get("mac-address") or "" + if not mac: + continue + addr = kv.get("active-address") or kv.get("address") or "" + if addr: + try: + if ipaddress.IPv4Address(addr) not in CPE_NET: + continue + except ValueError: + continue + out.append({ + "router": router, + "mac": norm_mac(mac), + "mac_raw": mac, + "address": addr, + "host": kv.get("host-name", ""), + }) + return out, True + + +# ---------- Gaiia ---------- + +def _retry_query(client: GaiiaClient, query: str, variables: dict | None = None, max_tries: int = 5): + """Run a Gaiia query with retries on transport errors.""" + for attempt in range(1, max_tries + 1): + try: + return client.query(query, variables) + except GaiiaTransportError: + if attempt == max_tries: + raise + delay = min(2 ** attempt, 30) + print(f" (transport error, retrying in {delay}s — attempt {attempt}/{max_tries})", + file=sys.stderr) + time.sleep(delay) + + +def paginate(client: GaiiaClient, q_with: str, q_first: str, path: str): + cur: str | None = None + while True: + d = _retry_query(client, q_first) if cur is None else _retry_query(client, q_with, {"after": cur}) + node = d + for part in path.split("."): + node = node[part] + for n in node.get("nodes") or []: + yield n + pi = node.get("pageInfo") or {} + if not pi.get("hasNextPage"): + return + cur = pi.get("endCursor") + if not cur: + return + + +def fetch_gaiia() -> tuple[dict[str, dict], dict[str, dict], dict[str, dict]]: + """Return (accounts_by_id, mac_to_cpe, mac_to_any). + + mac_to_cpe: outdoor CPE radios only (used for the main audit). + mac_to_any: ALL inventory items including indoor routers (used to resolve unknown MACs).""" + accounts: dict[str, dict] = {} + q_acct_with = """query Q($after:String!){ accounts(first:100,after:$after){ + nodes{ id readableId displayName status{ name } + billingSubscriptions(first:50){ nodes{ status } } } + pageInfo{ hasNextPage endCursor } } }""" + q_acct_first = """query{ accounts(first:100){ + nodes{ id readableId displayName status{ name } + billingSubscriptions(first:50){ nodes{ status } } } + pageInfo{ hasNextPage endCursor } } }""" + + mac_to_cpe: dict[str, dict] = {} + mac_to_any: dict[str, dict] = {} + q_inv_with = """query Q($after:String!){ inventoryItems(first:100,after:$after){ + nodes{ id model{ name } + fields{ nodes{ data modelField{ name } } } + assignation{ assigneeType assignee{ __typename ... on Account{ id } } } + } + pageInfo{ hasNextPage endCursor } } }""" + q_inv_first = q_inv_with.replace("query Q($after:String!)", "query").replace(",after:$after", "") + + def _add_macs(macs, item_id, model, acct_id, assignee_type, target_map): + for m in macs: + if m in target_map and target_map[m].get("account_id"): + continue + target_map[m] = { + "item_id": item_id, + "model": model, + "account_id": acct_id, + "assignee_type": assignee_type, + } + + with GaiiaClient(timezone="America/Chicago") as g: + for a in paginate(g, q_acct_with, q_acct_first, "accounts"): + subs = [s["status"] for s in ((a.get("billingSubscriptions") or {}).get("nodes") or [])] + accounts[a["id"]] = { + "id": a["id"], + "readable": a["readableId"], + "name": a["displayName"], + "status": a["status"]["name"], + "paying": any(s in PAYING for s in subs), + "subs": subs, + } + + for it in paginate(g, q_inv_with, q_inv_first, "inventoryItems"): + model = (it.get("model") or {}).get("name", "") + is_indoor = any(p.lower() in model.lower() for p in _INDOOR_PATTERNS) + + macs = [] + for f in ((it.get("fields") or {}).get("nodes") or []): + mf = f.get("modelField") or {} + if "mac" in mf.get("name", "").lower() and f.get("data"): + m = norm_mac(f["data"]) + if len(m) == 12: + macs.append(m) + assn = it.get("assignation") or {} + assignee = assn.get("assignee") or {} + assignee_type = assn.get("assigneeType") or None # None = truly unassigned + acct_id = assignee["id"] if assignee.get("__typename") == "Account" else None + + _add_macs(macs, it["id"], model, acct_id, assignee_type, mac_to_any) + if not is_indoor: + _add_macs(macs, it["id"], model, acct_id, assignee_type, mac_to_cpe) + + return accounts, mac_to_cpe, mac_to_any + + +# ---------- matching ---------- + +def fuzzy_match_host(host: str, accounts: dict[str, dict]) -> dict | None: + """Match a DHCP host-name to a Gaiia account by displayName tokens. + Requires >=2 token overlap to reduce false positives.""" + if not host: + return None + htoks = name_tokens(host) + if len(htoks) < 2: + return None + best, best_score = None, 0 + for aid, a in accounts.items(): + atoks = name_tokens(a["name"]) + score = len(htoks & atoks) + if score >= 2 and score > best_score: + best_score = score + best = {**a, "id": aid, "match_score": score} + return best + + +# ---------- audit ---------- + +def audit(leases, accounts, mac_to_cpe, mac_to_any): + """Compare DHCP leases against Gaiia assignments. Returns categorized results.""" + paying = {aid: a for aid, a in accounts.items() if a["paying"]} + + mismatches: list[dict] = [] # MAC belongs to different account than host suggests + unknowns: list[dict] = [] # MAC not in mac_to_cpe, but host matches paying account + unassigned: list[dict] = [] # MAC in Gaiia but not assigned to any account + ok_entries: list[dict] = [] # everything matches + unmatched_host: list[dict] = [] # MAC in Gaiia, assigned, but host doesn't match any account + + # MAC_UNKNOWN sub-categories, resolved against mac_to_any (full inventory) + fixable: list[dict] = [] # item exists, unassigned → can auto-assign + exists_assigned: list[dict] = [] # item exists, assigned to a different account + needs_creation: list[dict] = [] # not in Gaiia inventory at all + + # Track which Gaiia MACs we've seen in DHCP + seen_gaiia_macs: set[str] = set() + + for lease in leases: + mac = lease["mac"] + gaiia = mac_to_cpe.get(mac) + + if gaiia: + seen_gaiia_macs.add(mac) + gaiia_acct_id = gaiia.get("account_id") + gaiia_acct = accounts.get(gaiia_acct_id) if gaiia_acct_id else None + gaiia_has_assignee = bool(gaiia.get("assignee_type")) + else: + gaiia_acct_id = None + gaiia_acct = None + gaiia_has_assignee = False + + host_match = fuzzy_match_host(lease["host"], accounts) + + entry = { + "lease": lease, + "gaiia_mac_owner": gaiia_acct, + "host_match": host_match, + } + + if gaiia_acct and host_match: + if gaiia_acct["id"] == host_match["id"]: + ok_entries.append(entry) + else: + mismatches.append(entry) + elif gaiia_acct and not host_match: + unmatched_host.append(entry) + elif not gaiia_acct and host_match: + if host_match["paying"]: + unknowns.append(entry) + # Resolve against full inventory (including indoor routers) + any_item = mac_to_any.get(mac) + if not any_item: + # MAC genuinely absent from Gaiia inventory. + needs_creation.append(entry) + elif any_item.get("account_id"): + # Exists but assigned to a *different* account. + owner = accounts.get(any_item["account_id"]) + exists_assigned.append({ + **entry, + "existing_item": any_item, + "existing_owner_name": owner["name"] if owner else None, + }) + else: + # Exists but not on any account: truly unassigned OR parked at an + # inventory location/site. Either way, reassign to matched account. + fixable.append({**entry, "existing_item": any_item}) + elif gaiia and not gaiia_acct and not gaiia_has_assignee: + unassigned.append(entry) + # else: lease with no Gaiia MAC match and no host match → truly unknown, skip + # Items assigned to sites/locations are not flagged (silently correct). + + # Gaiia orphans: paying customers whose CPE MACs are NOT in any DHCP lease + orphans: list[dict] = [] + for mac, info in mac_to_cpe.items(): + acct_id = info.get("account_id") + if not acct_id or acct_id not in paying: + continue + if mac not in seen_gaiia_macs: + orphans.append({ + "mac": mac, + "mac_formatted": fmt_mac(mac), + "account": paying[acct_id], + "item_id": info["item_id"], + "model": info["model"], + }) + + return { + "mismatches": mismatches, + "unknowns": unknowns, + "fixable": fixable, + "exists_assigned": exists_assigned, + "needs_creation": needs_creation, + "unassigned": unassigned, + "unmatched_host": unmatched_host, + "orphans": orphans, + "ok": ok_entries, + "mac_to_any": mac_to_any, + } + + +# ---------- output ---------- + +def print_report(results, verbose=False): + m = results["mismatches"] + u = results["unknowns"] + fixable = results["fixable"] + exists_assigned = results["exists_assigned"] + needs_creation = results["needs_creation"] + na = results["unassigned"] + uh = results["unmatched_host"] + orph = results["orphans"] + stale = results.get("stale_orphans", []) + ok = results["ok"] + + print(f"\n=== CPE MAC AUDIT ===") + print(f" {len(ok)} OK | {len(m)} MAC_MISMATCH | {len(u)} MAC_UNKNOWN | " + f"{len(orph)} GAIIA_ORPHAN | {len(stale)} STALE") + print(f" ({len(fixable)} fixable | {len(exists_assigned)} conflict | " + f"{len(needs_creation)} needs-creation)") + print(f" ({len(na)} unassigned | {len(uh)} unmatched host)") + + if m: + print(f"\n--- {len(m)} MAC MISMATCH (DHCP host-name matches account A, " + f"but lease MAC belongs to account B) ---") + for e in sorted(m, key=lambda x: (x["lease"]["router"], x["lease"]["address"])): + l = e["lease"] + g_owner = e["gaiia_mac_owner"] + h_match = e["host_match"] + print(f" {l['router']:9} {l['address']:16} {fmt_mac(l['mac']):17} " + f"host={l['host']!r}") + print(f" -> host suggests: #{h_match['readable']} {h_match['name']!r} " + f"(paying={h_match['paying']})") + print(f" -> MAC belongs to: #{g_owner['readable']} {g_owner['name']!r} " + f"(paying={g_owner['paying']})") + + if fixable: + print(f"\n--- {len(fixable)} FIXABLE (MAC exists in Gaiia — unassigned or parked " + f"at a location — auto-fix with --fix) ---") + for e in sorted(fixable, key=lambda x: (x["lease"]["router"], x["lease"]["address"])): + l = e["lease"] + h = e["host_match"] + item = e["existing_item"] + cur = item.get("assignee_type") or "UNASSIGNED" + print(f" {l['router']:9} {l['address']:16} {fmt_mac(l['mac']):17} " + f"host={l['host']!r}") + print(f" -> assign to: #{h['readable']} {h['name']!r} " + f"item={item['item_id']} model={item['model']} currently={cur}") + + if exists_assigned: + print(f"\n--- {len(exists_assigned)} CONFLICT (MAC exists in Gaiia but assigned to different account) ---") + for e in sorted(exists_assigned, key=lambda x: (x["lease"]["router"], x["lease"]["address"])): + l = e["lease"] + h = e["host_match"] + item = e["existing_item"] + owner_name = e.get("existing_owner_name") or "unassigned" + print(f" {l['router']:9} {l['address']:16} {fmt_mac(l['mac']):17} " + f"host={l['host']!r}") + print(f" -> host suggests: #{h['readable']} {h['name']!r}") + print(f" -> item {item['item_id']} ({item['model']}) already assigned to: {owner_name!r}") + + if needs_creation: + print(f"\n--- {len(needs_creation)} NEEDS CREATION (MAC not in Gaiia — " + f"add device and assign to account) ---") + for e in sorted(needs_creation, key=lambda x: (x["lease"]["router"], x["lease"]["address"])): + l = e["lease"] + h = e["host_match"] + dev = guess_device(l["mac"]) + print(f" router={l['router']:9} ip={l['address']:16} " + f"mac={fmt_mac(l['mac']):17} device={dev}") + print(f" account=#{h['readable']} name={h['name']!r} " + f"host={l['host']!r}") + + if orph: + print(f"\n--- {len(orph)} GAIIA ORPHAN (not in DHCP, tower may be unreachable) ---") + for e in sorted(orph, key=lambda x: x["account"]["name"]): + a = e["account"] + print(f" {e['mac_formatted']:17} #{a['readable']:6} {a['name']!r} " + f"model={e['model']} item={e['item_id']}") + + if stale: + print(f"\n--- {len(stale)} STALE (on reachable tower but not in DHCP — " + f"unassign with --fix) ---") + for e in sorted(stale, key=lambda x: x["account"]["name"]): + a = e["account"] + print(f" {e['mac_formatted']:17} #{a['readable']:6} {a['name']!r} " + f"model={e['model']} item={e['item_id']}") + + if na: + print(f"\n--- {len(na)} UNASSIGNED (MAC in Gaiia inventory but not assigned to any account) ---") + for e in sorted(na, key=lambda x: (x["lease"]["router"], x["lease"]["address"])): + l = e["lease"] + print(f" {l['router']:9} {l['address']:16} {fmt_mac(l['mac']):17} " + f"host={l['host']!r}") + + if uh: + print(f"\n--- {len(uh)} UNMATCHED HOST (MAC assigned in Gaiia, host-name doesn't match any account) ---") + for e in sorted(uh, key=lambda x: (x["lease"]["router"], x["lease"]["address"])): + l = e["lease"] + g = e["gaiia_mac_owner"] + print(f" {l['router']:9} {l['address']:16} {fmt_mac(l['mac']):17} " + f"host={l['host']!r}") + print(f" -> MAC belongs to: #{g['readable']} {g['name']!r} (paying={g['paying']})") + + if verbose and ok: + print(f"\n--- {len(ok)} OK (MAC matches expected account) ---") + for e in sorted(ok, key=lambda x: (x["lease"]["router"], x["lease"]["address"])): + l = e["lease"] + a = e["gaiia_mac_owner"] + print(f" {l['router']:9} {l['address']:16} {fmt_mac(l['mac']):17} " + f"host={l['host']!r} acct=#{a['readable']} {a['name']!r}") + + if not any([m, u, orph, stale, na, uh]): + print("\nNo issues found.") + + +def json_report(results): + def strip(obj): + if isinstance(obj, dict): + return {k: strip(v) for k, v in obj.items()} + if isinstance(obj, list): + return [strip(v) for v in obj] + return obj + + out = { + "summary": { + "ok": len(results["ok"]), + "mac_mismatch": len(results["mismatches"]), + "mac_unknown": len(results["unknowns"]), + "fixable": len(results["fixable"]), + "exists_assigned": len(results["exists_assigned"]), + "needs_creation": len(results["needs_creation"]), + "gaiia_orphan": len(results["orphans"]), + "unassigned": len(results["unassigned"]), + "unmatched_host": len(results["unmatched_host"]), + }, + "mismatches": [ + { + "lease": {**e["lease"], "mac": fmt_mac(e["lease"]["mac"])}, + "gaiia_mac_owner": e["gaiia_mac_owner"], + "host_match": e["host_match"], + } + for e in results["mismatches"] + ], + "fixable": [ + { + "lease": {**e["lease"], "mac": fmt_mac(e["lease"]["mac"])}, + "host_match": e["host_match"], + "existing_item": e["existing_item"], + } + for e in results["fixable"] + ], + "exists_assigned": [ + { + "lease": {**e["lease"], "mac": fmt_mac(e["lease"]["mac"])}, + "host_match": e["host_match"], + "existing_item": e["existing_item"], + "existing_owner_name": e.get("existing_owner_name"), + } + for e in results["exists_assigned"] + ], + "needs_creation": [ + { + "lease": {**e["lease"], "mac": fmt_mac(e["lease"]["mac"])}, + "host_match": e["host_match"], + "device_guess": guess_device(e["lease"]["mac"]), + } + for e in results["needs_creation"] + ], + "orphans": results["orphans"], + "unassigned": [ + { + "lease": {**e["lease"], "mac": fmt_mac(e["lease"]["mac"])}, + } + for e in results["unassigned"] + ], + "unmatched_host": [ + { + "lease": {**e["lease"], "mac": fmt_mac(e["lease"]["mac"])}, + "gaiia_mac_owner": e["gaiia_mac_owner"], + } + for e in results["unmatched_host"] + ], + } + json.dump(strip(out), sys.stdout, indent=2, default=str) + + +# ---------- main ---------- + +def do_fixes(client, fixable): + """Call assignInventoryItem for each fixable entry.""" + mut = """mutation M($i: AssignInventoryItemInput!) { + assignInventoryItem(input: $i) { + errors { code message } + } + }""" + ok = 0 + for e in fixable: + h = e["host_match"] + item = e["existing_item"] + l = e["lease"] + try: + client.mutate(mut, "assignInventoryItem", + {"i": {"inventoryItemId": item["item_id"], + "assigneeType": "ACCOUNT", + "assigneeId": h["id"]}}) + print(f" assigned {item['item_id']} ({item['model']}) -> #{h['readable']} " + f"{h['name']!r} [MAC {fmt_mac(l['mac'])}]", file=sys.stderr) + ok += 1 + except Exception as exc: + print(f" FAILED {item['item_id']}: {exc}", file=sys.stderr) + return ok + + +def do_reassigns(client, conflicts): + """Reassign inventory items from the wrong account to the matched account.""" + mut = """mutation M($i: AssignInventoryItemInput!) { + assignInventoryItem(input: $i) { + errors { code message } + } + }""" + ok = 0 + for e in conflicts: + h = e["host_match"] + item = e["existing_item"] + l = e["lease"] + old = e.get("existing_owner_name") or "unknown" + try: + client.mutate(mut, "assignInventoryItem", + {"i": {"inventoryItemId": item["item_id"], + "assigneeType": "ACCOUNT", + "assigneeId": h["id"]}}) + print(f" reassigned {item['item_id']} ({item['model']}) " + f"from {old!r} -> #{h['readable']} {h['name']!r} " + f"[MAC {fmt_mac(l['mac'])}]", file=sys.stderr) + ok += 1 + except Exception as exc: + print(f" FAILED {item['item_id']}: {exc}", file=sys.stderr) + return ok + + +def do_unassigns(client, stale_orphans, leases, accounts): + """Reassign stale CPEs to their tower's inventory location.""" + # Fetch all inventory locations + locs = {} + q_loc = """query { inventoryLocations(first: 100) { + nodes { id name } + pageInfo { hasNextPage endCursor } + }}""" + r = client.query(q_loc) + for loc in (r.get("inventoryLocations") or {}).get("nodes") or []: + locs[loc["name"].lower()] = loc["id"] + + # Map account ID -> router from DHCP leases + acct_to_router: dict[str, str] = {} + for l in leases: + host_match = fuzzy_match_host(l["host"], accounts) + if host_match and host_match["paying"]: + if host_match["id"] not in acct_to_router: + acct_to_router[host_match["id"]] = l["router"] + + # Map router -> location (fuzzy: router name should match location name) + # Known mappings from the fleet + ROUTER_TO_LOC: dict[str, str] = { + "verona": "verona", "climax": "climax", "culleoka": "culleoka", + "newhope": "newhope", "lowry": "lowrycrossing", "982": "982", + "494": "494", "core": "380", + } + + mut = """mutation M($i: AssignInventoryItemInput!) { + assignInventoryItem(input: $i) { + errors { code message } + } + }""" + ok = 0 + for o in stale_orphans: + acct_id = o["account"]["id"] + router = acct_to_router.get(acct_id) + loc_key = ROUTER_TO_LOC.get(router) if router else None + loc_id = locs.get(loc_key) if loc_key else None + + if not loc_id: + print(f" SKIP {o['item_id']} ({o['model']}): " + f"can't determine location (router={router})", + file=sys.stderr) + continue + try: + client.mutate(mut, "assignInventoryItem", + {"i": {"inventoryItemId": o["item_id"], + "assigneeType": "INVENTORY_LOCATION", + "assigneeId": loc_id}}) + a = o["account"] + print(f" unassigned {o['item_id']} ({o['model']}) from #{a['readable']} " + f"{a['name']!r} -> {loc_key}", + file=sys.stderr) + ok += 1 + except Exception as exc: + print(f" FAILED {o['item_id']}: {exc}", file=sys.stderr) + return ok + + +def main(): + ap = argparse.ArgumentParser(description="Audit CPE MACs: Gaiia vs live DHCP leases") + ap.add_argument("--routers", help="comma-separated subset of tower routers") + ap.add_argument("--json", action="store_true", help="emit machine-readable JSON") + ap.add_argument("--verbose", "-v", action="store_true", help="also show OK entries") + ap.add_argument("--fix", action="store_true", + help="auto-assign unassigned inventory items to matched accounts") + args = ap.parse_args() + + if not os.environ.get("GAIIA_KEY"): + sys.exit("GAIIA_KEY not set") + + routers = args.routers.split(",") if args.routers else TOWER_ROUTERS + + print("Fetching Gaiia accounts + inventory...", file=sys.stderr) + accounts, mac_to_cpe, mac_to_any = fetch_gaiia() + paying = sum(1 for a in accounts.values() if a["paying"]) + cpe_assigned = sum(1 for v in mac_to_cpe.values() if v.get("account_id")) + print(f" {len(accounts)} accounts ({paying} paying), " + f"{len(mac_to_cpe)} outdoor CPEs in inventory ({cpe_assigned} account-assigned), " + f"{len(mac_to_any)} total items", + file=sys.stderr) + + print(f"Fetching DHCP leases from {len(routers)} routers...", file=sys.stderr) + with ThreadPoolExecutor(max_workers=len(routers)) as ex: + results_list = list(ex.map(fetch_dhcp_leases, routers)) + lease_lists = [r[0] for r in results_list] + reachable = {router: ok for router, (_, ok) in zip(routers, results_list)} + leases = [l for sub in lease_lists for l in sub] + print(f" {len(leases)} bound leases in 10.10.0.0/16 " + f"({sum(1 for v in reachable.values() if not v)} unreachable)", + file=sys.stderr) + + results = audit(leases, accounts, mac_to_cpe, mac_to_any) + + # Separate orphans into stale (on reachable towers) vs unknown + # An orphan is stale if the account has at least one DHCP lease on a reachable router. + accts_with_leases = {e["host_match"]["id"] for e in results["ok"] + results["mismatches"] + if e.get("host_match")} + accts_with_leases |= {e["host_match"]["id"] for e in results["unknowns"] + if e.get("host_match")} + # Also include account IDs from unmatched_host (MAC is in Gaiia, assigned) + for e in results["unmatched_host"]: + if e.get("gaiia_mac_owner"): + accts_with_leases.add(e["gaiia_mac_owner"]["id"]) + + stale_orphans = [o for o in results["orphans"] if o["account"]["id"] in accts_with_leases] + unknown_orphans = [o for o in results["orphans"] if o["account"]["id"] not in accts_with_leases] + results["stale_orphans"] = stale_orphans + results["unknown_orphans"] = unknown_orphans + # Replace orphans with unknown-only for display + results["orphans"] = unknown_orphans + + if args.fix: + with GaiiaClient(timezone="America/Chicago") as g: + if results["fixable"]: + print(f"\nApplying {len(results['fixable'])} assignments...", file=sys.stderr) + n = do_fixes(g, results["fixable"]) + print(f" {n}/{len(results['fixable'])} assigned", file=sys.stderr) + + if results["exists_assigned"]: + print(f"\nReassigning {len(results['exists_assigned'])} conflicting items...", + file=sys.stderr) + n = do_reassigns(g, results["exists_assigned"]) + print(f" {n}/{len(results['exists_assigned'])} reassigned", file=sys.stderr) + + if stale_orphans: + print(f"\nUnassigning {len(stale_orphans)} stale CPEs " + f"(on reachable towers but not in DHCP)...", file=sys.stderr) + n = do_unassigns(g, stale_orphans, leases, accounts) + print(f" {n}/{len(stale_orphans)} unassigned", file=sys.stderr) + + if args.json: + json_report(results) + else: + print_report(results, verbose=args.verbose) + + +if __name__ == "__main__": + main() diff --git a/scripts/freeloaders-snapshot-2026-05-11.json b/scripts/freeloaders-snapshot-2026-05-11.json new file mode 100644 index 0000000..93efb81 --- /dev/null +++ b/scripts/freeloaders-snapshot-2026-05-11.json @@ -0,0 +1,1267 @@ +{ + "summary": { + "accounts": 455, + "paying_accounts": 297, + "bound_leases_total": 452, + "bound_leases_customer_pools": 145, + "unmatched_paying_by_name": 38, + "unmatched_unpaid_by_name": 0, + "unmatched_unknown": 17, + "unassigned_in_gaiia": 4, + "unpaid_account": 6 + }, + "unmatched_paying": [ + { + "lease": { + "router": "verona", + "mac": "70a7414a3c3e", + "mac_raw": "70:A7:41:4A:3C:3E", + "address": "10.10.0.5", + "host": "Joey Whitfield", + "server": "verona-cpe", + "comment": "" + }, + "account": { + "readable": 187, + "name": "Joey Whitfield", + "status": "Active", + "paying": true, + "sub_statuses": [ + "ACTIVE" + ], + "id": "account_8bxr54hmvSEFRyDLfvCgie", + "match_score": 2 + } + }, + { + "lease": { + "router": "culleoka", + "mac": "58c17aac6946", + "mac_raw": "58:C1:7A:AC:69:46", + "address": "10.10.96.17", + "host": "Ray Schaak", + "server": "cpe", + "comment": "" + }, + "account": { + "readable": 2190, + "name": "Ray Schaak", + "status": "Active", + "paying": true, + "sub_statuses": [ + "ACTIVE" + ], + "id": "account_qhFf5wpTsv3FySr63biaRp", + "match_score": 2 + } + }, + { + "lease": { + "router": "culleoka", + "mac": "0004562ec16e", + "mac_raw": "00:04:56:2E:C1:6E", + "address": "10.10.96.2", + "host": "Jerry Johnson", + "server": "cpe", + "comment": "" + }, + "account": { + "readable": 177, + "name": "JERRY L JOHNSON", + "status": "Active", + "paying": true, + "sub_statuses": [ + "CANCELLED", + "ACTIVE" + ], + "id": "account_fa8emiMvGC9ZkdA6uuwMpF", + "match_score": 2 + } + }, + { + "lease": { + "router": "culleoka", + "mac": "0004562fd4bd", + "mac_raw": "00:04:56:2F:D4:BD", + "address": "10.10.96.29", + "host": "Paul Sammataro", + "server": "cpe", + "comment": "" + }, + "account": { + "readable": 283, + "name": "Paul Sammataro", + "status": "Active", + "paying": true, + "sub_statuses": [ + "ACTIVE" + ], + "id": "account_cwYTP5p9ubFdXioqnmuDsy", + "match_score": 2 + } + }, + { + "lease": { + "router": "culleoka", + "mac": "000456dc6759", + "mac_raw": "00:04:56:DC:67:59", + "address": "10.10.96.72", + "host": "Terri Mack", + "server": "cpe", + "comment": "" + }, + "account": { + "readable": 1467, + "name": "Terri Mack", + "status": "Active", + "paying": true, + "sub_statuses": [ + "ACTIVE" + ], + "id": "account_wU2qgfTxqjaLSfhwWx4Cxm", + "match_score": 2 + } + }, + { + "lease": { + "router": "culleoka", + "mac": "000456dc59a6", + "mac_raw": "00:04:56:DC:59:A6", + "address": "10.10.96.73", + "host": "Ausencio Luna", + "server": "cpe", + "comment": "" + }, + "account": { + "readable": 427, + "name": "Ausencio Luna", + "status": "Active", + "paying": true, + "sub_statuses": [ + "ACTIVE" + ], + "id": "account_7Gj6TBTXHmpu49iZGZBYT4", + "match_score": 2 + } + }, + { + "lease": { + "router": "culleoka", + "mac": "0004564863ef", + "mac_raw": "00:04:56:48:63:EF", + "address": "10.10.96.14", + "host": "Tamy Thomas", + "server": "cpe", + "comment": "" + }, + "account": { + "readable": 587, + "name": "Tamy Thomas", + "status": "Active", + "paying": true, + "sub_statuses": [ + "ACTIVE", + "ACTIVE" + ], + "id": "account_4gF4CqD6W7iS3UesE6js3g", + "match_score": 2 + } + }, + { + "lease": { + "router": "culleoka", + "mac": "58c17ab0f339", + "mac_raw": "58:C1:7A:B0:F3:39", + "address": "10.10.96.42", + "host": "Tiffany Stewart", + "server": "cpe", + "comment": "" + }, + "account": { + "readable": 2335, + "name": "Tiffany Stewart", + "status": "Active", + "paying": true, + "sub_statuses": [ + "ACTIVE", + "ACTIVE" + ], + "id": "account_h3oACsArSoq5fZ6vRyH9Br", + "match_score": 2 + } + }, + { + "lease": { + "router": "culleoka", + "mac": "000456dc736b", + "mac_raw": "00:04:56:DC:73:6B", + "address": "10.10.96.50", + "host": "Irene Garibay", + "server": "cpe", + "comment": "" + }, + "account": { + "readable": 10027, + "name": "Irene Garibay", + "status": "Active", + "paying": true, + "sub_statuses": [ + "CANCELLED", + "CANCELLED", + "ACTIVE", + "ACTIVE", + "CANCELLED" + ], + "id": "account_ctACFtZH9mDX1E9tVgkpzy", + "match_score": 2 + } + }, + { + "lease": { + "router": "culleoka", + "mac": "58c17aad1a91", + "mac_raw": "58:C1:7A:AD:1A:91", + "address": "10.10.96.3", + "host": "Howard Comstock", + "server": "cpe", + "comment": "" + }, + "account": { + "readable": 156, + "name": "Howard Comstock", + "status": "Active", + "paying": true, + "sub_statuses": [ + "ACTIVE", + "ACTIVE" + ], + "id": "account_kZMVM4zfKdgEAyCCvVREU8", + "match_score": 2 + } + }, + { + "lease": { + "router": "culleoka", + "mac": "0004564866e3", + "mac_raw": "00:04:56:48:66:E3", + "address": "10.10.96.9", + "host": "Janis Stewart", + "server": "cpe", + "comment": "" + }, + "account": { + "readable": 10024, + "name": "Janis Stewart", + "status": "Active", + "paying": true, + "sub_statuses": [ + "ACTIVE", + "ACTIVE" + ], + "id": "account_fztV7NVRixwgnD6n392ZfX", + "match_score": 2 + } + }, + { + "lease": { + "router": "culleoka", + "mac": "58c17aac5f11", + "mac_raw": "58:C1:7A:AC:5F:11", + "address": "10.10.96.5", + "host": "Shirley Elmore", + "server": "cpe", + "comment": "" + }, + "account": { + "readable": 1538, + "name": "Shirley Elmore", + "status": "Active", + "paying": true, + "sub_statuses": [ + "ACTIVE", + "ACTIVE" + ], + "id": "account_gfPawVk3gSuvufuC4zVjx9", + "match_score": 2 + } + }, + { + "lease": { + "router": "culleoka", + "mac": "0004562fdba1", + "mac_raw": "00:04:56:2F:DB:A1", + "address": "10.10.96.11", + "host": "Treceda Schmid", + "server": "cpe", + "comment": "" + }, + "account": { + "readable": 355, + "name": "Treceda Schmid", + "status": "Active", + "paying": true, + "sub_statuses": [ + "ACTIVE" + ], + "id": "account_iR4oM82C6m8zkrFDLkSbYZ", + "match_score": 2 + } + }, + { + "lease": { + "router": "culleoka", + "mac": "0004564a15b5", + "mac_raw": "00:04:56:4A:15:B5", + "address": "10.10.96.95", + "host": "Jeffrey Harder", + "server": "cpe", + "comment": "" + }, + "account": { + "readable": 10073, + "name": "Jeffrey Harder", + "status": "Active", + "paying": true, + "sub_statuses": [ + "ACTIVE", + "ACTIVE" + ], + "id": "account_gWD9gTNmR8RbzQkJmjpRYy", + "match_score": 2 + } + }, + { + "lease": { + "router": "culleoka", + "mac": "000456db9940", + "mac_raw": "00:04:56:DB:99:40", + "address": "10.10.96.13", + "host": "David Roesch", + "server": "cpe", + "comment": "" + }, + "account": { + "readable": 2407, + "name": "David Roesch", + "status": "Active", + "paying": true, + "sub_statuses": [ + "ACTIVE" + ], + "id": "account_bHVKB5E3eJFmbqnDwXAxFw", + "match_score": 2 + } + }, + { + "lease": { + "router": "culleoka", + "mac": "000456dc6b55", + "mac_raw": "00:04:56:DC:6B:55", + "address": "10.10.96.53", + "host": "Khushboo Agarwal", + "server": "cpe", + "comment": "" + }, + "account": { + "readable": 10052, + "name": "Khushboo Agarwal", + "status": "Active", + "paying": true, + "sub_statuses": [ + "ACTIVE", + "ACTIVE", + "ACTIVE" + ], + "id": "account_5xtd1ApTppgpHnRpvmQHmV", + "match_score": 2 + } + }, + { + "lease": { + "router": "culleoka", + "mac": "58c17ab10263", + "mac_raw": "58:C1:7A:B1:02:63", + "address": "10.10.96.10", + "host": "Aurea Sanchez", + "server": "cpe", + "comment": "" + }, + "account": { + "readable": 192, + "name": "Aurea Sanchez", + "status": "Active", + "paying": true, + "sub_statuses": [ + "ACTIVE" + ], + "id": "account_4UYtEvUy4GPzbiSjrjM6Sz", + "match_score": 2 + } + }, + { + "lease": { + "router": "culleoka", + "mac": "000456dc6c9f", + "mac_raw": "00:04:56:DC:6C:9F", + "address": "10.10.96.67", + "host": "Jeff Harder", + "server": "cpe", + "comment": "" + }, + "account": { + "readable": 293, + "name": "Jeff Harder", + "status": "Active", + "paying": true, + "sub_statuses": [ + "ACTIVE" + ], + "id": "account_p1be3wWBz7jjjLkymaZPMy", + "match_score": 2 + } + }, + { + "lease": { + "router": "culleoka", + "mac": "0004562fda84", + "mac_raw": "00:04:56:2F:DA:84", + "address": "10.10.96.39", + "host": "Gabriela Sereno", + "server": "cpe", + "comment": "" + }, + "account": { + "readable": 1908, + "name": "Gabriela Sereno", + "status": "Active", + "paying": true, + "sub_statuses": [ + "ACTIVE", + "ACTIVE" + ], + "id": "account_99RoiQcJpubSAqajPZjxFf", + "match_score": 2 + } + }, + { + "lease": { + "router": "culleoka", + "mac": "58c17ab0b61f", + "mac_raw": "58:C1:7A:B0:B6:1F", + "address": "10.10.96.58", + "host": "Patricia Vasquez", + "server": "cpe", + "comment": "" + }, + "account": { + "readable": 1128, + "name": "Patricia Vasquez", + "status": "Active", + "paying": true, + "sub_statuses": [ + "ACTIVE", + "ACTIVE" + ], + "id": "account_6wngyAj64jCwvn3W8T3sdT", + "match_score": 2 + } + }, + { + "lease": { + "router": "culleoka", + "mac": "0004564a1753", + "mac_raw": "00:04:56:4A:17:53", + "address": "10.10.96.36", + "host": "Pablo Martinez", + "server": "cpe", + "comment": "" + }, + "account": { + "readable": 10074, + "name": "Pablo Martinez", + "status": "Active", + "paying": true, + "sub_statuses": [ + "ACTIVE" + ], + "id": "account_g2THEPVu4zgnGsTeFh6MpF", + "match_score": 2 + } + }, + { + "lease": { + "router": "culleoka", + "mac": "58c17ab0d70d", + "mac_raw": "58:C1:7A:B0:D7:0D", + "address": "10.10.96.65", + "host": "Jerid Rutledge", + "server": "cpe", + "comment": "" + }, + "account": { + "readable": 1601, + "name": "Jerid Rutledge", + "status": "Active", + "paying": true, + "sub_statuses": [ + "ACTIVE" + ], + "id": "account_v6odXP1nVxoVvTMUrUn3SS", + "match_score": 2 + } + }, + { + "lease": { + "router": "culleoka", + "mac": "0004562eb76c", + "mac_raw": "00:04:56:2E:B7:6C", + "address": "10.10.96.41", + "host": "Iris Delira", + "server": "cpe", + "comment": "" + }, + "account": { + "readable": 1316, + "name": "Iris Delira", + "status": "Active", + "paying": true, + "sub_statuses": [ + "ACTIVE", + "ACTIVE" + ], + "id": "account_mVgspPEmufZG8KD83Cnyjh", + "match_score": 2 + } + }, + { + "lease": { + "router": "culleoka", + "mac": "58c17ab306a8", + "mac_raw": "58:C1:7A:B3:06:A8", + "address": "10.10.96.7", + "host": "Ken Patrick", + "server": "cpe", + "comment": "" + }, + "account": { + "readable": 875, + "name": "Ken Patrick", + "status": "Active", + "paying": true, + "sub_statuses": [ + "ACTIVE" + ], + "id": "account_omX4bSM3rXDShahsdc4aHd", + "match_score": 2 + } + }, + { + "lease": { + "router": "culleoka", + "mac": "000456dbe9fe", + "mac_raw": "00:04:56:DB:E9:FE", + "address": "10.10.96.38", + "host": "Thomas Hernandez", + "server": "cpe", + "comment": "" + }, + "account": { + "readable": 2479, + "name": "Thomas Hernandez", + "status": "Active", + "paying": true, + "sub_statuses": [ + "ACTIVE", + "ACTIVE" + ], + "id": "account_wL9tsaty83QCJpNGNXeSzZ", + "match_score": 2 + } + }, + { + "lease": { + "router": "culleoka", + "mac": "00045644540a", + "mac_raw": "00:04:56:44:54:0A", + "address": "10.10.96.54", + "host": "Heather Womack", + "server": "cpe", + "comment": "" + }, + "account": { + "readable": 2046, + "name": "HEATHER WOMACK", + "status": "Active", + "paying": true, + "sub_statuses": [ + "ACTIVE", + "ACTIVE" + ], + "id": "account_kSE9uAW6WPbt4msXe4mzHs", + "match_score": 2 + } + }, + { + "lease": { + "router": "culleoka", + "mac": "58c17aac625f", + "mac_raw": "58:C1:7A:AC:62:5F", + "address": "10.10.96.102", + "host": "Wanda Davis", + "server": "cpe", + "comment": "" + }, + "account": { + "readable": 2206, + "name": "Wanda Davis", + "status": "Active", + "paying": true, + "sub_statuses": [ + "ACTIVE", + "ACTIVE" + ], + "id": "account_iy49cEMsdawi18rTXarw73", + "match_score": 2 + } + }, + { + "lease": { + "router": "culleoka", + "mac": "000456dbc211", + "mac_raw": "00:04:56:DB:C2:11", + "address": "10.10.96.77", + "host": "Balnca Diaz", + "server": "cpe", + "comment": "" + }, + "account": { + "readable": 433, + "name": "Balnca Diaz", + "status": "Active", + "paying": true, + "sub_statuses": [ + "ACTIVE" + ], + "id": "account_ktdNSpKRV3ja7RMi7pZRBB", + "match_score": 2 + } + }, + { + "lease": { + "router": "culleoka", + "mac": "000456dbc22c", + "mac_raw": "00:04:56:DB:C2:2C", + "address": "10.10.96.26", + "host": "Jody McCluskey", + "server": "cpe", + "comment": "" + }, + "account": { + "readable": 497, + "name": "Jody McCluskey", + "status": "Active", + "paying": true, + "sub_statuses": [ + "ACTIVE" + ], + "id": "account_35QGS4ZHr9tSpCVKNHE1ZT", + "match_score": 2 + } + }, + { + "lease": { + "router": "culleoka", + "mac": "58c17aad1785", + "mac_raw": "58:C1:7A:AD:17:85", + "address": "10.10.96.45", + "host": "Angelica Cuayahuitl", + "server": "cpe", + "comment": "" + }, + "account": { + "readable": 2043, + "name": "Angelica Cuayahuitl", + "status": "Active", + "paying": true, + "sub_statuses": [ + "ACTIVE", + "ACTIVE" + ], + "id": "account_sooioQ729DC21LJa1zKMAN", + "match_score": 2 + } + }, + { + "lease": { + "router": "culleoka", + "mac": "58c17ab308b8", + "mac_raw": "58:C1:7A:B3:08:B8", + "address": "10.10.96.91", + "host": "Michael Newby", + "server": "cpe", + "comment": "" + }, + "account": { + "readable": 623, + "name": "Michael Newby", + "status": "Active", + "paying": true, + "sub_statuses": [ + "ACTIVE" + ], + "id": "account_1moqtbSRAJxiVR2pTtH3f9", + "match_score": 2 + } + }, + { + "lease": { + "router": "culleoka", + "mac": "000456dbe5ed", + "mac_raw": "00:04:56:DB:E5:ED", + "address": "10.10.96.40", + "host": "Tim Bronschidle", + "server": "cpe", + "comment": "" + }, + "account": { + "readable": 1788, + "name": "Tim Bronschidle", + "status": "Active", + "paying": true, + "sub_statuses": [ + "ACTIVE" + ], + "id": "account_o6gDBbdWzQYeUbAQL2Zp1s", + "match_score": 2 + } + }, + { + "lease": { + "router": "culleoka", + "mac": "58c17aac6319", + "mac_raw": "58:C1:7A:AC:63:19", + "address": "10.10.96.46", + "host": "Kileigh Even", + "server": "cpe", + "comment": "" + }, + "account": { + "readable": 215, + "name": "Kileigh Even", + "status": "Active", + "paying": true, + "sub_statuses": [ + "ACTIVE" + ], + "id": "account_4aPqi935STSGBSMxsLNwSm", + "match_score": 2 + } + }, + { + "lease": { + "router": "culleoka", + "mac": "0004562fce8d", + "mac_raw": "00:04:56:2F:CE:8D", + "address": "10.10.96.35", + "host": "Yolanda Judge", + "server": "cpe", + "comment": "" + }, + "account": { + "readable": 2375, + "name": "Yolanda Judge", + "status": "Active", + "paying": true, + "sub_statuses": [ + "ACTIVE", + "ACTIVE" + ], + "id": "account_cWR4qvSM1SepG3DuoD8rUz", + "match_score": 2 + } + }, + { + "lease": { + "router": "culleoka", + "mac": "0004564452c6", + "mac_raw": "00:04:56:44:52:C6", + "address": "10.10.96.27", + "host": "Syeda Haque", + "server": "cpe", + "comment": "" + }, + "account": { + "readable": 10048, + "name": "Syeda Haque", + "status": "Active", + "paying": true, + "sub_statuses": [ + "ACTIVE" + ], + "id": "account_4hoTCjwTatthCg7zp2Cnfe", + "match_score": 2 + } + }, + { + "lease": { + "router": "culleoka", + "mac": "0004564450aa", + "mac_raw": "00:04:56:44:50:AA", + "address": "10.10.96.55", + "host": "Billy Horton", + "server": "cpe", + "comment": "" + }, + "account": { + "readable": 2023, + "name": "Billy Horton", + "status": "Active", + "paying": true, + "sub_statuses": [ + "ACTIVE", + "ACTIVE" + ], + "id": "account_4DuPXfEqx5pRPftTGKkbhS", + "match_score": 2 + } + }, + { + "lease": { + "router": "culleoka", + "mac": "000456dc6d77", + "mac_raw": "00:04:56:DC:6D:77", + "address": "10.10.96.84", + "host": "Bobby Mack", + "server": "cpe", + "comment": "" + }, + "account": { + "readable": 1712, + "name": "Bobby Mack", + "status": "Active", + "paying": true, + "sub_statuses": [ + "ACTIVE", + "ACTIVE" + ], + "id": "account_nNUBZtEptfAD6TpDteqjfM", + "match_score": 2 + } + }, + { + "lease": { + "router": "culleoka", + "mac": "000456dbeaa0", + "mac_raw": "00:04:56:DB:EA:A0", + "address": "10.10.96.33", + "host": "Anthony Longo", + "server": "cpe", + "comment": "" + }, + "account": { + "readable": 2487, + "name": "Anthony Longo", + "status": "Active", + "paying": true, + "sub_statuses": [ + "ACTIVE", + "ACTIVE" + ], + "id": "account_eq2Po3eJPoEyv6RvhhyBMA", + "match_score": 2 + } + } + ], + "unmatched_unpaid": [], + "unmatched_unknown": [ + { + "router": "verona", + "mac": "245a4cd87f27", + "mac_raw": "24:5A:4C:D8:7F:27", + "address": "10.10.0.19", + "host": "Lyndon Gibson", + "server": "verona-cpe", + "comment": "" + }, + { + "router": "verona", + "mac": "ec5c68286dd1", + "mac_raw": "EC:5C:68:28:6D:D1", + "address": "100.64.0.9", + "host": "", + "server": "verona cgnat", + "comment": "" + }, + { + "router": "verona", + "mac": "14dda9c9ca48", + "mac_raw": "14:DD:A9:C9:CA:48", + "address": "100.64.0.79", + "host": "", + "server": "verona cgnat", + "comment": "" + }, + { + "router": "verona", + "mac": "788a20acc42c", + "mac_raw": "78:8A:20:AC:C4:2C", + "address": "10.10.0.34", + "host": "BB Fence", + "server": "verona-cpe", + "comment": "" + }, + { + "router": "verona", + "mac": "70a7414a3baf", + "mac_raw": "70:A7:41:4A:3B:AF", + "address": "10.10.0.29", + "host": "Pam Banschbach", + "server": "verona-cpe", + "comment": "" + }, + { + "router": "verona", + "mac": "ec13b293f0f4", + "mac_raw": "EC:13:B2:93:F0:F4", + "address": "100.64.0.2", + "host": "Altoga_Netonix_Switch", + "server": "verona cgnat", + "comment": "" + }, + { + "router": "verona", + "mac": "0862663b9fd0", + "mac_raw": "08:62:66:3B:9F:D0", + "address": "100.64.0.82", + "host": "", + "server": "verona cgnat", + "comment": "" + }, + { + "router": "verona", + "mac": "40b0764f5954", + "mac_raw": "40:B0:76:4F:59:54", + "address": "100.64.0.62", + "host": "", + "server": "verona cgnat", + "comment": "" + }, + { + "router": "verona", + "mac": "04d4c438b6b0", + "mac_raw": "04:D4:C4:38:B6:B0", + "address": "100.64.0.76", + "host": "", + "server": "verona cgnat", + "comment": "" + }, + { + "router": "verona", + "mac": "c87f542749d8", + "mac_raw": "C8:7F:54:27:49:D8", + "address": "100.64.0.43", + "host": "", + "server": "verona cgnat", + "comment": "" + }, + { + "router": "culleoka", + "mac": "f4e2c690f32d", + "mac_raw": "F4:E2:C6:90:F3:2D", + "address": "10.10.96.64", + "host": "Kushboo Agarwal", + "server": "cpe", + "comment": "" + }, + { + "router": "culleoka", + "mac": "0004562fdc28", + "mac_raw": "00:04:56:2F:DC:28", + "address": "10.10.96.106", + "host": "Irvin Borjas", + "server": "cpe", + "comment": "" + }, + { + "router": "culleoka", + "mac": "00045644f5d7", + "mac_raw": "00:04:56:44:F5:D7", + "address": "10.10.96.16", + "host": "Summer Beasley", + "server": "cpe", + "comment": "" + }, + { + "router": "culleoka", + "mac": "58c17aad1bcf", + "mac_raw": "58:C1:7A:AD:1B:CF", + "address": "10.10.96.62", + "host": "Ryan Rodriguez", + "server": "cpe", + "comment": "" + }, + { + "router": "culleoka", + "mac": "18e8297e0993", + "mac_raw": "18:E8:29:7E:09:93", + "address": "10.10.96.37", + "host": "Chisedi Qezada", + "server": "cpe", + "comment": "" + }, + { + "router": "culleoka", + "mac": "58c17aac5935", + "mac_raw": "58:C1:7A:AC:59:35", + "address": "10.10.96.15", + "host": "Marlen Villanueva", + "server": "cpe", + "comment": "" + }, + { + "router": "culleoka", + "mac": "000456dbc2fe", + "mac_raw": "00:04:56:DB:C2:FE", + "address": "10.10.96.8", + "host": "Jennifer Reed", + "server": "cpe", + "comment": "" + } + ], + "unassigned": [ + { + "lease": { + "router": "verona", + "mac": "f4e2c63027f2", + "mac_raw": "F4:E2:C6:30:27:F2", + "address": "10.10.0.28", + "host": "Penny Warner", + "server": "verona-cpe", + "comment": "" + }, + "item": { + "item_id": "inventory_item_7o9PYAiS8YMcTv6s2HQYRd", + "model": "PowerBeam AC Gen2", + "ip": "10.10.0.28", + "assignee_type": "InventoryLocation" + } + }, + { + "lease": { + "router": "verona", + "mac": "f4e2c630a47d", + "mac_raw": "F4:E2:C6:30:A4:7D", + "address": "10.10.0.73", + "host": "Vance Peltonen", + "server": "verona-cpe", + "comment": "" + }, + "item": { + "item_id": "inventory_item_4uRzNoB9Hd8W9MW2r1V21a", + "model": "PowerBeam AC Gen2", + "ip": "10.10.0.73", + "assignee_type": "InventoryLocation" + } + }, + { + "lease": { + "router": "culleoka", + "mac": "f4e2c630a691", + "mac_raw": "F4:E2:C6:30:A6:91", + "address": "10.10.96.94", + "host": "Briana Martinez", + "server": "cpe", + "comment": "" + }, + "item": { + "item_id": "inventory_item_96Hey3Lo57rbDoG1Cq3GzT", + "model": "PowerBeam AC Gen2", + "ip": "10.10.96.94", + "assignee_type": "InventoryLocation" + } + }, + { + "lease": { + "router": "culleoka", + "mac": "f4e2c63034b5", + "mac_raw": "F4:E2:C6:30:34:B5", + "address": "10.10.96.25", + "host": "Wendy Sanders", + "server": "cpe", + "comment": "" + }, + "item": { + "item_id": "inventory_item_7NsZ3ko4fZF6fBzxu8DBqE", + "model": "PowerBeam AC Gen2", + "ip": "10.10.96.25", + "assignee_type": "InventoryLocation" + } + } + ], + "unpaid": [ + { + "lease": { + "router": "verona", + "mac": "245a4cd881ec", + "mac_raw": "24:5A:4C:D8:81:EC", + "address": "10.10.0.53", + "host": "Sri Readdy", + "server": "verona-cpe", + "comment": "" + }, + "item": { + "item_id": "inventory_item_vtcnbQXR6SJozkTtESfcfP", + "model": "PowerBeam M5", + "ip": null, + "assignee_type": "Account", + "account_id": "account_jS3WVFZmrQu5EoM7UJ4L1N" + }, + "account": { + "readable": 514, + "name": "Sri Reddy", + "status": "Suspended", + "paying": false, + "sub_statuses": [ + "SUSPENDED" + ] + } + }, + { + "lease": { + "router": "verona", + "mac": "f09fc246f064", + "mac_raw": "F0:9F:C2:46:F0:64", + "address": "10.10.0.12", + "host": "Beverly Erwin", + "server": "verona-cpe", + "comment": "" + }, + "item": { + "item_id": "inventory_item_a3bbUdreT2r21voeywEB4i", + "model": "Loco M5", + "ip": null, + "assignee_type": "Account", + "account_id": "account_9kYh3vsMa7mG8JrGaQtF2V" + }, + "account": { + "readable": 48, + "name": "Beverly Erwin", + "status": "Inactive", + "paying": false, + "sub_statuses": [ + "CANCELLED" + ] + } + }, + { + "lease": { + "router": "verona", + "mac": "f4e2c6302a72", + "mac_raw": "F4:E2:C6:30:2A:72", + "address": "10.10.0.22", + "host": "Mary Hopper", + "server": "verona-cpe", + "comment": "" + }, + "item": { + "item_id": "inventory_item_g9Nd4Qh3CdaRGbA2wLrBvs", + "model": "PowerBeam AC Gen2", + "ip": "10.10.0.22", + "assignee_type": "Account", + "account_id": "account_jaGX2dnVZVs463ok5kANR5" + }, + "account": { + "readable": 245, + "name": "Mary Hopper", + "status": "Inactive", + "paying": false, + "sub_statuses": [ + "CANCELLED", + "CANCELLED" + ] + } + }, + { + "lease": { + "router": "culleoka", + "mac": "0418d6444a46", + "mac_raw": "04:18:D6:44:4A:46", + "address": "10.10.96.56", + "host": "Rachel Guzzardi", + "server": "cpe", + "comment": "" + }, + "item": { + "item_id": "inventory_item_oaFp6kxgriwsRaFTyAgS7D", + "model": "Loco M5", + "ip": "10.10.96.56", + "assignee_type": "Account", + "account_id": "account_tEZyHoiA8DvmisscRkYy7U" + }, + "account": { + "readable": 287, + "name": "Rachel Guzzardi", + "status": "Inactive", + "paying": false, + "sub_statuses": [ + "CANCELLED" + ] + } + }, + { + "lease": { + "router": "culleoka", + "mac": "70a7411663d1", + "mac_raw": "70:A7:41:16:63:D1", + "address": "10.10.96.71", + "host": "Carlos Gomez", + "server": "cpe", + "comment": "" + }, + "item": { + "item_id": "inventory_item_uUUuJqHoBQAQFwkygp1Ru7", + "model": "PowerBeam M5", + "ip": "10.10.96.71", + "assignee_type": "Account", + "account_id": "account_jNj9rpkTwq7wF3vJbMuAkF" + }, + "account": { + "readable": 1645, + "name": "Carlos Gomez", + "status": "Inactive", + "paying": false, + "sub_statuses": [ + "CANCELLED", + "CANCELLED" + ] + } + }, + { + "lease": { + "router": "culleoka", + "mac": "000456fb0d61", + "mac_raw": "00:04:56:FB:0D:61", + "address": "10.10.96.31", + "host": "Carlos Salaises", + "server": "cpe", + "comment": "" + }, + "item": { + "item_id": "inventory_item_g5zuUt5kbvqyNDtzoz4Fkj", + "model": "ePMP Force 180", + "ip": null, + "assignee_type": "Account", + "account_id": "account_5mD5W5PczTzo9H7it3qxLX" + }, + "account": { + "readable": 448, + "name": "Carlos Salaises", + "status": "Inactive", + "paying": false, + "sub_statuses": [ + "CANCELLED" + ] + } + } + ] +} \ No newline at end of file diff --git a/scripts/freeloaders.py b/scripts/freeloaders.py new file mode 100644 index 0000000..002da75 --- /dev/null +++ b/scripts/freeloaders.py @@ -0,0 +1,354 @@ +#!/usr/bin/env python3 +"""Cross-reference live DHCP leases against Gaiia paying accounts. + +Flags three classes of unpaid internet access on customer-facing pools: + + (1) UNMATCHED -- bound DHCP lease whose MAC has no matching Gaiia + inventory item assigned to any account. + (2) UNASSIGNED -- bound lease whose MAC IS in Gaiia inventory but the + item isn't assigned to an Account (sitting in + InventoryLocation, NetworkSite, etc.). + (3) UNPAID -- bound lease assigned to a Gaiia account that has + zero ACTIVE/TRIAL billing subscriptions. + +Sources of truth: + - Gaiia GraphQL (GAIIA_KEY env) + - MikroTik tower routers via mikrotik-tool API-SSL + +Usage: + GAIIA_KEY=... ./scripts/freeloaders.py + GAIIA_KEY=... ./scripts/freeloaders.py --json > out.json # full dump +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor +from typing import Any + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(REPO, "gaiia", "src")) +from gaiia import GaiiaClient # noqa: E402 + +# Routers that hand out customer DHCP. Skip `home` (graham's house) and `edge` +# (no customer pools) by default. +TOWER_ROUTERS = ["verona", "climax", "culleoka", "newhope", "lowry", "982", "494", "core"] +MTT = os.path.join(REPO, "mikrotik-tool", "mikrotik-tool") + +CUSTOMER_KEYWORDS = ("cpe", "cgnat", "customer", "hotspot") +INFRA_KEYWORDS = ("tower", "infra", "mgmt", "management") + + +def norm_mac(s: str) -> str: + return re.sub(r"[^0-9a-f]", "", s.lower()) + + +def parse_kv_line(line: str) -> dict[str, str]: + """Parse a `.id=*X key=value key=value` line from mikrotik-tool api output.""" + kv: dict[str, str] = {} + for part in re.split(r" +", line.strip()): + if "=" in part: + k, v = part.split("=", 1) + kv[k] = v + return kv + + +def fetch_leases(router: str) -> list[dict[str, Any]]: + proc = subprocess.run( + [MTT, "api", router, "/ip/dhcp-server/lease/print"], + capture_output=True, text=True, timeout=90, + cwd=os.path.join(REPO, "mikrotik-tool"), + ) + if proc.returncode != 0: + print(f"!! {router}: {proc.stderr.strip()[:200]}", file=sys.stderr) + return [] + out: list[dict[str, Any]] = [] + for line in proc.stdout.splitlines(): + if not line.startswith(".id="): + continue + kv = parse_kv_line(line) + if kv.get("status") != "bound": + continue + mac = kv.get("active-mac-address") or kv.get("mac-address") or "" + if not mac: + continue + out.append({ + "router": router, + "mac": norm_mac(mac), + "mac_raw": mac, + "address": kv.get("active-address") or kv.get("address") or "", + "host": kv.get("host-name", ""), + "server": kv.get("active-server") or kv.get("server", ""), + "comment": kv.get("comment", ""), + }) + return out + + +def paginate(client: GaiiaClient, query_with_after: str, query_without_after: str, path: str): + """Iterate Connection nodes; uses a separate first-page query that omits $after + because the gaiia API rejects null cursor values.""" + cursor: str | None = None + while True: + if cursor is None: + data = client.query(query_without_after) + else: + data = client.query(query_with_after, {"after": cursor}) + node = data + for part in path.split("."): + node = node[part] + for n in node.get("nodes") or []: + yield n + pi = node.get("pageInfo") or {} + if not pi.get("hasNextPage"): + return + cursor = pi.get("endCursor") + if not cursor: + return + + +def fetch_gaiia() -> tuple[dict[str, dict], dict[str, dict]]: + mac_to_item: dict[str, dict] = {} + account_info: dict[str, dict] = {} + + accts_with = """ + query Q($after: String!) { + accounts(first: 100, after: $after) { + nodes { + id readableId displayName + status { name } + billingSubscriptions(first: 50) { nodes { id status } } + } + pageInfo { hasNextPage endCursor } + } + }""" + accts_first = """ + query { + accounts(first: 100) { + nodes { + id readableId displayName + status { name } + billingSubscriptions(first: 50) { nodes { id status } } + } + pageInfo { hasNextPage endCursor } + } + }""" + + items_with = """ + query Q($after: String!) { + inventoryItems(first: 100, after: $after) { + nodes { + id + ipAddressV4 + model { name } + fields { nodes { data modelField { name } } } + assignation { + assigneeType + assignee { + __typename + ... on Account { id readableId displayName } + } + } + } + pageInfo { hasNextPage endCursor } + } + }""" + items_first = """ + query { + inventoryItems(first: 100) { + nodes { + id + ipAddressV4 + model { name } + fields { nodes { data modelField { name } } } + assignation { + assigneeType + assignee { + __typename + ... on Account { id readableId displayName } + } + } + } + pageInfo { hasNextPage endCursor } + } + }""" + + with GaiiaClient(timezone="America/Chicago") as g: + for a in paginate(g, accts_with, accts_first, "accounts"): + subs = (a.get("billingSubscriptions") or {}).get("nodes") or [] + statuses = [s["status"] for s in subs] + account_info[a["id"]] = { + "readable": a["readableId"], + "name": a["displayName"], + "status": a["status"]["name"], + "paying": any(s in ("ACTIVE", "TRIAL") for s in statuses), + "sub_statuses": statuses, + } + + for it in paginate(g, items_with, items_first, "inventoryItems"): + macs = [] + for f in ((it.get("fields") or {}).get("nodes") or []): + mf = f.get("modelField") or {} + if mf.get("name", "").lower().startswith("mac") and f.get("data"): + macs.append(norm_mac(f["data"])) + assn = it.get("assignation") or {} + assignee = assn.get("assignee") or {} + atype = assignee.get("__typename") + for m in macs: + if len(m) != 12: + continue + rec = { + "item_id": it["id"], + "model": (it.get("model") or {}).get("name", ""), + "ip": it.get("ipAddressV4"), + "assignee_type": atype, + } + if atype == "Account": + rec["account_id"] = assignee["id"] + # Prefer Account-assigned items if MAC is duplicated in inventory + if m in mac_to_item and mac_to_item[m].get("assignee_type") == "Account": + continue + mac_to_item[m] = rec + + return mac_to_item, account_info + + +def _name_tokens(s: str) -> set[str]: + return {w for w in re.split(r"[^a-z0-9]+", s.lower()) if len(w) >= 3} + + +def fuzzy_account_match(host: str, accounts: dict[str, dict]) -> dict | None: + """Best-effort account lookup by DHCP host-name against displayName. + Requires >=2 token overlap (first+last name) to reduce false positives.""" + if not host: + return None + htoks = _name_tokens(host) + if len(htoks) < 2: + return None + best = None + best_score = 0 + for aid, a in accounts.items(): + atoks = _name_tokens(a["name"]) + score = len(htoks & atoks) + if score >= 2 and score > best_score: + best_score = score + best = {**a, "id": aid, "match_score": score} + return best + + +def classify(leases, mac_to_item, accounts): + unmatched_paying, unmatched_unpaid, unmatched_unknown = [], [], [] + unassigned, unpaid = [], [] + for lease in leases: + server = lease["server"].lower() + if not any(k in server for k in CUSTOMER_KEYWORDS): + continue + if any(k in lease["comment"].lower() for k in ("backhaul", "exclusion", "infra")): + continue + mac = lease["mac"] + item = mac_to_item.get(mac) + if not item: + # Fall back to name match on DHCP host-name + guess = fuzzy_account_match(lease["host"], accounts) + if guess is None: + unmatched_unknown.append(lease) + elif guess["paying"]: + unmatched_paying.append((lease, guess)) + else: + unmatched_unpaid.append((lease, guess)) + elif item.get("assignee_type") != "Account": + unassigned.append((lease, item)) + else: + acct = accounts.get(item["account_id"]) + if not acct or not acct["paying"]: + unpaid.append((lease, item, acct)) + return unmatched_paying, unmatched_unpaid, unmatched_unknown, unassigned, unpaid + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--json", action="store_true", help="emit machine-readable JSON") + ap.add_argument("--routers", help="comma-separated subset of routers") + args = ap.parse_args() + + if not os.environ.get("GAIIA_KEY"): + sys.exit("GAIIA_KEY not set") + + routers = args.routers.split(",") if args.routers else TOWER_ROUTERS + + print("Fetching Gaiia inventory + accounts...", file=sys.stderr) + mac_to_item, accounts = fetch_gaiia() + paying = sum(1 for a in accounts.values() if a["paying"]) + print(f" {len(accounts)} accounts ({paying} paying), " + f"{len(mac_to_item)} MACs in inventory " + f"({sum(1 for v in mac_to_item.values() if v.get('assignee_type')=='Account')} account-assigned)", + file=sys.stderr) + + print(f"Fetching DHCP leases from {len(routers)} routers in parallel...", file=sys.stderr) + with ThreadPoolExecutor(max_workers=len(routers)) as ex: + results = list(ex.map(fetch_leases, routers)) + leases = [l for sub in results for l in sub] + cust_leases = [l for l in leases if any(k in l["server"].lower() for k in CUSTOMER_KEYWORDS)] + print(f" {len(leases)} bound leases ({len(cust_leases)} on customer pools)", file=sys.stderr) + + um_paying, um_unpaid, um_unknown, unassigned, unpaid = classify(leases, mac_to_item, accounts) + + if args.json: + json.dump({ + "summary": { + "accounts": len(accounts), + "paying_accounts": paying, + "bound_leases_total": len(leases), + "bound_leases_customer_pools": len(cust_leases), + "unmatched_paying_by_name": len(um_paying), + "unmatched_unpaid_by_name": len(um_unpaid), + "unmatched_unknown": len(um_unknown), + "unassigned_in_gaiia": len(unassigned), + "unpaid_account": len(unpaid), + }, + "unmatched_paying": [{"lease": l, "account": a} for l, a in um_paying], + "unmatched_unpaid": [{"lease": l, "account": a} for l, a in um_unpaid], + "unmatched_unknown": um_unknown, + "unassigned": [{"lease": l, "item": i} for l, i in unassigned], + "unpaid": [{"lease": l, "item": i, "account": a} for l, i, a in unpaid], + }, sys.stdout, indent=2, default=str) + return + + print() + print(f"### FREELOADER REPORT ###") + print() + print(f"!!! {len(unpaid)} CRITICAL: bound CPE on account with NO ACTIVE subscription !!!") + for l, i, a in sorted(unpaid, key=lambda x: (x[0]["router"], x[0]["address"])): + nm, st = a["name"], a["status"] + subs = ",".join(a["sub_statuses"]) if a["sub_statuses"] else "" + print(f" {l['router']:9} {l['address']:16} {l['mac_raw']:17} " + f"host={l['host']!r} acct=#{a['readable']} {nm!r} status={st} subs=[{subs}]") + + print() + print(f"!!! {len(um_unpaid)} CRITICAL: MAC unknown to Gaiia, but host-name matches a NON-paying account !!!") + for l, a in sorted(um_unpaid, key=lambda x: (x[0]["router"], x[0]["address"])): + subs = ",".join(a["sub_statuses"]) if a["sub_statuses"] else "" + print(f" {l['router']:9} {l['address']:16} {l['mac_raw']:17} " + f"host={l['host']!r} -> acct=#{a['readable']} {a['name']!r} status={a['status']} subs=[{subs}]") + + print() + print(f"--- {len(unassigned)} bound CPE: inventory item exists but assigned to InventoryLocation/etc (not an Account) ---") + for l, i in sorted(unassigned, key=lambda x: (x[0]["router"], x[0]["address"])): + print(f" {l['router']:9} {l['address']:16} {l['mac_raw']:17} " + f"item={i['item_id']} assignee_type={i.get('assignee_type')} host={l['host']!r}") + + print() + print(f"--- {len(um_unknown)} UNKNOWN: bound lease, no MAC match and no fuzzy name match to any account ---") + for l in sorted(um_unknown, key=lambda x: (x["router"], x["address"])): + print(f" {l['router']:9} {l['address']:16} {l['mac_raw']:17} " + f"server={l['server']!r} host={l['host']!r}") + + print() + print(f"(info) {len(um_paying)} bound leases not in Gaiia inventory but host-name matches a PAYING account (likely just inventory gap, not freeloaders)") + + +if __name__ == "__main__": + main() diff --git a/scripts/gaiia_ip_sync.py b/scripts/gaiia_ip_sync.py new file mode 100755 index 0000000..0ea399f --- /dev/null +++ b/scripts/gaiia_ip_sync.py @@ -0,0 +1,436 @@ +#!/usr/bin/env python3 +"""Sync MikroTik live PPPoE+DHCP state -> Gaiia MacAddressIpAddressAssignation. + +Bypasses the broken Gaiia AWS webhook workflow that's supposed to keep +Gaiia's IP-assignment table in sync with reality. Calls the public-but- +hidden mutations: + upsertMacAddressIpAddressAssignations(input) + deactivateMacAddressIpAddressAssignations(input) +both of which are reachable via raw GraphQL even though they don't appear +in Mutation.fields introspection. + +Defaults to dry-run. Use --apply to actually push changes. + +Usage: + GAIIA_KEY=... ./scripts/gaiia_ip_sync.py # dry-run + GAIIA_KEY=... ./scripts/gaiia_ip_sync.py --apply # do it + GAIIA_KEY=... ./scripts/gaiia_ip_sync.py --routers 982 # one tower + GAIIA_KEY=... ./scripts/gaiia_ip_sync.py --no-deactivate # only upserts +""" +from __future__ import annotations + +import argparse +import os +import re +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor +from typing import Any + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(REPO, "gaiia", "src")) +from gaiia import GaiiaClient, GaiiaAPIError # noqa: E402 + +TOWER_ROUTERS = ["verona", "climax", "culleoka", "newhope", "lowry", "982", "494", "core"] +MTT = os.path.join(REPO, "mikrotik-tool", "mikrotik-tool") + +PAYING = {"ACTIVE", "TRIAL"} +CUSTOMER_DHCP_KEYWORDS = ("cpe", "cgnat", "customer", "hotspot") + + +# ---------- helpers ---------- + +def norm_mac(s: str) -> str: + return re.sub(r"[^0-9a-f]", "", (s or "").lower()) + + +def fmt_mac(s: str) -> str: + n = norm_mac(s) + return ":".join(n[i:i+2] for i in range(0, 12, 2)).upper() if len(n) == 12 else s + + +def smush(s: str) -> str: + return re.sub(r"[^a-z0-9]", "", (s or "").lower()) + + +def parse_kv_line(line: str) -> dict[str, str]: + kv = {} + for part in re.split(r" +", line.strip()): + if "=" in part: + k, v = part.split("=", 1) + kv[k] = v + return kv + + +# ---------- MikroTik fetch ---------- + +def fetch_ppp_active(router: str) -> list[dict[str, Any]]: + p = subprocess.run([MTT, "api", router, "/ppp/active/print"], + capture_output=True, text=True, timeout=90, + cwd=os.path.join(REPO, "mikrotik-tool")) + if p.returncode != 0: + print(f"!! {router} ppp active: {p.stderr.strip()[:200]}", file=sys.stderr) + return [] + out = [] + for line in p.stdout.splitlines(): + if not line.startswith(".id="): continue + kv = parse_kv_line(line) + if kv.get("service") != "pppoe": continue + out.append({ + "source": "pppoe", + "router": router, + "ip": kv.get("address", ""), + "mac": norm_mac(kv.get("caller-id", "")), + "mac_raw": kv.get("caller-id", ""), + "username": kv.get("name", ""), + "host": "", + "uptime": kv.get("uptime", ""), + }) + return out + + +def fetch_dhcp_leases(router: str) -> list[dict[str, Any]]: + p = subprocess.run([MTT, "api", router, "/ip/dhcp-server/lease/print"], + capture_output=True, text=True, timeout=90, + cwd=os.path.join(REPO, "mikrotik-tool")) + if p.returncode != 0: + print(f"!! {router} dhcp leases: {p.stderr.strip()[:200]}", file=sys.stderr) + return [] + out = [] + for line in p.stdout.splitlines(): + if not line.startswith(".id="): continue + kv = parse_kv_line(line) + if kv.get("status") != "bound": continue + server = (kv.get("active-server") or kv.get("server") or "").lower() + if not any(k in server for k in CUSTOMER_DHCP_KEYWORDS): + continue + mac = kv.get("active-mac-address") or kv.get("mac-address") or "" + if not mac: + continue + out.append({ + "source": "dhcp", + "router": router, + "ip": kv.get("active-address") or kv.get("address") or "", + "mac": norm_mac(mac), + "mac_raw": mac, + "username": "", + "host": kv.get("host-name", ""), + "server": server, + }) + return out + + +def fetch_router_state(routers: list[str]) -> list[dict[str, Any]]: + with ThreadPoolExecutor(max_workers=len(routers) * 2) as ex: + futs = [] + for r in routers: + futs.append(ex.submit(fetch_ppp_active, r)) + futs.append(ex.submit(fetch_dhcp_leases, r)) + sessions = [] + for f in futs: + sessions.extend(f.result()) + return sessions + + +# ---------- Gaiia fetch ---------- + +def paginate(client: GaiiaClient, q_with: str, q_first: str, path: str): + cur: str | None = None + while True: + d = client.query(q_first) if cur is None else client.query(q_with, {"after": cur}) + node = d + for part in path.split("."): + node = node[part] + for n in (node.get("nodes") or []): + yield n + pi = node.get("pageInfo") or {} + if not pi.get("hasNextPage"): return + cur = pi.get("endCursor") + if not cur: return + + +def fetch_gaiia(client: GaiiaClient): + """Return (accounts_by_id, mac_to_inventory, ip_to_current_assignment, all_assignments).""" + accounts: dict[str, dict] = {} + q_acct_with = """query Q($after:String!){ accounts(first:100,after:$after){ + nodes{ id readableId displayName status{ name } + billingSubscriptions(first:50){ nodes{ status } } } + pageInfo{ hasNextPage endCursor } } }""" + q_acct_first = """query{ accounts(first:100){ + nodes{ id readableId displayName status{ name } + billingSubscriptions(first:50){ nodes{ status } } } + pageInfo{ hasNextPage endCursor } } }""" + for a in paginate(client, q_acct_with, q_acct_first, "accounts"): + subs = [s["status"] for s in ((a.get("billingSubscriptions") or {}).get("nodes") or [])] + accounts[a["id"]] = { + "id": a["id"], + "readable": a["readableId"], + "name": a["displayName"], + "status": a["status"]["name"], + "paying": any(s in PAYING for s in subs), + "subs": subs, + } + + mac_to_inv: dict[str, dict] = {} + ip_to_assn: dict[str, list[dict]] = {} + all_assn: list[dict] = [] + q_inv_with = """query Q($after:String!){ inventoryItems(first:100,after:$after){ + nodes{ id ipAddressV4 + fields{ nodes{ data modelField{ name } } } + assignation{ assigneeType assignee{ __typename ... on Account{ id } } } + ipAddressAssignments(filter:{isActive:true}, first:50){ + nodes{ id ipAddress macAddress isActive type + account{ id readableId displayName } } } + } + pageInfo{ hasNextPage endCursor } } }""" + q_inv_first = q_inv_with.replace("query Q($after:String!)", "query").replace(",after:$after", "") + for it in paginate(client, q_inv_with, q_inv_first, "inventoryItems"): + macs = [] + for f in ((it.get("fields") or {}).get("nodes") or []): + mf = f.get("modelField") or {} + if mf.get("name", "").lower().startswith("mac") and f.get("data"): + m = norm_mac(f["data"]) + if len(m) == 12: + macs.append(m) + assn = it.get("assignation") or {} + assignee = assn.get("assignee") or {} + acct_id = assignee["id"] if assignee.get("__typename") == "Account" else None + for m in macs: + rec = {"item_id": it["id"], "account_id": acct_id} + if m in mac_to_inv and mac_to_inv[m]["account_id"]: + continue + mac_to_inv[m] = rec + for ipa in (it.get("ipAddressAssignments") or {}).get("nodes") or []: + if not ipa.get("ipAddress"): continue + entry = {**ipa, "inventory_item_id": it["id"]} + ip_to_assn.setdefault(ipa["ipAddress"], []).append(entry) + all_assn.append(entry) + + return accounts, mac_to_inv, ip_to_assn, all_assn + + +# ---------- resolution ---------- + +def build_username_idx(accounts): + idx: dict[str, list[str]] = {} + for aid, a in accounts.items(): + k = smush(a["name"]) + if k: + idx.setdefault(k, []).append(aid) + return idx + + +def resolve_session(s, mac_to_inv, accounts, name_idx): + """Return (account_id, inventory_item_id, match_via) or (None, None, reason).""" + inv = mac_to_inv.get(s["mac"]) if s["mac"] else None + if inv and inv["account_id"]: + return inv["account_id"], inv["item_id"], "mac" + # fallback: fuzzy displayName match on username (PPPoE) or host (DHCP) + key = smush(s["username"] or s["host"]) + cands = name_idx.get(key, []) if key else [] + paying = [c for c in cands if accounts[c]["paying"]] + if len(paying) == 1: + return paying[0], inv["item_id"] if inv else None, "name-paying" + if len(cands) == 1: + return cands[0], inv["item_id"] if inv else None, "name" + if not cands: + return None, inv["item_id"] if inv else None, ("mac-unassigned" if inv else "unmatched") + return None, inv["item_id"] if inv else None, "name-ambiguous" + + +# ---------- diff ---------- + +def plan(sessions, accounts, mac_to_inv, ip_to_assn): + name_idx = build_username_idx(accounts) + upserts = [] + skipped = [] # sessions we can't resolve + seen_ips = set() + + for s in sessions: + if ":" in s["ip"] or not s["ip"]: + continue # IPv6 / blank skip + seen_ips.add(s["ip"]) + acct_id, item_id, via = resolve_session(s, mac_to_inv, accounts, name_idx) + if not acct_id: + skipped.append((s, via)) + continue + current = ip_to_assn.get(s["ip"], []) + # is there an exact-match active assignment already? + match = None + for c in current: + same_mac = norm_mac(c["macAddress"]) == s["mac"] + same_acct = (c.get("account") or {}).get("id") == acct_id + same_item = c.get("inventory_item_id") == item_id if item_id else True + if same_mac and same_acct and same_item: + match = c + break + if match: + continue # already correct + upserts.append({ + "session": s, + "via": via, + "account_id": acct_id, + "inventory_item_id": item_id, + "current": current, + }) + + # Deactivations: any IP that has an active Gaiia assignment but no live session for it + deactivations = [] + for ip, assignments in ip_to_assn.items(): + if ip in seen_ips: + continue + for a in assignments: + deactivations.append({"ip": ip, "id": a["id"], "current": a}) + + return upserts, deactivations, skipped + + +# ---------- mutations ---------- + +def do_upserts(client, upserts): + if not upserts: + return + BATCH = 50 + payload = [] + for u in upserts: + s = u["session"] + entry = { + "ipAddress": s["ip"], + "type": "IPV4", + "macAddress": fmt_mac(s["mac"]), + "accountId": u["account_id"], + "expired": False, + } + if u["inventory_item_id"]: + entry["inventoryItemId"] = u["inventory_item_id"] + payload.append(entry) + + mut = """mutation M($i: UpsertMacAddressIpAddressAssignationsInput!) { + upsertMacAddressIpAddressAssignations(input: $i) { + assignations { id ipAddress macAddress account { readableId } } + errors { code message } + } + }""" + total_ok = 0 + for i in range(0, len(payload), BATCH): + chunk = payload[i:i+BATCH] + try: + res = client.mutate(mut, "upsertMacAddressIpAddressAssignations", + {"i": {"assignations": chunk}}) + n = len(res.get("assignations") or []) + total_ok += n + print(f" upsert batch {i//BATCH+1}: {n}/{len(chunk)} ok") + except GaiiaAPIError as e: + print(f" upsert batch {i//BATCH+1}: ERROR {str(e)[:300]}", file=sys.stderr) + print(f"upserts applied: {total_ok}/{len(payload)}") + + +def do_deactivates(client, deactivations): + if not deactivations: + return + BATCH = 50 + ids = [d["id"] for d in deactivations] + mut = """mutation M($i: DeactivateMacAddressIpAddressAssignationsInput!) { + deactivateMacAddressIpAddressAssignations(input: $i) { + assignations { id ipAddress } + errors { code message } + } + }""" + total_ok = 0 + for i in range(0, len(ids), BATCH): + chunk = ids[i:i+BATCH] + try: + res = client.mutate(mut, "deactivateMacAddressIpAddressAssignations", + {"i": {"ids": chunk}}) + n = len(res.get("assignations") or []) + total_ok += n + print(f" deactivate batch {i//BATCH+1}: {n}/{len(chunk)} ok") + except GaiiaAPIError as e: + print(f" deactivate batch {i//BATCH+1}: ERROR {str(e)[:300]}", file=sys.stderr) + print(f"deactivations applied: {total_ok}/{len(ids)}") + + +# ---------- main ---------- + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--apply", action="store_true", help="actually call mutations (default: dry-run)") + ap.add_argument("--routers", help="comma-separated subset") + ap.add_argument("--no-deactivate", action="store_true", help="don't deactivate stale Gaiia assignments") + ap.add_argument("--limit", type=int, default=0, help="cap upserts (for testing)") + args = ap.parse_args() + + if not os.environ.get("GAIIA_KEY"): + sys.exit("GAIIA_KEY not set") + + routers = args.routers.split(",") if args.routers else TOWER_ROUTERS + print(f"Fetching live state from {len(routers)} routers...", file=sys.stderr) + sessions = fetch_router_state(routers) + print(f" {len(sessions)} customer sessions " + f"({sum(1 for s in sessions if s['source']=='pppoe')} pppoe, " + f"{sum(1 for s in sessions if s['source']=='dhcp')} dhcp)", file=sys.stderr) + + print("Fetching Gaiia state...", file=sys.stderr) + with GaiiaClient(timezone="America/Chicago") as g: + accounts, mac_to_inv, ip_to_assn, all_assn = fetch_gaiia(g) + paying = sum(1 for a in accounts.values() if a["paying"]) + print(f" {len(accounts)} accounts ({paying} paying), " + f"{len(mac_to_inv)} MACs in inventory, " + f"{len(all_assn)} active IP assignments", file=sys.stderr) + + upserts, deactivations, skipped = plan(sessions, accounts, mac_to_inv, ip_to_assn) + + if args.limit: + upserts = upserts[:args.limit] + deactivations = deactivations[:args.limit] + + print() + print(f"=== Plan ===") + print(f" {len(upserts)} upserts (Gaiia is wrong / missing)") + print(f" {len(deactivations)} deactivations (Gaiia has stale active assignment)") + print(f" {len(skipped)} sessions we couldn't resolve to an account (skipped)") + if upserts: + print("\n--- UPSERTS ---") + for u in upserts[:200]: + s = u["session"] + acct = accounts.get(u["account_id"], {}) + cur = "" + if u["current"]: + c = u["current"][0] + ca = (c.get("account") or {}).get("displayName") or "" + cur = f" was: ip={c['ipAddress']} mac={c['macAddress']} acct={ca!r}" + print(f" {s['router']:9} {s['ip']:18} {fmt_mac(s['mac']):17} " + f"src={s['source']:5} user={(s['username'] or s['host'])!r:25} -> " + f"#{acct.get('readable')} {acct.get('name')!r} via={u['via']}") + if cur: print(cur) + if len(upserts) > 200: + print(f" ... and {len(upserts)-200} more") + if deactivations: + print("\n--- DEACTIVATIONS ---") + for d in deactivations[:200]: + c = d["current"] + ca = (c.get("account") or {}).get("displayName") or "" + print(f" ip={d['ip']} mac={c['macAddress']} acct={ca!r}") + if len(deactivations) > 200: + print(f" ... and {len(deactivations)-200} more") + if skipped: + print("\n--- SKIPPED (couldn't resolve) ---") + for s, why in skipped[:50]: + print(f" {s['router']:9} {s['ip']:18} {fmt_mac(s['mac']):17} " + f"src={s['source']:5} user={(s['username'] or s['host'])!r:25} reason={why}") + if len(skipped) > 50: + print(f" ... and {len(skipped)-50} more") + + if not args.apply: + print("\n(dry-run; pass --apply to execute)") + return + + print("\n=== Applying ===") + with GaiiaClient(timezone="America/Chicago") as g: + do_upserts(g, upserts) + if not args.no_deactivate: + do_deactivates(g, deactivations) + + +if __name__ == "__main__": + main() diff --git a/scripts/identify_models.py b/scripts/identify_models.py new file mode 100644 index 0000000..4faa9d0 --- /dev/null +++ b/scripts/identify_models.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Identify device models via HTTPS for each IP in cpes.txt. + +For Cambium ePMP devices: reads /js/cambium_sku.js to get the SKU number, +maps it to a model name. For Ubiquiti devices: tries API endpoints. + +Usage: + python3 scripts/identify_models.py [cpes.txt] +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import ssl +import sys +import urllib.request +from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import Any + +# Cambium ePMP SKU → model name +CAMBIUM_SKU_MAP: dict[int, str] = { + 8: "ePMP Force 110", + 28: "ePMP Force 300", + 35: "ePMP Force 180", + 55: "ePMP Force 200", +} + +_CTX = ssl.create_default_context() +_CTX.check_hostname = False +_CTX.verify_mode = ssl.CERT_NONE + + +def http_get(url: str, timeout: float = 8) -> str | None: + try: + req = urllib.request.Request(url) + resp = urllib.request.urlopen(req, context=_CTX, timeout=timeout) + return resp.read().decode(errors="replace") + except Exception: + return None + + +def identify_model(ip: str) -> str: + # Try both HTTPS and HTTP + for proto in ("https", "http"): + body = http_get(f"{proto}://{ip}/js/cambium_sku.js") + if body: + break + + # 1. Try Cambium ePMP SKU file + if body: + m = re.search(r"window\.sku\s*=\s*(\d+)", body) + if m: + sku = int(m.group(1)) + fw_m = re.search(r"window\.cambiumFWVersion\s*=\s*['\"]([^'\"]+)", body) + fw = f" (fw={fw_m.group(1)})" if fw_m else "" + model = CAMBIUM_SKU_MAP.get(sku, f"Cambium SKU {sku}") + return f"{model}{fw}" + + # 2. Try Ubiquiti airOS 8.x API + body = http_get(f"https://{ip}/api/v1.0/status") + if body: + try: + data = json.loads(body) + host = data.get("host", {}) + model = host.get("deviceModel", "") + fw = host.get("firmwareVersion", "") + if model: + return f"{model} (fw={fw})" if fw else model + except json.JSONDecodeError: + pass + + # 3. Try Ubiquiti status.cgi + body = http_get(f"https://{ip}/status.cgi") + if body: + # Look for model in the XML/HTML + for pattern in [ + r"([^<]+)", + r"model.*?([A-Za-z0-9\-\. ]{5,50})", + ]: + m = re.search(pattern, body, re.IGNORECASE) + if m and m.group(1).strip() not in ("ePMP", "Ubiquiti", "Login"): + return m.group(1).strip() + + # 4. Fallback: check if it's Cambium at all + body = http_get(f"https://{ip}/") + if body: + if "ePMP" in body: + return "Cambium ePMP (unknown model)" + if "Ubiquiti" in body or "airOS" in body or "airMAX" in body: + return "Ubiquiti (unknown model)" + # Try to extract title + m = re.search(r"([^<]+)", body) + if m: + return f"Unknown ({m.group(1).strip()})" + + return "no HTTPS response" + + +def parse_cpes(path: str) -> list[dict[str, str]]: + entries: list[dict[str, str]] = [] + with open(path) as f: + for line in f: + line = line.strip() + if not line: + continue + # Extract fields positionally: + # MAC is first 17 chars (AA:BB:CC:DD:EE:FF) + mac_m = re.match(r"([0-9A-Fa-f:]{17})", line) + if not mac_m: + continue + mac = mac_m.group(1) + # IP is last field + ip_m = re.search(r"(\d+\.\d+\.\d+\.\d+)$", line) + if not ip_m: + continue + ip = ip_m.group(1) + # Account number: # followed by digits + acct_m = re.search(r"#(\d+)", line) + readable = acct_m.group(1) if acct_m else "" + entries.append({ + "mac": mac, + "readable": readable, + "name": "", # filled in below + "ip": ip, + }) + return entries + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("input", nargs="?", default="cpes.txt") + ap.add_argument("-w", "--workers", type=int, default=20) + args = ap.parse_args() + + if not os.path.exists(args.input): + sys.exit(f"{args.input} not found") + + entries = parse_cpes(args.input) + print(f"Identifying {len(entries)} devices via HTTPS...", file=sys.stderr) + + models: dict[str, str] = {} + with ThreadPoolExecutor(max_workers=args.workers) as ex: + futs = {ex.submit(identify_model, e["ip"]): e for e in entries} + for i, f in enumerate(as_completed(futs), 1): + e = futs[f] + models[e["ip"]] = f.result() + if i % 20 == 0: + print(f" {i}/{len(entries)}", file=sys.stderr) + print(f" {len(entries)}/{len(entries)} done", file=sys.stderr) + + # Output sorted by model + entries.sort(key=lambda e: models[e["ip"]].lower()) + + # Show grouped counts + groups: dict[str, list] = {} + for e in entries: + m = models[e["ip"]] + groups.setdefault(m, []).append(e) + + for model, devs in sorted(groups.items(), key=lambda x: x[0].lower()): + print(f"\n## {model} ({len(devs)} devices)") + for d in devs: + print(f" {d['mac']:17} #{d['readable']:5} {d['name']:24} {d['ip']}") + + +if __name__ == "__main__": + main() diff --git a/scripts/missing_cpe.py b/scripts/missing_cpe.py new file mode 100644 index 0000000..cd4b042 --- /dev/null +++ b/scripts/missing_cpe.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Find active paying Gaiia accounts that have no CPE equipment assigned. + +Usage: + GAIIA_KEY=... python3 scripts/missing_cpe.py + GAIIA_KEY=... python3 scripts/missing_cpe.py --json +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import sys + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(REPO, "gaiia", "src")) +from gaiia import GaiiaClient # noqa: E402 + +PAYING = {"ACTIVE", "TRIAL"} +_INDOOR_PATTERNS = ("AC1200", "AC1000", "Vilo", "hc220", "GWN") + + +def norm_mac(s: str) -> str: + return re.sub(r"[^0-9a-f]", "", (s or "").lower()) + + +def paginate(client, q_with, q_first, path): + cur = None + while True: + d = client.query(q_first) if cur is None else client.query(q_with, {"after": cur}) + node = d + for part in path.split("."): + node = node[part] + for n in node.get("nodes") or []: + yield n + pi = node.get("pageInfo") or {} + if not pi.get("hasNextPage"): + return + cur = pi.get("endCursor") + if not cur: + return + + +def fetch_data(): + """Return (paying_accounts, account_ids_with_cpe).""" + accounts: dict[str, dict] = {} + q_acct_with = """query Q($after:String!){ accounts(first:100,after:$after){ + nodes{ id readableId displayName status{ name } + billingSubscriptions(first:50){ nodes{ status } } } + pageInfo{ hasNextPage endCursor } } }""" + q_acct_first = """query{ accounts(first:100){ + nodes{ id readableId displayName status{ name } + billingSubscriptions(first:50){ nodes{ status } } } + pageInfo{ hasNextPage endCursor } } }""" + + q_inv_with = """query Q($after:String!){ inventoryItems(first:100,after:$after){ + nodes{ id model{ name } + fields{ nodes{ data modelField{ name } } } + assignation{ assignee{ __typename ... on Account{ id } } } + } + pageInfo{ hasNextPage endCursor } } }""" + q_inv_first = q_inv_with.replace("query Q($after:String!)", "query").replace(",after:$after", "") + + with GaiiaClient(timezone="America/Chicago") as g: + for a in paginate(g, q_acct_with, q_acct_first, "accounts"): + subs = [s["status"] for s in ((a.get("billingSubscriptions") or {}).get("nodes") or [])] + paying = any(s in PAYING for s in subs) + active = a["status"]["name"] == "Active" + if paying and active: + accounts[a["id"]] = { + "id": a["id"], + "readable": a["readableId"], + "name": a["displayName"], + } + + accts_with_cpe: set[str] = set() + for it in paginate(g, q_inv_with, q_inv_first, "inventoryItems"): + model = (it.get("model") or {}).get("name", "") + if any(p.lower() in model.lower() for p in _INDOOR_PATTERNS): + continue + assn = it.get("assignation") or {} + assignee = assn.get("assignee") or {} + if assignee.get("__typename") == "Account": + accts_with_cpe.add(assignee["id"]) + + return accounts, accts_with_cpe + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--json", action="store_true") + args = ap.parse_args() + + if not os.environ.get("GAIIA_KEY"): + sys.exit("GAIIA_KEY not set") + + print("Fetching Gaiia data...", file=sys.stderr) + accounts, accts_with_cpe = fetch_data() + missing = {aid: a for aid, a in accounts.items() if aid not in accts_with_cpe} + + print(f" {len(accounts)} paying accounts, " + f"{len(accts_with_cpe)} have CPEs, " + f"{len(missing)} missing CPEs", + file=sys.stderr) + + if args.json: + out = [{"readable": a["readable"], "name": a["name"], "id": a["id"]} + for a in sorted(missing.values(), key=lambda a: a["name"].lower())] + json.dump(out, sys.stdout, indent=2) + return + + print(f"\n{len(missing)} paying accounts without CPE equipment:\n") + for a in sorted(missing.values(), key=lambda a: a["name"].lower()): + print(f" #{a['readable']:6} {a['name']}") + + +if __name__ == "__main__": + main() diff --git a/scripts/pppoe_freeloaders.py b/scripts/pppoe_freeloaders.py new file mode 100755 index 0000000..074144f --- /dev/null +++ b/scripts/pppoe_freeloaders.py @@ -0,0 +1,344 @@ +#!/usr/bin/env python3 +"""PPPoE-side counterpart to freeloaders.py. + +Pulls /ppp/active/print from every tower router and classifies each active +PPPoE session against Gaiia inventory + accounts, the same way freeloaders.py +does for DHCP leases. The goal is to identify which active PPPoE customers +won't be mapped in Preseem because Gaiia can't tie their session to a paying +account. + +Match order for each active session: + 1. caller-id MAC -> InventoryItem (mac field) -> Account + 2. PPPoE username -> Account displayName (fuzzy smushed-name match; + "judydevine" -> "Judy Devine") + +Categories: + OK_PAYING - resolved to ACTIVE/TRIAL account (would be mapped in Preseem) + UNPAID - resolved to account whose subscriptions are all + CANCELLED/SUSPENDED/etc. (Preseem sync presumably skips) + UNASSIGNED - MAC is in Gaiia inventory but not assigned to an Account + UNMATCHED - neither MAC nor name resolves to anything in Gaiia + +Usage: + GAIIA_KEY=... ./scripts/pppoe_freeloaders.py + GAIIA_KEY=... ./scripts/pppoe_freeloaders.py --json > out.json +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor +from typing import Any + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(REPO, "gaiia", "src")) +from gaiia import GaiiaClient # noqa: E402 + +TOWER_ROUTERS = ["verona", "climax", "culleoka", "newhope", "lowry", "982", "494", "core"] +MTT = os.path.join(REPO, "mikrotik-tool", "mikrotik-tool") + +PAYING_STATUSES = {"ACTIVE", "TRIAL"} + + +def norm_mac(s: str) -> str: + return re.sub(r"[^0-9a-f]", "", (s or "").lower()) + + +def parse_kv_line(line: str) -> dict[str, str]: + kv: dict[str, str] = {} + for part in re.split(r" +", line.strip()): + if "=" in part: + k, v = part.split("=", 1) + kv[k] = v + return kv + + +def fetch_ppp_active(router: str) -> list[dict[str, Any]]: + proc = subprocess.run( + [MTT, "api", router, "/ppp/active/print"], + capture_output=True, text=True, timeout=90, + cwd=os.path.join(REPO, "mikrotik-tool"), + ) + if proc.returncode != 0: + print(f"!! {router}: {proc.stderr.strip()[:200]}", file=sys.stderr) + return [] + out: list[dict[str, Any]] = [] + for line in proc.stdout.splitlines(): + if not line.startswith(".id="): + continue + kv = parse_kv_line(line) + if kv.get("service") != "pppoe": + continue + out.append({ + "router": router, + "username": kv.get("name", ""), + "address": kv.get("address", ""), + "caller_mac": norm_mac(kv.get("caller-id", "")), + "caller_mac_raw": kv.get("caller-id", ""), + "uptime": kv.get("uptime", ""), + }) + return out + + +def paginate(client: GaiiaClient, q_with: str, q_first: str, path: str): + cursor: str | None = None + while True: + data = client.query(q_first) if cursor is None else client.query(q_with, {"after": cursor}) + node = data + for part in path.split("."): + node = node[part] + for n in node.get("nodes") or []: + yield n + pi = node.get("pageInfo") or {} + if not pi.get("hasNextPage"): + return + cursor = pi.get("endCursor") + if not cursor: + return + + +def fetch_gaiia() -> tuple[dict[str, dict], dict[str, dict]]: + """Return (mac -> inventory record, account_id -> account record).""" + mac_to_item: dict[str, dict] = {} + account_info: dict[str, dict] = {} + + accts_with = """ + query Q($after: String!) { + accounts(first: 100, after: $after) { + nodes { + id readableId displayName + status { name } + billingSubscriptions(first: 50) { nodes { status } } + } + pageInfo { hasNextPage endCursor } + } + }""" + accts_first = """ + query { + accounts(first: 100) { + nodes { + id readableId displayName + status { name } + billingSubscriptions(first: 50) { nodes { status } } + } + pageInfo { hasNextPage endCursor } + } + }""" + + items_with = """ + query Q($after: String!) { + inventoryItems(first: 100, after: $after) { + nodes { + id ipAddressV4 + model { name } + fields { nodes { data modelField { name } } } + assignation { + assigneeType + assignee { __typename ... on Account { id readableId displayName } } + } + } + pageInfo { hasNextPage endCursor } + } + }""" + items_first = items_with.replace("$after: String!", "").replace("after: $after", "").replace(", )", ")") + # simpler: hand-roll the first query + items_first = """ + query { + inventoryItems(first: 100) { + nodes { + id ipAddressV4 + model { name } + fields { nodes { data modelField { name } } } + assignation { + assigneeType + assignee { __typename ... on Account { id readableId displayName } } + } + } + pageInfo { hasNextPage endCursor } + } + }""" + + with GaiiaClient(timezone="America/Chicago") as g: + for a in paginate(g, accts_with, accts_first, "accounts"): + subs = (a.get("billingSubscriptions") or {}).get("nodes") or [] + statuses = [s["status"] for s in subs] + account_info[a["id"]] = { + "readable": a["readableId"], + "name": a["displayName"], + "status": a["status"]["name"], + "paying": any(s in PAYING_STATUSES for s in statuses), + "sub_statuses": statuses, + } + + for it in paginate(g, items_with, items_first, "inventoryItems"): + macs = [] + for f in ((it.get("fields") or {}).get("nodes") or []): + mf = f.get("modelField") or {} + if mf.get("name", "").lower().startswith("mac") and f.get("data"): + macs.append(norm_mac(f["data"])) + assn = it.get("assignation") or {} + assignee = assn.get("assignee") or {} + atype = assignee.get("__typename") + for m in macs: + if len(m) != 12: + continue + rec = { + "item_id": it["id"], + "model": (it.get("model") or {}).get("name", ""), + "ip": it.get("ipAddressV4"), + "assignee_type": atype, + } + if atype == "Account": + rec["account_id"] = assignee["id"] + # Prefer Account-assigned items if MAC is duplicated + if m in mac_to_item and mac_to_item[m].get("assignee_type") == "Account": + continue + mac_to_item[m] = rec + + return mac_to_item, account_info + + +def smush(s: str) -> str: + """Lowercase + drop non-alphanumeric. 'Judy Devine' -> 'judydevine'.""" + return re.sub(r"[^a-z0-9]", "", (s or "").lower()) + + +def build_username_index(accounts: dict[str, dict]) -> dict[str, list[str]]: + """Smushed displayName -> [account_id, ...]. Many accounts can collide.""" + idx: dict[str, list[str]] = {} + for aid, a in accounts.items(): + key = smush(a["name"]) + if key: + idx.setdefault(key, []).append(aid) + return idx + + +def classify(sessions, mac_to_item, accounts): + user_idx = build_username_index(accounts) + + results = [] + for s in sessions: + username = s["username"] + mac = s["caller_mac"] + match_via = None + item = None + account = None + + # 1. MAC match + if mac: + item = mac_to_item.get(mac) + if item and item.get("assignee_type") == "Account": + account = accounts.get(item["account_id"]) + match_via = "mac" + + # 2. Username -> displayName fuzzy match + if account is None: + cands = user_idx.get(smush(username), []) + if len(cands) == 1: + account = accounts.get(cands[0]) + match_via = "username" + elif len(cands) > 1: + # ambiguous; prefer a paying one + paying = [accounts[a] for a in cands if accounts[a]["paying"]] + if paying: + account = paying[0] + match_via = "username-ambig-paying" + else: + account = accounts[cands[0]] + match_via = "username-ambig" + + # Classify + if account is None and item is None: + cat = "UNMATCHED" + elif account is None: + cat = "UNASSIGNED" # MAC in inventory but no Account + elif account["paying"]: + cat = "OK_PAYING" + else: + cat = "UNPAID" + + results.append({ + **s, + "category": cat, + "match_via": match_via, + "account": account, + "inventory_item": item, + }) + return results + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--json", action="store_true") + ap.add_argument("--routers", help="comma-separated subset") + ap.add_argument("--show-ok", action="store_true", + help="also list OK_PAYING sessions (default: hidden)") + args = ap.parse_args() + + if not os.environ.get("GAIIA_KEY"): + sys.exit("GAIIA_KEY not set") + + routers = args.routers.split(",") if args.routers else TOWER_ROUTERS + + print("Fetching Gaiia inventory + accounts...", file=sys.stderr) + mac_to_item, accounts = fetch_gaiia() + paying = sum(1 for a in accounts.values() if a["paying"]) + print(f" {len(accounts)} accounts ({paying} paying), {len(mac_to_item)} MACs in inventory " + f"({sum(1 for v in mac_to_item.values() if v.get('assignee_type')=='Account')} account-assigned)", + file=sys.stderr) + + print(f"Fetching active PPPoE sessions from {len(routers)} routers...", file=sys.stderr) + with ThreadPoolExecutor(max_workers=len(routers)) as ex: + per_router = list(ex.map(fetch_ppp_active, routers)) + sessions = [s for sub in per_router for s in sub] + print(f" {len(sessions)} active PPPoE sessions", file=sys.stderr) + + results = classify(sessions, mac_to_item, accounts) + by_cat: dict[str, list] = {} + for r in results: + by_cat.setdefault(r["category"], []).append(r) + + if args.json: + json.dump({"summary": {k: len(v) for k, v in by_cat.items()}, + "results": results}, sys.stdout, indent=2, default=str) + return + + counts = {k: len(by_cat.get(k, [])) for k in ("OK_PAYING","UNPAID","UNASSIGNED","UNMATCHED")} + print() + print(f"=== PPPoE classification summary ({len(results)} sessions) ===") + for k, v in counts.items(): + print(f" {k:12} {v}") + + for cat in ("UNPAID", "UNASSIGNED", "UNMATCHED"): + rows = by_cat.get(cat, []) + if not rows: + continue + print() + print(f"--- {cat} ({len(rows)}) ---") + for r in sorted(rows, key=lambda x: (x["router"], x["address"])): + a = r["account"] + if a: + acct_str = (f"#{a['readable']} {a['name']!r} " + f"status={a['status']} " + f"subs=[{','.join(a['sub_statuses']) or ''}] " + f"via={r['match_via']}") + else: + acct_str = "(no account match)" + print(f" {r['router']:9} {r['address']:16} user={r['username']!r:25} " + f"caller={r['caller_mac_raw']:17} {acct_str}") + + if args.show_ok: + print() + print(f"--- OK_PAYING ({len(by_cat.get('OK_PAYING',[]))}) ---") + for r in sorted(by_cat.get("OK_PAYING", []), key=lambda x: (x["router"], x["address"])): + a = r["account"] + print(f" {r['router']:9} {r['address']:16} user={r['username']!r:25} " + f"acct=#{a['readable']} {a['name']!r} via={r['match_via']}") + + +if __name__ == "__main__": + main() diff --git a/secure.md b/secure.md new file mode 100644 index 0000000..3b54679 --- /dev/null +++ b/secure.md @@ -0,0 +1,57 @@ +# MikroTik Router Management Access Hardening + +These commands restrict API, Winbox, and SSH access to only trusted subnets +(CGNAT, VNTX public IPs, and private RFC 1918 space). + +Apply to **every router** in the fleet. The address list is the same on all +routers so the management ACL is consistent regardless of which tower you're +connecting from. + +## 1. Create the trusted address list + +``` +/ip firewall address-list +add address=204.110.188.0/22 comment="VNTX public IPs" list=trusted +add address=100.64.0.0/10 comment="CGNAT pools" list=trusted +add address=10.0.0.0/8 comment="RFC 1918 private" list=trusted +add address=192.168.0.0/16 comment="RFC 1918 private" list=trusted +``` + +## 2. Drop non-trusted traffic to management services + +Place these **at the top** of `/ip firewall filter chain=input` (before any +existing accept rules for management ports). The `place-before=0` puts them +at position 0 and 1 — adjust the number if you already have rules at the +top that must stay there. + +``` +/ip firewall filter +add action=accept chain=input comment="allow trusted to management services" dst-port=8728-8729,8291,22 protocol=tcp src-address-list=trusted place-before=0 +add action=drop chain=input comment="drop untrusted to management services" dst-port=8728-8729,8291,22 protocol=tcp place-before=1 +``` + +Ports covered: +- `8728` — API (plaintext, if ever used) +- `8729` — API-SSL +- `8291` — Winbox +- `22` — SSH + +## 3. Verify + +``` +/ip firewall address-list print where list=trusted +/ip firewall filter print chain=input where dst-port~"8728|8729|8291|22" +``` + +## Notes + +- The `accept` rule must come before the `drop` rule (lower number = earlier + in the chain). +- If a router already has an `accept` for `dst-port=22,8291` etc. without a + source filter, remove it or place these new rules above it — otherwise + the broad accept will match first and the drop will never fire. +- IPv6 management access is not covered here. If you want the same for v6, + mirror the rules in `/ipv6 firewall filter chain=input` using the v6 + equivalents of those subnets. +- After applying, test from a trusted source IP before closing the session + — a misordered rule can lock you out.