From ef498e4a72c8e3f76ee30a9832103901ec2d9d2a Mon Sep 17 00:00:00 2001 From: Joachim Friberg Date: Tue, 25 Aug 2026 14:08:52 +0200 Subject: [PATCH] initial commit --- .gitignore | 3 + .idea/.gitignore | 10 + .idea/hpe.iml | 12 + .../inspectionProfiles/profiles_settings.xml | 6 + .idea/modules.xml | 8 + AGENT.md | 56 + CHEATSHEET.md | 66 + TODO.md | 58 + actions/__init__.py | 0 actions/locator.py | 28 + discover.py | 36 + extractors/__init__.py | 0 extractors/backup_manager.py | 21 + extractors/buffered_log.py | 20 + extractors/clock.py | 20 + extractors/lldp_statistics.py | 22 + extractors/log_configuration.py | 18 + extractors/network_setup.py | 20 + extractors/port_summary.py | 21 + extractors/sntp.py | 18 + extractors/system_description.py | 18 + extractors/xe_page.py | 76 + menu.py | 101 + page_snapshots/BufferedLogs.html.html | 960 ++++++ page_snapshots/Clock_Detail.html.html | 198 ++ page_snapshots/FDBConfig.html.html | 258 ++ page_snapshots/FDBSearch.html.html | 67 + page_snapshots/JumboFrames.html.html | 53 + page_snapshots/LLDPConfig.html.html | 282 ++ page_snapshots/LLDPLocalDeviceSumm.html.html | 238 ++ page_snapshots/LLDPRemoteDeviceSumm.html.html | 63 + page_snapshots/LLDPStats.html.html | 156 + page_snapshots/Logging.html.html | 78 + page_snapshots/LoopProtectionCfg.html.html | 251 ++ page_snapshots/LoopProtectionStatus.html.html | 351 ++ page_snapshots/Network.html.html | 136 + page_snapshots/Ping.html.html | 90 + page_snapshots/PortConfiguration.html.html | 98 + page_snapshots/PortSummary.html.html | 434 +++ .../ResetConfigToDefaults.html.html | 52 + page_snapshots/SNTP.html.html | 103 + page_snapshots/SSLCfg.html.html | 89 + page_snapshots/SaveAllChanges.html.html | 52 + .../Summer_Time_Configuration.html.html | 222 ++ page_snapshots/SwitchConfig.html.html | 53 + page_snapshots/SysDescription.html.html | 118 + page_snapshots/SystemReset.html.html | 61 + .../Time_Zone_Configuration.html.html | 59 + page_snapshots/TrunkConfig.html.html | 87 + page_snapshots/TrunkMembership.html.html | 339 ++ page_snapshots/TrunkStatus.html.html | 57 + page_snapshots/UploadFile.html.html | 121 + page_snapshots/UserAccounts.html.html | 79 + page_snapshots/VLANPortConfig.html.html | 100 + .../VLANPortParticipation.html.html | 406 +++ page_snapshots/VlanCreateConfig.html.html | 88 + page_snapshots/_xe_BufferedLogs.js | 1 + page_snapshots/_xe_Clock_Detail.js | 1 + page_snapshots/_xe_FDBConfig.js | 1 + page_snapshots/_xe_FDBSearch.js | 1 + page_snapshots/_xe_JumboFrames.js | 1 + page_snapshots/_xe_LLDPConfig.js | 1 + page_snapshots/_xe_LLDPLocalDeviceSumm.js | 1 + page_snapshots/_xe_LLDPRemoteDeviceSumm.js | 1 + page_snapshots/_xe_LLDPStats.js | 1 + page_snapshots/_xe_Logging.js | 1 + page_snapshots/_xe_LoopProtectionCfg.js | 1 + page_snapshots/_xe_LoopProtectionStatus.js | 1 + page_snapshots/_xe_Network.js | 1 + page_snapshots/_xe_Ping.js | 1 + page_snapshots/_xe_PortConfiguration.js | 1 + page_snapshots/_xe_PortSummary.js | 1 + page_snapshots/_xe_ResetConfigToDefaults.js | 1 + page_snapshots/_xe_SNTP.js | 1 + page_snapshots/_xe_SSLCfg.js | 1 + page_snapshots/_xe_SaveAllChanges.js | 1 + .../_xe_Summer_Time_Configuration.js | 1 + page_snapshots/_xe_SwitchConfig.js | 1 + page_snapshots/_xe_SysDescription.js | 1 + page_snapshots/_xe_SystemReset.js | 1 + page_snapshots/_xe_Time_Zone_Configuration.js | 1 + page_snapshots/_xe_TrunkConfig.js | 1 + page_snapshots/_xe_TrunkMembership.js | 1 + page_snapshots/_xe_TrunkStatus.js | 1 + page_snapshots/_xe_UploadFile.js | 1 + page_snapshots/_xe_UserAccounts.js | 1 + page_snapshots/_xe_VLANPortConfig.js | 1 + page_snapshots/_xe_VLANPortParticipation.js | 1 + page_snapshots/_xe_VlanCreateConfig.js | 1 + page_snapshots/_xe_dual_image_cfg.js | 1 + page_snapshots/_xe_dual_image_status.js | 1 + page_snapshots/_xe_greenmode.js | 1 + page_snapshots/_xe_http_file_download.js | 1 + page_snapshots/_xe_jsvars.js | 1 + page_snapshots/_xe_locator.js | 1 + page_snapshots/_xe_security.js | 1 + page_snapshots/_xe_textBasedConfig.js | 1 + page_snapshots/current_state.json | 508 +++ page_snapshots/dual_image_cfg.html.html | 109 + page_snapshots/dual_image_status.html.html | 92 + page_snapshots/greenmode.html.html | 71 + page_snapshots/http_file_download.html.html | 130 + page_snapshots/locator.html.html | 53 + page_snapshots/manifest.json | 242 ++ page_snapshots/security.html.html | 81 + page_snapshots/textBasedConfig.html.html | 2845 +++++++++++++++++ page_snapshots/tree1.html | 207 ++ page_snapshots/xui_common.js | 1 + page_snapshots/xui_load.js | 1 + page_snapshots/xui_shed.js | 1 + page_snapshots/xui_submit.js | 1 + requirements.txt | 2 + switch_client.py | 126 + todo.py | 99 + 114 files changed, 11047 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/hpe.iml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/modules.xml create mode 100644 AGENT.md create mode 100644 CHEATSHEET.md create mode 100644 TODO.md create mode 100644 actions/__init__.py create mode 100644 actions/locator.py create mode 100644 discover.py create mode 100644 extractors/__init__.py create mode 100644 extractors/backup_manager.py create mode 100644 extractors/buffered_log.py create mode 100644 extractors/clock.py create mode 100644 extractors/lldp_statistics.py create mode 100644 extractors/log_configuration.py create mode 100644 extractors/network_setup.py create mode 100644 extractors/port_summary.py create mode 100644 extractors/sntp.py create mode 100644 extractors/system_description.py create mode 100644 extractors/xe_page.py create mode 100644 menu.py create mode 100644 page_snapshots/BufferedLogs.html.html create mode 100644 page_snapshots/Clock_Detail.html.html create mode 100644 page_snapshots/FDBConfig.html.html create mode 100644 page_snapshots/FDBSearch.html.html create mode 100644 page_snapshots/JumboFrames.html.html create mode 100644 page_snapshots/LLDPConfig.html.html create mode 100644 page_snapshots/LLDPLocalDeviceSumm.html.html create mode 100644 page_snapshots/LLDPRemoteDeviceSumm.html.html create mode 100644 page_snapshots/LLDPStats.html.html create mode 100644 page_snapshots/Logging.html.html create mode 100644 page_snapshots/LoopProtectionCfg.html.html create mode 100644 page_snapshots/LoopProtectionStatus.html.html create mode 100644 page_snapshots/Network.html.html create mode 100644 page_snapshots/Ping.html.html create mode 100644 page_snapshots/PortConfiguration.html.html create mode 100644 page_snapshots/PortSummary.html.html create mode 100644 page_snapshots/ResetConfigToDefaults.html.html create mode 100644 page_snapshots/SNTP.html.html create mode 100644 page_snapshots/SSLCfg.html.html create mode 100644 page_snapshots/SaveAllChanges.html.html create mode 100644 page_snapshots/Summer_Time_Configuration.html.html create mode 100644 page_snapshots/SwitchConfig.html.html create mode 100644 page_snapshots/SysDescription.html.html create mode 100644 page_snapshots/SystemReset.html.html create mode 100644 page_snapshots/Time_Zone_Configuration.html.html create mode 100644 page_snapshots/TrunkConfig.html.html create mode 100644 page_snapshots/TrunkMembership.html.html create mode 100644 page_snapshots/TrunkStatus.html.html create mode 100644 page_snapshots/UploadFile.html.html create mode 100644 page_snapshots/UserAccounts.html.html create mode 100644 page_snapshots/VLANPortConfig.html.html create mode 100644 page_snapshots/VLANPortParticipation.html.html create mode 100644 page_snapshots/VlanCreateConfig.html.html create mode 100644 page_snapshots/_xe_BufferedLogs.js create mode 100644 page_snapshots/_xe_Clock_Detail.js create mode 100644 page_snapshots/_xe_FDBConfig.js create mode 100644 page_snapshots/_xe_FDBSearch.js create mode 100644 page_snapshots/_xe_JumboFrames.js create mode 100644 page_snapshots/_xe_LLDPConfig.js create mode 100644 page_snapshots/_xe_LLDPLocalDeviceSumm.js create mode 100644 page_snapshots/_xe_LLDPRemoteDeviceSumm.js create mode 100644 page_snapshots/_xe_LLDPStats.js create mode 100644 page_snapshots/_xe_Logging.js create mode 100644 page_snapshots/_xe_LoopProtectionCfg.js create mode 100644 page_snapshots/_xe_LoopProtectionStatus.js create mode 100644 page_snapshots/_xe_Network.js create mode 100644 page_snapshots/_xe_Ping.js create mode 100644 page_snapshots/_xe_PortConfiguration.js create mode 100644 page_snapshots/_xe_PortSummary.js create mode 100644 page_snapshots/_xe_ResetConfigToDefaults.js create mode 100644 page_snapshots/_xe_SNTP.js create mode 100644 page_snapshots/_xe_SSLCfg.js create mode 100644 page_snapshots/_xe_SaveAllChanges.js create mode 100644 page_snapshots/_xe_Summer_Time_Configuration.js create mode 100644 page_snapshots/_xe_SwitchConfig.js create mode 100644 page_snapshots/_xe_SysDescription.js create mode 100644 page_snapshots/_xe_SystemReset.js create mode 100644 page_snapshots/_xe_Time_Zone_Configuration.js create mode 100644 page_snapshots/_xe_TrunkConfig.js create mode 100644 page_snapshots/_xe_TrunkMembership.js create mode 100644 page_snapshots/_xe_TrunkStatus.js create mode 100644 page_snapshots/_xe_UploadFile.js create mode 100644 page_snapshots/_xe_UserAccounts.js create mode 100644 page_snapshots/_xe_VLANPortConfig.js create mode 100644 page_snapshots/_xe_VLANPortParticipation.js create mode 100644 page_snapshots/_xe_VlanCreateConfig.js create mode 100644 page_snapshots/_xe_dual_image_cfg.js create mode 100644 page_snapshots/_xe_dual_image_status.js create mode 100644 page_snapshots/_xe_greenmode.js create mode 100644 page_snapshots/_xe_http_file_download.js create mode 100644 page_snapshots/_xe_jsvars.js create mode 100644 page_snapshots/_xe_locator.js create mode 100644 page_snapshots/_xe_security.js create mode 100644 page_snapshots/_xe_textBasedConfig.js create mode 100644 page_snapshots/current_state.json create mode 100644 page_snapshots/dual_image_cfg.html.html create mode 100644 page_snapshots/dual_image_status.html.html create mode 100644 page_snapshots/greenmode.html.html create mode 100644 page_snapshots/http_file_download.html.html create mode 100644 page_snapshots/locator.html.html create mode 100644 page_snapshots/manifest.json create mode 100644 page_snapshots/security.html.html create mode 100644 page_snapshots/textBasedConfig.html.html create mode 100644 page_snapshots/tree1.html create mode 100644 page_snapshots/xui_common.js create mode 100644 page_snapshots/xui_load.js create mode 100644 page_snapshots/xui_shed.js create mode 100644 page_snapshots/xui_submit.js create mode 100644 requirements.txt create mode 100644 switch_client.py create mode 100644 todo.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..12370ae --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +.session_cookie diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/hpe.iml b/.idea/hpe.iml new file mode 100644 index 0000000..8b8c395 --- /dev/null +++ b/.idea/hpe.iml @@ -0,0 +1,12 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..39a58f3 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 0000000..fdba2e0 --- /dev/null +++ b/AGENT.md @@ -0,0 +1,56 @@ +# Project notes for agents working on this repo + +## What this is today + +A Python PoC that scrapes and writes to an HP ProCurve/OfficeConnect 1810 (J9450A) +switch's built-in web UI at `192.168.2.10` (no auth configured on the switch). + +- `switch_client.py` — session/login (`get_session()`), generic `fetch()` (GET) and + `submit_form()` (POST, for writes). +- `extractors/xe_page.py` — generic parser for the firmware's auto-generated ("XE") pages; + every page renders one of two table shapes (scalar key/value, or tabular with headers), + values always sit inside hidden `` tags. Both reads and writes rely on + this same parser. +- `extractors/*.py` — one `get_x(session)` read function per switch data page. +- `actions/*.py` — one `get_x_state(session)` / `set_x(session, value)` pair per writable + setting (currently just `locator.py`). +- `todo.py` / `TODO.md` — tracks which menu items have a read extractor implemented. + +## Long-term goal: become a Terraform module + +The intended end state is a Terraform provider/module for this switch (and possibly the +pattern generalized to similar switches). Terraform's core flow — as far as understood +right now, exact provider-SDK details not yet researched — is: **read current state → +diff against declared (desired) state → apply only the difference**. Each switch setting +(Locator, System Name, VLAN config, port config, ...) should eventually become a Terraform +resource with that Read/Diff/Apply semantics. + +This is **not** an active task — no provider scaffolding, no SDK choice, no `ensure()` +helper exists yet. It's noted here so code written in the meantime doesn't need reshaping +later. + +### What this means for code written now + +When adding a new `get_x`/`set_x` pair in `actions/`: + +- `get_x_state(session)` must be a pure read — no side effects, safe to call anytime. +- `set_x(session, value)` should be idempotent — calling it again with the same `value` + it already holds should be harmless, not something the caller has to guard against + externally. (Not yet enforced anywhere; `actions/locator.py` always writes regardless of + current state — fine for a manual on/off toggle, but a future idempotent version would + read first and skip the write if already at `value`.) +- Keep `get_x_state` and `set_x` as separate, independently callable functions rather than + a combined "set and forget" call — that split is exactly what a Terraform resource's + Read and Update map onto. + +### Explicit non-goals right now + +- No Terraform provider code, scaffolding, or SDK/language decision (Go provider vs. + something else) exists yet. +- No generic `ensure(desired) -> read, diff, apply-if-different` helper exists yet either — + that's a natural next step once more `actions/` modules exist, but shouldn't be built + speculatively ahead of need. + +Next concrete step towards this goal, when picked up: look into what a Terraform +provider's Read/Diff/Apply contract actually requires (e.g. Terraform Plugin Framework) +before designing anything here around it. diff --git a/CHEATSHEET.md b/CHEATSHEET.md new file mode 100644 index 0000000..118dabd --- /dev/null +++ b/CHEATSHEET.md @@ -0,0 +1,66 @@ +# HPE 1810 switch scraper — cheat sheet + +## Innan du kör något + +```bash +source ~/code/venvs/hpe/bin/activate +cd ~/code/experiments/hpe +``` + +Alla kommandon nedan behöver `NO_PROXY` satt (annars går trafiken via er proxy och 502:ar): + +```bash +export NO_PROXY=192.168.2.10 +``` + +(sätt den en gång per terminal-session, eller lägg i `~/.zshrc`) + +**Stäng switchens webb-UI i webbläsaren** innan du kör skript — den tillåter bara en inloggad session åt gången. + +## Köra en extraktor + +```bash +python -m extractors.port_summary +python -m extractors.system_description +python -m extractors.network_setup +python -m extractors.buffered_log +python -m extractors.lldp_statistics +python -m extractors.log_configuration +python -m extractors.backup_manager +``` + +Var och en loggar in (eller återanvänder cachad session), hämtar, skriver ut, och flaggar sig som "Implemented" i `TODO.md`. + +## Kombinera flera i eget skript + +```python +import switch_client +from extractors.port_summary import get_port_summary + +session = switch_client.get_session() +ports = get_port_summary(session) +``` + +Logga in **en gång**, återanvänd `session` för alla anrop. + +## Menyupptäckt / TODO.md + +```bash +python discover.py # skanna om menyn, uppdatera TODO.md +python discover.py --dump-html ./out # + spara rå HTML för felsökning +``` + +Fyll i `y` i "Want it?"-kolumnen i `TODO.md` för sidor du vill ha en extraktor för. + +## Felsökning + +| Symptom | Orsak | Åtgärd | +|---|---|---| +| `RuntimeError: Login failed...` | Webb-UI:t öppet i en flik, eller nyss inloggad från annan process | Stäng fliken / vänta ~5 min (session-timeout) | +| `502 Bad Gateway` | Proxy fångar trafiken | `export NO_PROXY=192.168.2.10` | +| Vill tvinga ny inloggning | Cachad session krånglar | `switch_client.get_session(force_login=True)` | +| Rensa cachad session helt | — | `rm .session_cookie` | + +## Lägga till en ny extraktor + +Alla sidor delar samma HTML-mönster — kolla `extractors/xe_page.py`. Kopiera en befintlig extraktor (t.ex. `system_description.py`), byt `PATH` och tabellnamnet i `tables["..."]`. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..1e7083f --- /dev/null +++ b/TODO.md @@ -0,0 +1,58 @@ +# HPE 1810 switch scrape TODO + +Regenerate with `python discover.py`. Edit the "Want it?" column by hand; +"Implemented?" is updated by the assistant when an extractor exists. + +| Menu Item | URL | Want it? | Implemented? | +|---|---|---|---| +| Home | http://192.168.2.10/SysDescription.html | y | yes | +| Setup Network | http://192.168.2.10/Network.html | y | yes | +| Status | http://192.168.2.10/tree1.html#Status | y | | +| System Description | http://192.168.2.10/SysDescription.html | y | yes | +| Log | http://192.168.2.10/BufferedLogs.html | y | yes | +| Port Summary | http://192.168.2.10/PortSummary.html | y | yes | +| LLDP Statistics | http://192.168.2.10/LLDPStats.html | y | yes | +| Trunk | http://192.168.2.10/TrunkStatus.html | | | +| MAC Table | http://192.168.2.10/FDBSearch.html | | | +| Loop Protection | http://192.168.2.10/LoopProtectionStatus.html | | | +| Dual Image | http://192.168.2.10/dual_image_status.html | | | +| Clock | http://192.168.2.10/Clock_Detail.html | y | yes | +| Network Setup | http://192.168.2.10/tree1.html#NetworkSetup | y | | +| Get Connected | http://192.168.2.10/Network.html | | yes | +| SNTP | http://192.168.2.10/SNTP.html | y | yes | +| Time Zone | http://192.168.2.10/Time_Zone_Configuration.html | | | +| Daylight Saving Time | http://192.168.2.10/Summer_Time_Configuration.html | | | +| Switching | http://192.168.2.10/tree1.html#Switching | | | +| Port Configuration | http://192.168.2.10/PortConfiguration.html | | | +| Jumbo Frames | http://192.168.2.10/JumboFrames.html | | | +| Port Mirroring | http://192.168.2.10/FDBConfig.html | | | +| Flow Control | http://192.168.2.10/SwitchConfig.html | | | +| Green Features | http://192.168.2.10/greenmode.html | | | +| Loop Protection | http://192.168.2.10/LoopProtectionCfg.html | | | +| Security | http://192.168.2.10/tree1.html#Security | | | +| Advanced Security | http://192.168.2.10/security.html | | | +| Secure Connection | http://192.168.2.10/SSLCfg.html | | | +| Trunks | http://192.168.2.10/tree1.html#Trunks | | | +| Trunk Configuration | http://192.168.2.10/TrunkConfig.html | | | +| Trunk Membership | http://192.168.2.10/TrunkMembership.html | | | +| VLANs | http://192.168.2.10/tree1.html#VLANs | | | +| VLAN Configuration | http://192.168.2.10/VlanCreateConfig.html | | | +| VLAN Ports | http://192.168.2.10/VLANPortConfig.html | | | +| Participation / Tagging | http://192.168.2.10/VLANPortParticipation.html | | | +| LLDP | http://192.168.2.10/tree1.html#LLDP | | | +| LLDP Configuration | http://192.168.2.10/LLDPConfig.html | | | +| Local Device | http://192.168.2.10/LLDPLocalDeviceSumm.html | | | +| Remote Device | http://192.168.2.10/LLDPRemoteDeviceSumm.html | | | +| Diagnostics | http://192.168.2.10/tree1.html#Diagnostics | | | +| Ping Test | http://192.168.2.10/Ping.html | | | +| Log Configuration | http://192.168.2.10/Logging.html | y | yes | +| Reboot Switch | http://192.168.2.10/SystemReset.html | | | +| Factory Defaults | http://192.168.2.10/ResetConfigToDefaults.html | | | +| Support File | http://192.168.2.10/textBasedConfig.html | | | +| Locator | http://192.168.2.10/locator.html | | | +| Maintenance | http://192.168.2.10/tree1.html#Maintenance | | | +| Backup Manager | http://192.168.2.10/UploadFile.html | y | yes | +| Update Manager | http://192.168.2.10/http_file_download.html | | | +| Password Manager | http://192.168.2.10/UserAccounts.html | | | +| Save Configuration | http://192.168.2.10/SaveAllChanges.html | | | +| Dual Image Configuration | http://192.168.2.10/dual_image_cfg.html | | | diff --git a/actions/__init__.py b/actions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/actions/locator.py b/actions/locator.py new file mode 100644 index 0000000..166412d --- /dev/null +++ b/actions/locator.py @@ -0,0 +1,28 @@ +import sys + +import switch_client +from extractors.xe_page import fetch_tables + +PATH = "/locator.html" + + +def get_locator_state(session) -> str: + """Returns "Enable" or "Disable".""" + tables = fetch_tables(session, PATH) + return tables["Locator Configuration"]["record"]["Locate"] + + +def set_locator(session, enabled: bool) -> None: + tables = fetch_tables(session, PATH) + field_name = tables["Locator Configuration"]["field_names"]["Locate"] + switch_client.submit_form(session, PATH, {field_name: "Enable" if enabled else "Disable"}) + + +if __name__ == "__main__": + if len(sys.argv) != 2 or sys.argv[1] not in ("on", "off"): + print("usage: python -m actions.locator on|off") + raise SystemExit(1) + + session = switch_client.get_session() + set_locator(session, sys.argv[1] == "on") + print("Locator state now:", get_locator_state(session)) diff --git a/discover.py b/discover.py new file mode 100644 index 0000000..7d29623 --- /dev/null +++ b/discover.py @@ -0,0 +1,36 @@ +import argparse +import os + +import switch_client +import todo +from menu import get_menu_items, get_site_links_html, get_treeframe_url + + +def main(): + parser = argparse.ArgumentParser(description="Log in to the switch and discover its menu tree.") + parser.add_argument("--dump-html", metavar="DIR", help="Save fetched HTML pages into this directory for debugging.") + args = parser.parse_args() + + session = switch_client.get_session() + + if args.dump_html: + os.makedirs(args.dump_html, exist_ok=True) + root_html = switch_client.fetch(session, "/") + with open(os.path.join(args.dump_html, "root.html"), "w", encoding="utf-8") as f: + f.write(root_html) + + treeframe_url = get_treeframe_url(session) + site_links_html = get_site_links_html(session, treeframe_url) + with open(os.path.join(args.dump_html, "site_links.html"), "w", encoding="utf-8") as f: + f.write(site_links_html) + + items = get_menu_items(session) + for item in items: + print(f"{' ' * item.depth}{item.label} -> {item.href}") + + todo.refresh_todo(items) + print(f"\nWrote {todo.TODO_PATH}") + + +if __name__ == "__main__": + main() diff --git a/extractors/__init__.py b/extractors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/extractors/backup_manager.py b/extractors/backup_manager.py new file mode 100644 index 0000000..dced998 --- /dev/null +++ b/extractors/backup_manager.py @@ -0,0 +1,21 @@ +import json + +import switch_client +import todo +from extractors.xe_page import fetch_tables + +PATH = "/UploadFile.html" + + +def get_backup_manager(session) -> dict: + """Returns the current backup-config form fields (method, server + address, etc.) — this page is a config form, not a data table, so the + values reflect whatever was last configured/left blank.""" + tables = fetch_tables(session, PATH) + return tables["Backup"]["record"] + + +if __name__ == "__main__": + session = switch_client.get_session() + print(json.dumps(get_backup_manager(session), indent=2)) + todo.mark_implemented(switch_client.BASE_URL + PATH) diff --git a/extractors/buffered_log.py b/extractors/buffered_log.py new file mode 100644 index 0000000..0fbcd05 --- /dev/null +++ b/extractors/buffered_log.py @@ -0,0 +1,20 @@ +import json + +import switch_client +import todo +from extractors.xe_page import fetch_tables + +PATH = "/BufferedLogs.html" + + +def get_buffered_log(session) -> list[dict]: + """Returns one dict per log entry (Log Index, Severity, Log Time, + Component, Description, Message Text).""" + tables = fetch_tables(session, PATH) + return tables["Log Message"]["rows"] + + +if __name__ == "__main__": + session = switch_client.get_session() + print(json.dumps(get_buffered_log(session), indent=2)) + todo.mark_implemented(switch_client.BASE_URL + PATH) diff --git a/extractors/clock.py b/extractors/clock.py new file mode 100644 index 0000000..3271ad9 --- /dev/null +++ b/extractors/clock.py @@ -0,0 +1,20 @@ +import json + +import switch_client +import todo +from extractors.xe_page import fetch_tables + +PATH = "/Clock_Detail.html" + + +def get_clock(session) -> dict: + """Returns {"Current Time": record, "Time Zone": record, + "Daylight Saving Time": record}.""" + tables = fetch_tables(session, PATH) + return {caption: table["record"] for caption, table in tables.items()} + + +if __name__ == "__main__": + session = switch_client.get_session() + print(json.dumps(get_clock(session), indent=2)) + todo.mark_implemented(switch_client.BASE_URL + PATH) diff --git a/extractors/lldp_statistics.py b/extractors/lldp_statistics.py new file mode 100644 index 0000000..5885102 --- /dev/null +++ b/extractors/lldp_statistics.py @@ -0,0 +1,22 @@ +import json + +import switch_client +import todo +from extractors.xe_page import fetch_tables + +PATH = "/LLDPStats.html" + + +def get_lldp_statistics(session) -> dict: + """Returns {"global": record, "interfaces": list[dict]}.""" + tables = fetch_tables(session, PATH) + return { + "global": tables["LLDP Global Statistics"]["record"], + "interfaces": tables["LLDP Interface Statistics"]["rows"], + } + + +if __name__ == "__main__": + session = switch_client.get_session() + print(json.dumps(get_lldp_statistics(session), indent=2)) + todo.mark_implemented(switch_client.BASE_URL + PATH) diff --git a/extractors/log_configuration.py b/extractors/log_configuration.py new file mode 100644 index 0000000..dd86213 --- /dev/null +++ b/extractors/log_configuration.py @@ -0,0 +1,18 @@ +import json + +import switch_client +import todo +from extractors.xe_page import fetch_tables + +PATH = "/Logging.html" + + +def get_log_configuration(session) -> dict: + tables = fetch_tables(session, PATH) + return tables["Logging"]["record"] + + +if __name__ == "__main__": + session = switch_client.get_session() + print(json.dumps(get_log_configuration(session), indent=2)) + todo.mark_implemented(switch_client.BASE_URL + PATH) diff --git a/extractors/network_setup.py b/extractors/network_setup.py new file mode 100644 index 0000000..01352a6 --- /dev/null +++ b/extractors/network_setup.py @@ -0,0 +1,20 @@ +import json + +import switch_client +import todo +from extractors.xe_page import fetch_tables + +PATH = "/Network.html" + + +def get_network_setup(session) -> dict: + """Returns {table_caption: record} for Network Details, Web Parameters, + Management Access and SNMP.""" + tables = fetch_tables(session, PATH) + return {caption: table["record"] for caption, table in tables.items()} + + +if __name__ == "__main__": + session = switch_client.get_session() + print(json.dumps(get_network_setup(session), indent=2)) + todo.mark_implemented(switch_client.BASE_URL + PATH) diff --git a/extractors/port_summary.py b/extractors/port_summary.py new file mode 100644 index 0000000..23b4174 --- /dev/null +++ b/extractors/port_summary.py @@ -0,0 +1,21 @@ +import json + +import switch_client +import todo +from extractors.xe_page import fetch_tables + +PATH = "/PortSummary.html" + + +def get_port_summary(session) -> list[dict]: + """Returns one dict per port (Interface, Physical Type, Port Status, + AutoNeg Status, Link Speed, MTU).""" + tables = fetch_tables(session, PATH) + return tables["Port Summary"]["rows"] + + +if __name__ == "__main__": + session = switch_client.get_session() + ports = get_port_summary(session) + print(json.dumps(ports, indent=2)) + todo.mark_implemented(switch_client.BASE_URL + PATH) diff --git a/extractors/sntp.py b/extractors/sntp.py new file mode 100644 index 0000000..eabfd71 --- /dev/null +++ b/extractors/sntp.py @@ -0,0 +1,18 @@ +import json + +import switch_client +import todo +from extractors.xe_page import fetch_tables + +PATH = "/SNTP.html" + + +def get_sntp(session) -> dict: + tables = fetch_tables(session, PATH) + return tables["SNTP Configuration"]["record"] + + +if __name__ == "__main__": + session = switch_client.get_session() + print(json.dumps(get_sntp(session), indent=2)) + todo.mark_implemented(switch_client.BASE_URL + PATH) diff --git a/extractors/system_description.py b/extractors/system_description.py new file mode 100644 index 0000000..fd22c4d --- /dev/null +++ b/extractors/system_description.py @@ -0,0 +1,18 @@ +import json + +import switch_client +import todo +from extractors.xe_page import fetch_tables + +PATH = "/SysDescription.html" + + +def get_system_description(session) -> dict: + tables = fetch_tables(session, PATH) + return tables["System Description"]["record"] + + +if __name__ == "__main__": + session = switch_client.get_session() + print(json.dumps(get_system_description(session), indent=2)) + todo.mark_implemented(switch_client.BASE_URL + PATH) diff --git a/extractors/xe_page.py b/extractors/xe_page.py new file mode 100644 index 0000000..138d2e9 --- /dev/null +++ b/extractors/xe_page.py @@ -0,0 +1,76 @@ +"""Generic parser for the switch firmware's auto-generated ("XE") pages. + +Every data page (SysDescription.html, PortSummary.html, BufferedLogs.html, ...) +renders one or more elements in one of two shapes: + + - "scalar": no is a label/value pair via + per data row, each +
headers, each
(label text) + . + e.g. Network.html's "Network Details" table. + - "tabular": column headers followed by one
holding the cell value inside a hidden . + e.g. PortSummary.html's one row per switch port. + +This module parses either shape without needing per-page-specific code; each +extractors/.py module just calls fetch_tables() and picks out the +table(s)/fields it cares about by caption. +""" + +from bs4 import BeautifulSoup + +import switch_client + + +def extract_cell_value(td) -> str: + inp = td.find("input") + if inp is not None: + return inp.get("value", "") + return td.get_text(strip=True) + + +def parse_table(table) -> dict: + caption_tag = table.find("caption") + caption = caption_tag.get_text(strip=True) if caption_tag else None + headers = [th.get_text(strip=True) for th in table.find_all("th", recursive=False)] + trs = table.find_all("tr", recursive=False) + + if headers: + rows = [] + for tr in trs: + tds = tr.find_all("td", recursive=False) + if len(tds) != len(headers): + continue + rows.append({h: extract_cell_value(td) for h, td in zip(headers, tds)}) + return {"caption": caption, "kind": "tabular", "rows": rows} + + record = {} + field_names = {} + for tr in trs: + defleft = tr.find("td", class_="defleft") + defright = tr.find("td", class_="defright") + if defleft is None or defright is None: + continue + label = defleft.get_text(strip=True) + if not label: + continue + record[label] = extract_cell_value(defright) + inp = defright.find("input") + if inp is not None and inp.get("name"): + field_names[label] = inp["name"] + # field_names lets a write action look up the real for a + # label instead of hardcoding a guessed "v_R_C_1"-style field name. + return {"caption": caption, "kind": "scalar", "record": record, "field_names": field_names} + + +def parse_xe_tables(html: str) -> list[dict]: + soup = BeautifulSoup(html, "html.parser") + return [parse_table(t) for t in soup.find_all("table")] + + +def fetch_tables(session, path: str) -> dict[str, dict]: + """Fetch path and return {caption: table} for every captioned table. + + Tables with no/blank caption (usually just a submit button row) are + dropped since there's nothing useful to key them by.""" + html = switch_client.fetch(session, path) + tables = parse_xe_tables(html) + return {t["caption"]: t for t in tables if t["caption"]} diff --git a/menu.py b/menu.py new file mode 100644 index 0000000..86ba476 --- /dev/null +++ b/menu.py @@ -0,0 +1,101 @@ +from dataclasses import dataclass +from urllib.parse import urljoin + +from bs4 import BeautifulSoup + +import switch_client + + +@dataclass +class MenuItem: + label: str + href: str | None + parent: str | None + depth: int + + +def find_frame_src(html: str, frame_id_or_name: str) -> str | None: + """Search for a /