diff --git a/.github/actions/setup-mkdocs/action.yml b/.github/actions/setup-mkdocs/action.yml new file mode 100644 index 00000000..69a5568f --- /dev/null +++ b/.github/actions/setup-mkdocs/action.yml @@ -0,0 +1,20 @@ +name: 'Setup MkDocs' +description: "Install MkDocs and the plugins required to build the Infix User's Guide (post-build.sh bundles it into WebUI images)." + +# Mirrors the dependency set in .github/workflows/docs.yml. Kept in-repo +# (rather than an external action) so any checkout of a commit needing +# docs-in-image can reproduce it without an out-of-tree dependency. +runs: + using: composite + steps: + - name: Install MkDocs and plugins + shell: bash + run: | + command -v pipx >/dev/null 2>&1 || python3 -m pip install --user pipx + python3 -m pipx install --force mkdocs + python3 -m pipx inject mkdocs \ + mkdocs-material pymdown-extensions mkdocs-callouts mike mkdocs-to-pdf mkdocs-glightbox + # Make the pipx-installed mkdocs visible to later steps (the build, + # where post-build.sh invokes it). + python3 -m pipx environment --value PIPX_BIN_DIR >> "$GITHUB_PATH" 2>/dev/null \ + || echo "$HOME/.local/bin" >> "$GITHUB_PATH" diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 2e600039..059ef3e7 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -45,6 +45,9 @@ jobs: target: ${{ matrix.target }} enabled: ${{ inputs.use_cache }} + # WebUI images bundle the mkdocs User's Guide via post-build.sh. + - uses: ./.github/actions/setup-mkdocs + - name: Configure & Build env: INFIX_RELEASE: ${{ steps.vars.outputs.ver }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 47cc271d..afc9e145 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -124,6 +124,9 @@ jobs: with: target: ${{ env.TARGET }} + # WebUI images bundle the mkdocs User's Guide via post-build.sh. + - uses: ./.github/actions/setup-mkdocs + - name: Configure ${{ env.TARGET }} run: | make ${{ env.TARGET }}_defconfig diff --git a/board/common/post-build.sh b/board/common/post-build.sh index 2f9e665e..305f77ad 100755 --- a/board/common/post-build.sh +++ b/board/common/post-build.sh @@ -120,3 +120,29 @@ grep -qsE '^/bin/clish$$' "$TARGET_DIR/etc/shells" \ if [ "$BR2_PACKAGE_HOST_PYTHON_YANGDOC" = "y" ]; then mkyangdoc "$BINARIES_DIR/yangdoc.html" fi + +# Bundle the mkdocs User's Guide into the rootfs, served by the WebUI's +# nginx at /guide/. Only when the WebUI is present (nothing serves it +# otherwise, and it keeps minimal images small) and mkdocs is on the build +# host. Best-effort: a failed build warns but does not abort the image +# build, and the WebUI hides its User Guide entry when the docs are absent. +mkuserguide() +{ + local cfg dst + cfg="$(readlink -f "$common/../..")/mkdocs.yml" + dst="$TARGET_DIR/var/www/guide" + + if ! command -v mkdocs >/dev/null 2>&1; then + ixmsg "mkdocs not found, skipping User's Guide bundling" + return + fi + ixmsg "Building User's Guide into $dst" + if ! mkdocs build -f "$cfg" -d "$dst" --clean --quiet; then + ixmsg "WARNING: mkdocs build failed, shipping without on-device User's Guide" + rm -rf "$dst" + fi +} + +if [ "$BR2_PACKAGE_WEBUI" = "y" ]; then + mkuserguide +fi diff --git a/board/common/rootfs/etc/default/webui b/board/common/rootfs/etc/default/webui new file mode 100644 index 00000000..4b9f2800 --- /dev/null +++ b/board/common/rootfs/etc/default/webui @@ -0,0 +1,5 @@ +RESTCONF_URL=https://127.0.0.1/restconf +INSECURE_TLS=1 +# Spool firmware uploads (and any other temp files) to persistent storage +# instead of the RAM-backed /tmp to enable push-upgrades on low-memory systems. +TMPDIR=/var/tmp diff --git a/board/common/rootfs/etc/nginx/app/restconf.conf b/board/common/rootfs/etc/nginx/app/restconf.conf new file mode 120000 index 00000000..01182dc2 --- /dev/null +++ b/board/common/rootfs/etc/nginx/app/restconf.conf @@ -0,0 +1 @@ +../restconf.app \ No newline at end of file diff --git a/board/common/rootfs/etc/nginx/restconf-access-local.conf b/board/common/rootfs/etc/nginx/restconf-access-local.conf new file mode 100644 index 00000000..ec361e7d --- /dev/null +++ b/board/common/rootfs/etc/nginx/restconf-access-local.conf @@ -0,0 +1,3 @@ +allow 127.0.0.1; +allow ::1; +deny all; diff --git a/board/common/rootfs/etc/nginx/restconf-access.conf b/board/common/rootfs/etc/nginx/restconf-access.conf new file mode 120000 index 00000000..3a404cd8 --- /dev/null +++ b/board/common/rootfs/etc/nginx/restconf-access.conf @@ -0,0 +1 @@ +restconf-access-local.conf \ No newline at end of file diff --git a/board/common/rootfs/etc/nginx/restconf.app b/board/common/rootfs/etc/nginx/restconf.app index 7c8cadda..1d261741 100644 --- a/board/common/rootfs/etc/nginx/restconf.app +++ b/board/common/rootfs/etc/nginx/restconf.app @@ -1,5 +1,6 @@ # /telemetry/optics is for streaming (not used atm) location ~ ^/(restconf|yang|.well-known)/ { + include /etc/nginx/restconf-access.conf; grpc_pass grpc://[::1]:10080; grpc_set_header Host $host; grpc_set_header X-Real-IP $remote_addr; diff --git a/configs/aarch64_defconfig b/configs/aarch64_defconfig index d28e9ed5..447424ea 100644 --- a/configs/aarch64_defconfig +++ b/configs/aarch64_defconfig @@ -175,7 +175,6 @@ BR2_PACKAGE_FIREWALL=y BR2_PACKAGE_IITO=y BR2_PACKAGE_KEYACK=y BR2_PACKAGE_KLISH_PLUGIN_INFIX=y -BR2_PACKAGE_LANDING=y BR2_PACKAGE_LOWDOWN=y BR2_PACKAGE_MCD=y BR2_PACKAGE_MDNS_ALIAS=y @@ -187,6 +186,8 @@ BR2_PACKAGE_PODMAN_DRIVER_DEVICEMAPPER=y BR2_PACKAGE_PODMAN_DRIVER_VFS=y BR2_PACKAGE_TETRIS=y BR2_PACKAGE_ROUSETTE=y +# BR2_PACKAGE_LANDING is not set +BR2_PACKAGE_WEBUI=y BR2_PACKAGE_RAUC_INSTALLATION_STATUS=y BR2_PACKAGE_HOST_PYTHON_YANGDOC=y BR2_PACKAGE_PCIUTILS=y diff --git a/configs/aarch64_minimal_defconfig b/configs/aarch64_minimal_defconfig index 9ba5cb7c..c00579d0 100644 --- a/configs/aarch64_minimal_defconfig +++ b/configs/aarch64_minimal_defconfig @@ -144,6 +144,7 @@ BR2_PACKAGE_FIREWALL=y BR2_PACKAGE_IITO=y BR2_PACKAGE_KEYACK=y BR2_PACKAGE_KLISH_PLUGIN_INFIX=y +BR2_PACKAGE_LANDING=y BR2_PACKAGE_LOWDOWN=y BR2_PACKAGE_MCD=y BR2_PACKAGE_MDNS_ALIAS=y diff --git a/configs/arm_defconfig b/configs/arm_defconfig index be928860..c08ba158 100644 --- a/configs/arm_defconfig +++ b/configs/arm_defconfig @@ -162,7 +162,6 @@ BR2_PACKAGE_FIREWALL=y BR2_PACKAGE_IITO=y BR2_PACKAGE_KEYACK=y BR2_PACKAGE_KLISH_PLUGIN_INFIX=y -BR2_PACKAGE_LANDING=y BR2_PACKAGE_LOWDOWN=y BR2_PACKAGE_MCD=y BR2_PACKAGE_MDNS_ALIAS=y @@ -170,6 +169,8 @@ BR2_PACKAGE_NETBROWSE=y BR2_PACKAGE_ONIEPROM=y BR2_PACKAGE_TETRIS=y BR2_PACKAGE_ROUSETTE=y +# BR2_PACKAGE_LANDING is not set +BR2_PACKAGE_WEBUI=y BR2_PACKAGE_RAUC_INSTALLATION_STATUS=y BR2_PACKAGE_HOST_PYTHON_YANGDOC=y IMAGE_ITB_AUX=y diff --git a/configs/arm_minimal_defconfig b/configs/arm_minimal_defconfig index d1cd8025..b9090d2f 100644 --- a/configs/arm_minimal_defconfig +++ b/configs/arm_minimal_defconfig @@ -142,6 +142,7 @@ BR2_PACKAGE_FIREWALL=y BR2_PACKAGE_IITO=y BR2_PACKAGE_KEYACK=y BR2_PACKAGE_KLISH_PLUGIN_INFIX=y +BR2_PACKAGE_LANDING=y BR2_PACKAGE_LOWDOWN=y BR2_PACKAGE_MCD=y BR2_PACKAGE_MDNS_ALIAS=y diff --git a/configs/riscv64_defconfig b/configs/riscv64_defconfig index 30d59e69..b687676f 100644 --- a/configs/riscv64_defconfig +++ b/configs/riscv64_defconfig @@ -194,7 +194,6 @@ BR2_PACKAGE_FIREWALL=y BR2_PACKAGE_IITO=y BR2_PACKAGE_KEYACK=y BR2_PACKAGE_KLISH_PLUGIN_INFIX=y -BR2_PACKAGE_LANDING=y BR2_PACKAGE_LOWDOWN=y BR2_PACKAGE_MCD=y BR2_PACKAGE_MDNS_ALIAS=y @@ -206,6 +205,8 @@ BR2_PACKAGE_PODMAN_DRIVER_DEVICEMAPPER=y BR2_PACKAGE_PODMAN_DRIVER_VFS=y BR2_PACKAGE_TETRIS=y BR2_PACKAGE_ROUSETTE=y +# BR2_PACKAGE_LANDING is not set +BR2_PACKAGE_WEBUI=y BR2_PACKAGE_RAUC_INSTALLATION_STATUS=y BR2_PACKAGE_HOST_PYTHON_YANGDOC=y BR2_PACKAGE_PCIUTILS=y diff --git a/configs/x86_64_defconfig b/configs/x86_64_defconfig index 903533b5..981cabf5 100644 --- a/configs/x86_64_defconfig +++ b/configs/x86_64_defconfig @@ -169,7 +169,6 @@ BR2_PACKAGE_FIREWALL=y BR2_PACKAGE_IITO=y BR2_PACKAGE_KEYACK=y BR2_PACKAGE_KLISH_PLUGIN_INFIX=y -BR2_PACKAGE_LANDING=y BR2_PACKAGE_LOWDOWN=y BR2_PACKAGE_MCD=y BR2_PACKAGE_MDNS_ALIAS=y @@ -181,6 +180,8 @@ BR2_PACKAGE_PODMAN_DRIVER_DEVICEMAPPER=y BR2_PACKAGE_PODMAN_DRIVER_VFS=y BR2_PACKAGE_TETRIS=y BR2_PACKAGE_ROUSETTE=y +# BR2_PACKAGE_LANDING is not set +BR2_PACKAGE_WEBUI=y BR2_PACKAGE_RAUC_INSTALLATION_STATUS=y BR2_PACKAGE_HOST_PYTHON_YANGDOC=y BR2_PACKAGE_PCIUTILS=y diff --git a/configs/x86_64_minimal_defconfig b/configs/x86_64_minimal_defconfig index 816cdd0f..893163ec 100644 --- a/configs/x86_64_minimal_defconfig +++ b/configs/x86_64_minimal_defconfig @@ -141,6 +141,7 @@ BR2_PACKAGE_FIREWALL=y BR2_PACKAGE_IITO=y BR2_PACKAGE_KEYACK=y BR2_PACKAGE_KLISH_PLUGIN_INFIX=y +BR2_PACKAGE_LANDING=y BR2_PACKAGE_LOWDOWN=y BR2_PACKAGE_MCD=y BR2_PACKAGE_MDNS_ALIAS=y diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index ad369471..9544e32d 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -4,11 +4,15 @@ Change Log All notable changes to the project are documented in this file. [v26.06.0][UNRELEASED] --------------- +------------------------- ### Changes - Upgrade Linux kernel to 6.18.35 (LTS) +- Add basic web interface: static status pages and a tree view of operational + status. Curated configuration pages for some common tasks and a YANG tree + editor for the rest. Also includes a maintenance section for firmware + upgrade, backup & restore, and more - Add Wi-Fi roaming for fast, seamless handoff between access points that share an SSID: 802.11k, 802.11v and 802.11r (over-the-air FT). See the [Wi-Fi][wifi] guide for details @@ -21,11 +25,15 @@ All notable changes to the project are documented in this file. ### Fixes -- Firewall masquerade no longer enables the global IPv4/IPv6 forwarding - sysctls. You must now enable IP forwarding explicitly on the interfaces - that should route traffic; enabling NAT alone is no longer enough +- Enabling IP masquerading in the firewall no longer enables IP forwarding on + all interfaces. This has been an issue ever since the firewall support was + introduced in v25.10.0 +- Fix file permission regression in `/cfg/startup-config.cfg`, causing the + default `admin` user no permission to read or write the file from shell +- Fix admin url shown for HTTP/HTTPS links in browser, + used pre-conflict resolution hostname.local, instead of hostname-2.local -[wifi]: wifi.md +[wifi]: https://www.kernelkit.org/infix/latest/wifi/ [v26.05.0][] - 2026-05-29 ------------------------- @@ -84,9 +92,9 @@ All notable changes to the project are documented in this file. show`, which were not expanded, so `show interface` and other operational reads failed. Ranges are now expanded and listed correctly -[ethernet]: ethernet.md#restricting-advertised-link-modes +[ethernet]: https://www.kernelkit.org/infix/latest/ethernet/#restricting-advertised-link-modes [BPI-R3]: https://docs.banana-pi.org/en/BPI-R3/BananaPi_BPI-R3 -[AcerConnectVero]: ../board/aarch64/acer-connect-vero-w6m/ +[AcerConnectVero]: https://github.com/kernelkit/infix/tree/main/board/aarch64/acer-connect-vero-w6m [v26.04.0][] - 2026-04-30 ------------------------- @@ -2160,6 +2168,7 @@ Supported YANG models in addition to those used by sysrepo and netopeer: [buildroot]: https://buildroot.org/ [UNRELEASED]: https://github.com/kernelkit/infix/compare/v26.05.0...HEAD +[v26.06.0]: https://github.com/kernelkit/infix/compare/v26.05.0...v26.06.0 [v26.05.0]: https://github.com/kernelkit/infix/compare/v26.04.0...v26.05.0 [v26.04.0]: https://github.com/kernelkit/infix/compare/v26.03.0...v26.04.0 [v26.03.0]: https://github.com/kernelkit/infix/compare/v26.02.0...v26.03.0 diff --git a/doc/developers-guide.md b/doc/developers-guide.md index 032fc69d..b1cdd2e4 100644 --- a/doc/developers-guide.md +++ b/doc/developers-guide.md @@ -122,13 +122,21 @@ recommend using `pipx` to install the necessary tooling: ```bash $ sudo apt install pipx $ pipx install mkdocs -$ pipx inject mkdocs mkdocs-material pymdown-extensions mkdocs-callouts mike mkdocs-to-pdf +$ pipx inject mkdocs mkdocs-material pymdown-extensions mkdocs-callouts mike mkdocs-to-pdf mkdocs-glightbox ``` -The last two packages, `mike` and `mkdocs-to-pdf`, are used for online -versioning and PDF generation by GitHub Actions, but since they are in -the `mkdocs.yml` file, everyone who wants to preview the documentation -have to install all the tooling. +The `mike` and `mkdocs-to-pdf` packages are used for online versioning +and PDF generation by GitHub Actions, but since every plugin is listed +in `mkdocs.yml`, anyone who wants to preview the documentation has to +install all the tooling. + +> [!IMPORTANT] +> MkDocs is also required to **build a WebUI image**. The build bundles +> the User's Guide into the image (served on-device at `/guide/`), so +> `make` runs `mkdocs build` from `post-build.sh` when the `webui` +> package is selected. If MkDocs is missing the build still succeeds, +> but the image ships without the on-device guide. Minimal images and +> any build without the `webui` package skip this step entirely. Preview with: diff --git a/package/Config.in b/package/Config.in index 110d247b..6998f580 100644 --- a/package/Config.in +++ b/package/Config.in @@ -42,6 +42,7 @@ source "$BR2_EXTERNAL_INFIX_PATH/package/tetris/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/libyang-cpp/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/sysrepo-cpp/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/rousette/Config.in" +source "$BR2_EXTERNAL_INFIX_PATH/package/webui/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/nghttp2-asio/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/date-cpp/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/rauc-installation-status/Config.in" diff --git a/board/common/rootfs/etc/nginx/available/default.conf b/package/landing/default.conf similarity index 100% rename from board/common/rootfs/etc/nginx/available/default.conf rename to package/landing/default.conf diff --git a/package/landing/landing.mk b/package/landing/landing.mk index 6a18d67c..076a8deb 100644 --- a/package/landing/landing.mk +++ b/package/landing/landing.mk @@ -14,6 +14,8 @@ define LANDING_INSTALL_TARGET_CMDS mkdir -p $(TARGET_DIR)/usr/html/ cp $(@D)/*.html $(TARGET_DIR)/usr/html/ cp $(@D)/*.png $(TARGET_DIR)/usr/html/ + $(INSTALL) -D -m 0644 $(LANDING_PKGDIR)/default.conf \ + $(TARGET_DIR)/etc/nginx/available/default.conf endef $(eval $(generic-package)) diff --git a/package/webui/50x.html b/package/webui/50x.html new file mode 100644 index 00000000..b2b334aa --- /dev/null +++ b/package/webui/50x.html @@ -0,0 +1,30 @@ + + + +Loading… + + + + + +

Loading…

+

The device is finishing its startup. This page refreshes automatically.

+

+ + diff --git a/package/webui/Config.in b/package/webui/Config.in new file mode 100644 index 00000000..cbf1a354 --- /dev/null +++ b/package/webui/Config.in @@ -0,0 +1,9 @@ +config BR2_PACKAGE_WEBUI + bool "webui" + depends on BR2_PACKAGE_HOST_GO_TARGET_ARCH_SUPPORTS + depends on BR2_PACKAGE_ROUSETTE + depends on !BR2_PACKAGE_LANDING + help + Web management interface for Infix, a Go+HTMX application + that provides browser-based configuration and monitoring + via RESTCONF. diff --git a/package/webui/default.conf b/package/webui/default.conf new file mode 100644 index 00000000..20707bf6 --- /dev/null +++ b/package/webui/default.conf @@ -0,0 +1,44 @@ +# Throttle POST /login so a brute-force attempt can't pin the box on +# the bcrypt-shaped credential check inside rousette/PAM. GET is left +# unmetered: page loads after a 401-driven HX-Redirect shouldn't eat +# into the budget. +# +# Zone is 32k (~500 IPs) — the minimum nginx accepts via shared-memory +# allocation. With the cache full an attacker rotating source addresses +# still tops out at ~2500 attempts/min total, all serialised through +# bcrypt one at a time. +# +# 429 instead of the default 503 keeps the rate-limit response out of +# the /50x.html error_page rewrite below (which auto-refreshes — would +# loop a throttled client straight back to /login). +map $request_method $webui_login_key { + POST $binary_remote_addr; + default ""; +} +limit_req_zone $webui_login_key zone=webui_login:32k rate=5r/m; +limit_req_status 429; + +server { + listen 80; + listen [::]:80; + server_name _; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + server_name _; + include ssl.conf; + + # 404 also points at /50x.html: the page is a "Loading…" screen + # with a meta-refresh, so the early-boot window where the Go + # backend isn't up yet (and any other transient 404 / 5xx) self- + # recovers as soon as upstream comes back. + error_page 404 500 502 503 504 /50x.html; + location = /50x.html { + root html; + } + + include /etc/nginx/app/*.conf; +} diff --git a/package/webui/webui-proxy.conf b/package/webui/webui-proxy.conf new file mode 100644 index 00000000..923990d0 --- /dev/null +++ b/package/webui/webui-proxy.conf @@ -0,0 +1,11 @@ +# Shared proxy-pass shape for the webui upstream. Nested locations that +# declare their own proxy_* directives don't inherit from the outer block, +# so each location includes this file rather than relying on inheritance. + +proxy_pass http://127.0.0.1:10000; +proxy_http_version 1.1; +proxy_set_header Host $host; +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Forwarded-Proto $scheme; +proxy_redirect off; diff --git a/package/webui/webui.conf b/package/webui/webui.conf new file mode 100644 index 00000000..62c27838 --- /dev/null +++ b/package/webui/webui.conf @@ -0,0 +1,72 @@ +# Must be at server scope, not on an inner location: client_max_body_size +# does not inherit into a nested location that declares its own proxy_pass, +# and the http-level 1m default would silently apply and reject bundle +# uploads with 413. +client_max_body_size 256m; + +location / { + include /etc/nginx/webui-proxy.conf; +} + +# burst=3 nodelay: three POSTs land back-to-back; the next needs a +# fresh token (~12 s at 5r/m) or 429s. +location = /login { + limit_req zone=webui_login burst=3 nodelay; + include /etc/nginx/webui-proxy.conf; +} + +# Keep proxy_request_buffering on (the default). Streaming the body races +# the Go handler's response close, producing RST instead of FIN at +# Content-Length and a client-visible 502. nginx spools the body to +# /var/cache/nginx/client-body during the upload instead. +location = /software/upload { + proxy_read_timeout 600s; + include /etc/nginx/webui-proxy.conf; +} + +# SSE progress stream: RAUC's Progress D-Bus property doesn't change while +# it's writing a slot, so the upstream goes minutes without a frame. The +# default 60 s proxy_read_timeout closes the stream and the browser sees +# a transient error. Raise to cover a slow image write end-to-end. +location = /software/progress { + proxy_read_timeout 1800s; + proxy_buffering off; + include /etc/nginx/webui-proxy.conf; +} + +# SSE live-tail stream for Maintenance > Logs. Same buffering / timeout +# concerns as the software progress stream: nginx must not buffer the +# event frames, and the connection has to stay open through quiet logs. +# The Go side sends a 15 s heartbeat, so 600 s of read timeout is plenty +# of safety margin. +location ~ ^/maintenance/logs/[^/]+/tail$ { + proxy_read_timeout 600s; + proxy_buffering off; + include /etc/nginx/webui-proxy.conf; +} + +# Support bundle collection runs `support collect`, which emits nothing +# until it finishes (~50 s) and then sends the whole archive at once. +# The upstream is silent for that window, so the default 60 s +# proxy_read_timeout would 504 just as the bundle is ready. Buffering +# stays on — it's a plain file download, not a stream. +location = /maintenance/support-bundle { + proxy_read_timeout 300s; + include /etc/nginx/webui-proxy.conf; +} + +# On-device User's Guide: the mkdocs site bundled into the rootfs at +# /var/www/guide by post-build.sh. Served as static files (no upstream +# call), public like the published guide. Present only when the build +# host had mkdocs; the WebUI hides its User Guide entry when it's absent. +location /guide/ { + alias /var/www/guide/; + index index.html; +} + +# Liveness probe — nginx-only, no upstream call. Used by the watchdog +# div in base.html and the reboot-overlay poller. +location = /device-status { + access_log off; + return 204; +} diff --git a/package/webui/webui.mk b/package/webui/webui.mk new file mode 100644 index 00000000..f95c28f3 --- /dev/null +++ b/package/webui/webui.mk @@ -0,0 +1,29 @@ +################################################################################ +# +# webui +# +################################################################################ + +WEBUI_VERSION = 1.0 +WEBUI_SITE_METHOD = local +WEBUI_SITE = $(BR2_EXTERNAL_INFIX_PATH)/src/webui +WEBUI_GOMOD = infix/webui +WEBUI_LICENSE = MIT +WEBUI_LICENSE_FILES = LICENSE +WEBUI_REDISTRIBUTE = NO + +define WEBUI_INSTALL_EXTRA + $(INSTALL) -D -m 0644 $(WEBUI_PKGDIR)/webui.svc \ + $(FINIT_D)/available/webui.conf + $(INSTALL) -D -m 0644 $(WEBUI_PKGDIR)/webui.conf \ + $(TARGET_DIR)/etc/nginx/app/webui.conf + $(INSTALL) -D -m 0644 $(WEBUI_PKGDIR)/webui-proxy.conf \ + $(TARGET_DIR)/etc/nginx/webui-proxy.conf + $(INSTALL) -D -m 0644 $(WEBUI_PKGDIR)/default.conf \ + $(TARGET_DIR)/etc/nginx/available/default.conf + $(INSTALL) -D -m 0644 $(WEBUI_PKGDIR)/50x.html \ + $(TARGET_DIR)/usr/html/50x.html +endef +WEBUI_POST_INSTALL_TARGET_HOOKS += WEBUI_INSTALL_EXTRA + +$(eval $(golang-package)) diff --git a/package/webui/webui.svc b/package/webui/webui.svc new file mode 100644 index 00000000..9a8979e3 --- /dev/null +++ b/package/webui/webui.svc @@ -0,0 +1,3 @@ +service name:webui log:prio:daemon.info,tag:webui \ + [2345] env:-/etc/default/webui webui -listen 127.0.0.1:10000 \ + -- Web management interface diff --git a/src/bin/copy.c b/src/bin/copy.c index bd6a5972..2fcc8584 100644 --- a/src/bin/copy.c +++ b/src/bin/copy.c @@ -138,9 +138,16 @@ static void set_owner(const char *fn, const char *user) if (chown(fn, -1, gid) && errno != EPERM) { const struct group *gr = getgrgid(gid); - warn("setting group owner %s (%d) on %s", + warn("failed setting group owner %s (%d) on %s", gr ? gr->gr_name : "", gid, fn); } + + /* Make sure the group we just set can actually read/write the file. + * umask alone can't: the datastore export goes through a 0600 mkstemp + * temp and cp(1) propagates that mode to the destination. + */ + if (chmod(fn, 0660) && errno != EPERM) + warn("failed setting mode 0660 on %s", fn); } static const char *infix_ds(const char *text, const struct infix_ds **ds) diff --git a/src/confd/src/services.c b/src/confd/src/services.c index 0981f80d..b1db7caf 100644 --- a/src/confd/src/services.c +++ b/src/confd/src/services.c @@ -19,6 +19,8 @@ #define GENERATE_STRING(STRING) #STRING, #define NGINX_SSL_CONF "/etc/nginx/ssl.conf" +#define NGINX_RESTCONF_ACCESS "/etc/nginx/restconf-access.conf" +#define NGINX_RESTCONF_ACCESS_LOCAL "restconf-access-local.conf" #define AVAHI_SVC_PATH "/etc/avahi/services" #define LLDP_CONFIG "/etc/lldpd.d/confd.conf" @@ -557,7 +559,30 @@ static int restconf_change(sr_session_ctx_t *session, struct lyd_node *config, s ena = lydx_is_enabled(srv, "enabled") && lydx_is_enabled(lydx_get_xpathf(config, WEB_XPATH), "enabled"); - svc_enable(ena, restconf, "restconf"); + + /* + * restconf.app is permanently installed in nginx/app/ so rousette is + * always reachable from loopback (required by the WebUI). External + * access is gated by an `include` of restconf-access.conf — empty + * means no allow/deny rules apply and nginx's default of `allow all` + * takes effect; populated means loopback-only. + * + * The shipped state is a symlink to restconf-access-local.conf (which + * holds the loopback rules), so the rules are defined in exactly one + * place. erase() before re-creating because fopen("w") on a symlink + * follows the link and would clobber the template; the empty-file + * path needs a real file, and the loopback path needs a fresh + * symlink that may have been replaced on a previous transition. + */ + erase(NGINX_RESTCONF_ACCESS); + if (ena) { + FILE *fp = fopen(NGINX_RESTCONF_ACCESS, "w"); + if (fp) + fclose(fp); + } else if (symlink(NGINX_RESTCONF_ACCESS_LOCAL, NGINX_RESTCONF_ACCESS)) { + ERRNO("failed restoring %s", NGINX_RESTCONF_ACCESS); + } + mdns_records(ena ? MDNS_ADD : MDNS_DELETE, restconf); finit_reload("nginx"); return put(cfg); @@ -703,13 +728,16 @@ static int web_change(sr_session_ctx_t *session, struct lyd_node *config, struct /* Web master on/off: propagate to nginx and all sub-services */ if (lydx_get_xpathf(diff, WEB_XPATH "/enabled")) { + int rc_ena = ena && lydx_is_enabled(lydx_get_xpathf(config, WEB_RESTCONF_XPATH), "enabled"); int nb_ena = ena && lydx_is_enabled(lydx_get_xpathf(config, WEB_NETBROWSE_XPATH), "enabled"); svc_enable(ena && lydx_is_enabled(lydx_get_xpathf(config, WEB_CONSOLE_XPATH), "enabled"), ttyd, "ttyd"); svc_enable(nb_ena, netbrowse, "netbrowse"); - svc_enable(ena && lydx_is_enabled(lydx_get_xpathf(config, WEB_RESTCONF_XPATH), "enabled"), - restconf, "restconf"); + /* Rousette follows web/enabled; external access is gated separately via restconf/enabled */ + ena ? finit_enable("restconf") : finit_disable("restconf"); + ena ? finit_enable("webui") : finit_disable("webui"); + mdns_records(rc_ena ? MDNS_ADD : MDNS_DELETE, restconf); svc_enable(ena, web, "nginx"); mdns_alias_conf(nb_ena); finit_reload("mdns-alias"); diff --git a/src/netbrowse/browse.go b/src/netbrowse/browse.go index 2f32b041..d06cab3e 100644 --- a/src/netbrowse/browse.go +++ b/src/netbrowse/browse.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "log" + "net/url" "os/exec" "sort" "strings" @@ -221,7 +222,7 @@ func scan() map[string]Host { var url string if meta.adminurl != "" { - url = meta.adminurl + url = rebaseURLHost(meta.adminurl, link) } else if info.urlTemplate != "" { url = strings.NewReplacer( "{address}", address, @@ -340,7 +341,7 @@ func parseOperational(root *opRoot) map[string]Host { var url string if meta.adminurl != "" { - url = meta.adminurl + url = rebaseURLHost(meta.adminurl, link) } else if info.urlTemplate != "" { url = strings.NewReplacer( "{address}", addr, @@ -398,6 +399,30 @@ func scanAuto() map[string]Host { return parseOperational(root) } +// rebaseURLHost rewrites the host of raw to host, keeping scheme, port, and +// path. It corrects stale adminurl TXT records: avahi resolves hostname +// conflicts at runtime by appending -2, -3, … but the advertiser writes the +// adminurl with its configured hostname and never updates it, so a device +// renamed infix-2.local still advertises adminurl=http://infix.local — which +// resolves to the *other* device. The mDNS-resolved host is authoritative. +// A no-op when raw is unparseable, hostless, or already points at host (the +// common single-device case). +func rebaseURLHost(raw, host string) string { + if host == "" { + return raw + } + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return raw + } + if port := u.Port(); port != "" { + u.Host = host + ":" + port + } else { + u.Host = host + } + return u.String() +} + // decode handles avahi's DNS-SD escape sequences in service names: // - \DDD decimal value 0-255, e.g. \058 → ':' // - \X literal character X, e.g. \. → '.' diff --git a/src/rauc-installation-status/rauc-installation-status.c b/src/rauc-installation-status/rauc-installation-status.c index d2832dcc..cecb70e5 100644 --- a/src/rauc-installation-status/rauc-installation-status.c +++ b/src/rauc-installation-status/rauc-installation-status.c @@ -47,10 +47,10 @@ int main(int argc, char **argv) json_object_set_new(json, "last-error", json_string(strval)); props = rauc_installer_get_progress(rauc); if(props) { - GVariant *val; + gint32 pct; progress = json_object(); - g_variant_get(props, "(@isi)", &val, &strval, NULL); - json_object_set_new(progress, "percentage", json_string(g_variant_print(val, FALSE))); + g_variant_get(props, "(isi)", &pct, &strval, NULL); + json_object_set_new(progress, "percentage", json_integer(pct)); json_object_set_new(progress, "message", json_string(strval)); json_object_set_new(json, "progress", progress); } diff --git a/src/statd/python/yanger/ietf_interfaces/ethernet.py b/src/statd/python/yanger/ietf_interfaces/ethernet.py index 2a814c95..55003713 100644 --- a/src/statd/python/yanger/ietf_interfaces/ethernet.py +++ b/src/statd/python/yanger/ietf_interfaces/ethernet.py @@ -203,10 +203,11 @@ def link(ifname): if supported: eth["infix-ethernet-interface:supported-pmd-types"] = supported - # Suppress when advertised == supported — that's the default - # "advertise everything" state with no diagnostic value. + # Always emit when the PHY is advertising something — even when it equals + # the full supported set, displaying the list alongside supported is + # useful for "show interfaces detail" and the WebUI ethernet card. advertised = _ethtool_modes_to_pmd_identities(data.get("advertised-link-modes")) - if advertised and set(advertised) != set(supported): + if advertised: eth["auto-negotiation"]["infix-ethernet-interface:advertised-pmd-types"] = advertised speed_bps = None diff --git a/src/statd/python/yanger/ietf_system.py b/src/statd/python/yanger/ietf_system.py index 60c7c6c0..f82af822 100644 --- a/src/statd/python/yanger/ietf_system.py +++ b/src/statd/python/yanger/ietf_system.py @@ -257,9 +257,16 @@ def add_software(out): insert(out, "infix-system:software", software) def add_hostname(out): - hostname = HOST.run(tuple(["hostname"])) + hostname = HOST.run(tuple(["hostname"])) out["hostname"] = hostname.strip() +def add_contact_location(out): + for name in ("contact", "location"): + data = HOST.run_json(("copy", "running", "-x", f"/system/{name}"), {}) + val = data.get("ietf-system:system", {}).get(name) + if val: + out[name] = val + def add_timezone(out): path = HOST.run(tuple("realpath /etc/localtime".split()), "") timezone = None @@ -415,9 +422,14 @@ def add_resource_usage(out): except (FileNotFoundError, ValueError): pass - # Filesystem usage + # Filesystem usage. /run and /tmp are tmpfs (RAM-backed) and easily the + # scarcest writable storage on small embedded boards — surface them so + # operators can tell when the box is about to start dropping logs or + # failing to spool config diffs. / is the read-only rootfs (squashfs); + # always 100 % used by design but kept in the list so a flag/limit + # consumer can recognise it. filesystems = [] - for mount in ["/", "/var", "/cfg"]: + for mount in ["/", "/var", "/cfg", "/run", "/tmp"]: try: result = HOST.run_multiline(["df", "-k", mount], []) if len(result) > 1: @@ -448,6 +460,7 @@ def operational(): out_state = out["ietf-system:system-state"] out_system = out["ietf-system:system"] add_hostname(out_system) + add_contact_location(out_system) add_users(out_system) add_timezone(out_system) add_software(out_state) diff --git a/src/webui/.gitignore b/src/webui/.gitignore new file mode 100644 index 00000000..859dc423 --- /dev/null +++ b/src/webui/.gitignore @@ -0,0 +1 @@ +webui diff --git a/src/webui/LICENSE b/src/webui/LICENSE new file mode 100644 index 00000000..364389fb --- /dev/null +++ b/src/webui/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2026 The KernelKit Authors + +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/src/webui/Makefile b/src/webui/Makefile new file mode 100644 index 00000000..bcb6e3e2 --- /dev/null +++ b/src/webui/Makefile @@ -0,0 +1,15 @@ +BINARY = webui +GOARCH ?= $(shell go env GOARCH) +GOOS ?= $(shell go env GOOS) + +build: + CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) \ + go build -ldflags="-s -w" -o $(BINARY) . + +dev: build + go run . --listen :10000 --insecure-tls $(ARGS) + +clean: + rm -f $(BINARY) + +.PHONY: build dev clean diff --git a/src/webui/README.md b/src/webui/README.md new file mode 100644 index 00000000..61cb2ff0 --- /dev/null +++ b/src/webui/README.md @@ -0,0 +1,119 @@ +# Infix WebUI + +A lightweight web management interface for [Infix][1] network devices, +built with Go and [htmx][2]. + +The WebUI communicates with the device over [RESTCONF][3] (RFC 8040), +presenting the same operational data available through the Infix CLI in +a browser-friendly format. + +## Features + +- **Dashboard** -- system info, hardware, sensors, and interface summary + with bridge member grouping +- **Interfaces** -- list with status, addresses, and per-type detail; + click through to a detail page with live-updating counters, WiFi + station table, scan results, WireGuard peers, and ethernet frame + statistics +- **Firewall** -- zone-to-zone policy matrix +- **Keystore** -- symmetric and asymmetric key display +- **Firmware** -- slot overview, install from URL with live progress +- **Reboot** -- two-phase status polling (wait down, wait up) +- **Config download** -- startup datastore as JSON + + +## Building + +Requires Go 1.22 or later. + +```sh +make build +``` + +Produces a statically linked `webui` binary with all templates, +CSS, and JS embedded. + +Cross-compile for the target: + +```sh +GOOS=linux GOARCH=arm64 make build +``` + + +## Running + +```sh +./webui --restconf https://192.168.0.1/restconf --listen :10000 +``` + +| **Flag** | **Default** | **Description** | +|-------------------|-----------------------------------|-------------------------------------------| +| `--listen` | `:10000` | Address to listen on | +| `--restconf` | `http://localhost:8080/restconf` | RESTCONF base URL of the device | +| `--insecure-tls` | `false` | Disable TLS certificate verification | + +The RESTCONF URL can also be set via the `RESTCONF_URL` environment +variable. + + +## Development + +Point `RESTCONF_URL` at a running Infix device and start the dev +server: + +```sh +make dev ARGS="--restconf https://192.168.0.1/restconf" +``` + +This runs `go run .` on port 10000 with `--insecure-tls` already set. + + +## Architecture + +``` +Browser ──htmx──▶ Go server ──RESTCONF──▶ Infix device (rousette/sysrepo) +``` + +- **Single binary** -- templates, CSS, JS, and images are embedded via + `go:embed` +- **Server-side rendering** -- Go `html/template` with per-page parsing + to avoid `{{define "content"}}` collisions +- **htmx SPA navigation** -- sidebar links use `hx-get` / `hx-target` + for partial page updates with `hx-push-url` for browser history +- **Stateless sessions** -- AES-256-GCM encrypted cookies carry + credentials (needed for every RESTCONF call); no server-side session + store +- **Live polling** -- counters update every 5s, firmware progress every + 3s, all via htmx triggers + +``` +main.go Entry point, flags, embedded FS +internal/ + auth/ Login, logout, session (AES-GCM cookies) + restconf/ HTTP client (Get, GetRaw, Post, PostJSON) + handlers/ Page handlers + dashboard.go Dashboard, hardware, sensors + interfaces.go Interface list, detail, counters + firewall.go Zone matrix + keystore.go Key display + system.go Firmware, reboot, config download + server/ + server.go Route registration, template wiring, middleware +templates/ + layouts/ base.html (shell), sidebar.html + pages/ Per-page templates (one per route) + fragments/ htmx partial fragments +static/ + css/style.css All styles + js/htmx.min.js htmx library + img/ Logo, favicon +``` + + +## License + +See [LICENSE](LICENSE). + +[1]: https://github.com/kernelkit/infix +[2]: https://htmx.org +[3]: https://datatracker.ietf.org/doc/html/rfc8040 diff --git a/src/webui/go.mod b/src/webui/go.mod new file mode 100644 index 00000000..6dbdea36 --- /dev/null +++ b/src/webui/go.mod @@ -0,0 +1,17 @@ +module infix/webui + +go 1.22.0 + +toolchain go1.22.2 + +require ( + github.com/google/go-cmp v0.7.0 // indirect + github.com/openconfig/goyang v1.6.3 // indirect + github.com/pborman/getopt v1.1.0 // indirect +) + +// Local fork of goyang with YANG 1.1 fixes: +// - Uses.Augment: *Augment → []*Augment (multiple augments per uses) +// - Value: add Reference field (when { reference "..."; }) +// - Input/Output: add Must field (must statements in rpc input/output) +replace github.com/openconfig/goyang => ./internal/goyang diff --git a/src/webui/go.sum b/src/webui/go.sum new file mode 100644 index 00000000..e836eaa6 --- /dev/null +++ b/src/webui/go.sum @@ -0,0 +1,6 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/openconfig/goyang v1.6.3 h1:9nWXBwd6b4+nZr8ni7O4zUXVhrVMXCLFz8os5YWFuo4= +github.com/openconfig/goyang v1.6.3/go.mod h1:5WolITjek1NF8yrNERyVZ7jqjOClJTpO8p/+OwmETM4= +github.com/pborman/getopt v1.1.0 h1:eJ3aFZroQqq0bWmraivjQNt6Dmm5M0h2JcDW38/Azb0= +github.com/pborman/getopt v1.1.0/go.mod h1:FxXoW1Re00sQG/+KIkuSqRL/LwQgSkv7uyac+STFsbk= diff --git a/src/webui/internal/auth/login.go b/src/webui/internal/auth/login.go new file mode 100644 index 00000000..75b61f67 --- /dev/null +++ b/src/webui/internal/auth/login.go @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "errors" + "html/template" + "log" + "net/http" + "strconv" + "time" + + "infix/webui/internal/handlers" + "infix/webui/internal/restconf" + "infix/webui/internal/security" +) + +const cookieName = "session" + +// LoginHandler serves the login page and processes login/logout requests. +type LoginHandler struct { + Store *SessionStore + RC *restconf.Client + Template *template.Template + // OnLogin is called after every successful login with a context that + // carries the authenticated user's credentials. It is invoked in the + // foreground, so implementations should start their own goroutines for + // slow work. May be nil. + OnLogin func(ctx context.Context) +} + +type loginData struct { + Error string + CsrfToken string +} + +// ShowLogin renders the login page (GET /login). +func (h *LoginHandler) ShowLogin(w http.ResponseWriter, r *http.Request) { + h.renderLogin(w, r, "") +} + +// DoLogin validates credentials against RESTCONF and creates a session (POST /login). +func (h *LoginHandler) DoLogin(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + h.renderLogin(w, r, "Invalid request.") + return + } + + username := r.FormValue("username") + password := r.FormValue("password") + + if username == "" || password == "" { + h.renderLogin(w, r, "Username and password are required.") + return + } + + // Verify credentials by making a RESTCONF call with Basic Auth. + err := h.RC.CheckAuth(username, password) + if err != nil { + log.Printf("login failed for %q: %v", username, err) + var authErr *restconf.AuthError + if errors.As(err, &authErr) { + h.renderLogin(w, r, "Invalid username or password.") + } else { + h.renderLogin(w, r, "Unable to reach the device. Please try again later.") + } + return + } + + // Build an authenticated context for post-login work. + ctx := restconf.ContextWithCredentials(r.Context(), restconf.Credentials{ + Username: username, + Password: password, + }) + + // Probe optional features once at login and bake into the session. + caps := handlers.DetectCapabilities(ctx, h.RC) + // The external web-app shortcuts (console/ttyd, netbrowse) are + // config-gated; fold them into the same feature map so templates gate + // on .Capabilities.Has "console" / "netbrowse". + console, netbrowse := handlers.DetectWebShortcuts(ctx, h.RC) + caps.Features()["console"] = console + caps.Features()["netbrowse"] = netbrowse + // The User's Guide is bundled at build time (a filesystem check, not + // config); gate the Help entry on its presence. + caps.Features()["docs"] = handlers.DetectDocs() + + // Trigger any post-login hooks (e.g. schema sync) with full credentials. + if h.OnLogin != nil { + h.OnLogin(ctx) + } + + token, csrfToken, err := h.Store.Create(username, password, caps.Features()) + if err != nil { + log.Printf("session create error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: token, + Path: "/", + HttpOnly: true, + Secure: security.IsSecureRequest(r), + SameSite: http.SameSiteLaxMode, + }) + security.EnsureToken(w, r, csrfToken) + + fullRedirect(w, r, "/") +} + +// DoLogout destroys the session and redirects to the login page (POST /logout). +func (h *LoginHandler) DoLogout(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie(cookieName); err == nil { + h.Store.Delete(c.Value) + } + + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: security.IsSecureRequest(r), + SameSite: http.SameSiteLaxMode, + }) + security.ClearToken(w, r) + + fullRedirect(w, r, "/login") +} + +// SetSessionTimeout updates the current session's idle timeout from the +// client's Auto-logout menu. The "timeout" form value is seconds; 0 means +// never expire ("Off"). The store caps the upper bound. +func (h *LoginHandler) SetSessionTimeout(w http.ResponseWriter, r *http.Request) { + c, err := r.Cookie(cookieName) + if err != nil { + http.Error(w, "no session", http.StatusUnauthorized) + return + } + secs, err := strconv.Atoi(r.FormValue("timeout")) + if err != nil || secs < 0 { + http.Error(w, "bad timeout", http.StatusBadRequest) + return + } + h.Store.SetTimeout(c.Value, time.Duration(secs)*time.Second) + w.WriteHeader(http.StatusNoContent) +} + +// fullRedirect forces a full page navigation. When the request comes +// from htmx (boosted form) we use HX-Redirect so the browser does a +// real page load instead of an AJAX swap — this is essential for the +// login/logout transition where the page layout changes completely. +func fullRedirect(w http.ResponseWriter, r *http.Request, url string) { + if r.Header.Get("HX-Request") == "true" { + w.Header().Set("HX-Redirect", url) + return + } + http.Redirect(w, r, url, http.StatusSeeOther) +} + +func (h *LoginHandler) renderLogin(w http.ResponseWriter, r *http.Request, errMsg string) { + data := loginData{ + Error: errMsg, + CsrfToken: security.TokenFromContext(r.Context()), + } + if err := h.Template.ExecuteTemplate(w, "login.html", data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} diff --git a/src/webui/internal/auth/session.go b/src/webui/internal/auth/session.go new file mode 100644 index 00000000..f8d87aab --- /dev/null +++ b/src/webui/internal/auth/session.go @@ -0,0 +1,3 @@ +// SPDX-License-Identifier: MIT + +package auth diff --git a/src/webui/internal/auth/store.go b/src/webui/internal/auth/store.go new file mode 100644 index 00000000..ef7b9109 --- /dev/null +++ b/src/webui/internal/auth/store.go @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: MIT + +package auth + +import ( + "fmt" + "sync" + "time" + + "infix/webui/internal/security" +) + +const sessionTimeout = 1 * time.Hour +const maxSessionTimeout = 24 * time.Hour + +// sessionEntry is the in-memory state of a single authenticated +// session. The user's password is kept alongside the token so the +// webui can re-authenticate to RESTCONF on the user's behalf on each +// request; it is never written to disk or surfaced over the wire. +type sessionEntry struct { + username string + password string + csrfToken string + features map[string]bool + lastSeenAt time.Time + timeout time.Duration // idle timeout; 0 = never expire ("Off") +} + +// expired reports whether an entry has passed its idle timeout. A zero +// timeout means the session never idle-expires — the user chose "Off". +func expired(e *sessionEntry) bool { + return e.timeout > 0 && time.Since(e.lastSeenAt) > e.timeout +} + +// SessionStore issues opaque random session tokens backed by an +// in-memory map. Nothing is persisted: a webui restart drops every +// active session and the UI's 401 handler surfaces a fresh login +// page. +type SessionStore struct { + mu sync.RWMutex + sessions map[string]*sessionEntry +} + +// NewSessionStore returns a fresh store and starts a janitor +// goroutine that sweeps expired entries once a minute. +func NewSessionStore() *SessionStore { + s := &SessionStore{ + sessions: make(map[string]*sessionEntry), + } + go s.janitor() + return s +} + +// Create issues a session for the given credentials, returning the +// session token (cookie value) and a freshly minted CSRF token. +func (s *SessionStore) Create(username, password string, features map[string]bool) (string, string, error) { + token, err := security.RandomToken() + if err != nil { + return "", "", fmt.Errorf("session token: %w", err) + } + csrf, err := security.RandomToken() + if err != nil { + return "", "", fmt.Errorf("csrf token: %w", err) + } + + s.mu.Lock() + s.sessions[token] = &sessionEntry{ + username: username, + password: password, + csrfToken: csrf, + features: features, + lastSeenAt: time.Now(), + timeout: sessionTimeout, + } + s.mu.Unlock() + return token, csrf, nil +} + +// Lookup returns the credentials associated with a session token, or +// ok=false if the token is unknown or expired. An expired entry is +// reaped as a side effect. +func (s *SessionStore) Lookup(token string) (username, password, csrf string, features map[string]bool, ok bool) { + s.mu.RLock() + e, present := s.sessions[token] + if present && !expired(e) { + username, password, csrf, features = e.username, e.password, e.csrfToken, e.features + s.mu.RUnlock() + return username, password, csrf, features, true + } + s.mu.RUnlock() + + // Slow path: the entry was either missing or expired at read + // time. Re-check under the write lock — Refresh may have + // updated lastSeenAt in the gap — and only delete if still + // expired. + if present { + s.mu.Lock() + if e, ok := s.sessions[token]; ok && expired(e) { + delete(s.sessions, token) + } + s.mu.Unlock() + } + return "", "", "", nil, false +} + +// SetTimeout updates a session's idle timeout (0 = never expire) so the +// server-side expiry tracks the client's Auto-logout menu. Out-of-range +// values — including a negative from integer overflow on a garbage request — +// are capped at maxSessionTimeout so a session can't be pinned open +// indefinitely; 0 still means never. No-op if the token is unknown. +func (s *SessionStore) SetTimeout(token string, d time.Duration) { + if d < 0 || d > maxSessionTimeout { + d = maxSessionTimeout + } + s.mu.Lock() + if e, ok := s.sessions[token]; ok { + e.timeout = d + } + s.mu.Unlock() +} + +// Refresh extends a session's lifetime by resetting its last-seen +// timestamp. Called by the auth middleware on each user-driven +// request so an active session doesn't expire mid-use. No-op if the +// token is unknown. +func (s *SessionStore) Refresh(token string) { + s.mu.Lock() + if e, ok := s.sessions[token]; ok { + e.lastSeenAt = time.Now() + } + s.mu.Unlock() +} + +// Delete revokes a session. Called by logout and by Lookup on +// expiration. +func (s *SessionStore) Delete(token string) { + s.mu.Lock() + delete(s.sessions, token) + s.mu.Unlock() +} + +func (s *SessionStore) janitor() { + t := time.NewTicker(1 * time.Minute) + defer t.Stop() + for range t.C { + s.mu.Lock() + for token, e := range s.sessions { + if expired(e) { + delete(s.sessions, token) + } + } + s.mu.Unlock() + } +} diff --git a/src/webui/internal/goyang/.github/dependabot.yml b/src/webui/internal/goyang/.github/dependabot.yml new file mode 100644 index 00000000..a2a66d09 --- /dev/null +++ b/src/webui/internal/goyang/.github/dependabot.yml @@ -0,0 +1,15 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "gomod" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" + - package-ecosystem: "github-actions" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" diff --git a/src/webui/internal/goyang/.github/linters/.golangci.yml b/src/webui/internal/goyang/.github/linters/.golangci.yml new file mode 100644 index 00000000..dca2af2e --- /dev/null +++ b/src/webui/internal/goyang/.github/linters/.golangci.yml @@ -0,0 +1,53 @@ +--- +######################### +######################### +## Golang Linter rules ## +######################### +######################### + +# configure golangci-lint +# see https://github.com/golangci/golangci-lint/blob/master/.golangci.example.yml +run: + timeout: 10m +issues: + exclude-rules: + - path: _test\.go + linters: + - dupl + - gosec + - goconst + new: true +linters: + enable: + - gosec + - unconvert + - goconst + - goimports + - gofmt + - gocritic + - govet + - revive + - staticcheck + - unconvert + - unparam + - unused + - wastedassign + - whitespace +linters-settings: + errcheck: + # report about assignment of errors to blank identifier: `num, _ := strconv.Atoi(numStr)`; + # default is false: such cases aren't reported by default. + check-blank: true + govet: + # report about shadowed variables + check-shadowing: false + maligned: + # print struct with more effective memory layout or not, false by default + suggest-new: true + gocritic: + disabled-checks: + - singleCaseSwitch + - appendAssign + revive: + ignore-generated-header: true + severity: warning diff --git a/src/webui/internal/goyang/.github/linters/.yaml-lint.yml b/src/webui/internal/goyang/.github/linters/.yaml-lint.yml new file mode 100644 index 00000000..e9ec8bef --- /dev/null +++ b/src/webui/internal/goyang/.github/linters/.yaml-lint.yml @@ -0,0 +1,59 @@ +--- +########################################### +# These are the rules used for # +# linting all the yaml files in the stack # +# NOTE: # +# You can disable line with: # +# # yamllint disable-line # +########################################### +rules: + braces: + level: warning + min-spaces-inside: 0 + max-spaces-inside: 0 + min-spaces-inside-empty: 1 + max-spaces-inside-empty: 5 + brackets: + level: warning + min-spaces-inside: 0 + max-spaces-inside: 0 + min-spaces-inside-empty: 1 + max-spaces-inside-empty: 5 + colons: + level: warning + max-spaces-before: 0 + max-spaces-after: 1 + commas: + level: warning + max-spaces-before: 0 + min-spaces-after: 1 + max-spaces-after: 1 + comments: disable + comments-indentation: disable + document-end: disable + document-start: + level: warning + present: true + empty-lines: + level: warning + max: 2 + max-start: 0 + max-end: 0 + hyphens: + level: warning + max-spaces-after: 1 + indentation: + level: warning + spaces: consistent + indent-sequences: true + check-multi-line-strings: false + key-duplicates: enable + line-length: + level: warning + max: 120 + allow-non-breakable-words: true + allow-non-breakable-inline-mappings: true + new-line-at-end-of-file: disable + new-lines: + type: unix + trailing-spaces: disable diff --git a/src/webui/internal/goyang/.github/workflows/go.yml b/src/webui/internal/goyang/.github/workflows/go.yml new file mode 100644 index 00000000..24192088 --- /dev/null +++ b/src/webui/internal/goyang/.github/workflows/go.yml @@ -0,0 +1,15 @@ +name: Go + +on: + push: + branches: [ master ] + pull_request: + schedule: + - cron: "0 0 * * *" + +jobs: + go: + uses: openconfig/common-ci/.github/workflows/go.yml@125b6b58286d116b216e45c33cb859f547965d61 + + linter: + uses: openconfig/common-ci/.github/workflows/linter.yml@125b6b58286d116b216e45c33cb859f547965d61 diff --git a/src/webui/internal/goyang/.gitignore b/src/webui/internal/goyang/.gitignore new file mode 100644 index 00000000..6e92f57d --- /dev/null +++ b/src/webui/internal/goyang/.gitignore @@ -0,0 +1 @@ +tags diff --git a/src/webui/internal/goyang/AUTHORS b/src/webui/internal/goyang/AUTHORS new file mode 100644 index 00000000..121ba4ef --- /dev/null +++ b/src/webui/internal/goyang/AUTHORS @@ -0,0 +1,9 @@ +# This is the official list of goyang authors for copyright purposes. +# This file is distinct from the CONTRIBUTORS files. +# See the latter for an explanation. + +# Names should be added to this file as: +# Name or Organization +# The email address is not required for organizations. + +Google Inc. \ No newline at end of file diff --git a/src/webui/internal/goyang/CONTRIBUTING b/src/webui/internal/goyang/CONTRIBUTING new file mode 100644 index 00000000..574d217e --- /dev/null +++ b/src/webui/internal/goyang/CONTRIBUTING @@ -0,0 +1,25 @@ +Want to contribute? Great! First, read this page (including the small print at the end). + +### Before you contribute +Before we can use your code, you must sign the +[Google Individual Contributor License Agreement](https://developers.google.com/open-source/cla/individual?csw=1) +(CLA), which you can do online. The CLA is necessary mainly because you own the +copyright to your changes, even after your contribution becomes part of our +codebase, so we need your permission to use and distribute your code. We also +need to be sure of various other things—for instance that you'll tell us if you +know that your code infringes on other people's patents. You don't have to sign +the CLA until after you've submitted your code for review and a member has +approved it, but you must do it before we can put your code into our codebase. +Before you start working on a larger contribution, you should get in touch with +us first through the issue tracker with your idea so that we can help out and +possibly guide you. Coordinating up front makes it much easier to avoid +frustration later on. + +### Code reviews +All submissions, including submissions by project members, require review. We +use Github pull requests for this purpose. + +### The small print +Contributions made by corporations are covered by a different agreement than +the one above, the +[Software Grant and Corporate Contributor License Agreement](https://cla.developers.google.com/about/google-corporate). \ No newline at end of file diff --git a/src/webui/internal/goyang/CONTRIBUTORS b/src/webui/internal/goyang/CONTRIBUTORS new file mode 100644 index 00000000..b2ac0e81 --- /dev/null +++ b/src/webui/internal/goyang/CONTRIBUTORS @@ -0,0 +1,15 @@ +# People who have agreed to one of the CLAs and can contribute patches. +# The AUTHORS file lists the copyright holders; this file +# lists people. For example, Google employees are listed here +# but not in AUTHORS, because Google holds the copyright. +# +# https://developers.google.com/open-source/cla/individual +# https://developers.google.com/open-source/cla/corporate +# +# Names should be added to this file as: +# Name + +Paul Borman +Andrew Fort +Rob Shakir +Sean Condon diff --git a/src/webui/internal/goyang/Copyright b/src/webui/internal/goyang/Copyright new file mode 100644 index 00000000..663fa3a0 --- /dev/null +++ b/src/webui/internal/goyang/Copyright @@ -0,0 +1,14 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + diff --git a/src/webui/internal/goyang/LICENSE b/src/webui/internal/goyang/LICENSE new file mode 100644 index 00000000..8f71f43f --- /dev/null +++ b/src/webui/internal/goyang/LICENSE @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/src/webui/internal/goyang/README.md b/src/webui/internal/goyang/README.md new file mode 100644 index 00000000..df07ad8f --- /dev/null +++ b/src/webui/internal/goyang/README.md @@ -0,0 +1,54 @@ +![Go](https://github.com/openconfig/goyang/workflows/Go/badge.svg?branch=master) +[![Coverage Status](https://coveralls.io/repos/github/openconfig/goyang/badge.svg?branch=master)](https://coveralls.io/github/openconfig/goyang?branch=master) + +Current support for `goyang` is for the [latest 3 Go releases](https://golang.org/project/#release). + +# goyang +YANG parser and compiler for Go programs. + +The yang package (pkg/yang) is used to convert a YANG schema into either an +in memory abstract syntax trees (ast) or more fully resolved, in memory, "Entry" +trees. An Entry tree consists only of Entry structures and has had +augmentation, imports, and includes all applied. + +goyang is a sample program that uses the yang (pkg/yang) package. + +goyang uses the yang package to create an in-memory tree representation of +schemas defined in YANG and then dumps out the contents in several forms. +The forms include: + +* tree - a simple tree representation +* types - list understood types extracted from the schema + +The yang package, and the goyang program, are not complete and are a work in +progress. + +For more complex output types, such as Go structs, and protobuf messages +please use the [openconfig/ygot](https://github.com/openconfig/ygot) package, +which uses this package as its backend. + +### Getting started + +To build goyang, ensure you have go language tools installed +(available at [golang.org](https://golang.org/dl)) and that the `GOPATH` +environment variable is set to your Go workspace. + +1. `go get github.com/openconfig/goyang` + * This will download goyang code and dependencies into the src +subdirectory in your workspace. + +2. `cd /src/github.com/openconfig/goyang` + +3. `go build` + + * This will build the goyang binary and place it in the bin +subdirectory in your workspace. + +### Contributing to goyang + +goyang is still a work-in-progress and we welcome contributions. Please see +the `CONTRIBUTING` file for information about how to contribute to the codebase. + +### Disclaimer + +This is not an official Google product. diff --git a/src/webui/internal/goyang/go.mod b/src/webui/internal/goyang/go.mod new file mode 100644 index 00000000..0e7fc6ce --- /dev/null +++ b/src/webui/internal/goyang/go.mod @@ -0,0 +1,12 @@ +module github.com/openconfig/goyang + +go 1.22.0 + +toolchain go1.24.1 + +require ( + github.com/google/go-cmp v0.7.0 + github.com/kylelemons/godebug v1.1.0 + github.com/openconfig/gnmi v0.14.1 + github.com/pborman/getopt v1.1.0 +) diff --git a/src/webui/internal/goyang/go.sum b/src/webui/internal/goyang/go.sum new file mode 100644 index 00000000..8e9dc6ed --- /dev/null +++ b/src/webui/internal/goyang/go.sum @@ -0,0 +1,8 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/openconfig/gnmi v0.14.1 h1:qKMuFvhIRR2/xxCOsStPQ25aKpbMDdWr3kI+nP9bhMs= +github.com/openconfig/gnmi v0.14.1/go.mod h1:whr6zVq9PCU8mV1D0K9v7Ajd3+swoN6Yam9n8OH3eT0= +github.com/pborman/getopt v1.1.0 h1:eJ3aFZroQqq0bWmraivjQNt6Dmm5M0h2JcDW38/Azb0= +github.com/pborman/getopt v1.1.0/go.mod h1:FxXoW1Re00sQG/+KIkuSqRL/LwQgSkv7uyac+STFsbk= diff --git a/src/webui/internal/goyang/pkg/indent/indent.go b/src/webui/internal/goyang/pkg/indent/indent.go new file mode 100644 index 00000000..a67b8885 --- /dev/null +++ b/src/webui/internal/goyang/pkg/indent/indent.go @@ -0,0 +1,112 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package indent indents lines of text. +package indent + +import ( + "bytes" + "io" + "strings" +) + +// String returns s with each line in s prefixed by indent. +func String(indent, s string) string { + if indent == "" || s == "" { + return s + } + lines := strings.SplitAfter(s, "\n") + if len(lines[len(lines)-1]) == 0 { + lines = lines[:len(lines)-1] + } + return strings.Join(append([]string{""}, lines...), indent) +} + +// Bytes returns b with each line in b prefixed by indent. +func Bytes(indent, b []byte) []byte { + if len(indent) == 0 || len(b) == 0 { + return b + } + lines := bytes.SplitAfter(b, []byte{'\n'}) + if len(lines[len(lines)-1]) == 0 { + lines = lines[:len(lines)-1] + } + return bytes.Join(append([][]byte{{}}, lines...), indent) +} + +// NewWriter returns an io.Writer that prefixes the lines written to it with +// indent and then writes them to w. The writer returns the number of bytes +// written to the underlying Writer. +func NewWriter(w io.Writer, indent string) io.Writer { + if indent == "" { + return w + } + return &iw{ + w: w, + prefix: []byte(indent), + } +} + +type iw struct { + w io.Writer + prefix []byte + partial bool // true if next line's indent already written +} + +// Write implements io.Writer. +func (w *iw) Write(buf []byte) (int, error) { + if len(buf) == 0 { + return 0, nil + } + lines := bytes.SplitAfter(buf, []byte{'\n'}) + if len(lines[len(lines)-1]) == 0 { + lines = lines[:len(lines)-1] + } + if !w.partial { + lines = append([][]byte{{}}, lines...) + } + joined := bytes.Join(lines, w.prefix) + w.partial = joined[len(joined)-1] != '\n' + + n, err := w.w.Write(joined) + if err != nil { + return actualWrittenSize(n, len(w.prefix), lines), err + } + + return len(buf), nil +} + +func actualWrittenSize(underlay, prefix int, lines [][]byte) int { + actual := 0 + remain := underlay + for _, line := range lines { + if len(line) == 0 { + continue + } + + addition := remain - prefix + if addition <= 0 { + return actual + } + + if addition <= len(line) { + return actual + addition + } + + actual += len(line) + remain -= prefix + len(line) + } + + return actual +} diff --git a/src/webui/internal/goyang/pkg/indent/indent_test.go b/src/webui/internal/goyang/pkg/indent/indent_test.go new file mode 100644 index 00000000..3fad0cc9 --- /dev/null +++ b/src/webui/internal/goyang/pkg/indent/indent_test.go @@ -0,0 +1,146 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package indent + +import ( + "bytes" + "errors" + "testing" +) + +var tests = []struct { + prefix, in, out string +}{ + { + "", "", "", + }, { + "--", "", "", + }, { + "", "x\nx", "x\nx", + }, { + "--", "x", "--x", + }, { + "--", "\n", "--\n", + }, { + "--", "\n\n", "--\n--\n", + }, { + "--", "x\n", "--x\n", + }, { + "--", "\nx", "--\n--x", + }, { + "--", "two\nlines\n", "--two\n--lines\n", + }, { + "--", "\nempty\nfirst\n", "--\n--empty\n--first\n", + }, { + "--", "empty\nlast\n\n", "--empty\n--last\n--\n", + }, { + "--", "empty\n\nmiddle\n", "--empty\n--\n--middle\n", + }, +} + +func TestIndent(t *testing.T) { + for x, tt := range tests { + out := String(tt.prefix, tt.in) + if out != tt.out { + t.Errorf("#%d: got %q, want %q", x, out, tt.out) + } + bout := string(Bytes([]byte(tt.prefix), []byte(tt.in))) + if bout != out { + t.Errorf("#%d: Bytes got %q\n String got %q", x, bout, out) + } + } +} + +func TestWriter(t *testing.T) { +Test: + for x, tt := range tests { + for size := 1; size < 64; size <<= 1 { + var b bytes.Buffer + w := NewWriter(&b, tt.prefix) + data := []byte(tt.in) + for len(data) > size { + if _, err := w.Write(data[:size]); err != nil { + t.Errorf("#%d: %v", x, err) + continue Test + } + data = data[size:] + } + if _, err := w.Write(data); err != nil { + t.Errorf("#%d/%d: %v", x, size, err) + continue Test + } + + out := b.String() + if out != tt.out { + t.Errorf("#%d/%d: got %q, want %q", x, size, out, tt.out) + } + } + } +} + +func TestWrittenSize(t *testing.T) { + for x, tt := range tests { + var b bytes.Buffer + w := NewWriter(&b, tt.prefix) + data := []byte(tt.in) + if n, _ := w.Write(data); n != len(data) { + t.Errorf("#%d: got %d, want %d", x, n, len(data)) + } + } +} + +func TestWrittenSizeWithError(t *testing.T) { + table := []struct { + prefix string + input string + underlay int + expected int + }{ + {"--", "two\nlines\n", 0, 0}, + {"--", "two\nlines\n", 1, 0}, // - + {"--", "two\nlines\n", 2, 0}, // - + {"--", "two\nlines\n", 3, 1}, // t + {"--", "two\nlines\n", 4, 2}, // w + {"--", "two\nlines\n", 5, 3}, // o + {"--", "two\nlines\n", 6, 4}, // \n + {"--", "two\nlines\n", 7, 4}, // - + {"--", "two\nlines\n", 8, 4}, // - + {"--", "two\nlines\n", 9, 5}, // l + {"--", "two\nlines\n", 10, 6}, // i + {"--", "two\nlines\n", 11, 7}, // n + {"--", "two\nlines\n", 12, 8}, // e + {"--", "two\nlines\n", 13, 9}, // s + {"--", "two\nlines\n", 14, 10}, // \n + {"--", "two\nlines\n", 15, 10}, // - + {"--", "two\nlines\n", 16, 10}, // - + } + + for _, d := range table { + uw := errorWriter{d.underlay} + w := NewWriter(uw, d.prefix) + data := []byte(d.input) + if n, _ := w.Write(data); n != d.expected { + t.Errorf("underlay: %d, got %d, want %d, err: ", d.underlay, n, d.expected) + } + } +} + +type errorWriter struct { + ret int +} + +func (w errorWriter) Write(buf []byte) (int, error) { + return w.ret, errors.New("error") +} diff --git a/src/webui/internal/goyang/pkg/yang/ast.go b/src/webui/internal/goyang/pkg/yang/ast.go new file mode 100644 index 00000000..3c7edb6a --- /dev/null +++ b/src/webui/internal/goyang/pkg/yang/ast.go @@ -0,0 +1,461 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +// This file implements BuildAST() and its associated helper structs and +// functions for constructing an AST of Nodes from a Statement tree. This +// function also populates all typedefs into a type cache. +// +// The initTypes function generates the helper struct and functions that +// recursively fill in the various Node structures defined in yang.go. +// BuildAST() then uses those functions to convert raw parsed Statements into +// an AST. + +import ( + "errors" + "fmt" + "reflect" + "strings" +) + +func init() { + // Initialize the global variables `typeMap` and `nameMap`. + // By doing this, we are making the assumption that all modules will be + // parsed according to the type hierarchy rooted at `meta`, and thus + // all input YANG modules will be parsed in this manner. + initTypes(reflect.TypeOf(&meta{})) +} + +// A yangStatement contains all information needed to build a particular +// type of statement into an AST node. +type yangStatement struct { + // funcs is the map of YANG field names to the function that populates + // the statement into the AST node. + funcs map[string]func(*Statement, reflect.Value, reflect.Value, *typeDictionary) error + // required is a list of fields that must be present in the statement. + required []string + // sRequired maps a statement name to a list of required sub-field + // names. The statement name can be an alias of the primary field type. + // e.g. If a field is required by statement type foo, then only foo + // should have the field. If bar is an alias of foo, it must not + // have this field. + sRequired map[string][]string + // addext is the function to handle possible extensions. + addext func(*Statement, reflect.Value, reflect.Value) error +} + +// newYangStatement creates a new yangStatement. +func newYangStatement() *yangStatement { + return &yangStatement{ + funcs: make(map[string]func(*Statement, reflect.Value, reflect.Value, *typeDictionary) error), + sRequired: make(map[string][]string), + } +} + +var ( + // The following maps are built up at init time. + // typeMap provides a lookup from a Node type to the corresponding + // yangStatement. + typeMap = map[reflect.Type]*yangStatement{} + // nameMap provides a lookup from a keyword string to the corresponding + // concrete type implementing the Node interface (see yang.go). + nameMap = map[string]reflect.Type{} + + // The following are helper types used by the implementation. + statementType = reflect.TypeOf(&Statement{}) + nilValue = reflect.ValueOf(nil) + // nodeType is the reflect.Type of the Node interface. + nodeType = reflect.TypeOf((*Node)(nil)).Elem() +) + +// meta is a collection of top-level statements. There is no actual +// statement named "meta". All other statements are a sub-statement of one +// of the meta statements. +type meta struct { + Module []*Module `yang:"module"` +} + +// aliases is a map of "aliased" names, that is, two types of statements +// that parse (nearly) the same. +// NOTE: This only works for root-level aliasing for now, which is good enough +// for module/submodule. This is because yangStatement.funcs doesn't store the +// handler function for aliased fields, and sRequired also may only store the +// correct values when processing a root-level statement due to aliasing. These +// issues would need to be fixed in order to support aliasing for non-top-level +// statements. +var aliases = map[string]string{ + "submodule": "module", +} + +// buildASTWithTypeDict creates an AST for the input statement, and returns its +// root node. It also takes as input a type dictionary into which any +// encountered typedefs within the statement are cached. +func buildASTWithTypeDict(stmt *Statement, types *typeDictionary) (Node, error) { + v, err := build(stmt, nilValue, types) + if err != nil { + return nil, err + } + return v.Interface().(Node), nil +} + +// build builds and returns an AST from the statement stmt and with parent node +// parent. It also takes as input a type dictionary types into which any +// encountered typedefs within the statement are cached. The type of value +// returned depends on the keyword in stmt (see yang.go). It returns an error +// if it cannot build the statement into its corresponding Node type. +func build(stmt *Statement, parent reflect.Value, types *typeDictionary) (v reflect.Value, err error) { + defer func() { + // If we are returning a real Node then call addTypedefs + // if the node possibly contains typedefs. + // Cache these in the typedef cache for look-ups. + if err != nil || v == nilValue { + return + } + if t, ok := v.Interface().(Typedefer); ok { + types.addTypedefs(t) + } + }() + keyword := stmt.Keyword + if k, ok := aliases[stmt.Keyword]; ok { + keyword = k + } + t := nameMap[keyword] + y := typeMap[t] + // Keep track of which substatements are present in the statement. + found := map[string]bool{} + + // Get the struct type we are pointing to. + t = t.Elem() + // v is a pointer to the instantiated structure we are building. + v = reflect.New(t) + + // Handle special cases that are not actually substatements: + + if fn := y.funcs["Name"]; fn != nil { + // Name uses stmt directly. + if err := fn(stmt, v, parent, types); err != nil { + return nilValue, err + } + } + if fn := y.funcs["Statement"]; fn != nil { + // Statement uses stmt directly. + if err := fn(stmt, v, parent, types); err != nil { + return nilValue, err + } + } + if fn := y.funcs["Parent"]; fn != nil { + // parent is the parent node, which is nilValue (reflect.ValueOf(nil)) if there is none. + // parent.IsValid will return false when parent is a nil interface + // parent.IsValid will true if parent references a concrete type + // (even if it is nil). + if parent.IsValid() { + if err := fn(stmt, v, parent, types); err != nil { + return nilValue, err + } + } + } + + // Now handle the substatements + + for _, ss := range stmt.statements { + found[ss.Keyword] = true + fn := y.funcs[ss.Keyword] + switch { + case fn != nil: + // Normal case, the keyword is known. + if err := fn(ss, v, parent, types); err != nil { + return nilValue, err + } + case len(strings.Split(ss.Keyword, ":")) == 2: + // Keyword is not known but it has a prefix so it might + // be an extension. + if y.addext == nil { + return nilValue, fmt.Errorf("%s: no extension function", ss.Location()) + } + y.addext(ss, v, parent) + default: + return nilValue, fmt.Errorf("%s: unknown %s field: %s", ss.Location(), stmt.Keyword, ss.Keyword) + } + } + + // Make sure all of our required field are there. + for _, r := range y.required { + if !found[r] { + return nilValue, fmt.Errorf("%s: missing required %s field: %s", stmt.Location(), stmt.Keyword, r) + } + } + + // Make sure required fields based on our keyword are there (module vs submodule) + for _, r := range y.sRequired[stmt.Keyword] { + if !found[r] { + return nilValue, fmt.Errorf("%s: missing required %s field: %s", stmt.Location(), stmt.Keyword, r) + } + } + + // Make sure we don't have any field set that is required by a different keyword. + for n, or := range y.sRequired { + if n == stmt.Keyword { + continue + } + for _, r := range or { + if found[r] { + return nilValue, fmt.Errorf("%s: unknown %s field: %s", stmt.Location(), stmt.Keyword, r) + } + } + } + return v, nil +} + +// initTypes creates the functions necessary to build a Statement into the +// given the type "at" based on its possible substatements. at must implement +// Node, with its concrete type being a pointer to a struct defined in yang.go. +// +// This function also builds up the functions to populate the input type +// dictionary types with any encountered typedefs within the statement. +// +// For each field of the struct with a yang tag (e.g., `yang:"command"`), a +// function is created with "command" as its unique ID. The complete map of +// builder functions for at is then added to the typeMap map with at as the +// key. The idea is to call these builder functions for each substatement +// encountered. +// +// The functions have the form: +// +// func fn(ss *Statement, v, p reflect.Value, types *typeDictionary) error +// +// Given stmt as a Statement of type at, ss is a substatement of stmt (in a few +// exceptional cases, ss is the Statement itself). v must have the same type +// as at and is the structure being filled in. p is the parent Node, or nil. +// types is the type dictionary cache of the current set of modules being parsed, +// which is used for looking up typedefs. p is only used to set the Parent +// field of a Node. For example, given the following structure and variables: +// +// type Include struct { +// Name string `yang:"Name"` +// Source *Statement `yang:"Statement"` +// Parent Node `yang:"Parent"` +// Extensions []*Statement `yang:"Ext"` +// RevisionDate *Value `yang:"revision-date"` +// } +// +// var inc = &Include{} +// var vInc = reflect.ValueOf(inc) +// var tInc = reflect.TypeOf(inc) +// +// Functions are created for each fields and named Name, Statement, Parent, Ext, +// and revision-date. +// +// The function built for RevisionDate will be called for any substatement, +// ds, of stmt that has the keyword "revision-date" along with the value of +// vInc and its parent: +// +// typeMap[tInc]["revision-date"](ss, vInc, parent, types) +// +// Normal fields are all processed this same way. +// +// The other 4 fields are special. In the case of Name, Statement, and Parent, +// the function is passed stmt, rather than ss, as these fields are not filled in +// by substatements. +// +// The Name command must set its field to the Statement's argument. The +// Statement command must set its field to the Statement itself. The +// Parent command must set its field with the Node of its parent (the +// parent parameter). +// +// The Ext command is unique and must decode into a []*Statement. This is a +// slice of all statements that use unknown keywords with a prefix (in a valid +// .yang file these should be the extensions). +// +// The Field can have attributes delimited by a ','. The only +// supported attributes are: +// +// nomerge: Do not merge this field +// required: This field must be populated +// required=KIND: This field must be populated if the keyword is KIND +// otherwise this field must not be present. +// (This is to support merging Module and SubModule). +// +// If at contains substructures, initTypes recurses on the substructures. +func initTypes(at reflect.Type) { + if at.Kind() != reflect.Ptr || at.Elem().Kind() != reflect.Struct { + panic(fmt.Sprintf("interface not a struct pointer, is %v", at)) + } + if typeMap[at] != nil { + return // we already defined this type + } + + y := newYangStatement() + typeMap[at] = y + t := at.Elem() + for i := 0; i != t.NumField(); i++ { + i := i + f := t.Field(i) + yang := f.Tag.Get("yang") + if yang == "" { + continue + } + parts := strings.Split(yang, ",") + name := parts[0] + if a, ok := aliases[name]; ok { + name = a + } + + const reqe = "required=" + for _, p := range parts[1:] { + switch { + case p == "nomerge": + case p == "required": + y.required = append(y.required, name) + case strings.HasPrefix(p, reqe): + p = p[len(reqe):] + y.sRequired[p] = append(y.sRequired[p], name) + default: + panic(f.Name + ": unknown tag: " + p) + } + } + + // Ext means this is where we squirrel away extensions + if name == "Ext" { + // stmt is the extension to put into v at for field f. + y.addext = func(stmt *Statement, v, _ reflect.Value) error { + if v.Type() != at { + panic(fmt.Sprintf("given type %s, need type %s", v.Type(), at)) + } + fv := v.Elem().Field(i) + fv.Set(reflect.Append(fv, reflect.ValueOf(stmt))) + return nil + } + continue + } + + // descend runs initType on dt if it has not already done so. + descend := func(name string, dt reflect.Type) { + switch nameMap[name] { + case nil: + nameMap[name] = dt + initTypes(dt) // Make sure that structure type is included + case dt: + default: + panic("redeclared type " + name) + } + } + + // Create a function, fn, that will build the field from a + // Statement. These functions are used when actually making + // an AST from a Statement Tree. + var fn func(*Statement, reflect.Value, reflect.Value, *typeDictionary) error + + // The field can be a pointer, a slice or a string + switch f.Type.Kind() { + default: + panic(fmt.Sprintf("invalid type: %v", f.Type.Kind())) + + case reflect.Interface: + // The only case of this should be the "Parent" field. + if name != "Parent" { + panic(fmt.Sprintf("interface field is %s, not Parent", name)) + } + fn = func(stmt *Statement, v, p reflect.Value, types *typeDictionary) error { + if !p.Type().Implements(nodeType) { + panic(fmt.Sprintf("invalid interface: %v", f.Type.Kind())) + } + v.Elem().Field(i).Set(p) + return nil + } + case reflect.String: + // The only case of this should be the "Name" field + if name != "Name" { + panic(fmt.Sprintf("string field is %s, not Name", name)) + } + fn = func(stmt *Statement, v, _ reflect.Value, types *typeDictionary) error { + if v.Type() != at { + panic(fmt.Sprintf("got type %v, want %v", v.Type(), at)) + } + fv := v.Elem().Field(i) + if fv.String() != "" { + return errors.New(stmt.Keyword + ": already set") + } + + v.Elem().Field(i).SetString(stmt.Argument) + return nil + } + + case reflect.Ptr: + if f.Type == statementType { + // The only case of this should be the + // "Statement" field + if name != "Statement" { + panic(fmt.Sprintf("string field is %s, not Statement", name)) + } + fn = func(stmt *Statement, v, _ reflect.Value, types *typeDictionary) error { + if v.Type() != at { + panic(fmt.Sprintf("got type %v, want %v", v.Type(), at)) + } + v.Elem().Field(i).Set(reflect.ValueOf(stmt)) + return nil + } + break + } + + // Make sure our field type is also setup. + descend(name, f.Type) + + fn = func(stmt *Statement, v, p reflect.Value, types *typeDictionary) error { + if v.Type() != at { + panic(fmt.Sprintf("given type %s, need type %s", v.Type(), at)) + } + fv := v.Elem().Field(i) + if !fv.IsNil() { + return errors.New(stmt.Keyword + ": already set") + } + + // Use build to build the value for this field. + sv, err := build(stmt, v, types) + if err != nil { + return err + } + v.Elem().Field(i).Set(sv) + return nil + } + + case reflect.Slice: + // A slice at this point is always a slice of + // substructures. We may see the same keyword multiple + // times, each time we see it we just append to the + // slice. + st := f.Type.Elem() + switch st.Kind() { + default: + panic(fmt.Sprintf("invalid type: %v", st.Kind())) + case reflect.Ptr: + descend(name, st) + fn = func(stmt *Statement, v, p reflect.Value, types *typeDictionary) error { + if v.Type() != at { + panic(fmt.Sprintf("given type %s, need type %s", v.Type(), at)) + } + sv, err := build(stmt, v, types) + if err != nil { + return err + } + + fv := v.Elem().Field(i) + fv.Set(reflect.Append(fv, sv)) + return nil + } + } + } + y.funcs[name] = fn + } +} diff --git a/src/webui/internal/goyang/pkg/yang/ast_test.go b/src/webui/internal/goyang/pkg/yang/ast_test.go new file mode 100644 index 00000000..9010e5be --- /dev/null +++ b/src/webui/internal/goyang/pkg/yang/ast_test.go @@ -0,0 +1,538 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +import ( + "bytes" + "fmt" + "reflect" + "testing" +) + +type MainNode struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Field *Value `yang:"field"` + Slice []*Value `yang:"slice"` + ChildNode *SubNode `yang:"child_node"` + ChildSlice []*SubNode `yang:"child_slice"` + ReqNode *ReqNode `yang:"req_node"` + MainField *Value `yang:"main_field,required=main_node"` + AltField *Value `yang:"alt_field,required=alt_node"` +} + +func (m *MainNode) Kind() string { + if m.AltField != nil { + return "alt_node" + } + return "main_node" +} + +func (m *MainNode) ParentNode() Node { return m.Parent } +func (m *MainNode) NName() string { return m.Name } +func (m *MainNode) Statement() *Statement { return m.Source } +func (m *MainNode) Exts() []*Statement { return m.Extensions } + +func (m *MainNode) checkEqual(n Node) string { + o, ok := n.(*MainNode) + if !ok { + return fmt.Sprintf("expected *MainNode, got %T", n) + } + if m.Name != o.Name { + return fmt.Sprintf("got name %s, want %s", o.Name, m.Name) + } + if s := m.Source.checkEqual(o.Source); s != "" { + return s + } + if (m.Field == nil) != (o.Field == nil) { + if m.Field == nil { + return "unexpected field entry" + } + return "missing expected field entry" + } + if m.Field != nil { + if m.Field.Name != o.Field.Name { + return fmt.Sprintf("got field of %s, want %s", o.Field.Name, m.Field.Name) + } + } + if len(m.Slice) != len(o.Slice) { + return fmt.Sprintf("got slice of %d, want slice of %d", len(o.Slice), len(m.Slice)) + } + for x, s1 := range m.Slice { + s2 := o.Slice[x] + if s1.Name != s2.Name { + return fmt.Sprintf("slice[%d] got %s, want %s", x, s2.Name, s1.Name) + } + } + if (m.ChildNode == nil) != (o.ChildNode == nil) { + if m.ChildNode == nil { + return "unexpected child_node entry" + } + return "missing expected child_node entry" + } + if m.ChildNode != nil { + if s := m.ChildNode.checkEqual(o.ChildNode); s != "" { + return fmt.Sprintf("child_node: %s", s) + } + } + if len(m.ChildSlice) != len(o.ChildSlice) { + return fmt.Sprintf("got child_slice of %d, want slice of %d", len(o.ChildSlice), len(m.ChildSlice)) + } + for x, s1 := range m.ChildSlice { + s2 := o.ChildSlice[x] + if s := s1.checkEqual(s2); s != "" { + return fmt.Sprintf("child_slice[%d]: %s", x, s) + } + } + if (m.ReqNode == nil) != (o.ReqNode == nil) { + if m.ReqNode == nil { + return "unexpected req_node entry" + } + return "missing expected req_node entry" + } + if m.ReqNode != nil { + if s := m.ReqNode.checkEqual(o.ReqNode); s != "" { + return fmt.Sprintf("req_node: %s", s) + } + } + if (m.AltField == nil) != (o.AltField == nil) { + if m.AltField == nil { + return "unexpected alt_field entry" + } + return "missing expected alt_field entry" + } + if m.AltField != nil { + if m.AltField.Name != o.AltField.Name { + return fmt.Sprintf("got alt_field of %s, want %s", o.AltField.Name, m.AltField.Name) + } + } + return "" +} + +type SubNode struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + SubField *Value `yang:"sub_field"` +} + +func (SubNode) Kind() string { return "sub_node" } +func (s *SubNode) ParentNode() Node { return s.Parent } +func (s *SubNode) NName() string { return s.Name } +func (s *SubNode) Statement() *Statement { return s.Source } +func (s *SubNode) Exts() []*Statement { return s.Extensions } + +func (s *SubNode) checkEqual(o *SubNode) string { + if s.Name != o.Name { + return fmt.Sprintf("got name %s, want %s", o.Name, s.Name) + } + if s := s.Source.checkEqual(o.Source); s != "" { + return s + } + if (s.SubField == nil) != (o.SubField == nil) { + if s.SubField == nil { + return "unexpected sub_field entry" + } + return "missing expected sub_field entry" + } + if s.SubField != nil { + if s.SubField.Name != o.SubField.Name { + return fmt.Sprintf("got sub_field of %s, want %s", o.SubField.Name, s.SubField.Name) + } + } + return "" +} + +type ReqNode struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + + ReqField *Value `yang:"req_field,required"` + AltReqField *Value `yang:"alt_req_field,required=alt_req_node"` + Field *Value `yang:"field"` +} + +func (s *ReqNode) Kind() string { + return "req_node" +} +func (s *ReqNode) ParentNode() Node { return s.Parent } +func (s *ReqNode) NName() string { return s.Name } +func (s *ReqNode) Statement() *Statement { return s.Source } +func (m *ReqNode) Exts() []*Statement { return nil } + +func (s *ReqNode) checkEqual(o *ReqNode) string { + if s.Name != o.Name { + return fmt.Sprintf("got name %s, want %s", o.Name, s.Name) + } + if s := s.Source.checkEqual(o.Source); s != "" { + return s + } + if (s.ReqField == nil) != (o.ReqField == nil) { + if s.ReqField == nil { + return "unexpected req_field entry" + } + return "missing expected req_field entry" + } + if s.ReqField != nil { + if s.ReqField.Name != o.ReqField.Name { + return fmt.Sprintf("got req_field of %s, want %s", o.ReqField.Name, s.ReqField.Name) + } + } + if (s.AltReqField == nil) != (o.AltReqField == nil) { + if s.AltReqField == nil { + return "unexpected alt_req_field entry" + } + return "missing expected alt_req_field entry" + } + if s.AltReqField != nil { + if s.AltReqField.Name != o.AltReqField.Name { + return fmt.Sprintf("got alt_req_field of %s, want %s", o.AltReqField.Name, s.AltReqField.Name) + } + } + return "" +} + +func (s *Statement) checkEqual(o *Statement) string { + if (s == nil) != (o == nil) { + var b bytes.Buffer + if s == nil { + o.Write(&b, "") + return fmt.Sprintf("unexpected Statement entry\n%s", &b) + } + s.Write(&b, "") + return fmt.Sprintf("missing expected Statement entry\n%s", &b) + } + if s == nil { + return "" + } + var b1, b2 bytes.Buffer + s.Write(&b1, "") + o.Write(&b2, "") + ss := b1.String() + os := b2.String() + if ss != os { + return fmt.Sprintf("got statement:\n%swant:\n%s", os, ss) + } + return "" +} + +func TestAST(t *testing.T) { + // Teach the AST parser about our testing nodes + type meta struct { + MainNode []*MainNode `yang:"main_node"` + } + + old_aliases := aliases + aliases = map[string]string{ + "alt_node": "main_node", + } + + for _, tt := range []struct { + line int + in string + out *MainNode + err string + }{ + { + line: line(), + in: ` +main_node the_node { + // This test is testing to make sure unknown statements, that + // might be extensions, are properly put in the Extensions slice. + // When an extension is used, it must be of the form "prefix:name". + // See https://tools.ietf.org/html/rfc6020#section-7.17 + ex:ext1 value1; + ex:ext2 value2; + main_field foo; +} +`, + out: &MainNode{ + Source: SA("main_node", "the_node", + SA("ex:ext1", "value1"), + SA("ex:ext2", "value2"), + SA("main_field", "foo")), + Name: "the_node", + Extensions: []*Statement{ + SA("ex:ext1", "value1"), + SA("ex:ext2", "value2"), + }, + MainField: &Value{ + Name: "foo", + }, + }, + }, + { + line: line(), + in: ` +main_node the_node { + // This test tests fields, slices, and sub-statements. + field field_value; + slice sl1; + slice sl2; + child_node the_child { + sub_field val1; + } + child_slice element1 { + sub_field el1; + } + child_slice element2 { + sub_field el2; + } + main_field foo; +}`, + out: &MainNode{ + Source: SA("main_node", "the_node", + SA("field", "field_value"), + SA("slice", "sl1"), + SA("slice", "sl2"), + SA("child_node", "the_child", + SA("sub_field", "val1")), + SA("child_slice", "element1", + SA("sub_field", "el1")), + SA("child_slice", "element2", + SA("sub_field", "el2")), + SA("main_field", "foo"), + ), + Name: "the_node", + Field: &Value{ + Name: "field_value", + }, + Slice: []*Value{ + { + Name: "sl1", + }, + { + Name: "sl2", + }, + }, + ChildNode: &SubNode{ + Source: SA("child_node", "the_child", + SA("sub_field", "val1")), + Name: "the_child", + SubField: &Value{ + Name: "val1", + }, + }, + ChildSlice: []*SubNode{ + { + Source: SA("child_slice", "element1", + SA("sub_field", "el1")), + Name: "element1", + SubField: &Value{ + Name: "el1", + }, + }, + { + Source: SA("child_slice", "element2", + SA("sub_field", "el2")), + Name: "element2", + SubField: &Value{ + Name: "el2", + }, + }, + }, + MainField: &Value{ + Name: "foo", + }, + }, + }, + { + line: line(), + in: ` +// This test tests for the presence of a required field. +// main_node requires the field named "main_field". +main_node the_node { + main_field value1 { + } +} +`, + out: &MainNode{ + Source: SA("main_node", "the_node", + SA("main_field", "value1"), + ), + Name: "the_node", + MainField: &Value{ + Name: "value1", + }, + }, + }, + { + line: line(), + in: ` +// This test tests for the presence of a required= field. +// alt_node requires the field named "alt_field". +alt_node the_node { + alt_field value2 { + } +} +`, + out: &MainNode{ + Source: SA("alt_node", "the_node", + SA("alt_field", "value2"), + ), + Name: "the_node", + AltField: &Value{ + Name: "value2", + }, + }, + }, + { + line: line(), + in: ` +main_node the_node { + // This test tests that extensions are rejected when the node is not + // supposed to contain them. + req_node value1 { + req_field foo { + } + ex:ext1 value1; + ex:ext2 value2; + } +} +`, + err: `ast.yang:8:3: no extension function`, + }, + { + line: line(), + in: ` +main_node the_node { + // This test tests for the presence of a required field. + // req_node requires the field named "req_field". + req_node value1 { + req_field foo { + } + } + main_field foo; +} +`, + out: &MainNode{ + Source: SA("main_node", "the_node", + SA("req_node", "value1", + SA("req_field", "foo")), + SA("main_field", "foo"), + ), + Name: "the_node", + ReqNode: &ReqNode{ + Source: SA("req_node", "value1", + SA("req_field", "foo")), + Name: "value1", + ReqField: &Value{ + Name: "foo", + }, + }, + MainField: &Value{ + Name: "foo", + }, + }, + }, + { + line: line(), + in: ` +main_node the_node { + // This test tests that the absence of a required field fails. + // req_node requires the field named "req_field". + req_node value1 { + } + main_field foo; +} +`, + err: `ast.yang:5:2: missing required req_node field: req_field`, + }, + { + line: line(), + in: ` +main_node the_node { + // This test tests that the absence of a required field. + // main_node requires the field named "main_field". + req_node value1 { + req_field foo { + } + } +} +`, + err: `ast.yang:2:1: missing required main_node field: main_field`, + }, + { + line: line(), + in: ` +// This test tests that the alt_field, specified with +// required=alt_node, causes the AST construction to error when a +// main_node contains it. +main_node the_node { + main_field foo; + alt_field foo; +} +`, + err: `ast.yang:5:1: unknown main_node field: alt_field`, + }, + { + line: line(), + in: ` +// This test tests that required=alt_node enforces that +// alt_node must contain it. +alt_node the_node { + main_field foo; + alt_field foo; +} +`, + err: `ast.yang:4:1: unknown alt_node field: main_field`, + }, + { + line: line(), + in: ` +// This test tests that required=alt_node enforces that +// alt_node must contain it. +alt_node the_node { +} +`, + err: `ast.yang:4:1: missing required alt_node field: alt_field`, + }, + } { + ss, err := Parse(tt.in, "ast.yang") + if err != nil { + t.Errorf("%d: %v", tt.line, err) + continue + } + if len(ss) != 1 { + t.Errorf("%d: got %d results, want 1", tt.line, len(ss)) + continue + } + + typeDict := newTypeDictionary() + initTypes(reflect.TypeOf(&meta{})) + + ast, err := buildASTWithTypeDict(ss[0], typeDict) + switch { + case err == nil && tt.err == "": + if s := tt.out.checkEqual(ast); s != "" { + t.Errorf("%d: %s", tt.line, s) + } + case err == nil: + t.Errorf("%d: did not get expected error %s", tt.line, tt.err) + case tt.err == "": + t.Errorf("%d: %v", tt.line, err) + case err.Error() != tt.err: + t.Errorf("%d: got error %v, want %s", tt.line, err, tt.err) + } + } + + aliases = old_aliases +} diff --git a/src/webui/internal/goyang/pkg/yang/bgp_test.go b/src/webui/internal/goyang/pkg/yang/bgp_test.go new file mode 100644 index 00000000..1d96a6d5 --- /dev/null +++ b/src/webui/internal/goyang/pkg/yang/bgp_test.go @@ -0,0 +1,571 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +import ( + "reflect" + "testing" +) + +// TestBGP simply makes sure we are able to parse a version of Anees's +// BGP model. We don't actually attempt to validate we got the right +// AST. ast_test.go will test smaller peices to make sure the basics +// of BuildAST produce expected results. +func TestBGP(t *testing.T) { + ss, err := Parse(bgp, "bgp.yang") + if err != nil { + t.Fatal(err) + } + if len(ss) != 1 { + t.Fatalf("got %d results, want 1", len(ss)) + } + typeDict := newTypeDictionary() + initTypes(reflect.TypeOf(&meta{})) + if _, err := buildASTWithTypeDict(ss[0], typeDict); err != nil { + t.Fatal(err) + } +} + +var bgp = ` +module google-bgp { + + yang-version "1"; + + // namespace + namespace "http://google.com/yang/google-bgp-protocol-cfg"; + + prefix "gbgp"; + + // import some basic types -- no other dependency on + // in-progress models in draft status + import ietf-inet-types { prefix inet; } + + + // meta + organization "Google, Inc."; + + contact + "Google, Inc. + 1600 Amphitheatre Way + Mountain View, CA 94043"; + + description + "This module describes a YANG model for BGP protocol + configuration.It is a limited subset of all of the configuration + parameters available in the variety of vendor implementations, + hence it is expected that it would be augmented with vendor- + specific configuration data as needed.Additional modules or + submodules to handle other aspects of BGP configuration, + including policy, VRFs, and additional address families are also + expected."; + + revision "2014-07-07" { + description + "Initial revision"; + reference "TBD"; + } + + + identity afi-type { + description + "base identity type for BGP address family identifiers (AFI)"; + reference "IETF RFC 4760"; + } + + identity safi-type { + description + "base identity type for BGP subsequent address family + identifiers (SAFI)"; + reference "IETF RFC 4760"; + } + + identity ipv4-afi { + base afi-type; + description + "IPv4 AF identifier"; + } + + identity ipv6-afi { + base afi-type; + description + "IPv6 AF identifier"; + } + + identity unicast-safi { + base safi-type; + description + "unicast SAFI identifier"; + } + + identity labeled-unicast-safi { + base safi-type; + description + "labeled unicast SAFI identifier"; + reference "RFC 3107 - Carrying Label Information in BGP-4"; + } + + + typedef peer-group-type { + type enumeration { + enum INTERNAL { + description "internal (iBGP) peer"; + } + enum EXTERNAL { + description "external (eBGP) peer"; + } + } + description + "labels a peer as explicitly internal or external"; + } + + + typedef remove-private-as-option { + type enumeration { + enum ALL { + description "remove all private ASes in the path"; + } + enum REPLACE { + description "replace private ASes with local AS"; + } + } + description + "set of options for configuring how private AS path numbers + are removed from advertisements"; + } + + typedef percentage { + type uint8 { + range "0..100"; + } + description + "Integer indicating a percentage value"; + } + + typedef rr-cluster-id-type { + type union { + type uint32; + type inet:ipv4-address; + } + description + "union type for route reflector cluster ids: + option 1: 4-byte number + option 2: IP address"; + } + + grouping bgp-common-configuration { + description "Common configuration across neighbors, groups, + etc."; + + leaf description { + type string; + description + "A textual description of the peer or group"; + } + container use-multiple-paths { + description + "Configuration of BGP multipath to enable load sharing across + multiple paths to peers."; + leaf allow-multiple-as { + type boolean; + default "false"; + description + "Allow multipath to use paths from different neighboring + ASes. The default is to only consider multiple paths from + the same neighboring AS."; + } + leaf maximum-paths { + type uint32; + default 1; + description + "Maximum number of parallel paths to consider when using + BGP multipath. The default is to use a single path."; + reference "draft-ietf-idr-add-paths-09.txt"; + } + } + + } + + grouping bgp-group-common-configuration { + description "Configuration items that are applied at the peer + group level"; + } + + grouping bgp-group-neighbor-common-configuration { + description "Configuration options for peer and group context"; + + leaf auth-password { + type string; + description + "Configures an authentication password for use with + neighboring devices."; + } + + container timers { + description "Configuration of various BGP timers"; + + leaf hold-time { + type decimal64 { + fraction-digits 2; + } + default 90; + // hold-time should typically be set to 3x the + // keepalive-interval -- create a constraint for this? + description + "Time interval in seconds that a BGP session will be + considered active in the absence of keepalive or other + messages from the peer"; + reference + "RFC 1771 - A Border Gateway Protocol 4"; + } + + leaf keepalive-interval { + type decimal64 { + fraction-digits 2; + } + default 30; + description + "Time interval in seconds between transmission of keepalive + messages to the neighbor. Typically set to 1/3 the + hold-time."; + } + + leaf advertisement-interval { + type decimal64 { + fraction-digits 2; + } + default 30; + description + "Mininum time interval in seconds between transmission + of BGP updates to neighbors"; + reference + "RFC 1771 - A Border Gateway Protocol 4"; + } + + leaf connect-retry { + type decimal64 { + fraction-digits 2; + } + default 30; + description + "Time interval in seconds between attempts to establish a + session with the peer."; + } + } + + container ebgp-multihop { + description + "Configure multihop BGP for peers that are not directly + connected"; + + leaf multihop-ttl { + type uint8; + default 1; + description + "Time-to-live for multihop BGP sessions. The default + value of 1 is for directly connected peers (i.e., + multihop disabled"; + + } + + } + + container route-reflector { + description + "Configure the local router as a route-reflector + server"; + leaf route-reflector-clusterid { + type rr-cluster-id-type; + description + "route-reflector cluster id to use when local router is + configured as a route reflector. Commonly set at the group + level, but allows a different cluster + id to be set for each neighbor."; + } + + leaf route-reflector-client { + type boolean; + default "false"; + description + "configure the neighbor as a route reflector client"; + } + } + + leaf remove-private-as { + // could also make this a container with a flag to enable + // remove-private and separate option. here, option implies + // remove-private is enabled. + type remove-private-as-option; + description + "Remove private AS numbers from updates sent to peers"; + } + + + container bgp-logging-options { + description + "Configure various tracing/logging options for BGP peers + or groups. Expected that additional vendor-specific log + options would augment this container"; + + leaf log-neighbor-state-changes { + type boolean; + default "true"; + description + "Configure logging of peer state changes. Default is + to enable logging of peer state changes."; + } + } + + container transport-options { + description + "Transport protocol options for BGP sessions"; + + leaf tcp-mss { + type uint16; + description + "Sets the max segment size for BGP TCP sessions"; + } + + leaf passive-mode { + type boolean; + description + "Wait for peers to issue requests to open a BGP session, + rather than initiating sessions from the local router"; + } + } + + leaf local-address { + type inet:ip-address; + description + "Set the local IP (either IPv4 or IPv6) address to use for + the session when sending BGP update messages"; + } + + leaf route-flap-damping { + type boolean; + description + "Enable route flap damping"; + } + } + + grouping bgp-address-family-common-configuration { + description "Configuration options per address family context"; + + list address-family { + + key "afi-name"; + description + "Per address-family configuration, uniquely identified by AF + name"; + leaf afi-name { + type identityref { + base "afi-type"; + } + description + "Address family names are drawn from the afi-type base + identity, which has specific address family types as + derived identities"; + } + + list subsequent-address-family { + + key "safi-name"; + description + "Per subsequent address family configuration, under a + specific address family"; + + leaf safi-name { + // do we need to specify which SAFIs are possible within + // each AF? with the current set of AF/SAFI, all are + /// applicable + type identityref { + base "safi-type"; + } + description + "Within each address family, subsequent address family + names are drawn from the subsequent-address-family base + identity"; + } + + + container prefix-limit { + description + "Configure the maximum number of prefixes that will be + accepted from a peer"; + + leaf max-prefixes { + type uint32; + description + "Maximum number of prefixes that will be accepted from + the neighbor"; + } + + leaf shutdown-threshold-pct { + type percentage; + description + "Threshold on number of prefixes that can be received + from a neighbor before generation of warning messages + or log entries. Expressed as a percentage of + max-prefixes."; + } + + leaf restart-timer { + type decimal64 { + fraction-digits 2; + } + units "seconds"; + description + "Time interval in seconds after which the BGP session + is reestablished after being torn down due to exceeding + the max-prefixes limit."; + } + } + } + } + } + + + + container bgp { + description "Top-level configuration data for the BGP router"; + + container global { + description + "Top-level bgp protocol options applied across peer-groups, + neighbors, and address families"; + + leaf as { + type inet:as-number; + mandatory "true"; + description + "Local autonomous system number of the router. Uses + the as-number type defined in RFC 6991"; + } + leaf router-id { + type inet:ipv4-address; + description + "Router id of the router, expressed as an + IPv4 address"; + // there is a typedef for this in draft module ietf-routing + // but it does not use an appropriate type + } + container route-selection-options { + description + "Set of configuration options that govern best + path selection"; + leaf always-compare-med { + type boolean; + default "false"; + description + "Compare multi-exit discriminator (MED) value from + different ASes when selecting the best route. The + default behavior is to only compare MEDs for paths + received from the same AS."; + } + leaf ignore-as-path { + type boolean; + default "false"; + description + "Ignore the AS path length when selecting the best path. + The default is to use the AS path length and prefer paths + with shorter length."; + } + leaf external-compare-router-id { + type boolean; + default "true"; + description + "When comparing similar routes received from external + BGP peers, use the router-id as a criterion to select + the active path. The default is to use the router-id to + select among similar routes."; + } + leaf advertise-inactive-routes { + type boolean; + default "false"; + description + "Advertise inactive routes to external peers. The + default is to only advertise active routes."; + } + } + container default-route-distance { + description + "Administrative distance (or preference) assigned to + routes received from different sources + (external, internal, and local.)"; + leaf external-route-distance { + type uint8 { + range "1..255"; + } + description + "Administrative distance for routes learned from external + BGP (eBGP)"; + } + leaf internal-route-distance { + type uint8 { + range "1..255"; + } + description + "Administrative distance for routes learned from internal + BGP (iBGP)"; + + } + } + } + + uses bgp-address-family-common-configuration; + + list peer-group { + key "group-name"; + description + "List of peer-groups, uniquely identified by the peer group + names"; + leaf group-name { + type string; + description "Name of the peer group"; + } + leaf group-type { + type peer-group-type; + description + "Explicitly designate the peer group as internal (iBGP) + or external (eBGP)"; + } + uses bgp-common-configuration; + uses bgp-address-family-common-configuration; + uses bgp-group-neighbor-common-configuration; + } + + list neighbor { + key "neighbor-address"; + description + "List of BGP peers, uniquely identified by neighbor address"; + leaf neighbor-address { + type inet:ip-address; + description + "Address of the BGP peer, either IPv4 or IPv6"; + } + + leaf peer-as { + type inet:as-number; + mandatory "true"; + description + "AS number of the peer"; + + } + uses bgp-common-configuration; + uses bgp-address-family-common-configuration; + uses bgp-group-neighbor-common-configuration; + } + + } +}` diff --git a/src/webui/internal/goyang/pkg/yang/camelcase.go b/src/webui/internal/goyang/pkg/yang/camelcase.go new file mode 100644 index 00000000..2704ddcd --- /dev/null +++ b/src/webui/internal/goyang/pkg/yang/camelcase.go @@ -0,0 +1,94 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +var knownWords = map[string]string{ + "Ietf": "IETF", +} + +// Is c an ASCII lower-case letter? +func isASCIILower(c byte) bool { + return 'a' <= c && c <= 'z' +} + +// Is c an ASCII digit? +func isASCIIDigit(c byte) bool { + return '0' <= c && c <= '9' +} + +// CamelCase returns a CamelCased name for a YANG identifier. +// Currently this supports the output being used for a Go or proto identifier. +// Dash and dot are first converted to underscore, and then any underscores +// before a lower-case letter are removed, and the letter converted to +// upper-case. Any input characters not part of the YANG identifier +// specification (https://tools.ietf.org/html/rfc7950#section-6.2) are treated +// as lower-case characters. +// The first letter is always upper-case in order to be an exported name in Go. +// There is a remote possibility of this rewrite causing a name collision, but +// it's so remote we're prepared to pretend it's nonexistent - since the C++ +// generator lowercases names, it's extremely unlikely to have two fields with +// different capitalizations. In short, _my_field-name_2 becomes XMyFieldName_2. +func CamelCase(s string) string { + if s == "" { + return "" + } + + fix := func(c byte) byte { + if c == '-' || c == '.' { + return '_' + } + return c + } + + t := make([]byte, 0, 32) + i := 0 + if fix(s[0]) == '_' { + // Need a capital letter; drop the '_'. + t = append(t, 'X') + i++ + } + + // Invariant: if the next letter is lower case, it must be converted + // to upper case. + // That is, we process a word at a time, where words are marked by _ or + // upper case letter. Digits are treated as words. + for ; i < len(s); i++ { + c := fix(s[i]) + if c == '_' && i+1 < len(s) && isASCIILower(s[i+1]) { + continue // Skip the underscore in s. + } + if isASCIIDigit(c) { + t = append(t, c) + continue + } + // Assume we have a letter now - if not, it's a bogus identifier. + // The next word is a sequence of characters that must start upper case. + if isASCIILower(c) { + c ^= ' ' // Make it a capital letter. + } + start := len(t) + t = append(t, c) // Guaranteed not lower case. + // Accept lower case sequence that follows. + for i+1 < len(s) && isASCIILower(s[i+1]) { + i++ + t = append(t, s[i]) + } + // If the word turns out to be a special word, then use that instead. + if kn := knownWords[string(t[start:])]; kn != "" { + t = append(t[:start], []byte(kn)...) + } + } + return string(t) +} diff --git a/src/webui/internal/goyang/pkg/yang/camelcase_test.go b/src/webui/internal/goyang/pkg/yang/camelcase_test.go new file mode 100644 index 00000000..0539bfa8 --- /dev/null +++ b/src/webui/internal/goyang/pkg/yang/camelcase_test.go @@ -0,0 +1,53 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +import ( + "testing" +) + +func TestCamelCase(t *testing.T) { + tests := []struct { + in, want string + }{ + {"one", "One"}, + {"one_two", "OneTwo"}, + {"__one__two__three__four", "XOne_Two_Three_Four"}, + {"one.two.three", "OneTwoThree"}, + {"one.two.three.", "OneTwoThree_"}, + {"_my_field_name_2", "XMyFieldName_2"}, + {"Something_Capped", "Something_Capped"}, + {"_Foo-bar", "XFooBar"}, + {"my_Name", "My_Name"}, + {"OneTwo", "OneTwo"}, + {"_", "X"}, + {"_a_", "XA_"}, + {"ietf-interface", "IETFInterface"}, + {"ietf-interface-1", "IETFInterface_1"}, + {"out-unicast.pkts", "OutUnicastPkts"}, + // Invalid input conversion behaviours: + {"one/two", "One/two"}, + {"/one/two", "/one/two"}, + {"one:two", "One:two"}, + {"::one::two", "::one::two"}, + {"one|two", "One|two"}, + {"one||two", "One||two"}, + } + for _, tc := range tests { + if got := CamelCase(tc.in); got != tc.want { + t.Errorf("CamelCase(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} diff --git a/src/webui/internal/goyang/pkg/yang/doc.go b/src/webui/internal/goyang/pkg/yang/doc.go new file mode 100644 index 00000000..3dab829c --- /dev/null +++ b/src/webui/internal/goyang/pkg/yang/doc.go @@ -0,0 +1,48 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package yang is used to parse .yang files (see RFC 6020). +// +// A generic yang statements takes one of the forms: +// +// keyword [argument] ; +// keyword [argument] { [statement [...]] } +// +// At the lowest level, package yang returns a simple tree of statements via the +// Parse function. The Parse function makes no attempt to determine the +// validity of the source, other than checking for generic syntax errors. +// +// At it's simplest, the GetModule function is used. The GetModule function +// searches the current directory, and any directory added to the Path variable, +// for a matching .yang source file by appending .yang to the name of the +// module: +// +// // Get the tree for the module module-name by looking for the source +// // file named module-name.yang. +// e, errs := yang.GetModule("module-name" [, optional sources...]) +// if len(errs) > 0 { +// for _, err := range errs { +// fmt.Fprintln(os.Stderr, err) +// } +// os.Exit(1) +// } +// +// // e is the Entry tree for "module-name" +// +// More complicated uses cases should use NewModules and then some combination +// of Modules.GetModule, Modules.Read, Modules.Parse, and Modules.GetErrors. +// +// The GetErrors method is mandatory, however, both yang.GetModule and +// Modules.GetModule automatically call Modules.GetErrors. +package yang diff --git a/src/webui/internal/goyang/pkg/yang/entry.go b/src/webui/internal/goyang/pkg/yang/entry.go new file mode 100644 index 00000000..cbba30b7 --- /dev/null +++ b/src/webui/internal/goyang/pkg/yang/entry.go @@ -0,0 +1,1664 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +// The file contains the code to convert an AST (Node) tree into an Entry tree +// via the ToEntry function. The entry tree, once fully resolved, is the +// product of this package. The tree should have all types and references +// resolved. +// +// TODO(borman): handle types, leafrefs, and extensions + +import ( + "errors" + "fmt" + "io" + "math" + "reflect" + "sort" + "strconv" + "strings" + + "github.com/openconfig/goyang/pkg/indent" +) + +// A TriState may be true, false, or unset +type TriState int + +// The possible states of a TriState. +const ( + TSUnset = TriState(iota) + TSTrue + TSFalse +) + +// Value returns the value of t as a boolean. Unset is returned as false. +func (t TriState) Value() bool { + return t == TSTrue +} + +// String displays t as a string. +func (t TriState) String() string { + switch t { + case TSUnset: + return "unset" + case TSTrue: + return "true" + case TSFalse: + return "false" + default: + return fmt.Sprintf("ts-%d", t) + } +} + +// deviationPresence stores whether certain attributes for a DeviateEntry-type +// Entry have been given deviation values. This is useful when the attribute +// doesn't have a presence indicator (e.g. non-pointers). +type deviationPresence struct { + hasMinElements bool + hasMaxElements bool +} + +// Entry represents a single schema tree node, which can be a directory +// (containing a subtree) or a leaf node (which contains YANG types that have +// no children, e.g., leaf, leaf-list). They can be distinguished by whether +// their "Dir" field is nil. This object is created from a corresponding AST +// node after applying modifications (i.e. uses, augments, deviations). If +// Errors is not nil then it means semantic errors existed while converting the +// AST, in which case the only other valid field other than Errors is Node. +type Entry struct { + Parent *Entry `json:"-"` + Node Node `json:"-"` // the base node this Entry was derived from. + Name string // our name, same as the key in our parent Dirs + Description string `json:",omitempty"` // description from node, if any + // Default value for the node, if any. Note that only leaf-lists may + // have more than one value. For all other types, use the + // SingleDefaultValue() method to access the default value. + Default []string `json:",omitempty"` + Units string `json:",omitempty"` // units associated with the type, if any + Errors []error `json:"-"` // list of errors encountered on this node + Kind EntryKind // kind of Entry + Config TriState // config state of this entry, if known + Prefix *Value `json:",omitempty"` // prefix to use from this point down + Mandatory TriState `json:",omitempty"` // whether this entry is mandatory in the tree + + // Fields associated with directory nodes + Dir map[string]*Entry `json:",omitempty"` + Key string `json:",omitempty"` // Optional key name for lists (i.e., maps) + + // Fields associated with leaf nodes + Type *YangType `json:",omitempty"` + + // Extensions found + Exts []*Statement `json:",omitempty"` + + // Fields associated with list nodes (both lists and leaf-lists) + ListAttr *ListAttr `json:",omitempty"` + + RPC *RPCEntry `json:",omitempty"` // set if we are an RPC + + // Identities that are defined in this context, this is set if the Entry + // is a module only. + Identities []*Identity `json:",omitempty"` + + Augments []*Entry `json:",omitempty"` // Augments defined in this entry. + Augmented []*Entry `json:",omitempty"` // Augments merged into this entry. + Deviations []*DeviatedEntry `json:"-"` // Deviations associated with this entry. + Deviate map[deviationType][]*Entry `json:"-"` + // deviationPresence tracks whether certain attributes for a DeviateEntry-type + // Entry have been given deviation values. + deviatePresence deviationPresence + Uses []*UsesStmt `json:",omitempty"` // Uses merged into this entry. + + // Extra maps all the unsupported fields to their values + Extra map[string][]interface{} `json:"extra-unstable,omitempty"` + + // Annotation stores annotated values, and is not populated by this + // library but rather can be used by calling code where additional + // information should be stored alongside the Entry. + Annotation map[string]interface{} `json:",omitempty"` + + // namespace stores the namespace of the Entry if it overrides the + // root namespace within the schema tree. This is the case where an + // entry is augmented into the tree, and it retains the namespace of + // the augmenting entity per RFC6020 Section 7.15.2. The namespace + // of the Entry should be accessed using the Namespace function. + namespace *Value +} + +// An RPCEntry contains information related to an RPC Node. +type RPCEntry struct { + Input *Entry + Output *Entry +} + +// A ListAttr is associated with an Entry that represents a List node +type ListAttr struct { + MinElements uint64 // leaf-list or list MUST have at least min-elements + MaxElements uint64 // leaf-list or list has at most max-elements + // OrderedBy is deprecated. Use OrderedByUser instead. + OrderedBy *Value + // OrderedByUser indicates whether the entries are "ordered-by user". + // Otherwise the order is determined by the system. + OrderedByUser bool +} + +// parseOrderedBy parses the ordered-by value and classifies the list/leaf-list +// by whether the `ordered-by user` modifier is active. +// +// For more information see +// https://datatracker.ietf.org/doc/html/rfc7950#section-7.7.7 +func (l *ListAttr) parseOrderedBy(s *Value) error { + if s == nil { + return nil + } + l.OrderedBy = s + switch s.Name { + case "user": + l.OrderedByUser = true + case "system": + default: + return fmt.Errorf("%s: ordered-by has invalid argument: %q", Source(s), s.Name) + } + return nil +} + +// NewDefaultListAttr returns a new ListAttr object with min/max elements being +// set to 0/math.MaxUint64 respectively. +func NewDefaultListAttr() *ListAttr { + return &ListAttr{ + MinElements: 0, + MaxElements: math.MaxUint64, + } +} + +// A UsesStmt associates a *Uses with its referenced grouping *Entry +type UsesStmt struct { + Uses *Uses + Grouping *Entry +} + +// Modules returns the Modules structure that e is part of. This is needed +// when looking for rooted nodes not part of this Entry tree. +func (e *Entry) Modules() *Modules { + for e.Parent != nil { + e = e.Parent + } + return e.Node.(*Module).Modules +} + +// IsDir returns true if e is a directory. +func (e *Entry) IsDir() bool { + return e.Dir != nil +} + +// IsLeaf returns true if e is a leaf i.e. is not a container, list, leaf-list, +// choice or case statement. +func (e *Entry) IsLeaf() bool { + return !e.IsDir() && e.Kind == LeafEntry && e.ListAttr == nil +} + +// IsLeafList returns true if e is a leaf-list. +func (e *Entry) IsLeafList() bool { + return !e.IsDir() && e.Kind == LeafEntry && e.ListAttr != nil +} + +// IsList returns true if e is a list. +func (e *Entry) IsList() bool { + return e.IsDir() && e.ListAttr != nil +} + +// IsContainer returns true if e is a container. +func (e *Entry) IsContainer() bool { + return e.Kind == DirectoryEntry && e.ListAttr == nil +} + +// IsChoice returns true if the entry is a choice node within the schema. +func (e *Entry) IsChoice() bool { + return e.Kind == ChoiceEntry +} + +// IsCase returns true if the entry is a case node within the schema. +func (e *Entry) IsCase() bool { + return e.Kind == CaseEntry +} + +// Print prints e to w in human readable form. +func (e *Entry) Print(w io.Writer) { + if e.Description != "" { + fmt.Fprintln(w) + fmt.Fprintln(indent.NewWriter(w, "// "), e.Description) + } + if e.ReadOnly() { + fmt.Fprintf(w, "RO: ") + } else { + fmt.Fprintf(w, "rw: ") + } + if e.Type != nil { + fmt.Fprintf(w, "%s ", e.Type.Name) + } + switch { + case e.Dir == nil && e.ListAttr != nil: + fmt.Fprintf(w, "[]%s\n", e.Name) + return + case e.Dir == nil: + fmt.Fprintf(w, "%s\n", e.Name) + return + case e.ListAttr != nil: + fmt.Fprintf(w, "[%s]%s {\n", e.Key, e.Name) //} + default: + fmt.Fprintf(w, "%s {\n", e.Name) //} + } + var names []string + for k := range e.Dir { + names = append(names, k) + } + sort.Strings(names) + for _, k := range names { + e.Dir[k].Print(indent.NewWriter(w, " ")) + } + // { to match the brace below to keep brace matching working + fmt.Fprintln(w, "}") +} + +// An EntryKind is the kind of node an Entry is. All leaf nodes are of kind +// LeafEntry. A LeafList is also considered a leaf node. All other kinds are +// directory nodes. +type EntryKind int + +// Enumeration of the types of entries. +const ( + LeafEntry = EntryKind(iota) + DirectoryEntry + AnyDataEntry + AnyXMLEntry + CaseEntry + ChoiceEntry + InputEntry + NotificationEntry + OutputEntry + DeviateEntry +) + +// EntryKindToName maps EntryKind to their names +var EntryKindToName = map[EntryKind]string{ + LeafEntry: "Leaf", + DirectoryEntry: "Directory", + AnyDataEntry: "AnyData", + AnyXMLEntry: "AnyXML", + CaseEntry: "Case", + ChoiceEntry: "Choice", + InputEntry: "Input", + NotificationEntry: "Notification", + OutputEntry: "Output", + DeviateEntry: "Deviate", +} + +func (k EntryKind) String() string { + if s := EntryKindToName[k]; s != "" { + return s + } + return fmt.Sprintf("unknown-entry-%d", k) +} + +// newDirectory returns an empty directory Entry. +func newDirectory(n Node) *Entry { + return &Entry{ + Kind: DirectoryEntry, + Dir: make(map[string]*Entry), + Node: n, + Name: n.NName(), + Extra: map[string][]interface{}{}, + } +} + +// newLeaf returns an empty leaf Entry. +func newLeaf(n Node) *Entry { + return &Entry{ + Kind: LeafEntry, + Node: n, + Name: n.NName(), + Extra: map[string][]interface{}{}, + } +} + +// newError returns an error Entry using format and v to create the error +// contained in the node. The location of the error is prepended. +func newError(n Node, format string, v ...interface{}) *Entry { + e := &Entry{Node: n} + e.errorf("%s: "+format, append([]interface{}{Source(n)}, v...)...) + return e +} + +// errorf appends the error constructed from string and v to the list of errors +// on e. +func (e *Entry) errorf(format string, v ...interface{}) { + e.Errors = append(e.Errors, fmt.Errorf(format, v...)) +} + +// addError appends err to the list of errors on e if err is not nil. +func (e *Entry) addError(err error) { + if err != nil { + e.Errors = append(e.Errors, err) + } +} + +// importErrors imports all the errors from c and its children into e. +func (e *Entry) importErrors(c *Entry) { + if c == nil { + return + } + for _, err := range c.Errors { + e.addError(err) + } + // TODO(borman): need to determine if the extensions have errors + // for _, ce := range e.Exts { + // e.importErrors(ce) + // } + for _, ce := range c.Dir { + e.importErrors(ce) + } +} + +// checkErrors calls f on every error found in the tree e and its children. +func (e *Entry) checkErrors(f func(error)) { + if e == nil { + return + } + for _, e := range e.Dir { + e.checkErrors(f) + } + for _, err := range e.Errors { + f(err) + } + // TODO(borman): need to determine if the extensions have errors + // for _, e := range e.Exts { + // e.checkErrors(f) + // } +} + +// GetErrors returns a sorted list of errors found in e. +func (e *Entry) GetErrors() []error { + // the seen map is used to eliminate duplicate errors. + // Some entries will be processed more than once + // (groupings in particular) and as such may cause + // duplication of errors. + seen := map[error]bool{} + var errs []error + e.checkErrors(func(err error) { + if !seen[err] { + errs = append(errs, err) + seen[err] = true + } + }) + return errorSort(errs) +} + +// add adds the directory entry key assigned to the provided value. +func (e *Entry) add(key string, value *Entry) *Entry { + value.Parent = e + if e.Dir[key] != nil { + e.errorf("%s: duplicate key from %s: %s", Source(e.Node), Source(value.Node), key) + return e + } + e.Dir[key] = value + return e +} + +// delete removes the directory entry key from the entry. +func (e *Entry) delete(key string) { + if _, ok := e.Dir[key]; !ok { + e.errorf("%s: unknown child key %s", Source(e.Node), key) + } + delete(e.Dir, key) +} + +// GetWhenXPath returns the when XPath statement of e if able. +func (e *Entry) GetWhenXPath() (string, bool) { + switch n := e.Node.(type) { + case *Container: + if n.When != nil && n.When.Statement() != nil { + return n.When.Statement().Arg() + } + case *Leaf: + if n.When != nil && n.When.Statement() != nil { + return n.When.Statement().Arg() + } + case *LeafList: + if n.When != nil && n.When.Statement() != nil { + return n.When.Statement().Arg() + } + case *List: + if n.When != nil && n.When.Statement() != nil { + return n.When.Statement().Arg() + } + case *Choice: + if n.When != nil && n.When.Statement() != nil { + return n.When.Statement().Arg() + } + case *Case: + if n.When != nil && n.When.Statement() != nil { + return n.When.Statement().Arg() + } + case *AnyXML: + if n.When != nil && n.When.Statement() != nil { + return n.When.Statement().Arg() + } + case *AnyData: + if n.When != nil && n.When.Statement() != nil { + return n.When.Statement().Arg() + } + case *Augment: + if n.When != nil && n.When.Statement() != nil { + return n.When.Statement().Arg() + } + } + return "", false +} + +// deviationType specifies an enumerated value covering the different substatements +// to the deviate statement. +type deviationType int64 + +const ( + // DeviationUnset specifies that the argument was unset, which is invalid. + DeviationUnset deviationType = iota + // DeviationNotSupported corresponds to the not-supported deviate argument. + DeviationNotSupported + // DeviationAdd corresponds to the add deviate argument to the deviate stmt. + DeviationAdd + // DeviationReplace corresponds to the replace argument to the deviate stmt. + DeviationReplace + // DeviationDelete corresponds to the delete argument to the deviate stmt. + DeviationDelete +) + +var ( + // fromDeviation maps from an enumerated deviation type to the YANG keyword. + fromDeviation = map[deviationType]string{ + DeviationNotSupported: "not-supported", + DeviationAdd: "add", + DeviationReplace: "replace", + DeviationDelete: "delete", + DeviationUnset: "unknown", + } + + // toDeviation maps from the YANG keyword to an enumerated deviation type. + toDeviation = map[string]deviationType{ + "not-supported": DeviationNotSupported, + "add": DeviationAdd, + "replace": DeviationReplace, + "delete": DeviationDelete, + } +) + +func (d deviationType) String() string { + return fromDeviation[d] +} + +// DeviatedEntry stores a wrapped Entry that corresponds to a deviation. +type DeviatedEntry struct { + Type deviationType // Type specifies the deviation type. + DeviatedPath string // DeviatedPath corresponds to the path that is being deviated. + // Entry is the embedded Entry storing the deviations that are made. Fields + // are set to the value in the schema after the deviation has been applied. + *Entry +} + +// semCheckMaxElements checks whether the max-element argument is valid, and returns the specified value. +func semCheckMaxElements(v *Value) (uint64, error) { + if v == nil || v.Name == "unbounded" { + return math.MaxUint64, nil + } + val, err := strconv.ParseUint(v.Name, 10, 64) + if err != nil { + return val, fmt.Errorf(`%s: invalid max-elements value %q (expect "unbounded" or a positive integer): %v`, Source(v), v.Name, err) + } + if val == 0 { + return val, fmt.Errorf(`%s: invalid max-elements value 0 (expect "unbounded" or a positive integer)`, Source(v)) + } + return val, nil +} + +// semCheckMinElements checks whether the min-element argument is valid, and returns the specified value. +func semCheckMinElements(v *Value) (uint64, error) { + if v == nil { + return 0, nil + } + val, err := strconv.ParseUint(v.Name, 10, 64) + if err != nil { + return val, fmt.Errorf(`%s: invalid min-elements value %q (expect a non-negative integer): %v`, Source(v), v.Name, err) + } + return val, nil +} + +// ToEntry expands node n into a directory Entry. Expansion is based on the +// YANG tags in the structure behind n. ToEntry must only be used +// with nodes that are directories, such as top level modules and sub-modules. +// ToEntry never returns nil. Any errors encountered are found in the Errors +// fields of the returned Entry and its children. Use GetErrors to determine +// if there were any errors. +func ToEntry(n Node) (e *Entry) { + if n == nil { + err := errors.New("ToEntry called on nil AST node") + return &Entry{ + Node: &ErrorNode{Error: err}, + Errors: []error{err}, + } + } + ms := RootNode(n).Modules + if e := ms.getEntryCache(n); e != nil { + return e + } + defer func() { + ms.setEntryCache(n, e) + }() + + // Copy in the extensions from our Node, if any. + defer func(n Node) { + if e != nil { + e.Exts = append(e.Exts, n.Exts()...) + } + }(n) + + // tristateValue returns TSTrue if i contains the value of true, TSFalse + // if it contains the value of false, and TSUnset if i does not have + // a set value (for instance, i is nil). An error is returned if i + // contains a value other than true or false. + tristateValue := func(i interface{}) (TriState, error) { + if v, ok := i.(*Value); ok && v != nil { + switch v.Name { + case "true": + return TSTrue, nil + case "false": + return TSFalse, nil + default: + return TSUnset, fmt.Errorf("%s: invalid config value: %s", Source(n), v.Name) + } + } + return TSUnset, nil + } + + var err error + // Handle non-directory nodes (leaf, leafref, and oddly enough, uses). + switch s := n.(type) { + case *Leaf: + e := newLeaf(n) + if errs := s.Type.resolve(ms.typeDict); errs != nil { + e.Errors = errs + } + if s.Description != nil { + e.Description = s.Description.Name + } + if s.Default != nil { + e.Default = []string{s.Default.Name} + } + e.Type = s.Type.YangType + e.Config, err = tristateValue(s.Config) + e.addError(err) + e.Prefix = getRootPrefix(e) + addExtraKeywordsToLeafEntry(n, e) + e.Mandatory, err = tristateValue(s.Mandatory) + e.addError(err) + return e + case *LeafList: + // Create the equivalent leaf element that we are a list of. + // We can then just annotate it as a list rather than a leaf. + leaf := &Leaf{ + Name: s.Name, + Source: s.Source, + Parent: s.Parent, + Extensions: s.Extensions, + Config: s.Config, + Description: s.Description, + IfFeature: s.IfFeature, + Must: s.Must, + Reference: s.Reference, + Status: s.Status, + Type: s.Type, + Units: s.Units, + When: s.When, + } + + e = ToEntry(leaf) + e.ListAttr = NewDefaultListAttr() + if err := e.ListAttr.parseOrderedBy(s.OrderedBy); err != nil { + e.addError(err) + } + var err error + if e.ListAttr.MaxElements, err = semCheckMaxElements(s.MaxElements); err != nil { + e.addError(err) + } + if e.ListAttr.MinElements, err = semCheckMinElements(s.MinElements); err != nil { + e.addError(err) + } + if len(s.Default) != 0 { + for _, def := range s.Default { + e.Default = append(e.Default, def.Name) + } + } + e.Prefix = getRootPrefix(e) + return e + case *Uses: + g := FindGrouping(s, s.Name, map[string]bool{}) + if g == nil { + return newError(n, "unknown group: %s", s.Name) + } + // We need to return a duplicate so we resolve properly + // when the group is used in multiple locations and the + // grouping has a leafref that references outside the group. + e = ToEntry(g).dup() + addExtraKeywordsToLeafEntry(n, e) + return e + } + + e = newDirectory(n) + + // Special handling for individual Node types. Lists are like any other + // node except a List has a ListAttr. + // + // Nodes of identified special kinds have their Kind set here. + switch s := n.(type) { + case *List: + e.ListAttr = NewDefaultListAttr() + if err := e.ListAttr.parseOrderedBy(s.OrderedBy); err != nil { + e.addError(err) + } + var err error + if e.ListAttr.MaxElements, err = semCheckMaxElements(s.MaxElements); err != nil { + e.addError(err) + } + if e.ListAttr.MinElements, err = semCheckMinElements(s.MinElements); err != nil { + e.addError(err) + } + case *Choice: + e.Kind = ChoiceEntry + if s.Default != nil { + e.Default = []string{s.Default.Name} + } + case *Case: + e.Kind = CaseEntry + case *AnyData: + e.Kind = AnyDataEntry + case *AnyXML: + e.Kind = AnyXMLEntry + case *Input: + e.Kind = InputEntry + case *Output: + e.Kind = OutputEntry + case *Notification: + e.Kind = NotificationEntry + case *Deviate: + e.Kind = DeviateEntry + } + + // Use Elem to get the Value of structure that n is pointing to. + v := reflect.ValueOf(n).Elem() + t := v.Type() + found := false + + for i := t.NumField() - 1; i > 0; i-- { + f := t.Field(i) + yang := f.Tag.Get("yang") + if yang == "" { + continue + } + fv := v.Field(i) + name := strings.Split(yang, ",")[0] + switch name { + case "": + e.addError(fmt.Errorf("%s: nil statement", Source(n))) + case "config": + e.Config, err = tristateValue(fv.Interface()) + e.addError(err) + case "description": + if v := fv.Interface().(*Value); v != nil { + e.Description = v.Name + } + case "prefix": + if v := fv.Interface().(*Value); v != nil { + e.Prefix = v + } + case "action": + for _, r := range fv.Interface().([]*Action) { + e.add(r.Name, ToEntry(r)) + } + case "augment": + for _, a := range fv.Interface().([]*Augment) { + ne := ToEntry(a) + ne.Parent = e + e.Augments = append(e.Augments, ne) + } + case "anydata": + for _, a := range fv.Interface().([]*AnyData) { + e.add(a.Name, ToEntry(a)) + } + case "anyxml": + for _, a := range fv.Interface().([]*AnyXML) { + e.add(a.Name, ToEntry(a)) + } + case "case": + for _, a := range fv.Interface().([]*Case) { + e.add(a.Name, ToEntry(a)) + } + case "choice": + for _, a := range fv.Interface().([]*Choice) { + e.add(a.Name, ToEntry(a)) + } + case "container": + for _, a := range fv.Interface().([]*Container) { + e.add(a.Name, ToEntry(a)) + } + case "grouping": + for _, a := range fv.Interface().([]*Grouping) { + // We just want to parse the grouping to + // collect errors. + e.importErrors(ToEntry(a)) + } + case "import": + // Import only makes types and such available. + // There is nothing else for us to do. + case "include": + for _, a := range fv.Interface().([]*Include) { + // Handle circular dependencies between submodules. This can occur in + // two ways: + // - Where submodule A imports submodule B, and vice versa then the + // whilst processing A we will also try and process A (learnt via + // B). The default case of the switch handles this case. + // - Where submodule A imports submodule B that imports C, which also + // imports A, then we need to check whether we already have merged + // the specified module during this parse attempt. We check this + // against a map of merged submodules. + // The key of the map used is a synthesised value which is formed by + // concatenating the name of this node and the included submodule, + // separated by a ":". + srcToIncluded := a.Module.Name + ":" + n.NName() + includedToSrc := n.NName() + ":" + a.Module.Name + + switch { + case ms.mergedSubmodule[srcToIncluded]: + // We have already merged this module, so don't try and do it + // again. + continue + case !ms.mergedSubmodule[includedToSrc] && a.Module.NName() != n.NName(): + // We have not merged A->B, and B != B hence go ahead and merge. + includedToParent := a.Module.Name + ":" + a.Module.BelongsTo.Name + if ms.mergedSubmodule[includedToParent] { + // Don't try and re-import submodules that have already been imported + // into the top-level module. Note that this ensures that we get to the + // top the tree (whichever the actual module for the chain of + // submodules is). The tracking of the immediate parent is achieved + // through 'key', which ensures that we do not end up in loops + // walking through a sub-cycle of the include graph. + continue + } + ms.mergedSubmodule[srcToIncluded] = true + ms.mergedSubmodule[includedToParent] = true + e.merge(a.Module.Prefix, nil, ToEntry(a.Module)) + case ms.ParseOptions.IgnoreSubmoduleCircularDependencies: + continue + default: + e.addError(fmt.Errorf("%s: has a circular dependency, importing %s", n.NName(), a.Module.NName())) + } + } + case "leaf": + for _, a := range fv.Interface().([]*Leaf) { + e.add(a.Name, ToEntry(a)) + } + case "leaf-list": + for _, a := range fv.Interface().([]*LeafList) { + e.add(a.Name, ToEntry(a)) + } + case "list": + for _, a := range fv.Interface().([]*List) { + e.add(a.Name, ToEntry(a)) + } + case "key": + if v := fv.Interface().(*Value); v != nil { + e.Key = v.Name + } + case "notification": + for _, a := range fv.Interface().([]*Notification) { + e.add(a.Name, ToEntry(a)) + } + case "rpc": + // TODO(borman): what do we do with these? + // seems fine to ignore them for now, we are + // just interested in the tree structure. + for _, r := range fv.Interface().([]*RPC) { + switch rpc := ToEntry(r); { + case rpc.RPC == nil: + // When "rpc" has no "input" or "output" children + rpc.RPC = &RPCEntry{} + fallthrough + default: + e.add(r.Name, rpc) + } + } + + case "input": + if i := fv.Interface().(*Input); i != nil { + if e.RPC == nil { + e.RPC = &RPCEntry{} + } + in := ToEntry(i) + in.Parent = e + e.RPC.Input = in + e.RPC.Input.Name = "input" + e.RPC.Input.Kind = InputEntry + } + case "output": + if o := fv.Interface().(*Output); o != nil { + if e.RPC == nil { + e.RPC = &RPCEntry{} + } + out := ToEntry(o) + out.Parent = e + e.RPC.Output = out + e.RPC.Output.Name = "output" + e.RPC.Output.Kind = OutputEntry + } + case "identity": + if i := fv.Interface().([]*Identity); i != nil { + e.Identities = i + } + case "uses": + for _, a := range fv.Interface().([]*Uses) { + grouping := ToEntry(a) + e.merge(nil, nil, grouping) + // Apply inline augments from the uses statement. Their paths + // are relative to e (the node where the uses appears), so we + // resolve them directly rather than deferring to e.Augment(). + for _, aug := range a.Augment { + target := e.Find(aug.Name) + if target != nil { + augEntry := ToEntry(aug) + target.merge(nil, augEntry.Namespace(), augEntry) + } + } + if ms.ParseOptions.StoreUses { + e.Uses = append(e.Uses, &UsesStmt{a, grouping.shallowDup()}) + } + } + case "type": + // The type keyword is specific to deviate to change a type. Other type handling + // (e.g., leaf type resolution) is done outside of this case. + n, ok := n.(*Deviate) + if !ok { + e.addError(fmt.Errorf("unexpected type found, only valid under Deviate, is %T", n)) + continue + } + + if n.Type != nil { + if errs := n.Type.resolve(ms.typeDict); errs != nil { + e.addError(fmt.Errorf("deviation has unresolvable type, %v", errs)) + continue + } + e.Type = n.Type.YangType + } + continue + // Keywords that do not need to be handled as an Entry as they are added + // to other dictionaries. + case "default": + switch e.Kind { + case LeafEntry, ChoiceEntry: + // default is handled separately for leaf, leaf-list and choice + case DeviateEntry: + // handle deviate statements. + // TODO(wenovus): support refine statement's default substatement. + d, ok := fv.Interface().(*Value) + if !ok { + e.addError(fmt.Errorf("%s: unexpected default type in %s:%s", Source(n), n.Kind(), n.NName())) + } + // TODO(wenovus): deviate statement and refine statement should + // allow multiple default substatements for leaf-list types (YANG1.1). + if d != nil { + e.Default = []string{d.asString()} + } + } + case "typedef": + continue + case "deviation": + if a := fv.Interface().([]*Deviation); a != nil { + for _, d := range a { + deviatedEntry := ToEntry(d) + e.importErrors(deviatedEntry) + e.Deviations = append(e.Deviations, &DeviatedEntry{ + Entry: deviatedEntry, + DeviatedPath: d.Statement().Argument, + }) + + for _, sd := range d.Deviate { + if sd.Type != nil { + sd.Type.resolve(ms.typeDict) + } + } + } + } + case "deviate": + if a := fv.Interface().([]*Deviate); a != nil { + for _, d := range a { + de := ToEntry(d) + + dt, ok := toDeviation[d.Statement().Argument] + if !ok { + e.addError(fmt.Errorf("%s: unknown deviation type in %s:%s", Source(n), n.Kind(), n.NName())) + continue + } + + if e.Deviate == nil { + e.Deviate = map[deviationType][]*Entry{} + } + + e.Deviate[dt] = append(e.Deviate[dt], de) + } + } + case "mandatory": + v, ok := fv.Interface().(*Value) + if !ok { + e.addError(fmt.Errorf("%s: did not get expected value type", Source(n))) + } + e.Mandatory, err = tristateValue(v) + e.addError(err) + case "max-elements", "min-elements": + if e.Kind != DeviateEntry { + continue + } + // we can get max-elements or min-elements in a deviate statement, so create the + // corresponding logic. + v, ok := fv.Interface().(*Value) + if !ok { + e.addError(fmt.Errorf("%s: max or min elements had wrong type, %s:%s", Source(n), n.Kind(), n.NName())) + continue + } + + if e.ListAttr == nil { + e.ListAttr = NewDefaultListAttr() + } + + // Only record the deviation if the statement exists. + if v != nil { + var err error + if name == "max-elements" { + e.deviatePresence.hasMaxElements = true + if e.ListAttr.MaxElements, err = semCheckMaxElements(v); err != nil { + e.addError(err) + } + } else { + e.deviatePresence.hasMinElements = true + if e.ListAttr.MinElements, err = semCheckMinElements(v); err != nil { + e.addError(err) + } + } + } + case "units": + v, ok := fv.Interface().(*Value) + if !ok { + e.addError(fmt.Errorf("%s: units had wrong type, %s:%s", Source(n), n.Kind(), n.NName())) + } + if v != nil { + e.Units = v.asString() + } + // TODO(borman): unimplemented keywords + case "belongs-to", + "contact", + "extension", + "feature", + "if-feature", + "must", + "namespace", + "ordered-by", + "organization", + "presence", + "reference", + "revision", + "status", + "unique", + "when", + "yang-version": + if !fv.IsNil() { + addToExtrasSlice(fv, name, e) + } + continue + + case "Ext", "Name", "Parent", "Statement": + // These are meta-keywords used internally + continue + default: + e.addError(fmt.Errorf("%s: unexpected statement: %s", Source(n), name)) + continue + + } + // We found at least one field. + found = true + } + if !found { + return newError(n, "%T: cannot be converted to a *Entry", n) + } + // If prefix isn't set, provide it based on our root node (module) + if e.Prefix == nil { + e.Prefix = getRootPrefix(e) + } + + return e +} + +// addExtraKeywordsToLeafEntry stores the values for unimplemented keywords in leaf entries. +func addExtraKeywordsToLeafEntry(n Node, e *Entry) { + v := reflect.ValueOf(n).Elem() + t := v.Type() + + for i := t.NumField() - 1; i > 0; i-- { + f := t.Field(i) + yang := f.Tag.Get("yang") + if yang == "" { + continue + } + fv := v.Field(i) + name := strings.Split(yang, ",")[0] + switch name { + case "if-feature", + "must", + "reference", + "status", + "when": + if !fv.IsNil() { + addToExtrasSlice(fv, name, e) + } + } + } +} + +func addToExtrasSlice(fv reflect.Value, name string, e *Entry) { + if fv.Kind() == reflect.Slice { + for j := 0; j < fv.Len(); j++ { + e.Extra[name] = append(e.Extra[name], fv.Index(j).Interface()) + } + } else { + e.Extra[name] = append(e.Extra[name], fv.Interface()) + } +} + +// getRootPrefix returns the prefix of e's root node (module) +func getRootPrefix(e *Entry) *Value { + if m := RootNode(e.Node); m != nil { + return m.getPrefix() + } + return nil +} + +// Augment processes augments in e, return the number of augments processed +// and the augments skipped. If addErrors is true then missing augments will +// generate errors. +func (e *Entry) Augment(addErrors bool) (processed, skipped int) { + // Now process the augments we found + // NOTE(borman): is it possible this will fail if the augment refers + // to some removed sibling that has not been processed? Perhaps this + // should be done after the entire tree is built. Is it correct to + // assume augment paths are data tree paths and not schema tree paths? + // Augments can depend upon augments. We need to figure out how to + // order the augments (or just keep trying until we can make no further + // progress) + var unapplied []*Entry + for _, a := range e.Augments { + target := a.Find(a.Name) + if target == nil { + if addErrors { + e.errorf("%s: augment %s not found", Source(a.Node), a.Name) + } + skipped++ + unapplied = append(unapplied, a) + continue + } + // Augments do not have a prefix we merge in, just a node. + // We retain the namespace from the original context of the + // augment since the nodes have this namespace even though they + // are merged into another entry. + processed++ + target.merge(nil, a.Namespace(), a) + target.Augmented = append(target.Augmented, a.shallowDup()) + } + e.Augments = unapplied + return processed, skipped +} + +// ApplyDeviate walks the deviations within the supplied entry, and applies them to the +// schema. +func (e *Entry) ApplyDeviate(deviateOpts ...DeviateOpt) []error { + var errs []error + appendErr := func(err error) { errs = append(errs, err) } + for _, d := range e.Deviations { + deviatedNode := e.Find(d.DeviatedPath) + if deviatedNode == nil { + appendErr(fmt.Errorf("cannot find target node to deviate, %s", d.DeviatedPath)) + continue + } + + for dt, dv := range d.Deviate { + for _, devSpec := range dv { + switch dt { + case DeviationAdd, DeviationReplace: + if devSpec.Config != TSUnset { + deviatedNode.Config = devSpec.Config + } + + if len(devSpec.Default) > 0 { + switch dt { + case DeviationAdd: + switch { + case deviatedNode.IsLeafList(): + deviatedNode.Default = append(deviatedNode.Default, devSpec.Default...) + case len(devSpec.Default) > 1: + appendErr(fmt.Errorf("%s: tried to add more than one default to a non-leaflist entry at deviation", Source(e.Node))) + case len(deviatedNode.Default) != 0: + appendErr(fmt.Errorf("%s: tried to add a default value to an entry that already has a default value", Source(e.Node))) + case len(devSpec.Default) == 1 && len(deviatedNode.Default) == 0: + deviatedNode.Default = append([]string{}, devSpec.Default[0]) + } + case DeviationReplace: + deviatedNode.Default = append([]string{}, devSpec.Default...) + } + } + + if devSpec.Mandatory != TSUnset { + deviatedNode.Mandatory = devSpec.Mandatory + } + + if devSpec.deviatePresence.hasMinElements { + if !deviatedNode.IsList() && !deviatedNode.IsLeafList() { + appendErr(fmt.Errorf("tried to deviate min-elements on a non-list type %s", deviatedNode.Kind)) + continue + } + deviatedNode.ListAttr.MinElements = devSpec.ListAttr.MinElements + } + + if devSpec.deviatePresence.hasMaxElements { + if !deviatedNode.IsList() && !deviatedNode.IsLeafList() { + appendErr(fmt.Errorf("tried to deviate max-elements on a non-list type %s", deviatedNode.Kind)) + continue + } + deviatedNode.ListAttr.MaxElements = devSpec.ListAttr.MaxElements + } + + if devSpec.Units != "" { + deviatedNode.Units = devSpec.Units + } + + if devSpec.Type != nil { + deviatedNode.Type = devSpec.Type + } + + case DeviationNotSupported: + dp := deviatedNode.Parent + if dp == nil { + appendErr(fmt.Errorf("%s: node %s does not have a valid parent, but deviate not-supported references one", Source(e.Node), e.Name)) + continue + } + if !hasIgnoreDeviateNotSupported(deviateOpts) { + dp.delete(deviatedNode.Name) + } + case DeviationDelete: + if devSpec.Config != TSUnset { + deviatedNode.Config = TSUnset + } + + if len(devSpec.Default) > 0 { + switch { + case deviatedNode.IsLeafList(): + // It is unclear from RFC7950 on how deviate delete works + // when there are duplicate leaf-list values in config-false leafs. + // TODO(wenbli): Add support for deleting default values when the leaf-list is a config leaf (duplicates are not allowed). + appendErr(fmt.Errorf("%s: deviate delete on default statements unsupported for leaf-lists, please use replace instead", Source(e.Node))) + case len(deviatedNode.Default) == 0: + appendErr(fmt.Errorf("%s: tried to deviate delete a default statement that doesn't exist", Source(e.Node))) + case devSpec.Default[0] != deviatedNode.Default[0]: + appendErr(fmt.Errorf("%s: tried to deviate delete a default statement with a non-matching keyword", Source(e.Node))) + default: + deviatedNode.Default = nil + } + } + + if devSpec.Mandatory != TSUnset { + deviatedNode.Mandatory = TSUnset + } + + if devSpec.deviatePresence.hasMinElements { + if !deviatedNode.IsList() && !deviatedNode.IsLeafList() { + appendErr(fmt.Errorf("tried to deviate min-elements on a non-list type %s", deviatedNode.Kind)) + continue + } + if deviatedNode.ListAttr.MinElements != devSpec.ListAttr.MinElements { + // Argument value must match: + // https://tools.ietf.org/html/rfc7950#section-7.20.3.2 + appendErr(fmt.Errorf("min-element value %d differs from deviation's min-element value %d for entry %v", devSpec.ListAttr.MinElements, deviatedNode.ListAttr.MinElements, d.DeviatedPath)) + } + deviatedNode.ListAttr.MinElements = 0 + } + + if devSpec.deviatePresence.hasMaxElements { + if !deviatedNode.IsList() && !deviatedNode.IsLeafList() { + appendErr(fmt.Errorf("tried to deviate max-elements on a non-list type %s", deviatedNode.Kind)) + continue + } + if deviatedNode.ListAttr.MaxElements != devSpec.ListAttr.MaxElements { + appendErr(fmt.Errorf("max-element value %d differs from deviation's max-element value %d for entry %v", devSpec.ListAttr.MaxElements, deviatedNode.ListAttr.MaxElements, d.DeviatedPath)) + } + deviatedNode.ListAttr.MaxElements = math.MaxUint64 + } + + default: + appendErr(fmt.Errorf("invalid deviation type %s", dt)) + } + } + } + } + + return errs +} + +// FixChoice inserts missing Case entries for non-case entries within a choice +// entry. +func (e *Entry) FixChoice() { + if e.Kind == ChoiceEntry && len(e.Errors) == 0 { + for k, ce := range e.Dir { + if ce.Kind != CaseEntry { + ne := &Entry{ + Parent: e, + Node: &Case{ + Parent: ce.Node.ParentNode(), + Name: ce.Node.NName(), + Source: ce.Node.Statement(), + Extensions: ce.Node.Exts(), + }, + Name: ce.Name, + Kind: CaseEntry, + Config: ce.Config, + Prefix: ce.Prefix, + Dir: map[string]*Entry{ce.Name: ce}, + Extra: map[string][]interface{}{}, + } + ce.Parent = ne + e.Dir[k] = ne + } + } + } + for _, ce := range e.Dir { + ce.FixChoice() + } +} + +// ReadOnly returns true if e is a read-only variable (config == false). +// If Config is unset in e, then false is returned if e has no parent, +// otherwise the value parent's ReadOnly is returned. +func (e *Entry) ReadOnly() bool { + switch { + case e == nil: + // We made it all the way to the root of the tree + return false + case e.Kind == OutputEntry: + return true + case e.Config == TSUnset: + return e.Parent.ReadOnly() + default: + return !e.Config.Value() + } +} + +// Find finds the Entry named by name relative to e. +func (e *Entry) Find(name string) *Entry { + if e == nil || name == "" { + return nil + } + parts := strings.Split(name, "/") + + // If parts[0] is "" then this path started with a / + // and we need to find our parent. + if parts[0] == "" { + parts = parts[1:] + contextNode := e.Node + for e.Parent != nil { + e = e.Parent + } + if prefix, _ := getPrefix(parts[0]); prefix != "" { + mod := FindModuleByPrefix(contextNode, prefix) + if mod == nil { + e.addError(fmt.Errorf("cannot find module giving prefix %q within context entry %q", prefix, e.Path())) + return nil + } + m := module(mod) + if m == nil { + e.addError(fmt.Errorf("cannot find which module %q belongs to within context entry %q", + mod.NName(), e.Path())) + return nil + } + if m != e.Node.(*Module) { + e = ToEntry(m) + } + } + } + + for _, part := range parts { + switch { + case e == nil: + return nil + case part == ".": + case part == "..": + e = e.Parent + case e.RPC != nil: + _, part = getPrefix(part) + switch part { + case "input": + if e.RPC.Input == nil { + e.RPC.Input = &Entry{ + Name: "input", + Kind: InputEntry, + Dir: make(map[string]*Entry), + } + } + e = e.RPC.Input + case "output": + if e.RPC.Output == nil { + e.RPC.Output = &Entry{ + Name: "output", + Kind: OutputEntry, + Dir: make(map[string]*Entry), + } + } + e = e.RPC.Output + } + default: + _, part = getPrefix(part) + switch part { + case ".": + case "", "..": + return nil + default: + e = e.Dir[part] + } + } + } + return e +} + +// Path returns the path to e. A nil Entry returns "". +func (e *Entry) Path() string { + if e == nil { + return "" + } + return e.Parent.Path() + "/" + e.Name +} + +// Namespace returns the YANG/XML namespace Value for e as mounted in the Entry +// tree (e.g., as placed by grouping statements). +// +// Per RFC6020 section 7.12, the namespace on elements in the tree due to a +// "uses" statement is that of the where the uses statement occurs, i.e., the +// user, rather than creator (grouping) of those elements, so we follow the +// usage (Entry) tree up to the parent before obtaining the (then adjacent) root +// node for its namespace Value. +func (e *Entry) Namespace() *Value { + // Make e the root parent entry + for ; e.Parent != nil; e = e.Parent { + if e.namespace != nil { + return e.namespace + } + } + + // Return the namespace of a valid root parent entry + if e != nil && e.Node != nil { + if root := RootNode(e.Node); root != nil { + if root.Kind() == "submodule" { + root = root.Modules.Modules[root.BelongsTo.Name] + if root == nil { + return new(Value) + } + } + return root.Namespace + } + } + + // Otherwise return an empty namespace Value (rather than nil) + return new(Value) +} + +// InstantiatingModule returns the YANG module which instantiated the Entry +// within the schema tree - using the same rules described in the documentation +// of the Namespace function. The namespace is resolved in the module name. This +// approach to namespacing is used when serialising YANG-modelled data to JSON as +// per RFC7951. +func (e *Entry) InstantiatingModule() (string, error) { + n := e.Namespace() + if n == nil { + return "", fmt.Errorf("entry %s had nil namespace", e.Name) + } + + module, err := e.Modules().FindModuleByNamespace(n.Name) + if err != nil { + return "", fmt.Errorf("could not find module %q when retrieving namespace for %s: %v", n.Name, e.Name, err) + } + return module.Name, nil +} + +// shallowDup makes a shallow duplicate of e (only direct children are +// duplicated; grandchildren and deeper descendants are deleted). +func (e *Entry) shallowDup() *Entry { + // Warning: if we add any elements to Entry that should not be + // copied we will have to explicitly uncopy them. + ne := *e + + // Now only copy direct children, clear their Dir, and fix up + // Parent pointers. + if e.Dir != nil { + ne.Dir = make(map[string]*Entry, len(e.Dir)) + for k, v := range e.Dir { + de := *v + de.Dir = nil + de.Parent = &ne + ne.Dir[k] = &de + } + } + return &ne +} + +// dup makes a deep duplicate of e. +func (e *Entry) dup() *Entry { + // Warning: if we add any elements to Entry that should not be + // copied we will have to explicitly uncopy them. + // It is possible we may want to do a deep copy on some other fields, + // such as Exts, Choice and Case, but it is not clear that we need + // to do that. + ne := *e + + // Now recurse down to all of our children, fixing up Parent + // pointers as we go. + if e.Dir != nil { + ne.Dir = make(map[string]*Entry, len(e.Dir)) + for k, v := range e.Dir { + de := v.dup() + de.Parent = &ne + ne.Dir[k] = de + } + } + + ne.Extra = make(map[string][]interface{}) + for k, v := range e.Extra { + ne.Extra[k] = v + } + + return &ne +} + +// merge merges a duplicate of oe.Dir into e.Dir, setting the prefix of each +// element to prefix, if not nil. It is an error if e and oe contain common +// elements. +func (e *Entry) merge(prefix *Value, namespace *Value, oe *Entry) { + e.importErrors(oe) + for k, v := range oe.Dir { + v := v.dup() + if prefix != nil { + v.Prefix = prefix + } + if namespace != nil { + v.namespace = namespace + } + if se := e.Dir[k]; se != nil { + er := newError(oe.Node, `Duplicate node %q in %q from: + %s: %s + %s: %s`, k, e.Name, Source(v.Node), v.Name, Source(se.Node), se.Name) + e.addError(er.Errors[0]) + } else { + v.Parent = e + v.Exts = append(v.Exts, oe.Exts...) + for lk := range oe.Extra { + v.Extra[lk] = append(v.Extra[lk], oe.Extra[lk]...) + } + e.Dir[k] = v + } + } +} + +// nless returns -1 if a is less than b, 0 if a == b, and 1 if a > b. +// If a and b are both numeric, then nless compares them as numbers, +// otherwise they are compared lexicographically. +func nless(a, b string) int { + an, ae := strconv.Atoi(a) + bn, be := strconv.Atoi(b) + switch { + case ae == nil && be == nil: + switch { + case an < bn: + return -1 + case an > bn: + return 1 + default: + return 0 + } + case a < b: + return -1 + case a > b: + return 1 + default: + return 0 + } +} + +type sError struct { + s string + err error +} + +type sortedErrors []sError + +func (s sortedErrors) Len() int { return len(s) } +func (s sortedErrors) Swap(i, j int) { s[i], s[j] = s[j], s[i] } +func (s sortedErrors) Less(i, j int) bool { + // We expect the error strings to be composed of error messages, + // line numbers, etc. delimited by ":". + const errorSplitCount = 4 + fi := strings.SplitN(s[i].s, ":", errorSplitCount) + fj := strings.SplitN(s[j].s, ":", errorSplitCount) + // First, order the errors by the file name. + if fi[0] < fj[0] { + return true + } + if fi[0] > fj[0] { + return false + } + + // compare remaining indices of the error string slices + // in order to create a total ordering. + for i := 1; i < errorSplitCount; i++ { + switch { + // Handle when an expected index doesn't exist. + case len(fj) == i: + return false + case len(fi) == i: + return true + } + + switch nless(fi[i], fj[i]) { + case -1: + return true + case 1: + return false + } + } + return false +} + +// errorSort sorts the strings in the errors slice assuming each line starts +// with file:line:col. Line and column number are sorted numerically. +// Duplicate errors are stripped. +func errorSort(errors []error) []error { + switch len(errors) { + case 0: + return nil + case 1: + return errors + } + elist := make(sortedErrors, len(errors)) + for x, err := range errors { + elist[x] = sError{err.Error(), err} + } + sort.Sort(elist) + errors = make([]error, len(errors)) + i := 0 + for _, err := range elist { + if i > 0 && reflect.DeepEqual(err.err, errors[i-1]) { + continue + } + errors[i] = err.err + i++ + } + return errors[:i] +} + +// SingleDefaultValue returns the single schema default value for e and a bool +// indicating whether the entry contains one and only one default value. The +// empty string is returned when the entry has zero or multiple default values. +// This function is useful for determining the default values of a +// non-leaf-list leaf entry. If the leaf has no explicit default, its type +// default (if any) will be used. +// +// For a leaf-list entry, use DefaultValues() instead. +func (e *Entry) SingleDefaultValue() (string, bool) { + if dvals := e.DefaultValues(); len(dvals) == 1 { + return dvals[0], true + } + return "", false +} + +// DefaultValues returns all default values for the leaf entry. This is useful +// for determining the default values for a leaf-list, which may have more than +// one default value. If the entry has no explicit default, its type default +// (if any) will be used. nil is returned when no default value exists. +// +// For a leaf entry, use SingleDefaultValue() instead. +func (e *Entry) DefaultValues() []string { + if len(e.Default) > 0 { + return append([]string{}, e.Default...) + } + + if typ := e.Type; typ != nil && typ.HasDefault { + switch leaf := e.Node.(type) { + case *Leaf: + switch { + case e.IsLeaf() && (leaf.Mandatory == nil || leaf.Mandatory.Name == "false"), e.IsLeafList() && e.ListAttr.MinElements == 0: + return []string{typ.Default} + } + } + } + return nil +} diff --git a/src/webui/internal/goyang/pkg/yang/entry_test.go b/src/webui/internal/goyang/pkg/yang/entry_test.go new file mode 100644 index 00000000..2000238b --- /dev/null +++ b/src/webui/internal/goyang/pkg/yang/entry_test.go @@ -0,0 +1,4141 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +import ( + "bytes" + "errors" + "fmt" + "io/ioutil" + "math" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/openconfig/gnmi/errdiff" +) + +func TestNilEntry(t *testing.T) { + e := ToEntry(nil) + _, ok := e.Node.(*ErrorNode) + if !ok { + t.Fatalf("ToEntry(nil) did not return an error node") + } + errs := e.GetErrors() + switch len(errs) { + default: + t.Errorf("got %d errors, wanted 1", len(errs)) + fallthrough + case 1: + got := errs[0].Error() + want := "ToEntry called on nil AST node" + if got != want { + t.Fatalf("got error %q, want %q", got, want) + } + case 0: + t.Fatalf("GetErrors returned no error") + } +} + +var badInputs = []struct { + name string + in string + errors []string +}{ + { + name: "bad.yang", + in: ` +// Base test yang module. +// This module is syntactally correct (we can build an AST) but it is has +// invalid parameters in many statements. +module base { + namespace "urn:mod"; + prefix "base"; + + container c { + // bad config value in a container + config bad; + } + container d { + leaf bob { + // bad config value + config incorrect; + type unknown; + } + // duplicate leaf entry bob + leaf bob { type string; } + // unknown grouping to uses + uses the-beatles; + } + grouping the-group { + leaf one { type string; } + // duplicate leaf in unused grouping. + leaf one { type int; } + } + uses the-group; +} +`, + errors: []string{ + `bad.yang:9:3: invalid config value: bad`, + `bad.yang:13:3: duplicate key from bad.yang:20:5: bob`, + `bad.yang:14:5: invalid config value: incorrect`, + `bad.yang:17:7: unknown type: base:unknown`, + `bad.yang:22:5: unknown group: the-beatles`, + `bad.yang:24:3: duplicate key from bad.yang:27:5: one`, + }, + }, + { + name: "bad-augment.yang", + in: ` +module base { + namespace "urn:mod"; + prefix "base"; + // augmentation of unknown element + augment erewhon { + leaf bob { + type string; + // bad config value in unused augment + config wrong; + } + } +} +`, + errors: []string{ + `bad-augment.yang:6:3: augment erewhon not found`, + }, + }, + { + name: "bad-min-max-elements.yang", + in: ` +module base { + namespace "urn:mod"; + prefix "base"; + list foo { + // bad arguments to min-elements and max-elements + min-elements bar; + max-elements -5; + } + leaf-list bar { + type string; + // bad arguments to min-elements and max-elements + min-elements unbounded; + max-elements 122222222222222222222222222222222222222222222222222222222222; + } + list baz { + // good arguments + min-elements 0; + max-elements unbounded; + } + list caz { + // bad max element: has to be positive. + min-elements 0; + max-elements 0; + } +} +`, + errors: []string{ + `bad-min-max-elements.yang:7:5: invalid min-elements value`, + `bad-min-max-elements.yang:8:5: invalid max-elements value`, + `bad-min-max-elements.yang:13:5: invalid min-elements value`, + `bad-min-max-elements.yang:14:5: invalid max-elements value`, + `bad-min-max-elements.yang:24:5: invalid max-elements value`, + }, + }, +} + +func TestBadYang(t *testing.T) { + for _, tt := range badInputs { + ms := NewModules() + if err := ms.Parse(tt.in, tt.name); err != nil { + t.Fatalf("unexpected error %s", err) + } + errs := ms.Process() + if len(errs) != len(tt.errors) { + t.Errorf("got %d errors, want %d", len(errs), len(tt.errors)) + } else { + ok := true + for x, err := range errs { + if !strings.Contains(err.Error(), tt.errors[x]) { + ok = false + break + } + } + if ok { + continue + } + } + + var b bytes.Buffer + fmt.Fprint(&b, "got errors:\n") + for _, err := range errs { + fmt.Fprintf(&b, "\t%v\n", err) + } + fmt.Fprint(&b, "want errors:\n") + for _, err := range tt.errors { + fmt.Fprintf(&b, "\t%s\n", err) + } + t.Error(b.String()) + } +} + +var parentTestModules = []struct { + name string + in string +}{ + { + name: "foo.yang", + in: ` +module foo { + namespace "urn:foo"; + prefix "foo"; + + import bar { prefix "temp-bar"; } + container foo-c { + leaf zzz { type string; } + leaf-list foo-list { type string; } + uses temp-bar:common; + } + uses temp-bar:common; +} +`, + }, + { + name: "bar.yang", + in: ` +module bar { + namespace "urn:bar"; + prefix "bar"; + + grouping common { + container test1 { leaf str { type string; } } + container test2 { leaf str { type string; } } + } + + container bar-local { + leaf test1 { type string; } + } + +} +`, + }, + { + name: "baz.yang", + in: ` +module baz { + namespace "urn:baz"; + prefix "baz"; + + import foo { prefix "f"; } + + grouping baz-common { + leaf baz-common-leaf { type string; } + container baz-dir { + leaf aardvark { type string; } + } + } + + augment /f:foo-c { + uses baz-common; + leaf baz-direct-leaf { type string; } + } +} +`, + }, + { + name: "baz-augment.yang", + in: ` + submodule baz-augment { + belongs-to baz { + prefix "baz"; + } + + import foo { prefix "f"; } + + augment "/f:foo-c" { + leaf baz-submod-leaf { type string; } + } + } + `, + }, + { + name: "qux-augment.yang", + in: ` + submodule qux-augment { + belongs-to qux { + prefix "qux"; + } + + import foo { prefix "f"; } + + augment "/f:foo-c" { + leaf qux-submod-leaf { type string; } + } + } + `, + }, +} + +func TestUsesParent(t *testing.T) { + ms := NewModules() + for _, tt := range parentTestModules { + _ = ms.Parse(tt.in, tt.name) + } + + efoo, _ := ms.GetModule("foo") + used := efoo.Dir["foo-c"].Dir["test1"] + expected := "/foo/foo-c/test1" + if used.Path() != expected { + t.Errorf("want %s, got %s", expected, used.Path()) + } + + used = efoo.Dir["test1"] + expected = "/foo/test1" + if used.Path() != expected { + t.Errorf("want %s, got %s", expected, used.Path()) + } +} + +func TestPrefixes(t *testing.T) { + ms := NewModules() + for _, tt := range parentTestModules { + _ = ms.Parse(tt.in, tt.name) + } + + efoo, _ := ms.GetModule("foo") + if efoo.Prefix.Name != "foo" { + t.Errorf(`want prefix "foo", got %q`, efoo.Prefix.Name) + } + + used := efoo.Dir["foo-c"].Dir["zzz"] + if used.Prefix == nil || used.Prefix.Name != "foo" { + t.Errorf(`want prefix named "foo", got %#v`, used.Prefix) + } + + used = efoo.Dir["foo-c"].Dir["foo-list"] + if used.Prefix == nil || used.Prefix.Name != "foo" { + t.Errorf(`want prefix named "foo", got %#v`, used.Prefix) + } + used = efoo.Dir["foo-c"].Dir["test1"] + if used.Prefix.Name != "bar" { + t.Errorf(`want prefix "bar", got %q`, used.Prefix.Name) + } + + used = efoo.Dir["foo-c"].Dir["test1"].Dir["str"] + if used.Prefix == nil || used.Prefix.Name != "bar" { + t.Errorf(`want prefix named "bar", got %#v`, used.Prefix) + } + +} + +func TestEntryNamespace(t *testing.T) { + ms := NewModules() + for _, tt := range parentTestModules { + if err := ms.Parse(tt.in, tt.name); err != nil { + t.Fatalf("could not parse module %s: %v", tt.name, err) + } + } + + if errs := ms.Process(); len(errs) > 0 { + t.Fatalf("could not process modules: %v", errs) + } + + foo, _ := ms.GetModule("foo") + bar, _ := ms.GetModule("bar") + + for _, tc := range []struct { + descr string + entry *Entry + ns string + wantMod string + wantModError string + }{ + { + descr: "grouping used in foo always have foo's namespace, even if it was defined in bar", + entry: foo.Dir["foo-c"].Dir["test1"], + ns: "urn:foo", + wantMod: "foo", + }, + { + descr: "grouping defined and used in foo has foo's namespace", + entry: foo.Dir["foo-c"].Dir["zzz"], + ns: "urn:foo", + wantMod: "foo", + }, + { + descr: "grouping defined and used in bar has bar's namespace", + entry: bar.Dir["bar-local"].Dir["test1"], + ns: "urn:bar", + wantMod: "bar", + }, + { + descr: "leaf within a used grouping in baz augmented into foo has baz's namespace", + entry: foo.Dir["foo-c"].Dir["baz-common-leaf"], + ns: "urn:baz", + wantMod: "baz", + }, + { + descr: "leaf directly defined within an augment to foo from baz has baz's namespace", + entry: foo.Dir["foo-c"].Dir["baz-direct-leaf"], + ns: "urn:baz", + wantMod: "baz", + }, + { + descr: "leaf directly defined within an augment to foo from submodule baz-augment of baz has baz's namespace", + entry: foo.Dir["foo-c"].Dir["baz-submod-leaf"], + ns: "urn:baz", + wantMod: "baz", + }, + { + descr: "leaf directly defined within an augment to foo from orphan submodule qux-augment has empty namespace", + entry: foo.Dir["foo-c"].Dir["qux-submod-leaf"], + ns: "", + wantModError: `could not find module "" when retrieving namespace for qux-submod-leaf: "": no such namespace`, + }, + { + descr: "children of a container within an augment to from baz have baz's namespace", + entry: foo.Dir["foo-c"].Dir["baz-dir"].Dir["aardvark"], + ns: "urn:baz", + wantMod: "baz", + }, + } { + nsValue := tc.entry.Namespace() + if nsValue == nil { + t.Errorf("%s: want namespace %s, got nil", tc.descr, tc.ns) + } else if tc.ns != nsValue.Name { + t.Errorf("%s: want namespace %s, got %s", tc.descr, tc.ns, nsValue.Name) + } + + m, err := tc.entry.InstantiatingModule() + if err != nil { + if tc.wantModError == "" { + t.Errorf("%s: %s.InstantiatingModule(): got unexpected error: %v", tc.descr, tc.entry.Path(), err) + } else if got := err.Error(); got != tc.wantModError { + t.Errorf("%s: %s.InstantiatingModule(): got error: %q, want: %q", tc.descr, tc.entry.Path(), got, tc.wantModError) + } + continue + } else if tc.wantModError != "" { + t.Errorf("%s: %s.InstantiatingModule(): got no error, want: %q", tc.descr, tc.entry.Path(), tc.wantModError) + continue + } + + if m != tc.wantMod { + t.Errorf("%s: %s.InstantiatingModule(): did not get expected name, got: %v, want: %v", + tc.descr, tc.entry.Path(), m, tc.wantMod) + } + } +} + +var testWhenModules = []struct { + name string + in string +}{ + { + name: "when.yang", + in: ` +module when { + namespace "urn:when"; + prefix "when"; + + leaf condition { type string; } + + container alpha { + when "../condition = 'alpha'"; + } + + leaf beta { + when "../condition = 'beta'"; + type string; + } + + leaf-list gamma { + when "../condition = 'gamma'"; + type string; + } + + list delta { + when "../condition = 'delta'"; + } + + choice epsilon { + when "../condition = 'epsilon'"; + + case zeta { + when "../condition = 'zeta'"; + } + } + + anyxml eta { + when "../condition = 'eta'"; + } + + anydata theta { + when "../condition = 'theta'"; + } + + uses iota { + when "../condition = 'iota'"; + } + + grouping iota { + } + + augment "../alpha" { + when "../condition = 'kappa'"; + } +} +`, + }, +} + +func TestGetWhenXPath(t *testing.T) { + ms := NewModules() + ms.ParseOptions.StoreUses = true + for _, tt := range testWhenModules { + if err := ms.Parse(tt.in, tt.name); err != nil { + t.Fatalf("could not parse module %s: %v", tt.name, err) + } + } + + if errs := ms.Process(); len(errs) > 0 { + t.Fatalf("could not process modules: %v", errs) + } + + when, _ := ms.GetModule("when") + + testcases := []struct { + descr string + childName string + isCase bool + choiceName string + isAugment bool + augmentTarget string + }{ + { + descr: "extract when statement from *Container", + childName: "alpha", + }, { + descr: "extract when statement from *Leaf", + childName: "beta", + }, { + descr: "extract when statement from *LeafList", + childName: "gamma", + }, { + descr: "extract when statement from *List", + childName: "delta", + }, { + descr: "extract when statement from *Choice", + childName: "epsilon", + }, { + descr: "extract when statement from *Case", + childName: "zeta", + isCase: true, + choiceName: "epsilon", + }, { + descr: "extract when statement from *AnyXML", + childName: "eta", + }, { + descr: "extract when statement from *AnyData", + childName: "theta", + }, { + descr: "extract when statement from *Augment", + childName: "kappa", + isAugment: true, + augmentTarget: "alpha", + }, + } + + for _, tc := range testcases { + parentEntry := when + t.Run(tc.descr, func(t *testing.T) { + var child *Entry + + if tc.isAugment { + child = parentEntry.Dir[tc.augmentTarget].Augmented[0] + } else { + if tc.isCase { + parentEntry = parentEntry.Dir[tc.choiceName] + } + child = parentEntry.Dir[tc.childName] + } + + expectedWhen := "../condition = '" + tc.childName + "'" + + if gotWhen, ok := child.GetWhenXPath(); !ok { + t.Errorf("Cannot get when statement of child entry %v", tc.childName) + } else if gotWhen != expectedWhen { + t.Errorf("Expected when XPath %v, but got %v", expectedWhen, gotWhen) + } + }) + } +} + +var testAugmentAndUsesModules = []struct { + name string + in string +}{ + { + name: "original.yang", + in: ` +module original { + namespace "urn:original"; + prefix "orig"; + + import groupings { + prefix grp; + } + + container alpha { + leaf beta { + type string; + } + leaf psi { + type string; + } + leaf omega { + type string; + } + uses grp:nestedLevel0 { + when "beta = 'holaWorld'"; + } + } +} +`, + }, + { + name: "augments.yang", + in: ` +module augments { + namespace "urn:augments"; + prefix "aug"; + + import original { + prefix orig; + } + + import groupings { + prefix grp; + } + + augment "/orig:alpha" { + when "orig:beta = 'helloWorld'"; + + container charlie { + leaf charlieLeaf { + type string; + } + } + } + + grouping delta { + container echo { + leaf echoLeaf { + type string; + } + } + } + + augment "/orig:alpha" { + when "orig:omega = 'privetWorld'"; + uses delta { + when "current()/orig:beta = 'nihaoWorld'"; + } + } +} +`, + }, + { + name: "groupings.yang", + in: ` +module groupings { + namespace "urn:groupings"; + prefix "grp"; + + import "original" { + prefix orig; + } + + grouping nestedLevel0 { + leaf leafAtLevel0 { + type string; + } + uses nestedLevel1 { + when "orig:psi = 'geiasouWorld'"; + } + } + + grouping nestedLevel1 { + leaf leafAtLevel1 { + type string; + } + uses nestedLevel2 { + when "orig:omega = 'salveWorld'"; + } + } + + grouping nestedLevel2 { + leaf leafAtLevel2 { + type string; + } + } +} +`, + }, +} + +func TestAugmentedEntry(t *testing.T) { + ms := NewModules() + for _, tt := range testAugmentAndUsesModules { + if err := ms.Parse(tt.in, tt.name); err != nil { + t.Fatalf("could not parse module %s: %v", tt.name, err) + } + } + + if errs := ms.Process(); len(errs) > 0 { + t.Fatalf("could not process modules: %v", errs) + } + + orig, _ := ms.GetModule("original") + + testcases := []struct { + descr string + augmentEntry *Entry + augmentWhenStmt string + augmentChildNames map[string]bool + }{ + { + descr: "leaf charlie is augmented to container alpha", + augmentEntry: orig.Dir["alpha"].Augmented[0], + augmentWhenStmt: "orig:beta = 'helloWorld'", + augmentChildNames: map[string]bool{ + "charlie": false, + }, + }, { + descr: "grouping delta is augmented to container alpha", + augmentEntry: orig.Dir["alpha"].Augmented[1], + augmentWhenStmt: "orig:omega = 'privetWorld'", + augmentChildNames: map[string]bool{ + "echo": false, + }, + }, + } + + for _, tc := range testcases { + t.Run(tc.descr, func(t *testing.T) { + augment := tc.augmentEntry + + if tc.augmentWhenStmt != "" { + if gotAugmentWhenStmt, ok := augment.GetWhenXPath(); !ok { + t.Errorf("Expected augment when statement %v, but not present", + tc.augmentWhenStmt) + } else if gotAugmentWhenStmt != tc.augmentWhenStmt { + t.Errorf("Expected augment when statement %v, but got %v", + tc.augmentWhenStmt, gotAugmentWhenStmt) + } + } + + for name, entry := range augment.Dir { + if _, ok := tc.augmentChildNames[name]; ok { + tc.augmentChildNames[name] = true + } else { + t.Errorf("Got unexpected child name %v in augment", name) + } + + if entry.Dir != nil { + t.Errorf("Expected augment's child entry %v have nil dir, but got %v", + name, entry.Dir) + } + } + + for name, matched := range tc.augmentChildNames { + if !matched { + t.Errorf("Expected child name %v in augment, but not present", name) + } + } + + }) + } +} + +func TestUsesEntry(t *testing.T) { + ms := NewModules() + ms.ParseOptions.StoreUses = true + for _, tt := range testAugmentAndUsesModules { + if err := ms.Parse(tt.in, tt.name); err != nil { + t.Fatalf("could not parse module %s: %v", tt.name, err) + } + } + + if errs := ms.Process(); len(errs) > 0 { + t.Fatalf("could not process modules: %v", errs) + } + + orig, _ := ms.GetModule("original") + + testcases := []struct { + descr string + usesParentEntry *Entry + usesWhenStmts []string + groupingChildNames []map[string]bool + nestedLevel int + }{ + { + descr: "second augment in augments.yang uses grouping delta", + usesParentEntry: orig.Dir["alpha"].Augmented[1], + usesWhenStmts: []string{"current()/orig:beta = 'nihaoWorld'"}, + groupingChildNames: []map[string]bool{{"echo": false}}, + }, { + descr: "container alpha uses nested grouping nestedLevel0", + usesParentEntry: orig.Dir["alpha"], + usesWhenStmts: []string{ + "beta = 'holaWorld'", + "orig:psi = 'geiasouWorld'", + "orig:omega = 'salveWorld'", + }, + groupingChildNames: []map[string]bool{ + {"leafAtLevel0": false, "leafAtLevel1": false, "leafAtLevel2": false}, + {"leafAtLevel1": false, "leafAtLevel2": false}, + {"leafAtLevel2": false}, + }, + nestedLevel: 2, + }, + } + + for _, tc := range testcases { + t.Run(tc.descr, func(t *testing.T) { + usesParentEntry := tc.usesParentEntry + for i := 0; i <= tc.nestedLevel; i++ { + usesStmts := usesParentEntry.Uses + // want the usesStmts to have length 1, otherwise also need to verify + // every usesStmt slice element is expected. + if len(usesStmts) != 1 { + t.Errorf("Expected usesStmts to have length 1, but got %v", + len(usesStmts)) + } + + usesNode := usesStmts[0].Uses + grouping := usesStmts[0].Grouping + + if tc.usesWhenStmts[i] != "" { + if gotUsesWhenStmt, ok := usesNode.When.Statement().Arg(); !ok { + t.Errorf("Expected uses when statement %v, but not present", + tc.usesWhenStmts[i]) + } else if gotUsesWhenStmt != tc.usesWhenStmts[i] { + t.Errorf("Expected uses when statement %v, but got %v", + tc.usesWhenStmts[i], gotUsesWhenStmt) + } + } + + for name, entry := range grouping.Dir { + if _, ok := tc.groupingChildNames[i][name]; ok { + tc.groupingChildNames[i][name] = true + } else { + t.Errorf("Got unexpected child name %v in uses", name) + } + + if entry.Dir != nil { + t.Errorf("Expected uses's child entry %v have nil dir, but got %v", + name, entry.Dir) + } + } + + for name, matched := range tc.groupingChildNames[i] { + if !matched { + t.Errorf("Expected child name %v in grouping %v, but not present", + name, grouping.Name) + } + } + usesParentEntry = grouping + } + + }) + } +} + +func TestShallowDup(t *testing.T) { + testModule := struct { + name string + in string + }{ + + name: "mod.yang", + in: ` +module mod { + namespace "urn:mod"; + prefix "mod"; + + container level0 { + container level1-1 { + leaf level2-1 { type string;} + } + + container level1-2 { + leaf level2-2 { type string;} + } + + container level1-3{ + container level2-3 { + leaf level3-1 { type string;} + } + } + } +} +`, + } + + ms := NewModules() + + if err := ms.Parse(testModule.in, testModule.name); err != nil { + t.Fatalf("could not parse module %s: %v", testModule.name, err) + } + + if errs := ms.Process(); len(errs) > 0 { + t.Fatalf("could not process modules: %v", errs) + } + + mod, _ := ms.GetModule("mod") + level0 := mod.Dir["level0"] + level0ShallowDup := level0.shallowDup() + + for name, entry := range level0.Dir { + shallowDupedEntry, ok := level0ShallowDup.Dir[name] + if !ok { + t.Errorf("Expect shallowDup() to duplicate direct child %v, but did not", name) + } + if len(entry.Dir) != 1 { + t.Errorf("Expect original entry's direct child have length 1 dir") + } + if shallowDupedEntry.Dir != nil { + t.Errorf("Expect shallowDup()'ed entry's direct child to have nil dir") + } + } +} + +func TestIgnoreCircularDependencies(t *testing.T) { + tests := []struct { + name string + inModules map[string]string + inIgnoreCircDep bool + wantErrs bool + }{{ + name: "validation that non-circular dependencies are correct", + inModules: map[string]string{ + "mod-a": ` + module mod-a { + namespace "urn:a"; + prefix "a"; + + include subm-x; + include subm-y; + + leaf marker { type string; } + } + `, + "subm-x": ` + submodule subm-x { + belongs-to mod-a { prefix a; } + } + `, + "subm-y": ` + submodule subm-y { + belongs-to mod-a { prefix a; } + // Not circular. + include subm-x; + } + `}, + }, { + name: "circular dependency error identified", + inModules: map[string]string{ + "mod-a": ` + module mod-a { + namespace "urn:a"; + prefix "a"; + + include subm-x; + include subm-y; + + leaf marker { type string; } + } + `, + "subm-x": ` + submodule subm-x { + belongs-to mod-a { prefix a; } + // Circular + include subm-y; + } + `, + "subm-y": ` + submodule subm-y { + belongs-to mod-a { prefix a; } + // Circular + include subm-x; + } + `}, + wantErrs: true, + }, { + name: "circular dependency error skipped", + inModules: map[string]string{ + "mod-a": ` + module mod-a { + namespace "urn:a"; + prefix "a"; + + include subm-x; + include subm-y; + + leaf marker { type string; } + } + `, + "subm-x": ` + submodule subm-x { + belongs-to mod-a { prefix a; } + // Circular + include subm-y; + } + `, + "subm-y": ` + submodule subm-y { + belongs-to mod-a { prefix a; } + // Circular + include subm-x; + } + `}, + inIgnoreCircDep: true, + }} + + for _, tt := range tests { + ms := NewModules() + ms.ParseOptions.IgnoreSubmoduleCircularDependencies = tt.inIgnoreCircDep + for n, m := range tt.inModules { + if err := ms.Parse(m, n); err != nil { + if !tt.wantErrs { + t.Errorf("%s: could not parse modules, got: %v, want: nil", tt.name, err) + } + continue + } + } + } +} + +func TestEntryDefaultValue(t *testing.T) { + getdir := func(e *Entry, elements ...string) (*Entry, error) { + for _, elem := range elements { + next := e.Dir[elem] + if next == nil { + return nil, fmt.Errorf("%s missing directory %q", e.Path(), elem) + } + e = next + } + return e, nil + } + + modtext := ` +module defaults { + namespace "urn:defaults"; + prefix "defaults"; + + typedef string-default { + type string; + default "typedef default value"; + } + + typedef string-emptydefault { + type string; + default ""; + } + + grouping common { + container common-nodefault { + leaf string { + type string; + } + } + container common-withdefault { + leaf string { + type string; + default "default value"; + } + } + container common-withemptydefault { + leaf string { + type string; + default ""; + } + } + container common-typedef-withdefault { + leaf string { + type string-default; + } + } + container common-typedef-withemptydefault { + leaf string { + type string-emptydefault; + } + } + } + + container defaults { + leaf mandatory-default { + type string-default; + mandatory true; + } + leaf uint32-withdefault { + type uint32; + default 13; + } + leaf string-withdefault { + type string-default; + } + leaf nodefault { + type string; + } + uses common; + + choice choice-default { + case alpha { + leaf alpha { + type string; + } + } + case zeta { + leaf zeta { + type string; + } + } + default zeta; + } + } + + grouping leaflist-common { + container common-nodefault { + leaf string { + type string; + } + } + container common-withdefault { + leaf-list string { + type string; + default "default value"; + } + } + container common-typedef-withdefault { + leaf string { + type string-default; + } + } + } + + container leaflist-defaults { + leaf-list uint32-withdefault { + type uint32; + default "13"; + default 14; + } + leaf-list stringlist-withdefault { + type string-default; + } + leaf-list stringlist-withemptydefault { + type string-emptydefault; + } + leaf-list stringlist-withdefault-withminelem { + type string-default; + min-elements 1; + } + leaf-list emptydefault { + type string; + default ""; + } + leaf-list nodefault { + type string; + } + uses leaflist-common; + } + +} +` + + ms := NewModules() + if err := ms.Parse(modtext, "defaults.yang"); err != nil { + t.Fatal(err) + } + + for i, tc := range []struct { + wantSingle string + wantSingleOk bool + wantDefaults []string + path []string + }{ + { + path: []string{"defaults", "string-withdefault"}, + wantSingle: "typedef default value", + wantDefaults: []string{"typedef default value"}, + wantSingleOk: true, + }, + { + path: []string{"defaults", "uint32-withdefault"}, + wantSingle: "13", + wantDefaults: []string{"13"}, + wantSingleOk: true, + }, + { + path: []string{"defaults", "nodefault"}, + wantSingle: "", + wantDefaults: nil, + }, + { + path: []string{"defaults", "common-withdefault", "string"}, + wantSingle: "default value", + wantDefaults: []string{"default value"}, + wantSingleOk: true, + }, + { + path: []string{"defaults", "common-withemptydefault", "string"}, + wantSingle: "", + wantDefaults: []string{""}, + wantSingleOk: true, + }, + { + path: []string{"defaults", "common-typedef-withdefault", "string"}, + wantSingle: "typedef default value", + wantDefaults: []string{"typedef default value"}, + wantSingleOk: true, + }, + { + path: []string{"defaults", "common-typedef-withemptydefault", "string"}, + wantSingle: "", + wantDefaults: []string{""}, + wantSingleOk: true, + }, + { + path: []string{"defaults", "common-nodefault", "string"}, + wantSingle: "", + wantDefaults: nil, + }, + { + path: []string{"defaults", "mandatory-default"}, + wantSingle: "", + wantDefaults: nil, + }, + { + path: []string{"defaults", "choice-default"}, + wantSingle: "zeta", + wantDefaults: []string{"zeta"}, + wantSingleOk: true, + }, + { + path: []string{"leaflist-defaults", "uint32-withdefault"}, + wantSingle: "", + wantDefaults: []string{"13", "14"}, + }, + { + path: []string{"leaflist-defaults", "stringlist-withdefault"}, + wantSingle: "typedef default value", + wantDefaults: []string{"typedef default value"}, + wantSingleOk: true, + }, + { + path: []string{"leaflist-defaults", "stringlist-withemptydefault"}, + wantSingle: "", + wantDefaults: []string{""}, + wantSingleOk: true, + }, + { + path: []string{"leaflist-defaults", "stringlist-withdefault-withminelem"}, + wantSingle: "", + wantDefaults: nil, + }, + { + path: []string{"leaflist-defaults", "emptydefault"}, + wantSingle: "", + wantDefaults: []string{""}, + wantSingleOk: true, + }, + { + path: []string{"leaflist-defaults", "nodefault"}, + wantSingle: "", + wantDefaults: nil, + }, + { + path: []string{"leaflist-defaults", "common-nodefault", "string"}, + wantSingle: "", + wantDefaults: nil, + }, + { + path: []string{"leaflist-defaults", "common-withdefault", "string"}, + wantSingle: "default value", + wantDefaults: []string{"default value"}, + wantSingleOk: true, + }, + { + path: []string{"leaflist-defaults", "common-typedef-withdefault", "string"}, + wantSingle: "typedef default value", + wantDefaults: []string{"typedef default value"}, + wantSingleOk: true, + }, + } { + tname := strings.Join(tc.path, "/") + + mod, ok := ms.Modules["defaults"] + if !ok { + t.Fatalf("[%d] module not found: %q", i, tname) + } + defaults := ToEntry(mod) + dir, err := getdir(defaults, tc.path...) + if err != nil { + t.Fatalf("[%d_%s] could not retrieve path: %v", i, tname, err) + } + if got, gotOk := dir.SingleDefaultValue(); got != tc.wantSingle || gotOk != tc.wantSingleOk { + t.Errorf("[%d_%s] got SingleDefaultValue (%q, %v), want (%q, %v)", i, tname, got, gotOk, tc.wantSingle, tc.wantSingleOk) + } + if diff := cmp.Diff(dir.DefaultValues(), tc.wantDefaults); diff != "" { + t.Errorf("[%d_%s] DefaultValues (-got, +want):\n%s", i, tname, diff) + } + } +} + +func TestFullModuleProcess(t *testing.T) { + tests := []struct { + name string + inModules map[string]string + inIgnoreCircDeps bool + wantLeaves map[string][]string + customVerify func(t *testing.T, module *Entry) + wantErr bool + }{{ + name: "circular import via child", + inModules: map[string]string{ + "test": ` + module test { + prefix "t"; + namespace "urn:t"; + + include test-router; + include test-router-bgp; + include test-router-isis; + + container configure { + uses test-router; + } + }`, + "test-router": ` + submodule test-router { + belongs-to test { prefix "t"; } + + include test-router-bgp; + include test-router-isis; + include test-router-ldp; + + grouping test-router { + uses test-router-ldp; + } + }`, + "test-router-ldp": ` + submodule test-router-ldp { + belongs-to test { prefix "t"; } + + grouping test-router-ldp { } + }`, + "test-router-isis": ` + submodule test-router-isis { + belongs-to test { prefix "t"; } + + include test-router; + }`, + "test-router-bgp": ` + submodule test-router-bgp { + belongs-to test { prefix "t"; } + }`, + }, + inIgnoreCircDeps: true, + }, { + name: "non-circular via child", + inModules: map[string]string{ + "bgp": ` + module bgp { + prefix "bgp"; + namespace "urn:bgp"; + + include bgp-son; + include bgp-daughter; + + leaf parent { type string; } + }`, + "bgp-son": ` + submodule bgp-son { + belongs-to bgp { prefix "bgp"; } + + leaf son { type string; } + }`, + "bgp-daughter": ` + submodule bgp-daughter { + belongs-to bgp { prefix "bgp"; } + include bgp-son; + + leaf daughter { type string; } + }`, + }, + }, { + name: "simple circular via child", + inModules: map[string]string{ + "parent": ` + module parent { + prefix "p"; + namespace "urn:p"; + include son; + include daughter; + + leaf p { type string; } + } + `, + "son": ` + submodule son { + belongs-to parent { prefix "p"; } + include daughter; + + leaf s { type string; } + } + `, + "daughter": ` + submodule daughter { + belongs-to parent { prefix "p"; } + include son; + + leaf d { type string; } + } + `, + }, + wantErr: true, + }, { + name: "merge via grandchild", + inModules: map[string]string{ + "bgp": ` + module bgp { + prefix "bgp"; + namespace "urn:bgp"; + + include bgp-son; + + leaf parent { type string; } + }`, + "bgp-son": ` + submodule bgp-son { + belongs-to bgp { prefix "bgp"; } + + include bgp-grandson; + + leaf son { type string; } + }`, + "bgp-grandson": ` + submodule bgp-grandson { + belongs-to bgp { prefix "bgp"; } + + leaf grandson { type string; } + }`, + }, + wantLeaves: map[string][]string{ + "bgp": {"parent", "son", "grandson"}, + }, + }, { + name: "parent to son and daughter with common grandchild", + inModules: map[string]string{ + "parent": ` + module parent { + prefix "p"; + namespace "urn:p"; + include son; + include daughter; + + leaf p { type string; } + } + `, + "son": ` + submodule son { + belongs-to parent { prefix "p"; } + include grandchild; + + leaf s { type string; } + } + `, + "daughter": ` + submodule daughter { + belongs-to parent { prefix "p"; } + include grandchild; + + leaf d { type string; } + } + `, + "grandchild": ` + submodule grandchild { + belongs-to parent { prefix "p"; } + + leaf g { type string; } + } + `, + }, + wantLeaves: map[string][]string{ + "parent": {"p", "s", "d", "g"}, + }, + }, { + name: "parent to son and daughter, not a circdep", + inModules: map[string]string{ + "parent": ` + module parent { + prefix "p"; + namespace "urn:p"; + + include son; + include daughter; + + uses son-group; + } + `, + "son": ` + submodule son { + belongs-to parent { prefix "p"; } + include daughter; + + grouping son-group { + uses daughter-group; + } + } + `, + "daughter": ` + submodule daughter { + belongs-to parent { prefix "p"; } + + grouping daughter-group { + leaf s { type string; } + } + + leaf d { type string; } + } + `, + }, + wantLeaves: map[string][]string{ + "parent": {"s", "d"}, + }, + }, { + name: "parent with grouping and with extension", + inModules: map[string]string{ + "parent": ` + module parent { + prefix "p"; + namespace "urn:p"; + + import extensions { + prefix "ext"; + } + + container c { + ext:c-define "c's extension"; + uses daughter-group { + ext:u-define "uses's extension"; + } + } + + grouping daughter-group { + ext:g-define "daughter-group's extension"; + + leaf l { + ext:l-define "l's extension"; + type string; + } + + container c2 { + leaf l2 { + type string; + } + } + + // test nested grouping extensions. + uses son-group { + ext:sg-define "son-group's extension"; + } + } + + grouping son-group { + leaf s { + ext:s-define "s's extension"; + type string; + } + + } + } + `, + "extension": ` + module extensions { + prefix "q"; + namespace "urn:q"; + + extension c-define { + description + "Takes as an argument a name string. + c's extension."; + argument "name"; + } + extension g-define { + description + "Takes as an argument a name string. + daughter-group's extension."; + argument "name"; + } + extension sg-define { + description + "Takes as an argument a name string. + son-groups's extension."; + argument "name"; + } + extension s-define { + description + "Takes as an argument a name string. + s's extension."; + argument "name"; + } + extension l-define { + description + "Takes as an argument a name string. + l's extension."; + argument "name"; + } + extension u-define { + description + "Takes as an argument a name string. + uses's extension."; + argument "name"; + } + } + `, + }, + wantLeaves: map[string][]string{ + "parent": {"c"}, + }, + customVerify: func(t *testing.T, module *Entry) { + // Verify that an extension within the uses statement + // and within a grouping's definition is copied to each + // of the top-level nodes within the grouping, and no + // one else above or below. + less := cmpopts.SortSlices(func(l, r *Statement) bool { return l.Keyword < r.Keyword }) + + if diff := cmp.Diff([]*Statement{ + {Keyword: "ext:c-define", HasArgument: true, Argument: "c's extension"}, + }, module.Dir["c"].Exts, cmpopts.IgnoreUnexported(Statement{}), less); diff != "" { + t.Errorf("container c Exts (-want, +got):\n%s", diff) + } + + if diff := cmp.Diff([]*Statement{ + {Keyword: "ext:g-define", HasArgument: true, Argument: "daughter-group's extension"}, + {Keyword: "ext:l-define", HasArgument: true, Argument: "l's extension"}, + {Keyword: "ext:u-define", HasArgument: true, Argument: "uses's extension"}, + }, module.Dir["c"].Dir["l"].Exts, cmpopts.IgnoreUnexported(Statement{}), less); diff != "" { + t.Errorf("leaf l Exts (-want, +got):\n%s", diff) + } + + if diff := cmp.Diff([]*Statement{ + {Keyword: "ext:g-define", HasArgument: true, Argument: "daughter-group's extension"}, + {Keyword: "ext:sg-define", HasArgument: true, Argument: "son-group's extension"}, + {Keyword: "ext:s-define", HasArgument: true, Argument: "s's extension"}, + {Keyword: "ext:u-define", HasArgument: true, Argument: "uses's extension"}, + }, module.Dir["c"].Dir["s"].Exts, cmpopts.IgnoreUnexported(Statement{}), less); diff != "" { + t.Errorf("leaf s Exts (-want, +got):\n%s", diff) + } + + if diff := cmp.Diff([]*Statement{ + {Keyword: "ext:g-define", HasArgument: true, Argument: "daughter-group's extension"}, + {Keyword: "ext:u-define", HasArgument: true, Argument: "uses's extension"}, + }, module.Dir["c"].Dir["c2"].Exts, cmpopts.IgnoreUnexported(Statement{}), less); diff != "" { + t.Errorf("container c2 Exts (-want, +got):\n%s", diff) + } + + if diff := cmp.Diff([]*Statement{}, module.Dir["c"].Dir["c2"].Dir["l2"].Exts, cmpopts.IgnoreUnexported(Statement{}), less, cmpopts.EquateEmpty()); diff != "" { + t.Errorf("leaf l2 Exts (-want, +got):\n%s", diff) + } + }, + }} + + for _, tt := range tests { + ms := NewModules() + + ms.ParseOptions.IgnoreSubmoduleCircularDependencies = tt.inIgnoreCircDeps + for n, m := range tt.inModules { + if err := ms.Parse(m, n); err != nil { + t.Errorf("%s: error parsing module %s, got: %v, want: nil", tt.name, n, err) + } + } + + if errs := ms.Process(); len(errs) > 0 { + if !tt.wantErr { + t.Errorf("%s: error processing modules, got: %v, want: nil", tt.name, errs) + } + continue + } + + if tt.wantErr { + t.Errorf("%s: did not get expected errors", tt.name) + continue + } + + for m, l := range tt.wantLeaves { + mod, errs := ms.GetModule(m) + if len(errs) > 0 { + t.Errorf("%s: cannot retrieve expected module %s, got: %v, want: nil", tt.name, m, errs) + continue + } + + var leaves []string + for _, n := range mod.Dir { + leaves = append(leaves, n.Name) + } + + // Sort the two slices to ensure that we are comparing like with like. + sort.Strings(l) + sort.Strings(leaves) + if !reflect.DeepEqual(l, leaves) { + t.Errorf("%s: did not get expected leaves in %s, got: %v, want: %v", tt.name, m, leaves, l) + } + + if tt.customVerify != nil { + tt.customVerify(t, mod) + } + } + } +} + +func TestAnyDataAnyXML(t *testing.T) { + tests := []struct { + name string + inModule string + wantNodeKind string + wantEntryKind EntryKind + }{ + { + name: "test anyxml", + wantNodeKind: "anyxml", + wantEntryKind: AnyXMLEntry, + inModule: `module test { + namespace "urn:test"; + prefix "test"; + container c { + anyxml data { + description "anyxml"; + } + } +}`, + }, + { + name: "test anydata", + wantNodeKind: "anydata", + wantEntryKind: AnyDataEntry, + inModule: `module test { + namespace "urn:test"; + prefix "test"; + container c { + anydata data { + description "anydata"; + } + } +}`, + }, + } + for _, tt := range tests { + ms := NewModules() + if err := ms.Parse(tt.inModule, "test"); err != nil { + t.Errorf("%s: error parsing module 'test', got: %v, want: nil", tt.name, err) + } + + if errs := ms.Process(); len(errs) > 0 { + t.Errorf("%s: got module parsing errors", tt.name) + for i, err := range errs { + t.Errorf("%s: error #%d: %v", tt.name, i, err) + } + continue + } + + mod, ok := ms.Modules["test"] + if !ok { + t.Errorf("%s: did not find `test` module", tt.name) + continue + } + e := ToEntry(mod) + c := e.Dir["c"] + if c == nil { + t.Errorf("%s: did not find container c", tt.name) + continue + } + data := c.Dir["data"] + if data == nil { + t.Errorf("%s: did not find leaf c/data", tt.name) + continue + } + if got := data.Node.Kind(); got != tt.wantNodeKind { + t.Errorf("%s: want Node.Kind(): %q, got: %q", tt.name, tt.wantNodeKind, got) + } + if got := data.Kind; got != tt.wantEntryKind { + t.Errorf("%s: want Kind: %v, got: %v", tt.name, tt.wantEntryKind, got) + } + if got := data.Description; got != tt.wantNodeKind { + t.Errorf("%s: want data.Description: %q, got: %q", tt.name, tt.wantNodeKind, got) + } + } +} + +func getEntry(root *Entry, path []string) *Entry { + for _, elem := range path { + if root = root.Dir[elem]; root == nil { + break + } + } + return root +} + +func TestActionRPC(t *testing.T) { + tests := []struct { + name string + inModule string + operationPath []string + wantNodeKind string + wantError string + noInput bool + noOutput bool + }{ + { + name: "test action in container", + wantNodeKind: "action", + operationPath: []string{"c", "operation"}, + inModule: `module test { + namespace "urn:test"; + prefix "test"; + container c { + action operation { + description "action"; + input { leaf string { type string; } } + output { leaf string { type string; } } + } + } +}`, + }, + + { + name: "test action in list", + wantNodeKind: "action", + operationPath: []string{"list", "operation"}, + inModule: `module test { + namespace "urn:test"; + prefix "test"; + list list { + action operation { + description "action"; + input { leaf string { type string; } } + output { leaf string { type string; } } + } + } +}`, + }, + + { + name: "test action in container via grouping", + wantNodeKind: "action", + operationPath: []string{"c", "operation"}, + inModule: `module test { + namespace "urn:test"; + prefix "test"; + grouping g { + action operation { + description "action"; + input { leaf string { type string; } } + output { leaf string { type string; } } + } + } + container c { uses g; } +}`, + }, + + { + name: "test action in list via grouping", + wantNodeKind: "action", + operationPath: []string{"list", "operation"}, + inModule: `module test { + namespace "urn:test"; + prefix "test"; + grouping g { + action operation { + description "action"; + input { leaf string { type string; } } + output { leaf string { type string; } } + } + } + list list { uses g; } +}`, + }, + + { + name: "test rpc", + wantNodeKind: "rpc", + operationPath: []string{"operation"}, + inModule: `module test { + namespace "urn:test"; + prefix "test"; + rpc operation { + description "rpc"; + input { + leaf string { type string; } + } + output { + leaf string { type string; } + } + } +}`, + }, + + { + name: "minimal rpc", + wantNodeKind: "rpc", + operationPath: []string{"operation"}, + inModule: `module test { + namespace "urn:test"; + prefix "test"; + rpc operation { + description "rpc"; + } +}`, + noInput: true, + noOutput: true, + }, + + { + name: "input-only rpc", + wantNodeKind: "rpc", + operationPath: []string{"operation"}, + inModule: `module test { + namespace "urn:test"; + prefix "test"; + rpc operation { + description "rpc"; + input { + leaf string { type string; } + } + } +}`, + noOutput: true, + }, + + { + name: "output-only rpc", + wantNodeKind: "rpc", + operationPath: []string{"operation"}, + inModule: `module test { + namespace "urn:test"; + prefix "test"; + rpc operation { + description "rpc"; + output { + leaf string { type string; } + } + } +}`, + noInput: true, + }, + + // test cases with errors (in module parsing) + { + name: "rpc not module child", + wantError: "test:6:5: unknown container field: rpc", + inModule: `module test { + namespace "urn:test"; + prefix "test"; + container c { + // error: "rpc" is not a valid sub-statement to "container" + rpc operation; + } +}`, + }, + + { + name: "action not valid leaf child", + wantError: "test:6:5: unknown leaf field: action", + inModule: `module test { + namespace "urn:test"; + prefix "test"; + leaf l { + // error: "operation" is not a valid sub-statement to "leaf" + action operation; + } +}`, + }, + + { + name: "action not valid leaf-list child", + wantError: "test:6:5: unknown leaf-list field: action", + inModule: `module test { + namespace "urn:test"; + prefix "test"; + leaf-list leaf-list { + // error: "operation" is not a valid sub-statement to "leaf-list" + action operation; + } +}`, + }, + } + for _, tt := range tests { + ms := NewModules() + if err := ms.Parse(tt.inModule, "test"); err != nil { + if got := err.Error(); got != tt.wantError { + t.Errorf("%s: error parsing module 'test', got error: %q, want: %q", tt.name, got, tt.wantError) + } + continue + } + + if errs := ms.Process(); len(errs) > 0 { + t.Errorf("%s: got %d module parsing errors", tt.name, len(errs)) + for i, err := range errs { + t.Errorf("%s: error #%d: %v", tt.name, i, err) + } + continue + } + + mod := ms.Modules["test"] + e := ToEntry(mod) + if e = getEntry(e, tt.operationPath); e == nil { + t.Errorf("%s: want child entry at: %v, got: nil", tt.name, tt.operationPath) + continue + } + if got := e.Node.Kind(); got != tt.wantNodeKind { + t.Errorf("%s: got `operation` node kind: %q, want: %q", tt.name, got, tt.wantNodeKind) + } else if got := e.Description; got != tt.wantNodeKind { + t.Errorf("%s: got `operation` Description: %q, want: %q", tt.name, got, tt.wantNodeKind) + } + // confirm the child RPCEntry was populated for the entry. + if e.RPC == nil { + t.Errorf("%s: entry at %v has nil RPC child, want: non-nil. Entry: %#v", tt.name, tt.operationPath, e) + } else if !tt.noInput && e.RPC.Input == nil { + t.Errorf("%s: RPCEntry has nil Input, want: non-nil. Entry: %#v", tt.name, e.RPC) + } else if !tt.noOutput && e.RPC.Output == nil { + t.Errorf("%s: RPCEntry has nil Output, want: non-nil. Entry: %#v", tt.name, e.RPC) + } + } +} + +var testIfFeatureModules = []struct { + name string + in string +}{ + { + name: "if-feature.yang", + in: `module if-feature { + namespace "urn:if-feature"; + prefix "feat"; + + feature ft-container; + feature ft-action; + feature ft-anydata1; + feature ft-anydata2; + feature ft-anyxml; + feature ft-choice; + feature ft-case; + feature ft-feature; + feature ft-leaf; + feature ft-bit; + feature ft-leaf-list; + feature ft-enum; + feature ft-list; + feature ft-notification; + feature ft-rpc; + feature ft-augment; + feature ft-identity; + feature ft-uses; + feature ft-refine; + feature ft-augment-uses; + + container cont { + if-feature ft-container; + action act { + if-feature ft-action; + } + } + + anydata data { + if-feature ft-anydata1; + if-feature ft-anydata2; + } + + anyxml xml { + if-feature ft-anyxml; + } + + choice ch { + if-feature ft-choice; + case cs { + if-feature ft-case; + } + } + + feature f { + if-feature ft-feature; + } + + leaf l { + if-feature ft-leaf; + type bits { + bit A { + if-feature ft-bit; + } + } + } + + leaf-list ll { + if-feature ft-leaf-list; + type enumeration { + enum zero { + if-feature ft-enum; + } + } + } + + list ls { + if-feature ft-list; + } + + notification n { + if-feature ft-notification; + } + + rpc r { + if-feature ft-rpc; + } + + augment "/cont" { + if-feature ft-augment; + uses g { + if-feature ft-augment-uses; + } + } + + identity id { + if-feature ft-identity; + } + + uses g { + if-feature ft-uses; + refine rf { + if-feature ft-refine; + } + } + + grouping g { + container gc {} + } +} +`, + }, +} + +func TestIfFeature(t *testing.T) { + entryIfFeatures := func(e *Entry) []*Value { + extra := e.Extra["if-feature"] + if len(extra) == 0 { + return nil + } + values := make([]*Value, len(extra)) + for i, ex := range extra { + values[i] = ex.(*Value) + } + return values + } + + featureByName := func(e *Entry, name string) *Feature { + for _, f := range e.Extra["feature"] { + ft := f.(*Feature) + if ft.Name == name { + return ft + } + } + return nil + } + + ms := NewModules() + for _, tt := range testIfFeatureModules { + if err := ms.Parse(tt.in, tt.name); err != nil { + t.Fatalf("could not parse module %s: %v", tt.name, err) + } + } + + if errs := ms.Process(); len(errs) > 0 { + t.Fatalf("could not process modules: %v", errs) + } + + mod, _ := ms.GetModule("if-feature") + + testcases := []struct { + name string + inIfFeatures []*Value + wantIfFeatures []string + }{ + // Node statements + { + name: "action", + inIfFeatures: entryIfFeatures(mod.Dir["cont"].Dir["act"]), + wantIfFeatures: []string{"ft-action"}, + }, + { + name: "anydata", + inIfFeatures: entryIfFeatures(mod.Dir["data"]), + wantIfFeatures: []string{"ft-anydata1", "ft-anydata2"}, + }, + { + name: "anyxml", + inIfFeatures: entryIfFeatures(mod.Dir["xml"]), + wantIfFeatures: []string{"ft-anyxml"}, + }, + { + name: "case", + inIfFeatures: entryIfFeatures(mod.Dir["ch"].Dir["cs"]), + wantIfFeatures: []string{"ft-case"}, + }, + { + name: "choice", + inIfFeatures: entryIfFeatures(mod.Dir["ch"]), + wantIfFeatures: []string{"ft-choice"}, + }, + { + name: "container", + inIfFeatures: entryIfFeatures(mod.Dir["cont"]), + wantIfFeatures: []string{"ft-container"}, + }, + { + name: "feature", + inIfFeatures: featureByName(mod, "f").IfFeature, + wantIfFeatures: []string{"ft-feature"}, + }, + { + name: "leaf", + inIfFeatures: entryIfFeatures(mod.Dir["l"]), + wantIfFeatures: []string{"ft-leaf"}, + }, + { + name: "leaf-list", + inIfFeatures: entryIfFeatures(mod.Dir["ll"]), + wantIfFeatures: []string{"ft-leaf-list"}, + }, + { + name: "list", + inIfFeatures: entryIfFeatures(mod.Dir["ls"]), + wantIfFeatures: []string{"ft-list"}, + }, + { + name: "notification", + inIfFeatures: entryIfFeatures(mod.Dir["n"]), + wantIfFeatures: []string{"ft-notification"}, + }, + { + name: "rpc", + inIfFeatures: entryIfFeatures(mod.Dir["r"]), + wantIfFeatures: []string{"ft-rpc"}, + }, + // Other statements + { + name: "augment", + inIfFeatures: entryIfFeatures(mod.Dir["cont"].Augmented[0]), + wantIfFeatures: []string{"ft-augment"}, + }, + { + name: "bit", + inIfFeatures: mod.Dir["l"].Node.(*Leaf).Type.Bit[0].IfFeature, + wantIfFeatures: []string{"ft-bit"}, + }, + { + name: "enum", + inIfFeatures: mod.Dir["ll"].Node.(*Leaf).Type.Enum[0].IfFeature, + wantIfFeatures: []string{"ft-enum"}, + }, + { + name: "identity", + inIfFeatures: mod.Identities[0].IfFeature, + wantIfFeatures: []string{"ft-identity"}, + }, + { + name: "refine", + inIfFeatures: ms.Modules["if-feature"].Uses[0].Refine[0].IfFeature, + wantIfFeatures: []string{"ft-refine"}, + }, + { + name: "uses", + inIfFeatures: ms.Modules["if-feature"].Uses[0].IfFeature, + wantIfFeatures: []string{"ft-uses"}, + }, + { + // Verify that if-feature field defined in "uses" is correctly propagated to container + name: "uses", + inIfFeatures: entryIfFeatures(mod.Dir["gc"]), + wantIfFeatures: []string{"ft-uses"}, + }, + { + // Verify that if-feature field defined in "augment" and in "augment > uses" is correctly propagated to container + name: "augment-uses", + inIfFeatures: entryIfFeatures(mod.Dir["cont"].Dir["gc"]), + wantIfFeatures: []string{"ft-augment-uses", "ft-augment"}, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + var names []string + for _, f := range tc.inIfFeatures { + names = append(names, f.Name) + } + + if !reflect.DeepEqual(names, tc.wantIfFeatures) { + t.Errorf("%s: did not get expected if-features, got %v, want %v", tc.name, names, tc.wantIfFeatures) + } + }) + } +} + +var testNotificationModules = []struct { + name string + in string +}{ + { + name: "notification.yang", + in: `module notification { + namespace "urn:notification"; + prefix "n"; + + notification n {} + + grouping g { + notification g-n {} + } + + container cont { + notification cont-n {} + } + + list ls { + notification ls-n {} + uses g; + } + + augment "/cont" { + notification aug-n {} + } +} +`, + }, +} + +func TestNotification(t *testing.T) { + ms := NewModules() + for _, tt := range testNotificationModules { + if err := ms.Parse(tt.in, tt.name); err != nil { + t.Fatalf("could not parse module %s: %v", tt.name, err) + } + } + + if errs := ms.Process(); len(errs) > 0 { + t.Fatalf("could not process modules: %v", errs) + } + + mod, _ := ms.GetModule("notification") + + testcases := []struct { + name string + wantPath []string + }{ + { + name: "module", + wantPath: []string{"n"}, + }, + { + name: "container", + wantPath: []string{"cont", "cont-n"}, + }, + { + name: "list", + wantPath: []string{"ls", "ls-n"}, + }, + { + name: "grouping", + wantPath: []string{"ls", "g-n"}, + }, + { + name: "augment", + wantPath: []string{"cont", "aug-n"}, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + if e := getEntry(mod, tc.wantPath); e == nil || e.Node.Kind() != "notification" { + t.Errorf("%s: want notification entry at: %v, got: %+v", tc.name, tc.wantPath, e) + } + }) + } +} + +// addTreeE takes an input Entry and appends it to a directory, keyed by path, to the Entry. +// If the Entry has children, they are appended to the directory recursively. Used in test +// cases where a path is to be referred to. +func addTreeE(e *Entry, dir map[string]*Entry) { + for _, ch := range e.Dir { + dir[ch.Path()] = ch + if ch.Dir != nil { + addTreeE(ch, dir) + } + } +} + +func TestEntryFind(t *testing.T) { + tests := []struct { + name string + inModules map[string]string + inBaseEntryPath string + wantEntryPath map[string]string // keyed on path to find, with path expected as value. + wantError string + }{{ + name: "intra module find", + inModules: map[string]string{ + "test.yang": ` + module test { + prefix "t"; + namespace "urn:t"; + + leaf a { type string; } + leaf b { type string; } + + container c { leaf d { type string; } } + + rpc rpc1 { + input { leaf input1 { type string; } } + } + + container e { + action operation { + description "action"; + input { leaf input1 { type string; } } + output { leaf output1 { type string; } } + } + } + + } + `, + }, + inBaseEntryPath: "/test/a", + wantEntryPath: map[string]string{ + // Absolute path with no prefixes. + "/b": "/test/b", + // Relative path with no prefixes. + "../b": "/test/b", + // Absolute path with prefixes. + "/t:b": "/test/b", + // Relative path with prefixes. + "../t:b": "/test/b", + // Find within a directory. + "/c/d": "/test/c/d", + // Find within a directory specified relatively. + "../c/d": "/test/c/d", + // Find within a relative directory with prefixes. + "../t:c/t:d": "/test/c/d", + "../t:c/d": "/test/c/d", + "../c/t:d": "/test/c/d", + // Find within an absolute directory with prefixes. + "/t:c/d": "/test/c/d", + "/c/t:d": "/test/c/d", + "../t:rpc1/input": "/test/rpc1/input", + "/t:rpc1/input": "/test/rpc1/input", + "/t:rpc1/t:input": "/test/rpc1/input", + "/t:e/operation/input": "/test/e/operation/input", + "/t:e/operation/output": "/test/e/operation/output", + "/t:e/t:operation/t:input": "/test/e/operation/input", + "/t:e/t:operation/t:output": "/test/e/operation/output", + }, + }, { + name: "submodule find", + inModules: map[string]string{ + "test.yang": ` + module test { + prefix "t"; + namespace "urn:t"; + + include test1; + + leaf a { type string; } + leaf b { type string; } + + container c { leaf d { type string; } } + + rpc rpc1 { + input { leaf input1 { type string; } } + } + + container e { + action operation { + description "action"; + input { leaf input1 { type string; } } + output { leaf output1 { type string; } } + } + } + + } + `, + "test1.yang": ` + submodule test1 { + belongs-to test { + prefix "t"; + } + + leaf d { type string; } + } + `, + }, + inBaseEntryPath: "/test/d", + wantEntryPath: map[string]string{ + // Absolute path with no prefixes. + "/b": "/test/b", + // Relative path with no prefixes. + "../b": "/test/b", + // Absolute path with prefixes. + "/t:b": "/test/b", + // Relative path with prefixes. + "../t:b": "/test/b", + // Find within a directory. + "/c/d": "/test/c/d", + // Find within a directory specified relatively. + "../c/d": "/test/c/d", + // Find within a relative directory with prefixes. + "../t:c/t:d": "/test/c/d", + "../t:c/d": "/test/c/d", + "../c/t:d": "/test/c/d", + // Find within an absolute directory with prefixes. + "/t:c/d": "/test/c/d", + "/c/t:d": "/test/c/d", + "../t:rpc1/input": "/test/rpc1/input", + "/t:rpc1/input": "/test/rpc1/input", + "/t:rpc1/t:input": "/test/rpc1/input", + "/t:e/operation/input": "/test/e/operation/input", + "/t:e/operation/output": "/test/e/operation/output", + "/t:e/t:operation/t:input": "/test/e/operation/input", + "/t:e/t:operation/t:output": "/test/e/operation/output", + }, + }, { + name: "inter-module find", + inModules: map[string]string{ + "test.yang": ` + module test { + prefix "t"; + namespace "urn:t"; + + import foo { prefix foo; } + import bar { prefix baz; } + + leaf ctx { type string; } + leaf other { type string; } + leaf conflict { type string; } + }`, + "foo.yang": ` + module foo { + prefix "foo"; // matches the import above + namespace "urn:foo"; + + container bar { + leaf baz { type string; } + } + + leaf conflict { type string; } + }`, + "bar.yang": ` + module bar { + prefix "bar"; // does not match import in test + namespace "urn:b"; + + container fish { + leaf chips { type string; } + } + + leaf conflict { type string; } + }`, + }, + inBaseEntryPath: "/test/ctx", + wantEntryPath: map[string]string{ + // Check we can still do intra module lookups + "../other": "/test/other", + "/other": "/test/other", + "/foo:bar/foo:baz": "/foo/bar/baz", + // Technically partially prefixed paths to remote modules are + // not legal - check whether we can resolve them. + "/foo:bar/baz": "/foo/bar/baz", + // With mismatched prefixes. + "/baz:fish/baz:chips": "/bar/fish/chips", + // With conflicting node names + "/conflict": "/test/conflict", + "/foo:conflict": "/foo/conflict", + "/baz:conflict": "/bar/conflict", + "/t:conflict": "/test/conflict", + }, + }} + + for _, tt := range tests { + ms := NewModules() + var errs []error + for n, m := range tt.inModules { + if err := ms.Parse(m, n); err != nil { + errs = append(errs, err) + } + } + + if len(errs) > 0 { + t.Errorf("%s: ms.Parse(), got unexpected error parsing input modules: %v", tt.name, errs) + continue + } + + if errs := ms.Process(); len(errs) > 0 { + t.Errorf("%s: ms.Process(), got unexpected error processing entries: %v", tt.name, errs) + continue + } + + dir := map[string]*Entry{} + for _, m := range ms.Modules { + addTreeE(ToEntry(m), dir) + } + + if _, ok := dir[tt.inBaseEntryPath]; !ok { + t.Errorf("%s: could not find entry %s within the dir: %v", tt.name, tt.inBaseEntryPath, dir) + } + + for path, want := range tt.wantEntryPath { + got := dir[tt.inBaseEntryPath].Find(path) + if got.Path() != want { + t.Errorf("%s: (entry %s).Find(%s), did not find path, got: %v, want: %v, errors: %v", tt.name, dir[tt.inBaseEntryPath].Path(), path, got.Path(), want, dir[tt.inBaseEntryPath].Errors) + } + } + } +} + +func TestEntryTypes(t *testing.T) { + leafSchema := &Entry{Name: "leaf-schema", Kind: LeafEntry, Type: &YangType{Kind: Ystring}} + + containerSchema := &Entry{ + Name: "container-schema", + Kind: DirectoryEntry, + Dir: map[string]*Entry{ + "config": { + Dir: map[string]*Entry{ + "leaf1": { + Kind: LeafEntry, + Name: "Leaf1Name", + Type: &YangType{Kind: Ystring}, + }, + }, + }, + }, + } + + emptyContainerSchema := &Entry{ + Name: "empty-container-schema", + Kind: DirectoryEntry, + } + + leafListSchema := &Entry{ + Kind: LeafEntry, + ListAttr: &ListAttr{MinElements: 0}, + Type: &YangType{Kind: Ystring}, + Name: "leaf-list-schema", + } + + listSchema := &Entry{ + Name: "list-schema", + Kind: DirectoryEntry, + ListAttr: &ListAttr{MinElements: 0}, + Dir: map[string]*Entry{ + "leaf-name": { + Kind: LeafEntry, + Name: "LeafName", + Type: &YangType{Kind: Ystring}, + }, + }, + } + + choiceSchema := &Entry{ + Kind: ChoiceEntry, + Name: "Choice1Name", + Dir: map[string]*Entry{ + "case1": { + Kind: CaseEntry, + Name: "case1", + Dir: map[string]*Entry{ + "case1-leaf1": { + Kind: LeafEntry, + Name: "Case1Leaf1", + Type: &YangType{Kind: Ystring}, + }, + }, + }, + }, + } + + type SchemaType string + const ( + Leaf SchemaType = "Leaf" + Container SchemaType = "Container" + LeafList SchemaType = "LeafList" + List SchemaType = "List" + Choice SchemaType = "Choice" + Case SchemaType = "Case" + ) + + tests := []struct { + desc string + schema *Entry + wantType SchemaType + }{ + { + desc: "leaf", + schema: leafSchema, + wantType: Leaf, + }, + { + desc: "container", + schema: containerSchema, + wantType: Container, + }, + { + desc: "empty container", + schema: emptyContainerSchema, + wantType: Container, + }, + { + desc: "leaf-list", + schema: leafListSchema, + wantType: LeafList, + }, + { + desc: "list", + schema: listSchema, + wantType: List, + }, + { + desc: "choice", + schema: choiceSchema, + wantType: Choice, + }, + { + desc: "case", + schema: choiceSchema.Dir["case1"], + wantType: Case, + }, + } + + for _, tt := range tests { + gotm := map[SchemaType]bool{ + Leaf: tt.schema.IsLeaf(), + Container: tt.schema.IsContainer(), + LeafList: tt.schema.IsLeafList(), + List: tt.schema.IsList(), + Choice: tt.schema.IsChoice(), + Case: tt.schema.IsCase(), + } + + for stype, got := range gotm { + if want := (stype == tt.wantType); got != want { + t.Errorf("%s: got Is%v? %t, want Is%v? %t", tt.desc, stype, got, stype, want) + } + } + } +} + +func TestFixChoice(t *testing.T) { + choiceEntry := &Entry{ + Name: "choiceEntry", + Kind: ChoiceEntry, + Dir: map[string]*Entry{ + "unnamedAnyDataCase": { + Name: "unnamedAnyDataCase", + Kind: AnyDataEntry, + Node: &AnyData{ + Parent: &Container{ + Name: "AnyDataParentNode", + }, + Name: "unnamedAnyDataCase", + Source: &Statement{ + Keyword: "anyData-keyword", + HasArgument: true, + Argument: "anyData-argument", + statements: nil, + }, + Extensions: []*Statement{ + { + Keyword: "anyData-extension", + HasArgument: true, + Argument: "anyData-extension-arg", + statements: nil, + }, + }, + }, + }, + "unnamedAnyXMLCase": { + Name: "unnamedAnyXMLCase", + Kind: AnyXMLEntry, + Node: &AnyXML{ + Parent: &Container{ + Name: "AnyXMLParentNode", + }, + Name: "unnamedAnyXMLCase", + Source: &Statement{ + Keyword: "anyXML-keyword", + HasArgument: true, + Argument: "anyXML-argument", + statements: nil, + }, + Extensions: []*Statement{ + { + Keyword: "anyXML-extension", + HasArgument: true, + Argument: "anyXML-extension-arg", + statements: nil, + }, + }, + }, + }, + "unnamedContainerCase": { + Name: "unnamedContainerCase", + Kind: DirectoryEntry, + Node: &Container{ + Parent: &Container{ + Name: "AnyContainerNode", + }, + Name: "unnamedContainerCase", + Source: &Statement{ + Keyword: "container-keyword", + HasArgument: true, + Argument: "container-argument", + statements: nil, + }, + Extensions: []*Statement{ + { + Keyword: "container-extension", + HasArgument: true, + Argument: "container-extension-arg", + statements: nil, + }, + }, + }, + }, + "unnamedLeafCase": { + Name: "unnamedLeafCase", + Kind: LeafEntry, + Node: &Leaf{ + Parent: &Container{ + Name: "leafParentNode", + }, + Name: "unnamedLeafCase", + Source: &Statement{ + Keyword: "leaf-keyword", + HasArgument: true, + Argument: "leaf-argument", + statements: nil, + }, + Extensions: []*Statement{ + { + Keyword: "leaf-extension", + HasArgument: true, + Argument: "leaf-extension-arg", + statements: nil, + }, + }, + }, + }, + "unnamedLeaf-ListCase": { + Name: "unnamedLeaf-ListCase", + Kind: LeafEntry, + Node: &LeafList{ + Parent: &Container{ + Name: "LeafListNode", + }, + Name: "unnamedLeaf-ListCase", + Source: &Statement{ + Keyword: "leaflist-keyword", + HasArgument: true, + Argument: "leaflist-argument", + statements: nil, + }, + Extensions: []*Statement{ + { + Keyword: "leaflist-extension", + HasArgument: true, + Argument: "leaflist-extension-arg", + statements: nil, + }, + }, + }, + }, + "unnamedListCase": { + Name: "unnamedListCase", + Kind: DirectoryEntry, + Node: &List{ + Parent: &Container{ + Name: "ListNode", + }, + Name: "unnamedListCase", + Source: &Statement{ + Keyword: "list-keyword", + HasArgument: true, + Argument: "list-argument", + statements: nil, + }, + Extensions: []*Statement{ + { + Keyword: "list-extension", + HasArgument: true, + Argument: "list-extension-arg", + statements: nil, + }, + }, + }, + }, + }, + } + + choiceEntry.FixChoice() + + for _, e := range []string{"AnyData", "AnyXML", "Container", + "Leaf", "Leaf-List", "List"} { + entryName := "unnamed" + e + "Case" + t.Run(entryName, func(t *testing.T) { + + insertedCase := choiceEntry.Dir[entryName] + originalCase := insertedCase.Dir[entryName] + + insertedNode := insertedCase.Node + if insertedNode.Kind() != "case" { + t.Errorf("Got inserted node type %s, expected case", + insertedNode.Kind()) + } + + originalNode := originalCase.Node + if originalNode.Kind() != strings.ToLower(e) { + t.Errorf("Got original node type %s, expected %s", + originalNode.Kind(), strings.ToLower(e)) + } + + if insertedNode.ParentNode() != originalNode.ParentNode() { + t.Errorf("Got inserted node's parent node %v, expected %v", + insertedNode.ParentNode(), originalNode.ParentNode()) + } + + if insertedNode.NName() != originalNode.NName() { + t.Errorf("Got inserted node's name %s, expected %s", + insertedNode.NName(), originalNode.NName()) + } + + if insertedNode.Statement() != originalNode.Statement() { + t.Errorf("Got inserted node's statement %v, expected %v", + insertedNode.Statement(), originalNode.Statement()) + } + + if len(insertedNode.Exts()) != len(originalNode.Exts()) { + t.Errorf("Got inserted node extensions slice len %d, expected %v", + len(insertedNode.Exts()), len(originalNode.Exts())) + } + + for i, e := range insertedNode.Exts() { + if e != originalNode.Exts()[i] { + t.Errorf("Got inserted node's extension %v at index %d, expected %v", + e, i, originalNode.Exts()[i]) + } + } + }) + } +} + +func mustReadFile(path string) string { + s, err := ioutil.ReadFile(path) + if err != nil { + panic(err) + } + return string(s) +} + +func TestDeviation(t *testing.T) { + type deviationTest struct { + path string + entry *Entry // entry is the entry that is wanted at a particular path, if a field is left as nil, it is not checked. + } + tests := []struct { + desc string + inFiles map[string]string + inParseOptions Options + wants map[string][]deviationTest + wantParseErrSubstring string + wantProcessErrSubstring string + }{{ + desc: "deviation with add", + inFiles: map[string]string{"deviate": mustReadFile(filepath.Join("testdata", "deviate.yang"))}, + wants: map[string][]deviationTest{ + "deviate": {{ + path: "/target/add/config", + entry: &Entry{ + Config: TSFalse, + }, + }, { + path: "/target/add/default", + entry: &Entry{ + Default: []string{"a default value"}, + }, + }, { + path: "/target/add/default-typedef", + entry: &Entry{ + Default: nil, + }, + }, { + path: "/target/add/default-list", + entry: &Entry{ + Default: []string{"foo", "bar", "foo"}, + }, + }, { + path: "/target/add/default-list-typedef-default", + entry: &Entry{ + Default: nil, + }, + }, { + path: "/target/add/mandatory", + entry: &Entry{ + Mandatory: TSTrue, + }, + }, { + path: "/target/add/min-elements", + entry: &Entry{ + ListAttr: &ListAttr{ + MinElements: 42, + }, + deviatePresence: deviationPresence{ + hasMinElements: true, + }, + }, + }, { + path: "/target/add/max-elements", + entry: &Entry{ + ListAttr: &ListAttr{ + MaxElements: 42, + }, + deviatePresence: deviationPresence{ + hasMaxElements: true, + }, + }, + }, { + path: "/target/add/max-and-min-elements", + entry: &Entry{ + ListAttr: &ListAttr{ + MinElements: 42, + MaxElements: 42, + }, + deviatePresence: deviationPresence{ + hasMinElements: true, + hasMaxElements: true, + }, + }, + }, { + path: "/target/add/units", + entry: &Entry{ + Units: "fish per second", + }, + }}, + }, + }, { + desc: "error case - deviation add that already has a default", + inFiles: map[string]string{ + "deviate": ` + module deviate { + prefix "d"; + namespace "urn:d"; + + leaf a { + type string; + default "fish"; + } + + deviation /a { + deviate add { + default "fishsticks"; + } + } + }`, + }, + wantProcessErrSubstring: "already has a default value", + }, { + desc: "error case - deviate type not recognized", + inFiles: map[string]string{ + "deviate": ` + module deviate { + prefix "d"; + namespace "urn:d"; + + leaf a { type string; } + + deviation /a { + deviate shrink { + max-elements 42; + } + } + }`, + }, + wantProcessErrSubstring: "unknown deviation type", + }, { + desc: "error case - deviation add max-element to non-list", + inFiles: map[string]string{ + "deviate": ` + module deviate { + prefix "d"; + namespace "urn:d"; + + leaf a { type string; } + + deviation /a { + deviate add { + max-elements 42; + } + } + }`, + }, + wantProcessErrSubstring: "tried to deviate max-elements on a non-list type", + }, { + desc: "error case - deviation add min elements to non-list", + inFiles: map[string]string{ + "deviate": ` + module deviate { + prefix "d"; + namespace "urn:d"; + + leaf a { type string; } + + deviation /a { + deviate add { + min-elements 42; + } + } + }`, + }, + wantProcessErrSubstring: "tried to deviate min-elements on a non-list type", + }, { + desc: "error case - deviation delete max-element on non-list", + inFiles: map[string]string{ + "deviate": ` + module deviate { + prefix "d"; + namespace "urn:d"; + + leaf a { type string; } + + deviation /a { + deviate delete { + max-elements 42; + } + } + }`, + }, + wantProcessErrSubstring: "tried to deviate max-elements on a non-list type", + }, { + desc: "error case - deviation delete min elements on non-list", + inFiles: map[string]string{ + "deviate": ` + module deviate { + prefix "d"; + namespace "urn:d"; + + leaf a { type string; } + + deviation /a { + deviate delete { + min-elements 42; + } + } + }`, + }, + wantProcessErrSubstring: "tried to deviate min-elements on a non-list type", + }, { + desc: "deviation - not supported", + inFiles: map[string]string{"deviate": mustReadFile(filepath.Join("testdata", "deviate-notsupported.yang"))}, + wants: map[string][]deviationTest{ + "deviate": {{ + path: "/target", + }, { + path: "/target-list", + }, { + path: "/a-leaf", + }, { + path: "/a-leaflist", + }, { + path: "survivor", + entry: &Entry{Name: "survivor"}, + }}, + }, + }, { + desc: "deviation - not supported but ignored by option", + inFiles: map[string]string{"deviate": mustReadFile(filepath.Join("testdata", "deviate-notsupported.yang"))}, + inParseOptions: Options{ + DeviateOptions: DeviateOptions{ + IgnoreDeviateNotSupported: true, + }, + }, + wants: map[string][]deviationTest{ + "deviate": {{ + path: "/target", + entry: &Entry{Name: "target"}, + }, { + path: "/target-list", + entry: &Entry{Name: "target-list"}, + }, { + path: "/a-leaf", + entry: &Entry{Name: "a-leaf"}, + }, { + path: "/a-leaflist", + entry: &Entry{Name: "a-leaflist"}, + }, { + path: "survivor", + entry: &Entry{Name: "survivor"}, + }}, + }, + }, { + desc: "deviation removing non-existent node", + inFiles: map[string]string{ + "deviate": ` + module deviate { + prefix "d"; + namespace "urn:d"; + + deviation /a/b/c { + deviate not-supported; + } + } + `, + }, + wantProcessErrSubstring: "cannot find target node to deviate", + }, { + desc: "deviation not supported across modules", + inFiles: map[string]string{ + "source": ` + module source { + prefix "s"; + namespace "urn:s"; + + leaf a { type string; } + leaf b { type string; } + }`, + "deviation": ` + module deviation { + prefix "d"; + namespace "urn:d"; + + import source { prefix s; } + + deviation /s:a { + deviate not-supported; + } + }`, + }, + wants: map[string][]deviationTest{ + "source": {{ + path: "/a", + }, { + path: "/b", + entry: &Entry{}, + }}, + }, + }, { + desc: "deviation with replace", + inFiles: map[string]string{"deviate": mustReadFile(filepath.Join("testdata", "deviate-replace.yang"))}, + wants: map[string][]deviationTest{ + "deviate": {{ + path: "/target/replace/config", + entry: &Entry{ + Config: TSFalse, + }, + }, { + path: "/target/replace/default", + entry: &Entry{ + Default: []string{"a default value"}, + }, + }, { + path: "/target/replace/default-list", + entry: &Entry{ + Default: []string{"nematodes"}, + }, + }, { + path: "/target/replace/mandatory", + entry: &Entry{ + Mandatory: TSTrue, + }, + }, { + path: "/target/replace/min-elements", + entry: &Entry{ + ListAttr: &ListAttr{ + MinElements: 42, + }, + deviatePresence: deviationPresence{ + hasMinElements: true, + }, + }, + }, { + path: "/target/replace/max-elements", + entry: &Entry{ + ListAttr: &ListAttr{ + MaxElements: 42, + }, + deviatePresence: deviationPresence{ + hasMaxElements: true, + }, + }, + }, { + path: "/target/replace/max-and-min-elements", + entry: &Entry{ + ListAttr: &ListAttr{ + MinElements: 42, + MaxElements: 42, + }, + deviatePresence: deviationPresence{ + hasMinElements: true, + hasMaxElements: true, + }, + }, + }, { + path: "/target/replace/units", + entry: &Entry{ + Units: "fish per second", + }, + }, { + path: "/target/replace/type", + entry: &Entry{ + Type: &YangType{ + Name: "uint16", + Kind: Yuint16, + }, + }, + }}, + }, + }, { + desc: "deviation with delete", + inFiles: map[string]string{"deviate": mustReadFile(filepath.Join("testdata", "deviate-delete.yang"))}, + wants: map[string][]deviationTest{ + "deviate": {{ + path: "/target/delete/config", + entry: &Entry{ + Config: TSUnset, + }, + }, { + path: "/target/delete/default", + entry: &Entry{}, + }, { + path: "/target/delete/mandatory", + entry: &Entry{ + Mandatory: TSUnset, + }, + }, { + path: "/target/delete/min-elements", + entry: &Entry{ + ListAttr: &ListAttr{ + MinElements: 0, + }, + deviatePresence: deviationPresence{ + hasMinElements: true, + }, + }, + }, { + path: "/target/delete/max-elements", + entry: &Entry{ + ListAttr: &ListAttr{ + MaxElements: math.MaxUint64, + }, + deviatePresence: deviationPresence{ + hasMaxElements: true, + }, + }, + }, { + path: "/target/delete/max-and-min-elements", + entry: &Entry{ + ListAttr: &ListAttr{ + MinElements: 0, + MaxElements: math.MaxUint64, + }, + deviatePresence: deviationPresence{ + hasMinElements: true, + hasMaxElements: true, + }, + }, + }, { + path: "/target/delete/units", + entry: &Entry{ + Units: "", + }, + }}, + }, + }, { + // TODO(wenovus): Support deviate delete for leaf-lists for config-false leafs once its semantics are clear. + // https://github.com/mbj4668/pyang/issues/756 + desc: "error case - deviation delete on a leaf-list", + inFiles: map[string]string{ + "deviate": ` + module deviate { + prefix "d"; + namespace "urn:d"; + + leaf-list a { + type string; + default "fish"; + } + + deviation /a { + deviate delete { + default "fishsticks"; + } + } + }`, + }, + wantProcessErrSubstring: "deviate delete on default statements unsupported for leaf-lists", + }, { + desc: "error case - deviation delete of default has different keyword value", + inFiles: map[string]string{ + "deviate": ` + module deviate { + prefix "d"; + namespace "urn:d"; + + leaf a { + type string; + default "fish"; + } + + deviation /a { + deviate delete { + default "fishsticks"; + } + } + }`, + }, + wantProcessErrSubstring: "non-matching keyword", + }, { + desc: "error case - deviation delete where the default didn't exist", + inFiles: map[string]string{ + "deviate": ` + module deviate { + prefix "d"; + namespace "urn:d"; + + leaf a { + type string; + } + + deviation /a { + deviate delete { + default "fishsticks"; + } + } + }`, + }, + wantProcessErrSubstring: "default statement that doesn't exist", + }, { + desc: "error case - deviation delete of min-elements has different keyword value", + inFiles: map[string]string{ + "deviate": ` + module deviate { + prefix "d"; + namespace "urn:d"; + + leaf-list a { type string; } + + deviation /a { + deviate delete { + min-elements 42; + } + } + }`, + }, + wantProcessErrSubstring: "differs from deviation's min-element value", + }, { + desc: "error case - deviation delete of max-elements has different keyword value", + inFiles: map[string]string{ + "deviate": ` + module deviate { + prefix "d"; + namespace "urn:d"; + + leaf-list a { + type string; + max-elements 100; + } + + deviation /a { + deviate delete { + max-elements 42; + } + } + }`, + }, + wantProcessErrSubstring: "differs from deviation's max-element value", + }, { + desc: "deviation using locally defined typedef", + inFiles: map[string]string{ + "deviate": ` + module deviate { + prefix "d"; + namespace "urn:d"; + + import source { prefix s; } + + typedef rstr { + type string { + pattern "a.*"; + } + } + + deviation /s:a { + deviate replace { + type rstr; + } + } + } + `, + "source": ` + module source { + prefix "s"; + namespace "urn:s"; + + leaf a { type uint16; } + } + `, + }, + wants: map[string][]deviationTest{ + "source": {{ + path: "/a", + entry: &Entry{ + Type: &YangType{ + Name: "rstr", + Kind: Ystring, + Pattern: []string{"a.*"}, + }, + }, + }}, + }, + }, { + desc: "complex deviation of multiple leaves", + inFiles: map[string]string{ + "foo": ` + module foo { + prefix "f"; + namespace "urn:f"; + + container a { leaf b { type string; } } + + typedef abc { type boolean; } + typedef abt { type uint32; } + + deviation /a/b { + // typedef is not valid here. + //typedef abc { + // type boolean; + //} + deviate replace { type abc; } + } + + deviation /a/b { + // typedef is not valid here. + //typedef abt { + // type uint16; + //} + deviate replace { type abt; } + } + }`, + }, + wants: map[string][]deviationTest{ + "foo": {{ + path: "/a/b", + entry: &Entry{ + Type: &YangType{ + Name: "abt", + Kind: Yuint32, + }, + }, + }}, + }, + }} + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + ms := NewModules() + ms.ParseOptions = tt.inParseOptions + + for name, mod := range tt.inFiles { + if err := ms.Parse(mod, name); err != nil { + if diff := errdiff.Substring(err, tt.wantParseErrSubstring); diff != "" { + t.Fatalf("error parsing module %s, %s", name, diff) + } + } + } + + errs := ms.Process() + if len(errs) == 0 { + // Add a nil error to compare against the wanted error string. + errs = append(errs, nil) + } + var match bool + for _, err := range errs { + if diff := errdiff.Substring(err, tt.wantProcessErrSubstring); diff == "" { + match = true + break + } + } + if !match { + t.Fatalf("got errs: %v, want: %v", errs, tt.wantProcessErrSubstring) + } + + if tt.wantProcessErrSubstring == "" && len(tt.wants) == 0 { + t.Fatalf("test case expects no error and no entry. Please change your test case to contain one of them.") + } + + for mod, tcs := range tt.wants { + m, errs := ms.GetModule(mod) + if errs != nil { + t.Errorf("couldn't find module %s", mod) + continue + } + + for idx, want := range tcs { + got := m.Find(want.path) + switch { + case got == nil && want.entry != nil: + t.Errorf("%d: expected entry %s does not exist", idx, want.path) + continue + case got != nil && want.entry == nil: + t.Errorf("%d: unexpected entry %s exists, got: %v", idx, want.path, got) + continue + case want.entry == nil: + continue + } + + if got.Config != want.entry.Config { + t.Errorf("%d (%s): did not get expected config statement, got: %v, want: %v", idx, want.path, got.Config, want.entry.Config) + } + + if diff := cmp.Diff(got.Default, want.entry.Default, cmpopts.EquateEmpty()); diff != "" { + t.Errorf("%d (%s): did not get expected default statement, (-got, +want): %s", idx, want.path, diff) + } + + if got.Mandatory != want.entry.Mandatory { + t.Errorf("%d (%s): did not get expected mandatory statement, got: %v, want: %v", idx, want.path, got.Mandatory, want.entry.Mandatory) + } + + if want.entry.ListAttr != nil { + if got.ListAttr == nil { + t.Errorf("%d (%s): listattr was nil for an entry expected to be a list at %s", idx, want.path, want.path) + continue + } + if want.entry.deviatePresence.hasMinElements { + if gotMin, wantMin := got.ListAttr.MinElements, want.entry.ListAttr.MinElements; gotMin != wantMin { + t.Errorf("%d (%s): min-elements, got: %v, want: %v", idx, want.path, gotMin, wantMin) + } + } + if want.entry.deviatePresence.hasMaxElements { + if gotMax, wantMax := got.ListAttr.MaxElements, want.entry.ListAttr.MaxElements; gotMax != wantMax { + t.Errorf("%d (%s): max-elements, got: %v, want: %v", idx, want.path, gotMax, wantMax) + } + } + } + + if want.entry.Type != nil { + if got.Type.Name != want.entry.Type.Name { + t.Errorf("%d (%s): type name, got: %s, want: %s", idx, want.path, got.Type.Name, want.entry.Type.Name) + } + + if got.Type.Kind != want.entry.Type.Kind { + t.Errorf("%d (%s): type kind, got: %s, want: %s", idx, want.path, got.Type.Kind, want.entry.Type.Kind) + } + } + + if got.Units != want.entry.Units { + t.Errorf("%d (%s): did not get expected units statement, got: %s, want: %s", idx, want.path, got.Units, want.entry.Units) + } + } + } + }) + } +} + +func TestLeafEntry(t *testing.T) { + tests := []struct { + name string + inModules map[string]string + wantEntryPath string + wantEntryCustomTest func(t *testing.T, e *Entry) + wantErrSubstr string + }{{ + name: "direct decimal64 type", + inModules: map[string]string{ + "test.yang": ` + module test { + prefix "t"; + namespace "urn:t"; + + leaf "gain-adjustment" { + type "decimal64" { + fraction-digits "1"; + range "-12.0..12.0"; + } + default "0.0"; + } + } + `, + }, + wantEntryPath: "/test/gain-adjustment", + wantEntryCustomTest: func(t *testing.T, e *Entry) { + if got, want := e.Type.FractionDigits, 1; got != want { + t.Errorf("got %d, want %d", got, want) + } + if got, want := e.Mandatory, TSUnset; got != want { + t.Errorf("got %d, want %d", got, want) + } + if got, want := e.Type.Range, (YangRange{Rf(-120, 120, 1)}); !cmp.Equal(got, want) { + t.Errorf("Range got: %v, want: %v", got, want) + } + }, + }, { + name: "typedef decimal64 type", + inModules: map[string]string{ + "test.yang": ` + module test { + prefix "t"; + namespace "urn:t"; + + typedef "optical-dB" { + type "decimal64" { + fraction-digits "1"; + } + } + + leaf "gain-adjustment" { + type "optical-dB" { + range "-12.0..12.0"; + } + default "0.0"; + } + } + `, + }, + wantEntryPath: "/test/gain-adjustment", + wantEntryCustomTest: func(t *testing.T, e *Entry) { + if got, want := e.Type.FractionDigits, 1; got != want { + t.Errorf("got %d, want %d", got, want) + } + if diff := cmp.Diff(e.Type.Range, YangRange{Rf(-120, 120, 1)}); diff != "" { + t.Errorf("Range (-got, +want):\n%s", diff) + } + }, + }, { + name: "typedef decimal64 type with overriding fraction-digits", + inModules: map[string]string{ + "test.yang": ` + module test { + prefix "t"; + namespace "urn:t"; + + typedef "optical-dB" { + type "decimal64" { + fraction-digits "1"; + } + } + + leaf "gain-adjustment" { + type "optical-dB" { + fraction-digits "2"; + range "-12.0..12.0"; + } + default "0.0"; + } + } + `, + }, + wantErrSubstr: "overriding of fraction-digits not allowed", + }, { + name: "leaf mandatory true", + inModules: map[string]string{ + "test.yang": ` + module test { + prefix "t"; + namespace "urn:t"; + + leaf "mandatory" { + type "string" { + } + mandatory true; + } + } + `, + }, + wantEntryPath: "/test/mandatory", + wantEntryCustomTest: func(t *testing.T, e *Entry) { + if got, want := e.Mandatory, TSTrue; got != want { + t.Errorf("got %d, want %d", got, want) + } + }, + }, { + name: "leaf mandatory false", + inModules: map[string]string{ + "test.yang": ` + module test { + prefix "t"; + namespace "urn:t"; + + leaf "mandatory" { + type "string" { + } + mandatory false; + } + } + `, + }, + wantEntryPath: "/test/mandatory", + wantEntryCustomTest: func(t *testing.T, e *Entry) { + if got, want := e.Mandatory, TSFalse; got != want { + t.Errorf("got %d, want %d", got, want) + } + }, + }, { + name: "leaf description", + inModules: map[string]string{ + "test.yang": ` + module test { + prefix "t"; + namespace "urn:t"; + + leaf "mandatory" { + type "string" { + } + description "I am a leaf"; + } + } + `, + }, + wantEntryPath: "/test/mandatory", + wantEntryCustomTest: func(t *testing.T, e *Entry) { + if got, want := e.Description, "I am a leaf"; got != want { + t.Errorf("got %q, want %q", got, want) + } + }, + }} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ms := NewModules() + var errs []error + for n, m := range tt.inModules { + if err := ms.Parse(m, n); err != nil { + errs = append(errs, err) + } + } + + if len(errs) > 0 { + t.Fatalf("ms.Parse(), got unexpected error parsing input modules: %v", errs) + } + + if errs := ms.Process(); len(errs) > 0 { + if len(errs) == 1 { + if diff := errdiff.Substring(errs[0], tt.wantErrSubstr); diff != "" { + t.Fatalf("did not get expected error, %s", diff) + } + return + } + t.Fatalf("ms.Process(), got too many errors processing entries: %v", errs) + } + + dir := map[string]*Entry{} + for _, m := range ms.Modules { + addTreeE(ToEntry(m), dir) + } + + e, ok := dir[tt.wantEntryPath] + if !ok { + t.Fatalf("could not find entry %s within the dir: %v", tt.wantEntryPath, dir) + } + tt.wantEntryCustomTest(t, e) + }) + } +} + +func TestLess(t *testing.T) { + sErrors := sortedErrors{ + {"testfile0", errors.New("test error0")}, + {"testfile1", errors.New("test error1")}, + {"testfile1:1", errors.New("test error2")}, + {"testfile2:1", errors.New("test error3")}, + {"testfile2:1:1", errors.New("test error4")}, + {"testfile3:1:1:error5", errors.New("test error5")}, + {"testfile3:1:2:error6", errors.New("test error6")}, + {"testfile3:1:1:error7", errors.New("test error7")}, + } + + tests := []struct { + desc string + i int + j int + want bool + }{{ + desc: "compare two different strings without seperator ':'", + i: 0, + j: 1, + want: true, + }, { + desc: "compare two different strings without seperator ':'", + i: 1, + j: 0, + want: false, + }, { + desc: "compare one slice in a string with two slices in another string", + i: 1, + j: 2, + want: true, + }, { + desc: "compare two different strings with two slices each", + i: 2, + j: 3, + want: true, + }, { + desc: "compare two different strings with two slices each", + i: 3, + j: 2, + want: false, + }, { + desc: "compare two slices in a string with three slices in another string", + i: 3, + j: 4, + want: true, + }, { + desc: "compare three slices in a string with two slices in another string", + i: 4, + j: 3, + want: false, + }, { + desc: "compare two different strings with four slices each", + i: 5, + j: 6, + want: true, + }, { + desc: "compare two different strings with four slices each", + i: 6, + j: 5, + want: false, + }, { + desc: "compare two identical strings without separator ':'", + i: 1, + j: 1, + want: false, + }, { + desc: "compare two identical strings with two slices", + i: 2, + j: 2, + want: false, + }, { + desc: "compare two identical strings with three slices", + i: 4, + j: 4, + want: false, + }, { + desc: "compare two identical strings with four slices", + i: 5, + j: 5, + want: false, + }, { + desc: "compare different strings with four slices", + i: 7, + j: 5, + want: false, + }, { + desc: "compare different strings with four slices", + i: 5, + j: 7, + want: true, + }} + var cmpSymbol byte + for _, tt := range tests { + want := sErrors.Less(tt.i, tt.j) + if want != tt.want { + if want { + cmpSymbol = '<' + } else { + cmpSymbol = '>' + } + t.Errorf("%s: incorrect less comparison: \"%s\" %c \"%s\"", tt.desc, sErrors[tt.i].s, cmpSymbol, sErrors[tt.j].s) + } + } +} + +type customTestCases struct { + wantEntryPath string + wantEntryCustomTest func(t *testing.T, e *Entry) +} + +func TestOrderedBy(t *testing.T) { + tests := []struct { + name string + inModules map[string]string + testcases []customTestCases + wantErrSubstr string + }{{ + name: "ordered-by user", + inModules: map[string]string{ + "test.yang": ` + module test { + prefix "t"; + namespace "urn:t"; + + list ordered-list { + key "name"; + ordered-by user; + leaf name { + type string; + } + } + + list unordered-list { + key "name"; + ordered-by system; + leaf name { + type string; + } + } + + list unordered-list2 { + key "name"; + leaf name { + type string; + } + } + + leaf-list ordered-leaflist { + ordered-by user; + type string; + } + + leaf-list unordered-leaflist { + ordered-by system; + type string; + } + + leaf-list unordered-leaflist2 { + type string; + } + } + `, + }, + testcases: []customTestCases{{ + wantEntryPath: "/test/ordered-list", + wantEntryCustomTest: func(t *testing.T, e *Entry) { + if got, want := e.ListAttr.OrderedByUser, true; got != want { + t.Errorf("got %v, want %v", got, want) + } + }, + }, { + wantEntryPath: "/test/unordered-list", + wantEntryCustomTest: func(t *testing.T, e *Entry) { + if got, want := e.ListAttr.OrderedByUser, false; got != want { + t.Errorf("got %v, want %v", got, want) + } + }, + }, { + wantEntryPath: "/test/unordered-list2", + wantEntryCustomTest: func(t *testing.T, e *Entry) { + if got, want := e.ListAttr.OrderedByUser, false; got != want { + t.Errorf("got %v, want %v", got, want) + } + }, + }, { + wantEntryPath: "/test/ordered-leaflist", + wantEntryCustomTest: func(t *testing.T, e *Entry) { + if got, want := e.ListAttr.OrderedByUser, true; got != want { + t.Errorf("got %v, want %v", got, want) + } + }, + }, { + wantEntryPath: "/test/unordered-leaflist", + wantEntryCustomTest: func(t *testing.T, e *Entry) { + if got, want := e.ListAttr.OrderedByUser, false; got != want { + t.Errorf("got %v, want %v", got, want) + } + }, + }, { + wantEntryPath: "/test/unordered-leaflist2", + wantEntryCustomTest: func(t *testing.T, e *Entry) { + if got, want := e.ListAttr.OrderedByUser, false; got != want { + t.Errorf("got %v, want %v", got, want) + } + }, + }}, + }, { + name: "ordered-by client: invalid argument", + inModules: map[string]string{ + "test.yang": ` + module test { + prefix "t"; + namespace "urn:t"; + + list ordered-list { + key "name"; + ordered-by client; + leaf name { + type string; + } + } + } + `, + }, + wantErrSubstr: "ordered-by has invalid argument", + }} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ms := NewModules() + var errs []error + for n, m := range tt.inModules { + if err := ms.Parse(m, n); err != nil { + errs = append(errs, err) + } + } + + if len(errs) > 0 { + t.Fatalf("ms.Parse(), got unexpected error parsing input modules: %v", errs) + } + + if errs := ms.Process(); len(errs) > 0 { + if len(errs) == 1 { + if diff := errdiff.Substring(errs[0], tt.wantErrSubstr); diff != "" { + t.Fatalf("did not get expected error, %s", diff) + } + return + } + t.Fatalf("ms.Process(), got too many errors processing entries: %v", errs) + } + + dir := map[string]*Entry{} + for _, m := range ms.Modules { + addTreeE(ToEntry(m), dir) + } + + for _, tc := range tt.testcases { + e, ok := dir[tc.wantEntryPath] + if !ok { + t.Fatalf("could not find entry %s within the dir: %v", tc.wantEntryPath, dir) + } + tc.wantEntryCustomTest(t, e) + } + }) + } +} diff --git a/src/webui/internal/goyang/pkg/yang/file.go b/src/webui/internal/goyang/pkg/yang/file.go new file mode 100644 index 00000000..48ff76be --- /dev/null +++ b/src/webui/internal/goyang/pkg/yang/file.go @@ -0,0 +1,167 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +var ( + // revisionDateSuffixRegex matches on the revision-date portion of a YANG + // file's name. + revisionDateSuffixRegex = regexp.MustCompile(`^@\d{4}-\d{2}-\d{2}\.yang$`) +) + +// PathsWithModules returns all paths under and including the +// root containing files with a ".yang" extension, as well as +// any error encountered +func PathsWithModules(root string) (paths []string, err error) { + pm := map[string]bool{} + filepath.Walk(root, func(p string, info os.FileInfo, e error) error { + err = e + if err == nil { + if info == nil { + return nil + } + if !info.IsDir() && strings.HasSuffix(p, ".yang") { + dir := filepath.Dir(p) + if !pm[dir] { + pm[dir] = true + paths = append(paths, dir) + } + } + return nil + } + return err + }) + return +} + +// AddPath adds the directories specified in p, a colon separated list +// of directory names, to Path, if they are not already in Path. Using +// multiple arguments is also supported. +func (ms *Modules) AddPath(paths ...string) { + for _, path := range paths { + for _, p := range strings.Split(path, ":") { + if !ms.pathMap[p] { + ms.pathMap[p] = true + ms.Path = append(ms.Path, p) + } + } + } +} + +// readFile makes testing of findFile easier. +var readFile = ioutil.ReadFile + +// scanDir makes testing of findFile easier. +var scanDir = findInDir + +// findFile returns the name and contents of the .yang file associated with +// name, or an error. If name is a module name rather than a file name (it does +// not have a .yang extension and there is no / in name), .yang is appended to +// the the name. The directory that the .yang file is found in is added to Path +// if not already in Path. If a file is not found by exact match, directories +// are scanned for "name@revision-date.yang" files, the latest (sorted by +// YYYY-MM-DD revision-date) of these will be selected. +// +// If a path has the form dir/... then dir and all direct or indirect +// subdirectories of dir are searched. +// +// The current directory (.) is always checked first, no matter the value of +// Path. +func (ms *Modules) findFile(name string) (string, string, error) { + slash := strings.Index(name, "/") + if slash < 0 && !strings.HasSuffix(name, ".yang") { + name += ".yang" + if best := scanDir(".", name, false); best != "" { + // we found a matching candidate in the local directory + name = best + } + } + + switch data, err := readFile(name); true { + case err == nil: + ms.AddPath(filepath.Dir(name)) + return name, string(data), nil + case slash >= 0: + // If there are any /'s in the name then don't search Path. + return "", "", fmt.Errorf("no such file: %s", name) + } + + for _, dir := range ms.Path { + var n string + if filepath.Base(dir) == "..." { + n = scanDir(filepath.Dir(dir), name, true) + } else { + n = scanDir(dir, name, false) + } + if n == "" { + continue + } + if data, err := readFile(n); err == nil { + return n, string(data), nil + } + } + return "", "", fmt.Errorf("no such file: %s", name) +} + +// findInDir looks for a file named name in dir or any of its subdirectories if +// recurse is true. if recurse is false, scan only the directory dir. +// If no matching file is found, an empty string is returned. +// +// The file SHOULD have the following name, per +// https://tools.ietf.org/html/rfc7950#section-5.2: +// module-or-submodule-name ['@' revision-date] '.yang' +// where revision-date = 4DIGIT "-" 2DIGIT "-" 2DIGIT +// +// If a perfect name match is found, then that file's path is returned. +// Else if file(s) with otherwise matching names but which contain a +// revision-date pattern exactly matching the above are found, then path of the +// one with the latest date is returned. +func findInDir(dir, name string, recurse bool) string { + fis, err := ioutil.ReadDir(dir) + if err != nil { + return "" + } + + var revisions []string + mname := strings.TrimSuffix(name, ".yang") + for _, fi := range fis { + switch { + case !fi.IsDir(): + if fn := fi.Name(); fn == name { + return filepath.Join(dir, name) + } else if strings.HasPrefix(fn, mname) && revisionDateSuffixRegex.MatchString(strings.TrimPrefix(fn, mname)) { + revisions = append(revisions, fn) + } + case recurse: + if n := findInDir(filepath.Join(dir, fi.Name()), name, recurse); n != "" { + return n + } + } + } + if len(revisions) == 0 { + return "" + } + sort.Strings(revisions) + return filepath.Join(dir, revisions[len(revisions)-1]) +} diff --git a/src/webui/internal/goyang/pkg/yang/file_test.go b/src/webui/internal/goyang/pkg/yang/file_test.go new file mode 100644 index 00000000..6500a6de --- /dev/null +++ b/src/webui/internal/goyang/pkg/yang/file_test.go @@ -0,0 +1,166 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +import ( + "errors" + "io/ioutil" + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestFindFile(t *testing.T) { + sep := string(os.PathSeparator) + + for _, tt := range []struct { + name string + path []string + check []string + }{ + { + name: "one", + check: []string{"one.yang"}, + }, + { + name: "./two", + check: []string{"./two"}, + }, + { + name: "three.yang", + check: []string{"three.yang"}, + }, + { + name: "four", + path: []string{"dir1", "dir2"}, + check: []string{"four.yang", "dir1" + sep + "four.yang", "dir2" + sep + "four.yang"}, + }, + } { + var checked []string + ms := NewModules() + ms.Path = tt.path + readFile = func(path string) ([]byte, error) { + checked = append(checked, path) + return nil, errors.New("no such file") + } + scanDir = func(dir, name string, recurse bool) string { + return filepath.Join(dir, name) + } + if _, _, err := ms.findFile(tt.name); err == nil { + t.Errorf("%s unexpectedly succeeded", tt.name) + continue + } + if !reflect.DeepEqual(tt.check, checked) { + t.Errorf("%s: got %v, want %v", tt.name, checked, tt.check) + } + } +} + +func TestScanForPathsAndAddModules(t *testing.T) { + // disable any readFile mock setup by other tests + readFile = ioutil.ReadFile + + // Scan the directory tree for YANG modules + paths, err := PathsWithModules("../../testdata") + if err != nil { + t.Fatal(err) + } + // we should have seen two directories being testdata and + // testdata/subdir. + if len(paths) != 2 { + t.Errorf("got %d paths imported, want 2", len(paths)) + } + ms := NewModules() + // add the paths found in the scan to the module path + ms.AddPath(paths...) + + // confirm we can load the four modules that exist in + // the two paths we scanned. + modules := []string{"aug", "base", "other", "subdir1"} + for _, name := range modules { + if _, err := ms.GetModule(name); err != nil { + t.Errorf("getting %s: %v", name, err) + } + } + + // however, a sub module is not a valid argument to GetModule. + if _, err := ms.GetModule("sub"); err == nil { + t.Error("want an error when loading 'sub', got nil") + } + +} + +func TestFindInDir(t *testing.T) { + testDir := "testdata/find-file-test" + + tests := []struct { + desc string + inDir string + inName string + inRecurse bool + want string + }{{ + desc: "file not found", + inDir: testDir, + inName: "green.yang", + inRecurse: true, + want: "", + }, { + desc: "input directory does not exist", + inDir: filepath.Join(testDir, "dne"), + inName: "red.yang", + inRecurse: true, + want: "", + }, { + desc: "exact match", + inDir: testDir, + inName: "blue.yang", + inRecurse: false, + want: filepath.Join(testDir, "blue.yang"), + }, { + desc: "exact match, recursive", + inDir: testDir, + inName: "blue.yang", + inRecurse: true, + want: filepath.Join(testDir, "blue.yang"), + }, { + desc: "exact match with non-standard name", + inDir: testDir, + inName: "non-standard.name", + inRecurse: false, + want: filepath.Join(testDir, "non-standard.name"), + }, { + desc: "revision match without recursion, and ignoring invalid revision", + inDir: testDir, + inName: "red.yang", + inRecurse: false, + want: filepath.Join(testDir, "red@2010-10-10.yang"), + }, { + desc: "revision match with recursion", + inDir: testDir, + inName: "red.yang", + inRecurse: true, + want: filepath.Join(testDir, "dir", "dirdir", "red@2022-02-22.yang"), + }} + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + if got, want := findInDir(tt.inDir, tt.inName, tt.inRecurse), tt.want; got != want { + t.Errorf("got: %q, want: %q", got, want) + } + }) + } +} diff --git a/src/webui/internal/goyang/pkg/yang/find.go b/src/webui/internal/goyang/pkg/yang/find.go new file mode 100644 index 00000000..9f0575d4 --- /dev/null +++ b/src/webui/internal/goyang/pkg/yang/find.go @@ -0,0 +1,96 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +// This file has functions that search the AST for specified nodes. + +import ( + "reflect" + "strings" +) + +// localPrefix returns the local prefix used by the containing (sub)module to +// refer to its own module. +func localPrefix(n Node) string { + return RootNode(n).GetPrefix() +} + +// trimLocalPrefix trims the current module's prefix from the given name. If the +// name is not prefixed with the local module's prefix or is unprefixed +// entirely, then the same string is returned unchanged. +func trimLocalPrefix(n Node, name string) string { + pfx := localPrefix(n) + if pfx != "" { + pfx += ":" + } + return strings.TrimPrefix(name, pfx) +} + +// FindGrouping finds the grouping named name according to YANG namespace rules +// using the input node as the initial context node. The seen parameter +// provides a list of the modules previously seen by FindGrouping during +// traversal. If the named grouping cannot be found, nil is returned. +// +// FindGrouping works by recursively looking through the context node's parent +// nodes for grouping fields, or in included or imported submodules/modules for +// externally-defined groupings. Note that any prefix in the name must match +// the module prefix of its import statement in the context node's module. +func FindGrouping(n Node, name string, seen map[string]bool) *Grouping { + name = trimLocalPrefix(n, name) + for n != nil { + // Grab the Grouping field of the underlying structure. n is + // always a pointer to a structure, + e := reflect.ValueOf(n).Elem() + v := e.FieldByName("Grouping") + if v.IsValid() { + for _, g := range v.Interface().([]*Grouping) { + if g.Name == name { + return g + } + } + } + v = e.FieldByName("Import") + if v.IsValid() { + for _, i := range v.Interface().([]*Import) { + // If the prefix matches the import statement, + // then search for the trimmed name in that module. + pname := strings.TrimPrefix(name, i.Prefix.Name+":") + if pname == name { + continue + } + if g := FindGrouping(i.Module, pname, seen); g != nil { + return g + } + } + } + v = e.FieldByName("Include") + if v.IsValid() { + for _, i := range v.Interface().([]*Include) { + if seen[i.Module.Name] { + // Prevent infinite loops in the case that we have already looked at + // this submodule. This occurs where submodules have include statements + // in them, or there is a circular dependency. + continue + } + seen[i.Module.Name] = true + if g := FindGrouping(i.Module, name, seen); g != nil { + return g + } + } + } + n = n.ParentNode() + } + return nil +} diff --git a/src/webui/internal/goyang/pkg/yang/find_test.go b/src/webui/internal/goyang/pkg/yang/find_test.go new file mode 100644 index 00000000..da39d504 --- /dev/null +++ b/src/webui/internal/goyang/pkg/yang/find_test.go @@ -0,0 +1,358 @@ +package yang + +import ( + "testing" +) + +func TestFindGrouping(t *testing.T) { + tests := []struct { + desc string + inMods map[string]string + inNode func(*Modules) (Node, error) + inName string + wantGroupNodePath string + // wantCannotFound indicates that the grouping cannot be found. + wantCannotFound bool + }{{ + desc: "grouping within module", + inMods: map[string]string{ + "dev": ` + module dev { + prefix d; + namespace "urn:d"; + + revision 01-01-01 { description "the start of time"; } + + grouping g { leaf a { type string; } } + + container c { leaf b { type string; } } + }`, + }, + inNode: func(ms *Modules) (Node, error) { + return FindNode(ms.Modules["dev"], "c") + }, + inName: "g", + wantGroupNodePath: "/dev/g", + }, { + desc: "nested grouping within module", + inMods: map[string]string{ + "dev": ` + module dev { + prefix d; + namespace "urn:d"; + + revision 01-01-01 { description "the start of time"; } + + grouping g { grouping gg { leaf a { type string; } } uses gg; } + + container c { leaf b { type string; } } + }`, + }, + inNode: func(ms *Modules) (Node, error) { + return FindNode(ms.Modules["dev"], "g") + }, + inName: "gg", + wantGroupNodePath: "/dev/g/gg", + }, { + desc: "grouping that uses another grouping both within the same module", + inMods: map[string]string{ + "dev": ` + module dev { + prefix d; + namespace "urn:d"; + + revision 01-01-01 { description "the start of time"; } + + grouping gg { leaf a { type string; } } + + grouping g { uses gg; } + + container c { leaf b { type string; } } + }`, + }, + inNode: func(ms *Modules) (Node, error) { + return FindNode(ms.Modules["dev"], "g") + }, + inName: "gg", + wantGroupNodePath: "/dev/gg", + }, { + desc: "grouping in included submodule", + inMods: map[string]string{ + "dev": ` + module dev { + prefix d; + namespace "urn:d"; + include sys; + + container c { leaf b { type string; } } + + revision 01-01-01 { description "the start of time"; } + }`, + "sys": ` + submodule sys { + belongs-to dev { + prefix "d"; + } + + revision 01-01-01 { description "the start of time"; } + + grouping g { leaf a { type string; } } + }`, + }, + inNode: func(ms *Modules) (Node, error) { + return FindNode(ms.Modules["dev"], "c") + }, + inName: "g", + wantGroupNodePath: "/sys/g", + }, { + desc: "grouping in indirectly-included submodule", + inMods: map[string]string{ + "dev": ` + module dev { + prefix d; + namespace "urn:d"; + include sys; + + revision 01-01-01 { description "the start of time"; } + + container c { leaf b { type string; } } + }`, + "sys": ` + submodule sys { + belongs-to dev { + prefix "d"; + } + include sysdb; + + revision 01-01-01 { description "the start of time"; } + }`, + "sysdb": ` + submodule sysdb { + belongs-to dev { + prefix "d"; + } + + revision 01-01-01 { description "the start of time"; } + + grouping g { leaf a { type string; } } + }`, + }, + inNode: func(ms *Modules) (Node, error) { + return FindNode(ms.Modules["dev"], "c") + }, + inName: "g", + wantGroupNodePath: "/sysdb/g", + }, { + desc: "grouping in indirectly-included submodule with node in submodule", + inMods: map[string]string{ + "dev": ` + module dev { + prefix d; + namespace "urn:d"; + include sys; + + revision 01-01-01 { description "the start of time"; } + }`, + "sys": ` + submodule sys { + belongs-to dev { + prefix "d"; + } + include sysdb; + + revision 01-01-01 { description "the start of time"; } + + container c { leaf b { type string; } } + }`, + "sysdb": ` + submodule sysdb { + belongs-to dev { + prefix "d"; + } + + revision 01-01-01 { description "the start of time"; } + + grouping g { leaf a { type string; } } + }`, + }, + inNode: func(ms *Modules) (Node, error) { + return FindNode(ms.SubModules["sys"], "c") + }, + inName: "g", + wantGroupNodePath: "/sysdb/g", + }, { + desc: "grouping in submodule", + inMods: map[string]string{ + "dev": ` + module dev { + prefix d; + namespace "urn:d"; + import sysdb { prefix "s"; } + + revision 01-01-01 { description "the start of time"; } + + container c { leaf b { type string; } uses s:g; } + }`, + "sysdb": ` + module sysdb { + prefix sd; + namespace "urn:sd"; + + revision 01-01-01 { description "the start of time"; } + + grouping g { leaf a { type string; } } + }`, + }, + inNode: func(ms *Modules) (Node, error) { + return FindNode(ms.Modules["dev"], "c") + }, + inName: "s:g", + wantGroupNodePath: "/sysdb/g", + }, { + desc: "grouping that uses another grouping both in different modules", + inMods: map[string]string{ + "dev": ` + module dev { + prefix d; + namespace "urn:d"; + import dev2 { prefix "de2"; } + + revision 01-01-01 { description "the start of time"; } + + container c { leaf l { type string; } uses de2:g; } + }`, + "dev2": ` + module dev2 { + prefix d2; + namespace "urn:d2"; + import dev3 { prefix "de3"; } + + revision 01-01-01 { description "the start of time"; } + + grouping g { leaf a { type string; } uses de3:gg; } + }`, + "dev3": ` + module dev3 { + prefix d3; + namespace "urn:d3"; + + revision 01-01-01 { description "the start of time"; } + + grouping gg { leaf b { type string; } } + }`, + }, + inNode: func(ms *Modules) (Node, error) { + return FindNode(ms.Modules["dev2"], "g") + }, + inName: "de3:gg", + wantGroupNodePath: "/dev3/gg", + }, { + desc: "grouping that uses another grouping both in different modules but prefix is wrong", + inMods: map[string]string{ + "dev": ` + module dev { + prefix d; + namespace "urn:d"; + import dev2 { prefix "de2"; } + + revision 01-01-01 { description "the start of time"; } + + container c { leaf l { type string; } uses de2:g; } + }`, + "dev2": ` + module dev2 { + prefix d2; + namespace "urn:d2"; + import dev3 { prefix "de3"; } + + revision 01-01-01 { description "the start of time"; } + + grouping g { leaf a { type string; } uses de3:gg; } + }`, + "dev3": ` + module dev3 { + prefix dev3; + namespace "urn:dev3"; + + revision 01-01-01 { description "the start of time"; } + + grouping gg { leaf b { type string; } } + }`, + }, + inNode: func(ms *Modules) (Node, error) { + return FindNode(ms.Modules["dev2"], "g") + }, + inName: "d3:gg", + wantCannotFound: true, + }, { + desc: "grouping that uses another grouping both in different modules but uses wrong context node", + inMods: map[string]string{ + "dev": ` + module dev { + prefix d; + namespace "urn:d"; + import dev2 { prefix "de2"; } + + revision 01-01-01 { description "the start of time"; } + + container c { leaf l { type string; } uses de2:g; } + }`, + "dev2": ` + module dev2 { + prefix d2; + namespace "urn:d2"; + import dev3 { prefix "dev3"; } + + revision 01-01-01 { description "the start of time"; } + + grouping g { leaf a { type string; } uses dev3:gg; } + }`, + "dev3": ` + module dev3 { + prefix dev3; + namespace "urn:dev3"; + + revision 01-01-01 { description "the start of time"; } + + grouping gg { leaf b { type string; } } + }`, + }, + inNode: func(ms *Modules) (Node, error) { + return FindNode(ms.Modules["dev"], "c") + }, + inName: "dev3:gg", + wantCannotFound: true, + }} + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + ms := NewModules() + + for n, m := range tt.inMods { + if err := ms.Parse(m, n); err != nil { + t.Fatalf("cannot parse module %s, err: %v", n, err) + } + } + + if errs := ms.Process(); errs != nil { + t.Fatalf("cannot process modules: %v", errs) + } + + seen := map[string]bool{} + node, err := tt.inNode(ms) + if err != nil { + t.Fatalf("cannot find input node: %v", err) + } + g := FindGrouping(node, tt.inName, seen) + if got, want := g == nil, tt.wantCannotFound; got != want { + t.Fatalf("got grouping: %v, wantCannotFound: %v", got, want) + } + if tt.wantCannotFound { + return + } + if got, want := NodePath(g), tt.wantGroupNodePath; got != want { + t.Errorf("found grouping path doesn't match expected, got: %s, want: %s", got, want) + } + }) + } +} diff --git a/src/webui/internal/goyang/pkg/yang/identity.go b/src/webui/internal/goyang/pkg/yang/identity.go new file mode 100644 index 00000000..615bff79 --- /dev/null +++ b/src/webui/internal/goyang/pkg/yang/identity.go @@ -0,0 +1,192 @@ +// Copyright 2016 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +import ( + "fmt" + "sort" + "sync" +) + +// This file implements data structures and functions that relate to the +// identity type. + +// identityDictionary stores a set of identities across all parsed Modules that +// have been resolved to be identified by their module and name. +type identityDictionary struct { + mu sync.Mutex + // dict is a global cache of identities keyed by + // modulename:identityname, where modulename is the full name of the + // module to which the identity belongs. If the identity were defined + // in a submodule, then the parent module name is used instead. + dict map[string]resolvedIdentity +} + +// resolvedIdentity is an Identity that has been disambiguated. +type resolvedIdentity struct { + Module *Module + Identity *Identity +} + +// isEmpty determines whether the resolvedIdentity struct value is populated. +func (r resolvedIdentity) isEmpty() bool { + return r.Module == nil && r.Identity == nil +} + +// newResolvedIdentity creates a resolved identity from an identity and its +// associated module, and returns the prefixed name (Prefix:IdentityName) +// along with the resolved identity. +func newResolvedIdentity(m *Module, i *Identity) (string, *resolvedIdentity) { + r := &resolvedIdentity{ + Module: m, + Identity: i, + } + return i.modulePrefixedName(), r +} + +func appendIfNotIn(ids []*Identity, chk *Identity) []*Identity { + for _, id := range ids { + if id == chk { + return ids + } + } + return append(ids, chk) +} + +// addChildren adds identity r and all of its children to ids +// deterministically. +func addChildren(r *Identity, ids []*Identity) []*Identity { + ids = appendIfNotIn(ids, r) + + // Iterate through the values of r. + for _, ch := range r.Values { + ids = addChildren(ch, ids) + } + return ids +} + +// findIdentityBase returns the resolved identity that is corresponds to the +// baseStr string in the context of the module/submodule mod. +func (mod *Module) findIdentityBase(baseStr string) (*resolvedIdentity, []error) { + var base resolvedIdentity + var ok bool + var errs []error + + basePrefix, baseName := getPrefix(baseStr) + rootPrefix := mod.GetPrefix() + source := Source(mod) + typeDict := mod.Modules.typeDict + + switch basePrefix { + case "", rootPrefix: + // This is a local identity which is defined within the current + // module + keyName := fmt.Sprintf("%s:%s", module(mod).Name, baseName) + base, ok = typeDict.identities.dict[keyName] + if !ok { + errs = append(errs, fmt.Errorf("%s: can't resolve the local base %s as %s", source, baseStr, keyName)) + } + default: + // This is an identity which is defined within another module + extmod := FindModuleByPrefix(mod, basePrefix) + if extmod == nil { + errs = append(errs, + fmt.Errorf("%s: can't find external module with prefix %s", source, basePrefix)) + break + } + // The identity we are looking for is modulename:basename. + if id, ok := typeDict.identities.dict[fmt.Sprintf("%s:%s", module(extmod).Name, baseName)]; ok { + base = id + break + } + + // Error if we did not find the identity that had the name specified in + // the module it was expected to be in. + if base.isEmpty() { + errs = append(errs, fmt.Errorf("%s: can't resolve remote base %s", source, baseStr)) + } + } + return &base, errs +} + +func (ms *Modules) resolveIdentities() []error { + defer ms.typeDict.identities.mu.Unlock() + ms.typeDict.identities.mu.Lock() + + var errs []error + + // Across all modules, read the identity values that have been extracted + // from them, and compile them into a "fully resolved" map that means that + // we can look them up based on the 'real' prefix of the module and the + // name of the identity. + for _, mod := range ms.Modules { + for _, i := range mod.Identities() { + keyName, r := newResolvedIdentity(mod, i) + ms.typeDict.identities.dict[keyName] = *r + } + + // Hoist up all identities in our included submodules. + // We could just do a range on ms.SubModules, but that + // might process a submodule that no module included. + for _, in := range mod.Include { + if in.Module == nil { + continue + } + for _, i := range in.Module.Identities() { + keyName, r := newResolvedIdentity(in.Module, i) + ms.typeDict.identities.dict[keyName] = *r + } + } + } + + // Now, we want to create for all identities a view of all of their children. + // A child identity here means an inherited identity. + // + // We start by finding the direct children of all identities using the + // 'base' statement. + for _, i := range ms.typeDict.identities.dict { + if i.Identity.Base != nil { + // This identity inherits from one or more other identities. + + root := RootNode(i.Identity) + for _, b := range i.Identity.Base { + base, baseErr := root.findIdentityBase(b.asString()) + + if baseErr != nil { + errs = append(errs, baseErr...) + continue + } + + // Build up a list of direct children of this identity. + base.Identity.Values = append(base.Identity.Values, i.Identity) + } + } + } + + // Now, we can find all transitive identities by recursively populating + // the children of each identity. + for _, i := range ms.typeDict.identities.dict { + newValues := []*Identity{} + for _, j := range i.Identity.Values { + newValues = addChildren(j, newValues) + } + sort.SliceStable(newValues, func(j, k int) bool { + return newValues[j].Name < newValues[k].Name + }) + i.Identity.Values = newValues + } + + return errs +} diff --git a/src/webui/internal/goyang/pkg/yang/identity_test.go b/src/webui/internal/goyang/pkg/yang/identity_test.go new file mode 100644 index 00000000..631680c9 --- /dev/null +++ b/src/webui/internal/goyang/pkg/yang/identity_test.go @@ -0,0 +1,802 @@ +// Copyright 2016 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/openconfig/gnmi/errdiff" +) + +// inputModule is a mock input YANG module. +type inputModule struct { + name string // The filename of the YANG module. + content string // The contents of the YANG module. +} + +type idrefOut struct { + module string // The module that the identityref is within. + name string // The name of the identityref. + values []string // Names of the identities that the identityref relates to. +} + +// identityOut is the output for a particular identity within the test case. +type identityOut struct { + module string // The module that the identity is within. + name string // The name of the identity. + baseNames []string // The base(s) of the identity as string(s). + values []string // The string names of derived identities. +} + +// identityTestCase is a test case for a module which contains identities. +type identityTestCase struct { + name string + in []inputModule // The set of input modules for the test + identities []identityOut // Slice of the identity values expected + idrefs []idrefOut // Slice of identityref results expected + wantErrSubstr string // wanErrSubstr is a substring of the wanted error. +} + +// getBaseNamesFrom is a utility function for getting the base name(s) of an identity +func getBaseNamesFrom(i *Identity) []string { + baseNames := []string{} + for _, base := range i.Base { + baseNames = append(baseNames, base.Name) + } + return baseNames +} + +// Test cases for basic identity extraction. +var basicTestCases = []identityTestCase{ + { + name: "basic-test-case-1: Check identity is found in module.", + in: []inputModule{ + { + name: "idtest-one", + content: ` + module idtest-one { + namespace "urn:idone"; + prefix "idone"; + + identity TEST_ID; + } + `}, + }, + identities: []identityOut{ + {module: "idtest-one", name: "TEST_ID"}, + }, + }, + { + name: "basic-test-case-2: Check identity with base is found in module.", + in: []inputModule{ + { + name: "idtest-two", + content: ` + module idtest-two { + namespace "urn:idtwo"; + prefix "idone"; + + identity TEST_ID; + identity TEST_ID_TWO; + identity TEST_CHILD { + base TEST_ID; + } + } + `}, + }, + identities: []identityOut{ + {module: "idtest-two", name: "TEST_ID"}, + {module: "idtest-two", name: "TEST_ID_TWO"}, + {module: "idtest-two", name: "TEST_CHILD", baseNames: []string{"TEST_ID"}}, + }, + }, + { + name: "basic-test-case-3: Check identity with multiple bases.", + in: []inputModule{ + { + name: "idtest-three", + content: ` + module idtest-three { + namespace "urn:idthree"; + prefix "idthree"; + + identity BASE_ONE; + identity BASE_TWO; + identity TEST_CHILD_WITH_MULTIPLE_BASES { + base BASE_ONE; + base BASE_TWO; + } + } + `}, + }, + identities: []identityOut{ + {module: "idtest-three", name: "BASE_ONE"}, + {module: "idtest-three", name: "BASE_TWO"}, + {module: "idtest-three", name: "TEST_CHILD_WITH_MULTIPLE_BASES", baseNames: []string{"BASE_ONE", "BASE_TWO"}}, + }, + }, + { + name: "basic-test-case-4: Check identity base is found from submodule.", + in: []inputModule{ + { + name: "idtest-one", + content: ` + module idtest-one { + namespace "urn:idone"; + prefix "idone"; + + include "idtest-one-sub"; + + identity TEST_ID_DERIVED { + base TEST_ID; + } + } + `}, + { + name: "idtest-one-sub", + content: ` + submodule idtest-one-sub { + belongs-to idtest-one { + prefix "idone"; + } + + identity TEST_ID; + } + `}, + }, + identities: []identityOut{ + {module: "idtest-one", name: "TEST_ID"}, + {module: "idtest-one", name: "TEST_ID_DERIVED", baseNames: []string{"TEST_ID"}}, + }, + }, + { + name: "basic-test-case-5: Check identity base is found from module.", + in: []inputModule{ + { + name: "idtest-one", + content: ` + module idtest-one { + namespace "urn:idone"; + prefix "idone"; + + include "idtest-one-sub"; + + identity TEST_ID; + } + `}, + { + name: "idtest-one-sub", + content: ` + submodule idtest-one-sub { + belongs-to idtest-one { + prefix "idone"; + } + + identity TEST_ID_DERIVED { + base TEST_ID; + } + } + `}, + }, + identities: []identityOut{ + {module: "idtest-one", name: "TEST_ID_DERIVED", baseNames: []string{"TEST_ID"}}, + {module: "idtest-one", name: "TEST_ID"}, + }, + }, +} + +// Test the ability to extract identities from a module with the correct base +// statements. +func TestIdentityExtract(t *testing.T) { + for _, tt := range basicTestCases { + ms := NewModules() + for _, mod := range tt.in { + _ = ms.Parse(mod.content, mod.name) + } + + for _, ti := range tt.identities { + _, err := ms.GetModule(ti.module) + + if err != nil { + t.Errorf("Could not parse module : %s", ti.module) + continue + } + + foundIdentity := false + var thisID *Identity + for _, ri := range ms.typeDict.identities.dict { + moduleName := module(ri.Module).Name + if ri.Identity.Name == ti.name && moduleName == ti.module { + foundIdentity = true + thisID = ri.Identity + break + } + } + + if !foundIdentity { + t.Errorf("Could not find identity %s in module %s, identity dict:\n%+v", ti.name, ti.module, ms.typeDict.identities.dict) + continue + } + + actualBaseNames := getBaseNamesFrom(thisID) + if len(ti.baseNames) > 0 { + if diff := cmp.Diff(actualBaseNames, ti.baseNames); diff != "" { + t.Errorf("(-got, +want):\n%s", diff) + } + } else { + if thisID.Base != nil { + t.Errorf("Identity %s had unexpected base(s) %s", thisID.Name, + actualBaseNames) + } + } + } + } +} + +// Test cases for validating that identities can be resolved correctly. +var treeTestCases = []identityTestCase{ + { + name: "tree-test-case-0: Validate identity resolution across submodules", + in: []inputModule{ + { + name: "base.yang", + content: ` + module base { + namespace "urn:base"; + prefix "base"; + + include side; + + identity REMOTE_BASE; + } + `}, + { + name: "remote.yang", + content: ` + submodule side { + belongs-to base { + prefix "r"; + } + + identity LOCAL_REMOTE_BASE { + base r:REMOTE_BASE; + } + } + `}, + }, + identities: []identityOut{ + { + module: "base", + name: "REMOTE_BASE", + values: []string{"LOCAL_REMOTE_BASE"}, + }, + }, + }, + { + name: "tree-test-case-1: Validate identity resolution across modules", + in: []inputModule{ + { + name: "base.yang", + content: ` + module base { + namespace "urn:base"; + prefix "base"; + + import remote { prefix "r"; } + import remote2 { prefix "r2"; } + + identity LOCAL_REMOTE_BASE { + base r:REMOTE_BASE; + } + + identity LOCAL_REMOTE_BASE2 { + base r2:REMOTE_BASE2; + } + } + `}, + { + name: "remote.yang", + content: ` + module remote { + namespace "urn:remote"; + prefix "r"; + + identity REMOTE_BASE; + } + `}, + { + name: "remote2.yang", + content: ` + module remote2 { + namespace "urn:remote2"; + prefix "remote"; + + identity REMOTE_BASE2; + } + `}, + }, + identities: []identityOut{ + { + module: "remote", + name: "REMOTE_BASE", + values: []string{"LOCAL_REMOTE_BASE"}, + }, + { + module: "remote2", + name: "REMOTE_BASE2", + values: []string{"LOCAL_REMOTE_BASE2"}, + }, + { + module: "base", + name: "LOCAL_REMOTE_BASE", + baseNames: []string{"r:REMOTE_BASE"}, + }, + { + module: "base", + name: "LOCAL_REMOTE_BASE2", + baseNames: []string{"r2:REMOTE_BASE2"}, + }, + }, + }, + { + name: "tree-test-case-2: Multi-level inheritance validation.", + in: []inputModule{ + { + name: "base.yang", + content: ` + module base { + namespace "urn:base"; + prefix "base"; + + identity GREATGRANDFATHER; + identity GRANDFATHER { + base "GREATGRANDFATHER"; + } + identity FATHER { + base "GRANDFATHER"; + } + identity SON { + base "FATHER"; + } + identity UNCLE { + base "GRANDFATHER"; + } + identity BROTHER { + base "FATHER"; + } + identity GREATUNCLE { + base "GREATGRANDFATHER"; + } + } + `}, + }, + identities: []identityOut{ + { + module: "base", + name: "GREATGRANDFATHER", + values: []string{ + "BROTHER", // Order is alphabetical + "FATHER", + "GRANDFATHER", + "GREATUNCLE", + "SON", + "UNCLE", + }, + }, + { + module: "base", + name: "GRANDFATHER", + baseNames: []string{"GREATGRANDFATHER"}, + values: []string{"BROTHER", "FATHER", "SON", "UNCLE"}, + }, + { + module: "base", + name: "GREATUNCLE", + baseNames: []string{"GREATGRANDFATHER"}, + }, + { + module: "base", + name: "FATHER", + baseNames: []string{"GRANDFATHER"}, + values: []string{"BROTHER", "SON"}, + }, + { + module: "base", + name: "UNCLE", + baseNames: []string{"GRANDFATHER"}, + }, + { + module: "base", + name: "BROTHER", + baseNames: []string{"FATHER"}, + }, + }, + }, + { + name: "tree-test-case-3", + in: []inputModule{ + { + name: "base.yang", + content: ` + module base { + namespace "urn:base"; + prefix "base"; + + identity BASE; + identity NOTBASE { + base BASE; + } + + leaf idref { + type identityref { + base "BASE"; + } + } + } + `}, + }, + identities: []identityOut{ + { + module: "base", + name: "BASE", + values: []string{"NOTBASE"}, + }, + { + module: "base", + name: "NOTBASE", + baseNames: []string{"BASE"}, + }, + }, + idrefs: []idrefOut{ + { + module: "base", + name: "idref", + values: []string{"NOTBASE"}, + }, + }, + }, + { + name: "tree-test-case-4", + in: []inputModule{ + { + name: "base.yang", + content: ` + module base4 { + namespace "urn:base"; + prefix "base4"; + + identity BASE4; + identity CHILD4 { + base BASE4; + } + + typedef t { + type identityref { + base BASE4; + } + } + + leaf tref { + type t; + } + } + `}, + }, + identities: []identityOut{ + { + module: "base4", + name: "BASE4", + values: []string{"CHILD4"}, + }, + { + module: "base4", + name: "CHILD4", + baseNames: []string{"BASE4"}, + }, + }, + idrefs: []idrefOut{ + { + module: "base4", + name: "tref", + values: []string{"CHILD4"}, + }, + }, + }, + { + name: "tree-test-case-5", + in: []inputModule{ + { + name: "base.yang", + content: ` + module base5 { + namespace "urn:base"; + prefix "base5"; + + identity BASE5A; + identity BASE5B; + + identity FIVE_ONE { + base BASE5A; + } + + identity FIVE_TWO { + base BASE5B; + } + + leaf union { + type union { + type identityref { + base BASE5A; + } + type identityref { + base BASE5B; + } + } + } + }`}, + }, + identities: []identityOut{ + { + module: "base5", + name: "BASE5A", + values: []string{"FIVE_ONE"}, + }, + { + module: "base5", + name: "BASE5B", + values: []string{"FIVE_TWO"}, + }, + }, + idrefs: []idrefOut{ + { + module: "base5", + name: "union", + values: []string{"FIVE_ONE", "FIVE_TWO"}, + }, + }, + }, + { + name: "identity's base can't be found", + in: []inputModule{ + { + name: "idtest", + content: ` + module idtest{ + namespace "urn:idtwo"; + prefix "idone"; + + identity TEST_ID_TWO; + identity TEST_CHILD { + base TEST_ID; + } + } + `}, + }, + identities: []identityOut{ + {module: "idtest", name: "TEST_ID2"}, + }, + wantErrSubstr: "can't resolve the local base", + }, + { + name: "identity's base can't be found in remote", + in: []inputModule{ + { + name: "remote.yang", + content: ` + module remote { + namespace "urn:remote"; + prefix "remote"; + + identity REMOTE_BASE_ESCAPE; + } + `}, + { + name: "base.yang", + content: ` + module base { + namespace "urn:base"; + prefix "base"; + + import remote { prefix "r"; } + + identity LOCAL_REMOTE_BASE { + base r:REMOTE_BASE; + } + } + `}, + }, + identities: []identityOut{ + {module: "base", name: "LOCAL_REMOTE_BASE"}, + }, + wantErrSubstr: "can't resolve remote base", + }, + { + name: "identity's base's module can't be found", + in: []inputModule{ + { + name: "remote.yang", + content: ` + module remote { + namespace "urn:remote"; + prefix "remote"; + + identity REMOTE_BASE; + } + `}, + { + name: "base.yang", + content: ` + module base { + namespace "urn:base"; + prefix "base"; + + import remote { prefix "r"; } + + identity LOCAL_REMOTE_BASE { + base roe:REMOTE_BASE; + } + } + `}, + }, + identities: []identityOut{ + {module: "base", name: "LOCAL_REMOTE_BASE"}, + }, + wantErrSubstr: "can't find external module", + }, +} + +// TestIdentityTree - check inheritance of identities from local and remote +// sources. The Values of an Identity correspond to the values that are +// referenced by that identity, which need to be inherited. +func TestIdentityTree(t *testing.T) { + for _, tt := range treeTestCases { + t.Run(tt.name, func(t *testing.T) { + ms := NewModules() + + for _, mod := range tt.in { + _ = ms.Parse(mod.content, mod.name) + } + + errs := ms.Process() + + var err error + switch len(errs) { + case 1: + err = errs[0] + if diff := errdiff.Substring(err, tt.wantErrSubstr); diff != "" { + t.Fatalf("%s", diff) + } + return + case 0: + if diff := errdiff.Substring(err, tt.wantErrSubstr); diff != "" { + t.Fatalf("%s", diff) + } + default: + t.Fatalf("got multiple errors: %v", errs) + } + + // Walk through the identities that are defined in the test case output + // and validate that they exist, and their base and values are as expected. + for _, chkID := range tt.identities { + m, errs := ms.GetModule(chkID.module) + if errs != nil { + t.Errorf("Couldn't find expected module: %v", errs) + continue + } + + var foundID *Identity + for _, i := range m.Identities { + if i.Name == chkID.name { + foundID = i + break + } + } + + if foundID == nil { + t.Errorf("Couldn't find identity %s in module %s", chkID.name, + chkID.module) + continue + } + + if len(chkID.baseNames) > 0 { + actualBaseNames := getBaseNamesFrom(foundID) + if diff := cmp.Diff(actualBaseNames, chkID.baseNames); diff != "" { + t.Errorf("(-got, +want):\n%s", diff) + } + } + + valueMap := make(map[string]bool) + + for i, val := range chkID.values { + valueMap[val] = false + // Check that IsDefined returns the right result + if !foundID.IsDefined(val) { + t.Errorf("Couldn't find defined value %s for %s", val, chkID.name) + } + + // Check that the values are sorted in a consistent order + if foundID.Values[i].Name != val { + t.Errorf("Invalid order for value #%d. Expecting %s Got %s", i, foundID.Values[i].Name, val) + } + // Check that GetValue returns the right Identity + idval := foundID.GetValue(val) + if idval == nil { + t.Errorf("Couldn't GetValue(%s) for %s", val, chkID.name) + } + } + + // Ensure that IsDefined does not return false positives + if foundID.IsDefined("DoesNotExist") { + t.Errorf("Non-existent value IsDefined for %s", foundID.Name) + } + + if foundID.GetValue("DoesNotExist") != nil { + t.Errorf("Non-existent value GetValue not nil for %s", foundID.Name) + } + + for _, chkv := range foundID.Values { + _, ok := valueMap[chkv.Name] + if !ok { + t.Errorf("Found unexpected value %s for %s", chkv.Name, chkID.name) + continue + } + valueMap[chkv.Name] = true + } + + for k, v := range valueMap { + if v == false { + t.Errorf("Could not find identity %s for %s", k, chkID.name) + } + } + } + + for _, idr := range tt.idrefs { + m, errs := ms.GetModule(idr.module) + if errs != nil { + t.Errorf("Couldn't find expected module %s: %v", idr.module, errs) + continue + } + + if _, ok := m.Dir[idr.name]; !ok { + t.Errorf("Could not find expected identity, got: nil, want: %v", idr.name) + continue + } + + identity := m.Dir[idr.name] + var vals []*Identity + switch len(identity.Type.Type) { + case 0: + vals = identity.Type.IdentityBase.Values + default: + for _, b := range identity.Type.Type { + if b.IdentityBase != nil { + vals = append(vals, b.IdentityBase.Values...) + } + } + } + + var valNames []string + for _, v := range vals { + valNames = append(valNames, v.Name) + } + + if diff := cmp.Diff(idr.values, valNames); diff != "" { + t.Errorf("Identity %s did not have expected values, (-got, +want):\n%s", idr.name, diff) + } + } + }) + } +} diff --git a/src/webui/internal/goyang/pkg/yang/lex.go b/src/webui/internal/goyang/pkg/yang/lex.go new file mode 100644 index 00000000..49a0515b --- /dev/null +++ b/src/webui/internal/goyang/pkg/yang/lex.go @@ -0,0 +1,522 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +// This file implements the lexical tokenization of yang. The lexer returns +// a series of tokens with one of the following codes: +// +// tError // an error was encountered +// tEOF // end-of-file +// tString // A de-quoted string (e.g., "\"bob\"" becomes "bob") +// tUnquoted // An un-quoted string +// '{' +// ';' +// '}' + +import ( + "bytes" + "fmt" + "io" + "os" + "reflect" + "runtime" + "strings" + "unicode/utf8" +) + +const ( + eof = 0x7fffffff // end of file, also an invalid rune + maxErrors = 8 + tooMany = "too many errors...\n" +) + +// stateFn represents a state in the lexer as a function, returning the next +// state the lexer should move to. +type stateFn func(*lexer) stateFn + +// A lexer holds the internal state of the lexer. +type lexer struct { + errout io.Writer // destination for errors, defaults to os.Stderr + errcnt int // number of errors encountered + + file string // name of file we are processing + input string // contents of the file + start int // start position in input of unconsumed data. + pos int // current position in the input. + line int // the current line number (1's based) + col int // the current column number (0 based, add 1 before displaying) + + debug bool // set to true to include internal debugging + inPattern bool // set when parsing the argument to a pattern + items chan *token // channel of scanned items. + tcol int // column with tabs expanded (for multi-line strings) + scol int // starting col of current token + sline int // starting line of current token + state stateFn // current state of the lexer + width int // width of last rune read from input. +} + +// A code is a token code. Single character tokens (i.e., punctuation) +// are represented by their unicode code point. +type code int + +const ( + tEOF = code(-1 - iota) // Reached end of file + tError // An error + tString // A dequoted string + tUnquoted // A non-quoted string +) + +// String returns c as a string. +func (c code) String() string { + switch c { + case tError: + return "Error" + case tString: + return "String" + case tUnquoted: + return "Unquoted" + } + if c < 0 || c > '~' { + return fmt.Sprintf("%d", c) + } + return fmt.Sprintf("'%c'", c) +} + +// A token represents one lexical unit read from the input. +// Line and Col are both 1's based. +type token struct { + code code + Text string // the actual text of the token + File string // the source file the token is from + Line int // the source line number the token is from + Col int // the source column number the token is from (8 space tabs) +} + +// Code returns the code of t. If t is nil, tEOF is returned. +func (t *token) Code() code { + if t == nil { + return tEOF + } + return t.code +} + +// String returns the location, code, and text of t as a string. +func (t *token) String() string { + var s []string + if t.File != "" { + s = append(s, t.File+":") + } + if t.Line != 0 { + s = append(s, fmt.Sprintf("%d:%d:", t.Line, t.Col)) + } + if t.Text == "" { + s = append(s, fmt.Sprintf(" %v", t.code)) + } else { + s = append(s, " ", t.Text) + } + return strings.Join(s, "") +} + +// A note on writing to errout. Errors should always be written to errout +// in a single Write call. The test code makes this assumption for testing +// expected errors. + +// newLexer returns a new lexer, importing into it the provided input and path. +// The provided path should indicate where the source originated. +func newLexer(input, path string) *lexer { + // Force input to be newline terminated. + if len(input) > 0 && input[len(input)-1] != '\n' { + input += "\n" + } + return &lexer{ + file: path, + input: input, + line: 1, // humans start with 1 + items: make(chan *token, maxErrors), + state: lexGround, + errout: os.Stderr, + } +} + +// NextToken returns the next token from the input, returning nil on EOF. +func (l *lexer) NextToken() *token { + for { + select { + case item := <-l.items: + return item + default: + if l.state == nil { + return nil + } + if l.debug { + name := runtime.FuncForPC(reflect.ValueOf(l.state).Pointer()).Name() + name = name[strings.LastIndex(name, ".")+1:] + name = strings.TrimPrefix(name, "lex") + input := l.input[l.pos:] + if len(input) > 8 { + input = input[:8] + "..." + } + fmt.Fprintf(os.Stderr, "%d:%d: state %s %q\n", l.line, l.col+1, name, input) + } + l.state = l.state(l) + } + } +} + +// emit emits the currently parsed token marked with code c using emitText. +func (l *lexer) emit(c code) { + l.emitText(c, l.input[l.start:l.pos]) +} + +// emitText emits text as a token marked with c. +// All input up to the current cursor (pos) is consumed. +func (l *lexer) emitText(c code, text string) { + if l.debug { + fmt.Fprintf(os.Stderr, "%v: %q\n", c, text) + } + select { + case l.items <- &token{ + code: c, + Text: text, + File: l.file, + Line: l.sline, + Col: l.scol + 1, + }: + default: + } + l.consume() +} + +// consume consumes all input to the current cursor. +func (l *lexer) consume() { + l.start = l.pos +} + +// backup steps back one rune. It can be called only immediately after a call +// of next. Backing up over a tab will set tcol to the last position of the +// tab, not where the tab started. This is okay as when we call next again it +// will move tcol back to where it was before backup was called. +func (l *lexer) backup() { + l.pos -= l.width + if l.width > 0 { + l.col-- + l.tcol-- + if l.col < 0 { + // We must have backuped up over a newline. + // Don't bother to figure out the column number + // as the next call to next will reset it to 0. + l.line-- + l.col = 0 + l.tcol = 0 + } + } +} + +// peek returns but does not move past the next rune in the input. backup +// is not supported over peeked characters. +func (l *lexer) peek() rune { + rune := l.next() + l.backup() + return rune +} + +// next returns the next rune in the input. If next encounters the end of input +// then it will return eof. +func (l *lexer) next() (rune rune) { + if l.pos >= len(l.input) { + l.width = 0 + return eof + } + // l.width is what limits more than a single backup. + rune, l.width = utf8.DecodeRuneInString(l.input[l.pos:]) + l.pos += l.width + switch rune { + case '\n': + l.line++ + l.col = 0 + l.tcol = 0 + case '\t': + l.tcol = (l.tcol + 8) & ^7 + l.col++ // should this be l.width? + default: + l.tcol++ + l.col++ // should this be l.width? + } + return rune +} + +// acceptRun moves the cursor forward up to, but not including, the first rune +// not found in the valid set. It returns true if any runes were accepted. +func (l *lexer) acceptRun(valid string) bool { + ret := false + for strings.ContainsRune(valid, l.next()) { + ret = true + } + l.backup() + return ret +} + +// skipTo moves the cursor up to, but not including, s. +// Returns whether s was found in the remaining input. +func (l *lexer) skipTo(s string) bool { + if x := strings.Index(l.input[l.pos:], s); x >= 0 { + l.updateCursor(x) + return true + } + return false +} + +// updateCursor moves the cursor forward n bytes. updateCursor does not +// correctly handle tabs. This is okay as it is only used by skipTo, and skipTo +// is never used to skip to an initial " (which is the only time that tcol is +// necessary, as per YANG's multi-line quoted string requirement). +func (l *lexer) updateCursor(n int) { + s := l.input[l.pos : l.pos+n] + l.pos += n + // we could get away without updating width at all because backup is + // only promised to work after a call to next. + l.width = n + + if c := strings.Count(s, "\n"); c > 0 { + l.line += c + l.col = 0 + } + l.col += utf8.RuneCountInString(s[strings.LastIndex(s, "\n")+1:]) +} + +// Errorf writes an error on l.errout and increments the error count. +// If too many errors (8) are encountered then lexing will stop and +// eof is returned as the next token. +func (l *lexer) Errorf(f string, v ...interface{}) { + buf := &bytes.Buffer{} + + if l.debug { + // For internal debugging, print the file and line number + // of the call to Errorf + _, name, line, _ := runtime.Caller(1) + + fmt.Fprintf(buf, "%s:%d: ", name, line) + } + fmt.Fprintf(buf, "%s:%d:%d: ", l.file, l.line, l.col+1) + fmt.Fprintf(buf, f, v...) + b := buf.Bytes() + if b[len(b)-1] != '\n' { + buf.Write([]byte{'\n'}) + } + l.emit(tError) + l.adderror(buf.Bytes()) +} + +func (l *lexer) ErrorfAt(line, col int, f string, v ...interface{}) { + oline, ocol := l.line, l.col + defer func() { + l.line, l.col = oline, ocol + }() + l.line, l.col = line, col + l.Errorf(f, v...) +} + +// adderror writes out the error string err and increases the error count. +// If more than maxErrors are encountered, a "too many errors" message is +// displayed and processing stops (by clearing the input). +func (l *lexer) adderror(err []byte) { + if l.errcnt == maxErrors { + l.pos = 0 + l.start = 0 + l.input = "" + l.errout.Write([]byte(tooMany)) + l.errcnt++ + return + } else if l.errcnt == maxErrors+1 { + return + } + l.errout.Write(err) + l.errcnt++ +} + +// Below are all the states + +// lexGround is the state when the lexer is not in the middle of a token. The +// ground state is left once the start of a token is found. Pure comment lines +// leave the lexer in the ground state. +func lexGround(l *lexer) stateFn { + l.acceptRun(" \t\r\n") // Skip leading spaces + l.consume() + l.sline = l.line + l.scol = l.col + + switch c := l.peek(); c { + case eof: + return nil + case ';', '{', '}': + l.next() + l.emit(code(c)) + return lexGround + case '\'': + l.next() + l.consume() // Toss the leading ' + if !l.skipTo("'") { + l.ErrorfAt(l.line, l.col-1, `missing closing '`) + return nil + } + l.emit(tString) + l.next() // Either EOF or the matching ' + return lexGround + case '"': + l.next() + return lexQString + case '/': + l.next() + switch l.peek() { + case '/': + // Start of a // comment + if !l.skipTo("\n") { + // Here "\n" should always be found, since we force all + // input to be "\n" terminated. + l.ErrorfAt(l.line, l.col-1, `lexer internal error: all lines should be newline-terminated.`) + return nil + } + return lexGround + case '*': + // Start of a /* comment + if !l.skipTo("*/") { + l.ErrorfAt(l.line, l.col-1, `missing closing */`) + return nil + } + // Now actually skip the */ + l.next() + l.next() + return lexGround + default: + return lexUnquoted + } + case '+': + l.next() + switch l.peek() { + case '"', '\'': + l.emit(tUnquoted) + return lexGround + default: + return lexUnquoted + } + default: + return lexUnquoted + } +} + +// From the YANG standard: +// +// If the double-quoted string contains a line break followed by space +// or tab characters that are used to indent the text according to the +// layout in the YANG file, this leading whitespace is stripped from the +// string, up to and including the column of the double quote character, +// or to the first non-whitespace character, whichever occurs first. In +// this process, a tab character is treated as 8 space characters. +// +// If the double-quoted string contains space or tab characters before a +// line break, this trailing whitespace is stripped from the string. + +// lexQString handles double quoted strings, see the above text on how they +// work. The leading " has already been parsed. +func lexQString(l *lexer) stateFn { + indent := l.tcol // the column our text starts on + over := true // set to false when we are not past the indent + + // Keep track of where the starting quote was + line, col := l.line, l.col-1 + + var text []byte + for { + // l.next can return non-8bit unicode code points. + // c cannot be treated as only a single byte. + switch c := l.next(); c { + case eof: + l.ErrorfAt(line, col, `missing closing "`) + return nil + case '"': + l.emitText(tString, string(text)) + + return lexGround + case '\n': + Loop: + // Trim trailing white space from the line. + for i := len(text); i > 0; { + i-- + switch text[i] { + case ' ', '\t': + text = text[:i] + default: + break Loop + } + } + text = append(text, []byte(string(c))...) + over = false + case ' ', '\t': + // Ignore leading white space up to our indent. + if !over && l.tcol <= indent { + break + } + over = true + text = append(text, []byte(string(c))...) + case '\\': + switch c = l.next(); c { + case 'n': + c = '\n' + case 't': + c = '\t' + case '"': + case '\\': + default: + // Strings are use both in descriptions and + // in patterns. In strings only \n, \t, \" + // and \\ are defined. In patterns the \ + // can either mean to escape the character + // (e..g., \{) or to be part of of a special + // sequence such as \S. + if !l.inPattern { + l.ErrorfAt(l.line, l.col-2, `invalid escape sequence: \`+string(c)) + } + text = append(text, '\\') + } + fallthrough + default: + over = true + text = append(text, []byte(string(c))...) + } + } +} + +// lexUnquoted reads one identifier/number/un-quoted-string/... +// +// From https://tools.ietf.org/html/rfc7950#section-6.1.3: +// An unquoted string is any sequence of characters that does not +// contain any space, tab, carriage return, or line feed characters, a +// single or double quote character, a semicolon (";"), braces ("{" or +// "}"), or comment sequences ("//", "/*", or "*/"). +func lexUnquoted(l *lexer) stateFn { + for { + switch c := l.peek(); c { + // TODO: Support detection of comment immediately following an + // unquoted string, likely through supporting two peeks instead + // of just one. + case ' ', '\r', '\n', '\t', ';', '"', '\'', '{', '}', eof: + l.emit(tUnquoted) + return lexGround + default: + l.next() + } + } +} diff --git a/src/webui/internal/goyang/pkg/yang/lex_test.go b/src/webui/internal/goyang/pkg/yang/lex_test.go new file mode 100644 index 00000000..5d1c56a3 --- /dev/null +++ b/src/webui/internal/goyang/pkg/yang/lex_test.go @@ -0,0 +1,309 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +import ( + "bytes" + "runtime" + "testing" +) + +// line returns the line number from which it was called. +// Used to mark where test entries are in the source. +func line() int { + _, _, line, _ := runtime.Caller(1) + return line + +} + +// Equal returns true if t and tt are equal (have the same code and text), +// false if not. +func (t *token) Equal(tt *token) bool { + return t.code == tt.code && t.Text == tt.Text +} + +// T Creates a new token from the provided code and string. +func T(c code, text string) *token { return &token{code: c, Text: text} } + +func TestLex(t *testing.T) { +Tests: + for _, tt := range []struct { + line int + in string + tokens []*token + }{ + {line(), "", nil}, + {line(), "bob", []*token{ + T(tUnquoted, "bob"), + }}, + {line(), "bob //bob", []*token{ + T(tUnquoted, "bob"), + }}, + {line(), "/the/path", []*token{ + T(tUnquoted, "/the/path"), + }}, + {line(), "+the/path", []*token{ + T(tUnquoted, "+the/path"), + }}, + {line(), "+the+path", []*token{ + T(tUnquoted, "+the+path"), + }}, + {line(), "+ the/path", []*token{ + T(tUnquoted, "+"), + T(tUnquoted, "the/path"), + }}, + {line(), "{bob}", []*token{ + T('{', "{"), + T(tUnquoted, "bob"), + T('}', "}"), + }}, + {line(), "bob;fred", []*token{ + T(tUnquoted, "bob"), + T(';', ";"), + T(tUnquoted, "fred"), + }}, + {line(), "\t bob\t; fred ", []*token{ + T(tUnquoted, "bob"), + T(';', ";"), + T(tUnquoted, "fred"), + }}, + {line(), ` + bob; + fred +`, []*token{ + T(tUnquoted, "bob"), + T(';', ";"), + T(tUnquoted, "fred"), + }}, + {line(), ` + // This is a comment + bob; + fred +`, []*token{ + T(tUnquoted, "bob"), + T(';', ";"), + T(tUnquoted, "fred"), + }}, + {line(), ` + /* This is a comment */ + bob; + fred +`, []*token{ + T(tUnquoted, "bob"), + T(';', ";"), + T(tUnquoted, "fred"), + }}, + {line(), ` + /* + * This is a comment + */ + bob; + fred +`, []*token{ + T(tUnquoted, "bob"), + T(';', ";"), + T(tUnquoted, "fred"), + }}, + {line(), ` + bob; // This is bob + fred // This is fred +`, []*token{ + T(tUnquoted, "bob"), + T(';', ";"), + T(tUnquoted, "fred"), + }}, + {line(), ` +pattern '[a-zA-Z0-9!#$%&'+"'"+'*+/=?^_` + "`" + `{|}~-]+'; +`, []*token{ + T(tUnquoted, "pattern"), + T(tString, "[a-zA-Z0-9!#$%&"), + T(tUnquoted, "+"), + T(tString, "'"), + T(tUnquoted, "+"), + T(tString, "*+/=?^_`{|}~-]+"), + T(';', ";"), + }}, + {line(), ` +// tab indent both lines + "Broken + line" +`, []*token{ + T(tString, "Broken\nline"), + }}, + {line(), ` +// tab indent both lines, trailing spaces and tabs + "Broken + line" +`, []*token{ + T(tString, "Broken\nline"), + }}, + {line(), ` +// tab indent first line, spaces and tab second line + "Broken + line" +`, []*token{ + T(tString, "Broken\nline"), + }}, + {line(), ` +// tab indent first line, spaces second linfe + "Broken + line" +`, []*token{ + T(tString, "Broken\nline"), + }}, + {line(), ` +// extra space in second line + "Broken + space" +`, []*token{ + T(tString, "Broken\n space"), + }}, + {line(), ` +// spaces first line, tab on second + "Broken + space" +`, []*token{ + T(tString, "Broken\nspace"), + }}, + {line(), ` +// Odd indenting + "Broken + space" +`, []*token{ + T(tString, "Broken\nspace"), + }}, + {line(), ` +// Odd indenting + "Broken \t + space with trailing space" +`, []*token{ + T(tString, "Broken\nspace with trailing space"), + }}, + } { + l := newLexer(tt.in, "") + // l.debug = true + for i := 0; ; i++ { + token := l.NextToken() + if token == nil { + if len(tt.tokens) != i { + t.Errorf("%d: got %d tokens, want %d", tt.line, i, len(tt.tokens)) + } + continue Tests + } + if len(tt.tokens) > i && !token.Equal(tt.tokens[i]) { + t.Errorf("%d, %d: got (%v, %q) want (%v, %q)", tt.line, i, token.code, token.Text, tt.tokens[i].code, tt.tokens[i].Text) + } + } + } +} + +func TestLexErrors(t *testing.T) { + for _, tt := range []struct { + line int + in string + errcnt int + errs string + }{ + {line(), + `1: "no closing quote`, + 1, + `test.yang:1:4: missing closing " +`, + }, + {line(), + `1: on another line +2: there is "no closing quote\"`, + 1, + `test.yang:2:13: missing closing " +`, + }, + {line(), + `1: +2: "Mares eat oats," +3: "And does eat oats," +4: "But little lambs eat ivy," +5: "and if I were a little lamb," +6: "I'ld eat ivy too. +5: So saith the sage.`, + 1, + `test.yang:6:4: missing closing " +`, + }, + {line(), + `1: +2: "Quoted string" +3: "Missing quote +4: "Another quoted string" +`, + 1, + `test.yang:4:26: missing closing " +`, + }, + {line(), + `1: +2: 'Quoted string' +3: 'Missing quote +4: 'Another quoted string' +`, + 1, + `test.yang:4:26: missing closing ' +`, + }, + {line(), + `1: "Quoted string\" +2: Missing end-quote\q`, + 2, + `test.yang:2:21: invalid escape sequence: \q +test.yang:1:4: missing closing " +`, + }, + {line(), + `/* This is a comment +without an ending. +`, + 1, + `test.yang:1:1: missing closing */ +`, + }, + {line(), + // Two errors too many. + `yang-version 1.1;description "\/\/\/\/\/\/\/\/\/\/";`, + 9, + `test.yang:1:31: invalid escape sequence: \/ +test.yang:1:33: invalid escape sequence: \/ +test.yang:1:35: invalid escape sequence: \/ +test.yang:1:37: invalid escape sequence: \/ +test.yang:1:39: invalid escape sequence: \/ +test.yang:1:41: invalid escape sequence: \/ +test.yang:1:43: invalid escape sequence: \/ +test.yang:1:45: invalid escape sequence: \/ +` + tooMany, + }, + } { + l := newLexer(tt.in, "test.yang") + errbuf := &bytes.Buffer{} + l.errout = errbuf + for l.NextToken() != nil { + + } + if l.errcnt != tt.errcnt { + t.Errorf("%d: got %d errors, want %v", tt.line, l.errcnt, tt.errcnt) + } + errs := errbuf.String() + if errs != tt.errs { + t.Errorf("%d: got errors:\n%s\nwant:\n%s", tt.line, errs, tt.errs) + } + } +} diff --git a/src/webui/internal/goyang/pkg/yang/marshal_test.go b/src/webui/internal/goyang/pkg/yang/marshal_test.go new file mode 100644 index 00000000..6151aa15 --- /dev/null +++ b/src/webui/internal/goyang/pkg/yang/marshal_test.go @@ -0,0 +1,832 @@ +// Copyright 2017 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/kylelemons/godebug/pretty" +) + +func TestMarshalJSON(t *testing.T) { + tests := []struct { + name string + in *Entry + want string + wantErr bool + }{{ + name: "simple leaf entry", + in: &Entry{ + Name: "leaf", + Node: &Leaf{ + Name: "leaf", + }, + Description: "This is a fake leaf.", + Default: []string{"default-leaf-value"}, + Errors: []error{fmt.Errorf("error one")}, + Kind: LeafEntry, + Config: TSTrue, + Prefix: &Value{ + Name: "ModulePrefix", + Source: &Statement{ + Keyword: "prefix", + Argument: "ModulePrefix", + HasArgument: true, + }, + }, + Type: &YangType{ + Name: "string", + Kind: Ystring, + Default: "string-value", + }, + Annotation: map[string]interface{}{ + "fish": struct{ Side string }{"chips"}, + }, + }, + want: `{ + "Name": "leaf", + "Description": "This is a fake leaf.", + "Default": [ + "default-leaf-value" + ], + "Kind": 0, + "Config": 1, + "Prefix": { + "Name": "ModulePrefix", + "Source": { + "Keyword": "prefix", + "HasArgument": true, + "Argument": "ModulePrefix" + } + }, + "Type": { + "Name": "string", + "Kind": 18, + "Default": "string-value" + }, + "Annotation": { + "fish": { + "Side": "chips" + } + } +}`, + }, { + name: "simple container entry with parent", + in: &Entry{ + Name: "container", + Node: &Container{ + Name: "container", + }, + Kind: DirectoryEntry, + Config: TSFalse, + Prefix: &Value{ + Name: "ModulePrefix", + Source: &Statement{ + Keyword: "prefix", + Argument: "ModulePrefix", + HasArgument: true, + }, + }, + Dir: map[string]*Entry{ + "child": { + Name: "leaf", + Node: &Leaf{ + Name: "leaf", + }, + Kind: LeafEntry, + Config: TSUnset, + Prefix: &Value{ + Name: "ModulePrefix", + Source: &Statement{ + Keyword: "prefix", + Argument: "ModulePrefix", + HasArgument: true, + }, + }, + Type: &YangType{ + Name: "union", + Type: []*YangType{{ + Name: "string", + Pattern: []string{"^a.*$"}, + Kind: Ystring, + Length: YangRange{{ + Min: FromInt(10), + Max: FromInt(20), + }}, + }}, + }, + }, + }, + Augments: []*Entry{{ + Name: "augment", + Node: &Leaf{ + Name: "leaf", + }, + Kind: LeafEntry, + Config: TSFalse, + Prefix: &Value{ + Name: "ModulePrefix", + Source: &Statement{ + Keyword: "prefix", + Argument: "ModulePrefix", + HasArgument: true, + }, + }, + }}, + Augmented: []*Entry{{ + Name: "augmented", + Node: &Leaf{ + Name: "leaf", + }, + Kind: LeafEntry, + Config: TSTrue, + Prefix: &Value{ + Name: "ModulePrefix", + Source: &Statement{ + Keyword: "prefix", + Argument: "ModulePrefix", + HasArgument: true, + }, + }, + }}, + Uses: []*UsesStmt{{ + Uses: &Uses{ + Name: "grouping", + }, + Grouping: &Entry{ + Name: "grouping", + Node: &Grouping{ + Name: "grouping", + Leaf: []*Leaf{{ + Name: "groupingLeaf", + }}, + }, + Config: TSFalse, + Prefix: &Value{ + Name: "ModulePrefix", + Source: &Statement{ + Keyword: "prefix", + Argument: "ModulePrefix", + HasArgument: true, + }, + }, + }, + }}, + }, + want: `{ + "Name": "container", + "Kind": 1, + "Config": 2, + "Prefix": { + "Name": "ModulePrefix", + "Source": { + "Keyword": "prefix", + "HasArgument": true, + "Argument": "ModulePrefix" + } + }, + "Dir": { + "child": { + "Name": "leaf", + "Kind": 0, + "Config": 0, + "Prefix": { + "Name": "ModulePrefix", + "Source": { + "Keyword": "prefix", + "HasArgument": true, + "Argument": "ModulePrefix" + } + }, + "Type": { + "Name": "union", + "Kind": 0, + "Type": [ + { + "Name": "string", + "Kind": 18, + "Length": [ + { + "Min": { + "Value": 10, + "FractionDigits": 0, + "Negative": false + }, + "Max": { + "Value": 20, + "FractionDigits": 0, + "Negative": false + } + } + ], + "Pattern": [ + "^a.*$" + ] + } + ] + } + } + }, + "Augments": [ + { + "Name": "augment", + "Kind": 0, + "Config": 2, + "Prefix": { + "Name": "ModulePrefix", + "Source": { + "Keyword": "prefix", + "HasArgument": true, + "Argument": "ModulePrefix" + } + } + } + ], + "Augmented": [ + { + "Name": "augmented", + "Kind": 0, + "Config": 1, + "Prefix": { + "Name": "ModulePrefix", + "Source": { + "Keyword": "prefix", + "HasArgument": true, + "Argument": "ModulePrefix" + } + } + } + ], + "Uses": [ + { + "Uses": { + "Name": "grouping" + }, + "Grouping": { + "Name": "grouping", + "Kind": 0, + "Config": 2, + "Prefix": { + "Name": "ModulePrefix", + "Source": { + "Keyword": "prefix", + "HasArgument": true, + "Argument": "ModulePrefix" + } + } + } + } + ] +}`, + }, { + name: "Entry with list and leaflist", + in: &Entry{ + Name: "list", + Kind: DirectoryEntry, + Config: TSUnset, + Dir: map[string]*Entry{ + "leaf": { + Name: "string", + Kind: LeafEntry, + }, + "leaf-list": { + Name: "leaf-list", + ListAttr: &ListAttr{ + MaxElements: 18446744073709551615, + MinElements: 0, + }, + }, + }, + ListAttr: &ListAttr{ + MaxElements: 42, + MinElements: 48, + }, + Identities: []*Identity{{ + Name: "ID_ONE", + }}, + Exts: []*Statement{{ + Keyword: "some-extension:ext", + Argument: "ext-value", + HasArgument: true, + }}, + }, + want: `{ + "Name": "list", + "Kind": 1, + "Config": 0, + "Dir": { + "leaf": { + "Name": "string", + "Kind": 0, + "Config": 0 + }, + "leaf-list": { + "Name": "leaf-list", + "Kind": 0, + "Config": 0, + "ListAttr": { + "MinElements": 0, + "MaxElements": 18446744073709551615, + "OrderedBy": null, + "OrderedByUser": false + } + } + }, + "Exts": [ + { + "Keyword": "some-extension:ext", + "HasArgument": true, + "Argument": "ext-value" + } + ], + "ListAttr": { + "MinElements": 48, + "MaxElements": 42, + "OrderedBy": null, + "OrderedByUser": false + }, + "Identities": [ + { + "Name": "ID_ONE" + } + ] +}`, + }} + + for _, tt := range tests { + got, err := json.MarshalIndent(tt.in, "", " ") + if err != nil { + if !tt.wantErr { + t.Errorf("%s: json.MarshalIndent(%v, ...): got unexpected error: %v", tt.name, tt.in, err) + } + continue + } + + if diff := pretty.Compare(string(got), tt.want); diff != "" { + t.Errorf("%s: jsonMarshalIndent(%v, ...): did not get expected JSON, diff(-got,+want):\n%s", tt.name, tt.in, diff) + } + } +} + +func TestParseAndMarshal(t *testing.T) { + tests := []struct { + name string + in []inputModule + want map[string]string + }{{ + name: "simple single module", + in: []inputModule{{ + name: "test.yang", + content: `module test { + prefix "t"; + namespace "urn:t"; + + typedef foobar { + type string { + length "10"; + } + } + + identity "BASE"; + identity "DERIVED" { base "BASE"; } + + container test { + list a { + key "k"; + min-elements 10; + max-elements "unbounded"; + leaf k { type string; } + + leaf bar { + type foobar; + } + } + + leaf d { + type decimal64 { + fraction-digits 8; + } + } + + leaf-list zip { + type string; + } + + leaf-list zip2 { + max-elements 1000; + type string; + } + + leaf x { + type union { + type string; + type identityref { + base "BASE"; + } + } + } + } + }`, + }}, + want: map[string]string{ + "test": `{ + "Name": "test", + "Kind": 1, + "Config": 0, + "Prefix": { + "Name": "t", + "Source": { + "Keyword": "prefix", + "HasArgument": true, + "Argument": "t" + } + }, + "Dir": { + "test": { + "Name": "test", + "Kind": 1, + "Config": 0, + "Prefix": { + "Name": "t", + "Source": { + "Keyword": "prefix", + "HasArgument": true, + "Argument": "t" + } + }, + "Dir": { + "a": { + "Name": "a", + "Kind": 1, + "Config": 0, + "Prefix": { + "Name": "t", + "Source": { + "Keyword": "prefix", + "HasArgument": true, + "Argument": "t" + } + }, + "Dir": { + "bar": { + "Name": "bar", + "Kind": 0, + "Config": 0, + "Prefix": { + "Name": "t", + "Source": { + "Keyword": "prefix", + "HasArgument": true, + "Argument": "t" + } + }, + "Type": { + "Name": "foobar", + "Kind": 18, + "Length": [ + { + "Min": { + "Value": 10, + "FractionDigits": 0, + "Negative": false + }, + "Max": { + "Value": 10, + "FractionDigits": 0, + "Negative": false + } + } + ] + } + }, + "k": { + "Name": "k", + "Kind": 0, + "Config": 0, + "Prefix": { + "Name": "t", + "Source": { + "Keyword": "prefix", + "HasArgument": true, + "Argument": "t" + } + }, + "Type": { + "Name": "string", + "Kind": 18 + } + } + }, + "Key": "k", + "ListAttr": { + "MinElements": 10, + "MaxElements": 18446744073709551615, + "OrderedBy": null, + "OrderedByUser": false + } + }, + "d": { + "Name": "d", + "Kind": 0, + "Config": 0, + "Prefix": { + "Name": "t", + "Source": { + "Keyword": "prefix", + "HasArgument": true, + "Argument": "t" + } + }, + "Type": { + "Name": "decimal64", + "Kind": 12, + "FractionDigits": 8, + "Range": [ + { + "Min": { + "Value": 9223372036854775808, + "FractionDigits": 8, + "Negative": true + }, + "Max": { + "Value": 9223372036854775807, + "FractionDigits": 8, + "Negative": false + } + } + ] + } + }, + "x": { + "Name": "x", + "Kind": 0, + "Config": 0, + "Prefix": { + "Name": "t", + "Source": { + "Keyword": "prefix", + "HasArgument": true, + "Argument": "t" + } + }, + "Type": { + "Name": "union", + "Kind": 19, + "Type": [ + { + "Name": "string", + "Kind": 18 + }, + { + "Name": "identityref", + "Kind": 15, + "IdentityBase": { + "Name": "BASE", + "Values": [ + { + "Name": "DERIVED" + } + ] + } + } + ] + } + }, + "zip": { + "Name": "zip", + "Kind": 0, + "Config": 0, + "Prefix": { + "Name": "t", + "Source": { + "Keyword": "prefix", + "HasArgument": true, + "Argument": "t" + } + }, + "Type": { + "Name": "string", + "Kind": 18 + }, + "ListAttr": { + "MinElements": 0, + "MaxElements": 18446744073709551615, + "OrderedBy": null, + "OrderedByUser": false + } + }, + "zip2": { + "Name": "zip2", + "Kind": 0, + "Config": 0, + "Prefix": { + "Name": "t", + "Source": { + "Keyword": "prefix", + "HasArgument": true, + "Argument": "t" + } + }, + "Type": { + "Name": "string", + "Kind": 18 + }, + "ListAttr": { + "MinElements": 0, + "MaxElements": 1000, + "OrderedBy": null, + "OrderedByUser": false + } + } + } + } + }, + "Identities": [ + { + "Name": "BASE", + "Values": [ + { + "Name": "DERIVED" + } + ] + }, + { + "Name": "DERIVED" + } + ], + "extra-unstable": { + "namespace": [ + { + "Name": "urn:t", + "Source": { + "Keyword": "namespace", + "HasArgument": true, + "Argument": "urn:t" + } + } + ] + } +}`, + }, + }, { + name: "multiple modules with extension", + in: []inputModule{{ + name: "ext.yang", + content: `module ext { + prefix "e"; + namespace "urn:e"; + + extension foobar { + argument "baz"; + } + }`, + }, { + name: "test.yang", + content: `module test { + prefix "t"; + namespace "urn:t"; + + import ext { prefix ext; } + + leaf t { + type string; + ext:foobar "marked"; + } + }`, + }}, + want: map[string]string{ + "test": `{ + "Name": "test", + "Kind": 1, + "Config": 0, + "Prefix": { + "Name": "t", + "Source": { + "Keyword": "prefix", + "HasArgument": true, + "Argument": "t" + } + }, + "Dir": { + "t": { + "Name": "t", + "Kind": 0, + "Config": 0, + "Prefix": { + "Name": "t", + "Source": { + "Keyword": "prefix", + "HasArgument": true, + "Argument": "t" + } + }, + "Type": { + "Name": "string", + "Kind": 18 + }, + "Exts": [ + { + "Keyword": "ext:foobar", + "HasArgument": true, + "Argument": "marked" + } + ] + } + }, + "extra-unstable": { + "namespace": [ + { + "Name": "urn:t", + "Source": { + "Keyword": "namespace", + "HasArgument": true, + "Argument": "urn:t" + } + } + ] + } +}`, + "ext": `{ + "Name": "ext", + "Kind": 1, + "Config": 0, + "Prefix": { + "Name": "e", + "Source": { + "Keyword": "prefix", + "HasArgument": true, + "Argument": "e" + } + }, + "extra-unstable": { + "extension": [ + { + "Name": "foobar", + "Argument": { + "Name": "baz" + } + } + ], + "namespace": [ + { + "Name": "urn:e", + "Source": { + "Keyword": "namespace", + "HasArgument": true, + "Argument": "urn:e" + } + } + ] + } +}`, + }, + }} + + for _, tt := range tests { + ms := NewModules() + + for _, mod := range tt.in { + if err := ms.Parse(mod.content, mod.name); err != nil { + t.Errorf("%s: ms.Parse(..., %v): parsing error with module: %v", tt.name, mod.name, err) + continue + } + + if errs := ms.Process(); len(errs) != 0 { + t.Errorf("%s: ms.Process(): could not parse modules: %v", tt.name, errs) + continue + } + + entries := make(map[string]*Entry) + for _, m := range ms.Modules { + if _, ok := entries[m.Name]; !ok { + entries[m.Name] = ToEntry(m) + + got, err := json.MarshalIndent(entries[m.Name], "", " ") + if err != nil { + t.Errorf("%s: json.MarshalIndent(...): got unexpected error: %v", tt.name, err) + continue + } + + if diff := cmp.Diff(string(got), tt.want[m.Name]); diff != "" { + t.Errorf("%s: json.MarshalIndent(...): did not get expected JSON, diff(-got,+want):\n%s", tt.name, diff) + } + } + } + } + } +} diff --git a/src/webui/internal/goyang/pkg/yang/modules.go b/src/webui/internal/goyang/pkg/yang/modules.go new file mode 100644 index 00000000..ab543d25 --- /dev/null +++ b/src/webui/internal/goyang/pkg/yang/modules.go @@ -0,0 +1,466 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +// This file implements the Modules type. This includes the processing of +// include and import statements, which must be done prior to turning the +// module into an Entry tree. + +import ( + "fmt" + "sync" +) + +// Modules contains information about all the top level modules and +// submodules that are read into it via its Read method. +type Modules struct { + Modules map[string]*Module // All "module" nodes + SubModules map[string]*Module // All "submodule" nodes + includes map[*Module]bool // Modules we have already done include on + nsMu sync.Mutex // nsMu protects the byNS map. + byNS map[string]*Module // Cache of namespace lookup + typeDict *typeDictionary // Cache for type definitions. + entryCacheMu sync.RWMutex // entryCacheMu protects the entryCache map. + // entryCache is used to prevent unnecessary recursion into previously + // converted nodes. To access the map, use the get/set/ClearEntryCache() + // thread-safe functions. + entryCache map[Node]*Entry + // mergedSubmodule is used to prevent re-parsing a submodule that has already + // been merged into a particular entity when circular dependencies are being + // ignored. The keys of the map are a string that is formed by concatenating + // the name of the including (sub)module and the included submodule. + mergedSubmodule map[string]bool + // ParseOptions sets the options for the current YANG module parsing. It can be + // directly set by the caller to influence how goyang will behave in the presence + // of certain exceptional cases. + ParseOptions Options + // Path is the list of directories to look for .yang files in. + Path []string + // pathMap is used to prevent adding dups in Path. + pathMap map[string]bool +} + +// NewModules returns a newly created and initialized Modules. +func NewModules() *Modules { + ms := &Modules{ + Modules: map[string]*Module{}, + SubModules: map[string]*Module{}, + includes: map[*Module]bool{}, + byNS: map[string]*Module{}, + typeDict: newTypeDictionary(), + mergedSubmodule: map[string]bool{}, + entryCache: map[Node]*Entry{}, + pathMap: map[string]bool{}, + } + return ms +} + +// Read reads the named yang module into ms. The name can be the name of an +// actual .yang file or a module/submodule name (the base name of a .yang file, +// e.g., foo.yang is named foo). An error is returned if the file is not +// found or there was an error parsing the file. +func (ms *Modules) Read(name string) error { + name, data, err := ms.findFile(name) + if err != nil { + return err + } + return ms.Parse(data, name) +} + +// Parse parses data as YANG source and adds it to ms. The name should reflect +// the source of data. +// Note: If an error is returned, valid modules might still have been added to +// the Modules cache. +func (ms *Modules) Parse(data, name string) error { + ss, err := Parse(data, name) + if err != nil { + return err + } + for _, s := range ss { + n, err := buildASTWithTypeDict(s, ms.typeDict) + if err != nil { + return err + } + if err := ms.add(n); err != nil { + return err + } + } + return nil +} + +// GetModule returns the Entry of the module named by name. GetModule will +// search for and read the file named name + ".yang" if it cannot satisfy the +// request from what it has currently read. +// +// GetModule is a convenience function for calling Read and Process, and +// then looking up the module name. It is safe to call Read and Process prior +// to calling GetModule. +func (ms *Modules) GetModule(name string) (*Entry, []error) { + if ms.Modules[name] == nil { + if err := ms.Read(name); err != nil { + return nil, []error{err} + } + if ms.Modules[name] == nil { + return nil, []error{fmt.Errorf("module not found: %s", name)} + } + } + // Make sure that the modules have all been processed and have no + // errors. + if errs := ms.Process(); len(errs) != 0 { + return nil, errs + } + return ToEntry(ms.Modules[name]), nil +} + +// GetModule optionally reads in a set of YANG source files, named by sources, +// and then returns the Entry for the module named module. If sources is +// missing, or the named module is not yet known, GetModule searches for name +// with the suffix ".yang". GetModule either returns an Entry or returns +// one or more errors. +// +// GetModule is a convenience function for calling NewModules, Read, and Process, +// and then looking up the module name. +func GetModule(name string, sources ...string) (*Entry, []error) { + var errs []error + ms := NewModules() + for _, source := range sources { + if err := ms.Read(source); err != nil { + errs = append(errs, err) + } + } + if len(errs) > 0 { + return nil, errs + } + return ms.GetModule(name) +} + +// add adds Node n to ms. n must be assignable to *Module (i.e., it is a +// "module" or "submodule"). An error is returned if n is a duplicate of +// a name already added, or n is not assignable to *Module. +func (ms *Modules) add(n Node) error { + var m map[string]*Module + + name := n.NName() + kind := n.Kind() + switch kind { + case "module": + m = ms.Modules + case "submodule": + m = ms.SubModules + default: + return fmt.Errorf("not a module or submodule: %s is of type %s", name, kind) + } + + mod := n.(*Module) + fullName := mod.FullName() + mod.Modules = ms + + if o := m[fullName]; o != nil { + return fmt.Errorf("duplicate %s %s at %s and %s", kind, fullName, Source(o), Source(n)) + } + m[fullName] = mod + if fullName == name { + return nil + } + + // Add us to the map if: + // name has not been added before + // fullname is a more recent version of the entry. + if o := m[name]; o == nil || o.FullName() < fullName { + m[name] = mod + } + return nil +} + +// FindModule returns the Module/Submodule specified by n, which must be a +// *Include or *Import. If n is a *Include then a submodule is returned. If n +// is a *Import then a module is returned. +func (ms *Modules) FindModule(n Node) *Module { + name := n.NName() + rev := name + var m map[string]*Module + + switch i := n.(type) { + case *Include: + m = ms.SubModules + if i.RevisionDate != nil { + rev = name + "@" + i.RevisionDate.Name + } + // TODO(borman): we should check the BelongsTo field below? + case *Import: + m = ms.Modules + if i.RevisionDate != nil { + rev = name + "@" + i.RevisionDate.Name + } + default: + return nil + } + if n := m[rev]; n != nil { + return n + } + if n := m[name]; n != nil { + return n + } + + // Try to read first a module by revision + if err := ms.Read(rev); err != nil { + // if failed, try to read a module by its bare name + if err := ms.Read(name); err != nil { + return nil + } + } + if n := m[rev]; n != nil { + return n + } + return m[name] +} + +// FindModuleByNamespace either returns the Module specified by the namespace +// or returns an error. +func (ms *Modules) FindModuleByNamespace(ns string) (*Module, error) { + // Protect the byNS map from concurrent accesses + ms.nsMu.Lock() + defer ms.nsMu.Unlock() + + if m, ok := ms.byNS[ns]; ok { + return m, nil + } + var found *Module + for _, m := range ms.Modules { + if m.Namespace.Name == ns { + switch { + case m == found: + case found != nil: + return nil, fmt.Errorf("namespace %s matches two or more modules (%s, %s)", + ns, found.Name, m.Name) + default: + found = m + } + } + } + if found == nil { + return nil, fmt.Errorf("%q: no such namespace", ns) + } + // Don't cache negative results because new modules could be added. + ms.byNS[ns] = found + return found, nil +} + +// process satisfies all include and import statements and verifies that all +// link ref paths reference a known node. If an import or include references +// a [sub]module that is not already known, Process will search for a .yang +// file that contains it, returning an error if not found. An error is also +// returned if there is an unknown link ref path or other parsing errors. +// +// Process must be called once all the source modules have been read in and +// prior to converting Node tree into an Entry tree. +func (ms *Modules) process() []error { + var mods []*Module + var errs []error + + // Collect the list of modules we know about now so when we range + // below we don't pick up new modules. We assume the user tells + // us explicitly which modules they are interested in. + for _, m := range ms.Modules { + mods = append(mods, m) + } + for _, m := range mods { + if err := ms.include(m); err != nil { + errs = append(errs, err) + } + } + + // Resolve identities before resolving typedefs, otherwise when we resolve a + // typedef that has an identityref within it, then the identity dictionary + // has not yet been built. + errs = append(errs, ms.resolveIdentities()...) + // Append any errors found trying to resolve typedefs + errs = append(errs, ms.typeDict.resolveTypedefs()...) + + return errs +} + +// Process processes all the modules and submodules that have been read into +// ms. While processing, if an include or import is found for which there +// is no matching module, Process attempts to locate the source file (using +// Path) and automatically load them. If a file cannot be found then an +// error is returned. When looking for a source file, Process searches for a +// file using the module's or submodule's name with ".yang" appended. After +// searching the current directory, the directories in Path are searched. +// +// Process builds Entry trees for each modules and submodules in ms. These +// trees are accessed using the ToEntry function. Process does augmentation +// on Entry trees once all the modules and submodules in ms have been built. +// Following augmentation, Process inserts implied case statements. I.e., +// +// choice interface-type { +// container ethernet { ... } +// } +// +// has a case statement inserted to become: +// +// choice interface-type { +// case ethernet { +// container ethernet { ... } +// } +// } +// +// Process may return multiple errors if multiple errors were encountered +// while processing. Even though multiple errors may be returned, this does +// not mean these are all the errors. Process will terminate processing early +// based on the type and location of the error. +func (ms *Modules) Process() []error { + // Reset globals that may remain stale if multiple Process() calls are + // made by the same caller. + ms.mergedSubmodule = map[string]bool{} + ms.ClearEntryCache() + + errs := ms.process() + if len(errs) > 0 { + return errorSort(errs) + } + + for _, m := range ms.Modules { + errs = append(errs, ToEntry(m).GetErrors()...) + } + for _, m := range ms.SubModules { + errs = append(errs, ToEntry(m).GetErrors()...) + } + + if len(errs) > 0 { + return errorSort(errs) + } + + // Now handle all the augments. We don't have a good way to know + // what order to process them in, so repeat until no progress is made + + mods := make([]*Module, 0, len(ms.Modules)+len(ms.SubModules)) + for _, m := range ms.Modules { + mods = append(mods, m) + } + for _, m := range ms.SubModules { + mods = append(mods, m) + } + for len(mods) > 0 { + var processed int + for i := 0; i < len(mods); { + m := mods[i] + p, s := ToEntry(m).Augment(false) + processed += p + if s == 0 { + mods[i] = mods[len(mods)-1] + mods = mods[:len(mods)-1] + continue + } + i++ + } + if processed == 0 { + break + } + } + + // Now fix up all the choice statements to add in the missing case + // statements. + for _, m := range ms.Modules { + ToEntry(m).FixChoice() + } + for _, m := range ms.SubModules { + ToEntry(m).FixChoice() + } + + // Go through any modules that have remaining augments and collect + // the errors. + for _, m := range mods { + ToEntry(m).Augment(true) + errs = append(errs, ToEntry(m).GetErrors()...) + } + + // The deviation statement is only valid under a module or submodule, + // which allows us to avoid having to process it within ToEntry, and + // rather we can just walk all modules and submodules *after* entries + // are resolved. This means we do not need to concern ourselves that + // an entry does not exist. + dvP := map[string]bool{} // cache the modules we've handled since we have both modname and modname@revision-date + for _, devmods := range []map[string]*Module{ms.Modules, ms.SubModules} { + for _, m := range devmods { + e := ToEntry(m) + if !dvP[e.Name] { + errs = append(errs, e.ApplyDeviate(ms.ParseOptions.DeviateOptions)...) + dvP[e.Name] = true + } + } + } + + return errorSort(errs) +} + +// include resolves all the include and import statements for m. It returns +// an error if m, or recursively, any of the modules it includes or imports, +// reference a module that cannot be found. +func (ms *Modules) include(m *Module) error { + if ms.includes[m] { + return nil + } + ms.includes[m] = true + + // First process any includes in this module. + for _, i := range m.Include { + im := ms.FindModule(i) + if im == nil { + return fmt.Errorf("no such submodule: %s", i.Name) + } + // Process the include statements in our included module. + if err := ms.include(im); err != nil { + return err + } + i.Module = im + } + + // Next process any imports in this module. Imports are used + // when searching. + for _, i := range m.Import { + im := ms.FindModule(i) + if im == nil { + return fmt.Errorf("no such module: %s", i.Name) + } + // Process the include statements in our included module. + if err := ms.include(im); err != nil { + return err + } + + i.Module = im + } + return nil +} + +func (ms *Modules) getEntryCache(n Node) *Entry { + ms.entryCacheMu.RLock() + defer ms.entryCacheMu.RUnlock() + return ms.entryCache[n] +} + +func (ms *Modules) setEntryCache(n Node, e *Entry) { + ms.entryCacheMu.Lock() + defer ms.entryCacheMu.Unlock() + ms.entryCache[n] = e +} + +// ClearEntryCache clears the entryCache containing previously converted nodes +// used by the ToEntry function. +func (ms *Modules) ClearEntryCache() { + ms.entryCacheMu.Lock() + defer ms.entryCacheMu.Unlock() + ms.entryCache = map[Node]*Entry{} +} diff --git a/src/webui/internal/goyang/pkg/yang/modules_test.go b/src/webui/internal/goyang/pkg/yang/modules_test.go new file mode 100644 index 00000000..b0a260c4 --- /dev/null +++ b/src/webui/internal/goyang/pkg/yang/modules_test.go @@ -0,0 +1,414 @@ +// Copyright 2016 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +import ( + "strings" + "testing" + + "github.com/openconfig/gnmi/errdiff" +) + +var testdataFindModulesText = map[string]string{ + "foo": `module foo { prefix "foo"; namespace "urn:foo"; }`, + "bar": `module bar { prefix "bar"; namespace "urn:bar"; }`, + "baz": `module baz { prefix "baz"; namespace "urn:baz"; }`, + "dup-pre-one": `module dup-pre-one { prefix duplicate; namespace urn:duplicate:one; }`, + "dup-pre-two": `module dup-pre-two { prefix duplicate; namespace urn:duplicate:two; }`, + "dup-ns-one": `module dup-ns-one { prefix ns-one; namespace urn:duplicate; }`, + "dup-ns-two": `module dup-ns-two { prefix ns-two; namespace urn:duplicate; }`, +} + +func TestDupModule(t *testing.T) { + tests := []struct { + desc string + inModules map[string]string + wantErr bool + }{{ + desc: "two modules with the same name", + inModules: map[string]string{ + "foo": `module foo { prefix "foo"; namespace "urn:foo"; }`, + "bar": `module foo { prefix "foo"; namespace "urn:foo"; }`, + }, + wantErr: true, + }} + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + ms := NewModules() + var err error + for name, modtext := range tt.inModules { + if err = ms.Parse(modtext, name+".yang"); err != nil { + break + } + } + if gotErr := err != nil; gotErr != tt.wantErr { + t.Fatalf("wantErr: %v, got error: %v", tt.wantErr, err) + } + }) + } +} + +func testModulesForTestdataModulesText(t *testing.T) *Modules { + ms := NewModules() + for name, modtext := range testdataFindModulesText { + if err := ms.Parse(modtext, name+".yang"); err != nil { + t.Fatalf("error importing testdataFindModulesText[%q]: %v", name, err) + } + } + if errs := ms.Process(); errs != nil { + for _, err := range errs { + t.Errorf("error: %v", err) + } + t.Fatalf("fatal error(s) calling Process()") + } + return ms +} + +func testModulesFindByCommonHandler(t *testing.T, i int, got, want *Module, wantError string, err error) { + if err != nil { + if wantError != "" { + if !strings.Contains(err.Error(), wantError) { + t.Errorf("[%d] want error containing %q, got %q", + i, wantError, err.Error()) + } + } else { + t.Errorf("[%d] unexpected error: %v", i, err) + } + } else if wantError != "" { + t.Errorf("[%d] want error containing %q, got nil", i, wantError) + } else if want != got { + t.Errorf("[%d] want module %#v, got %#v", i, want, got) + } +} + +func TestModulesFindByNamespace(t *testing.T) { + ms := testModulesForTestdataModulesText(t) + + for i, tc := range []struct { + namespace string + want *Module + wantError string + }{ + { + namespace: "does-not-exist", + wantError: `"does-not-exist": no such namespace`, + }, + { + namespace: "urn:foo", + want: ms.Modules["foo"], + }, + { + namespace: "urn:bar", + want: ms.Modules["bar"], + }, + { + namespace: "urn:baz", + want: ms.Modules["baz"], + }, + { + namespace: "urn:duplicate", + wantError: "namespace urn:duplicate matches two or more modules (dup-ns-", + }, + } { + got, err := ms.FindModuleByNamespace(tc.namespace) + testModulesFindByCommonHandler(t, i, got, tc.want, tc.wantError, err) + } +} + +func TestModuleLinkage(t *testing.T) { + tests := []struct { + desc string + inMods map[string]string + wantErrSubstr string + }{{ + desc: "invalid import", + inMods: map[string]string{ + "dev": ` + module dev { + prefix d; + namespace "urn:d"; + import sys { prefix sys; } + + revision 01-01-01 { description "the start of time"; } + + deviation /sys:sys/sys:hostname { + deviate not-supported; + } + }`, + }, + wantErrSubstr: "no such module", + }, { + desc: "valid include", + inMods: map[string]string{ + "dev": ` + module dev { + prefix d; + namespace "urn:d"; + include sys; + + revision 01-01-01 { description "the start of time"; } + }`, + "sys": ` + submodule sys { + belongs-to dev { + prefix "d"; + } + + revision 01-01-01 { description "the start of time"; } + + container sys { leaf hostname { type string; } } + }`, + }, + }, { + desc: "invalid include", + inMods: map[string]string{ + "dev": ` + module dev { + prefix d; + namespace "urn:d"; + include sys; + + revision 01-01-01 { description "the start of time"; } + }`, + "sysdb": ` + submodule sysdb { + belongs-to dev { + prefix "d"; + } + + revision 01-01-01 { description "the start of time"; } + + container sys { leaf hostname { type string; } } + }`, + }, + wantErrSubstr: "no such submodule", + }, { + desc: "valid include in submodule", + inMods: map[string]string{ + "dev": ` + module dev { + prefix d; + namespace "urn:d"; + include sys; + + revision 01-01-01 { description "the start of time"; } + }`, + "sys": ` + submodule sys { + belongs-to dev { + prefix "d"; + } + include sysdb; + + revision 01-01-01 { description "the start of time"; } + + container sys { leaf hostname { type string; } } + }`, + "sysdb": ` + submodule sysdb { + belongs-to dev { + prefix "d"; + } + + revision 01-01-01 { description "the start of time"; } + + container sysdb { leaf hostname { type string; } } + }`, + }, + }, { + desc: "invalid include in submodule", + inMods: map[string]string{ + "dev": ` + module dev { + prefix d; + namespace "urn:d"; + include sys; + + revision 01-01-01 { description "the start of time"; } + }`, + "sys": ` + submodule sys { + belongs-to dev { + prefix "d"; + } + include sysdb; + + revision 01-01-01 { description "the start of time"; } + + container sys { leaf hostname { type string; } } + }`, + "syyysdb": ` + submodule syyysdb { + belongs-to dev { + prefix "d"; + } + + revision 01-01-01 { description "the start of time"; } + + container sysdb { leaf hostname { type string; } } + }`, + }, + wantErrSubstr: "no such submodule", + }, { + desc: "valid import in submodule", + inMods: map[string]string{ + "dev": ` + module dev { + prefix d; + namespace "urn:d"; + include sys; + + revision 01-01-01 { description "the start of time"; } + }`, + "sys": ` + submodule sys { + belongs-to dev { + prefix "d"; + } + import sysdb { + prefix "sd"; + } + + revision 01-01-01 { description "the start of time"; } + + container sys { leaf hostname { type string; } } + }`, + "sysdb": ` + module sysdb { + prefix sd; + namespace "urn:sd"; + + revision 01-01-01 { description "the start of time"; } + + container sysdb { leaf hostname { type string; } } + }`, + }, + }, { + desc: "invalid import in submodule", + inMods: map[string]string{ + "dev": ` + module dev { + prefix d; + namespace "urn:d"; + include sys; + + revision 01-01-01 { description "the start of time"; } + }`, + "sys": ` + submodule sys { + belongs-to dev { + prefix "d"; + } + import sysdb { + prefix "sd"; + } + + revision 01-01-01 { description "the start of time"; } + + container sys { leaf hostname { type string; } } + }`, + "syyysdb": ` + module syyysdb { + prefix sd; + namespace "urn:sd"; + + revision 01-01-01 { description "the start of time"; } + + container sysdb { leaf hostname { type string; } } + }`, + }, + wantErrSubstr: "no such module", + }} + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + ms := NewModules() + + for n, m := range tt.inMods { + if err := ms.Parse(m, n); err != nil { + t.Fatalf("cannot parse module %s, err: %v", n, err) + } + } + + errs := ms.Process() + var err error + switch len(errs) { + case 1: + err = errs[0] + fallthrough + case 0: + if diff := errdiff.Substring(err, tt.wantErrSubstr); diff != "" { + t.Fatalf("%s", diff) + } + default: + t.Fatalf("got multiple errors: %v", errs) + } + }) + } +} + +func TestModulesTotalProcess(t *testing.T) { + tests := []struct { + desc string + inMods map[string]string + wantErr bool + }{{ + desc: "import with deviation", + inMods: map[string]string{ + "dev": ` + module dev { + prefix d; + namespace "urn:d"; + import sys { prefix sys; } + + revision 01-01-01 { description "the start of time"; } + + deviation /sys:sys/sys:hostname { + deviate not-supported; + } + }`, + "sys": ` + module sys { + prefix s; + namespace "urn:s"; + + revision 01-01-01 { description "the start of time"; } + + container sys { leaf hostname { type string; } } + }`, + }, + }} + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + ms := NewModules() + + for n, m := range tt.inMods { + if err := ms.Parse(m, n); err != nil { + t.Fatalf("cannot parse module %s, err: %v", n, err) + } + } + + errs := ms.Process() + switch { + case len(errs) == 0 && tt.wantErr: + t.Fatalf("did not get expected errors, got: %v, wantErr: %v", errs, tt.wantErr) + case len(errs) != 0 && !tt.wantErr: + t.Fatalf("got unexpected errors, got: %v, wantErr: %v", errs, tt.wantErr) + } + }) + } +} diff --git a/src/webui/internal/goyang/pkg/yang/node.go b/src/webui/internal/goyang/pkg/yang/node.go new file mode 100644 index 00000000..ee52efeb --- /dev/null +++ b/src/webui/internal/goyang/pkg/yang/node.go @@ -0,0 +1,388 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +import ( + "errors" + "fmt" + "io" + "reflect" + "strings" + + "github.com/openconfig/goyang/pkg/indent" +) + +// A Node contains a yang statement and all attributes and sub-statements. +// Only pointers to structures should implement Node. +type Node interface { + // Kind returns the kind of yang statement (the keyword). + Kind() string + // NName returns the node's name (the argument) + NName() string + // Statement returns the original Statement of this Node. + Statement() *Statement + // ParentNode returns the parent of this Node, or nil if the + // Node has no parent. + ParentNode() Node + // Exts returns the list of extension statements found. + Exts() []*Statement +} + +// A Typedefer is a Node that defines typedefs. +type Typedefer interface { + Node + Typedefs() []*Typedef +} + +// An ErrorNode is a node that only contains an error. +type ErrorNode struct { + Parent Node `yang:"Parent,nomerge"` + + Error error +} + +func (ErrorNode) Kind() string { return "error" } +func (s *ErrorNode) ParentNode() Node { return s.Parent } +func (s *ErrorNode) NName() string { return "error" } +func (s *ErrorNode) Statement() *Statement { return &Statement{} } +func (s *ErrorNode) Exts() []*Statement { return nil } + +// isRPCNode is a terrible hack to return back that a path points into +// an RPC and we should ignore it. +var isRPCNode = &ErrorNode{Error: errors.New("rpc is unsupported")} + +// Source returns the location of the source where n was defined. +func Source(n Node) string { + if n != nil && n.Statement() != nil { + return n.Statement().Location() + } + return "unknown" +} + +// getPrefix returns the prefix and base name of s. If s has no prefix +// then the returned prefix is "". +func getPrefix(s string) (string, string) { + f := strings.SplitN(s, ":", 2) + if len(f) == 1 { + return "", s + } + return f[0], f[1] +} + +// Prefix notes for types: +// +// If there is prefix, look in nodes ancestors. +// +// If prefix matches the module's prefix statement, look in nodes ancestors. +// +// If prefix matches the submodule's belongs-t statement, look in nodes +// ancestors. +// +// Finally, look in the module imported with prefix. + +// FindModuleByPrefix finds the module or submodule with the provided prefix +// relative to where n was defined. If the prefix cannot be resolved then nil +// is returned. +func FindModuleByPrefix(n Node, prefix string) *Module { + if n == nil { + return nil + } + mod := RootNode(n) + + if prefix == "" || prefix == mod.GetPrefix() { + return mod + } + + for _, i := range mod.Import { + if prefix == i.Prefix.Name { + return mod.Modules.FindModule(i) + } + } + return nil +} + +// MatchingExtensions returns the subset of the given node's extensions +// that match the given module and identifier. +func MatchingExtensions(n Node, module, identifier string) ([]*Statement, error) { + return matchingExtensions(n, n.Exts(), module, identifier) +} + +// MatchingEntryExtensions returns the subset of the given entry's extensions +// that match the given module and identifier. +func MatchingEntryExtensions(e *Entry, module, identifier string) ([]*Statement, error) { + return matchingExtensions(e.Node, e.Exts, module, identifier) +} + +// matchingEntryExtensions returns the subset of the given node's extensions +// that match the given module and identifier. +func matchingExtensions(n Node, exts []*Statement, module, identifier string) ([]*Statement, error) { + var matchingExtensions []*Statement + for _, ext := range exts { + names := strings.SplitN(ext.Keyword, ":", 2) + mod := FindModuleByPrefix(n, names[0]) + if mod == nil { + return nil, fmt.Errorf("matchingExtensions: module prefix %q not found", names[0]) + } + if len(names) == 2 && names[1] == identifier && mod.Name == module { + matchingExtensions = append(matchingExtensions, ext) + } + } + return matchingExtensions, nil +} + +// RootNode returns the submodule or module that n was defined in. +func RootNode(n Node) *Module { + for ; n.ParentNode() != nil; n = n.ParentNode() { + } + if mod, ok := n.(*Module); ok { + return mod + } + return nil +} + +// module returns the Module to which n belongs. If n resides in a submodule, +// the belonging module will be returned. +// If n is nil or a module could not be find, nil is returned. +func module(n Node) *Module { + m := RootNode(n) + if m.Kind() == "submodule" { + m = m.Modules.Modules[m.BelongsTo.Name] + } + return m +} + +// NodePath returns the full path of the node from the module name. +func NodePath(n Node) string { + var path string + for n != nil { + path = "/" + n.NName() + path + n = n.ParentNode() + } + return path +} + +// FindNode finds the node referenced by path relative to n. If path does not +// reference a node then nil is returned (i.e. path not found). The path looks +// similar to an XPath but currently has no wildcarding. For example: +// "/if:interfaces/if:interface" and "../config". +func FindNode(n Node, path string) (Node, error) { + if path == "" { + return n, nil + } + // / is not a valid path, it needs a module name + if path == "/" { + return nil, fmt.Errorf("invalid path %q", path) + } + // Paths do not end in /'s + if path[len(path)-1] == '/' { + return nil, fmt.Errorf("invalid path %q", path) + } + + parts := strings.Split(path, "/") + + // An absolute path has a leading component of "". + // We need to discover which module they are part of + // based on our imports. + if parts[0] == "" { + parts = parts[1:] + + // TODO(borman): merge this with FindModuleByPrefix? + // The base is always a module + mod := RootNode(n) + n = mod + prefix, _ := getPrefix(parts[0]) + if mod.Kind() == "submodule" { + m := mod.Modules.Modules[mod.BelongsTo.Name] + if m == nil { + return nil, fmt.Errorf("%s: unknown module %s", m.Name, mod.BelongsTo.Name) + } + if prefix == "" || prefix == mod.BelongsTo.Prefix.Name { + goto processing + } + mod = m + } + + if prefix == "" || prefix == mod.Prefix.Name { + goto processing + } + + for _, i := range mod.Import { + if prefix == i.Prefix.Name { + n = i.Module + goto processing + } + } + // We didn't find a matching prefix. + return nil, fmt.Errorf("unknown prefix: %q", prefix) + processing: + // At this point, n should be pointing to the Module node + // of module we are rooted in + } + + for _, part := range parts { + // If we encounter an RPC node in our search then we + // return the magic isRPCNode Node which just contains + // an error that it is an RPC node. isRPCNode is a singleton + // and can be checked against. + if n.Kind() == "rpc" { + return isRPCNode, nil + } + if part == ".." { + Loop: + for { + n = n.ParentNode() + if n == nil { + return nil, fmt.Errorf(".. with no parent") + } + // choice, leaf, and case nodes + // are "invisible" when doing ".." + // up the tree. + switch n.Kind() { + case "choice", "leaf", "case": + default: + break Loop + } + } + continue + } + // For now just strip off any prefix + // TODO(borman): fix this + _, spart := getPrefix(part) + n = ChildNode(n, spart) + if n == nil { + return nil, fmt.Errorf("%s: no such element", part) + } + } + return n, nil +} + +// ChildNode finds n's child node named name. It returns nil if the node +// could not be found. ChildNode looks at every direct Node pointer in +// n as well as every node in all slices of Node pointers. Names must +// be non-ambiguous, otherwise ChildNode has a non-deterministic result. +func ChildNode(n Node, name string) Node { + v := reflect.ValueOf(n).Elem() + t := v.Type() + nf := t.NumField() + +Loop: + for i := 0; i < nf; i++ { + ft := t.Field(i) + yang := ft.Tag.Get("yang") + if yang == "" { + continue + } + parts := strings.Split(yang, ",") + for _, p := range parts[1:] { + if p == "nomerge" { + continue Loop + } + } + + f := v.Field(i) + if !f.IsValid() || f.IsNil() { + continue + } + + check := func(n Node) Node { + if n.NName() == name { + return n + } + return nil + } + if parts[0] == "uses" { + check = func(n Node) Node { + uname := n.NName() + // unrooted uses are rooted at root + if !strings.HasPrefix(uname, "/") { + uname = "/" + uname + } + if n, _ = FindNode(n, uname); n != nil { + return ChildNode(n, name) + } + return nil + } + } + + switch ft.Type.Kind() { + case reflect.Ptr: + if n = check(f.Interface().(Node)); n != nil { + return n + } + case reflect.Slice: + sl := f.Len() + for i := 0; i < sl; i++ { + n = f.Index(i).Interface().(Node) + if n = check(n); n != nil { + return n + } + } + } + } + return nil +} + +// PrintNode prints node n to w, recursively. +// TODO(borman): display more information +func PrintNode(w io.Writer, n Node) { + v := reflect.ValueOf(n).Elem() + t := v.Type() + nf := t.NumField() + fmt.Fprintf(w, "%s [%s]\n", n.NName(), n.Kind()) +Loop: + for i := 0; i < nf; i++ { + ft := t.Field(i) + yang := ft.Tag.Get("yang") + if yang == "" { + continue + } + parts := strings.Split(yang, ",") + for _, p := range parts[1:] { + if p == "nomerge" { + continue Loop + } + } + + // Skip uppercase elements. + if parts[0][0] >= 'A' && parts[0][0] <= 'Z' { + continue + } + + f := v.Field(i) + if !f.IsValid() || f.IsNil() { + continue + } + + switch ft.Type.Kind() { + case reflect.Ptr: + n = f.Interface().(Node) + if v, ok := n.(*Value); ok { + fmt.Fprintf(w, "%s = %s\n", ft.Name, v.Name) + } else { + PrintNode(indent.NewWriter(w, " "), n) + } + case reflect.Slice: + sl := f.Len() + for i := 0; i < sl; i++ { + n = f.Index(i).Interface().(Node) + if v, ok := n.(*Value); ok { + fmt.Fprintf(w, "%s[%d] = %s\n", ft.Name, i, v.Name) + } else { + PrintNode(indent.NewWriter(w, " "), n) + } + } + } + } +} diff --git a/src/webui/internal/goyang/pkg/yang/node_test.go b/src/webui/internal/goyang/pkg/yang/node_test.go new file mode 100644 index 00000000..5fdac628 --- /dev/null +++ b/src/webui/internal/goyang/pkg/yang/node_test.go @@ -0,0 +1,622 @@ +// Copyright 2019 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +import ( + "errors" + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/openconfig/gnmi/errdiff" +) + +func TestNodePath(t *testing.T) { + tests := []struct { + desc string + in Node + want string + }{{ + desc: "basic", + in: &Leaf{ + Name: "bar", + Parent: &Container{ + Name: "c", + Parent: &List{ + Name: "b", + Parent: &Module{ + Name: "foo", + }, + }, + }, + }, + want: "/foo/b/c/bar", + }, { + desc: "nil input node", + in: nil, + want: "", + }, { + desc: "single node", + in: &Module{ + Name: "foo", + }, + want: "/foo", + }} + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + if diff := cmp.Diff(NodePath(tt.in), tt.want); diff != "" { + t.Errorf("(-got, +want):\n%s", diff) + } + }) + } +} + +// TestNode provides a framework for processing tests that can check particular +// nodes being added to the grammar. It can be used to ensure that particular +// statement combinations are supported, especially where they are opaque to +// the YANG library. +func TestNode(t *testing.T) { + tests := []struct { + desc string + inFn func(*Modules) (Node, error) + inModules map[string]string + wantNode func(Node) error + wantErrSubstr string + }{{ + desc: "import reference statement", + inFn: func(ms *Modules) (Node, error) { + + const module = "test" + m, ok := ms.Modules[module] + if !ok { + return nil, fmt.Errorf("can't find module %q", module) + } + + if len(m.Import) == 0 { + return nil, fmt.Errorf("node %v is missing imports", m) + } + + return m.Import[0], nil + }, + inModules: map[string]string{ + "test": ` + module test { + prefix "t"; + namespace "urn:t"; + + import foo { + prefix "f"; + reference "bar"; + } + } + `, + "foo": ` + module foo { + prefix "f"; + namespace "urn:f"; + } + `, + }, + wantNode: func(n Node) error { + is, ok := n.(*Import) + if !ok { + return fmt.Errorf("got node: %v, want type: import", n) + } + + switch { + case is.Reference == nil: + return errors.New("did not get expected reference, got: nil, want: *yang.Statement") + case is.Reference.Statement().Argument != "bar": + return fmt.Errorf("did not get expected reference, got: %v, want: 'bar'", is.Reference.Statement()) + } + + return nil + }, + }, { + desc: "get submodule from prefix in submodule", + inFn: func(ms *Modules) (Node, error) { + + m, ok := ms.SubModules["foo"] + if !ok { + return nil, fmt.Errorf("can't find submodule in %v", ms) + } + + if m.BelongsTo == nil { + return nil, fmt.Errorf("node %v is missing belongs-to", m) + } + + return m.BelongsTo, nil + }, + inModules: map[string]string{ + "test": ` + module test { + prefix "t"; + namespace "urn:t"; + + include foo { + revision-date 2008-01-01; + } + } + `, + "foo": ` + submodule foo { + belongs-to test { + prefix "t"; + } + } + `, + }, + wantNode: func(n Node) error { + is, ok := n.(*BelongsTo) + if !ok { + return fmt.Errorf("got node: %v, want type: belongs-to", n) + } + + switch { + case is.Prefix == nil: + return errors.New("did not get expected reference, got: nil, want: *yang.Statement") + case is.Prefix.Statement().Argument != "t": + return fmt.Errorf("did not get expected reference, got: %v, want: 't'", is.Prefix.Statement()) + } + + m := FindModuleByPrefix(is, is.Prefix.Statement().Argument) + if m == nil { + return fmt.Errorf("can't find module from submodule's belongs-to prefix value") + } + if want := "foo"; m.Name != want { + return fmt.Errorf("module from submodule's belongs-to prefix value doesn't match, got %q, want %q", m.Name, want) + } + + return nil + }, + }, { + desc: "import statement from submodule", + inFn: func(ms *Modules) (Node, error) { + + m, ok := ms.SubModules["foo"] + if !ok { + return nil, fmt.Errorf("can't find submodule in %v", ms) + } + + if len(m.Import) == 0 { + return nil, fmt.Errorf("node %v is missing import statement", m) + } + + return m.Import[0], nil + }, + inModules: map[string]string{ + "test": ` + module test { + prefix "t"; + namespace "urn:t"; + + include foo { + revision-date 2008-01-01; + } + + typedef t { + type string; + } + } + `, + "foo": ` + submodule foo { + belongs-to test { + prefix "t"; + } + + import test2 { + prefix "t2"; + description "test2 module"; + } + } + `, + "test2": ` + module test2 { + prefix "t2"; + namespace "urn:t2"; + } + `, + }, + wantNode: func(n Node) error { + is, ok := n.(*Import) + if !ok { + return fmt.Errorf("got node: %v, want type: belongs-to", n) + } + + switch { + case is.Prefix == nil: + return errors.New("did not get expected reference, got: nil, want: *yang.Statement") + case is.Prefix.Statement().Argument != "t2": + return fmt.Errorf("did not get expected reference, got: %v, want: 't'", is.Prefix.Statement()) + } + + m := FindModuleByPrefix(is, is.Prefix.Statement().Argument) + if m == nil { + return fmt.Errorf("can't find module from submodule's import prefix value") + } + if want := "test2"; m.Name != want { + return fmt.Errorf("module from submodule's import prefix value doesn't match, got %q, want %q", m.Name, want) + } + + return nil + }, + }, { + desc: "import description statement", + inFn: func(ms *Modules) (Node, error) { + + const module = "test" + m, ok := ms.Modules[module] + if !ok { + return nil, fmt.Errorf("can't find module %q", module) + } + + if len(m.Import) == 0 { + return nil, fmt.Errorf("node %v is missing imports", m) + } + + return m.Import[0], nil + }, + inModules: map[string]string{ + "test": ` + module test { + prefix "t"; + namespace "urn:t"; + + import foo { + prefix "f"; + description "foo module"; + } + } + `, + "foo": ` + module foo { + prefix "f"; + namespace "urn:f"; + } + `, + }, + wantNode: func(n Node) error { + is, ok := n.(*Import) + if !ok { + return fmt.Errorf("got node: %v, want type: import", n) + } + + switch { + case is.Description == nil: + return errors.New("did not get expected reference, got: nil, want: *yang.Statement") + case is.Description.Statement().Argument != "foo module": + return fmt.Errorf("did not get expected reference, got: '%v', want: 'foo module'", is.Description.Statement().Argument) + } + + return nil + }, + }, { + desc: "Test matchingExtensions", + inFn: func(ms *Modules) (Node, error) { + + module := "test" + m, ok := ms.Modules[module] + if !ok { + return nil, fmt.Errorf("can't find module %q", module) + } + + if len(m.Leaf) == 0 { + return nil, fmt.Errorf("node %v is missing imports", m) + } + + module = "foo" + if _, ok := ms.Modules[module]; !ok { + return nil, fmt.Errorf("can't find module %q", module) + } + + return m.Leaf[0].Type, nil + }, + inModules: map[string]string{ + "test": ` + module test { + prefix "t"; + namespace "urn:t"; + + import foo { + prefix "f"; + description "foo module"; + } + + import foo2 { + prefix "f2"; + description "foo2 module"; + } + + leaf test-leaf { + type string { + pattern 'alpha'; + // Test different modules and different ext names. + f:bar 'boo'; + f2:bar 'boo2'; + + f:bar 'coo'; + f2:bar 'coo2'; + + f:far 'doo'; + f2:far 'doo2'; + + f:bar 'foo'; + f2:bar 'foo2'; + + f:far 'goo'; + f2:far 'goo2'; + } + } + } + `, + "foo": ` + module foo { + prefix "f"; + namespace "urn:f"; + + extension bar { + argument "baz"; + } + + extension far { + argument "baz"; + } + } + `, + "foo2": ` + module foo2 { + prefix "f2"; + namespace "urn:f2"; + + extension bar { + argument "baz"; + } + + extension far { + argument "baz"; + } + } + `, + }, + wantNode: func(n Node) error { + n, ok := n.(*Type) + if !ok { + return fmt.Errorf("got node: %v, want type: Leaf", n) + } + + var bars []string + matches, err := matchingExtensions(n, n.Exts(), "foo", "bar") + if err != nil { + return err + } + for _, ext := range matches { + bars = append(bars, ext.Argument) + } + + if diff := cmp.Diff(bars, []string{"boo", "coo", "foo"}); diff != "" { + return fmt.Errorf("matchingExtensions (-got, +want):\n%s", diff) + } + + return nil + }, + }, { + desc: "Test matchingExtensions when module is not found", + inFn: func(ms *Modules) (Node, error) { + + module := "test" + m, ok := ms.Modules[module] + if !ok { + return nil, fmt.Errorf("can't find module %q", module) + } + + if len(m.Leaf) == 0 { + return nil, fmt.Errorf("node %v is missing imports", m) + } + + module = "foo" + if _, ok := ms.Modules[module]; !ok { + return nil, fmt.Errorf("can't find module %q", module) + } + + return m.Leaf[0].Type, nil + }, + inModules: map[string]string{ + "test": ` + module test { + prefix "t"; + namespace "urn:t"; + + import foo { + prefix "f"; + description "foo module"; + } + + leaf test-leaf { + type string { + pattern 'alpha'; + not-found:bar 'foo'; + } + } + } + `, + "foo": ` + module foo { + prefix "f"; + namespace "urn:f"; + + extension bar { + argument "baz"; + } + + extension far { + argument "baz"; + } + } + `, + }, + wantNode: func(n Node) error { + n, ok := n.(*Type) + if !ok { + return fmt.Errorf("got node: %v, want type: Leaf", n) + } + + var bars []string + matches, err := matchingExtensions(n, n.Exts(), "foo", "bar") + if err != nil { + return err + } + for _, ext := range matches { + bars = append(bars, ext.Argument) + } + + if diff := cmp.Diff(bars, []string{"boo", "coo", "foo"}); diff != "" { + return fmt.Errorf("matchingExtensions (-got, +want):\n%s", diff) + } + + return nil + }, + wantErrSubstr: `module prefix "not-found" not found`, + }} + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + ms := NewModules() + + for n, m := range tt.inModules { + if err := ms.Parse(m, n); err != nil { + t.Errorf("error parsing module %s, got: %v, want: nil", n, err) + } + } + + errs := ms.Process() + var err error + if len(errs) > 1 { + t.Fatalf("Got more than 1 error: %v", errs) + } else if len(errs) == 1 { + err = errs[0] + } + if diff := errdiff.Substring(err, tt.wantErrSubstr); diff != "" { + t.Errorf("Did not get expected error: %s", diff) + } + if err != nil { + return + } + + node, err := tt.inFn(ms) + if err != nil { + t.Fatalf("cannot run in function, %v", err) + } + + if err := tt.wantNode(node); err != nil { + t.Fatalf("failed check function, %v", err) + } + }) + } +} + +func TestModulesFindByPrefix(t *testing.T) { + // Some examples of where prefixes might be used are in the following + // YANG statements: extension, uses, augment, deviation, type, leafref. + // Not all are put into the test here, since the logic is the same for + // each. + modules := map[string]string{ + "foo": `module foo { prefix "foo"; namespace "urn:foo"; include bar; leaf leafref { type leafref { path "../foo:leaf"; } } uses foo:lg; }`, + "bar": `submodule bar { belongs-to foo { prefix "bar"; } container c { uses bar:lg; } grouping lg { leaf leaf { type string; } } }`, + "baz": `module baz { prefix "foo"; namespace "urn:foo"; import foo { prefix f; } extension e; uses f:lg; foo:e; }`, + } + + ms := NewModules() + for name, modtext := range modules { + if err := ms.Parse(modtext, name+".yang"); err != nil { + t.Fatalf("error parsing module %q: %v", name, err) + } + } + if errs := ms.Process(); errs != nil { + for _, err := range errs { + t.Errorf("error: %v", err) + } + t.Fatalf("fatal error(s) calling Process()") + } + + for _, tt := range []struct { + desc string + node Node + prefix string + want *Module + }{ + { + desc: "nil node", + node: nil, + prefix: "does-not-exist", + want: nil, + }, + { + desc: "module foo", + node: ms.Modules["foo"], + prefix: "foo", + want: ms.Modules["foo"], + }, + { + desc: "submodule bar", + node: ms.SubModules["bar"], + prefix: "bar", + want: ms.SubModules["bar"], + }, + { + desc: "module baz", + node: ms.Modules["baz"], + prefix: "foo", + want: ms.Modules["baz"], + }, + { + desc: "foo leafref", + node: ms.Modules["foo"].Leaf[0].Type, + prefix: "foo", + want: ms.Modules["foo"], + }, + { + desc: "foo uses", + node: ms.Modules["foo"].Uses[0], + prefix: "foo", + want: ms.Modules["foo"], + }, + { + desc: "bar uses", + node: ms.SubModules["bar"].Container[0].Uses[0], + prefix: "bar", + want: ms.SubModules["bar"], + }, + { + desc: "baz uses", + node: ms.Modules["baz"].Uses[0], + prefix: "f", + want: ms.Modules["foo"], + }, + { + desc: "baz extension", + node: ms.Modules["baz"], + prefix: "foo", + want: ms.Modules["baz"], + }, + } { + t.Run(tt.desc, func(t *testing.T) { + if got := FindModuleByPrefix(tt.node, tt.prefix); got != tt.want { + t.Errorf("got: %v, want: %v", got, tt.want) + } + }) + } +} diff --git a/src/webui/internal/goyang/pkg/yang/options.go b/src/webui/internal/goyang/pkg/yang/options.go new file mode 100644 index 00000000..2de2ebd5 --- /dev/null +++ b/src/webui/internal/goyang/pkg/yang/options.go @@ -0,0 +1,59 @@ +// Copyright 2017 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +// Options defines the options that should be used when parsing YANG modules, +// including specific overrides for potentially problematic YANG constructs. +type Options struct { + // IgnoreSubmoduleCircularDependencies specifies whether circular dependencies + // between submodules. Setting this value to true will ensure that this + // package will explicitly ignore the case where a submodule will include + // itself through a circular reference. + IgnoreSubmoduleCircularDependencies bool + // StoreUses controls whether the Uses field of each YANG entry should be + // populated. Setting this value to true will cause each Entry which is + // generated within the schema to store the logical grouping from which it + // is derived. + StoreUses bool + // DeviateOptions contains options for how deviations are handled. + DeviateOptions DeviateOptions +} + +// DeviateOptions contains options for how deviations are handled. +type DeviateOptions struct { + // IgnoreDeviateNotSupported indicates to the parser to retain nodes + // that are marked with "deviate not-supported". An example use case is + // where the user wants to interact with different targets that have + // different support for a leaf without having to use a second instance + // of an AST. + IgnoreDeviateNotSupported bool +} + +// IsDeviateOpt ensures that DeviateOptions satisfies the DeviateOpt interface. +func (DeviateOptions) IsDeviateOpt() {} + +// DeviateOpt is an interface that can be used in function arguments. +type DeviateOpt interface { + IsDeviateOpt() +} + +func hasIgnoreDeviateNotSupported(opts []DeviateOpt) bool { + for _, o := range opts { + if opt, ok := o.(DeviateOptions); ok { + return opt.IgnoreDeviateNotSupported + } + } + return false +} diff --git a/src/webui/internal/goyang/pkg/yang/parse.go b/src/webui/internal/goyang/pkg/yang/parse.go new file mode 100644 index 00000000..60c388b6 --- /dev/null +++ b/src/webui/internal/goyang/pkg/yang/parse.go @@ -0,0 +1,338 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +// This file implements Parse, which parses the input as generic YANG and +// returns a slice of base Statements (which in turn may contain more +// Statements, i.e., a slice of Statement trees.) + +import ( + "bytes" + "errors" + "fmt" + "io" + "strings" +) + +// a parser is used to parse the contents of a single .yang file. +type parser struct { + lex *lexer + errout *bytes.Buffer + tokens []*token // stack of pushed tokens (for backing up) + + // Depth of statements in nested braces + statementDepth int + + // hitBrace is returned when we encounter a '}'. The statement location + // is updated with the location of the '}'. The brace may be legitimate + // but only the caller will know if it is. That is, the brace may be + // closing our parent or may be an error (we didn't expect it). + // hitBrace is updated with the file, line, and column of the brace's + // location. + hitBrace *Statement +} + +// Statement is a generic YANG statement that may have sub-statements. +// It implements the Node interface. +// +// Within the parser, it represents a non-terminal token. +// From https://tools.ietf.org/html/rfc7950#section-6.3: +// statement = keyword [argument] (";" / "{" *statement "}") +// The argument is a string. +type Statement struct { + Keyword string + HasArgument bool + Argument string + statements []*Statement + + file string + line int // 1's based line number + col int // 1's based column number +} + +func (s *Statement) NName() string { return s.Argument } +func (s *Statement) Kind() string { return s.Keyword } +func (s *Statement) Statement() *Statement { return s } +func (s *Statement) ParentNode() Node { return nil } +func (s *Statement) Exts() []*Statement { return nil } + +// Arg returns the optional argument to s. It returns false if s has no +// argument. +func (s *Statement) Arg() (string, bool) { return s.Argument, s.HasArgument } + +// SubStatements returns a slice of Statements found in s. +func (s *Statement) SubStatements() []*Statement { return s.statements } + +// Location returns the location in the source where s was defined. +func (s *Statement) Location() string { + switch { + case s.file == "" && s.line == 0: + return "unknown" + case s.file == "": + return fmt.Sprintf("line %d:%d", s.line, s.col) + case s.line == 0: + return s.file + default: + return fmt.Sprintf("%s:%d:%d", s.file, s.line, s.col) + } +} + +// Write writes the tree in s to w, each line indented by ident. Children +// nodes are indented further by a tab. Typically indent is "" at the top +// level. Write is intended to display the contents of Statement, but +// not necessarily reproduce the input of Statement. +func (s *Statement) Write(w io.Writer, indent string) error { + if s.Keyword == "" { + // We are just a collection of statements at the top level. + for _, s := range s.statements { + if err := s.Write(w, indent); err != nil { + return err + } + } + return nil + } + + parts := []string{fmt.Sprintf("%s%s", indent, s.Keyword)} + if s.HasArgument { + args := strings.Split(s.Argument, "\n") + if len(args) == 1 { + parts = append(parts, fmt.Sprintf(" %q", s.Argument)) + } else { + parts = append(parts, ` "`, args[0], "\n") + i := fmt.Sprintf("%*s", len(s.Keyword)+1, "") + for x, p := range args[1:] { + s := fmt.Sprintf("%q", p) + s = s[1 : len(s)-1] + parts = append(parts, indent, " ", i, s) + if x == len(args[1:])-1 { + // last part just needs the closing " + parts = append(parts, `"`) + } else { + parts = append(parts, "\n") + } + } + } + } + + if len(s.statements) == 0 { + _, err := fmt.Fprintf(w, "%s;\n", strings.Join(parts, "")) + return err + } + if _, err := fmt.Fprintf(w, "%s {\n", strings.Join(parts, "")); err != nil { + return err + } + for _, s := range s.statements { + if err := s.Write(w, indent+"\t"); err != nil { + return err + } + } + if _, err := fmt.Fprintf(w, "%s}\n", indent); err != nil { + return err + } + return nil +} + +// ignoreMe is an error recovery token used by the parser in order +// to continue processing for other errors in the file. +var ignoreMe = &Statement{} + +// Parse parses the input as generic YANG and returns the statements parsed. +// The path parameter should be the source name where input was read from (e.g., +// the file name the input was read from). If one more more errors are +// encountered, nil and an error are returned. The error's text includes all +// errors encountered. +func Parse(input, path string) ([]*Statement, error) { + var statements []*Statement + p := &parser{ + lex: newLexer(input, path), + errout: &bytes.Buffer{}, + hitBrace: &Statement{}, + } + p.lex.errout = p.errout +Loop: + for { + switch ns := p.nextStatement(); ns { + case nil: + break Loop + case p.hitBrace: + fmt.Fprintf(p.errout, "%s:%d:%d: unexpected %c\n", ns.file, ns.line, ns.col, '}') + default: + statements = append(statements, ns) + } + } + + p.checkStatementDepthIsZero() + + if p.errout.Len() == 0 { + return statements, nil + } + return nil, errors.New(strings.TrimSpace(p.errout.String())) +} + +// push pushes tokens t back on the input stream so they will be the next +// tokens returned by next. The tokens list is a LIFO so the final token +// listed to push will be the next token returned. +func (p *parser) push(t ...*token) { + p.tokens = append(p.tokens, t...) +} + +// pop returns the last token pushed, or nil if the token stack is empty. +func (p *parser) pop() *token { + if n := len(p.tokens); n > 0 { + n-- + defer func() { p.tokens = p.tokens[:n] }() + return p.tokens[n] + } + return nil +} + +// next returns the next token from the lexer. If the next token is a +// concatenated string, it returns the concatenated string as the token. +func (p *parser) next() *token { + if t := p.pop(); t != nil { + return t + } + // next returns the next unprocessed lexer token. + next := func() *token { + for { + if t := p.lex.NextToken(); t.Code() != tError { + return t + } + } + } + t := next() + if t.Code() != tString { + return t + } + // Process string concatenation (both single and double quote). + // See https://tools.ietf.org/html/rfc7950#section-6.1.3.1 + // The lexer trimmed the quotes already. + for { + nt := next() + switch nt.Code() { + case tEOF: + return t + case tUnquoted: + if nt.Text != "+" { + p.push(nt) + return t + } + default: + p.push(nt) + return t + } + // Invariant: nt is a + sign. + nnt := next() + switch nnt.Code() { + case tEOF: + p.push(nt) + return t + case tString: + // Accumulate the concatenation. + t.Text += nnt.Text + default: + p.push(nnt, nt) + return t + } + } +} + +// nextStatement returns the next statement in the input, which may in turn +// recurse to read sub statements. +// nil is returned when EOF has been reached, or is reached halfway through +// parsing the next statement (with associated syntax errors printed to +// errout). +func (p *parser) nextStatement() *Statement { + t := p.next() + switch t.Code() { + case tEOF: + return nil + case '}': + p.statementDepth -= 1 + p.hitBrace.file = t.File + p.hitBrace.line = t.Line + p.hitBrace.col = t.Col + return p.hitBrace + case tUnquoted: + default: + fmt.Fprintf(p.errout, "%v: keyword token not an unquoted string\n", t) + return ignoreMe + } + // Invariant: t represents a keyword token. + + s := &Statement{ + Keyword: t.Text, + file: t.File, + line: t.Line, + col: t.Col, + } + + // The keyword "pattern" must be treated specially. When + // parsing the argument for "pattern", escape sequences + // must be expanded differently. + p.lex.inPattern = t.Text == "pattern" + t = p.next() + p.lex.inPattern = false + switch t.Code() { + case tString, tUnquoted: + s.HasArgument = true + s.Argument = t.Text + t = p.next() + } + + switch t.Code() { + case tEOF: + fmt.Fprintf(p.errout, "%s: unexpected EOF\n", s.file) + return nil + case ';': + return s + case '{': + p.statementDepth += 1 + for { + switch ns := p.nextStatement(); ns { + case nil: + // Signal EOF reached. + return nil + case p.hitBrace: + return s + default: + s.statements = append(s.statements, ns) + } + } + default: + fmt.Fprintf(p.errout, "%v: syntax error, expected ';' or '{'\n", t) + return ignoreMe + } +} + +// checkStatementDepthIsZero checks that we aren't missing closing +// braces. Note: the parser will error out for the case where we +// start with an unmatched close brace, i.e. depth < 0 +// +// This test should only be done if there are no other errors as +// we may exit early due to those errors -- and therefore there *might* +// not really be a mismatched brace issue. +func (p *parser) checkStatementDepthIsZero() { + if p.errout.Len() > 0 || p.statementDepth == 0 { + return + } + + plural := "" + if p.statementDepth > 1 { + plural = "s" + } + fmt.Fprintf(p.errout, "%s:%d:%d: missing %d closing brace%s\n", + p.lex.file, p.lex.line, p.lex.col, p.statementDepth, plural) +} diff --git a/src/webui/internal/goyang/pkg/yang/parse_test.go b/src/webui/internal/goyang/pkg/yang/parse_test.go new file mode 100644 index 00000000..33269966 --- /dev/null +++ b/src/webui/internal/goyang/pkg/yang/parse_test.go @@ -0,0 +1,539 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +import ( + "bytes" + "testing" +) + +func (s1 *Statement) equal(s2 *Statement) bool { + if s1.Keyword != s2.Keyword || + s1.HasArgument != s2.HasArgument || + s1.Argument != s2.Argument || + len(s1.statements) != len(s2.statements) { + return false + } + + for x, ss := range s1.statements { + if !ss.equal(s2.statements[x]) { + return false + } + } + return true +} + +// SA returns a statement with an argument and optional substatements. +func SA(k, a string, ss ...*Statement) *Statement { + return &Statement{ + Keyword: k, + Argument: a, + HasArgument: true, + statements: ss, + } +} + +// S returns a statement with no argument and optional substatements. +func S(k string, ss ...*Statement) *Statement { + return &Statement{ + Keyword: k, + statements: ss, + } +} + +func TestParse(t *testing.T) { + for _, tt := range []struct { + line int + in string + out []*Statement + err string + }{ + {line: line()}, + {line: line(), in: ` +foo; +`, + out: []*Statement{ + S("foo"), + }, + }, + {line: line(), in: ` +foo {} +`, + out: []*Statement{ + S("foo"), + }, + }, + {line: line(), in: ` +foo ""; +`, + out: []*Statement{ + SA("foo", ""), + }, + }, + {line: line(), in: ` +foo bar; +`, + out: []*Statement{ + SA("foo", "bar"), + }, + }, + {line: line(), in: ` +foo "bar"; +`, + out: []*Statement{ + SA("foo", "bar"), + }, + }, + {line: line(), in: ` +foo "\\ \S \n"; +`, + err: `test.yang:2:9: invalid escape sequence: \S`, + }, + {line: line(), in: ` +pattern "\\ \S \n"; +`, + out: []*Statement{ + SA("pattern", `\ \S +`), + }, + }, + {line: line(), in: ` +foo '\\ \S \n'; +`, + out: []*Statement{ + SA("foo", `\\ \S \n`), + }, + }, + {line: line(), in: ` +pattern '\\ \S \n'; +`, + out: []*Statement{ + SA("pattern", `\\ \S \n`), + }, + }, + {line: line(), in: ` +foo "bar" + "baz"; +`, + out: []*Statement{ + SA("foo", "barbaz"), + }, + }, + {line: line(), in: ` +foo "bar" + "+" + "baz"; +`, + out: []*Statement{ + SA("foo", "bar+baz"), + }, + }, + {line: line(), in: ` +foo "bar" +`, + err: `test.yang: unexpected EOF`, + }, + {line: line(), in: ` +foo "bar" + "baz" +`, + err: `test.yang: unexpected EOF`, + }, + {line: line(), in: ` +foo "bar" baz; +`, + err: `test.yang:2:11: baz: syntax error, expected ';' or '{' +test.yang:2:14: ;: keyword token not an unquoted string`, + }, + {line: line(), in: ` +foo "bar" + baz; +`, + err: `test.yang:2:11: +: syntax error, expected ';' or '{'`, + }, + {line: line(), in: ` +foo "bar" + +`, + err: `test.yang:2:11: +: syntax error, expected ';' or '{'`, + }, + {line: line(), in: ` +foo "bar"; +`, + out: []*Statement{ + SA("foo", "bar"), + }, + }, + {line: line(), in: ` +foo "bar" {} +`, + out: []*Statement{ + SA("foo", "bar"), + }, + }, + {line: line(), in: ` +foo 'bar' + 'baz'; +`, + out: []*Statement{ + SA("foo", "barbaz"), + }, + }, + {line: line(), in: ` +foo 'bar' + '+' + 'baz'; +`, + out: []*Statement{ + SA("foo", "bar+baz"), + }, + }, + {line: line(), in: ` +foo 'bar' +`, + err: `test.yang: unexpected EOF`, + }, + {line: line(), in: ` +foo 'bar' + 'baz' +`, + err: `test.yang: unexpected EOF`, + }, + {line: line(), in: ` +foo 'bar' baz; +`, + err: `test.yang:2:11: baz: syntax error, expected ';' or '{' +test.yang:2:14: ;: keyword token not an unquoted string`, + }, + {line: line(), in: ` +foo 'bar' + baz; +`, + err: `test.yang:2:11: +: syntax error, expected ';' or '{'`, + }, + {line: line(), in: ` +foo 'bar' + +`, + err: `test.yang:2:11: +: syntax error, expected ';' or '{'`, + }, + {line: line(), in: ` +foo 'bar'; +`, + out: []*Statement{ + SA("foo", "bar"), + }, + }, + {line: line(), in: ` +foo 'bar' {} +`, + out: []*Statement{ + SA("foo", "bar"), + }, + }, + {line: line(), in: ` +foo bar; +red black; +`, + out: []*Statement{ + SA("foo", "bar"), + SA("red", "black"), + }, + }, + {line: line(), in: ` +foo { + key value; +} +`, + out: []*Statement{ + S("foo", + SA("key", "value"), + ), + }, + }, + {line: line(), in: ` +foo { + key value; +} +`, + out: []*Statement{ + S("foo", + SA("key", "value"), + ), + }, + }, + {line: line(), in: ` +foo { + key "value1 value2 + + value3"; +} +`, + out: []*Statement{ + S("foo", + SA("key", "value1 value2\n\n value3"), + ), + }, + }, + {line: line(), in: ` +foo { + key value; + key2; +} +`, + out: []*Statement{ + S("foo", + SA("key", "value"), + S("key2"), + ), + }, + }, + {line: line(), in: ` +foo1 { + key value1; +} +foo2 { + key value2; +} +foo3 value3; +`, + out: []*Statement{ + S("foo1", + SA("key", "value1"), + ), + S("foo2", + SA("key", "value2"), + ), + SA("foo3", "value3"), + }, + }, + {line: line(), in: ` +foo1 { + key value1; + foo2 { + key value2; + } +} +`, + out: []*Statement{ + S("foo1", + SA("key", "value1"), + S("foo2", + SA("key", "value2"), + ), + ), + }, + }, + {line: line(), in: ` +foo1 { + key value1; + foo2 { + pattern '[a-zA-Z0-9!#$%&'+"'"+'*+/=?^_` + "`" + `{|}~-]+' + + '(\.[a-zA-Z0-9!#$%&'+"'"+'*+/=?^_` + "`" + `{|}~-]+)*' + + '@' + + '[a-zA-Z0-9!#$%&'+"'"+'*+/=?^_` + "`" + `{|}~-]+' + + '(\.[a-zA-Z0-9!#$%&'+"'"+'*+/=?^_` + "`" + `{|}~-]+)*'; + } +} +`, + out: []*Statement{ + S("foo1", + SA("key", "value1"), + S("foo2", + SA("pattern", "[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(\\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(\\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*"), + ), + ), + }, + }, + {line: line(), in: ` + } +`, + err: `test.yang:2:2: unexpected }`, + }, + {line: line(), in: ` +id +`, + err: `test.yang: unexpected EOF`, + }, + {line: line(), in: ` + { +`, + err: `test.yang:2:4: {: keyword token not an unquoted string`, + }, + {line: line(), in: ` +; +`, + err: `test.yang:2:1: ;: keyword token not an unquoted string`, + }, + {line: line(), in: ` +statement one two { } +`, + err: `test.yang:2:15: two: syntax error, expected ';' or '{' +test.yang:2:19: {: keyword token not an unquoted string +test.yang:2:21: unexpected }`, + }, + {line: line(), in: ` + } +foo { + key: "value"; +} +`, + err: `test.yang:2:5: unexpected }`, + }, + {line: line(), in: ` +{ + something: "bad"; +} +foo { + key: "\Value"; + key2: "value2"; + bar { + key3: "value\3; + } +}`, + err: `test.yang:2:1: {: keyword token not an unquoted string +test.yang:4:1: unexpected } +test.yang:6:8: invalid escape sequence: \V +test.yang:9:15: invalid escape sequence: \3 +test.yang:9:9: missing closing " +test.yang: unexpected EOF`, + }, + {line: line(), in: ` +module base { + container top-missing-close-brace { + leaf my-leaf { + type string; + } + } +`, + err: "test.yang:8:0: missing 1 closing brace", + }, + {line: line(), in: ` +module base { + container top-missing-close-brace { + leaf my-leaf { + type string; + } +`, + err: "test.yang:7:0: missing 2 closing braces", + }, + } { + s, err := Parse(tt.in, "test.yang") + if (s == nil) != (tt.out == nil) { + if s == nil { + t.Errorf("%d: did not get expected statements: %v", tt.line, tt.out) + } else { + t.Errorf("%d: get unexpected statements: %v", tt.line, s) + } + } + switch { + case err == nil && tt.err == "": + case tt.err == "": + t.Errorf("%d: unexpected error %v", tt.line, err) + continue + case err == nil: + t.Errorf("%d: did not get expected error %v", tt.line, tt.err) + continue + case err.Error() == tt.err: + continue + default: + t.Errorf("%d: got error:\n%s\nwant:\n%s", tt.line, err, tt.err) + continue + } + s1 := &Statement{statements: s} + s2 := &Statement{statements: tt.out} + if !s1.equal(s2) { + t.Errorf("%d: got:\n%v\nwant:\n%v", tt.line, s1, s2) + } + } +} + +func TestWrite(t *testing.T) { +Testing: + for _, tt := range []struct { + line int + in string + out string + }{ + {line: line(), + in: `key arg { substatement; }`, + out: `key "arg" { + substatement; +} +`, + }, + {line: line(), + in: `key { substatement { key arg; }}`, + out: `key { + substatement { + key "arg"; + } +} +`, + }, + {line: line(), + in: ` +module base { + namespace "urn:mod"; + prefix "base"; + + typedef base-type { type int32; } + + grouping base-group { + description + "The base-group is used to test the + 'uses' statement below. This description + is here to simply include a multi-line + string as an example of multi-line strings"; + leaf base-group-leaf { + config false; + type string; + } + } + uses base-group; +} +`, out: `module "base" { + namespace "urn:mod"; + prefix "base"; + typedef "base-type" { + type "int32"; + } + grouping "base-group" { + description "The base-group is used to test the + 'uses' statement below. This description + is here to simply include a multi-line + string as an example of multi-line strings"; + leaf "base-group-leaf" { + config "false"; + type "string"; + } + } + uses "base-group"; +} +`, + }, + } { + in := tt.in + // Run twice. The first time we are parsing tt.in, the second + // time we are parsing the output from the first parsing. + for i := 0; i < 2; i++ { + s, err := Parse(in, "test.yang") + if err != nil { + t.Errorf("%d: unexpected error %v", tt.line, err) + continue Testing + } + if len(s) != 1 { + t.Errorf("%d: got %d statements, expected 1", tt.line, len(s)) + continue Testing + } + var buf bytes.Buffer + s[0].Write(&buf, "") + out := buf.String() + if out != tt.out { + t.Errorf("%d: got:\n%swant:\n%s", tt.line, out, tt.out) + continue Testing + } + in = out + } + } +} diff --git a/src/webui/internal/goyang/pkg/yang/testdata/deviate-delete.yang b/src/webui/internal/goyang/pkg/yang/testdata/deviate-delete.yang new file mode 100644 index 00000000..8e864666 --- /dev/null +++ b/src/webui/internal/goyang/pkg/yang/testdata/deviate-delete.yang @@ -0,0 +1,89 @@ +module deviate { + prefix "d"; + namespace "urn:d"; + + grouping substmts { + leaf config { + type string; + config true; + } + leaf default { + type string; + default "fish"; + } + leaf mandatory { + type string; + mandatory false; + } + leaf-list max-elements { + type string; + max-elements 1000; + } + leaf-list min-elements { + type string; + min-elements 1000; + } + leaf-list max-and-min-elements { + type string; + max-elements 1024; + min-elements 1; + } + leaf type { + type string; + } + // TODO(robjs): unique for deviation + leaf units { + type uint16; + units "nanofish per millenium"; + } + } + + container target { + container delete { + uses substmts; + } + } + + deviation /target/delete/config { + deviate delete { + config true; + } + } + + deviation /target/delete/default { + deviate delete { + default "fish"; + } + } + + deviation /target/delete/mandatory { + deviate delete { + mandatory false; + } + } + + deviation /target/delete/min-elements { + deviate delete { + min-elements 1000; + } + } + + deviation /target/delete/max-elements { + deviate delete { + max-elements 1000; + } + } + + deviation /target/delete/max-and-min-elements { + deviate delete { + max-elements 1024; + min-elements 1; + } + } + + deviation /target/delete/units { + deviate delete { + units "nanofish per millenium"; + } + } +} diff --git a/src/webui/internal/goyang/pkg/yang/testdata/deviate-notsupported.yang b/src/webui/internal/goyang/pkg/yang/testdata/deviate-notsupported.yang new file mode 100644 index 00000000..5c59af2d --- /dev/null +++ b/src/webui/internal/goyang/pkg/yang/testdata/deviate-notsupported.yang @@ -0,0 +1,42 @@ +module deviate { + prefix "d"; + namespace "urn:d"; + + grouping substmts { + container child { + leaf zzz { type string; } + } + } + + container target { + uses substmts; + } + + list target-list { + key "k"; + + leaf k { type string; } + uses substmts; + } + + leaf a-leaf { type string; } + leaf a-leaflist { type string; } + + leaf survivor { type string; } + + deviation /target { + deviate not-supported; + } + + deviation /target-list { + deviate not-supported; + } + + deviation /a-leaf { + deviate not-supported; + } + + deviation /a-leaflist { + deviate not-supported; + } +} diff --git a/src/webui/internal/goyang/pkg/yang/testdata/deviate-replace.yang b/src/webui/internal/goyang/pkg/yang/testdata/deviate-replace.yang new file mode 100644 index 00000000..9f1166e0 --- /dev/null +++ b/src/webui/internal/goyang/pkg/yang/testdata/deviate-replace.yang @@ -0,0 +1,106 @@ +module deviate { + prefix "d"; + namespace "urn:d"; + + grouping substmts { + leaf config { + type string; + config true; + } + leaf default { + type string; + default "fish"; + } + leaf-list default-list { + type string; + default "fish"; + default "sticks"; + } + leaf mandatory { + type string; + mandatory false; + } + leaf-list max-elements { + type string; + max-elements 1000; + } + leaf-list min-elements { + type string; + min-elements 1000; + } + leaf-list max-and-min-elements { + type string; + max-elements 1024; + min-elements 1; + } + leaf type { + type string; + } + // TODO(robjs): unique for deviation + leaf units { + type uint16; + units "nanofish per millenium"; + } + } + + container target { + container replace { + uses substmts; + } + } + + deviation /target/replace/config { + deviate replace { + config false; + } + } + + deviation /target/replace/default { + deviate replace { + default "a default value"; + } + } + + deviation /target/replace/default-list { + deviate replace { + default "nematodes"; + } + } + + deviation /target/replace/mandatory { + deviate replace { + mandatory true; + } + } + + deviation /target/replace/min-elements { + deviate replace { + min-elements 42; + } + } + + deviation /target/replace/max-elements { + deviate replace { + max-elements 42; + } + } + + deviation /target/replace/max-and-min-elements { + deviate replace { + max-elements 42; + min-elements 42; + } + } + + deviation /target/replace/type { + deviate replace { + type uint16; + } + } + + deviation /target/replace/units { + deviate replace { + units "fish per second"; + } + } +} diff --git a/src/webui/internal/goyang/pkg/yang/testdata/deviate.yang b/src/webui/internal/goyang/pkg/yang/testdata/deviate.yang new file mode 100644 index 00000000..d8fbaa60 --- /dev/null +++ b/src/webui/internal/goyang/pkg/yang/testdata/deviate.yang @@ -0,0 +1,81 @@ +module deviate { + prefix "d"; + namespace "urn:d"; + + typedef derived-string { + type string; + default "barnacles"; + } + + grouping substmts { + leaf config { type string; } + leaf default { type string; } + leaf default-typedef { type derived-string; } + leaf-list default-list { type string; default "foo"; default "bar"; } + leaf-list default-list-typedef-default { type derived-string; } + leaf mandatory { type string; } + leaf-list max-elements { type string; } + leaf-list min-elements { type string; } + leaf-list max-and-min-elements { type string; } + leaf type { type string; } + // TODO(robjs): unique requires a list target + leaf units { type uint16; } + } + + container target { + container add { + uses substmts; + } + } + + deviation /target/add/config { + deviate add { + config false; + } + } + + deviation /target/add/default { + deviate add { + default "a default value"; + } + } + + deviation /target/add/default-list { + deviate add { + default "foo"; + // TODO(wenovus): support multiple default statements for deviate. + //default "baz"; + } + } + + deviation /target/add/mandatory { + deviate add { + mandatory true; + } + } + + deviation /target/add/min-elements { + deviate add { + min-elements 42; + } + } + + deviation /target/add/max-elements { + deviate add { + max-elements 42; + } + } + + deviation /target/add/max-and-min-elements { + deviate add { + max-elements 42; + min-elements 42; + } + } + + deviation /target/add/units { + deviate add { + units "fish per second"; + } + } +} diff --git a/src/webui/internal/goyang/pkg/yang/testdata/find-file-test/blue.yang b/src/webui/internal/goyang/pkg/yang/testdata/find-file-test/blue.yang new file mode 100644 index 00000000..e69de29b diff --git a/src/webui/internal/goyang/pkg/yang/testdata/find-file-test/blue@2000-10-10.yang b/src/webui/internal/goyang/pkg/yang/testdata/find-file-test/blue@2000-10-10.yang new file mode 100644 index 00000000..e69de29b diff --git a/src/webui/internal/goyang/pkg/yang/testdata/find-file-test/dir/dirdir/red@2022-02-22.yang b/src/webui/internal/goyang/pkg/yang/testdata/find-file-test/dir/dirdir/red@2022-02-22.yang new file mode 100644 index 00000000..e69de29b diff --git a/src/webui/internal/goyang/pkg/yang/testdata/find-file-test/dir/red@2020-02-02.yang b/src/webui/internal/goyang/pkg/yang/testdata/find-file-test/dir/red@2020-02-02.yang new file mode 100644 index 00000000..e69de29b diff --git a/src/webui/internal/goyang/pkg/yang/testdata/find-file-test/dir/red@2020-02-20.yang b/src/webui/internal/goyang/pkg/yang/testdata/find-file-test/dir/red@2020-02-20.yang new file mode 100644 index 00000000..e69de29b diff --git a/src/webui/internal/goyang/pkg/yang/testdata/find-file-test/non-standard.name b/src/webui/internal/goyang/pkg/yang/testdata/find-file-test/non-standard.name new file mode 100644 index 00000000..e69de29b diff --git a/src/webui/internal/goyang/pkg/yang/testdata/find-file-test/red@2010-10-10.yang b/src/webui/internal/goyang/pkg/yang/testdata/find-file-test/red@2010-10-10.yang new file mode 100644 index 00000000..e69de29b diff --git a/src/webui/internal/goyang/pkg/yang/testdata/find-file-test/red@2222-2-22.yang b/src/webui/internal/goyang/pkg/yang/testdata/find-file-test/red@2222-2-22.yang new file mode 100644 index 00000000..e69de29b diff --git a/src/webui/internal/goyang/pkg/yang/types.go b/src/webui/internal/goyang/pkg/yang/types.go new file mode 100644 index 00000000..2475fa4a --- /dev/null +++ b/src/webui/internal/goyang/pkg/yang/types.go @@ -0,0 +1,425 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +// This file implements the functions relating to types and typedefs. + +import ( + "errors" + "fmt" + "regexp/syntax" + "sync" +) + +// A typeDictionary is a dictionary of all Typedefs defined in all Typedefers. +// A map of Nodes is used rather than a map of Typedefers to simplify usage +// when traversing up a Node tree. +type typeDictionary struct { + mu sync.Mutex + dict map[Node]map[string]*Typedef + // identities contains a dictionary of resolved identities. + identities identityDictionary +} + +func newTypeDictionary() *typeDictionary { + return &typeDictionary{ + dict: map[Node]map[string]*Typedef{}, + identities: identityDictionary{dict: map[string]resolvedIdentity{}}, + } +} + +// add adds an entry to the typeDictionary d. +func (d *typeDictionary) add(n Node, name string, td *Typedef) { + defer d.mu.Unlock() + d.mu.Lock() + if d.dict[n] == nil { + d.dict[n] = map[string]*Typedef{} + } + d.dict[n][name] = td +} + +// find returns the Typedef name define in node n, or nil. +func (d *typeDictionary) find(n Node, name string) *Typedef { + defer d.mu.Unlock() + d.mu.Lock() + if d.dict[n] == nil { + return nil + } + return d.dict[n][name] +} + +// findExternal finds the externally-defined typedef name in a module imported +// by n's root with the specified prefix. +func (d *typeDictionary) findExternal(n Node, prefix, name string) (*Typedef, error) { + root := FindModuleByPrefix(n, prefix) + if root == nil { + return nil, fmt.Errorf("%s: unknown prefix: %s for type %s", Source(n), prefix, name) + } + if td := d.find(root, name); td != nil { + return td, nil + } + if prefix != "" { + name = prefix + ":" + name + } + return nil, fmt.Errorf("%s: unknown type %s", Source(n), name) +} + +// typedefs returns a slice of all typedefs in d. +func (d *typeDictionary) typedefs() []*Typedef { + var tds []*Typedef + defer d.mu.Unlock() + d.mu.Lock() + for _, dict := range d.dict { + for _, td := range dict { + tds = append(tds, td) + } + } + return tds +} + +// addTypedefs is called from BuildAST after each Typedefer is defined. There +// are no error conditions in this process as it is simply used to build up the +// typedef dictionary. +func (d *typeDictionary) addTypedefs(t Typedefer) { + for _, td := range t.Typedefs() { + d.add(t, td.Name, td) + } +} + +// resolveTypedefs is called after all of modules and submodules have been read, +// as well as their imports and includes. It resolves all typedefs found in all +// modules and submodules read in. +func (d *typeDictionary) resolveTypedefs() []error { + var errs []error + + // When resolve typedefs, we may need to look up other typedefs. + // We gather all typedefs into a slice so we don't deadlock on + // typeDict. + for _, td := range d.typedefs() { + errs = append(errs, td.resolve(d)...) + } + return errs +} + +// resolve creates a YangType for t, if not already done. Resolving t +// requires resolving the Type that t is based on. +func (t *Typedef) resolve(d *typeDictionary) []error { + // If we have no parent we are a base type and + // are already resolved. + if t.Parent == nil || t.YangType != nil { + return nil + } + + if errs := t.Type.resolve(d); len(errs) != 0 { + return errs + } + + // Make a copy of the YangType we are based on and then + // update it with local information. + y := *t.Type.YangType + y.Name = t.Name + y.Base = t.Type + + if t.Units != nil { + y.Units = t.Units.Name + } + if t.Default != nil { + y.HasDefault = true + y.Default = t.Default.Name + } + + if t.Type.IdentityBase != nil { + // We need to copy over the IdentityBase statement if the type has one + if idBase, err := RootNode(t).findIdentityBase(t.Type.IdentityBase.Name); err == nil { + y.IdentityBase = idBase.Identity + } else { + return []error{fmt.Errorf("could not resolve identity base for typedef: %s", t.Type.IdentityBase.Name)} + } + } + + // If we changed something, we are the new root. + if y.Root == t.Type.YangType || !y.Equal(y.Root) { + y.Root = &y + } + t.YangType = &y + return nil +} + +// resolve resolves Type t, as well as the underlying typedef for t. If t +// cannot be resolved then one or more errors are returned. +func (t *Type) resolve(d *typeDictionary) (errs []error) { + if t.YangType != nil { + return nil + } + + // If t.Name is a base type then td will not be nil, otherwise + // td will be nil and of type *Typedef. + td := BaseTypedefs[t.Name] + + prefix, name := getPrefix(t.Name) + root := RootNode(t) + rootPrefix := root.GetPrefix() + + source := "unknown" +check: + switch { + case td != nil: + source = "builtin" + // This was a base type + case prefix == "" || rootPrefix == prefix: + source = "local" + // If we have no prefix, or the prefix is what we call our own + // root, then we look in our ancestors for a typedef of name. + for n := Node(t); n != nil; n = n.ParentNode() { + if td = d.find(n, name); td != nil { + break check + } + } + // We need to check our sub-modules as well + for _, in := range root.Include { + if td = d.find(in.Module, name); td != nil { + break check + } + } + var pname string + switch { + case prefix == "", prefix == root.Prefix.Name: + pname = root.Prefix.Name + ":" + t.Name + default: + pname = fmt.Sprintf("%s[%s]:%s", prefix, root.Prefix.Name, t.Name) + } + + return []error{fmt.Errorf("%s: unknown type: %s", Source(t), pname)} + + default: + source = "imported" + // prefix is not local to our module, so we have to go find + // what module it is part of and if it is defined at the top + // level of that module. + var err error + td, err = d.findExternal(t, prefix, name) + if err != nil { + return []error{err} + } + } + if errs := td.resolve(d); len(errs) > 0 { + return errs + } + + // Make a copy of the typedef we are based on so we can + // augment it. + if td.YangType == nil { + return []error{fmt.Errorf("%s: no YangType defined for %s %s", Source(td), source, td.Name)} + } + y := *td.YangType + + y.Base = td.Type + t.YangType = &y + + if v := t.RequireInstance; v != nil { + b, err := v.asBool() + if err != nil { + errs = append(errs, err) + } + y.OptionalInstance = !b + } + if v := t.Path; v != nil { + y.Path = v.asString() + } + isDecimal64 := y.Kind == Ydecimal64 && (t.Name == "decimal64" || y.FractionDigits != 0) + switch { + case isDecimal64 && y.FractionDigits != 0: + if t.FractionDigits != nil { + return append(errs, fmt.Errorf("%s: overriding of fraction-digits not allowed", Source(t))) + } + // FractionDigits already set via type inheritance. + case isDecimal64: + // If we are directly of type decimal64 then we must specify + // fraction-digits in the range from 1-18. + i, err := t.FractionDigits.asRangeInt(1, 18) + if err != nil { + errs = append(errs, fmt.Errorf("%s: %v", Source(t), err)) + } + y.FractionDigits = int(i) + // We only know to how to populate Range after knowing the + // fractional digit value. + y.Range = YangRange{{ + Number{Value: AbsMinInt64, Negative: true, FractionDigits: uint8(i)}, + Number{Value: MaxInt64, FractionDigits: uint8(i)}, + }} + case t.FractionDigits != nil: + errs = append(errs, fmt.Errorf("%s: fraction-digits only allowed for decimal64 values", Source(t))) + case y.Kind == Yidentityref: + if source != "builtin" { + // This is a typedef that refers to an identityref, so we want to simply + // maintain the base that the typedef resolution provided + break + } + + if t.IdentityBase == nil { + errs = append(errs, fmt.Errorf("%s: an identityref must specify a base", Source(t))) + break + } + + root := RootNode(t.Parent) + resolvedBase, baseErr := root.findIdentityBase(t.IdentityBase.Name) + if baseErr != nil { + errs = append(errs, baseErr...) + break + } + + if resolvedBase.Identity == nil { + errs = append(errs, fmt.Errorf("%s: identity has a null base", t.IdentityBase.Name)) + break + } + y.IdentityBase = resolvedBase.Identity + } + + if t.Range != nil { + yr, err := y.Range.parseChildRanges(t.Range.Name, isDecimal64, uint8(y.FractionDigits)) + switch { + case err != nil: + errs = append(errs, fmt.Errorf("%s: bad range: %v", Source(t.Range), err)) + case yr.Equal(y.Range): + default: + y.Range = yr + } + } + + if t.Length != nil { + parentRange := Uint64Range + if y.Length != nil { + parentRange = y.Length + } + yr, err := parentRange.parseChildRanges(t.Length.Name, false, 0) + switch { + case err != nil: + errs = append(errs, fmt.Errorf("%s: bad length: %v", Source(t.Length), err)) + case yr.Equal(y.Length): + default: + for _, r := range yr { + if r.Min.Negative { + errs = append(errs, fmt.Errorf("%s: negative length: %v", Source(t.Length), yr)) + break + } + } + y.Length = yr + } + } + + set := func(e *EnumType, name string, value *Value) error { + if value == nil { + return e.SetNext(name) + } + n, err := ParseInt(value.Name) + if err != nil { + return err + } + i, err := n.Int() + if err != nil { + return err + } + return e.Set(name, i) + } + + if len(t.Enum) > 0 { + enum := NewEnumType() + for _, e := range t.Enum { + if err := set(enum, e.Name, e.Value); err != nil { + errs = append(errs, fmt.Errorf("%s: %v", Source(e), err)) + } + } + y.Enum = enum + } + + if len(t.Bit) > 0 { + bit := NewBitfield() + for _, e := range t.Bit { + if err := set(bit, e.Name, e.Position); err != nil { + errs = append(errs, fmt.Errorf("%s: %v", Source(e), err)) + } + } + y.Bit = bit + } + + // Append any newly found patterns to the end of the list of patterns. + // Patterns are ANDed according to section 9.4.6. If all the patterns + // declared by t were also declared by the type t is based on, then + // no patterns are added. + seenPatterns := map[string]bool{} + for _, p := range y.Pattern { + seenPatterns[p] = true + } + seenPOSIXPatterns := map[string]bool{} + for _, p := range y.POSIXPattern { + seenPOSIXPatterns[p] = true + } + + // First parse out the pattern statements. + // These patterns are not checked because there is no support for W3C regexes by Go. + for _, pv := range t.Pattern { + if !seenPatterns[pv.Name] { + seenPatterns[pv.Name] = true + y.Pattern = append(y.Pattern, pv.Name) + } + } + + // Then, parse out the posix-pattern statements, if they exist. + // A YANG module could make use of either or both, so we deal with each separately. + posixPatterns, err := MatchingExtensions(t, "openconfig-extensions", "posix-pattern") + if err != nil { + return []error{err} + } + + checkPattern := func(n Node, p string, flags syntax.Flags) { + if _, err := syntax.Parse(p, flags); err != nil { + if re, ok := err.(*syntax.Error); ok { + // Error adds "error parsing regexp" to + // the error, re.Code is the real error. + err = errors.New(re.Code.String()) + } + errs = append(errs, fmt.Errorf("%s: bad pattern: %v: %s", Source(n), err, p)) + } + } + for _, ext := range posixPatterns { + checkPattern(ext, ext.Argument, syntax.POSIX) + if !seenPOSIXPatterns[ext.Argument] { + seenPOSIXPatterns[ext.Argument] = true + y.POSIXPattern = append(y.POSIXPattern, ext.Argument) + } + } + + // I don't know of an easy way to use a type as a key to a map, + // so we have to check equality the hard way. +looking: + for _, ut := range t.Type { + errs = append(errs, ut.resolve(d)...) + if ut.YangType != nil { + for _, yt := range y.Type { + if ut.YangType.Equal(yt) { + continue looking + } + } + y.Type = append(y.Type, ut.YangType) + } + } + + // If we changed something, we are the new root. + if !y.Equal(y.Root) { + y.Root = &y + } + + return errs +} diff --git a/src/webui/internal/goyang/pkg/yang/types_builtin.go b/src/webui/internal/goyang/pkg/yang/types_builtin.go new file mode 100644 index 00000000..f87e1b93 --- /dev/null +++ b/src/webui/internal/goyang/pkg/yang/types_builtin.go @@ -0,0 +1,700 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +// This module contains all the builtin types as well as types related +// to types (such as ranges, enums, etc). + +import ( + "errors" + "fmt" + "math" + "sort" + "strconv" + "strings" +) + +// This file handles interpretation of types + +// These are the default ranges defined by the YANG standard. +var ( + Int8Range = mustParseRangesInt("-128..127") + Int16Range = mustParseRangesInt("-32768..32767") + Int32Range = mustParseRangesInt("-2147483648..2147483647") + Int64Range = mustParseRangesInt("-9223372036854775808..9223372036854775807") + + Uint8Range = mustParseRangesInt("0..255") + Uint16Range = mustParseRangesInt("0..65535") + Uint32Range = mustParseRangesInt("0..4294967295") + Uint64Range = mustParseRangesInt("0..18446744073709551615") +) + +const ( + // MaxInt64 corresponds to the maximum value of a signed int64. + MaxInt64 = 1<<63 - 1 + // MinInt64 corresponds to the maximum value of a signed int64. + MinInt64 = -1 << 63 + // Min/MaxDecimal64 are the max/min decimal64 values. + MinDecimal64 float64 = -922337203685477580.8 + MaxDecimal64 float64 = 922337203685477580.7 + // AbsMinInt64 is the absolute value of MinInt64. + AbsMinInt64 = 1 << 63 + // MaxEnum is the maximum value of an enumeration. + MaxEnum = 1<<31 - 1 + // MinEnum is the minimum value of an enumeration. + MinEnum = -1 << 31 + // MaxBitfieldSize is the maximum number of bits in a bitfield. + MaxBitfieldSize = 1 << 32 + // MaxFractionDigits is the maximum number of fractional digits as per RFC6020 Section 9.3.4. + MaxFractionDigits uint8 = 18 + + space18 = "000000000000000000" // used for prepending 0's +) + +// A Number is either an integer the range of [-(1<<64) - 1, (1<<64)-1], or a +// YANG decimal conforming to https://tools.ietf.org/html/rfc6020#section-9.3.4. +type Number struct { + // Absolute value of the number. + Value uint64 + // Number of fractional digits. + // 0 means it's an integer. For decimal64 it falls within [1, 18]. + FractionDigits uint8 + // Negative indicates whether the number is negative. + Negative bool +} + +// IsDecimal reports whether n is a decimal number. +func (n Number) IsDecimal() bool { + return n.FractionDigits != 0 +} + +// String returns n as a string in decimal. +func (n Number) String() string { + out := strconv.FormatUint(n.Value, 10) + + if n.IsDecimal() { + if fd := int(n.FractionDigits); fd > 0 { + ofd := len(out) - fd + if ofd <= 0 { + // We want 0.1 not .1 + out = space18[:-ofd+1] + out + ofd = 1 + } + out = out[:ofd] + "." + out[ofd:] + } + } + if n.Negative { + out = "-" + out + } + + return out +} + +// Int returns n as an int64. It returns an error if n overflows an int64 or +// the number is decimal. +func (n Number) Int() (int64, error) { + if n.IsDecimal() { + return 0, errors.New("called Int() on decimal64 value") + } + if n.Negative { + return -int64(n.Value), nil + } + if n.Value <= MaxInt64 { + return int64(n.Value), nil + } + return 0, errors.New("signed integer overflow") +} + +// addQuantum adds the smallest quantum to n without checking overflow. +func (n Number) addQuantum(i uint64) Number { + switch n.Negative { + case true: + if n.Value <= i { + n.Value = i - n.Value + n.Negative = false + } else { + n.Value -= i + } + case false: + n.Value += i + } + return n +} + +// Less returns true if n is less than m. Panics if n and m are a mix of integer +// and decimal. +func (n Number) Less(m Number) bool { + switch { + case n.Negative && !m.Negative: + return true + case !n.Negative && m.Negative: + return false + } + + nt, mt := n.Trunc(), m.Trunc() + lt := nt < mt + if nt == mt { + nf, mf := n.frac(), m.frac() + if nf == mf { + return false + } + lt = nf < mf + } + + if n.Negative { + return !lt + } + return lt +} + +// Equal returns true if n is equal to m. +func (n Number) Equal(m Number) bool { + return !n.Less(m) && !m.Less(n) +} + +// Trunc returns the whole part of abs(n) as a signed integer. +func (n Number) Trunc() uint64 { + nv := n.Value + e := pow10(n.FractionDigits) + return nv / e +} + +// frac returns the fraction part with a precision of 18 fractional digits. +// E.g. if n is 3.1 then n.frac() returns 100,000,000,000,000,000 +func (n Number) frac() uint64 { + frac := n.FractionDigits + i := n.Trunc() * pow10(frac) + return (n.Value - i) * pow10(uint8(18-frac)) +} + +// YRange is a single range of consecutive numbers, inclusive. +type YRange struct { + Min Number + Max Number +} + +// Valid returns false if r is not a valid range (min > max). +func (r YRange) Valid() bool { + return !r.Max.Less(r.Min) +} + +// String returns r as a string using YANG notation, either a simple +// value if min == max or min..max. +func (r YRange) String() string { + if r.Min.Equal(r.Max) { + return r.Min.String() + } + return r.Min.String() + ".." + r.Max.String() +} + +// Equal compares whether two YRanges are equal. +func (r YRange) Equal(s YRange) bool { + return r.Min.Equal(s.Min) && r.Max.Equal(s.Max) +} + +// A YangRange is a set of non-overlapping ranges. +type YangRange []YRange + +// String returns the ranges r using YANG notation. Individual ranges +// are separated by pipes (|). +func (r YangRange) String() string { + s := make([]string, len(r)) + for i, r := range r { + s[i] = r.String() + } + return strings.Join(s, "|") +} + +func (r YangRange) Len() int { return len(r) } +func (r YangRange) Swap(i, j int) { r[i], r[j] = r[j], r[i] } +func (r YangRange) Less(i, j int) bool { + switch { + case r[i].Min.Less(r[j].Min): + return true + case r[j].Min.Less(r[i].Min): + return false + default: + return r[i].Max.Less(r[j].Max) + } +} + +// Validate returns an error if r has either an invalid range or has +// overlapping ranges. +// r is expected to be sorted use YangRange.Sort() +func (r YangRange) Validate() error { + if !sort.IsSorted(r) { + return errors.New("range not sorted") + } + switch { + case len(r) == 0: + return nil + case !r[0].Valid(): + return errors.New("invalid number") + } + p := r[0] + + for _, n := range r[1:] { + if n.Min.Less(p.Max) { + return errors.New("overlapping ranges") + } + } + return nil +} + +// Sort r. Must be called before Validate and coalesce if unsorted +func (r YangRange) Sort() { + sort.Sort(r) +} + +// Equal returns true if ranges r and q are identically equivalent. +// TODO(borman): should we coalesce ranges in the comparison? +func (r YangRange) Equal(q YangRange) bool { + if len(r) != len(q) { + return false + } + for i, r := range r { + if !r.Equal(q[i]) { + return false + } + } + return true +} + +// Contains returns true if all possible values in s are also possible values +// in r. An empty range is assumed to be min..max when it is the receiver +// argument. +func (r YangRange) Contains(s YangRange) bool { + if len(r) == 0 || len(s) == 0 { + return true + } + + // Check if every range in s is subsumed under r. + // Both range lists should be in order and non-adjacent (coalesced). + ri := 0 + for _, ss := range s { + for r[ri].Max.Less(ss.Min) { + ri++ + if ri == len(r) { + return false + } + } + if ss.Min.Less(r[ri].Min) || r[ri].Max.Less(ss.Max) { + return false + } + } + return true +} + +// FromInt creates a Number from an int64. +func FromInt(i int64) Number { + if i < 0 { + return Number{Negative: true, Value: uint64(-i)} + } + return Number{Value: uint64(i)} +} + +// FromUint creates a Number from a uint64. +func FromUint(i uint64) Number { + return Number{Value: i} +} + +// FromFloat creates a Number from a float64. Input values with absolute value +// outside the boundaries specified for the decimal64 value specified in +// RFC6020/RFC7950 are clamped down to the closest boundary value. +func FromFloat(f float64) Number { + if f > MaxDecimal64 { + return Number{ + Value: FromInt(MaxInt64).Value, + FractionDigits: 1, + } + } + if f < MinDecimal64 { + return Number{ + Negative: true, + Value: FromInt(MaxInt64).Value, + FractionDigits: 1, + } + } + + // Per RFC7950/6020, fraction-digits must be at least 1. + fracDig := uint8(1) + f *= 10.0 + for ; Frac(f) != 0.0 && fracDig <= MaxFractionDigits; fracDig++ { + f *= 10.0 + } + negative := false + if f < 0 { + negative = true + f = -f + } + v := uint64(f) + + return Number{Negative: negative, Value: v, FractionDigits: fracDig} +} + +// ParseInt returns s as a Number with FractionDigits=0. +// octal, or hexadecimal using the standard prefix notations (e.g., 0 and 0x) +func ParseInt(s string) (Number, error) { + s = strings.TrimSpace(s) + var n Number + switch s { + case "": + return n, errors.New("converting empty string to number") + case "+", "-": + return n, errors.New("sign with no value") + } + + ns := s + switch s[0] { + case '+': + ns = s[1:] + case '-': + n.Negative = true + ns = s[1:] + } + + var err error + n.Value, err = strconv.ParseUint(ns, 0, 64) + return n, err +} + +// ParseDecimal returns s as a Number with a non-zero FractionDigits. +// octal, or hexadecimal using the standard prefix notations (e.g., 0 and 0x) +func ParseDecimal(s string, fracDigRequired uint8) (n Number, err error) { + s = strings.TrimSpace(s) + switch s { + case "": + return n, errors.New("converting empty string to number") + case "+", "-": + return n, errors.New("sign with no value") + } + + return decimalValueFromString(s, fracDigRequired) +} + +// decimalValueFromString returns a decimal Number representation of numStr. +// fracDigRequired is used to set the number of fractional digits, which must +// be at least the greatest precision seen in numStr. +// which must be between 1 and 18. +// numStr must conform to Section 9.3.4. +func decimalValueFromString(numStr string, fracDigRequired uint8) (n Number, err error) { + if fracDigRequired > MaxFractionDigits || fracDigRequired < 1 { + return n, fmt.Errorf("invalid number of fraction digits %d > max of %d, minimum 1", fracDigRequired, MaxFractionDigits) + } + + s := numStr + dx := strings.Index(s, ".") + var fracDig uint8 + if dx >= 0 { + fracDig = uint8(len(s) - 1 - dx) + // remove first decimal, if dx > 1, will fail ParseInt below + s = s[:dx] + s[dx+1:] + } + + if fracDig > fracDigRequired { + return n, fmt.Errorf("%s has too much precision, expect <= %d fractional digits", s, fracDigRequired) + } + + s += space18[:fracDigRequired-fracDig] + + v, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return n, fmt.Errorf("%s is not a valid decimal number: %s", numStr, err) + } + + negative := false + if v < 0 { + negative = true + v = -v + } + + return Number{Value: uint64(v), FractionDigits: fracDigRequired, Negative: negative}, nil +} + +// ParseRangesInt parses s into a series of ranges. Each individual range is in s +// is separated by the pipe character (|). The min and max value of a range +// are separated by "..". An error is returned if the range is invalid. The +// output range is sorted and coalesced. +func ParseRangesInt(s string) (YangRange, error) { + return YangRange{}.parseChildRanges(s, false, 0) +} + +// ParseRangesDecimal parses s into a series of ranges. Each individual range is in s +// is separated by the pipe character (|). The min and max value of a range +// are separated by "..". An error is returned if the range is invalid. The +// output range is sorted and coalesced. +func ParseRangesDecimal(s string, fracDigRequired uint8) (YangRange, error) { + return YangRange{}.parseChildRanges(s, true, fracDigRequired) +} + +// parseChildRanges parses a child ranges statement 's' into a series of ranges +// based on an already-parsed parent YangRange. Each individual range is in s +// is separated by the pipe character (|). The min and max value of a range are +// separated by "..". An error is returned if the child ranges are not +// equally-limiting or more limiting than the parent range +// (rfc7950#section-9.2.5). The output range is sorted and coalesced. +// fracDigRequired is ignored when decimal=false. +func (y YangRange) parseChildRanges(s string, decimal bool, fracDigRequired uint8) (YangRange, error) { + parseNumber := func(s string) (Number, error) { + switch { + case s == "max": + if len(y) == 0 { + return Number{}, errors.New("cannot resolve 'max' keyword using an empty YangRange parent object") + } + max := y[len(y)-1].Max + max.FractionDigits = fracDigRequired + return max, nil + case s == "min": + if len(y) == 0 { + return Number{}, errors.New("cannot resolve 'min' keyword using an empty YangRange parent object") + } + min := y[0].Min + min.FractionDigits = fracDigRequired + return min, nil + case decimal: + return ParseDecimal(s, fracDigRequired) + default: + return ParseInt(s) + } + } + + parts := strings.Split(s, "|") + r := make(YangRange, len(parts)) + for i, s := range parts { + parts := strings.Split(s, "..") + min, err := parseNumber(strings.TrimSpace(parts[0])) + if err != nil { + return nil, err + } + var max Number + switch len(parts) { + case 1: + max = min + case 2: + if max, err = parseNumber(strings.TrimSpace(parts[1])); err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("too many '..' in %s", s) + } + if max.Less(min) { + return nil, fmt.Errorf("range boundaries out of order (%s less than %s): %s", max, min, s) + } + r[i] = YRange{min, max} + } + r.Sort() + r = coalesce(r) + + if !y.Contains(r) { + return nil, fmt.Errorf("%v not within %v", s, y) + } + + if err := r.Validate(); err != nil { + return nil, err + } + return r, nil +} + +// coalesce coalesces r into as few ranges as possible. For example, +// 1..5|6..10 would become 1..10. r is assumed to be sorted. +func coalesce(r YangRange) YangRange { + // coalesce the ranges if we have more than 1. + if len(r) < 2 { + return r + } + cr := make(YangRange, len(r)) + i := 0 + cr[i] = r[0] + for _, r1 := range r[1:] { + // r1.Min is always at least as large as cr[i].Min + // Cases are: + // r1 is contained in cr[i] + // r1 starts inside of cr[i] + // r1.Min cr[i].Max+1 + // r1 is beyond cr[i] + if cr[i].Max.addQuantum(1).Less(r1.Min) { + // r1 starts after cr[i], this is a new range + i++ + cr[i] = r1 + } else if cr[i].Max.Less(r1.Max) { + cr[i].Max = r1.Max + } + } + return cr[:i+1] +} + +func mustParseRangesInt(s string) YangRange { + r, err := ParseRangesInt(s) + if err != nil { + panic(err) + } + return r +} + +func mustParseRangesDecimal(s string, fracDigRequired uint8) YangRange { + r, err := ParseRangesDecimal(s, fracDigRequired) + if err != nil { + panic(err) + } + return r +} + +// Frac returns the fractional part of f. +func Frac(f float64) float64 { + return f - math.Trunc(f) +} + +// pow10 returns 10^e without checking for overflow. +func pow10(e uint8) uint64 { + var out uint64 = 1 + for i := uint8(0); i < e; i++ { + out *= 10 + } + return out +} + +// A EnumType represents a mapping of strings to integers. It is used both +// for enumerations as well as bitfields. +type EnumType struct { + last int64 // maximum value assigned thus far + min int64 // minimum value allowed + max int64 // maximum value allowed + unique bool // numeric values must be unique (enums) + ToString map[int64]string `json:",omitempty"` // map of enum entries by value (integer) + ToInt map[string]int64 `json:",omitempty"` // map of enum entries by name (string) +} + +// NewEnumType returns an initialized EnumType. +func NewEnumType() *EnumType { + return &EnumType{ + last: -1, // +1 will start at 0 + min: MinEnum, + max: MaxEnum, + unique: true, + ToString: map[int64]string{}, + ToInt: map[string]int64{}, + } +} + +// NewBitfield returns an EnumType initialized as a bitfield. Multiple string +// values may map to the same numeric values. Numeric values must be small +// non-negative integers. +func NewBitfield() *EnumType { + return &EnumType{ + last: -1, // +1 will start at 0 + min: 0, + max: MaxBitfieldSize - 1, + ToString: map[int64]string{}, + ToInt: map[string]int64{}, + } +} + +// Set sets name in e to the provided value. Set returns an error if the value +// is invalid, name is already signed, or when used as an enum rather than a +// bitfield, the value has previousl been used. When two different names are +// assigned to the same value, the conversion from value to name will result in +// the most recently assigned name. +func (e *EnumType) Set(name string, value int64) error { + if _, ok := e.ToInt[name]; ok { + return fmt.Errorf("field %s already assigned", name) + } + if oname, ok := e.ToString[value]; e.unique && ok { + return fmt.Errorf("fields %s and %s conflict on value %d", name, oname, value) + } + if value < e.min { + return fmt.Errorf("value %d for %s too small (minimum is %d)", value, name, e.min) + } + if value > e.max { + return fmt.Errorf("value %d for %s too large (maximum is %d)", value, name, e.max) + } + e.ToString[value] = name + e.ToInt[name] = value + if value >= e.last { + e.last = value + } + return nil +} + +// SetNext sets the name in e using the next possible value that is greater than +// all previous values. +func (e *EnumType) SetNext(name string) error { + if e.last == MaxEnum { + return fmt.Errorf("enum %q must specify a value since previous enum is the maximum value allowed", name) + } + return e.Set(name, e.last+1) +} + +// Name returns the name in e associated with value. The empty string is +// returned if no name has been assigned to value. +func (e *EnumType) Name(value int64) string { return e.ToString[value] } + +// Value returns the value associated with name in e associated. 0 is returned +// if name is not in e, or if it is the first value in an unnumbered enum. Use +// IsDefined to definitively confirm name is in e. +func (e *EnumType) Value(name string) int64 { return e.ToInt[name] } + +// IsDefined returns true if name is defined in e, else false. +func (e *EnumType) IsDefined(name string) bool { + _, defined := e.ToInt[name] + return defined +} + +// Names returns the sorted list of enum string names. +func (e *EnumType) Names() []string { + names := make([]string, len(e.ToInt)) + i := 0 + for name := range e.ToInt { + names[i] = name + i++ + } + sort.Strings(names) + return names +} + +type int64Slice []int64 + +func (p int64Slice) Len() int { return len(p) } +func (p int64Slice) Less(i, j int) bool { return p[i] < p[j] } +func (p int64Slice) Swap(i, j int) { p[i], p[j] = p[j], p[i] } + +// Values returns the sorted list of enum values. +func (e *EnumType) Values() []int64 { + values := make([]int64, len(e.ToInt)) + i := 0 + for _, value := range e.ToInt { + values[i] = value + i++ + } + sort.Sort(int64Slice(values)) + return values +} + +// NameMap returns a map of names to values. +func (e *EnumType) NameMap() map[string]int64 { + m := make(map[string]int64, len(e.ToInt)) + for name, value := range e.ToInt { + m[name] = value + } + return m +} + +// ValueMap returns a map of values to names. +func (e *EnumType) ValueMap() map[int64]string { + m := make(map[int64]string, len(e.ToString)) + for name, value := range e.ToString { + m[name] = value + } + return m +} diff --git a/src/webui/internal/goyang/pkg/yang/types_builtin_test.go b/src/webui/internal/goyang/pkg/yang/types_builtin_test.go new file mode 100644 index 00000000..5ae654da --- /dev/null +++ b/src/webui/internal/goyang/pkg/yang/types_builtin_test.go @@ -0,0 +1,915 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +import ( + "encoding/json" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/openconfig/gnmi/errdiff" +) + +const ( + maxUint64 uint64 = 18446744073709551615 + maxUint32 = 0xFFFFFFFF + maxUint16 = 0xFFFF + maxUint8 = 0xFF + maxInt32 = 1<<31 - 1 + minInt32 = -1 << 31 + maxInt16 = 1<<15 - 1 + minInt16 = -1 << 15 + maxInt8 = 1<<7 - 1 + minInt8 = -1 << 7 +) + +// R is a test helper for creating an int-based YRange. +func R(a, b int64) YRange { + return YRange{FromInt(a), FromInt(b)} +} + +// Rf is a test helper for creating a float-based YRange. +func Rf(a, b int64, fracDig uint8) YRange { + n1 := Number{Value: uint64(a), FractionDigits: fracDig} + n2 := Number{Value: uint64(b), FractionDigits: fracDig} + if a < 0 { + n1.Value = uint64(-a) + n1.Negative = true + } + if b < 0 { + n2.Value = uint64(-b) + n2.Negative = true + } + return YRange{n1, n2} +} + +func TestFromFloat(t *testing.T) { + tests := []struct { + desc string + in float64 + want Number + }{{ + desc: "positive - no decimals", + in: 10.0, + want: Number{ + Negative: false, + Value: 10, + FractionDigits: 0, + }, + }, { + desc: "positive - decimals", + in: 10.15, + want: Number{ + Negative: false, + Value: 1015, + FractionDigits: 2, + }, + }, { + desc: "negative - no decimals", + in: -10.0, + want: Number{ + Negative: true, + Value: 10, + FractionDigits: 0, + }, + }, { + desc: "negative - decimals", + in: -10.15, + want: Number{ + Negative: true, + Value: 1015, + FractionDigits: 2, + }, + }} + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + if got := FromFloat(tt.in); !cmp.Equal(got, tt.want) { + t.Fatalf("FromFloat(%v): did not get expected value, got: %+v, want: %+v", tt.in, got, tt.want) + } + }) + } +} + +func TestNumberInt(t *testing.T) { + tests := []struct { + desc string + in Number + want int64 + wantErr bool + }{{ + desc: "zero", + in: FromInt(0), + want: 0, + }, { + desc: "positive", + in: FromInt(42), + want: 42, + }, { + desc: "negative", + in: FromInt(-42), + want: -42, + }, { + desc: "decimal", + in: FromFloat(42), + wantErr: true, + }, { + desc: "overflow", + in: FromUint(maxUint64), + wantErr: true, + }} + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + got, err := tt.in.Int() + if got != tt.want { + t.Errorf("got: %v, want: %v", got, tt.want) + } + if (err != nil) != tt.wantErr { + t.Errorf("gotErr: %v, wantErr: %v", err, tt.wantErr) + } + }) + } +} + +func TestRangeEqual(t *testing.T) { + tests := []struct { + desc string + inBaseRange YangRange + inTestRange YangRange + want bool + }{{ + desc: "empty range equals empty range", + want: true, + }, { + desc: "test range is default", + inBaseRange: YangRange{R(1, 2)}, want: false, + }, { + desc: "base range is default", + inTestRange: YangRange{R(1, 2)}, want: false, + }, { + desc: "equal ranges", + inBaseRange: YangRange{R(1, 2)}, + inTestRange: YangRange{R(1, 2)}, + want: true, + }, { + desc: "wider base range", + inBaseRange: YangRange{R(1, 3)}, + inTestRange: YangRange{R(1, 2)}, + want: false, + }, { + desc: "equal ranges with multiple subranges", + inBaseRange: YangRange{R(1, 2), R(4, 5)}, + inTestRange: YangRange{R(1, 2), R(4, 5)}, + want: true, + }, { + desc: "multiple subranges with one unequal", + inBaseRange: YangRange{R(1, 2), R(4, 6)}, + inTestRange: YangRange{R(1, 2), R(4, 5)}, + want: false, + }, { + desc: "extra subrange in base range", + inBaseRange: YangRange{R(1, 2)}, + inTestRange: YangRange{R(1, 2), R(4, 5)}, + want: false, + }, { + desc: "extra subrange in test range", + inBaseRange: YangRange{R(1, 2), R(4, 5)}, + inTestRange: YangRange{R(1, 2)}, + want: false, + }} + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + if want := tt.inBaseRange.Equal(tt.inTestRange); want != tt.want { + t.Errorf("got %v, want %v", want, tt.want) + } + }) + } +} + +func TestRangeContains(t *testing.T) { + tests := []struct { + desc string + inBaseRange YangRange + inTestRange YangRange + want bool + }{{ + desc: "empty range contained in empty range", + want: true, + }, { + desc: "empty range contained in non-empty range", + inBaseRange: YangRange{R(1, 2)}, + want: true, + }, { + desc: "non-empty range contained in empty range", + inTestRange: YangRange{R(1, 2)}, + want: true, + }, { + desc: "equal ranges contain", + inBaseRange: YangRange{R(1, 2)}, + inTestRange: YangRange{R(1, 2)}, + want: true, + }, { + desc: "superset contains", + inBaseRange: YangRange{R(1, 5)}, + inTestRange: YangRange{R(2, 3)}, + want: true, + }, { + desc: "subset doesn't contain", + inBaseRange: YangRange{R(2, 3)}, + inTestRange: YangRange{R(1, 5)}, + want: false, + }, { + desc: "contain subranges", + inBaseRange: YangRange{R(1, 10)}, + inTestRange: YangRange{R(1, 2), R(4, 5), R(7, 10)}, + want: true, + }, { + desc: "subranges leaks out", + inBaseRange: YangRange{R(1, 10)}, + inTestRange: YangRange{R(1, 2), R(7, 11)}, + want: false, + }, { + desc: "subranges containing a subset", + inBaseRange: YangRange{R(1, 9), R(11, 19), R(21, 29)}, + inTestRange: YangRange{R(23, 25)}, + want: true, + }, { + desc: "subranges containing a single valued range", + inBaseRange: YangRange{R(1, 9), R(11, 19), R(21, 29)}, + inTestRange: YangRange{R(23, 23)}, + want: true, + }, { + desc: "subranges doesn't contain a single outside value", + inBaseRange: YangRange{R(1, 9), R(11, 19), R(21, 29)}, + inTestRange: YangRange{R(20, 20)}, + want: false, + }, { + desc: "smaller range doesn't contain min..max", + inBaseRange: YangRange{R(1, 10)}, + inTestRange: YangRange{R(MinInt64, MaxInt64)}, + want: false, + }, { + desc: "full range contains any", + inBaseRange: YangRange{R(MinInt64, MaxInt64)}, + inTestRange: YangRange{R(1, 10)}, + want: true, + }, { + desc: "smaller range doesn't contain min..a|b..max", + inBaseRange: YangRange{R(1024, 65535)}, + inTestRange: YangRange{R(MinInt64, 4096), R(5120, MaxInt64)}, + want: false, + }, { + desc: "ranges don't overlap with max word used", + inBaseRange: YangRange{R(1024, 65535)}, + inTestRange: YangRange{R(-999999, 4096), R(5120, MaxInt64)}, + want: false, + }, { + desc: "ranges don't overlap with min word used", + inBaseRange: YangRange{R(1024, 65535)}, + inTestRange: YangRange{R(MinInt64, 4096), R(5120, 999999)}, + want: false, + }} + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + if got := tt.inBaseRange.Contains(tt.inTestRange); got != tt.want { + t.Errorf("got %v, want %v", got, tt.want) + } + }) + } +} + +func TestParseRangesInt(t *testing.T) { + tests := []struct { + desc string + inParentRange YangRange + in string + want YangRange + wantErrSubstring string + }{{ + desc: "small numbers, coalescing", + in: "0|2..3|4..5", + want: YangRange{R(0, 0), R(2, 5)}, + }, { + desc: "small numbers, out of order, coalescing", + in: "4..5|0|2..3", + want: YangRange{R(0, 0), R(2, 5)}, + }, { + desc: "invalid input: too many ..s", + in: "0|2..3|4..5..6", + wantErrSubstring: "too many '..' in 4..5..6", + }, { + desc: "invalid input: range boundaries out of order", + in: "0|2..3|5..4", + wantErrSubstring: "range boundaries out of order", + }, { + desc: "range with min", + inParentRange: Int64Range, + in: "min..0|2..3|4..5", + want: YangRange{R(MinInt64, 0), R(2, 5)}, + }, { + desc: "range with min but without parent range", + in: "min..0|2..3|4..5", + wantErrSubstring: "empty YangRange parent object", + }, { + desc: "range with max", + inParentRange: Int32Range, + in: "min..0|2..3|4..5|7..max", + want: YangRange{R(minInt32, 0), R(2, 5), R(7, maxInt32)}, + }, { + desc: "coalescing from min to max for uint64", + inParentRange: Uint64Range, + in: "min..0|1..max", + want: YangRange{YRange{FromInt(0), FromUint(maxUint64)}}, + }, { + desc: "coalescing from min to max for uint32", + inParentRange: Uint32Range, + in: "min..0|1..max", + want: YangRange{R(0, maxUint32)}, + }, { + desc: "coalescing from min to max for uint16", + inParentRange: Uint16Range, + in: "min..0|1..max", + want: YangRange{R(0, maxUint16)}, + }, { + desc: "coalescing from min to max for uint8", + inParentRange: Uint8Range, + in: "min..0|1..max", + want: YangRange{R(0, maxUint8)}, + }, { + desc: "coalescing from min to max for int64", + inParentRange: Int64Range, + in: "min..0|1..max", + want: YangRange{R(MinInt64, MaxInt64)}, + }, { + desc: "coalescing from min to max for int32", + inParentRange: Int32Range, + in: "min..0|1..max", + want: YangRange{R(minInt32, maxInt32)}, + }, { + desc: "coalescing from min to max for int16", + inParentRange: Int16Range, + in: "min..0|1..max", + want: YangRange{R(minInt16, maxInt16)}, + }, { + desc: "coalescing from min to max for int8", + inParentRange: Int8Range, + in: "min..0|1..max", + want: YangRange{R(minInt8, maxInt8)}, + }, { + desc: "spelling error", + inParentRange: Int64Range, + in: "mean..0|1..max", + wantErrSubstring: "invalid syntax", + }, { + desc: "big numbers, coalescing", + in: "0..69|4294967294|4294967295", + want: YangRange{R(0, 69), R(4294967294, 4294967295)}, + }, { + desc: "no ranges", + in: "250|500|1000", + want: YangRange{R(250, 250), R(500, 500), R(1000, 1000)}, + }, { + desc: "no ranges unsorted", + in: "1000|500|250", + want: YangRange{R(250, 250), R(500, 500), R(1000, 1000)}, + }, { + desc: "negative numbers", + in: "-31..-1|1..31", + want: YangRange{R(-31, -1), R(1, 31)}, + }, { + desc: "spaces", + in: "-22 | -15 | -7 | 0", + want: YangRange{R(-22, -22), R(-15, -15), R(-7, -7), R(0, 0)}, + }} + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + got, err := tt.inParentRange.parseChildRanges(tt.in, false, 0) + if err != nil { + if diff := errdiff.Substring(err, tt.wantErrSubstring); diff != "" { + t.Fatalf("did not get expected error, %s", diff) + } + return + } + + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("parseChildRanges (-want, +got):\n%s", diff) + } + + if tt.inParentRange == nil { + if got, err = ParseRangesInt(tt.in); err != nil { + t.Fatalf("ParseRangesInt: unexpected error: %v", err) + } + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("ParseRangesInt (-want, +got):\n%s", diff) + } + } + }) + } +} + +func TestCoalesce(t *testing.T) { + for x, tt := range []struct { + in, out YangRange + }{ + {}, + {YangRange{R(1, 4)}, YangRange{R(1, 4)}}, + {YangRange{R(1, 2), R(3, 4)}, YangRange{R(1, 4)}}, + {YangRange{Rf(10, 25, 1), Rf(30, 40, 1)}, YangRange{Rf(10, 25, 1), Rf(30, 40, 1)}}, + {YangRange{Rf(10, 29, 1), Rf(30, 40, 1)}, YangRange{Rf(10, 40, 1)}}, + {YangRange{R(1, 2), R(2, 4)}, YangRange{R(1, 4)}}, + {YangRange{R(1, 2), R(4, 5)}, YangRange{R(1, 2), R(4, 5)}}, + {YangRange{R(1, 3), R(2, 5)}, YangRange{R(1, 5)}}, + {YangRange{R(1, 10), R(2, 5)}, YangRange{R(1, 10)}}, + {YangRange{R(1, 10), R(1, 2), R(4, 5), R(7, 8)}, YangRange{R(1, 10)}}, + {YangRange{Rf(1, 10, 3), Rf(1, 2, 3), Rf(4, 5, 3), Rf(7, 8, 3)}, YangRange{Rf(1, 10, 3)}}, + } { + out := coalesce(tt.in) + if !out.Equal(tt.out) { + t.Errorf("#%d: got %v, want %v", x, out, tt.out) + } + } +} + +func TestYangRangeSort(t *testing.T) { + for x, tt := range []struct { + in, out YangRange + }{ + {YangRange{}, YangRange{}}, + {YangRange{R(1, 4), R(6, 10)}, YangRange{R(1, 4), R(6, 10)}}, + {YangRange{R(6, 10), R(1, 4)}, YangRange{R(1, 4), R(6, 10)}}, + {YangRange{Rf(10, 25, 1), Rf(30, 40, 1)}, YangRange{Rf(10, 25, 1), Rf(30, 40, 1)}}, + {YangRange{Rf(30, 40, 1), Rf(10, 25, 1)}, YangRange{Rf(10, 25, 1), Rf(30, 40, 1)}}, + {YangRange{R(1, 2)}, YangRange{R(1, 2)}}, + {YangRange{R(1, 2), R(4, 5)}, YangRange{R(1, 2), R(4, 5)}}, + {YangRange{R(1, 3), R(2, 5)}, YangRange{R(1, 3), R(2, 5)}}, + {YangRange{R(1, 10), R(2, 5)}, YangRange{R(1, 10), R(2, 5)}}, + {YangRange{R(1, 10), R(1, 2), R(4, 5), R(7, 8)}, YangRange{R(1, 2), R(1, 10), R(4, 5), R(7, 8)}}, + } { + tt.in.Sort() + if !tt.in.Equal(tt.out) { + t.Errorf("#%d: got %v, want %v", x, tt.in, tt.out) + } + } +} + +func TestParseRangesDecimal(t *testing.T) { + rangeMax := mustParseRangesDecimal("-922337203685477580.8..922337203685477580.7", 1) + rangeRestricted := mustParseRangesDecimal("-42..42|100", 5) + + tests := []struct { + desc string + inParentRange YangRange + in string + inFracDig uint8 + want YangRange + wantErrSubstring string + }{{ + desc: "min..max fraction-digits 1", + inParentRange: rangeMax, + in: "min..max", + inFracDig: 1, + want: YangRange{Rf(MinInt64, MaxInt64, 1)}, + }, { + desc: "min..max fraction-digits 2", + inParentRange: rangeMax, + in: "min..max", + inFracDig: 2, + want: YangRange{Rf(MinInt64, MaxInt64, 2)}, + }, { + desc: "min..max no parent range", + in: "min..max", + inFracDig: 2, + want: YangRange{Rf(MinInt64, MaxInt64, 2)}, + wantErrSubstring: "empty YangRange parent object", + }, { + desc: "min..max on fragmented range", + inParentRange: rangeRestricted, + in: "min..max", + inFracDig: 5, + wantErrSubstring: "not within", + }, { + desc: "small decimals", + inParentRange: rangeMax, + in: "0.0|2.0..30.0|1.34..1.99", + inFracDig: 2, + want: YangRange{Rf(0, 0, 2), Rf(134, 3000, 2)}, + }, { + desc: "small decimals on restricted range", + inParentRange: rangeRestricted, + in: "0.0|2.0..30.0|1.34..1.99999", + inFracDig: 5, + want: YangRange{Rf(0, 0, 5), Rf(134000, 3000000, 5)}, + }, { + desc: "small decimals with coalescing", + inParentRange: rangeMax, + in: "0.0|2.0..30.0", + inFracDig: 1, + want: YangRange{Rf(0, 0, 1), Rf(20, 300, 1)}, + }, { + desc: "fractional digit cannot be too high", + in: "0.0|2.0..30.0", + inFracDig: 19, + wantErrSubstring: "invalid number of fraction digits", + }, { + desc: "fractional digit cannot be 0", + in: "0.0|2.0..30.0", + inFracDig: 0, + wantErrSubstring: "invalid number of fraction digits", + }, { + desc: "big decimals", + in: "0.0..69|4294967294.1234|4294967295.1234", + inFracDig: 4, + want: YangRange{Rf(0, 690000, 4), Rf(42949672941234, 42949672941234, 4), Rf(42949672951234, 42949672951234, 4)}, + }, { + desc: "small decimals, out of order", + in: "4.0..5.55|0|2.32..3.23", + inFracDig: 3, + want: YangRange{Rf(0, 0, 3), Rf(2320, 3230, 3), Rf(4000, 5550, 3)}, + }, { + desc: "invalid input: too many ..s", + in: "4.0..5.55..6.66|0|2.32..3.23", + inFracDig: 3, + wantErrSubstring: "too many '..'", + }, { + desc: "invalid input: range boundaries out of order", + in: "5..4.0|0|2.32..3.23", + inFracDig: 3, + wantErrSubstring: "range boundaries out of order", + }, { + desc: "range with min", + inParentRange: rangeMax, + in: "4.0..5.55|min..0|2.32..3.23", + inFracDig: 3, + want: YangRange{Rf(MinInt64, 0, 3), Rf(2320, 3230, 3), Rf(4000, 5550, 3)}, + }, { + desc: "range with max", + inParentRange: rangeMax, + in: "4.0..max|min..0|2.32..3.23", + inFracDig: 3, + want: YangRange{Rf(MinInt64, 0, 3), Rf(2320, 3230, 3), Rf(4000, MaxInt64, 3)}, + }, { + desc: "coalescing from min to max", + inParentRange: rangeMax, + in: "min..0.9|1..max", + inFracDig: 1, + want: YangRange{Rf(MinInt64, MaxInt64, 1)}, + }, { + desc: "spelling error", + inParentRange: rangeMax, + in: "min..0.9|1..masks", + inFracDig: 1, + wantErrSubstring: "invalid syntax", + }, { + desc: "no ranges", + in: "250.55|500.0|1000", + inFracDig: 2, + want: YangRange{Rf(25055, 25055, 2), Rf(50000, 50000, 2), Rf(100000, 100000, 2)}, + }, { + desc: "no ranges unsorted", + in: "1000|500.0|250.55", + inFracDig: 2, + want: YangRange{Rf(25055, 25055, 2), Rf(50000, 50000, 2), Rf(100000, 100000, 2)}, + }, { + desc: "negative decimals", + in: "-31.2..-1.5|1.5..31.2", + inFracDig: 1, + want: YangRange{Rf(-312, -15, 1), Rf(15, 312, 1)}, + }, { + desc: "spaces", + in: "-22.5 | -15 | -7.5 | 0", + inFracDig: 1, + want: YangRange{Rf(-225, -225, 1), Rf(-150, -150, 1), Rf(-75, -75, 1), Rf(0, 0, 1)}, + }} + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + got, err := tt.inParentRange.parseChildRanges(tt.in, true, tt.inFracDig) + if err != nil { + if diff := errdiff.Substring(err, tt.wantErrSubstring); diff != "" { + t.Fatalf("did not get expected error, %s", diff) + } + return + } + + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("(-want, +got):\n%s", diff) + } + + if tt.inParentRange == nil { + if got, err = ParseRangesDecimal(tt.in, tt.inFracDig); err != nil { + t.Fatalf("ParseRangesDecimal: unexpected error: %v", err) + } + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("ParseRangesDecimal (-want, +got):\n%s", diff) + } + } + }) + } +} + +func TestAdd(t *testing.T) { + tests := []struct { + desc string + inVal Number + inAdd uint64 + want Number + }{{ + desc: "add one to integer", + inVal: FromInt(1), + inAdd: 1, + want: FromInt(2), + }, { + desc: "add one to decimal64", + inVal: FromFloat(1.0), + inAdd: 1, + want: FromFloat(1.1), + }, { + desc: "negative int becomes positive", + inVal: FromInt(-2), + inAdd: 3, + want: FromInt(1), + }, { + desc: "negative int stays negative", + inVal: FromInt(-3), + inAdd: 1, + want: FromInt(-2), + }, { + desc: "negative decimal becomes positive", + inVal: FromFloat(-2), + inAdd: 35, + want: FromFloat(1.5), + }, { + desc: "negative decimal stays negative", + inVal: FromFloat(-42.22), + inAdd: 4122, + want: FromFloat(-1.0), + }, { + desc: "explicitly set fraction digits", + inVal: Number{Value: 10000, FractionDigits: 5}, + inAdd: 1, + want: Number{Value: 10001, FractionDigits: 5}, + }, { + desc: "explicitly set fraction digits - negative", + inVal: Number{Value: 0, FractionDigits: 3}, + inAdd: 42, + want: FromFloat(0.042), + }} + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + got := tt.inVal.addQuantum(tt.inAdd) + if !cmp.Equal(got, tt.want) { + t.Fatalf("did get expected result, got: %s, want: %s", got.String(), tt.want.String()) + } + }) + } +} + +func TestParseInt(t *testing.T) { + tests := []struct { + desc string + inStr string + want Number + wantErrSubstring string + }{{ + desc: "invalid string supplied", + inStr: "fish", + wantErrSubstring: "valid syntax", + }, { + desc: "negative int", + inStr: "-42", + want: FromInt(-42), + }, { + desc: "positive int", + inStr: "42", + want: FromInt(42), + }, { + desc: "positive int with plus sign", + inStr: "+42", + want: FromInt(42), + }, { + desc: "zero", + inStr: "0", + want: FromInt(0), + }, { + desc: "min", + inStr: "min", + wantErrSubstring: "invalid syntax", + }, { + desc: "max", + inStr: "max", + wantErrSubstring: "invalid syntax", + }, { + desc: "just a sign", + inStr: "-", + wantErrSubstring: "sign with no value", + }} + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + got, err := ParseInt(tt.inStr) + if err != nil { + if diff := errdiff.Substring(err, tt.wantErrSubstring); diff != "" { + t.Fatalf("did not get expected error, %s", diff) + } + return + } + + if !cmp.Equal(got, tt.want) { + t.Errorf("did not get expected Number, got: %s, want: %s", got, tt.want) + } + + if got.IsDecimal() { + t.Errorf("Got decimal value instead of int: %v", got) + } + }) + } +} + +func TestParseDecimal(t *testing.T) { + tests := []struct { + desc string + inStr string + inFracDig uint8 + skipFractionDigitsCheck bool + want Number + wantErrSubstring string + }{{ + desc: "too few fractional digits", + inStr: "1.000", + inFracDig: 0, + wantErrSubstring: "invalid number of fraction digits", + }, { + desc: "too many fraction digits", + inStr: "1.000", + inFracDig: 24, + wantErrSubstring: "invalid number of fraction digits", + }, { + desc: "more digits supplied", + inStr: "1.14242", + inFracDig: 2, + wantErrSubstring: "has too much precision", + }, { + desc: "single digit precision", + inStr: "1.1", + inFracDig: 1, + want: Number{Value: 11, FractionDigits: 1}, + }, { + desc: "max precision", + inStr: "0.100000000000000000", + inFracDig: 18, + skipFractionDigitsCheck: true, + want: FromFloat(0.1), + }, { + desc: "max precision but not supplied", + inStr: "0.1", + inFracDig: 4, + skipFractionDigitsCheck: true, + want: FromFloat(0.1), + }, { + desc: "invalid string supplied", + inStr: "fish", + inFracDig: 17, + wantErrSubstring: "not a valid decimal number", + }, { + desc: "negative number", + inStr: "-42.0", + inFracDig: 1, + want: FromFloat(-42), + }} + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + got, err := ParseDecimal(tt.inStr, tt.inFracDig) + if err != nil { + if diff := errdiff.Substring(err, tt.wantErrSubstring); diff != "" { + t.Fatalf("did not get expected error, %s", diff) + } + return + } + + if !cmp.Equal(got, tt.want) { + t.Errorf("did not get expected Number, got: %s, want: %s", got, tt.want) + } + + if !tt.skipFractionDigitsCheck { + if got, want := got.FractionDigits, tt.want.FractionDigits; got != want { + t.Errorf("fractional digits not equal, got: %d, want: %d", got, want) + } + } + + if !got.IsDecimal() { + t.Errorf("Got non-decimal value: %v", got) + } + }) + } +} + +func TestNumberString(t *testing.T) { + tests := []struct { + desc string + in Number + want string + }{{ + desc: "min", + in: FromInt(MinInt64), + want: "-9223372036854775808", + }, { + desc: "max", + in: FromInt(MaxInt64), + want: "9223372036854775807", + }, { + desc: "integer", + in: Number{Value: 1}, + want: "1", + }, { + desc: "negative integer", + in: Number{Value: 1, Negative: true}, + want: "-1", + }, { + desc: "decimal, fractional digits = 1", + in: Number{Value: 1, FractionDigits: 1}, + want: "0.1", + }, { + desc: "decimal, fractional digits = 18", + in: Number{Value: 123456789012345678, FractionDigits: 18}, + want: "0.123456789012345678", + }, { + desc: "negative decimal", + in: Number{Value: 100, FractionDigits: 2, Negative: true}, + want: "-1.00", + }} + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + if got := tt.in.String(); got != tt.want { + t.Fatalf("did not get expected number, got: %s, want: %s", got, tt.want) + } + }) + } +} + +func TestEnumToJson(t *testing.T) { + tests := []struct { + desc string + in *EnumType + want string + wantErr bool + }{{ + "empty enum to JSON", + &EnumType{ + last: -1, // +1 will start at 0 + min: 0, + max: MaxBitfieldSize - 1, + ToString: map[int64]string{}, + ToInt: map[string]int64{}, + }, + `{}`, + false, + }, { + "2 value enum to JSON", + &EnumType{ + last: 2, + min: 0, + max: MaxBitfieldSize - 1, + ToString: map[int64]string{ + 1: "value1", + 2: "value2", + }, + ToInt: map[string]int64{ + "value1": 1, + "value2": 2, + }, + }, + `{"ToString":{"1":"value1","2":"value2"},"ToInt":{"value1":1,"value2":2}}`, + false, + }} + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + got, err := json.Marshal(tt.in) + if string(got) != tt.want { + t.Errorf("got: %v, want: %v", string(got), tt.want) + } + if (err != nil) != tt.wantErr { + t.Errorf("gotErr: %v, wantErr: %v", err, tt.wantErr) + } + }) + } +} diff --git a/src/webui/internal/goyang/pkg/yang/types_test.go b/src/webui/internal/goyang/pkg/yang/types_test.go new file mode 100644 index 00000000..d66a4f43 --- /dev/null +++ b/src/webui/internal/goyang/pkg/yang/types_test.go @@ -0,0 +1,1720 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +import ( + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/openconfig/gnmi/errdiff" +) + +func TestTypeResolve(t *testing.T) { + tests := []struct { + desc string + in *Type + err string + out *YangType + }{{ + desc: "basic int64", + in: &Type{ + Name: "int64", + }, + out: &YangType{ + Name: "int64", + Kind: Yint64, + Range: Int64Range, + }, + }, { + desc: "basic int64 with a range", + in: &Type{ + Name: "int64", + Range: &Range{Name: "-42..42"}, + }, + out: &YangType{ + Name: "int64", + Kind: Yint64, + Range: YangRange{{Min: FromInt(-42), Max: FromInt(42)}}, + }, + }, { + desc: "basic uint64 with an invalid range", + in: &Type{ + Name: "uint64", + Range: &Range{Name: "-42..42"}, + }, + err: "unknown: bad range: -42..42 not within 0..18446744073709551615", + }, { + desc: "basic uint64 with an unparseable range", + in: &Type{ + Name: "uint64", + Range: &Range{Name: "-42..forty-two"}, + }, + err: `unknown: bad range: strconv.ParseUint: parsing "forty-two": invalid syntax`, + }, { + desc: "basic string with a length", + in: &Type{ + Name: "string", + Length: &Length{Name: "24..42"}, + }, + out: &YangType{ + Name: "string", + Kind: Ystring, + Length: YangRange{{Min: FromInt(24), Max: FromInt(42)}}, + }, + }, { + desc: "basic string with an invalid range", + in: &Type{ + Name: "string", + Length: &Length{Name: "-42..42"}, + }, + err: `unknown: bad length: -42..42 not within 0..18446744073709551615`, + }, { + desc: "basic binary with a length", + in: &Type{ + Name: "binary", + Length: &Length{Name: "24..42"}, + }, + out: &YangType{ + Name: "binary", + Kind: Ybinary, + Length: YangRange{{Min: FromInt(24), Max: FromInt(42)}}, + }, + }, { + desc: "basic binary with an unparseable range", + in: &Type{ + Name: "binary", + Length: &Length{Name: "42..forty-two"}, + }, + err: `unknown: bad length: strconv.ParseUint: parsing "forty-two": invalid syntax`, + }, { + desc: "invalid fraction-digits argument for boolean value", + in: &Type{ + Name: "boolean", + FractionDigits: &Value{Name: "42"}, + }, + err: "unknown: fraction-digits only allowed for decimal64 values", + }, { + desc: "required field fraction-digits not supplied for decimal64", + in: &Type{ + Name: "decimal64", + }, + err: "unknown: value is required in the range of [1..18]", + }, { + desc: "invalid identityref that doesn't have a base identity name", + in: &Type{ + Name: "identityref", + }, + err: "unknown: an identityref must specify a base", + }, { + desc: "invalid decimal64 having an invalid fraction-digits value", + in: &Type{ + Name: "decimal64", + FractionDigits: &Value{Name: "42"}, + }, + err: "unknown: value 42 out of range [1..18]", + }, { + desc: "decimal64", + in: &Type{ + Name: "decimal64", + FractionDigits: &Value{Name: "7"}, + }, + out: &YangType{ + Name: "decimal64", + Kind: Ydecimal64, + FractionDigits: 7, + Range: YangRange{Rf(MinInt64, MaxInt64, 7)}, + }, + }, { + desc: "instance-identifier with unspecified require-instance value (default true)", + in: &Type{ + Name: "instance-identifier", + RequireInstance: nil, + }, + out: &YangType{ + Name: "instance-identifier", + Kind: YinstanceIdentifier, + // https://tools.ietf.org/html/rfc7950#section-9.9.3 + // require-instance defaults to true. + OptionalInstance: false, + }, + }, { + desc: "instance-identifier with true require-instance value", + in: &Type{ + Name: "instance-identifier", + RequireInstance: &Value{Name: "true"}, + }, + out: &YangType{ + Name: "instance-identifier", + Kind: YinstanceIdentifier, + OptionalInstance: false, + }, + }, { + desc: "instance-identifier with false require-instance value", + in: &Type{ + Name: "instance-identifier", + RequireInstance: &Value{Name: "false"}, + }, + out: &YangType{ + Name: "instance-identifier", + Kind: YinstanceIdentifier, + OptionalInstance: true, + }, + }, { + desc: "instance-identifier with invalid require-instance value", + in: &Type{ + Name: "instance-identifier", + RequireInstance: &Value{Name: "foo"}, + }, + err: "invalid boolean: foo", + }, { + desc: "enum with unspecified values", + in: &Type{ + Name: "enumeration", + Enum: []*Enum{ + {Name: "MERCURY"}, + {Name: "VENUS"}, + {Name: "EARTH"}, + }, + }, + out: &YangType{ + Name: "enumeration", + Kind: Yenum, + Enum: &EnumType{ + last: 2, + min: MinEnum, + max: MaxEnum, + unique: true, + ToString: map[int64]string{ + 0: "MERCURY", + 1: "VENUS", + 2: "EARTH", + }, + ToInt: map[string]int64{ + "MERCURY": 0, + "VENUS": 1, + "EARTH": 2, + }, + }, + }, + }, { + desc: "enum with specified values", + in: &Type{ + Name: "enumeration", + Enum: []*Enum{ + {Name: "MERCURY", Value: &Value{Name: "-1"}}, + {Name: "VENUS", Value: &Value{Name: "10"}}, + {Name: "EARTH", Value: &Value{Name: "30"}}, + }, + }, + out: &YangType{ + Name: "enumeration", + Kind: Yenum, + Enum: &EnumType{ + last: 30, + min: MinEnum, + max: MaxEnum, + unique: true, + ToString: map[int64]string{ + -1: "MERCURY", + 10: "VENUS", + 30: "EARTH", + }, + ToInt: map[string]int64{ + "MERCURY": -1, + "VENUS": 10, + "EARTH": 30, + }, + }, + }, + }, { + desc: "enum with some values specified", + in: &Type{ + Name: "enumeration", + Enum: []*Enum{ + {Name: "MERCURY", Value: &Value{Name: "-1"}}, + {Name: "VENUS", Value: &Value{Name: "10"}}, + {Name: "EARTH"}, + }, + }, + out: &YangType{ + Name: "enumeration", + Kind: Yenum, + Enum: &EnumType{ + last: 11, + min: MinEnum, + max: MaxEnum, + unique: true, + ToString: map[int64]string{ + -1: "MERCURY", + 10: "VENUS", + 11: "EARTH", + }, + ToInt: map[string]int64{ + "MERCURY": -1, + "VENUS": 10, + "EARTH": 11, + }, + }, + }, + }, { + desc: "enum with repeated specified values", + in: &Type{ + Name: "enumeration", + Enum: []*Enum{ + {Name: "MERCURY", Value: &Value{Name: "1"}}, + {Name: "VENUS", Value: &Value{Name: "10"}}, + {Name: "EARTH", Value: &Value{Name: "1"}}, + }, + }, + err: "unknown: fields EARTH and MERCURY conflict on value 1", + }, { + desc: "enum with repeated specified names", + in: &Type{ + Name: "enumeration", + Enum: []*Enum{ + {Name: "MERCURY", Value: &Value{Name: "-1"}}, + {Name: "VENUS", Value: &Value{Name: "10"}}, + {Name: "MERCURY", Value: &Value{Name: "30"}}, + }, + }, + err: "unknown: field MERCURY already assigned", + }, { + desc: "enum with last specified value equal to the max enum value", + in: &Type{ + Name: "enumeration", + Enum: []*Enum{ + {Name: "MERCURY", Value: &Value{Name: "-2147483648"}}, + {Name: "VENUS", Value: &Value{Name: "2147483647"}}, + {Name: "EARTH"}, + }, + }, + err: `unknown: enum "EARTH" must specify a value since previous enum is the maximum value allowed`, + }, { + desc: "enum value too small", + in: &Type{ + Name: "enumeration", + Enum: []*Enum{ + {Name: "MERCURY", Value: &Value{Name: "-2147483649"}}, + {Name: "VENUS", Value: &Value{Name: "0"}}, + {Name: "EARTH"}, + }, + }, + err: `unknown: value -2147483649 for MERCURY too small (minimum is -2147483648)`, + }, { + desc: "enum value too large", + in: &Type{ + Name: "enumeration", + Enum: []*Enum{ + {Name: "MERCURY", Value: &Value{Name: "-2147483648"}}, + {Name: "VENUS", Value: &Value{Name: "2147483648"}}, + {Name: "EARTH"}, + }, + }, + err: `unknown: value 2147483648 for VENUS too large (maximum is 2147483647)`, + }, { + desc: "enum with an unparseable value", + in: &Type{ + Name: "enumeration", + Enum: []*Enum{ + {Name: "MERCURY", Value: &Value{Name: "-1"}}, + {Name: "VENUS", Value: &Value{Name: "10"}}, + {Name: "EARTH", Value: &Value{Name: "five"}}, + }, + }, + err: `unknown: strconv.ParseUint: parsing "five": invalid syntax`, + // TODO(borman): Add in more tests as we honor more fields + // in Type. + }} + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + // We can initialize a value to ourself, so to it here. + errs := tt.in.resolve(newTypeDictionary()) + + // TODO(borman): Do not hack out Root and Base. These + // are hacked out for now because they can be self-referential, + // making construction of them difficult. + tt.in.YangType.Root = nil + tt.in.YangType.Base = nil + + switch { + case tt.err == "" && len(errs) > 0: + t.Fatalf("unexpected errors: %v", errs) + case tt.err != "" && len(errs) == 0: + t.Fatalf("did not get expected errors: %v", tt.err) + case len(errs) > 1: + t.Fatalf("too many errors: %v", errs) + case len(errs) == 1 && errs[0].Error() != tt.err: + t.Fatalf("got error %v, want %s", errs[0], tt.err) + case len(errs) != 0: + return + } + + if diff := cmp.Diff(tt.in.YangType, tt.out); diff != "" { + t.Errorf("YangType (-got, +want):\n%s", diff) + } + }) + } +} + +func TestTypedefResolve(t *testing.T) { + tests := []struct { + desc string + in *Typedef + err string + out *YangType + }{{ + desc: "basic int64", + in: &Typedef{ + Name: "time", + Parent: baseTypes["int64"].typedef(), + Default: &Value{Name: "42"}, + Type: &Type{ + Name: "int64", + }, + Units: &Value{Name: "nanoseconds"}, + }, + out: &YangType{ + Name: "time", + Kind: Yint64, + Base: &Type{ + Name: "int64", + }, + Units: "nanoseconds", + Default: "42", + HasDefault: true, + Range: Int64Range, + }, + }, { + desc: "uint32 with more specific range", + in: &Typedef{ + Name: "another-counter", + Parent: &Typedef{ + Name: "counter", + Parent: baseTypes["uint32"].typedef(), + Type: &Type{ + Name: "uint32", + Range: &Range{Name: "0..42"}, + }, + }, + Type: &Type{ + Name: "uint32", + Range: &Range{Name: "10..20"}, + }, + }, + out: &YangType{ + Name: "another-counter", + Kind: Yuint32, + Base: &Type{ + Name: "uint32", + }, + Range: YangRange{{Min: FromInt(10), Max: FromInt(20)}}, + }, + // TODO(wenovus): Add tests on range and length inheritance once those are fixed. + }} + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + // We can initialize a value to ourself, so to it here. + errs := tt.in.resolve(newTypeDictionary()) + + switch { + case tt.err == "" && len(errs) > 0: + t.Fatalf("unexpected errors: %v", errs) + case tt.err != "" && len(errs) == 0: + t.Fatalf("did not get expected errors: %v", tt.err) + case len(errs) > 1: + t.Fatalf("too many errors: %v", errs) + case len(errs) == 1 && errs[0].Error() != tt.err: + t.Fatalf("got error %v, want %s", errs[0], tt.err) + case len(errs) != 0: + return + } + + if diff := cmp.Diff(tt.in.YangType, tt.out); diff != "" { + t.Errorf("YangType (-got, +want):\n%s", diff) + } + }) + } +} + +func TestTypeResolveUnions(t *testing.T) { + tests := []struct { + desc string + leafNode string + wantType *testEnumTypeStruct + wantErrSubstr string + }{{ + desc: "simple union", + leafNode: ` + typedef alpha { + type union { + type string; + type uint32; + type enumeration { + enum zero; + enum one; + enum seven { + value 7; + } + } + } + } + + leaf test-leaf { + type alpha; + } + } // end module`, + wantType: &testEnumTypeStruct{ + Name: "alpha", + Type: []*testEnumTypeStruct{{ + Name: "string", + }, { + Name: "uint32", + }, { + Name: "enumeration", + ToInt: map[string]int64{"one": 1, "seven": 7, "zero": 0}, + }}, + }, + }, { + desc: "union with typedef", + leafNode: ` + typedef alpha { + type union { + type string; + type uint32; + type enumeration { + enum zero; + enum one; + enum seven { + value 7; + } + } + type bravo; + } + } + + typedef bravo { + type union { + type uint8; + type uint16; + type enumeration { + enum two { + value 2; + } + enum three; + enum four; + } + } + } + + leaf test-leaf { + type alpha; + } + } // end module`, + wantType: &testEnumTypeStruct{ + Name: "alpha", + Type: []*testEnumTypeStruct{{ + Name: "string", + }, { + Name: "uint32", + }, { + Name: "enumeration", + ToInt: map[string]int64{"one": 1, "seven": 7, "zero": 0}, + }, { + Name: "bravo", + Type: []*testEnumTypeStruct{{ + Name: "uint8", + }, { + Name: "uint16", + }, { + Name: "enumeration", + ToInt: map[string]int64{"two": 2, "three": 3, "four": 4}, + }}, + }}, + }, + }, { + desc: "nested unions with typedef", + leafNode: ` + typedef alpha { + type union { + type union { + type uint32; + type string; + type enumeration { + enum zero; + enum one; + enum seven { + value 7; + } + } + } + type bravo; + } + } + + typedef bravo { + type union { + type uint8; + type uint16; + type enumeration { + enum two { + value 2; + } + enum three; + enum four; + } + } + } + + leaf test-leaf { + type alpha; + } + } // end module`, + wantType: &testEnumTypeStruct{ + Name: "alpha", + Type: []*testEnumTypeStruct{{ + Name: "union", + Type: []*testEnumTypeStruct{{ + Name: "uint32", + }, { + Name: "string", + }, { + Name: "enumeration", + ToInt: map[string]int64{"one": 1, "seven": 7, "zero": 0}, + }}, + }, { + Name: "bravo", + Type: []*testEnumTypeStruct{{ + Name: "uint8", + }, { + Name: "uint16", + }, { + Name: "enumeration", + ToInt: map[string]int64{"two": 2, "three": 3, "four": 4}, + }}, + }}, + }, + }, { + desc: "simple union with multiple enumerations", + leafNode: ` + leaf test-leaf { + type union { + type string; + type uint32; + type enumeration { + enum zero; + enum one; + enum seven { + value 7; + } + } + type enumeration { + enum two { + value 2; + } + enum three; + enum four; + } + } + } + } // end module`, + wantType: &testEnumTypeStruct{ + Name: "union", + Type: []*testEnumTypeStruct{{ + Name: "string", + }, { + Name: "uint32", + }, { + Name: "enumeration", + ToInt: map[string]int64{"one": 1, "seven": 7, "zero": 0}, + }, { + Name: "enumeration", + ToInt: map[string]int64{"two": 2, "three": 3, "four": 4}, + }}, + }, + }, { + desc: "typedef union with multiple enumerations", + leafNode: ` + typedef alpha { + type union { + type string; + type uint32; + type enumeration { + enum zero; + enum one; + enum seven { + value 7; + } + } + type enumeration { + enum two { + value 2; + } + enum three; + enum four; + } + } + } + + leaf test-leaf { + type alpha; + } + } // end module`, + wantType: &testEnumTypeStruct{ + Name: "alpha", + Type: []*testEnumTypeStruct{{ + Name: "string", + }, { + Name: "uint32", + }, { + Name: "enumeration", + ToInt: map[string]int64{"one": 1, "seven": 7, "zero": 0}, + }, { + Name: "enumeration", + ToInt: map[string]int64{"two": 2, "three": 3, "four": 4}, + }}, + }, + }, { + desc: "simple union containing typedef union, both with enumerations", + leafNode: ` + typedef alpha { + type union { + type string; + type uint32; + type enumeration { + enum zero; + enum one; + enum seven { + value 7; + } + } + } + } + + leaf test-leaf { + type union { + type alpha; + type enumeration { + enum two { + value 2; + } + enum three; + enum four; + } + } + } + } // end module`, + wantType: &testEnumTypeStruct{ + Name: "union", + Type: []*testEnumTypeStruct{{ + Name: "alpha", + Type: []*testEnumTypeStruct{{ + Name: "string", + }, { + Name: "uint32", + }, { + Name: "enumeration", + ToInt: map[string]int64{"one": 1, "seven": 7, "zero": 0}, + }}, + }, { + Name: "enumeration", + ToInt: map[string]int64{"two": 2, "three": 3, "four": 4}, + }}, + }, + }, { + desc: "simple union containing typedef union containing another typedef union, all with multiple simple and typedef enumerations", + leafNode: ` + typedef a { + type enumeration { + enum un { + value 1; + } + enum deux; + } + } + + typedef b { + type enumeration { + enum trois { + value 3; + } + enum quatre; + } + } + + typedef c { + type enumeration { + enum cinq { + value 5; + } + enum sept { + value 7; + } + } + } + + typedef d { + type enumeration { + enum huit { + value 8; + } + enum neuf; + } + } + + typedef e { + type enumeration { + enum dix { + value 10; + } + enum onze; + } + } + + typedef f { + type enumeration { + enum douze { + value 12; + } + enum treize; + } + } + + typedef bravo { + type union { + type uint32; + type enumeration { + enum eight { + value 8; + } + enum nine; + } + type enumeration { + enum ten { + value 10; + } + enum eleven; + } + type e; + type f; + } + } + + typedef alpha { + type union { + type uint16; + type enumeration { + enum four { + value 4; + } + enum five; + } + type enumeration { + enum six { + value 6; + } + enum seven; + } + type c; + type d; + type bravo; + } + } + + leaf test-leaf { + type union { + type uint8; + type enumeration { + enum zero; + enum one; + } + type enumeration { + enum two { + value 2; + } + enum three; + } + type a; + type b; + type alpha; + } + } + } // end module`, + wantType: &testEnumTypeStruct{ + Name: "union", + Type: []*testEnumTypeStruct{{ + Name: "uint8", + }, { + Name: "enumeration", + ToInt: map[string]int64{"zero": 0, "one": 1}, + }, { + Name: "enumeration", + ToInt: map[string]int64{"two": 2, "three": 3}, + }, { + Name: "a", + ToInt: map[string]int64{"un": 1, "deux": 2}, + }, { + Name: "b", + ToInt: map[string]int64{"trois": 3, "quatre": 4}, + }, { + Name: "alpha", + Type: []*testEnumTypeStruct{{ + Name: "uint16", + }, { + Name: "enumeration", + ToInt: map[string]int64{"four": 4, "five": 5}, + }, { + Name: "enumeration", + ToInt: map[string]int64{"six": 6, "seven": 7}, + }, { + Name: "c", + ToInt: map[string]int64{"cinq": 5, "sept": 7}, + }, { + Name: "d", + ToInt: map[string]int64{"huit": 8, "neuf": 9}, + }, { + Name: "bravo", + Type: []*testEnumTypeStruct{{ + Name: "uint32", + }, { + Name: "enumeration", + ToInt: map[string]int64{"eight": 8, "nine": 9}, + }, { + Name: "enumeration", + ToInt: map[string]int64{"ten": 10, "eleven": 11}, + }, { + Name: "e", + ToInt: map[string]int64{"dix": 10, "onze": 11}, + }, { + Name: "f", + ToInt: map[string]int64{"douze": 12, "treize": 13}, + }}, + }}, + }}, + }, + }} + + getTestLeaf := func(ms *Modules) (*YangType, error) { + const module = "test" + m, ok := ms.Modules[module] + if !ok { + return nil, fmt.Errorf("can't find module %q", module) + } + + if len(m.Leaf) == 0 { + return nil, fmt.Errorf("node %v is missing imports", m) + } + e := ToEntry(m) + return e.Dir["test-leaf"].Type, nil + } + + for _, tt := range tests { + inModules := map[string]string{ + "test": ` + module test { + prefix "t"; + namespace "urn:t"; + + ` + tt.leafNode, + } + + t.Run(tt.desc, func(t *testing.T) { + ms := NewModules() + for n, m := range inModules { + if err := ms.Parse(m, n); err != nil { + t.Fatalf("error parsing module %s, got: %v, want: nil", n, err) + } + } + errs := ms.Process() + var err error + if len(errs) > 1 { + t.Fatalf("Got more than 1 error: %v", errs) + } else if len(errs) == 1 { + err = errs[0] + } + if diff := errdiff.Substring(err, tt.wantErrSubstr); diff != "" { + t.Errorf("Did not get expected error: %s", diff) + } + if err != nil { + return + } + + gotType, err := getTestLeaf(ms) + if err != nil { + t.Fatal(err) + } + + if diff := cmp.Diff(filterTypeNames(gotType), tt.wantType); diff != "" { + t.Errorf("Type.resolve() union types test (-got, +want):\n%s", diff) + } + }) + } +} + +type testEnumTypeStruct struct { + Name string + // ToInt is the ToInt map representing the enum value (if present). + ToInt map[string]int64 + Type []*testEnumTypeStruct +} + +// filterTypeNames returns a testEnumTypeStruct with only the +// YangType.Name fields of the given type, preserving +// the recursive structure of the type, to work around cmp not +// having an allowlist way of specifying which fields to +// compare and YangType having a custom Equal function. +func filterTypeNames(ytype *YangType) *testEnumTypeStruct { + filteredNames := &testEnumTypeStruct{Name: ytype.Name} + if ytype.Enum != nil { + filteredNames.ToInt = ytype.Enum.ToInt + } + for _, subtype := range ytype.Type { + filteredNames.Type = append(filteredNames.Type, filterTypeNames(subtype)) + } + return filteredNames +} + +func TestPattern(t *testing.T) { + tests := []struct { + desc string + leafNode string + wantType *YangType + wantErrSubstr string + }{{ + desc: "Only normal patterns", + leafNode: ` + leaf test-leaf { + type string { + o:bar 'coo'; + o:bar 'foo'; + pattern 'charlie'; + o:bar 'goo'; + } + } + } // end module`, + wantType: &YangType{ + Pattern: []string{"charlie"}, + }, + }, { + desc: "Only posix patterns", + leafNode: ` + leaf test-leaf { + type string { + o:bar 'coo'; + o:posix-pattern 'bravo'; + o:bar 'foo'; + o:posix-pattern 'charlie'; + o:bar 'goo'; + } + } + } // end module`, + wantType: &YangType{ + POSIXPattern: []string{"bravo", "charlie"}, + }, + }, { + desc: "No patterns", + leafNode: ` + leaf test-leaf { + type string; + } + }`, + wantType: &YangType{ + Pattern: nil, + POSIXPattern: nil, + }, + }, { + desc: "Both patterns", + leafNode: ` + leaf test-leaf { + type string { + pattern 'alpha'; + o:posix-pattern 'bravo'; + o:posix-pattern 'charlie'; + o:bar 'coo'; + o:posix-pattern 'delta'; + } + } + } // end module`, + wantType: &YangType{ + Pattern: []string{"alpha"}, + POSIXPattern: []string{"bravo", "charlie", "delta"}, + }, + }, { + desc: "Both patterns, but with non-openconfig-extensions pretenders", + leafNode: ` + leaf test-leaf { + type string { + pattern 'alpha'; + o:bar 'coo'; + o:posix-pattern 'delta'; + + n:posix-pattern 'golf'; + + pattern 'bravo'; + o:bar 'foo'; + o:posix-pattern 'echo'; + + pattern 'charlie'; + o:bar 'goo'; + o:posix-pattern 'foxtrot'; + + n:posix-pattern 'hotel'; + } + } + } // end module`, + wantType: &YangType{ + Pattern: []string{"alpha", "bravo", "charlie"}, + POSIXPattern: []string{"delta", "echo", "foxtrot"}, + }, + }, { + desc: "Union type", + leafNode: ` + leaf test-leaf { + type union { + type string { + pattern 'alpha'; + o:bar 'coo'; + o:posix-pattern 'delta'; + + pattern 'bravo'; + o:bar 'foo'; + o:posix-pattern 'echo'; + n:posix-pattern 'echo2'; + + pattern 'charlie'; + o:bar 'goo'; + o:posix-pattern 'foxtrot'; + } + type uint64; + } + } + } // end module`, + wantType: &YangType{ + Type: []*YangType{{ + Pattern: []string{"alpha", "bravo", "charlie"}, + POSIXPattern: []string{"delta", "echo", "foxtrot"}, + }, { + Pattern: nil, + POSIXPattern: nil, + }}, + }, + }, { + desc: "Union type -- de-duping string types", + leafNode: ` + leaf test-leaf { + type union { + type string { + pattern 'alpha'; + o:posix-pattern 'alpha'; + } + type string { + pattern 'alpha'; + o:posix-pattern 'alpha'; + } + } + } + } // end module`, + wantType: &YangType{ + Type: []*YangType{{ + Pattern: []string{"alpha"}, + POSIXPattern: []string{"alpha"}, + }}, + }, + }, { + desc: "Union type -- different string types due to different patterns", + leafNode: ` + leaf test-leaf { + type union { + type string { + pattern 'alpha'; + } + type string { + pattern 'bravo'; + } + } + } + } // end module`, + wantType: &YangType{ + Type: []*YangType{{ + Pattern: []string{"alpha"}, + }, { + Pattern: []string{"bravo"}, + }}, + }, + }, { + desc: "Union type -- different string types due to different posix-patterns", + leafNode: ` + leaf test-leaf { + type union { + type string { + o:posix-pattern 'alpha'; + } + type string { + o:posix-pattern 'bravo'; + } + } + } + } // end module`, + wantType: &YangType{ + Type: []*YangType{{ + POSIXPattern: []string{"alpha"}, + }, { + POSIXPattern: []string{"bravo"}, + }}, + }, + }, { + desc: "typedef", + leafNode: ` + leaf test-leaf { + type leaf-type; + } + + typedef leaf-type { + type string { + pattern 'alpha'; + o:bar 'coo'; + o:posix-pattern 'delta'; + + pattern 'bravo'; + o:bar 'foo'; + o:posix-pattern 'echo'; + + pattern 'charlie'; + o:bar 'goo'; + o:posix-pattern 'foxtrot'; + } + } + } // end module`, + wantType: &YangType{ + Pattern: []string{"alpha", "bravo", "charlie"}, + POSIXPattern: []string{"delta", "echo", "foxtrot"}, + }, + }, { + desc: "invalid POSIX pattern", + leafNode: ` + leaf test-leaf { + type leaf-type; + } + + typedef leaf-type { + type string { + o:posix-pattern '?'; + } + } + } // end module`, + wantErrSubstr: "bad pattern", + }} + + getTestLeaf := func(ms *Modules) (*YangType, error) { + const module = "test" + m, ok := ms.Modules[module] + if !ok { + return nil, fmt.Errorf("can't find module %q", module) + } + + if len(m.Leaf) == 0 { + return nil, fmt.Errorf("node %v is missing imports", m) + } + e := ToEntry(m) + return e.Dir["test-leaf"].Type, nil + } + + for _, tt := range tests { + inModules := map[string]string{ + "test": ` + module test { + prefix "t"; + namespace "urn:t"; + + import non-openconfig-extensions { + prefix "n"; + description "non-openconfig-extensions module"; + } + import openconfig-extensions { + prefix "o"; + description "openconfig-extensions module"; + }` + tt.leafNode, + "openconfig-extensions": ` + module openconfig-extensions { + prefix "o"; + namespace "urn:o"; + + extension bar { + argument "baz"; + } + + extension posix-pattern { + argument "pattern"; + } + } + `, + "non-openconfig-extensions": ` + module non-openconfig-extensions { + prefix "n"; + namespace "urn:n"; + + extension bar { + argument "baz"; + } + + extension posix-pattern { + argument "pattern"; + } + } + `, + } + + t.Run(tt.desc, func(t *testing.T) { + ms := NewModules() + for n, m := range inModules { + if err := ms.Parse(m, n); err != nil { + t.Fatalf("error parsing module %s, got: %v, want: nil", n, err) + } + } + errs := ms.Process() + var err error + if len(errs) > 1 { + t.Fatalf("Got more than 1 error: %v", errs) + } else if len(errs) == 1 { + err = errs[0] + } + if diff := errdiff.Substring(err, tt.wantErrSubstr); diff != "" { + t.Errorf("Did not get expected error: %s", diff) + } + if err != nil { + return + } + + yangType, err := getTestLeaf(ms) + if err != nil { + t.Fatal(err) + } + + gotType := &YangType{} + populatePatterns(yangType, gotType) + if diff := cmp.Diff(gotType, tt.wantType, cmpopts.EquateEmpty()); diff != "" { + t.Errorf("Type.resolve() pattern test (-got, +want):\n%s", diff) + } + }) + } +} + +// populatePatterns populates targetType with only the +// Pattern/POSIXPattern fields of the given type, preserving +// the recursive structure of the type, to work around cmp not +// having an allowlist way of specifying which fields to +// compare. +func populatePatterns(ytype *YangType, targetType *YangType) { + targetType.Pattern = ytype.Pattern + targetType.POSIXPattern = ytype.POSIXPattern + for _, subtype := range ytype.Type { + targetSubtype := &YangType{} + targetType.Type = append(targetType.Type, targetSubtype) + populatePatterns(subtype, targetSubtype) + } +} + +func TestTypeLengthRange(t *testing.T) { + tests := []struct { + desc string + leafNode string + wantType *testRangeTypeStruct + wantErrSubstr string + }{{ + desc: "simple uint32", + leafNode: ` + typedef alpha { + type uint32 { + range "1..4 | 10..20"; + } + } + leaf test-leaf { + type alpha; + } + } // end module`, + wantType: &testRangeTypeStruct{ + Name: "alpha", + Range: YangRange{R(1, 4), R(10, 20)}, + }, + }, { + desc: "inherited uint32", + leafNode: ` + typedef alpha { + type uint32 { + range "1..4 | 10..20"; + } + } + typedef bravo { + type alpha { + range "min..3 | 12..max"; + } + } + leaf test-leaf { + type bravo; + } + } // end module`, + wantType: &testRangeTypeStruct{ + Name: "bravo", + Range: YangRange{R(1, 3), R(12, 20)}, + }, + }, { + desc: "inherited uint32 range violation", + leafNode: ` + typedef alpha { + type uint32 { + range "1..4 | 10..20"; + } + } + typedef bravo { + type alpha { + range "min..max"; + } + } + leaf test-leaf { + type bravo; + } + } // end module`, + wantErrSubstr: "not within", + }, { + desc: "unrestricted decimal64", + leafNode: ` + typedef alpha { + type decimal64 { + fraction-digits 2; + } + } + leaf test-leaf { + type alpha; + } + } // end module`, + wantType: &testRangeTypeStruct{ + Name: "alpha", + Range: YangRange{Rf(MinInt64, MaxInt64, 2)}, + }, + }, { + desc: "simple restricted decimal64", + leafNode: ` + typedef alpha { + type decimal64 { + fraction-digits 2; + range "1 .. 3.14 | 10 | 20..max"; + } + } + leaf test-leaf { + type alpha; + } + } // end module`, + wantType: &testRangeTypeStruct{ + Name: "alpha", + Range: YangRange{Rf(100, 314, 2), Rf(1000, 1000, 2), Rf(2000, MaxInt64, 2)}, + }, + }, { + desc: "simple decimal64 with inherited ranges", + leafNode: ` + typedef alpha { + type decimal64 { + fraction-digits 3; + range "1 .. 3.14 | 10 | 20..max"; + } + } + typedef bravo { + type alpha { + range "min .. 2.72 | 42 .. max"; + } + } + leaf test-leaf { + type bravo; + } + } // end module`, + wantType: &testRangeTypeStruct{ + Name: "bravo", + Range: YangRange{Rf(1000, 2720, 3), Rf(42000, MaxInt64, 3)}, + }, + }, { + desc: "triple-inherited decimal64", + leafNode: ` + typedef alpha { + type decimal64 { + fraction-digits 2; + } + } + typedef bravo { + type alpha { + range "1 .. 3.14 | 10 | 20..max"; + } + } + typedef charlie { + type bravo { + range "min .. 2.72 | 42 .. max"; + } + } + leaf test-leaf { + type charlie; + } + } // end module`, + wantType: &testRangeTypeStruct{ + Name: "charlie", + Range: YangRange{Rf(100, 272, 2), Rf(4200, MaxInt64, 2)}, + }, + }, { + desc: "simple decimal64 with inherited ranges", + leafNode: ` + typedef alpha { + type decimal64 { + fraction-digits 2; + range "1 .. 3.14 | 10 | 20..max"; + } + } + typedef bravo { + type alpha { + range "min..max"; + } + } + leaf test-leaf { + type alpha; + } + } // end module`, + wantErrSubstr: "not within", + }, { + desc: "simple decimal64 with too few fractional digits", + leafNode: ` + typedef alpha { + type decimal64 { + fraction-digits 1; + range "1 .. 3.14 | 10 | 20..max"; + } + } + leaf test-leaf { + type alpha; + } + } // end module`, + wantErrSubstr: "has too much precision", + }, { + desc: "simple decimal64 fractional digit on inherited decimal64 type", + leafNode: ` + typedef alpha { + type decimal64 { + fraction-digits 2; + range "1 .. 3.14 | 10 | 20..max"; + } + } + typedef bravo { + type alpha { + fraction-digits 2; + range "25..max"; + } + } + leaf test-leaf { + type bravo; + } + } // end module`, + wantErrSubstr: "overriding of fraction-digits not allowed", + }, { + desc: "simple string with length", + leafNode: ` + typedef alpha { + type string { + length "1..4 | 10..20 | 30..max"; + } + } + leaf test-leaf { + type alpha; + } + } // end module`, + wantType: &testRangeTypeStruct{ + Name: "alpha", + Length: YangRange{R(1, 4), R(10, 20), YRange{FromInt(30), FromUint(maxUint64)}}, + }, + }, { + desc: "inherited string", + leafNode: ` + typedef alpha { + type string { + length "1..4 | 10..20 | 30..max"; + } + } + typedef bravo { + type alpha { + length "min..3 | 42..max"; + } + } + leaf test-leaf { + type bravo; + } + } // end module`, + wantType: &testRangeTypeStruct{ + Name: "bravo", + Length: YangRange{R(1, 3), YRange{FromInt(42), FromUint(maxUint64)}}, + }, + }, { + desc: "inherited binary", + leafNode: ` + typedef alpha { + type binary { + length "1..4 | 10..20 | 30..max"; + } + } + typedef bravo { + type alpha { + length "min..3 | 42..max"; + } + } + leaf test-leaf { + type bravo; + } + } // end module`, + wantType: &testRangeTypeStruct{ + Name: "bravo", + Length: YangRange{R(1, 3), YRange{FromInt(42), FromUint(maxUint64)}}, + }, + }, { + desc: "inherited string length violation", + leafNode: ` + typedef alpha { + type string { + length "1..4 | 10..20 | 30..max"; + } + } + typedef bravo { + type alpha { + length "min..max"; + } + } + leaf test-leaf { + type bravo; + } + } // end module`, + wantErrSubstr: "not within", + }, { + desc: "simple union", + leafNode: ` + typedef alpha { + type union { + type string; + type binary { + length "min..5|999..max"; + } + type int8 { + range "min..-42|42..max"; + } + type enumeration { + enum zero; + enum one; + enum seven { + value 7; + } + } + } + } + leaf test-leaf { + type alpha; + } + } // end module`, + wantType: &testRangeTypeStruct{ + Name: "alpha", + Type: []*testRangeTypeStruct{{ + Name: "string", + }, { + Name: "binary", + Length: YangRange{R(0, 5), YRange{FromInt(999), FromUint(maxUint64)}}, + }, { + Name: "int8", + Range: YangRange{R(minInt8, -42), R(42, maxInt8)}, + }, { + Name: "enumeration", + }}, + }, + }} + + getTestLeaf := func(ms *Modules) (*YangType, error) { + const moduleName = "test" + m, ok := ms.Modules[moduleName] + if !ok { + return nil, fmt.Errorf("module not found: %q", moduleName) + } + if len(m.Leaf) == 0 { + return nil, fmt.Errorf("node %v is missing imports", m) + } + e := ToEntry(m) + return e.Dir["test-leaf"].Type, nil + } + + for _, tt := range tests { + inModules := map[string]string{ + "test": ` + module test { + prefix "t"; + namespace "urn:t"; + ` + tt.leafNode, + } + + t.Run(tt.desc, func(t *testing.T) { + ms := NewModules() + for n, m := range inModules { + if err := ms.Parse(m, n); err != nil { + t.Fatalf("error parsing module %s, got: %v, want: nil", n, err) + } + } + errs := ms.Process() + var err error + if len(errs) > 1 { + t.Fatalf("Got more than 1 error: %v", errs) + } else if len(errs) == 1 { + err = errs[0] + } + if diff := errdiff.Substring(err, tt.wantErrSubstr); diff != "" { + t.Errorf("Did not get expected error: %s", diff) + } + if err != nil { + return + } + + gotType, err := getTestLeaf(ms) + if err != nil { + t.Fatal(err) + } + + if diff := cmp.Diff(filterRanges(gotType), tt.wantType); diff != "" { + t.Errorf("Type.resolve() union types test (-got, +want):\n%s", diff) + } + }) + } +} + +// testRangeTypeStruct is a filtered-down version of YangType where only certain +// fields are preserved for targeted testing. +type testRangeTypeStruct struct { + Name string + Length YangRange + Range YangRange + Type []*testRangeTypeStruct +} + +// filterRanges returns a testRangeTypeStruct with only the Name, Length, and Range +// fields of the given YangType, preserving the recursive structure of the +// type, to work around cmp not having an allowlist way of specifying which +// fields to compare and YangType having a custom Equal function. +func filterRanges(ytype *YangType) *testRangeTypeStruct { + filteredType := &testRangeTypeStruct{Name: ytype.Name} + filteredType.Length = ytype.Length + filteredType.Range = ytype.Range + for _, subtype := range ytype.Type { + filteredType.Type = append(filteredType.Type, filterRanges(subtype)) + } + return filteredType +} diff --git a/src/webui/internal/goyang/pkg/yang/yang.go b/src/webui/internal/goyang/pkg/yang/yang.go new file mode 100644 index 00000000..fad667e2 --- /dev/null +++ b/src/webui/internal/goyang/pkg/yang/yang.go @@ -0,0 +1,1101 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +import "fmt" + +// This file contains the definitions for all nodes of the yang AST. +// The actual building of the AST is in ast.go + +// Some field names have specific meanings: +// +// Grouping - This field must always be of type []*Grouping +// Typedef - This field must always be of type []*Typedef + +// A Value is just a string that can have extensions. +type Value struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge" json:",omitempty"` + Parent Node `yang:"Parent,nomerge" json:"-"` + Extensions []*Statement `yang:"Ext" json:",omitempty"` + + Description *Value `yang:"description" json:",omitempty"` + Reference *Value `yang:"reference" json:",omitempty"` +} + +func (Value) Kind() string { return "string" } +func (s *Value) ParentNode() Node { return s.Parent } +func (s *Value) NName() string { return s.Name } +func (s *Value) Statement() *Statement { return s.Source } +func (s *Value) Exts() []*Statement { return s.Extensions } + +// asRangeInt returns the value v as an int64 if it is between the values of +// min and max inclusive. An error is returned if v is out of range or does +// not parse into a number. If v is nil then an error is returned. +func (s *Value) asRangeInt(min, max int64) (int64, error) { + if s == nil { + return 0, fmt.Errorf("value is required in the range of [%d..%d]", min, max) + } + n, err := ParseInt(s.Name) + if err != nil { + return 0, err + } + i, err := n.Int() + if err != nil { + return 0, err + } + if i < min || i > max { + return 0, fmt.Errorf("value %s out of range [%d..%d]", s.Name, min, max) + } + return i, nil +} + +// asBool returns v as a boolean (true or flase) or returns an error if v +// is neither true nor false. If v is nil then false is returned. +func (s *Value) asBool() (bool, error) { + // A missing value is considered false + if s == nil { + return false, nil + } + switch s.Name { + case "true": + return true, nil + case "false": + return false, nil + default: + return false, fmt.Errorf("invalid boolean: %s", s.Name) + } +} + +// asString simply returns the string value of v. If v is nil then an empty +// string is returned. +func (s *Value) asString() string { + if s == nil { + return "" + } + return s.Name +} + +// See http://tools.ietf.org/html/rfc6020#section-7 for a description of the +// following structures. The structures are derived from that document. + +// A Module is defined in: http://tools.ietf.org/html/rfc6020#section-7.1 +// +// A SubModule is defined in: http://tools.ietf.org/html/rfc6020#section-7.2 +type Module struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge" json:"-"` + Parent Node `yang:"Parent,nomerge" json:"-"` + Extensions []*Statement `yang:"Ext"` + + Anydata []*AnyData `yang:"anydata"` + Anyxml []*AnyXML `yang:"anyxml"` + Augment []*Augment `yang:"augment"` + BelongsTo *BelongsTo `yang:"belongs-to,required=submodule,nomerge"` + Choice []*Choice `yang:"choice"` + Contact *Value `yang:"contact,nomerge"` + Container []*Container `yang:"container"` + Description *Value `yang:"description,nomerge"` + Deviation []*Deviation `yang:"deviation"` + Extension []*Extension `yang:"extension"` + Feature []*Feature `yang:"feature"` + Grouping []*Grouping `yang:"grouping"` + Identity []*Identity `yang:"identity"` + Import []*Import `yang:"import"` + Include []*Include `yang:"include"` + Leaf []*Leaf `yang:"leaf"` + LeafList []*LeafList `yang:"leaf-list"` + List []*List `yang:"list"` + Namespace *Value `yang:"namespace,required=module,nomerge"` + Notification []*Notification `yang:"notification"` + Organization *Value `yang:"organization,nomerge"` + Prefix *Value `yang:"prefix,required=module,nomerge"` + Reference *Value `yang:"reference,nomerge"` + Revision []*Revision `yang:"revision,nomerge"` + RPC []*RPC `yang:"rpc"` + Typedef []*Typedef `yang:"typedef"` + Uses []*Uses `yang:"uses"` + YangVersion *Value `yang:"yang-version,nomerge"` + + // Modules references the Modules object from which this Module node + // was parsed. + Modules *Modules +} + +func (s *Module) Kind() string { + if s.BelongsTo != nil { + return "submodule" + } + return "module" +} +func (s *Module) ParentNode() Node { return s.Parent } +func (s *Module) NName() string { return s.Name } +func (s *Module) Statement() *Statement { return s.Source } +func (s *Module) Exts() []*Statement { return s.Extensions } +func (s *Module) Groupings() []*Grouping { return s.Grouping } +func (s *Module) Typedefs() []*Typedef { return s.Typedef } +func (s *Module) Identities() []*Identity { return s.Identity } + +// Current returns the most recent revision of this module, or "" if the module +// has no revisions. +func (s *Module) Current() string { + var rev string + for _, r := range s.Revision { + if r.Name > rev { + rev = r.Name + } + } + return rev +} + +// FullName returns the full name of the module including the most recent +// revision, if any. +func (s *Module) FullName() string { + if rev := s.Current(); rev != "" { + return s.Name + "@" + rev + } + return s.Name +} + +// GetPrefix returns the proper prefix of m. Useful when looking up types +// in modules found by FindModuleByPrefix. +func (s *Module) GetPrefix() string { + pfx := s.getPrefix() + if pfx == nil { + // This case can be true during testing. + return "" + } + return pfx.Name +} + +// getPrefix returns the local prefix of the module used to refer to itself. +func (s *Module) getPrefix() *Value { + switch { + case s == nil: + return nil + case s.Kind() == "module" && s.Prefix != nil: + return s.Prefix + case s.Kind() == "submodule" && s.BelongsTo != nil: + return s.BelongsTo.Prefix + default: + return nil + } +} + +// An Import is defined in: http://tools.ietf.org/html/rfc6020#section-7.1.5 +type Import struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge" json:"-"` + Parent Node `yang:"Parent,nomerge" json:"-"` + Extensions []*Statement `yang:"Ext"` + + Prefix *Value `yang:"prefix,required"` + RevisionDate *Value `yang:"revision-date"` + Reference *Value `yang:"reference,nomerge"` + Description *Value `yang:"description,nomerge"` + + // Module is the imported module. The types and groupings are + // available to the importer with the defined prefix. + Module *Module +} + +func (Import) Kind() string { return "import" } +func (s *Import) ParentNode() Node { return s.Parent } +func (s *Import) NName() string { return s.Name } +func (s *Import) Statement() *Statement { return s.Source } +func (s *Import) Exts() []*Statement { return s.Extensions } + +// An Include is defined in: http://tools.ietf.org/html/rfc6020#section-7.1.6 +type Include struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge" json:"-"` + Parent Node `yang:"Parent,nomerge" json:"-"` + Extensions []*Statement `yang:"Ext" json:",omitempty"` + + RevisionDate *Value `yang:"revision-date"` + + // Module is the included module. The types and groupings are + // available to the importer with the defined prefix. + Module *Module +} + +func (Include) Kind() string { return "include" } +func (s *Include) ParentNode() Node { return s.Parent } +func (s *Include) NName() string { return s.Name } +func (s *Include) Statement() *Statement { return s.Source } +func (s *Include) Exts() []*Statement { return s.Extensions } + +// A Revision is defined in: http://tools.ietf.org/html/rfc6020#section-7.1.9 +type Revision struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge" json:"-"` + Parent Node `yang:"Parent,nomerge" json:"-"` + Extensions []*Statement `yang:"Ext" json:",omitempty"` + + Description *Value `yang:"description"` + Reference *Value `yang:"reference"` +} + +func (Revision) Kind() string { return "revision" } +func (s *Revision) ParentNode() Node { return s.Parent } +func (s *Revision) NName() string { return s.Name } +func (s *Revision) Statement() *Statement { return s.Source } +func (s *Revision) Exts() []*Statement { return s.Extensions } + +// A BelongsTo is defined in: http://tools.ietf.org/html/rfc6020#section-7.2.2 +type BelongsTo struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge" json:"-"` + Parent Node `yang:"Parent,nomerge" json:"-"` + Extensions []*Statement `yang:"Ext" json:",omitempty"` + + Prefix *Value `yang:"prefix,required"` +} + +func (BelongsTo) Kind() string { return "belongs-to" } +func (s *BelongsTo) ParentNode() Node { return s.Parent } +func (s *BelongsTo) NName() string { return s.Name } +func (s *BelongsTo) Statement() *Statement { return s.Source } +func (s *BelongsTo) Exts() []*Statement { return s.Extensions } + +// A Typedef is defined in: http://tools.ietf.org/html/rfc6020#section-7.3 +type Typedef struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Default *Value `yang:"default"` + Description *Value `yang:"description"` + Reference *Value `yang:"reference"` + Status *Value `yang:"status"` + Type *Type `yang:"type,required"` + Units *Value `yang:"units"` + + YangType *YangType `json:"-"` +} + +func (Typedef) Kind() string { return "typedef" } +func (s *Typedef) ParentNode() Node { return s.Parent } +func (s *Typedef) NName() string { return s.Name } +func (s *Typedef) Statement() *Statement { return s.Source } +func (s *Typedef) Exts() []*Statement { return s.Extensions } + +// A Type is defined in: http://tools.ietf.org/html/rfc6020#section-7.4 +// Note that Name is the name of the type we want, it is what must +// be looked up and resolved. +type Type struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + IdentityBase *Value `yang:"base"` // Name == identityref + Bit []*Bit `yang:"bit"` + Enum []*Enum `yang:"enum"` + FractionDigits *Value `yang:"fraction-digits"` // Name == decimal64 + Length *Length `yang:"length"` + Path *Value `yang:"path"` + Pattern []*Pattern `yang:"pattern"` + Range *Range `yang:"range"` + RequireInstance *Value `yang:"require-instance"` + Type []*Type `yang:"type"` // len > 1 only when Name is "union" + + YangType *YangType +} + +func (Type) Kind() string { return "type" } +func (s *Type) ParentNode() Node { return s.Parent } +func (s *Type) NName() string { return s.Name } +func (s *Type) Statement() *Statement { return s.Source } +func (s *Type) Exts() []*Statement { return s.Extensions } + +// A Container is defined in: http://tools.ietf.org/html/rfc6020#section-7.5 +// and http://tools.ietf.org/html/rfc7950#section-7.5 ("container" sub-statement) +type Container struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Anydata []*AnyData `yang:"anydata"` + Action []*Action `yang:"action"` + Anyxml []*AnyXML `yang:"anyxml"` + Choice []*Choice `yang:"choice"` + Config *Value `yang:"config"` + Container []*Container `yang:"container"` + Description *Value `yang:"description"` + Grouping []*Grouping `yang:"grouping"` + IfFeature []*Value `yang:"if-feature"` + Leaf []*Leaf `yang:"leaf"` + LeafList []*LeafList `yang:"leaf-list"` + List []*List `yang:"list"` + Must []*Must `yang:"must"` + Notification []*Notification `yang:"notification"` + Presence *Value `yang:"presence"` + Reference *Value `yang:"reference"` + Status *Value `yang:"status"` + Typedef []*Typedef `yang:"typedef"` + Uses []*Uses `yang:"uses"` + When *Value `yang:"when"` +} + +func (Container) Kind() string { return "container" } +func (s *Container) ParentNode() Node { return s.Parent } +func (s *Container) NName() string { return s.Name } +func (s *Container) Statement() *Statement { return s.Source } +func (s *Container) Exts() []*Statement { return s.Extensions } +func (s *Container) Groupings() []*Grouping { return s.Grouping } +func (s *Container) Typedefs() []*Typedef { return s.Typedef } + +// A Must is defined in: http://tools.ietf.org/html/rfc6020#section-7.5.3 +type Must struct { + Name string `yang:"Name,nomerge" json:",omitempty"` + Source *Statement `yang:"Statement,nomerge" json:"-"` + Parent Node `yang:"Parent,nomerge" json:"-"` + Extensions []*Statement `yang:"Ext" json:",omitempty"` + + Description *Value `yang:"description" json:",omitempty"` + ErrorAppTag *Value `yang:"error-app-tag" json:",omitempty"` + ErrorMessage *Value `yang:"error-message" json:",omitempty"` + Reference *Value `yang:"reference" json:",omitempty"` +} + +func (Must) Kind() string { return "must" } +func (s *Must) ParentNode() Node { return s.Parent } +func (s *Must) NName() string { return s.Name } +func (s *Must) Statement() *Statement { return s.Source } +func (s *Must) Exts() []*Statement { return s.Extensions } + +// A Leaf is defined in: http://tools.ietf.org/html/rfc6020#section-7.6 +type Leaf struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Config *Value `yang:"config"` + Default *Value `yang:"default"` + Description *Value `yang:"description"` + IfFeature []*Value `yang:"if-feature"` + Mandatory *Value `yang:"mandatory"` + Must []*Must `yang:"must"` + Reference *Value `yang:"reference"` + Status *Value `yang:"status"` + Type *Type `yang:"type,required"` + Units *Value `yang:"units"` + When *Value `yang:"when"` +} + +func (Leaf) Kind() string { return "leaf" } +func (s *Leaf) ParentNode() Node { return s.Parent } +func (s *Leaf) NName() string { return s.Name } +func (s *Leaf) Statement() *Statement { return s.Source } +func (s *Leaf) Exts() []*Statement { return s.Extensions } + +// A LeafList is defined in: +// YANG 1: http://tools.ietf.org/html/rfc6020#section-7.7 +// YANG 1.1: https://tools.ietf.org/html/rfc7950#section-7.7 +// It this is supposed to be an array of nodes.. +type LeafList struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Config *Value `yang:"config"` + Default []*Value `yang:"default"` + Description *Value `yang:"description"` + IfFeature []*Value `yang:"if-feature"` + MaxElements *Value `yang:"max-elements"` + MinElements *Value `yang:"min-elements"` + Must []*Must `yang:"must"` + OrderedBy *Value `yang:"ordered-by"` + Reference *Value `yang:"reference"` + Status *Value `yang:"status"` + Type *Type `yang:"type,required"` + Units *Value `yang:"units"` + When *Value `yang:"when"` +} + +func (LeafList) Kind() string { return "leaf-list" } +func (s *LeafList) ParentNode() Node { return s.Parent } +func (s *LeafList) NName() string { return s.Name } +func (s *LeafList) Statement() *Statement { return s.Source } +func (s *LeafList) Exts() []*Statement { return s.Extensions } + +// A List is defined in: http://tools.ietf.org/html/rfc6020#section-7.8 +// and http://tools.ietf.org/html/rfc7950#section-7.8 ("list" sub-statement) +type List struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Anydata []*AnyData `yang:"anydata"` + Action []*Action `yang:"action"` + Anyxml []*AnyXML `yang:"anyxml"` + Choice []*Choice `yang:"choice"` + Config *Value `yang:"config"` + Container []*Container `yang:"container"` + Description *Value `yang:"description"` + Grouping []*Grouping `yang:"grouping"` + IfFeature []*Value `yang:"if-feature"` + Key *Value `yang:"key"` + Leaf []*Leaf `yang:"leaf"` + LeafList []*LeafList `yang:"leaf-list"` + List []*List `yang:"list"` + MaxElements *Value `yang:"max-elements"` + MinElements *Value `yang:"min-elements"` + Must []*Must `yang:"must"` + Notification []*Notification `yang:"notification"` + OrderedBy *Value `yang:"ordered-by"` + Reference *Value `yang:"reference"` + Status *Value `yang:"status"` + Typedef []*Typedef `yang:"typedef"` + Unique []*Value `yang:"unique"` + Uses []*Uses `yang:"uses"` + When *Value `yang:"when"` +} + +func (List) Kind() string { return "list" } +func (s *List) ParentNode() Node { return s.Parent } +func (s *List) NName() string { return s.Name } +func (s *List) Statement() *Statement { return s.Source } +func (s *List) Exts() []*Statement { return s.Extensions } +func (s *List) Groupings() []*Grouping { return s.Grouping } +func (s *List) Typedefs() []*Typedef { return s.Typedef } + +// A Choice is defined in: http://tools.ietf.org/html/rfc6020#section-7.9 +type Choice struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Anydata []*AnyData `yang:"anydata"` + Anyxml []*AnyXML `yang:"anyxml"` + Case []*Case `yang:"case"` + Config *Value `yang:"config"` + Container []*Container `yang:"container"` + Default *Value `yang:"default"` + Description *Value `yang:"description"` + IfFeature []*Value `yang:"if-feature"` + Leaf []*Leaf `yang:"leaf"` + LeafList []*LeafList `yang:"leaf-list"` + List []*List `yang:"list"` + Mandatory *Value `yang:"mandatory"` + Reference *Value `yang:"reference"` + Status *Value `yang:"status"` + When *Value `yang:"when"` +} + +func (Choice) Kind() string { return "choice" } +func (s *Choice) ParentNode() Node { return s.Parent } +func (s *Choice) NName() string { return s.Name } +func (s *Choice) Statement() *Statement { return s.Source } +func (s *Choice) Exts() []*Statement { return s.Extensions } + +// A Case is defined in: http://tools.ietf.org/html/rfc6020#section-7.9.2 +type Case struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Anydata []*AnyData `yang:"anydata"` + Anyxml []*AnyXML `yang:"anyxml"` + Choice []*Choice `yang:"choice"` + Container []*Container `yang:"container"` + Description *Value `yang:"description"` + IfFeature []*Value `yang:"if-feature"` + Leaf []*Leaf `yang:"leaf"` + LeafList []*LeafList `yang:"leaf-list"` + List []*List `yang:"list"` + Reference *Value `yang:"reference"` + Status *Value `yang:"status"` + Uses []*Uses `yang:"uses"` + When *Value `yang:"when"` +} + +func (Case) Kind() string { return "case" } +func (s *Case) ParentNode() Node { return s.Parent } +func (s *Case) NName() string { return s.Name } +func (s *Case) Statement() *Statement { return s.Source } +func (s *Case) Exts() []*Statement { return s.Extensions } + +// An AnyXML is defined in: http://tools.ietf.org/html/rfc6020#section-7.10 +type AnyXML struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Config *Value `yang:"config"` + Description *Value `yang:"description"` + IfFeature []*Value `yang:"if-feature"` + Mandatory *Value `yang:"mandatory"` + Must []*Must `yang:"must"` + Reference *Value `yang:"reference"` + Status *Value `yang:"status"` + When *Value `yang:"when"` +} + +func (AnyXML) Kind() string { return "anyxml" } +func (s *AnyXML) ParentNode() Node { return s.Parent } +func (s *AnyXML) NName() string { return s.Name } +func (s *AnyXML) Statement() *Statement { return s.Source } +func (s *AnyXML) Exts() []*Statement { return s.Extensions } + +// An AnyData is defined in: http://tools.ietf.org/html/rfc7950#section-7.10 +// +// AnyData are only expected in YANG 1.1 modules (those with a +// "yang-version 1.1;" statement in the module). +type AnyData struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Config *Value `yang:"config"` + Description *Value `yang:"description"` + IfFeature []*Value `yang:"if-feature"` + Mandatory *Value `yang:"mandatory"` + Must []*Must `yang:"must"` + Reference *Value `yang:"reference"` + Status *Value `yang:"status"` + When *Value `yang:"when"` +} + +func (AnyData) Kind() string { return "anydata" } +func (s *AnyData) ParentNode() Node { return s.Parent } +func (s *AnyData) NName() string { return s.Name } +func (s *AnyData) Statement() *Statement { return s.Source } +func (s *AnyData) Exts() []*Statement { return s.Extensions } + +// A Grouping is defined in: http://tools.ietf.org/html/rfc6020#section-7.11 +// and http://tools.ietf.org/html/rfc7950#section-7.12 ("grouping" sub-statement) +type Grouping struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Anydata []*AnyData `yang:"anydata"` + Action []*Action `yang:"action"` + Anyxml []*AnyXML `yang:"anyxml"` + Choice []*Choice `yang:"choice"` + Container []*Container `yang:"container"` + Description *Value `yang:"description"` + Grouping []*Grouping `yang:"grouping"` + Leaf []*Leaf `yang:"leaf"` + LeafList []*LeafList `yang:"leaf-list"` + List []*List `yang:"list"` + Notification []*Notification `yang:"notification"` + Reference *Value `yang:"reference"` + Status *Value `yang:"status"` + Typedef []*Typedef `yang:"typedef"` + Uses []*Uses `yang:"uses"` +} + +func (Grouping) Kind() string { return "grouping" } +func (s *Grouping) ParentNode() Node { return s.Parent } +func (s *Grouping) NName() string { return s.Name } +func (s *Grouping) Statement() *Statement { return s.Source } +func (s *Grouping) Exts() []*Statement { return s.Extensions } +func (s *Grouping) Groupings() []*Grouping { return s.Grouping } +func (s *Grouping) Typedefs() []*Typedef { return s.Typedef } + +// A Uses is defined in: http://tools.ietf.org/html/rfc6020#section-7.12 +type Uses struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge" json:"-"` + Parent Node `yang:"Parent,nomerge" json:"-"` + Extensions []*Statement `yang:"Ext" json:"-"` + + Augment []*Augment `yang:"augment" json:",omitempty"` + Description *Value `yang:"description" json:",omitempty"` + IfFeature []*Value `yang:"if-feature" json:"-"` + Refine []*Refine `yang:"refine" json:"-"` + Reference *Value `yang:"reference" json:"-"` + Status *Value `yang:"status" json:"-"` + When *Value `yang:"when" json:",omitempty"` +} + +func (Uses) Kind() string { return "uses" } +func (s *Uses) ParentNode() Node { return s.Parent } +func (s *Uses) NName() string { return s.Name } +func (s *Uses) Statement() *Statement { return s.Source } +func (s *Uses) Exts() []*Statement { return s.Extensions } + +// A Refine is defined in: http://tools.ietf.org/html/rfc6020#section-7.12.2 +type Refine struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Default *Value `yang:"default"` + Description *Value `yang:"description"` + IfFeature []*Value `yang:"if-feature"` + Reference *Value `yang:"reference"` + Config *Value `yang:"config"` + Mandatory *Value `yang:"mandatory"` + Presence *Value `yang:"presence"` + Must []*Must `yang:"must"` + MaxElements *Value `yang:"max-elements"` + MinElements *Value `yang:"min-elements"` +} + +func (Refine) Kind() string { return "refine" } +func (s *Refine) ParentNode() Node { return s.Parent } +func (s *Refine) NName() string { return s.Name } +func (s *Refine) Statement() *Statement { return s.Source } +func (s *Refine) Exts() []*Statement { return s.Extensions } + +// An RPC is defined in: http://tools.ietf.org/html/rfc6020#section-7.13 +type RPC struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Description *Value `yang:"description"` + Grouping []*Grouping `yang:"grouping"` + IfFeature []*Value `yang:"if-feature"` + Input *Input `yang:"input"` + Output *Output `yang:"output"` + Reference *Value `yang:"reference"` + Status *Value `yang:"status"` + Typedef []*Typedef `yang:"typedef"` +} + +func (RPC) Kind() string { return "rpc" } +func (s *RPC) ParentNode() Node { return s.Parent } +func (s *RPC) NName() string { return s.Name } +func (s *RPC) Statement() *Statement { return s.Source } +func (s *RPC) Exts() []*Statement { return s.Extensions } +func (s *RPC) Groupings() []*Grouping { return s.Grouping } +func (s *RPC) Typedefs() []*Typedef { return s.Typedef } + +// An Input is defined in: http://tools.ietf.org/html/rfc6020#section-7.13.2 +type Input struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Anydata []*AnyData `yang:"anydata"` + Anyxml []*AnyXML `yang:"anyxml"` + Choice []*Choice `yang:"choice"` + Container []*Container `yang:"container"` + Grouping []*Grouping `yang:"grouping"` + Leaf []*Leaf `yang:"leaf"` + LeafList []*LeafList `yang:"leaf-list"` + List []*List `yang:"list"` + Must []*Must `yang:"must"` + Typedef []*Typedef `yang:"typedef"` + Uses []*Uses `yang:"uses"` +} + +func (Input) Kind() string { return "input" } +func (s *Input) ParentNode() Node { return s.Parent } +func (s *Input) NName() string { return s.Name } +func (s *Input) Statement() *Statement { return s.Source } +func (s *Input) Exts() []*Statement { return s.Extensions } +func (s *Input) Groupings() []*Grouping { return s.Grouping } +func (s *Input) Typedefs() []*Typedef { return s.Typedef } + +// An Output is defined in: http://tools.ietf.org/html/rfc6020#section-7.13.3 +type Output struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Anydata []*AnyData `yang:"anydata"` + Anyxml []*AnyXML `yang:"anyxml"` + Choice []*Choice `yang:"choice"` + Container []*Container `yang:"container"` + Grouping []*Grouping `yang:"grouping"` + Leaf []*Leaf `yang:"leaf"` + LeafList []*LeafList `yang:"leaf-list"` + List []*List `yang:"list"` + Must []*Must `yang:"must"` + Typedef []*Typedef `yang:"typedef"` + Uses []*Uses `yang:"uses"` +} + +func (Output) Kind() string { return "output" } +func (s *Output) ParentNode() Node { return s.Parent } +func (s *Output) NName() string { return s.Name } +func (s *Output) Statement() *Statement { return s.Source } +func (s *Output) Exts() []*Statement { return s.Extensions } +func (s *Output) Groupings() []*Grouping { return s.Grouping } +func (s *Output) Typedefs() []*Typedef { return s.Typedef } + +// A Notification is defined in: http://tools.ietf.org/html/rfc6020#section-7.14 +type Notification struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Anydata []*AnyData `yang:"anydata"` + Anyxml []*AnyXML `yang:"anyxml"` + Choice []*Choice `yang:"choice"` + Container []*Container `yang:"container"` + Description *Value `yang:"description"` + Grouping []*Grouping `yang:"grouping"` + IfFeature []*Value `yang:"if-feature"` + Leaf []*Leaf `yang:"leaf"` + LeafList []*LeafList `yang:"leaf-list"` + List []*List `yang:"list"` + Reference *Value `yang:"reference"` + Status *Value `yang:"status"` + Typedef []*Typedef `yang:"typedef"` + Uses []*Uses `yang:"uses"` +} + +func (Notification) Kind() string { return "notification" } +func (s *Notification) ParentNode() Node { return s.Parent } +func (s *Notification) NName() string { return s.Name } +func (s *Notification) Statement() *Statement { return s.Source } +func (s *Notification) Exts() []*Statement { return s.Extensions } +func (s *Notification) Groupings() []*Grouping { return s.Grouping } +func (s *Notification) Typedefs() []*Typedef { return s.Typedef } + +// An Augment is defined in: http://tools.ietf.org/html/rfc6020#section-7.15 +// and http://tools.ietf.org/html/rfc7950#section-7.17 ("augment" sub-statement) +type Augment struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Anydata []*AnyData `yang:"anydata"` + Action []*Action `yang:"action"` + Anyxml []*AnyXML `yang:"anyxml"` + Case []*Case `yang:"case"` + Choice []*Choice `yang:"choice"` + Container []*Container `yang:"container"` + Description *Value `yang:"description"` + IfFeature []*Value `yang:"if-feature"` + Leaf []*Leaf `yang:"leaf"` + LeafList []*LeafList `yang:"leaf-list"` + List []*List `yang:"list"` + Notification []*Notification `yang:"notification"` + Reference *Value `yang:"reference"` + Status *Value `yang:"status"` + Uses []*Uses `yang:"uses"` + When *Value `yang:"when"` +} + +func (Augment) Kind() string { return "augment" } +func (s *Augment) ParentNode() Node { return s.Parent } +func (s *Augment) NName() string { return s.Name } +func (s *Augment) Statement() *Statement { return s.Source } +func (s *Augment) Exts() []*Statement { return s.Extensions } + +// An Identity is defined in: http://tools.ietf.org/html/rfc6020#section-7.16 +type Identity struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge" json:"-"` + Parent Node `yang:"Parent,nomerge" json:"-"` + Extensions []*Statement `yang:"Ext" json:"-"` + + Base []*Value `yang:"base" json:"-"` + Description *Value `yang:"description" json:"-"` + IfFeature []*Value `yang:"if-feature" json:"-"` + Reference *Value `yang:"reference" json:"-"` + Status *Value `yang:"status" json:"-"` + Values []*Identity `json:",omitempty"` +} + +func (Identity) Kind() string { return "identity" } +func (s *Identity) ParentNode() Node { return s.Parent } +func (s *Identity) NName() string { return s.Name } +func (s *Identity) Statement() *Statement { return s.Source } +func (s *Identity) Exts() []*Statement { return s.Extensions } + +// PrefixedName returns the prefix-qualified name for the identity +func (s *Identity) PrefixedName() string { + return fmt.Sprintf("%s:%s", RootNode(s).GetPrefix(), s.Name) +} + +// modulePrefixedName returns the module-qualified name for the identity. +func (s *Identity) modulePrefixedName() string { + return fmt.Sprintf("%s:%s", module(s).Name, s.Name) +} + +// IsDefined behaves the same as the implementation for Enum - it returns +// true if an identity with the name is defined within the Values of the +// identity +func (s *Identity) IsDefined(name string) bool { + return s.GetValue(name) != nil +} + +// GetValue returns a pointer to the identity with name "name" that is within +// the values of the identity +func (s *Identity) GetValue(name string) *Identity { + for _, v := range s.Values { + if v.Name == name { + return v + } + } + return nil +} + +// An Extension is defined in: http://tools.ietf.org/html/rfc6020#section-7.17 +type Extension struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge" json:"-"` + Parent Node `yang:"Parent,nomerge" json:"-"` + Extensions []*Statement `yang:"Ext" json:",omitempty"` + + Argument *Argument `yang:"argument" json:",omitempty"` + Description *Value `yang:"description" json:",omitempty"` + Reference *Value `yang:"reference" json:",omitempty"` + Status *Value `yang:"status" json:",omitempty"` +} + +func (Extension) Kind() string { return "extension" } +func (s *Extension) ParentNode() Node { return s.Parent } +func (s *Extension) NName() string { return s.Name } +func (s *Extension) Statement() *Statement { return s.Source } +func (s *Extension) Exts() []*Statement { return s.Extensions } + +// An Argument is defined in: http://tools.ietf.org/html/rfc6020#section-7.17.2 +type Argument struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge" json:"-"` + Parent Node `yang:"Parent,nomerge" json:"-"` + Extensions []*Statement `yang:"Ext" json:",omitempty"` + + YinElement *Value `yang:"yin-element" json:",omitempty"` +} + +func (Argument) Kind() string { return "argument" } +func (s *Argument) ParentNode() Node { return s.Parent } +func (s *Argument) NName() string { return s.Name } +func (s *Argument) Statement() *Statement { return s.Source } +func (s *Argument) Exts() []*Statement { return s.Extensions } + +// An Element is defined in: http://tools.ietf.org/html/rfc6020#section-7.17.2.2 +type Element struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + YinElement *Value `yang:"yin-element"` +} + +func (Element) Kind() string { return "element" } +func (s *Element) ParentNode() Node { return s.Parent } +func (s *Element) NName() string { return s.Name } +func (s *Element) Statement() *Statement { return s.Source } +func (s *Element) Exts() []*Statement { return s.Extensions } + +// A Feature is defined in: http://tools.ietf.org/html/rfc6020#section-7.18.1 +type Feature struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge" json:"-"` + Parent Node `yang:"Parent,nomerge" json:"-"` + Extensions []*Statement `yang:"Ext" json:",omitempty"` + + Description *Value `yang:"description" json:",omitempty"` + IfFeature []*Value `yang:"if-feature" json:",omitempty"` + Status *Value `yang:"status" json:",omitempty"` + Reference *Value `yang:"reference" json:",omitempty"` +} + +func (Feature) Kind() string { return "feature" } +func (s *Feature) ParentNode() Node { return s.Parent } +func (s *Feature) NName() string { return s.Name } +func (s *Feature) Statement() *Statement { return s.Source } +func (s *Feature) Exts() []*Statement { return s.Extensions } + +// A Deviation is defined in: http://tools.ietf.org/html/rfc6020#section-7.18.3 +type Deviation struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Description *Value `yang:"description"` + Deviate []*Deviate `yang:"deviate,required"` + Reference *Value `yang:"reference"` +} + +func (Deviation) Kind() string { return "deviation" } +func (s *Deviation) ParentNode() Node { return s.Parent } +func (s *Deviation) NName() string { return s.Name } +func (s *Deviation) Statement() *Statement { return s.Source } +func (s *Deviation) Exts() []*Statement { return s.Extensions } + +// A Deviate is defined in: http://tools.ietf.org/html/rfc6020#section-7.18.3.2 +type Deviate struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Config *Value `yang:"config"` + Default *Value `yang:"default"` + Mandatory *Value `yang:"mandatory"` + MaxElements *Value `yang:"max-elements"` + MinElements *Value `yang:"min-elements"` + Must []*Must `yang:"must"` + Type *Type `yang:"type"` + Unique []*Value `yang:"unique"` + Units *Value `yang:"units"` +} + +func (Deviate) Kind() string { return "deviate" } +func (s *Deviate) ParentNode() Node { return s.Parent } +func (s *Deviate) NName() string { return s.Name } +func (s *Deviate) Statement() *Statement { return s.Source } +func (s *Deviate) Exts() []*Statement { return s.Extensions } + +// An Enum is defined in: http://tools.ietf.org/html/rfc6020#section-9.6.4 +type Enum struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Description *Value `yang:"description"` + IfFeature []*Value `yang:"if-feature"` + Reference *Value `yang:"reference"` + Status *Value `yang:"status"` + Value *Value `yang:"value"` +} + +func (Enum) Kind() string { return "enum" } +func (s *Enum) ParentNode() Node { return s.Parent } +func (s *Enum) NName() string { return s.Name } +func (s *Enum) Statement() *Statement { return s.Source } +func (s *Enum) Exts() []*Statement { return s.Extensions } + +// A Bit is defined in: http://tools.ietf.org/html/rfc6020#section-9.7.4 +type Bit struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Description *Value `yang:"description"` + IfFeature []*Value `yang:"if-feature"` + Reference *Value `yang:"reference"` + Status *Value `yang:"status"` + Position *Value `yang:"position"` +} + +func (Bit) Kind() string { return "bit" } +func (s *Bit) ParentNode() Node { return s.Parent } +func (s *Bit) NName() string { return s.Name } +func (s *Bit) Statement() *Statement { return s.Source } +func (s *Bit) Exts() []*Statement { return s.Extensions } + +// A Range is defined in: http://tools.ietf.org/html/rfc6020#section-9.2.4 +type Range struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Description *Value `yang:"description"` + ErrorAppTag *Value `yang:"error-app-tag"` + ErrorMessage *Value `yang:"error-message"` + Reference *Value `yang:"reference"` +} + +func (Range) Kind() string { return "range" } +func (s *Range) ParentNode() Node { return s.Parent } +func (s *Range) NName() string { return s.Name } +func (s *Range) Statement() *Statement { return s.Source } +func (s *Range) Exts() []*Statement { return s.Extensions } + +// A Length is defined in: http://tools.ietf.org/html/rfc6020#section-9.4.4 +type Length struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Description *Value `yang:"description"` + ErrorAppTag *Value `yang:"error-app-tag"` + ErrorMessage *Value `yang:"error-message"` + Reference *Value `yang:"reference"` +} + +func (Length) Kind() string { return "length" } +func (s *Length) ParentNode() Node { return s.Parent } +func (s *Length) NName() string { return s.Name } +func (s *Length) Statement() *Statement { return s.Source } +func (s *Length) Exts() []*Statement { return s.Extensions } + +// A Pattern is defined in: http://tools.ietf.org/html/rfc6020#section-9.4.6 +// and http://tools.ietf.org/html/rfc7950#section-9.4.5.1 ("modifier" sub-statement) +type Pattern struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Description *Value `yang:"description"` + ErrorAppTag *Value `yang:"error-app-tag"` + ErrorMessage *Value `yang:"error-message"` + Reference *Value `yang:"reference"` + Modifier *Value `yang:"modifier"` +} + +func (Pattern) Kind() string { return "pattern" } +func (s *Pattern) ParentNode() Node { return s.Parent } +func (s *Pattern) NName() string { return s.Name } +func (s *Pattern) Statement() *Statement { return s.Source } +func (s *Pattern) Exts() []*Statement { return s.Extensions } + +// An Action is defined in http://tools.ietf.org/html/rfc7950#section-7.15 +// +// Action define an RPC operation connected to a specific container or list data +// node in the schema. In the schema tree, Action differ from RPC only in where +// in the tree they are found. RPC nodes are only found as sub-statements of a +// Module, while Action are found only as sub-statements of Container, List, +// Grouping and Augment nodes. +type Action struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Description *Value `yang:"description"` + Grouping []*Grouping `yang:"grouping"` + IfFeature []*Value `yang:"if-feature"` + Input *Input `yang:"input"` + Output *Output `yang:"output"` + Reference *Value `yang:"reference"` + Status *Value `yang:"status"` + Typedef []*Typedef `yang:"typedef"` +} + +func (Action) Kind() string { return "action" } +func (s *Action) ParentNode() Node { return s.Parent } +func (s *Action) NName() string { return s.Name } +func (s *Action) Statement() *Statement { return s.Source } +func (s *Action) Exts() []*Statement { return s.Extensions } +func (s *Action) Groupings() []*Grouping { return s.Grouping } +func (s *Action) Typedefs() []*Typedef { return s.Typedef } diff --git a/src/webui/internal/goyang/pkg/yang/yangtype.go b/src/webui/internal/goyang/pkg/yang/yangtype.go new file mode 100644 index 00000000..cc1e33fd --- /dev/null +++ b/src/webui/internal/goyang/pkg/yang/yangtype.go @@ -0,0 +1,330 @@ +// Copyright 2021 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +import ( + "fmt" + + "github.com/google/go-cmp/cmp" +) + +var ( + // TypeKindFromName maps the string name used in a YANG file to the enumerated + // TypeKind used in this library. + TypeKindFromName = map[string]TypeKind{ + "none": Ynone, + "int8": Yint8, + "int16": Yint16, + "int32": Yint32, + "int64": Yint64, + "uint8": Yuint8, + "uint16": Yuint16, + "uint32": Yuint32, + "uint64": Yuint64, + "binary": Ybinary, + "bits": Ybits, + "boolean": Ybool, + "decimal64": Ydecimal64, + "empty": Yempty, + "enumeration": Yenum, + "identityref": Yidentityref, + "instance-identifier": YinstanceIdentifier, + "leafref": Yleafref, + "string": Ystring, + "union": Yunion, + } + + // TypeKindToName maps the enumerated type used in this library to the string + // used in a YANG file. + TypeKindToName = map[TypeKind]string{ + Ynone: "none", + Yint8: "int8", + Yint16: "int16", + Yint32: "int32", + Yint64: "int64", + Yuint8: "uint8", + Yuint16: "uint16", + Yuint32: "uint32", + Yuint64: "uint64", + Ybinary: "binary", + Ybits: "bits", + Ybool: "boolean", + Ydecimal64: "decimal64", + Yempty: "empty", + Yenum: "enumeration", + Yidentityref: "identityref", + YinstanceIdentifier: "instance-identifier", + Yleafref: "leafref", + Ystring: "string", + Yunion: "union", + } + + // BaseTypedefs is a map of all base types to the Typedef structure manufactured + // for the type. + BaseTypedefs = map[string]*Typedef{} + + baseTypes = map[string]*YangType{ + "int8": { + Name: "int8", + Kind: Yint8, + Range: Int8Range, + }, + "int16": { + Name: "int16", + Kind: Yint16, + Range: Int16Range, + }, + "int32": { + Name: "int32", + Kind: Yint32, + Range: Int32Range, + }, + "int64": { + Name: "int64", + Kind: Yint64, + Range: Int64Range, + }, + "uint8": { + Name: "uint8", + Kind: Yuint8, + Range: Uint8Range, + }, + "uint16": { + Name: "uint16", + Kind: Yuint16, + Range: Uint16Range, + }, + "uint32": { + Name: "uint32", + Kind: Yuint32, + Range: Uint32Range, + }, + "uint64": { + Name: "uint64", + Kind: Yuint64, + Range: Uint64Range, + }, + + "decimal64": { + Name: "decimal64", + Kind: Ydecimal64, + }, + "string": { + Name: "string", + Kind: Ystring, + }, + "boolean": { + Name: "boolean", + Kind: Ybool, + }, + "enumeration": { + Name: "enumeration", + Kind: Yenum, + }, + "bits": { + Name: "bits", + Kind: Ybits, + }, + "binary": { + Name: "binary", + Kind: Ybinary, + }, + "leafref": { + Name: "leafref", + Kind: Yleafref, + }, + "identityref": { + Name: "identityref", + Kind: Yidentityref, + }, + "empty": { + Name: "empty", + Kind: Yempty, + }, + "union": { + Name: "union", + Kind: Yunion, + }, + "instance-identifier": { + Name: "instance-identifier", + Kind: YinstanceIdentifier, + }, + } +) + +// Install builtin types as know types +func init() { + for k, v := range baseTypes { + // Base types are always their own root + v.Root = v + BaseTypedefs[k] = v.typedef() + } +} + +// TypeKind is the enumeration of the base types available in YANG. It +// is analogous to reflect.Kind. +type TypeKind uint + +func (k TypeKind) String() string { + if s := TypeKindToName[k]; s != "" { + return s + } + return fmt.Sprintf("unknown-type-%d", k) +} + +const ( + // Ynone represents the invalid (unset) type. + Ynone = TypeKind(iota) + // Yint8 is an int in the range [-128, 127]. + Yint8 + // Yint16 is an int in the range [-32768, 32767]. + Yint16 + // Yint32 is an int in the range [-2147483648, 2147483647]. + Yint32 + // Yint64 is an int in the range [-9223372036854775808, 9223372036854775807] + Yint64 + // Yuint8 is an int in the range [0, 255] + Yuint8 + // Yuint16 is an int in the range [0, 65535] + Yuint16 + // Yuint32 is an int in the range [0, 4294967295] + Yuint32 + // Yuint64 is an int in the range [0, 18446744073709551615] + Yuint64 + + // Ybinary stores arbitrary data. + Ybinary + // Ybits is a named set of bits or flags. + Ybits + // Ybool is true or false. + Ybool + // Ydecimal64 is a signed decimal number. + Ydecimal64 + // Yempty has no associated value. + Yempty + // Yenum stores enumerated strings. + Yenum + // Yidentityref stores an extensible enumeration. + Yidentityref + // YinstanceIdentifier stores a reference to a data tree node. + YinstanceIdentifier + // Yleafref stores a reference to a leaf instance. + Yleafref + // Ystring is a human readable string. + Ystring + // Yunion is a choice of types. + Yunion +) + +// A YangType is the internal representation of a type in YANG. It may +// refer to either a builtin type or type specified with typedef. Not +// all fields in YangType are used for all types. +type YangType struct { + Name string + Kind TypeKind // Ynone if not a base type + Base *Type `json:"-"` // Base type for non-builtin types + IdentityBase *Identity `json:",omitempty"` // Base statement for a type using identityref + Root *YangType `json:"-"` // root of this type that is the same + Bit *EnumType `json:",omitempty"` // bit position, "status" is lost + Enum *EnumType `json:",omitempty"` // enum name to value, "status" is lost + Units string `json:",omitempty"` // units to be used for this type + Default string `json:",omitempty"` // default value, if any + HasDefault bool `json:",omitempty"` // whether the type has a default. + FractionDigits int `json:",omitempty"` // decimal64 fixed point precision + Length YangRange `json:",omitempty"` // this should be processed by section 12 + OptionalInstance bool `json:",omitempty"` // !require-instances which defaults to true + Path string `json:",omitempty"` // the path in a leafref + Pattern []string `json:",omitempty"` // limiting XSD-TYPES expressions on strings + POSIXPattern []string `json:",omitempty"` // limiting POSIX ERE on strings (specified by openconfig-extensions:posix-pattern) + Range YangRange `json:",omitempty"` // range for integers + Type []*YangType `json:",omitempty"` // for unions +} + +// Equal returns true if y and t describe the same type. +func (y *YangType) Equal(t *YangType) bool { + switch { + case y == t: + return true + case y == nil || t == nil: + return false + case + // Don't check the Name, it contains no information + y.Kind != t.Kind, + y.Units != t.Units, + y.Default != t.Default, + y.HasDefault != t.HasDefault, + y.FractionDigits != t.FractionDigits, + y.IdentityBase != t.IdentityBase, + len(y.Length) != len(t.Length), + !y.Length.Equal(t.Length), + y.OptionalInstance != t.OptionalInstance, + y.Path != t.Path, + !ssEqual(y.Pattern, t.Pattern), + !ssEqual(y.POSIXPattern, t.POSIXPattern), + len(y.Range) != len(t.Range), + !y.Range.Equal(t.Range), + !tsEqual(y.Type, t.Type), + !cmp.Equal(y.Enum, t.Enum, cmp.Comparer(func(t, u EnumType) bool { + return cmp.Equal(t.unique, u.unique) && cmp.Equal(t.ToInt, u.ToInt) && cmp.Equal(t.ToString, u.ToString) + })): + + return false + } + // TODO(borman): Base, Bit + return true +} + +// typedef returns a Typedef created from y for insertion into the BaseTypedefs +// map. +func (y *YangType) typedef() *Typedef { + return &Typedef{ + Name: y.Name, + Source: &Statement{}, + Type: &Type{ + Name: y.Name, + Source: &Statement{}, + YangType: y, + }, + YangType: y, + } +} + +// ssEqual returns true if the two slices are equivalent. +func ssEqual(s1, s2 []string) bool { + if len(s1) != len(s2) { + return false + } + for x, s := range s1 { + if s != s2[x] { + return false + } + } + return true +} + +// tsEqual returns true if the two Type slices are identical. +func tsEqual(t1, t2 []*YangType) bool { + if len(t1) != len(t2) { + return false + } + // For now we compare absolute pointers. + // This may be wrong. + for x, t := range t1 { + if !t.Equal(t2[x]) { + return false + } + } + return true +} diff --git a/src/webui/internal/goyang/pkg/yang/yangtype_test.go b/src/webui/internal/goyang/pkg/yang/yangtype_test.go new file mode 100644 index 00000000..f02e66e2 --- /dev/null +++ b/src/webui/internal/goyang/pkg/yang/yangtype_test.go @@ -0,0 +1,136 @@ +// Copyright 2021 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +import ( + "testing" +) + +func TestYangTypeEqual(t *testing.T) { + + tests := []struct { + name string + inLeft *YangType + inRight *YangType + wantEqual bool + }{{ + name: "both-nil", + inLeft: nil, + inRight: nil, + wantEqual: true, + }, { + name: "one-nil", + inLeft: &YangType{ + Kind: Ydecimal64, + FractionDigits: 5, + }, + inRight: nil, + wantEqual: false, + }, { + name: "name-unequal", + inLeft: &YangType{ + Name: "foo", + Kind: Ydecimal64, + FractionDigits: 5, + }, + inRight: &YangType{ + Name: "bar", + Kind: Ydecimal64, + FractionDigits: 5, + }, + wantEqual: true, + }, { + name: "fraction-digits-unequal", + inLeft: &YangType{ + Name: "foo", + Kind: Ydecimal64, + FractionDigits: 5, + }, + inRight: &YangType{ + Name: "foo", + Kind: Ydecimal64, + FractionDigits: 4, + }, + wantEqual: false, + }, { + name: "types-unequal", + inLeft: &YangType{ + Name: "foo", + Kind: Ydecimal64, + FractionDigits: 5, + }, + inRight: &YangType{ + Name: "foo", + Kind: Yint64, + }, + wantEqual: false, + }, { + name: "defaults-equal", + inLeft: &YangType{ + Name: "foo", + Kind: Ystring, + Default: "bar", + HasDefault: true, + }, + inRight: &YangType{ + Name: "foo", + Kind: Ystring, + Default: "bar", + HasDefault: true, + }, + wantEqual: true, + }, { + name: "defaults-unequal", + inLeft: &YangType{ + Name: "foo", + Kind: Ystring, + Default: "bar", + HasDefault: true, + }, + inRight: &YangType{ + Name: "foo", + Kind: Ystring, + Default: "baz", + HasDefault: true, + }, + wantEqual: false, + }, { + name: "has-default-unequal", + inLeft: &YangType{ + Name: "foo", + Kind: Ystring, + Default: "", + }, + inRight: &YangType{ + Name: "foo", + Kind: Ystring, + Default: "", + HasDefault: true, + }, + wantEqual: false, + }} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if gotEqual := tt.inLeft.Equal(tt.inRight); gotEqual != tt.wantEqual { + t.Errorf("gotEqual: %v, wantEqual: %v", gotEqual, tt.wantEqual) + } + // Must be symmetric + if reverseEqual := tt.inRight.Equal(tt.inLeft); reverseEqual != tt.wantEqual { + t.Errorf("got reverseEqual: %v, wantEqual: %v", reverseEqual, tt.wantEqual) + } + }) + } +} diff --git a/src/webui/internal/goyang/pkg/yangentry/build_yang.go b/src/webui/internal/goyang/pkg/yangentry/build_yang.go new file mode 100644 index 00000000..486f7299 --- /dev/null +++ b/src/webui/internal/goyang/pkg/yangentry/build_yang.go @@ -0,0 +1,78 @@ +// Copyright 2020 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package yangentry contains high-level helpers for using yang.Entry objects. +package yangentry + +import ( + "fmt" + + "github.com/openconfig/goyang/pkg/yang" +) + +// Parse takes a list of either module/submodule names or .yang file +// paths, and a list of include paths. It runs the yang parser on the YANG +// files by searching for them in the include paths or in the current +// directory, returning a slice of yang.Entry pointers which represent the +// parsed top level modules. It also returns a list of errors encountered while +// parsing, if any. +func Parse(yangfiles, path []string) (map[string]*yang.Entry, []error) { + return parse(yangfiles, path, yang.NewModules()) +} + +// ParseWithOptions takes a list of either module/submodule names or .yang file +// paths, a list of include paths, and a set of parse options. It configures the +// yang parser with the specified parse options and runs it on the YANG +// files by searching for them in the include paths or in the current +// directory, returning a slice of yang.Entry pointers which represent the +// parsed top level modules. It also returns a list of errors encountered while +// parsing, if any. +func ParseWithOptions(yangfiles, path []string, parseOptions yang.Options) (map[string]*yang.Entry, []error) { + ms := yang.NewModules() + ms.ParseOptions = parseOptions + + return parse(yangfiles, path, ms) +} + +func parse(yangfiles, path []string, ms *yang.Modules) (map[string]*yang.Entry, []error) { + for _, p := range path { + ms.AddPath(fmt.Sprintf("%s/...", p)) + } + + var processErr []error + for _, name := range yangfiles { + if name == "" { + continue + } + if err := ms.Read(name); err != nil { + processErr = append(processErr, err) + } + } + + if len(processErr) > 0 { + return nil, processErr + } + + if errs := ms.Process(); len(errs) != 0 { + return nil, errs + } + + entries := make(map[string]*yang.Entry) + for _, m := range ms.Modules { + e := yang.ToEntry(m) + entries[e.Name] = e + } + + return entries, nil +} diff --git a/src/webui/internal/goyang/pkg/yangentry/build_yang_test.go b/src/webui/internal/goyang/pkg/yangentry/build_yang_test.go new file mode 100644 index 00000000..266df9cd --- /dev/null +++ b/src/webui/internal/goyang/pkg/yangentry/build_yang_test.go @@ -0,0 +1,129 @@ +// Copyright 2020 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yangentry + +import ( + "testing" + + "github.com/openconfig/goyang/pkg/yang" +) + +// TestParse tests the Parse function - which takes an input +// set of modules and processes them using the goyang compiler into a set of +// yang.Entry pointers. +func TestParse(t *testing.T) { + tests := []struct { + name string + inFiles []string + inPath []string + wantErr bool + wantMods []string + }{{ + name: "simple valid module", + inFiles: []string{"testdata/00-valid-module.yang"}, + inPath: []string{"testdata"}, + wantMods: []string{"test-module"}, + }, { + name: "simple valid module without .yang extension", + inFiles: []string{"00-valid-module"}, + inPath: []string{"testdata"}, + wantMods: []string{"test-module"}, + }, { + name: "simple invalid module", + inFiles: []string{"testdata/01-invalid-module.yang"}, + inPath: []string{"testdata"}, + wantErr: true, + }, { + name: "valid import", + inFiles: []string{"testdata/02-valid-import.yang"}, + inPath: []string{"testdata/subdir"}, + wantMods: []string{"test-module"}, + }, { + name: "invalid import", + inFiles: []string{"testdata/03-invalid-import.yang"}, + inPath: []string{}, + wantErr: true, + }, { + name: "two modules", + inFiles: []string{"testdata/04-valid-module-one.yang", "testdata/04-valid-module-two.yang"}, + inPath: []string{}, + wantMods: []string{"module-one", "module-two"}, + }, { + name: "circular submodule dependency", + inFiles: []string{"testdata/05-circular-main.yang"}, + inPath: []string{"testdata/subdir"}, + wantErr: true, + }} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + entries, errs := Parse(tt.inFiles, tt.inPath) + if len(errs) != 0 && !tt.wantErr { + t.Fatalf("%s: unexpected error processing modules: %v", tt.name, errs) + } + + for _, m := range tt.wantMods { + if _, ok := entries[m]; !ok { + t.Fatalf("%s: could not find module %s", tt.name, m) + } + } + }) + } +} + +// TestParseWithOptions tests the ParseWithOptions function - which takes an input +// set of modules along with a set of parse options, and processes them using the goyang +// compiler into a set of yang.Entry pointers. +func TestParseWithOptions(t *testing.T) { + tests := []struct { + name string + inFiles []string + inPath []string + parseOptions yang.Options + wantErr bool + wantMods []string + }{ + { + name: "circular submodule dependency with default options", + inFiles: []string{"testdata/05-circular-main.yang"}, + inPath: []string{"testdata/subdir"}, + parseOptions: yang.Options{}, + wantErr: true, + }, + { + name: "circular submodule dependency with IgnoreSubmoduleCircularDependencies", + inFiles: []string{"testdata/05-circular-main.yang"}, + inPath: []string{"testdata/subdir"}, + parseOptions: yang.Options{IgnoreSubmoduleCircularDependencies: true}, + wantMods: []string{"circular-main"}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + entries, errs := ParseWithOptions(tt.inFiles, tt.inPath, tt.parseOptions) + if len(errs) != 0 && !tt.wantErr { + t.Fatalf("%s: unexpected error processing modules: %v", tt.name, errs) + } + + for _, m := range tt.wantMods { + if _, ok := entries[m]; !ok { + t.Fatalf("%s: could not find module %s", tt.name, m) + } + } + }) + } +} diff --git a/src/webui/internal/goyang/pkg/yangentry/testdata/00-valid-module.yang b/src/webui/internal/goyang/pkg/yangentry/testdata/00-valid-module.yang new file mode 100644 index 00000000..b216af12 --- /dev/null +++ b/src/webui/internal/goyang/pkg/yangentry/testdata/00-valid-module.yang @@ -0,0 +1,6 @@ +module test-module { + prefix "t"; + namespace "urn:t"; + + leaf valid-leaf { type string; } +} diff --git a/src/webui/internal/goyang/pkg/yangentry/testdata/01-invalid-module.yang b/src/webui/internal/goyang/pkg/yangentry/testdata/01-invalid-module.yang new file mode 100644 index 00000000..7e28c904 --- /dev/null +++ b/src/webui/internal/goyang/pkg/yangentry/testdata/01-invalid-module.yang @@ -0,0 +1,6 @@ +module test-module { + prefix "t"; + namespace "urn:t"; + + leaf invalid-leaf { } +} diff --git a/src/webui/internal/goyang/pkg/yangentry/testdata/02-valid-import.yang b/src/webui/internal/goyang/pkg/yangentry/testdata/02-valid-import.yang new file mode 100644 index 00000000..621a1070 --- /dev/null +++ b/src/webui/internal/goyang/pkg/yangentry/testdata/02-valid-import.yang @@ -0,0 +1,6 @@ +module test-module { + prefix "t"; + namespace "urn:t"; + + import imported { prefix "i"; } +} diff --git a/src/webui/internal/goyang/pkg/yangentry/testdata/03-invalid-import.yang b/src/webui/internal/goyang/pkg/yangentry/testdata/03-invalid-import.yang new file mode 100644 index 00000000..65ba27a5 --- /dev/null +++ b/src/webui/internal/goyang/pkg/yangentry/testdata/03-invalid-import.yang @@ -0,0 +1,6 @@ +module test-module { + prefix "t"; + namespace "urn:t"; + + import import-not-found { prefix "i"; } +} diff --git a/src/webui/internal/goyang/pkg/yangentry/testdata/04-valid-module-one.yang b/src/webui/internal/goyang/pkg/yangentry/testdata/04-valid-module-one.yang new file mode 100644 index 00000000..28ed7702 --- /dev/null +++ b/src/webui/internal/goyang/pkg/yangentry/testdata/04-valid-module-one.yang @@ -0,0 +1,6 @@ +module module-one { + prefix "t"; + namespace "urn:t"; + + leaf one { type int8; } +} diff --git a/src/webui/internal/goyang/pkg/yangentry/testdata/04-valid-module-two.yang b/src/webui/internal/goyang/pkg/yangentry/testdata/04-valid-module-two.yang new file mode 100644 index 00000000..d17cbccd --- /dev/null +++ b/src/webui/internal/goyang/pkg/yangentry/testdata/04-valid-module-two.yang @@ -0,0 +1,6 @@ +module module-two { + prefix "t"; + namespace "urn:t"; + + leaf two { type int8; } +} diff --git a/src/webui/internal/goyang/pkg/yangentry/testdata/05-circular-main.yang b/src/webui/internal/goyang/pkg/yangentry/testdata/05-circular-main.yang new file mode 100644 index 00000000..d27bfcf6 --- /dev/null +++ b/src/webui/internal/goyang/pkg/yangentry/testdata/05-circular-main.yang @@ -0,0 +1,12 @@ +module circular-main { + yang-version "1.1"; + + namespace "urn:test:circular:main"; + + prefix "main"; + + include circular-sub-one; + include circular-sub-two; + + revision "2025-02-15"; +} diff --git a/src/webui/internal/goyang/pkg/yangentry/testdata/subdir/circular-sub-one.yang b/src/webui/internal/goyang/pkg/yangentry/testdata/subdir/circular-sub-one.yang new file mode 100644 index 00000000..cf50a8b3 --- /dev/null +++ b/src/webui/internal/goyang/pkg/yangentry/testdata/subdir/circular-sub-one.yang @@ -0,0 +1,9 @@ +submodule circular-sub-one { + yang-version "1.1"; + + belongs-to circular-main { prefix "main"; } + + include circular-sub-two; + + revision "2025-02-15"; +} diff --git a/src/webui/internal/goyang/pkg/yangentry/testdata/subdir/circular-sub-two.yang b/src/webui/internal/goyang/pkg/yangentry/testdata/subdir/circular-sub-two.yang new file mode 100644 index 00000000..afaf4c4e --- /dev/null +++ b/src/webui/internal/goyang/pkg/yangentry/testdata/subdir/circular-sub-two.yang @@ -0,0 +1,9 @@ +submodule circular-sub-two { + yang-version "1.1"; + + belongs-to circular-main { prefix "main"; } + + include circular-sub-one; + + revision "2025-02-15"; +} diff --git a/src/webui/internal/goyang/pkg/yangentry/testdata/subdir/imported.yang b/src/webui/internal/goyang/pkg/yangentry/testdata/subdir/imported.yang new file mode 100644 index 00000000..71ddd933 --- /dev/null +++ b/src/webui/internal/goyang/pkg/yangentry/testdata/subdir/imported.yang @@ -0,0 +1,5 @@ +module imported { + prefix "imported"; + namespace "urn:i"; + +} diff --git a/src/webui/internal/goyang/testdata/aug.yang b/src/webui/internal/goyang/testdata/aug.yang new file mode 100644 index 00000000..785a1a1c --- /dev/null +++ b/src/webui/internal/goyang/testdata/aug.yang @@ -0,0 +1,28 @@ +module aug { + namespace "yang-sucks"; + prefix "yang"; + grouping bgp-neighbor_config { + leaf peer-as { type string; } + } + grouping bgp-neighbors { + list neighbor { + uses bgp-neighbor-group; + } + } + grouping bgp-neighbor-group { + container config { + uses bgp-neighbor_config; + } + } + grouping bgp-neighbor-peer-group_config { + leaf peer-group { type string; } + } + augment /bgp/neighbors/neighbor/config { + uses bgp-neighbor-peer-group_config; + } + container bgp { + container neighbors { + uses bgp-neighbors; + } + } +} diff --git a/src/webui/internal/goyang/testdata/base.yang b/src/webui/internal/goyang/testdata/base.yang new file mode 100644 index 00000000..91563198 --- /dev/null +++ b/src/webui/internal/goyang/testdata/base.yang @@ -0,0 +1,93 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Base test yang module. +module base { + namespace "urn:mod"; + prefix "base"; + + include sub; + import other { + prefix bother; + } + + // basic type tests + typedef base-type { type int32; } + leaf base-leaf1 { type base-type; } + leaf base-leaf2 { type base:base-type; } + leaf base-leaf3 { type bother:other-type; } + leaf base-leaf4 { type sub-type; } + + grouping base-group { + description + "The base-group is used to test the 'uses' statement below. + This description is here to simply include a multi-line string + as an example of multi-line strings"; + leaf base-group-leaf { + config false; + type string; + } + } + + // test uses and leaf ref + container base-container-1 { + uses base-group; + uses bother:other-group; + uses base:sub-group; + choice base-choice { + case choice-a { + leaf base-choice-a1 { type string; } + leaf base-choice-a2 { + type leafref { path ../base-container-1-leaf; } + } + } + case choice-b { + leaf base-choice-b1 { type string; } + leaf base-choice-b2 { + type leafref { path ../../base-container-2/base-container-2a/base-container-2a-leaf; } + } + } + } + leaf base-container-1-leaf { type string; } + } + + // container referenced by a leafref above + container base-container-2 { + container base-container-2a { + leaf base-container-2a-leaf { type string; } + } + } + + // test basic augmenting + augment /base-container-1/base-choice/choice-a { + leaf base-choice-a3 { type string; } + } + augment /base-container-1/base-choice { + case choice-c { + leaf base-choice-c1 { type string; } + } + } + + // simple extension test + extension base-ext { + argument base-arg; + } + container ext-container { + config false; + leaf ext-container-leaf { type string; } + base:base-ext "EXTENSION" { + leaf base-ext-leaf { type string; } + } + } +} diff --git a/src/webui/internal/goyang/testdata/other.yang b/src/webui/internal/goyang/testdata/other.yang new file mode 100644 index 00000000..acbe95a4 --- /dev/null +++ b/src/webui/internal/goyang/testdata/other.yang @@ -0,0 +1,30 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// imported by base.yang. +module other { + namespace "uri:empty"; + prefix "otherp"; + typedef other-type { type string; } + + // This container should not appear in base, even though this file is + // imported by base. That is just the YANG is defined. + container other-container { + leaf other-container-leaf1 { type other-type; } + leaf other-container-leaf2 { type otherp:other-type; } + } + grouping other-group { + leaf other-group-leaf { type string; } + } +} diff --git a/src/webui/internal/goyang/testdata/sub.yang b/src/webui/internal/goyang/testdata/sub.yang new file mode 100644 index 00000000..940920c6 --- /dev/null +++ b/src/webui/internal/goyang/testdata/sub.yang @@ -0,0 +1,26 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// included by base.yang. +submodule sub { + belongs-to base { prefix "sbase"; } + typedef sub-type { type sub-type2; } + typedef sub-type2 { type int8; } + container sub-container { + leaf sub-container-leaf { type sub-type; } + } + grouping sub-group { + leaf sub-group-leaf { type string; } + } +} diff --git a/src/webui/internal/goyang/testdata/subdir/subdir1.yang b/src/webui/internal/goyang/testdata/subdir/subdir1.yang new file mode 100644 index 00000000..c6a8523b --- /dev/null +++ b/src/webui/internal/goyang/testdata/subdir/subdir1.yang @@ -0,0 +1,12 @@ +// A YANG module located in a subdirectory, to test the AddYANGPaths +// helper function. + +module subdir1 { + yang-version "1"; + +namespace "namespace:goes:here"; + prefix "subdir1"; + + description + "This module is to be found by test cases."; +} diff --git a/src/webui/internal/goyang/tree.go b/src/webui/internal/goyang/tree.go new file mode 100644 index 00000000..7ba17c8b --- /dev/null +++ b/src/webui/internal/goyang/tree.go @@ -0,0 +1,112 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "fmt" + "io" + "sort" + + "github.com/openconfig/goyang/pkg/indent" + "github.com/openconfig/goyang/pkg/yang" +) + +func init() { + register(&formatter{ + name: "tree", + f: doTree, + help: "display in a tree format", + }) +} + +func doTree(w io.Writer, entries []*yang.Entry) { + for _, e := range entries { + Write(w, e) + } +} + +// Write writes e, formatted, and all of its children, to w. +func Write(w io.Writer, e *yang.Entry) { + if e.Description != "" { + fmt.Fprintln(w) + fmt.Fprintln(indent.NewWriter(w, "// "), e.Description) + } + if len(e.Exts) > 0 { + fmt.Fprintf(w, "extensions: {\n") + for _, ext := range e.Exts { + if n := ext.NName(); n != "" { + fmt.Fprintf(w, " %s %s;\n", ext.Kind(), n) + } else { + fmt.Fprintf(w, " %s;\n", ext.Kind()) + } + } + fmt.Fprintln(w, "}") + } + switch { + case e.RPC != nil: + fmt.Fprintf(w, "RPC: ") + case e.ReadOnly(): + fmt.Fprintf(w, "RO: ") + default: + fmt.Fprintf(w, "rw: ") + } + if e.Type != nil { + fmt.Fprintf(w, "%s ", getTypeName(e)) + } + name := e.Name + if e.Prefix != nil { + name = e.Prefix.Name + ":" + name + } + switch { + case e.Dir == nil && e.ListAttr != nil: + fmt.Fprintf(w, "[]%s\n", name) + return + case e.Dir == nil: + fmt.Fprintf(w, "%s\n", name) + return + case e.ListAttr != nil: + fmt.Fprintf(w, "[%s]%s {\n", e.Key, name) //} + default: + fmt.Fprintf(w, "%s {\n", name) //} + } + if r := e.RPC; r != nil { + if r.Input != nil { + Write(indent.NewWriter(w, " "), r.Input) + } + if r.Output != nil { + Write(indent.NewWriter(w, " "), r.Output) + } + } + var names []string + for k := range e.Dir { + names = append(names, k) + } + sort.Strings(names) + for _, k := range names { + Write(indent.NewWriter(w, " "), e.Dir[k]) + } + // { to match the brace below to keep brace matching working + fmt.Fprintln(w, "}") +} + +func getTypeName(e *yang.Entry) string { + if e == nil || e.Type == nil { + return "" + } + // Return our root's type name. + // This is should be the builtin type-name + // for this entry. + return e.Type.Root.Name +} diff --git a/src/webui/internal/goyang/types.go b/src/webui/internal/goyang/types.go new file mode 100644 index 00000000..864e59ed --- /dev/null +++ b/src/webui/internal/goyang/types.go @@ -0,0 +1,135 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "fmt" + "io" + "strings" + + "github.com/openconfig/goyang/pkg/indent" + "github.com/openconfig/goyang/pkg/yang" + "github.com/pborman/getopt" +) + +var ( + typesDebug bool + typesVerbose bool +) + +func init() { + flags := getopt.New() + register(&formatter{ + name: "types", + f: doTypes, + help: "display found types", + flags: flags, + }) + flags.BoolVarLong(&typesDebug, "types_debug", 0, "display debug information") + flags.BoolVarLong(&typesVerbose, "types_verbose", 0, "include base information") +} + +func doTypes(w io.Writer, entries []*yang.Entry) { + types := Types{} + for _, e := range entries { + types.AddEntry(e) + } + + for t := range types { + printType(w, t, typesVerbose) + } + if typesDebug { + for _, e := range entries { + showall(w, e) + } + } +} + +// Types keeps track of all the YangTypes defined. +type Types map[*yang.YangType]struct{} + +// AddEntry adds all types defined in e and its descendants to t. +func (t Types) AddEntry(e *yang.Entry) { + if e == nil { + return + } + if e.Type != nil { + t[e.Type.Root] = struct{}{} + } + for _, d := range e.Dir { + t.AddEntry(d) + } +} + +// printType prints type t in a moderately human readable format to w. +func printType(w io.Writer, t *yang.YangType, verbose bool) { + if verbose && t.Base != nil { + base := yang.Source(t.Base) + if base == "unknown" { + base = "unnamed type" + } + fmt.Fprintf(w, "%s: ", base) + } + fmt.Fprintf(w, "%s", t.Root.Name) + if t.Kind.String() != t.Root.Name { + fmt.Fprintf(w, "(%s)", t.Kind) + } + if t.Units != "" { + fmt.Fprintf(w, " units=%s", t.Units) + } + if t.Default != "" { + fmt.Fprintf(w, " default=%q", t.Default) + } + if t.FractionDigits != 0 { + fmt.Fprintf(w, " fraction-digits=%d", t.FractionDigits) + } + if len(t.Length) > 0 { + fmt.Fprintf(w, " length=%s", t.Length) + } + if t.Kind == yang.YinstanceIdentifier && !t.OptionalInstance { + fmt.Fprintf(w, " required") + } + if t.Kind == yang.Yleafref && t.Path != "" { + fmt.Fprintf(w, " path=%q", t.Path) + } + if len(t.Pattern) > 0 { + fmt.Fprintf(w, " pattern=%s", strings.Join(t.Pattern, "|")) + } + b := yang.BaseTypedefs[t.Kind.String()].YangType + if len(t.Range) > 0 && !t.Range.Equal(b.Range) { + fmt.Fprintf(w, " range=%s", t.Range) + } + if len(t.Type) > 0 { + fmt.Fprintf(w, "{\n") + for _, t := range t.Type { + printType(indent.NewWriter(w, " "), t, verbose) + } + fmt.Fprintf(w, "}") + } + fmt.Fprintf(w, ";\n") +} + +func showall(w io.Writer, e *yang.Entry) { + if e == nil { + return + } + if e.Type != nil { + fmt.Fprintf(w, "\n%s\n ", e.Node.Statement().Location()) + printType(w, e.Type.Root, false) + } + for _, d := range e.Dir { + showall(w, d) + } +} diff --git a/src/webui/internal/goyang/yang.go b/src/webui/internal/goyang/yang.go new file mode 100644 index 00000000..54826327 --- /dev/null +++ b/src/webui/internal/goyang/yang.go @@ -0,0 +1,214 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Program yang parses YANG files, displays errors, and possibly writes +// something related to the input on output. +// +// Usage: yang [--path DIR] [--format FORMAT] [FORMAT OPTIONS] [MODULE] [FILE ...] +// +// If MODULE is specified (an argument that does not end in .yang), it is taken +// as the name of the module to display. Any FILEs specified are read, and the +// tree for MODULE is displayed. If MODULE was not defined in FILEs (or no +// files were specified), then the file MODULES.yang is read as well. An error +// is displayed if no definition for MODULE was found. +// +// If MODULE is missing, then all base modules read from the FILEs are +// displayed. If there are no arguments then standard input is parsed. +// +// If DIR is specified, it is considered a comma separated list of paths +// to append to the search directory. If DIR appears as DIR/... then +// DIR and all direct and indirect subdirectories are checked. +// +// FORMAT, which defaults to "tree", specifies the format of output to produce. +// Use "goyang --help" for a list of available formats. +// +// FORMAT OPTIONS are flags that apply to a specific format. They must follow +// --format. +// +// THIS PROGRAM IS STILL JUST A DEVELOPMENT TOOL. +package main + +import ( + "fmt" + "io" + "io/ioutil" + "os" + "runtime/trace" + "sort" + "strings" + + "github.com/openconfig/goyang/pkg/indent" + "github.com/openconfig/goyang/pkg/yang" + "github.com/pborman/getopt" +) + +// Each format must register a formatter with register. The function f will +// be called once with the set of yang Entry trees generated. +type formatter struct { + name string + f func(io.Writer, []*yang.Entry) + help string + flags *getopt.Set +} + +var formatters = map[string]*formatter{} + +func register(f *formatter) { + formatters[f.name] = f +} + +// exitIfError writes errs to standard error and exits with an exit status of 1. +// If errs is empty then exitIfError does nothing and simply returns. +func exitIfError(errs []error) { + if len(errs) > 0 { + for _, err := range errs { + fmt.Fprintln(os.Stderr, err) + } + stop(1) + } +} + +var stop = os.Exit + +func main() { + var format string + formats := make([]string, 0, len(formatters)) + for k := range formatters { + formats = append(formats, k) + } + sort.Strings(formats) + + var traceP string + var help bool + var paths []string + var ignoreSubmoduleCircularDependencies bool + getopt.ListVarLong(&paths, "path", 'p', "comma separated list of directories to add to search path", "DIR[,DIR...]") + getopt.StringVarLong(&format, "format", 'f', "format to display: "+strings.Join(formats, ", "), "FORMAT") + getopt.StringVarLong(&traceP, "trace", 't', "write trace into to TRACEFILE", "TRACEFILE") + getopt.BoolVarLong(&help, "help", 'h', "display help") + getopt.BoolVarLong(&ignoreSubmoduleCircularDependencies, "ignore-circdep", 'g', "ignore circular dependencies between submodules") + getopt.SetParameters("[FORMAT OPTIONS] [SOURCE] [...]") + + if err := getopt.Getopt(func(o getopt.Option) bool { + if o.Name() == "--format" { + f, ok := formatters[format] + if !ok { + fmt.Fprintf(os.Stderr, "%s: invalid format. Choices are %s\n", format, strings.Join(formats, ", ")) + stop(1) + } + if f.flags != nil { + f.flags.VisitAll(func(o getopt.Option) { + getopt.AddOption(o) + }) + } + } + return true + }); err != nil { + fmt.Fprintln(os.Stderr, err) + getopt.PrintUsage(os.Stderr) + os.Exit(1) + } + + if traceP != "" { + fp, err := os.Create(traceP) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + trace.Start(fp) + stop = func(c int) { trace.Stop(); os.Exit(c) } + defer func() { trace.Stop() }() + } + + if help { + getopt.CommandLine.PrintUsage(os.Stderr) + fmt.Fprintf(os.Stderr, ` +SOURCE may be a module name or a .yang file. + +Formats: +`) + for _, fn := range formats { + f := formatters[fn] + fmt.Fprintf(os.Stderr, " %s - %s\n", f.name, f.help) + if f.flags != nil { + f.flags.PrintOptions(indent.NewWriter(os.Stderr, " ")) + } + fmt.Fprintln(os.Stderr) + } + stop(0) + } + + ms := yang.NewModules() + ms.ParseOptions.IgnoreSubmoduleCircularDependencies = ignoreSubmoduleCircularDependencies + + for _, path := range paths { + expanded, err := yang.PathsWithModules(path) + if err != nil { + fmt.Fprintln(os.Stderr, err) + continue + } + ms.AddPath(expanded...) + } + + if format == "" { + format = "tree" + } + if _, ok := formatters[format]; !ok { + fmt.Fprintf(os.Stderr, "%s: invalid format. Choices are %s\n", format, strings.Join(formats, ", ")) + stop(1) + + } + + files := getopt.Args() + + if len(files) == 0 { + data, err := ioutil.ReadAll(os.Stdin) + if err == nil { + err = ms.Parse(string(data), "") + } + if err != nil { + fmt.Fprintln(os.Stderr, err) + stop(1) + } + } + + for _, name := range files { + if err := ms.Read(name); err != nil { + fmt.Fprintln(os.Stderr, err) + continue + } + } + + // Process the read files, exiting if any errors were found. + exitIfError(ms.Process()) + + // Keep track of the top level modules we read in. + // Those are the only modules we want to print below. + mods := map[string]*yang.Module{} + var names []string + + for _, m := range ms.Modules { + if mods[m.Name] == nil { + mods[m.Name] = m + names = append(names, m.Name) + } + } + sort.Strings(names) + entries := make([]*yang.Entry, len(names)) + for x, n := range names { + entries[x] = yang.ToEntry(mods[n]) + } + + formatters[format].f(os.Stdout, entries) +} diff --git a/src/webui/internal/handlers/capabilities.go b/src/webui/internal/handlers/capabilities.go new file mode 100644 index 00000000..403df1cc --- /dev/null +++ b/src/webui/internal/handlers/capabilities.go @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "context" + "log" + "os" + + "infix/webui/internal/restconf" +) + +// docsIndexPath is the entry point of the on-device User's Guide bundled +// by post-build.sh (mkdocs → /var/www/guide), served by nginx at /guide/. +const docsIndexPath = "/var/www/guide/index.html" + +// DetectDocs reports whether the User's Guide was bundled into this image +// (the build host had mkdocs), so the UI can show or hide the entry. +func DetectDocs() bool { + _, err := os.Stat(docsIndexPath) + return err == nil +} + +type feature struct { + Name string // key used in Has() and session cookie + Module string // YANG module that carries the feature + Feature string // YANG feature name in the module's feature array +} + +// optionalFeatures maps UI capabilities to YANG module features. Extend here. +var optionalFeatures = []feature{ + {Name: "wifi", Module: "infix-interfaces", Feature: "wifi"}, + {Name: "containers", Module: "infix-interfaces", Feature: "containers"}, +} + +// Capabilities tracks which optional features are present on the device. +// Use Has("feature-name") in templates and Go code. +type Capabilities struct { + features map[string]bool +} + +func NewCapabilities(features map[string]bool) *Capabilities { + if features == nil { + features = make(map[string]bool) + } + return &Capabilities{features: features} +} + +func (c *Capabilities) Has(name string) bool { + return c != nil && c.features[name] +} + +func (c *Capabilities) Features() map[string]bool { + if c == nil { + return nil + } + return c.features +} + +type capsCtxKey struct{} + +func ContextWithCapabilities(ctx context.Context, caps *Capabilities) context.Context { + return context.WithValue(ctx, capsCtxKey{}, caps) +} + +func CapabilitiesFromContext(ctx context.Context) *Capabilities { + caps, _ := ctx.Value(capsCtxKey{}).(*Capabilities) + if caps == nil { + return NewCapabilities(nil) + } + return caps +} + +type yangLibrary struct { + YangLibrary struct { + ModuleSet []struct { + Module []struct { + Name string `json:"name"` + Feature []string `json:"feature"` + } `json:"module"` + } `json:"module-set"` + } `json:"ietf-yang-library:yang-library"` +} + +// webConfig mirrors the slice of infix-services we need to gate the +// external web-app shortcuts (console, netbrowse). +type webConfig struct { + Web struct { + Console struct { + Enabled bool `json:"enabled"` + } `json:"console"` + Netbrowse struct { + Enabled bool `json:"enabled"` + } `json:"netbrowse"` + } `json:"infix-services:web"` +} + +// DetectWebShortcuts reports whether the web console (ttyd on :7681) and +// the mDNS network browser (netbrowse at network.local) are enabled in +// config, so the UI can show or hide their shortcuts. One read covers +// both. Fails closed: a read error hides both rather than offering a +// dead link. +func DetectWebShortcuts(ctx context.Context, rc restconf.Fetcher) (console, netbrowse bool) { + var cfg webConfig + if err := rc.Get(ctx, "/data/infix-services:web", &cfg); err != nil { + log.Printf("web config: %v (console/netbrowse shortcuts hidden)", err) + return false, false + } + return cfg.Web.Console.Enabled, cfg.Web.Netbrowse.Enabled +} + +func DetectCapabilities(ctx context.Context, rc restconf.Fetcher) *Capabilities { + var lib yangLibrary + if err := rc.Get(ctx, "/data/ietf-yang-library:yang-library", &lib); err != nil { + log.Printf("yang-library: %v (ignored, no optional features)", err) + return NewCapabilities(nil) + } + + // Build index: module name → set of YANG features advertised. + modFeatures := make(map[string]map[string]bool) + for _, ms := range lib.YangLibrary.ModuleSet { + for _, m := range ms.Module { + fs := make(map[string]bool, len(m.Feature)) + for _, f := range m.Feature { + fs[f] = true + } + modFeatures[m.Name] = fs + } + } + + result := make(map[string]bool, len(optionalFeatures)) + for _, f := range optionalFeatures { + result[f.Name] = modFeatures[f.Module][f.Feature] + } + + return NewCapabilities(result) +} diff --git a/src/webui/internal/handlers/capabilities_test.go b/src/webui/internal/handlers/capabilities_test.go new file mode 100644 index 00000000..5abaebdd --- /dev/null +++ b/src/webui/internal/handlers/capabilities_test.go @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "context" + "errors" + "testing" + + "infix/webui/internal/testutil" +) + +func yangLibraryResponse(modules ...map[string]interface{}) map[string]interface{} { + mods := make([]interface{}, len(modules)) + for i, m := range modules { + mods[i] = m + } + return map[string]interface{}{ + "ietf-yang-library:yang-library": map[string]interface{}{ + "module-set": []interface{}{ + map[string]interface{}{"module": mods}, + }, + }, + } +} + +func module(name string, features ...string) map[string]interface{} { + m := map[string]interface{}{"name": name} + if len(features) > 0 { + m["feature"] = features + } + return m +} + +func TestDetectCapabilities_NoModules(t *testing.T) { + mock := testutil.NewMockFetcher() + mock.SetResponse("/data/ietf-yang-library:yang-library", yangLibraryResponse()) + + caps := DetectCapabilities(context.Background(), mock) + if caps.Has("wifi") || caps.Has("containers") { + t.Errorf("expected no features, got wifi=%v containers=%v", + caps.Has("wifi"), caps.Has("containers")) + } +} + +func TestDetectCapabilities_YangLibraryError(t *testing.T) { + mock := testutil.NewMockFetcher() + mock.SetError("/data/ietf-yang-library:yang-library", errors.New("unreachable")) + + caps := DetectCapabilities(context.Background(), mock) + if caps.Has("wifi") || caps.Has("containers") { + t.Errorf("expected no features on error, got wifi=%v containers=%v", + caps.Has("wifi"), caps.Has("containers")) + } +} + +func TestDetectCapabilities_ContainersModule(t *testing.T) { + mock := testutil.NewMockFetcher() + mock.SetResponse("/data/ietf-yang-library:yang-library", + yangLibraryResponse( + module("ietf-interfaces"), + module("infix-interfaces", "vlan-filtering", "containers"), + )) + + caps := DetectCapabilities(context.Background(), mock) + if !caps.Has("containers") { + t.Error("expected containers=true") + } + if caps.Has("wifi") { + t.Error("expected wifi=false") + } +} + +func TestDetectCapabilities_WiFiModule(t *testing.T) { + mock := testutil.NewMockFetcher() + mock.SetResponse("/data/ietf-yang-library:yang-library", + yangLibraryResponse( + module("ietf-interfaces"), + module("infix-interfaces", "vlan-filtering", "wifi"), + )) + + caps := DetectCapabilities(context.Background(), mock) + if !caps.Has("wifi") { + t.Error("expected wifi=true") + } + if caps.Has("containers") { + t.Error("expected containers=false") + } +} + +func TestDetectCapabilities_BothModules(t *testing.T) { + mock := testutil.NewMockFetcher() + mock.SetResponse("/data/ietf-yang-library:yang-library", + yangLibraryResponse( + module("ietf-interfaces"), + module("infix-interfaces", "vlan-filtering", "containers", "wifi"), + )) + + caps := DetectCapabilities(context.Background(), mock) + if !caps.Has("wifi") || !caps.Has("containers") { + t.Errorf("expected both features, got wifi=%v containers=%v", + caps.Has("wifi"), caps.Has("containers")) + } +} + +func TestCapabilitiesFromContext_NilReturnsEmpty(t *testing.T) { + caps := CapabilitiesFromContext(context.Background()) + if caps == nil { + t.Fatal("expected non-nil Capabilities") + } + if caps.Has("wifi") || caps.Has("containers") { + t.Error("expected no features for empty context") + } +} + +func TestCapabilitiesFromContext_RoundTrip(t *testing.T) { + orig := NewCapabilities(map[string]bool{"wifi": true, "containers": true}) + ctx := ContextWithCapabilities(context.Background(), orig) + got := CapabilitiesFromContext(ctx) + if !got.Has("wifi") || !got.Has("containers") { + t.Errorf("expected wifi=true containers=true, got wifi=%v containers=%v", + got.Has("wifi"), got.Has("containers")) + } +} diff --git a/src/webui/internal/handlers/cfg_unsaved_test.go b/src/webui/internal/handlers/cfg_unsaved_test.go new file mode 100644 index 00000000..0bcd8895 --- /dev/null +++ b/src/webui/internal/handlers/cfg_unsaved_test.go @@ -0,0 +1,43 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" +) + +func TestCfgUnsavedFromRequest(t *testing.T) { + orig := webuiStartTime + defer func() { webuiStartTime = orig }() + webuiStartTime = time.Now() + + mkReq := func(value string) *http.Request { + r := httptest.NewRequest("GET", "/", nil) + if value != "" { + r.AddCookie(&http.Cookie{Name: cfgUnsavedCookie, Value: value}) + } + return r + } + + cases := []struct { + name string + value string + want bool + }{ + {"no cookie", "", false}, + {"empty value", "", false}, + {"legacy literal 1", "1", false}, + {"junk", "abc", false}, + {"before boot", strconv.FormatInt(webuiStartTime.Add(-time.Hour).Unix(), 10), false}, + {"after boot", strconv.FormatInt(webuiStartTime.Add(time.Hour).Unix(), 10), true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := cfgUnsavedFromRequest(mkReq(tc.value)); got != tc.want { + t.Errorf("got %v, want %v", got, tc.want) + } + }) + } +} diff --git a/src/webui/internal/handlers/common.go b/src/webui/internal/handlers/common.go new file mode 100644 index 00000000..6a513867 --- /dev/null +++ b/src/webui/internal/handlers/common.go @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "context" + "net/http" + "strconv" + "strings" + + "infix/webui/internal/restconf" + "infix/webui/internal/security" +) + +// PageData is the base template data passed to every page. +type PageData struct { + Username string + CsrfToken string + PageTitle string + ActivePage string + Capabilities *Capabilities + CfgUnsaved bool // running config differs from startup (Apply was used without ApplyAndSave) + // RetryAfter, when > 0, renders a + // in the page head with that many seconds. Used by transient + // fetch failures (e.g. post-upgrade dashboard fetch racing + // yanger / sysrepo startup) to self-recover without the user + // having to remember to reload. + RetryAfter int +} + +func csrfToken(ctx context.Context) string { + return security.TokenFromContext(ctx) +} + +// pageContext returns the top-level nav group ("Status", "Configure", +// "Maintenance") for a given ActivePage slug. Used to build breadcrumb-style +// browser-tab titles ("Page · Context") without each handler having to know +// where it lives in the sidebar. +func pageContext(page string) string { + switch page { + case "software", "logs", "diagnostics", "backup", "system-control": + return "Maintenance" + } + if strings.HasPrefix(page, "configure-") { + return "Configure" + } + return "Status" +} + +func newPageData(w http.ResponseWriter, r *http.Request, page, leaf string) PageData { + title := leaf + if ctx := pageContext(page); ctx != "" { + if leaf == "" { + title = ctx + } else { + title = leaf + " · " + ctx + } + } + + // On HTMX swaps only #content is replaced, leaving the element in + // <head> stale. Fire a setPageTitle event so the JS listener in app.js + // can update document.title. Safe to overwrite any prior HX-Trigger + // header: only GET handlers reach newPageData, and those don't share + // response paths with the save-side helpers (renderSaved / + // renderSaveError) that also use HX-Trigger. + // strconv.QuoteToASCII escapes non-ASCII as \uXXXX so the header value + // survives transit as 7-bit ASCII; browsers decode header bytes as + // ISO-8859-1, which would otherwise turn our middle-dot separator into + // mojibake on the JS side. + if r.Header.Get("HX-Request") == "true" { + w.Header().Set("HX-Trigger", `{"setPageTitle":`+strconv.QuoteToASCII(title)+`}`) + } + return PageData{ + Username: restconf.CredentialsFromContext(r.Context()).Username, + CsrfToken: csrfToken(r.Context()), + PageTitle: title, + ActivePage: page, + Capabilities: CapabilitiesFromContext(r.Context()), + CfgUnsaved: cfgUnsavedFromRequest(r), + } +} diff --git a/src/webui/internal/handlers/configure.go b/src/webui/internal/handlers/configure.go new file mode 100644 index 00000000..75f9d9cc --- /dev/null +++ b/src/webui/internal/handlers/configure.go @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "encoding/json" + "errors" + "log" + "net/http" + "strconv" + "time" + + "infix/webui/internal/restconf" +) + +const cfgUnsavedCookie = "cfg-unsaved" + +// webuiStartTime is captured when the process starts. The cfg-unsaved +// cookie carries the timestamp it was set at; cookies whose timestamp +// predates this value are from a previous boot (or a previous webui +// restart) and the running/startup state they referred to no longer +// applies — running was reloaded from startup at boot, so they're +// equal and the banner must not show. Using process-start instead of +// kernel boot keeps the check entirely in-process: a webui restart +// from configd flush also resets the marker, which matches "fresh +// running" semantics. +var webuiStartTime = time.Now() + +// ConfigureHandler manages the candidate datastore lifecycle. +type ConfigureHandler struct { + RC restconf.Fetcher +} + +func setCfgUnsaved(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: cfgUnsavedCookie, + Value: strconv.FormatInt(time.Now().Unix(), 10), + Path: "/", + MaxAge: 86400, + SameSite: http.SameSiteLaxMode, + }) +} + +func clearCfgUnsaved(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{Name: cfgUnsavedCookie, Value: "", Path: "/", MaxAge: -1, SameSite: http.SameSiteLaxMode}) +} + +// cfgUnsavedFromRequest returns true when the cfg-unsaved cookie is +// present AND its timestamp is from this webui session. Stale cookies +// (process restart / device reboot since the cookie was set) are +// treated as absent — running has been reloaded from startup, so the +// "unsaved" condition the cookie referred to no longer holds. +func cfgUnsavedFromRequest(r *http.Request) bool { + cookie, err := r.Cookie(cfgUnsavedCookie) + if err != nil { + return false + } + ts, perr := strconv.ParseInt(cookie.Value, 10, 64) + if perr != nil { + // Legacy cookie format ("1") from before the timestamp change, + // or unparseable junk. Treat as stale. + return false + } + return time.Unix(ts, 0).After(webuiStartTime) +} + +// Enter copies running → candidate, initialising a fresh edit session. +// Called when the user opens the Configure accordion. +// POST /configure/enter +func (h *ConfigureHandler) Enter(w http.ResponseWriter, r *http.Request) { + if err := h.RC.CopyDatastore(r.Context(), "running", "candidate"); err != nil { + log.Printf("configure enter: %v", err) + http.Error(w, "Could not initialise candidate datastore", http.StatusBadGateway) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// applyError surfaces a failed datastore copy from Apply / Apply&Save / Save. +// A RESTCONF error means the device answered and rejected the config — almost +// always a validation failure (e.g. deleting an interface other nodes still +// reference). The device is reachable, so report the reason inline via an +// HX-Trigger and a 200, NOT a 502: the front-end's connection monitor reads +// 502/503/504 as "device disconnected", which is exactly wrong here. Only a +// transport failure (no RESTCONF response at all) is a real gateway error. +func applyError(w http.ResponseWriter, op string, err error) { + log.Printf("configure %s: %v", op, err) + var re *restconf.Error + if errors.As(err, &re) { + // parseError always populates Message (server text, else HTTP status + // text), so a single fallback covers the otherwise-unreachable empty. + msg := re.Message + if msg == "" { + msg = "the device rejected the configuration" + } + b, _ := json.Marshal(msg) + w.Header().Set("HX-Trigger", `{"cfgApplyError":`+string(b)+`}`) + w.WriteHeader(http.StatusOK) + return + } + http.Error(w, "Could not reach the device: "+err.Error(), http.StatusBadGateway) +} + +// Apply copies candidate → running, activating all staged changes atomically. +// Sets the cfg-unsaved cookie so the persistent banner appears until startup is saved. +// POST /configure/apply +func (h *ConfigureHandler) Apply(w http.ResponseWriter, r *http.Request) { + if err := h.RC.CopyDatastore(r.Context(), "candidate", "running"); err != nil { + applyError(w, "apply", err) + return + } + setCfgUnsaved(w) + w.Header().Set("HX-Refresh", "true") + w.WriteHeader(http.StatusNoContent) +} + +// Abort copies running → candidate, discarding all staged changes. +// POST /configure/abort +func (h *ConfigureHandler) Abort(w http.ResponseWriter, r *http.Request) { + if err := h.RC.CopyDatastore(r.Context(), "running", "candidate"); err != nil { + log.Printf("configure abort: %v", err) + // Best-effort reset; refresh regardless. + } + w.Header().Set("HX-Refresh", "true") + w.WriteHeader(http.StatusNoContent) +} + +// ApplyAndSave copies candidate → running then running → startup in one step. +// Clears the cfg-unsaved cookie. +// POST /configure/apply-and-save +func (h *ConfigureHandler) ApplyAndSave(w http.ResponseWriter, r *http.Request) { + if err := h.RC.CopyDatastore(r.Context(), "candidate", "running"); err != nil { + applyError(w, "apply-and-save", err) + return + } + if err := h.RC.CopyDatastore(r.Context(), "running", "startup"); err != nil { + applyError(w, "apply-and-save (save)", err) + return + } + clearCfgUnsaved(w) + w.Header().Set("HX-Refresh", "true") + w.WriteHeader(http.StatusNoContent) +} + +// DeleteLeaf removes a single leaf from the candidate datastore so the YANG +// default takes effect. Used by curated-page reset buttons. +// DELETE /configure/leaf?path=...&redirect=... +func (h *ConfigureHandler) DeleteLeaf(w http.ResponseWriter, r *http.Request) { + path := r.URL.Query().Get("path") + redirect := r.URL.Query().Get("redirect") + if path == "" || redirect == "" { + http.Error(w, "path and redirect required", http.StatusBadRequest) + return + } + // Swallow data-missing: the leaf was already absent, so the reset + // semantically succeeded — there was nothing left to remove. + if err := h.RC.Delete(r.Context(), candidatePath+path); err != nil && !restconf.IsDataMissing(err) { + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Reset to default", redirect) +} + +// Save copies running → startup, persisting the active configuration. +// Clears the cfg-unsaved cookie and does a full-page refresh so the banner disappears. +// POST /configure/save +func (h *ConfigureHandler) Save(w http.ResponseWriter, r *http.Request) { + if err := h.RC.CopyDatastore(r.Context(), "running", "startup"); err != nil { + applyError(w, "save", err) + return + } + clearCfgUnsaved(w) + w.Header().Set("HX-Refresh", "true") + w.WriteHeader(http.StatusNoContent) +} diff --git a/src/webui/internal/handlers/configure_firewall.go b/src/webui/internal/handlers/configure_firewall.go new file mode 100644 index 00000000..1f477dfe --- /dev/null +++ b/src/webui/internal/handlers/configure_firewall.go @@ -0,0 +1,729 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "context" + "fmt" + "html/template" + "log" + "net/http" + "net/url" + "strconv" + "strings" + + "infix/webui/internal/restconf" + "infix/webui/internal/schema" +) + +const fwConfigPath = candidatePath + "/infix-firewall:firewall" + +// ─── RESTCONF read wrappers ─────────────────────────────────────────────────── + +// cfgFwWrapper wraps the firewall presence container for GET responses. +// Using a pointer distinguishes "present" from "absent". +type cfgFwWrapper struct { + Firewall *firewallJSON `json:"infix-firewall:firewall,omitempty"` +} + +// cfgFwZoneWrapper is used when reading a single zone by path. +type cfgFwZoneWrapper struct { + Zone []zoneJSON `json:"infix-firewall:zone"` +} + +// ─── Template display rows ──────────────────────────────────────────────────── + +type cfgZoneRow struct { + zoneJSON + IfaceCount int + IfaceSet map[string]bool + ServiceSet map[string]bool + ServicesTxt string // fallback when ServiceOptions unavailable + NetworksTxt string // comma-separated, shown read-only when zone uses networks +} + +type cfgPolicyRow struct { + policyJSON + IngressDisplay string + EgressDisplay string + MasqDisplay string + IngressSet map[string]bool + EgressSet map[string]bool +} + +type cfgServiceRow struct { + fwServiceJSON + PortsDisplay string // "tcp:80,443; udp:53" — at-a-glance +} + +// cfgFwSvcWrapper is used when reading a single service by path. +type cfgFwSvcWrapper struct { + Service []fwServiceJSON `json:"infix-firewall:service"` +} + +// ─── Template data ──────────────────────────────────────────────────────────── + +type cfgFirewallPageData struct { + PageData + Loading bool + Active bool // firewall presence container exists + Enabled bool + Default string + Logging string + Zones []cfgZoneRow + ZoneNames []string // for policy ingress/egress multi-select + Policies []cfgPolicyRow + Services []cfgServiceRow + ProtoOptions []schema.IdentityOption + Desc map[string]string + LoggingOptions []schema.IdentityOption + ActionOptions []schema.IdentityOption + PolicyActionOptions []schema.IdentityOption + ServiceOptions []schema.IdentityOption + AllInterfaces []string + Error string +} + +// ─── Handler ───────────────────────────────────────────────────────────────── + +// ConfigureFirewallHandler serves the Configure > Firewall page. +type ConfigureFirewallHandler struct { + Template *template.Template + RC restconf.Fetcher + Schema *schema.Cache +} + +// Overview renders the Configure > Firewall page. +// GET /configure/firewall +func (h *ConfigureFirewallHandler) Overview(w http.ResponseWriter, r *http.Request) { + data := cfgFirewallPageData{ + PageData: newPageData(w, r, "configure-firewall", "Firewall"), + } + + mgr := h.Schema.Manager() + data.Loading = mgr == nil + if mgr != nil { + fwPath := "/infix-firewall:firewall" + zPath := fwPath + "/zone" + pPath := fwPath + "/policy" + sPath := fwPath + "/service" + data.Desc = map[string]string{ + "enabled": schema.DescriptionOf(mgr, fwPath+"/enabled"), + "default": schema.DescriptionOf(mgr, fwPath+"/default"), + "logging": schema.DescriptionOf(mgr, fwPath+"/logging"), + "zone-name": schema.DescriptionOf(mgr, zPath+"/name"), + "zone-action": schema.DescriptionOf(mgr, zPath+"/action"), + "zone-description": schema.DescriptionOf(mgr, zPath+"/description"), + "zone-interface": schema.DescriptionOf(mgr, zPath+"/interface"), + "zone-service": schema.DescriptionOf(mgr, zPath+"/service"), + "policy-name": schema.DescriptionOf(mgr, pPath+"/name"), + "policy-action": schema.DescriptionOf(mgr, pPath+"/action"), + "policy-ingress": schema.DescriptionOf(mgr, pPath+"/ingress"), + "policy-egress": schema.DescriptionOf(mgr, pPath+"/egress"), + "policy-masquerade": schema.DescriptionOf(mgr, pPath+"/masquerade"), + "service-name": schema.DescriptionOf(mgr, sPath+"/name"), + "service-description": schema.DescriptionOf(mgr, sPath+"/description"), + "service-destination": schema.DescriptionOf(mgr, sPath+"/destination"), + "service-port-lower": schema.DescriptionOf(mgr, sPath+"/port/lower"), + "service-port-upper": schema.DescriptionOf(mgr, sPath+"/port/upper"), + "service-port-proto": schema.DescriptionOf(mgr, sPath+"/port/proto"), + } + data.LoggingOptions = schema.OptionsFor(mgr, fwPath+"/logging") + data.ActionOptions = schema.OptionsFor(mgr, zPath+"/action") + data.PolicyActionOptions = schema.OptionsFor(mgr, pPath+"/action") + data.ServiceOptions = schema.OptionsFor(mgr, zPath+"/service") + data.ProtoOptions = schema.OptionsFor(mgr, sPath+"/port/proto") + } + + fw, active, err := h.fetchFirewall(r.Context()) + if err != nil { + log.Printf("configure firewall: %v", err) + data.Error = "Could not read firewall configuration" + } + data.Active = active + if active && fw != nil { + if fw.Enabled == nil { + data.Enabled = true // YANG default + } else { + data.Enabled = bool(*fw.Enabled) + } + data.Default = fw.Default + data.Logging = fw.Logging + for _, z := range fw.Zone { + ifaceSet := make(map[string]bool, len(z.Interface)) + for _, iface := range z.Interface { + ifaceSet[iface] = true + } + svcSet := make(map[string]bool, len(z.Service)) + for _, svc := range z.Service { + svcSet[svc] = true + } + data.Zones = append(data.Zones, cfgZoneRow{ + zoneJSON: z, + IfaceCount: len(z.Interface), + IfaceSet: ifaceSet, + ServiceSet: svcSet, + ServicesTxt: strings.Join(z.Service, "\n"), + NetworksTxt: strings.Join(z.Network, ", "), + }) + data.ZoneNames = append(data.ZoneNames, z.Name) + } + for _, p := range fw.Policy { + masq := "—" + if p.Masquerade { + masq = "Yes" + } + ingressSet := make(map[string]bool, len(p.Ingress)) + for _, z := range p.Ingress { + ingressSet[z] = true + } + egressSet := make(map[string]bool, len(p.Egress)) + for _, z := range p.Egress { + egressSet[z] = true + } + data.Policies = append(data.Policies, cfgPolicyRow{ + policyJSON: p, + IngressDisplay: strings.Join(p.Ingress, ", "), + EgressDisplay: strings.Join(p.Egress, ", "), + MasqDisplay: masq, + IngressSet: ingressSet, + EgressSet: egressSet, + }) + } + for _, s := range fw.Service { + data.Services = append(data.Services, cfgServiceRow{ + fwServiceJSON: s, + PortsDisplay: formatServicePorts(s.Port), + }) + } + // Custom services are leafref'd by the zone service leaf-list — surface + // them in the dropdown alongside the YANG-defined well-known identities + // so the zone editor can pick either. + for _, s := range fw.Service { + data.ServiceOptions = append(data.ServiceOptions, schema.IdentityOption{ + Value: s.Name, + Label: s.Name, + }) + } + } + + data.AllInterfaces = h.fetchInterfaceNames(r.Context()) + + tmplName := "configure-firewall.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// Enable creates the firewall presence container with an initial "trusted" zone. +// POST /configure/firewall/enable +func (h *ConfigureFirewallHandler) Enable(w http.ResponseWriter, r *http.Request) { + body := map[string]any{ + "infix-firewall:firewall": map[string]any{ + "enabled": true, + "logging": "off", + "default": "trusted", + "zone": []map[string]any{{ + "name": "trusted", + "action": "accept", + }}, + }, + } + if err := h.RC.Put(r.Context(), fwConfigPath, body); err != nil { + log.Printf("configure firewall enable: %v", err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Firewall enabled", "/configure/firewall") +} + +// SaveSettings patches the global firewall settings (enabled, logging, default zone). +// POST /configure/firewall/settings +func (h *ConfigureFirewallHandler) SaveSettings(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + body := map[string]any{ + "infix-firewall:firewall": map[string]any{ + "enabled": r.FormValue("enabled") == "true", + "logging": r.FormValue("logging"), + "default": r.FormValue("default"), + }, + } + if err := h.RC.Patch(r.Context(), fwConfigPath, body); err != nil { + log.Printf("configure firewall settings: %v", err) + renderSaveError(w, err) + return + } + renderSaved(w, "Settings saved") +} + +// AddZone creates a new zone in the firewall candidate. +// POST /configure/firewall/zones +func (h *ConfigureFirewallHandler) AddZone(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := strings.TrimSpace(r.FormValue("name")) + if name == "" { + renderSaveError(w, fmt.Errorf("zone name is required")) + return + } + zone := map[string]any{ + "name": name, + "action": r.FormValue("action"), + } + if desc := strings.TrimSpace(r.FormValue("description")); desc != "" { + zone["description"] = desc + } + body := map[string]any{"infix-firewall:zone": []map[string]any{zone}} + if err := h.RC.Put(r.Context(), fwConfigPath+"/zone="+url.PathEscape(name), body); err != nil { + log.Printf("configure firewall zone add %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Zone added", "/configure/firewall") +} + +// DeleteZone removes a zone from the firewall. +// DELETE /configure/firewall/zones/{name} +func (h *ConfigureFirewallHandler) DeleteZone(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if err := h.RC.Delete(r.Context(), fwConfigPath+"/zone="+url.PathEscape(name)); err != nil { + log.Printf("configure firewall zone delete %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Zone deleted", "/configure/firewall") +} + +// SaveZone updates a zone's action, description, interfaces, and services. +// Uses read-modify-write to preserve fields not managed by this UI (network, +// port-forward). Note: port-forward entries are lost on save (Phase 3 limitation). +// POST /configure/firewall/zones/{name} +func (h *ConfigureFirewallHandler) SaveZone(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := r.PathValue("name") + + var wrap cfgFwZoneWrapper + if err := h.RC.Get(r.Context(), fwConfigPath+"/zone="+url.PathEscape(name), &wrap); err != nil { + log.Printf("configure firewall zone save %q: GET: %v", name, err) + renderSaveError(w, err) + return + } + cur := zoneJSON{Name: name} + if len(wrap.Zone) > 0 { + cur = wrap.Zone[0] + } + + cur.Action = r.FormValue("action") + cur.Description = strings.TrimSpace(r.FormValue("description")) + + if len(cur.Network) == 0 { + ifaces := r.Form["interfaces"] + if ifaces == nil { + ifaces = []string{} + } + cur.Interface = ifaces + } + svcs := r.Form["services"] + if svcs == nil { + svcs = []string{} + } + cur.Service = svcs + + zone := map[string]any{ + "name": cur.Name, + "action": cur.Action, + "interface": cur.Interface, + "service": cur.Service, + } + if cur.Description != "" { + zone["description"] = cur.Description + } + if len(cur.Network) > 0 { + zone["network"] = cur.Network + } + body := map[string]any{"infix-firewall:zone": []map[string]any{zone}} + if err := h.RC.Put(r.Context(), fwConfigPath+"/zone="+url.PathEscape(name), body); err != nil { + log.Printf("configure firewall zone save %q: PUT: %v", name, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Zone saved", "/configure/firewall") +} + +// ResetZoneLeafList clears a leaf-list (interface or service) on a zone by +// re-PUTting the zone container without that field. RFC 8040 leaf-list +// DELETE requires per-entry key predicates, so a bulk clear has to go +// through the parent. +func (h *ConfigureFirewallHandler) resetZoneLeafList(w http.ResponseWriter, r *http.Request, leaf string) { + name := r.PathValue("name") + var wrap cfgFwZoneWrapper + if err := h.RC.Get(r.Context(), fwConfigPath+"/zone="+url.PathEscape(name), &wrap); err != nil { + log.Printf("configure firewall zone reset %s/%s: GET: %v", name, leaf, err) + renderSaveError(w, err) + return + } + cur := zoneJSON{Name: name} + if len(wrap.Zone) > 0 { + cur = wrap.Zone[0] + } + switch leaf { + case "interface": + cur.Interface = nil + case "service": + cur.Service = nil + } + + zone := map[string]any{ + "name": cur.Name, + "action": cur.Action, + } + if cur.Description != "" { + zone["description"] = cur.Description + } + if len(cur.Interface) > 0 { + zone["interface"] = cur.Interface + } + if len(cur.Network) > 0 { + zone["network"] = cur.Network + } + if len(cur.Service) > 0 { + zone["service"] = cur.Service + } + body := map[string]any{"infix-firewall:zone": []map[string]any{zone}} + if err := h.RC.Put(r.Context(), fwConfigPath+"/zone="+url.PathEscape(name), body); err != nil { + log.Printf("configure firewall zone reset %s/%s: PUT: %v", name, leaf, err) + renderSaveError(w, err) + return + } + renderSaved(w, "Reset to default") +} + +// ResetZoneInterfaces clears the zone's interface leaf-list. +// DELETE /configure/firewall/zones/{name}/interfaces +func (h *ConfigureFirewallHandler) ResetZoneInterfaces(w http.ResponseWriter, r *http.Request) { + h.resetZoneLeafList(w, r, "interface") +} + +// ResetZoneServices clears the zone's service leaf-list. +// DELETE /configure/firewall/zones/{name}/services +func (h *ConfigureFirewallHandler) ResetZoneServices(w http.ResponseWriter, r *http.Request) { + h.resetZoneLeafList(w, r, "service") +} + +// AddPortForward inserts a new DNAT rule into the zone's port-forward list. +// POST /configure/firewall/zones/{name}/port-forwards +func (h *ConfigureFirewallHandler) AddPortForward(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + zone := r.PathValue("name") + lower, err := strconv.Atoi(strings.TrimSpace(r.FormValue("lower"))) + if err != nil || lower < 1 || lower > 65535 { + renderSaveError(w, fmt.Errorf("lower port must be 1–65535")) + return + } + proto := strings.TrimSpace(r.FormValue("proto")) + if proto == "" { + renderSaveError(w, fmt.Errorf("protocol is required")) + return + } + pf := map[string]any{"lower": lower, "proto": proto} + if up := strings.TrimSpace(r.FormValue("upper")); up != "" { + if uv, err := strconv.Atoi(up); err == nil && uv >= lower && uv <= 65535 { + pf["upper"] = uv + } + } + to := map[string]any{} + if addr := strings.TrimSpace(r.FormValue("to-addr")); addr != "" { + to["addr"] = addr + } + if tp := strings.TrimSpace(r.FormValue("to-port")); tp != "" { + if tpv, err := strconv.Atoi(tp); err == nil { + to["port"] = tpv + } + } + if len(to) > 0 { + pf["to"] = to + } + body := map[string]any{"infix-firewall:port-forward": []map[string]any{pf}} + keyPath := fmt.Sprintf("%s/zone=%s/port-forward=%d,%s", fwConfigPath, url.PathEscape(zone), lower, url.PathEscape(proto)) + if err := h.RC.Put(r.Context(), keyPath, body); err != nil { + log.Printf("configure firewall port-forward add %s/%d/%s: %v", zone, lower, proto, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Port-forward added", "/configure/firewall") +} + +// DeletePortForward removes a DNAT rule from the zone. +// DELETE /configure/firewall/zones/{name}/port-forwards/{lower}/{proto} +func (h *ConfigureFirewallHandler) DeletePortForward(w http.ResponseWriter, r *http.Request) { + zone := r.PathValue("name") + lower := r.PathValue("lower") + proto := r.PathValue("proto") + keyPath := fmt.Sprintf("%s/zone=%s/port-forward=%s,%s", fwConfigPath, url.PathEscape(zone), url.PathEscape(lower), url.PathEscape(proto)) + if err := h.RC.Delete(r.Context(), keyPath); err != nil { + log.Printf("configure firewall port-forward delete %s/%s/%s: %v", zone, lower, proto, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Port-forward removed", "/configure/firewall") +} + +// AddPolicy creates a new inter-zone forwarding policy. +// POST /configure/firewall/policies +func (h *ConfigureFirewallHandler) AddPolicy(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := strings.TrimSpace(r.FormValue("name")) + ingress := r.Form["ingress"] + egress := r.Form["egress"] + if name == "" { + renderSaveError(w, fmt.Errorf("policy name is required")) + return + } + if len(ingress) == 0 || len(egress) == 0 { + renderSaveError(w, fmt.Errorf("policy requires at least one ingress and one egress zone")) + return + } + policy := map[string]any{ + "name": name, + "action": r.FormValue("action"), + "ingress": ingress, + "egress": egress, + } + if r.FormValue("masquerade") == "on" { + policy["masquerade"] = true + } + body := map[string]any{"infix-firewall:policy": []map[string]any{policy}} + if err := h.RC.Put(r.Context(), fwConfigPath+"/policy="+url.PathEscape(name), body); err != nil { + log.Printf("configure firewall policy add %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Policy added", "/configure/firewall") +} + +// DeletePolicy removes an inter-zone forwarding policy. +// DELETE /configure/firewall/policies/{name} +func (h *ConfigureFirewallHandler) DeletePolicy(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if err := h.RC.Delete(r.Context(), fwConfigPath+"/policy="+url.PathEscape(name)); err != nil { + log.Printf("configure firewall policy delete %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Policy deleted", "/configure/firewall") +} + +// SavePolicy updates an existing policy's action, ingress, egress, masquerade. +// POST /configure/firewall/policies/{name} +func (h *ConfigureFirewallHandler) SavePolicy(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := r.PathValue("name") + ingress := r.Form["ingress"] + egress := r.Form["egress"] + if len(ingress) == 0 || len(egress) == 0 { + renderSaveError(w, fmt.Errorf("policy requires at least one ingress and one egress zone")) + return + } + policy := map[string]any{ + "name": name, + "action": r.FormValue("action"), + "ingress": ingress, + "egress": egress, + "masquerade": r.FormValue("masquerade") == "true", + } + body := map[string]any{"infix-firewall:policy": []map[string]any{policy}} + if err := h.RC.Put(r.Context(), fwConfigPath+"/policy="+url.PathEscape(name), body); err != nil { + log.Printf("configure firewall policy save %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Policy saved", "/configure/firewall") +} + +// ─── Services CRUD ─────────────────────────────────────────────────────────── + +// parseServicePorts converts the form's per-port arrays into the YANG body +// shape. Empty rows (no lower port) are silently dropped, so the user can +// leave trailing rows blank without invalidating the submission. +func parseServicePorts(r *http.Request) []map[string]any { + lower := r.Form["port-lower"] + upper := r.Form["port-upper"] + proto := r.Form["port-proto"] + out := []map[string]any{} + for i, lo := range lower { + lo = strings.TrimSpace(lo) + if lo == "" { + continue + } + lower, err := strconv.Atoi(lo) + if err != nil { + continue + } + entry := map[string]any{"lower": lower} + if i < len(proto) && proto[i] != "" { + entry["proto"] = proto[i] + } + if i < len(upper) { + up := strings.TrimSpace(upper[i]) + if up != "" { + if uv, err := strconv.Atoi(up); err == nil { + entry["upper"] = uv + } + } + } + out = append(out, entry) + } + return out +} + +// formatServicePorts renders the port list as a one-line summary for the +// services table — "tcp:80,443-445; udp:53". +func formatServicePorts(ports []fwServicePortJSON) string { + if len(ports) == 0 { + return "" + } + byProto := map[string][]string{} + order := []string{} + for _, p := range ports { + if _, ok := byProto[p.Proto]; !ok { + order = append(order, p.Proto) + } + one := fmt.Sprintf("%d", int(p.Lower)) + if int(p.Upper) > int(p.Lower) { + one = fmt.Sprintf("%d-%d", int(p.Lower), int(p.Upper)) + } + byProto[p.Proto] = append(byProto[p.Proto], one) + } + parts := make([]string, 0, len(order)) + for _, proto := range order { + parts = append(parts, fmt.Sprintf("%s:%s", proto, strings.Join(byProto[proto], ","))) + } + return strings.Join(parts, "; ") +} + +// AddService creates a new user-defined service. +// POST /configure/firewall/services +func (h *ConfigureFirewallHandler) AddService(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := strings.TrimSpace(r.FormValue("name")) + if name == "" { + renderSaveError(w, fmt.Errorf("service name is required")) + return + } + svc := map[string]any{"name": name} + if desc := strings.TrimSpace(r.FormValue("description")); desc != "" { + svc["description"] = desc + } + if dest := strings.TrimSpace(r.FormValue("destination")); dest != "" { + svc["destination"] = dest + } + if ports := parseServicePorts(r); len(ports) > 0 { + svc["port"] = ports + } + body := map[string]any{"infix-firewall:service": []map[string]any{svc}} + if err := h.RC.Put(r.Context(), fwConfigPath+"/service="+url.PathEscape(name), body); err != nil { + log.Printf("configure firewall service add %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Service added", "/configure/firewall") +} + +// SaveService updates an existing user-defined service. +// POST /configure/firewall/services/{name} +func (h *ConfigureFirewallHandler) SaveService(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := r.PathValue("name") + svc := map[string]any{"name": name} + if desc := strings.TrimSpace(r.FormValue("description")); desc != "" { + svc["description"] = desc + } + if dest := strings.TrimSpace(r.FormValue("destination")); dest != "" { + svc["destination"] = dest + } + if ports := parseServicePorts(r); len(ports) > 0 { + svc["port"] = ports + } + body := map[string]any{"infix-firewall:service": []map[string]any{svc}} + if err := h.RC.Put(r.Context(), fwConfigPath+"/service="+url.PathEscape(name), body); err != nil { + log.Printf("configure firewall service save %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSaved(w, "Service saved") +} + +// DeleteService removes a user-defined service. +// DELETE /configure/firewall/services/{name} +func (h *ConfigureFirewallHandler) DeleteService(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if err := h.RC.Delete(r.Context(), fwConfigPath+"/service="+url.PathEscape(name)); err != nil { + log.Printf("configure firewall service delete %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Service deleted", "/configure/firewall") +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +// fetchInterfaceNames returns configured interface names from candidate (fallback running). +func (h *ConfigureFirewallHandler) fetchInterfaceNames(ctx context.Context) []string { + var wrap interfacesWrapper + if err := h.RC.Get(ctx, candidatePath+"/ietf-interfaces:interfaces", &wrap); err != nil { + h.RC.Get(ctx, "/data/ietf-interfaces:interfaces", &wrap) //nolint:errcheck + } + names := make([]string, 0, len(wrap.Interfaces.Interface)) + for _, iface := range wrap.Interfaces.Interface { + names = append(names, iface.Name) + } + return names +} + +// fetchFirewall reads the firewall presence container from candidate, +// falling back to running. Returns (nil, false, nil) when absent everywhere. +func (h *ConfigureFirewallHandler) fetchFirewall(ctx context.Context) (*firewallJSON, bool, error) { + var wrap cfgFwWrapper + err := h.RC.Get(ctx, fwConfigPath, &wrap) + if err == nil { + return wrap.Firewall, wrap.Firewall != nil, nil + } + if !restconf.IsNotFound(err) { + return nil, false, err + } + runErr := h.RC.Get(ctx, "/data/infix-firewall:firewall", &wrap) + if runErr == nil { + return wrap.Firewall, wrap.Firewall != nil, nil + } + if restconf.IsNotFound(runErr) { + return nil, false, nil + } + return nil, false, runErr +} diff --git a/src/webui/internal/handlers/configure_hardware.go b/src/webui/internal/handlers/configure_hardware.go new file mode 100644 index 00000000..ec447656 --- /dev/null +++ b/src/webui/internal/handlers/configure_hardware.go @@ -0,0 +1,451 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "context" + "fmt" + "html/template" + "log" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "sync" + + "infix/webui/internal/restconf" + "infix/webui/internal/schema" +) + +// Configure > Hardware: curated management of components in ietf-hardware / +// infix-hardware that have configurable leaves — USB ports (lock/unlock), +// WiFi radios (country code, channel, band) and GPS receivers (presence). +// +// The page mirrors the Configure > Interfaces model: the main table lists +// only components present in running/candidate config; an "Add hardware" +// picker offers detected-but-unconfigured components from operational. +// Status > Hardware shows the full detected inventory (sensors, chassis, +// VPD, …) — this page stays focused on what the user can configure. + +const hwCandPath = candidatePath + "/ietf-hardware:hardware" + +// hwCompCfgRow is one configured component in the main table. Per-class +// fields are populated only for the matching Class. IsUSB/IsWiFi/IsGPS +// spare the template from dispatching on stringly-typed Class slugs. +type hwCompCfgRow struct { + Name string + Class string + ClassDisplay string + Description string + IsUSB bool + IsWiFi bool + IsGPS bool + + // USB-specific. + Unlocked bool // admin-state == "unlocked" + + // WiFi-specific. + CountryCode string + Channel string + Band string + + // Schema descriptions carried per-row so the fold-out forms are + // self-contained — Go templates can't pass extra arguments through + // {{template}}. + CountryOptions []schema.IdentityOption + BandOptions []schema.IdentityOption + DescDescription string + DescAdminState string + DescCountry string + DescBand string + DescChannel string +} + +// hwAvailable is a detected-but-unconfigured component shown in the Add +// picker. Class drives which class-specific Add fields the inline form +// renders. +type hwAvailable struct { + Name string + Class string + ClassDisplay string +} + +type cfgHardwarePageData struct { + PageData + Loading bool + Configured []hwCompCfgRow + AvailableUSB []hwAvailable + AvailableWiFi []hwAvailable + AvailableGPS []hwAvailable + CountryOptions []schema.IdentityOption + BandOptions []schema.IdentityOption + Desc map[string]string + Error string +} + +// HasAvailable reports whether any detected component can be added. Used +// by the template to hide the "+ Add hardware" affordance entirely when +// the user has already configured everything. +func (d cfgHardwarePageData) HasAvailable() bool { + return len(d.AvailableUSB)+len(d.AvailableWiFi)+len(d.AvailableGPS) > 0 +} + +// ConfigureHardwareHandler serves the Configure > Hardware page. +type ConfigureHardwareHandler struct { + Template *template.Template + RC restconf.Fetcher + Schema *schema.Cache +} + +// Overview renders the Configure > Hardware page. +// GET /configure/hardware +func (h *ConfigureHardwareHandler) Overview(w http.ResponseWriter, r *http.Request) { + data := cfgHardwarePageData{ + PageData: newPageData(w, r, "configure-hardware", "Hardware"), + } + + mgr := h.Schema.Manager() + data.Loading = mgr == nil + if mgr != nil { + compPath := "/ietf-hardware:hardware/component" + radioPath := compPath + "/infix-hardware:wifi-radio" + data.Desc = map[string]string{ + "description": schema.DescriptionOf(mgr, compPath+"/description"), + "admin-state": schema.DescriptionOf(mgr, compPath+"/state/admin-state"), + "country-code": schema.DescriptionOf(mgr, radioPath+"/country-code"), + "channel": schema.DescriptionOf(mgr, radioPath+"/channel"), + "band": schema.DescriptionOf(mgr, radioPath+"/band"), + } + data.CountryOptions = schema.OptionsFor(mgr, radioPath+"/country-code") + data.BandOptions = schema.OptionsFor(mgr, radioPath+"/band") + } + + var ( + cfgWrap hardwareWrapper + operWrap hardwareWrapper + cfgErr error + operErr error + wg sync.WaitGroup + ) + wg.Add(2) + go func() { defer wg.Done(); cfgErr = h.RC.Get(r.Context(), hwCandPath, &cfgWrap) }() + go func() { + defer wg.Done() + operErr = h.RC.Get(r.Context(), "/data/ietf-hardware:hardware", &operWrap) + }() + wg.Wait() + + if cfgErr != nil && !restconf.IsNotFound(cfgErr) { + // 404 just means nothing is configured yet — proceed with empty list. + log.Printf("configure hardware: candidate fetch: %v", cfgErr) + data.Error = "Could not read hardware configuration" + } + if operErr != nil { + log.Printf("configure hardware: operational fetch: %v", operErr) + } + + configured := make(map[string]bool, len(cfgWrap.Hardware.Component)) + for _, c := range cfgWrap.Hardware.Component { + configured[c.Name] = true + class := shortClass(c.Class) + if !isConfigurableClass(class) { + continue + } + data.Configured = append(data.Configured, h.buildRow(c, class, data)) + } + sort.SliceStable(data.Configured, func(i, j int) bool { + if data.Configured[i].Class != data.Configured[j].Class { + return data.Configured[i].Class < data.Configured[j].Class + } + return data.Configured[i].Name < data.Configured[j].Name + }) + + for _, c := range operWrap.Hardware.Component { + if configured[c.Name] { + continue + } + class := shortClass(c.Class) + if !isConfigurableClass(class) { + continue + } + avail := hwAvailable{Name: c.Name, Class: class, ClassDisplay: hwClassDisplay(class)} + switch class { + case classUSB: + data.AvailableUSB = append(data.AvailableUSB, avail) + case classWiFi: + data.AvailableWiFi = append(data.AvailableWiFi, avail) + case classGPS: + data.AvailableGPS = append(data.AvailableGPS, avail) + } + } + sortAvail(data.AvailableUSB) + sortAvail(data.AvailableWiFi) + sortAvail(data.AvailableGPS) + + tmplName := "configure-hardware.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +func (h *ConfigureHardwareHandler) buildRow(c hwComponentJSON, class string, data cfgHardwarePageData) hwCompCfgRow { + row := hwCompCfgRow{ + Name: c.Name, + Class: class, + ClassDisplay: hwClassDisplay(class), + Description: c.Description, + DescDescription: data.Desc["description"], + DescAdminState: data.Desc["admin-state"], + } + switch class { + case classUSB: + row.IsUSB = true + row.Unlocked = c.State != nil && c.State.AdminState == adminStateUnlocked + case classWiFi: + row.IsWiFi = true + row.CountryOptions = data.CountryOptions + row.BandOptions = data.BandOptions + row.DescCountry = data.Desc["country-code"] + row.DescBand = data.Desc["band"] + row.DescChannel = data.Desc["channel"] + if c.WiFiRadio != nil { + row.CountryCode = c.WiFiRadio.CountryCode + row.Band = c.WiFiRadio.Band + row.Channel = wifiChannelString(c.WiFiRadio.Channel) + } + case classGPS: + row.IsGPS = true + } + return row +} + +// SaveUSBPort writes description and admin-state for a USB component. +// Per-leaf PUT so the component-list entry is auto-created when it lived +// only in operational before — component-level PATCH 404s otherwise. +// POST /configure/hardware/usb/{name} +func (h *ConfigureHardwareHandler) SaveUSBPort(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := r.PathValue("name") + state := adminStateLocked + if r.FormValue("enabled") == "true" { + state = adminStateUnlocked + } + if err := h.saveDescription(r.Context(), name, r.FormValue("description")); err != nil { + log.Printf("configure hardware usb %s description: %v", name, err) + renderSaveError(w, err) + return + } + if err := h.putAdminState(r.Context(), name, state); err != nil { + log.Printf("configure hardware usb %s state: %v", name, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, name+" "+state, "/configure/hardware") +} + +// SaveWiFiRadio writes description and the wifi-radio container. +// POST /configure/hardware/wifi/{name} +func (h *ConfigureHardwareHandler) SaveWiFiRadio(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := r.PathValue("name") + radio, err := parseWiFiRadio(r) + if err != nil { + renderSaveError(w, err) + return + } + if err := h.saveDescription(r.Context(), name, r.FormValue("description")); err != nil { + log.Printf("configure hardware wifi %s description: %v", name, err) + renderSaveError(w, err) + return + } + if err := h.putWiFiRadio(r.Context(), name, radio); err != nil { + log.Printf("configure hardware wifi %s: %v", name, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Radio "+name+" saved", "/configure/hardware") +} + +// SaveGPS writes description for an already-configured GPS component. The +// gps-receiver presence container is the configured signal; once added it +// is left in place by Save and only removed by DeleteComponent. +// POST /configure/hardware/gps/{name} +func (h *ConfigureHardwareHandler) SaveGPS(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := r.PathValue("name") + if err := h.saveDescription(r.Context(), name, r.FormValue("description")); err != nil { + log.Printf("configure hardware gps %s description: %v", name, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "GPS "+name+" saved", "/configure/hardware") +} + +// CreateHardware adds a detected-but-unconfigured component to running +// in a single PUT — name+class plus the class-specific child container — +// so the candidate never sees a half-configured component if the request +// is interrupted between writes. +// POST /configure/hardware +func (h *ConfigureHardwareHandler) CreateHardware(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := strings.TrimSpace(r.FormValue("name")) + class := r.FormValue("class") + if name == "" || class == "" { + renderSaveError(w, fmt.Errorf("name and class are required")) + return + } + yangClass, ok := yangClassFromSlug(class) + if !ok { + renderSaveError(w, fmt.Errorf("unknown hardware class %q", class)) + return + } + + comp := map[string]any{"name": name, "class": yangClass} + switch class { + case classUSB: + comp["state"] = map[string]any{"admin-state": adminStateUnlocked} + case classWiFi: + radio, err := parseWiFiRadio(r) + if err != nil { + renderSaveError(w, err) + return + } + comp["infix-hardware:wifi-radio"] = radio + case classGPS: + comp["infix-hardware:gps-receiver"] = map[string]any{} + } + body := map[string]any{"ietf-hardware:component": []any{comp}} + if err := h.RC.Put(r.Context(), hwComponentPath(name), body); err != nil { + log.Printf("configure hardware create %s: %v", name, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, name+" added", "/configure/hardware") +} + +func (h *ConfigureHardwareHandler) putAdminState(ctx context.Context, name, state string) error { + return h.RC.Put(ctx, hwComponentPath(name)+"/state/admin-state", + map[string]any{"ietf-hardware:admin-state": state}) +} + +func (h *ConfigureHardwareHandler) putWiFiRadio(ctx context.Context, name string, radio map[string]any) error { + return h.RC.Put(ctx, hwComponentPath(name)+"/infix-hardware:wifi-radio", + map[string]any{"infix-hardware:wifi-radio": radio}) +} + +// parseWiFiRadio builds the wifi-radio body from form fields. Country +// code is mandatory; band and channel are optional and only included +// when non-empty so we don't clobber YANG defaults with empty strings. +func parseWiFiRadio(r *http.Request) (map[string]any, error) { + country := strings.TrimSpace(r.FormValue("country-code")) + if country == "" { + return nil, fmt.Errorf("country code is required") + } + radio := map[string]any{"country-code": country} + if band := strings.TrimSpace(r.FormValue("band")); band != "" { + radio["band"] = band + } + if ch := strings.TrimSpace(r.FormValue("channel")); ch != "" { + // YANG union: uint16 (1..196) or the literal "auto". Coerce + // numeric input so the RESTCONF payload type-matches the union. + if ch == "auto" { + radio["channel"] = "auto" + } else { + n, err := strconv.Atoi(ch) + if err != nil || n < 1 || n > 196 { + return nil, fmt.Errorf("channel must be 'auto' or a number 1–196") + } + radio["channel"] = n + } + } + return radio, nil +} + +// saveDescription PUTs or DELETEs the description leaf depending on whether +// the form value is empty — empty means "no user-supplied description", and +// PUT-ing the empty string would leave a stray empty leaf around. +func (h *ConfigureHardwareHandler) saveDescription(ctx context.Context, name, desc string) error { + desc = strings.TrimSpace(desc) + path := hwComponentPath(name) + "/description" + if desc == "" { + if err := h.RC.Delete(ctx, path); err != nil && !restconf.IsNotFound(err) { + return err + } + return nil + } + return h.RC.Put(ctx, path, map[string]any{"ietf-hardware:description": desc}) +} + +// DeleteComponent removes the entire running-config entry for a component, +// leaving only the discovered operational state. Reverts the component to +// YANG/operational defaults until the user adds it again. +// DELETE /configure/hardware/{name} +func (h *ConfigureHardwareHandler) DeleteComponent(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if err := h.RC.Delete(r.Context(), hwComponentPath(name)); err != nil && !restconf.IsNotFound(err) { + log.Printf("configure hardware delete %s: %v", name, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, name+" configuration removed", "/configure/hardware") +} + +func hwComponentPath(name string) string { + return hwCandPath + "/component=" + url.PathEscape(name) +} + +func isConfigurableClass(class string) bool { + switch class { + case classUSB, classWiFi, classGPS: + return true + } + return false +} + +func yangClassFromSlug(slug string) (string, bool) { + switch slug { + case classUSB: + return "infix-hardware:usb", true + case classWiFi: + return "infix-hardware:wifi", true + case classGPS: + return "infix-hardware:gps", true + } + return "", false +} + +// hwClassDisplay turns the short class slug into a human-readable label. +func hwClassDisplay(class string) string { + switch class { + case classChassis: + return "Chassis" + case classUSB: + return "USB port" + case classWiFi: + return "WiFi radio" + case classGPS: + return "GPS receiver" + } + return class +} + +func sortAvail(s []hwAvailable) { + sort.SliceStable(s, func(i, j int) bool { return s[i].Name < s[j].Name }) +} diff --git a/src/webui/internal/handlers/configure_interfaces.go b/src/webui/internal/handlers/configure_interfaces.go new file mode 100644 index 00000000..f2073427 --- /dev/null +++ b/src/webui/internal/handlers/configure_interfaces.go @@ -0,0 +1,2758 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "context" + "crypto/ecdh" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "html/template" + "log" + "net/http" + "net/url" + "os/exec" + "sort" + "strconv" + "strings" + "sync" + + "infix/webui/internal/restconf" + "infix/webui/internal/schema" +) + +const ifaceCandPath = candidatePath + "/ietf-interfaces:interfaces" + +// ─── RESTCONF JSON structs (configure-only fields) ─────────────────────────── + +type bridgeCfgJSON struct { + IEEEGroupFwd []string `json:"ieee-group-forward"` + VLANs *vlanFilterCfgJSON `json:"vlans"` // non-nil means 802.1Q mode + STP bridgeSTPCfgJSON `json:"stp"` + Multicast *bridgeMulticastJSON `json:"multicast"` +} + +type bridgeMulticastJSON struct { + Snooping *bool `json:"snooping"` + Querier string `json:"querier"` + QueryInterval *int `json:"query-interval"` +} + +type vlanFilterCfgJSON struct { + Proto string `json:"proto"` + VLANs []bridgeVLAN `json:"vlan"` +} + +type bridgeVLAN struct { + VID int `json:"vid"` + Untagged []string `json:"untagged"` + Tagged []string `json:"tagged"` +} + +type bridgeSTPCfgJSON struct { + ForceProtocol string `json:"force-protocol"` + HelloTime *int `json:"hello-time"` + ForwardDelay *int `json:"forward-delay"` + MaxAge *int `json:"max-age"` + TransmitHoldCount *int `json:"transmit-hold-count"` + MaxHops *int `json:"max-hops"` +} + +type lagCfgJSON struct { + Mode string `json:"mode"` + Hash string `json:"hash"` + LACP lagLACPJSON `json:"lacp"` +} + +type lagLACPJSON struct { + Mode string `json:"mode"` + Rate string `json:"rate"` + SystemPriority *int `json:"system-priority"` +} + +type lagPortCfgJSON struct { + LAG string `json:"lag"` +} + +// ─── Template display types ─────────────────────────────────────────────────── + +type cfgVLANRow struct { + VID int + UntaggedTxt string + TaggedTxt string + UntaggedSet map[string]bool + TaggedSet map[string]bool +} + +type cfgIfaceRow struct { + ifaceJSON + TypeSlug string + TypeDisplay string + AdminEnabled bool // true when enabled leaf absent (YANG default) or explicitly true + MemberOf string // bridge or lag name this interface belongs to + AddrSummary string + ConfigTags []string // type-aware overview pills (DHCP, SLAAC, vid N, …) + BridgeMembers []string // interface names that are ports of this bridge/lag + BridgeMemberSet map[string]bool // for checkbox pre-selection + PortCandidates []string // free ports + current members of this bridge/lag + BridgeIs8021Q bool + VLANRows []cfgVLANRow + IsBridge bool + IsBridgePort bool + IsLag bool + IsLagPort bool + IsVlan bool + IsWifi bool + WifiMode string // "station" or "access-point" once known + HasIP bool // can carry IP addresses + // ParentBridgeIs8021Q says whether the bridge this port is attached + // to has VLAN filtering on. PVID only makes sense in that mode, so + // the bridge-port editor hides the field when this is false. + ParentBridgeIs8021Q bool + // PortRadio is a snapshot of the wifi-radio component mirrored into + // the interface editor so the user can adjust country/band/channel + // from the WiFi interface page without bouncing to Configure > + // Hardware. Nil when the wifi/radio reference is empty or unknown. + PortRadio *ifaceRadioMirror + // MDIXState renders the *bool ethernet/mdi-x as a template-friendly + // string: "" = absent (Auto-MDIX), "true" / "false" = explicit force. + MDIXState string + EthAutoneg bool // current candidate value; defaults to YANG default (true) + EthDuplex string // "" / "full" / "half" + EthAdvertised []string // identityref leaf-list, empty = advertise all + EthSupported []string // identityref leaf-list from operational data + // DHCP enabled flags — captured BEFORE the placeholder DHCP/DHCPv6 + // containers are seeded so the template can tell "configured" from + // "auto-injected placeholder" apart. + DHCPv4Enabled bool + DHCPv6Enabled bool + + // Page-level bits copied onto each row so the per-interface IPv4/IPv6 + // blocks can render as standalone fragments — both inline on the full + // page and on their own when a save handler re-renders just one block + // (to surface confd-inferred values without collapsing the page). + Desc map[string]string + DHCPv4Options []schema.IdentityOption + DHCPv6Options []schema.IdentityOption + // JustSaved marks a post-save re-render (renderIPBlock) so the DHCP + // foldout auto-expands to reveal the freshly-inferred options. False + // on the normal page render, so foldouts stay collapsed there. + JustSaved bool + // SavedMsg carries a confirmation rendered inline in the block footer on + // a post-save re-render. The block is swapped via outerHTML, so the + // triggering form (and its status span) is gone by the time any JS event + // fires — server-rendering the confirmation is the only reliable place. + SavedMsg string +} + +// ifaceRadioMirror is the subset of wifi-radio fields we expose on the +// WiFi interface editor. The form posts to /configure/hardware/wifi/{name} +// so the backend stays single-sourced. +type ifaceRadioMirror struct { + Name string + CountryCode string + Band string + Channel *int +} + +type cfgIfacePageData struct { + PageData + Loading bool + Interfaces []cfgIfaceRow + AllNames []string // every interface name (running config) + BridgeNames []string // type=bridge only + LagNames []string // type=lag only + // UnconfiguredPhysical lists physical Ethernet interfaces present in + // operational state but NOT in running config — i.e. ports that have + // been deleted and can be re-bound. Shown as datalist suggestions on + // the Add Interface row so users don't have to remember the name. + UnconfiguredPhysical []string + Desc map[string]string + + // Add Interface modal — pre-computed once per page render so the + // dialog has no per-tile-click latency. WizardTypes feeds the type + // pulldown; WizardNames[slug] pre-fills each per-type fieldset's + // name input (veth peer under key "veth-peer"); FreePortCandidates + // is the bridge/lag ports checkbox list; VlanDefaultName is the + // initial value of the VLAN fieldset's name input + // (<first-parent>.1) which JS keeps in sync when parent/vid change. + // WizardAsymKeys is the keystore /asymmetric-keys name list for the + // WireGuard private-key picker. + WizardTypes []ifaceTypeOption + WizardNames map[string]string + FreePortCandidates []string + VlanDefaultName string + WizardAsymKeys []string // for WireGuard private-key picker + WizardSymKeys []symKeyEntry // for WiFi PSK picker (Name + decoded cleartext) + WizardWifiRadios []wifiRadioOption // configured (in candidate) + WizardAvailableRadios []string // detected (operational) but not yet in candidate + WizardCountryOptions []schema.IdentityOption + WizardBandOptions []schema.IdentityOption + + STPProtoOptions []schema.IdentityOption + LagModeOptions []schema.IdentityOption + LagHashOptions []schema.IdentityOption + DHCPv4Options []schema.IdentityOption + DHCPv6Options []schema.IdentityOption + // MCRouterOptions populates the per-bridge-port multicast-router + // select. Schema-driven; falls back to a tiny hardcoded list when + // the schema lookup misses (e.g. older YANG). + MCRouterOptions []schema.IdentityOption + WiFiSecOptionsAP []schema.IdentityOption + WiFiSecOptionsSta []schema.IdentityOption + WiFiQuerierOptions []schema.IdentityOption + // CountryOptions / BandOptions reused by the mirrored radio editor + // on WiFi interface rows (same enums as the wizard). + CountryOptions []schema.IdentityOption + BandOptions []schema.IdentityOption + // PSKKeys is the keystore symmetric-keys list for the WiFi security + // secret picker (mirrors WizardSymKeys without the cleartext). + PSKKeys []symKeyEntry + Error string +} + +// ─── Handler ───────────────────────────────────────────────────────────────── + +// ConfigureInterfacesHandler serves the Configure > Interfaces page. +type ConfigureInterfacesHandler struct { + Template *template.Template + RC restconf.Fetcher + Schema *schema.Cache +} + +// Overview renders the Configure > Interfaces page. +// GET /configure/interfaces +func (h *ConfigureInterfacesHandler) Overview(w http.ResponseWriter, r *http.Request) { + data := cfgIfacePageData{ + PageData: newPageData(w, r, "configure-interfaces", "Interfaces"), + } + + mgr := h.Schema.Manager() + data.Loading = mgr == nil + if mgr != nil { + // The list-entry path is /ietf-interfaces:interfaces/interface — + // the singular form returns nil from entryAt() and silently + // empties every DescriptionOf and OptionsFor below, which is + // why no field-info (i) icons rendered before this change. + ifPath := "/ietf-interfaces:interfaces/interface" + bPath := "/infix-interfaces:bridge" + lPath := "/infix-interfaces:lag" + ip4 := "/ietf-ip:ipv4" + ip6 := "/ietf-ip:ipv6" + data.Desc = map[string]string{ + "name": descOr(mgr, ifPath+"/name", "The unique name for this interface. Pick one not already in use."), + "description": schema.DescriptionOf(mgr, ifPath+"/description"), + "type": schema.DescriptionOf(mgr, ifPath+"/type"), + "enabled": schema.DescriptionOf(mgr, ifPath+"/enabled"), + "mac": descOr(mgr, ifPath+"/infix-interfaces:custom-phys-address/static", "Override the interface's default physical (MAC) address with a static unicast value."), + "bridge-type": descOr(mgr, ifPath+bPath+"/vlans", "Presence of bridge/vlans switches the bridge into IEEE 802.1Q VLAN-filtering mode. Pick this if downstream ports need PVID and tagged/untagged membership."), + "stp-force": schema.DescriptionOf(mgr, ifPath+bPath+"/stp/force-protocol"), + "stp-hello": schema.DescriptionOf(mgr, ifPath+bPath+"/stp/hello-time"), + "stp-fwd-delay": schema.DescriptionOf(mgr, ifPath+bPath+"/stp/forward-delay"), + "stp-max-age": schema.DescriptionOf(mgr, ifPath+bPath+"/stp/max-age"), + "stp-hold-count": schema.DescriptionOf(mgr, ifPath+bPath+"/stp/transmit-hold-count"), + "stp-max-hops": schema.DescriptionOf(mgr, ifPath+bPath+"/stp/max-hops"), + "lag-mode": descOr(mgr, ifPath+lPath+"/mode", "Aggregation mode: static (balanced XOR) or lacp (IEEE 802.3ad)."), + "lag-hash": schema.DescriptionOf(mgr, ifPath+lPath+"/hash"), + "lacp-mode": schema.DescriptionOf(mgr, ifPath+lPath+"/lacp/mode"), + "lacp-rate": schema.DescriptionOf(mgr, ifPath+lPath+"/lacp/rate"), + "lacp-sysprio": schema.DescriptionOf(mgr, ifPath+lPath+"/lacp/system-priority"), + "vlan-id": descOr(mgr, ifPath+"/infix-interfaces:vlan/id", "VLAN tag (1–4094) carried on the parent interface."), + "vlan-lower": descOr(mgr, ifPath+"/infix-interfaces:vlan/lower-layer-if", "Parent (lower-layer) interface on which the VLAN sub-interface sits."), + "ipv4-address": schema.DescriptionOf(mgr, ifPath+ip4+"/address/ip"), + "ipv4-prefix": schema.DescriptionOf(mgr, ifPath+ip4+"/address/prefix-length"), + "ipv4-dhcp": schema.DescriptionOf(mgr, ifPath+ip4+"/infix-dhcp-client:dhcp"), + "ipv4-autoconf": schema.DescriptionOf(mgr, ifPath+ip4+"/infix-ip:autoconf"), + "ipv4-forwarding": schema.DescriptionOf(mgr, ifPath+ip4+"/forwarding"), + "ipv6-address": schema.DescriptionOf(mgr, ifPath+ip6+"/address/ip"), + "ipv6-prefix": schema.DescriptionOf(mgr, ifPath+ip6+"/address/prefix-length"), + "ipv6-slaac": schema.DescriptionOf(mgr, ifPath+ip6+"/autoconf"), + "ipv6-dhcp": schema.DescriptionOf(mgr, ifPath+ip6+"/infix-dhcpv6-client:dhcp"), + "ipv6-forwarding": schema.DescriptionOf(mgr, ifPath+ip6+"/forwarding"), + + // Add Interface wizard — augment paths still don't resolve + // through goyang (same gap as LAG mode), so each entry is + // wrapped in descOr() with a fallback string taken straight + // from the YANG description text. Drop the fallback once + // the schema layer learns to traverse augments. + "bridge-port-bridge": descOr(mgr, ifPath+"/infix-interfaces:bridge-port/bridge", "Interfaces enslaved to this bridge. Each becomes a member port carrying L2 traffic for the bridge."), + "veth-peer": descOr(mgr, ifPath+"/infix-interfaces:veth/peer", "Peer veth interface to which this interface is connected (the other end of the pair)."), + "gre-local": descOr(mgr, ifPath+"/infix-interfaces:gre/local", "Local address used as the tunnel source. Must be the same family (IPv4 or IPv6) as the remote."), + "gre-remote": descOr(mgr, ifPath+"/infix-interfaces:gre/remote", "Remote peer address for the tunnel. Must be the same family (IPv4 or IPv6) as the local."), + "gre-pmtu": descOr(mgr, ifPath+"/infix-interfaces:gre/pmtu-discovery", "Enable Path MTU Discovery (default). Disable on links with broken ICMP filtering — at the cost of suboptimal performance."), + "vxlan-local": descOr(mgr, ifPath+"/infix-interfaces:vxlan/local", "Local address used as the tunnel source. Must be the same family (IPv4 or IPv6) as the remote."), + "vxlan-remote": descOr(mgr, ifPath+"/infix-interfaces:vxlan/remote", "Remote unicast or multicast address for the VXLAN overlay."), + "vxlan-vni": descOr(mgr, ifPath+"/infix-interfaces:vxlan/vni", "VXLAN Network Identifier (0–16777215). Pick a unique VNI per overlay segment."), + "vxlan-port": descOr(mgr, ifPath+"/infix-interfaces:vxlan/remote-port", "Destination UDP port. IANA-assigned VXLAN port is 4789 (default)."), + "wg-key": descOr(mgr, ifPath+"/infix-interfaces:wireguard/private-key", "Reference to the WireGuard private key (X25519/Curve25519) stored in the keystore."), + "wg-port": descOr(mgr, ifPath+"/infix-interfaces:wireguard/listen-port", "Local UDP port to listen on for incoming WireGuard traffic (default 51820)."), + "wifi-radio": descOr(mgr, ifPath+"/infix-interfaces:wifi/radio", "Parent WiFi radio (hardware component, class=wifi). Configure the radio's band, channel, and country code in Configure › Hardware first."), + "wifi-mode": "Station (client) connects to an existing AP. Access Point creates a network that clients join. Only one Station per radio; multiple APs per radio supported.", + "wifi-ssid": descOr(mgr, ifPath+"/infix-interfaces:wifi/station/ssid", "WiFi network name (1–32 characters). Case-sensitive; must match the target network for Station mode."), + "wifi-sec-mode": descOr(mgr, ifPath+"/infix-interfaces:wifi/access-point/security/mode", "Security mode. Open is unencrypted (insecure). For AP: wpa2-wpa3-personal is recommended for compatibility + security."), + "wifi-secret": descOr(mgr, ifPath+"/infix-interfaces:wifi/access-point/security/secret", "Pre-shared key reference — a symmetric key in the keystore. 8–63 characters per the WPA spec."), + "wifi-hidden": descOr(mgr, ifPath+"/infix-interfaces:wifi/access-point/hidden", "Hide the SSID from broadcast beacons. Minimal security benefit and may cause compatibility issues with some clients."), + "eth-autoneg": descOr(mgr, ifPath+"/ieee802-ethernet-interface:ethernet/auto-negotiation/enable", "Enable IEEE 802.3 auto-negotiation. When off, the link must come up via parallel detection or the forced PMD picked below."), + "eth-advertised": descOr(mgr, ifPath+"/ieee802-ethernet-interface:ethernet/auto-negotiation/infix-ethernet-interface:advertised-pmd-types", "Restrict auto-negotiation to advertise only these PMD types. Leave empty to advertise every mode the PHY supports."), + "eth-duplex": descOr(mgr, ifPath+"/ieee802-ethernet-interface:ethernet/duplex", "Force half- or full-duplex. Leave on Auto to let auto-negotiation pick. Modern PMDs are full-duplex only."), + "eth-mdix": descOr(mgr, ifPath+"/ieee802-ethernet-interface:ethernet/infix-ethernet-interface:mdi-x", "Force the copper MDI/MDI-X crossover pinout. Leave on Auto-MDIX (default) for any link that negotiates. Force MDI/MDI-X only when negotiation is disabled and the two ends must use opposite values."), + "bp-bridge": descOr(mgr, ifPath+"/infix-interfaces:bridge-port/bridge", "Bridge that this port joins as a member, carrying L2 traffic on its behalf."), + "bp-pvid": descOr(mgr, ifPath+"/infix-interfaces:bridge-port/pvid", "Port VLAN ID — VLAN assigned to untagged frames arriving on this port. Only meaningful when the parent bridge is in IEEE 802.1Q VLAN-filtering mode."), + "bp-flood": descOr(mgr, ifPath+"/infix-interfaces:bridge-port/flood", "Per-traffic-class control of how unknown destinations are flooded out this port. Unticking suppresses flooding of that traffic class."), + "bp-mc-router": descOr(mgr, ifPath+"/infix-interfaces:bridge-port/multicast/router", "Multicast router behaviour on this port. Auto (default) lets IGMP/MLD snooping decide; permanent forces the port to always receive multicast; off blocks it."), + "bp-mc-fast-leave": descOr(mgr, ifPath+"/infix-interfaces:bridge-port/multicast/fast-leave", "Drop the port from a multicast group immediately on IGMP/MLD leave instead of waiting for the next query. Suitable when each port has at most one receiver."), + "lp-lag": descOr(mgr, ifPath+"/infix-interfaces:lag-port/lag", "LAG that this port joins as a slave. The LAG itself is configured on its own row."), + "mc-snoop": descOr(mgr, ifPath+bPath+"/multicast/snooping", "Enable IGMP/MLD snooping on the bridge so multicast is forwarded only to ports with active receivers."), + "mc-querier": descOr(mgr, ifPath+bPath+"/multicast/querier", "Querier role: auto (only when no other querier is heard), on (always send queries), or off."), + "mc-query-int": descOr(mgr, ifPath+bPath+"/multicast/query-interval", "Interval (seconds) between IGMP/MLD general queries when this bridge is acting as querier."), + } + if data.Desc["ipv6-slaac"] == "" { + data.Desc["ipv6-slaac"] = "SLAAC (Stateless Address Autoconfiguration, RFC 4862) " + + "automatically assigns an IPv6 address using the network prefix " + + "advertised by the local router — no DHCPv6 server required." + } + if data.Desc["ipv4-autoconf"] == "" { + data.Desc["ipv4-autoconf"] = "Link-Local / Zeroconf (RFC 3927) automatically assigns " + + "a 169.254.x.x address when no DHCP server is reachable, enabling " + + "local network communication without any manual configuration." + } + data.STPProtoOptions = schema.OptionsFor(mgr, ifPath+bPath+"/stp/force-protocol") + data.LagModeOptions = schema.OptionsFor(mgr, ifPath+lPath+"/mode") + if len(data.LagModeOptions) == 0 { + // FIXME: schema augment lookup for /ietf-interfaces:interfaces/ + // interface/infix-interfaces:lag/mode returns empty (augment + // targets aren't being merged into the goyang Entry tree). + // Fall back to the YANG-defined lag-mode enum so the Add + // Interface wizard's LAG fieldset isn't unusable. + data.LagModeOptions = []schema.IdentityOption{ + {Value: "static", Label: "static"}, + {Value: "lacp", Label: "lacp"}, + } + } + data.LagHashOptions = schema.OptionsFor(mgr, ifPath+lPath+"/hash") + data.DHCPv4Options = schema.OptionsFor(mgr, ifPath+ip4+"/infix-dhcp-client:dhcp/option/id") + data.DHCPv6Options = schema.OptionsFor(mgr, ifPath+ip6+"/infix-dhcpv6-client:dhcp/option/id") + data.WizardTypes = buildIfaceTypeList(mgr) + // WiFi-radio enums sourced from infix-hardware:wifi-radio. Used + // by the inline "+ New radio" form in the WiFi fieldset and by + // the WiFi interface row's mirrored radio editor. + const radioSchemaPath = "/ietf-hardware:hardware/component/infix-hardware:wifi-radio" + data.WizardCountryOptions = schema.OptionsFor(mgr, radioSchemaPath+"/country-code") + data.WizardBandOptions = schema.OptionsFor(mgr, radioSchemaPath+"/band") + data.CountryOptions = data.WizardCountryOptions + data.BandOptions = data.WizardBandOptions + // Cross-module typedef enums (iwcc:country-code, wifi-band) + // don't always resolve through goyang's Entry tree, so the + // schema lookup can return empty. Fall back to a small set so + // the WiFi radio editor at least shows pickable values. + if len(data.CountryOptions) == 0 { + for _, cc := range []string{"00", "US", "GB", "DE", "SE", "FI", "JP", "AU"} { + data.CountryOptions = append(data.CountryOptions, schema.IdentityOption{Value: cc, Label: cc}) + } + } + if len(data.BandOptions) == 0 { + data.BandOptions = []schema.IdentityOption{ + {Value: "2.4GHz", Label: "2.4 GHz"}, + {Value: "5GHz", Label: "5 GHz"}, + {Value: "6GHz", Label: "6 GHz"}, + } + } + + // Bridge-port multicast-router enum + IGMP querier mode + + // WiFi security modes. Schema-driven with small fallbacks. + data.MCRouterOptions = schema.OptionsFor(mgr, ifPath+"/infix-interfaces:bridge-port/multicast/router") + if len(data.MCRouterOptions) == 0 { + data.MCRouterOptions = []schema.IdentityOption{ + {Value: "auto", Label: "auto"}, + {Value: "off", Label: "off"}, + {Value: "permanent", Label: "permanent"}, + } + } + data.WiFiQuerierOptions = schema.OptionsFor(mgr, ifPath+bPath+"/multicast/querier") + if len(data.WiFiQuerierOptions) == 0 { + data.WiFiQuerierOptions = []schema.IdentityOption{ + {Value: "auto", Label: "auto"}, + {Value: "off", Label: "off"}, + {Value: "on", Label: "on"}, + } + } + data.WiFiSecOptionsAP = schema.OptionsFor(mgr, ifPath+"/infix-interfaces:wifi/access-point/security/mode") + if len(data.WiFiSecOptionsAP) == 0 { + data.WiFiSecOptionsAP = []schema.IdentityOption{ + {Value: "wpa2-wpa3-personal", Label: "wpa2-wpa3-personal"}, + {Value: "wpa3-personal", Label: "wpa3-personal"}, + {Value: "wpa2-personal", Label: "wpa2-personal"}, + {Value: "disabled", Label: "disabled (open)"}, + } + } + data.WiFiSecOptionsSta = schema.OptionsFor(mgr, ifPath+"/infix-interfaces:wifi/station/security/mode") + if len(data.WiFiSecOptionsSta) == 0 { + data.WiFiSecOptionsSta = []schema.IdentityOption{ + {Value: "auto", Label: "auto"}, + {Value: "disabled", Label: "disabled (open)"}, + } + } + } + + // Fetch the candidate (running) interface list, operational view, + // keystore, and hardware (both candidate and operational) in + // parallel — on yanger-backed targets each takes ~1s, so serial + // fetches multiply the page latency. Keystore feeds the WG + // private-key + WiFi PSK pickers; candidate hardware feeds the + // configured WiFi radio picker; operational hardware feeds the + // "available radios" list shown in the inline "+ New radio" form. + ctx := context.WithoutCancel(r.Context()) + var ( + ifaces []ifaceJSON + operWrap interfacesWrapper + ks keystoreWrapper + hw hardwareWrapper // operational + hwCand hardwareWrapper // candidate + ifaceErr, operErr, ksErr, hwErr, hwCandErr error + wg sync.WaitGroup + ) + wg.Add(5) + go func() { defer wg.Done(); ifaces, ifaceErr = h.fetchAllInterfaces(ctx) }() + go func() { defer wg.Done(); operErr = h.RC.Get(ctx, "/data/ietf-interfaces:interfaces", &operWrap) }() + go func() { + defer wg.Done() + // Candidate first, /data/ on 404 — so uncommitted keystore + // edits show up in the wizard's pickers and Edit can pre-fill + // values from keys the user has just added in candidate. + ksErr = h.RC.Get(ctx, keystoreCandPath, &ks) + if ksErr != nil && restconf.IsNotFound(ksErr) { + ksErr = h.RC.Get(ctx, "/data/ietf-keystore:keystore", &ks) + } + }() + go func() { + defer wg.Done() + hwErr = h.RC.Get(ctx, "/data/ietf-hardware:hardware", &hw) + }() + go func() { + defer wg.Done() + hwCandErr = h.RC.Get(ctx, candidatePath+"/ietf-hardware:hardware", &hwCand) + }() + wg.Wait() + + if ifaceErr != nil { + log.Printf("configure interfaces: %v", ifaceErr) + data.Error = "Could not read interface configuration" + } + if ksErr != nil && !restconf.IsNotFound(ksErr) { + // Keystore unavailable just means the WG / WiFi pickers will + // show an empty list — not a fatal page error. + log.Printf("configure interfaces: keystore fetch: %v", ksErr) + } else { + for _, k := range ks.Keystore.AsymmetricKeys.AsymmetricKey { + data.WizardAsymKeys = append(data.WizardAsymKeys, k.Name) + } + for _, k := range ks.Keystore.SymmetricKeys.SymmetricKey { + data.WizardSymKeys = append(data.WizardSymKeys, symKeyEntry{ + Name: k.Name, + Value: decodeSymmetricValue(k), + }) + } + sort.Strings(data.WizardAsymKeys) + sort.SliceStable(data.WizardSymKeys, func(i, j int) bool { + return data.WizardSymKeys[i].Name < data.WizardSymKeys[j].Name + }) + data.PSKKeys = data.WizardSymKeys + } + if hwErr != nil && !restconf.IsNotFound(hwErr) { + log.Printf("configure interfaces: hardware fetch: %v", hwErr) + } + if hwCandErr != nil && !restconf.IsNotFound(hwCandErr) { + log.Printf("configure interfaces: candidate hardware fetch: %v", hwCandErr) + } + // Configured WiFi radios live in candidate (so the picker reflects + // uncommitted edits the user is in the middle of). Available radios + // = detected (operational class=wifi) - those already in candidate. + data.WizardWifiRadios = buildWifiRadioOptions(hwCand.Hardware.Component) + configuredRadioNames := make(map[string]bool, len(data.WizardWifiRadios)) + for _, r := range data.WizardWifiRadios { + configuredRadioNames[r.Name] = true + } + for _, c := range hw.Hardware.Component { + if shortClass(c.Class) != classWiFi { + continue + } + if configuredRadioNames[c.Name] { + continue + } + data.WizardAvailableRadios = append(data.WizardAvailableRadios, c.Name) + } + sort.Strings(data.WizardAvailableRadios) + + data.Interfaces = h.buildRows(ifaces, operWrap.Interfaces.Interface) + // Copy page-level descriptions + DHCP option enums onto each row so the + // IPv4/IPv6 blocks render standalone (see cfgIfaceRow.Desc). + for i := range data.Interfaces { + data.Interfaces[i].Desc = data.Desc + data.Interfaces[i].DHCPv4Options = data.DHCPv4Options + data.Interfaces[i].DHCPv6Options = data.DHCPv6Options + } + // Populate the mirrored radio editor for WiFi interface rows from + // the already-fetched candidate hardware tree (no extra fetch). + radios := indexWifiRadios(hwCand.Hardware.Component) + for i := range data.Interfaces { + row := &data.Interfaces[i] + if !row.IsWifi || row.WiFi == nil || row.WiFi.Radio == "" { + continue + } + if rh, ok := radios[row.WiFi.Radio]; ok { + row.PortRadio = rh + } + } + + taken := make(map[string]bool, len(ifaces)) + for _, iface := range ifaces { + slug := typeSlug(iface.Type) + data.AllNames = append(data.AllNames, iface.Name) + taken[iface.Name] = true + switch slug { + case "bridge": + data.BridgeNames = append(data.BridgeNames, iface.Name) + case "lag": + data.LagNames = append(data.LagNames, iface.Name) + } + } + sort.Strings(data.AllNames) + data.WizardNames = buildWizardNames(taken) + + // Bridge port candidates for the Add Interface wizard: any interface + // not currently enslaved to a bridge/lag and of a type that can be + // a bridge port. Computed by reusing portCandidatesFor("", …) which + // the bridge edit-form path already relies on (line 831). + memberOf := make(map[string]membership, len(ifaces)) + for _, iface := range ifaces { + if iface.BridgePort != nil && iface.BridgePort.Bridge != "" { + memberOf[iface.Name] = membership{"bridge", iface.BridgePort.Bridge} + } else if iface.LagPort != nil && iface.LagPort.LAG != "" { + memberOf[iface.Name] = membership{"lag", iface.LagPort.LAG} + } + } + data.FreePortCandidates = portCandidatesFor("", ifaces, memberOf) + + // VLAN default name = <first-parent>.1; AllNames is sorted above. + if len(data.AllNames) > 0 { + data.VlanDefaultName = data.AllNames[0] + ".1" + } + + if operErr != nil { + log.Printf("configure interfaces: operational fetch: %v", operErr) + } else { + data.UnconfiguredPhysical = unconfiguredPhysical(ifaces, operWrap.Interfaces.Interface) + } + + tmplName := "configure-interfaces.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// CreateInterface creates a new interface of the chosen type. +// POST /configure/interfaces +func (h *ConfigureInterfacesHandler) CreateInterface(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := strings.TrimSpace(r.FormValue("name")) + ifType := r.FormValue("type") + if name == "" || ifType == "" { + renderSaveError(w, fmt.Errorf("name and type are required")) + return + } + + iface := map[string]any{ + "name": name, + "type": ifType, + "enabled": true, + } + + switch typeSlug(ifType) { + case "bridge": + bridge := map[string]any{} + if r.FormValue("bridge-type") == "ieee8021q" { + // Presence of `vlans` container switches the bridge into + // IEEE 802.1Q VLAN-filtering mode; empty container is fine + // since list `vlan` has no min-elements. User adds VLANs + // through the bridge's fold-out form after Create. + bridge["vlans"] = map[string]any{} + } + iface["infix-interfaces:bridge"] = bridge + + // Bridge ports: pre-assign existing free interfaces to this + // new bridge in the same write. PATCH on the parent collection + // merges the bridge-port augmentation into each port's + // existing entry without overwriting other config. + if members := r.Form["members"]; len(members) > 0 { + ifs := []map[string]any{iface} + for _, port := range members { + ifs = append(ifs, map[string]any{ + "name": port, + "infix-interfaces:bridge-port": map[string]any{ + "bridge": name, + }, + }) + } + body := map[string]any{ + "ietf-interfaces:interfaces": map[string]any{"interface": ifs}, + } + if err := h.RC.Patch(r.Context(), ifaceCandPath, body); err != nil { + log.Printf("configure interfaces create bridge %s with ports: %v", name, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, fmt.Sprintf("%s created with %d port(s)", name, len(members)), "/configure/interfaces") + return + } + case "lag": + mode := strings.TrimSpace(r.FormValue("mode")) + if mode == "" { + mode = "static" + } + iface["infix-interfaces:lag"] = map[string]any{"mode": mode} + + // LAG ports: enslave selected interfaces to this new LAG in one + // PATCH on the parent collection — same atomic-create pattern + // as bridge ports above. + if members := r.Form["members"]; len(members) > 0 { + ifs := []map[string]any{iface} + for _, port := range members { + ifs = append(ifs, map[string]any{ + "name": port, + "infix-interfaces:lag-port": map[string]any{ + "lag": name, + }, + }) + } + body := map[string]any{ + "ietf-interfaces:interfaces": map[string]any{"interface": ifs}, + } + if err := h.RC.Patch(r.Context(), ifaceCandPath, body); err != nil { + log.Printf("configure interfaces create lag %s with ports: %v", name, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, fmt.Sprintf("%s created with %d port(s)", name, len(members)), "/configure/interfaces") + return + } + case "vlan": + vid, err := strconv.Atoi(r.FormValue("vid")) + if err != nil || vid < 1 || vid > 4094 { + renderSaveError(w, fmt.Errorf("VID must be 1–4094")) + return + } + lowerIf := strings.TrimSpace(r.FormValue("lower-layer-if")) + if lowerIf == "" { + renderSaveError(w, fmt.Errorf("lower-layer interface is required for VLAN")) + return + } + iface["infix-interfaces:vlan"] = map[string]any{ + "id": vid, + "lower-layer-if": lowerIf, + } + case "veth": + peer := strings.TrimSpace(r.FormValue("peer")) + if peer == "" { + renderSaveError(w, fmt.Errorf("peer name is required for veth")) + return + } + if peer == name { + renderSaveError(w, fmt.Errorf("peer name must differ from interface name")) + return + } + iface["infix-interfaces:veth"] = map[string]any{"peer": peer} + peerIface := map[string]any{ + "name": peer, + "type": ifType, + "enabled": true, + "infix-interfaces:veth": map[string]any{"peer": name}, + } + // One PATCH on the parent collection inserts both ends atomically. + // The veth/peer `must` constraint only fires at commit, so the + // half-pair window from two sequential PUTs is avoided. + body := map[string]any{ + "ietf-interfaces:interfaces": map[string]any{ + "interface": []map[string]any{iface, peerIface}, + }, + } + if err := h.RC.Patch(r.Context(), ifaceCandPath, body); err != nil { + log.Printf("configure interfaces create veth pair %s/%s: %v", name, peer, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, fmt.Sprintf("veth pair %s/%s created", name, peer), "/configure/interfaces") + return + case "gre", "gretap": + local := strings.TrimSpace(r.FormValue("local")) + remote := strings.TrimSpace(r.FormValue("remote")) + if local == "" || remote == "" { + renderSaveError(w, fmt.Errorf("local and remote IP addresses are required")) + return + } + // Both gre and gretap augment under the same `infix-interfaces:gre` + // container — the YANG `when` clause discriminates on if:type. + gre := map[string]any{"local": local, "remote": remote} + if r.FormValue("pmtu-discovery") == "false" { + gre["pmtu-discovery"] = false + } + iface["infix-interfaces:gre"] = gre + case "vxlan": + local := strings.TrimSpace(r.FormValue("local")) + remote := strings.TrimSpace(r.FormValue("remote")) + vniStr := strings.TrimSpace(r.FormValue("vni")) + if local == "" || remote == "" || vniStr == "" { + renderSaveError(w, fmt.Errorf("local, remote and VNI are required")) + return + } + vni, err := strconv.Atoi(vniStr) + if err != nil || vni < 0 || vni > 16777215 { + renderSaveError(w, fmt.Errorf("VNI must be 0–16777215")) + return + } + vxlan := map[string]any{"local": local, "remote": remote, "vni": vni} + if portStr := strings.TrimSpace(r.FormValue("remote-port")); portStr != "" { + port, err := strconv.Atoi(portStr) + if err != nil || port < 0 || port > 65535 { + renderSaveError(w, fmt.Errorf("UDP port must be 0–65535")) + return + } + vxlan["remote-port"] = port + } + iface["infix-interfaces:vxlan"] = vxlan + case "wireguard": + keyName := strings.TrimSpace(r.FormValue("private-key")) + if keyName == "" { + renderSaveError(w, fmt.Errorf("a keystore private-key reference is required")) + return + } + wg := map[string]any{"private-key": keyName} + if portStr := strings.TrimSpace(r.FormValue("listen-port")); portStr != "" { + port, err := strconv.Atoi(portStr) + if err != nil || port < 0 || port > 65535 { + renderSaveError(w, fmt.Errorf("listen port must be 0–65535")) + return + } + wg["listen-port"] = port + } + iface["infix-interfaces:wireguard"] = wg + case "wifi": + radio := strings.TrimSpace(r.FormValue("radio")) + if radio == "" { + renderSaveError(w, fmt.Errorf("a WiFi radio reference is required")) + return + } + mode := r.FormValue("wifi-mode") + ssid := strings.TrimSpace(r.FormValue("ssid")) + if ssid == "" { + renderSaveError(w, fmt.Errorf("SSID is required")) + return + } + secMode := r.FormValue("security-mode") + secret := strings.TrimSpace(r.FormValue("secret")) + wifi := map[string]any{"radio": radio} + switch mode { + case "access-point": + ap := map[string]any{"ssid": ssid} + if r.FormValue("hidden") == "true" { + ap["hidden"] = true + } + if secMode == "" { + secMode = "wpa2-wpa3-personal" + } + security := map[string]any{"mode": secMode} + if secMode != "open" { + if secret == "" { + renderSaveError(w, fmt.Errorf("PSK is required for security mode %q", secMode)) + return + } + security["secret"] = secret + } + ap["security"] = security + wifi["access-point"] = ap + case "station": + sta := map[string]any{"ssid": ssid} + if secMode == "" { + secMode = "auto" + } + security := map[string]any{"mode": secMode} + if secMode != "disabled" { + if secret == "" { + renderSaveError(w, fmt.Errorf("PSK is required for security mode %q", secMode)) + return + } + security["secret"] = secret + } + sta["security"] = security + wifi["station"] = sta + default: + renderSaveError(w, fmt.Errorf("WiFi mode must be 'station' or 'access-point'")) + return + } + iface["infix-interfaces:wifi"] = wifi + } + + body := map[string]any{"ietf-interfaces:interface": []map[string]any{iface}} + if err := h.RC.Put(r.Context(), ifacePath(name), body); err != nil { + log.Printf("configure interfaces create %s: %v", name, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, fmt.Sprintf("%s created", name), "/configure/interfaces") +} + +// ─── Add Interface wizard — inline keystore key creation ──────────────────── + +// keystoreCandPath is the candidate-datastore base for keystore writes. +const keystoreCandPath = candidatePath + "/ietf-keystore:keystore" + +// WizardCreateSymKey creates a passphrase-format symmetric key in the +// candidate keystore and returns the refreshed WiFi PSK picker as an +// HTML fragment so HTMX can swap it in place. Used by the inline "+ New" +// affordance in the Add Interface modal's WiFi fieldset. +// POST /configure/interfaces/wizard/sym-key +func (h *ConfigureInterfacesHandler) WizardCreateSymKey(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := strings.TrimSpace(r.FormValue("key-name")) + value := r.FormValue("key-value") + if name == "" { + renderSaveError(w, fmt.Errorf("key name is required")) + return + } + if value == "" { + renderSaveError(w, fmt.Errorf("passphrase is required")) + return + } + body := map[string]any{ + "ietf-keystore:symmetric-key": []map[string]any{{ + "name": name, + "key-format": "infix-crypto-types:passphrase-key-format", + "cleartext-symmetric-key": base64.StdEncoding.EncodeToString([]byte(value)), + }}, + } + path := keystoreCandPath + "/symmetric-keys/symmetric-key=" + url.PathEscape(name) + if err := h.RC.Put(r.Context(), path, body); err != nil { + log.Printf("wizard create sym key %q: %v", name, err) + renderSaveError(w, err) + return + } + h.renderPSKPicker(w, r, name) +} + +// WizardCreateAsymKey creates an asymmetric key from a PEM private-key +// blob pasted by the user and returns the refreshed WireGuard private- +// key picker. Mirrors AddAsymKey in configure_keystore.go but returns a +// fragment instead of redirecting. +// POST /configure/interfaces/wizard/asym-key +func (h *ConfigureInterfacesHandler) WizardCreateAsymKey(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := strings.TrimSpace(r.FormValue("key-name")) + privPEM := r.FormValue("key-value") + if name == "" { + renderSaveError(w, fmt.Errorf("key name is required")) + return + } + if privPEM == "" { + renderSaveError(w, fmt.Errorf("private key (PEM) is required")) + return + } + if err := h.putAsymKeyFromPEM(r.Context(), name, privPEM); err != nil { + log.Printf("wizard create asym key %q: %v", name, err) + renderSaveError(w, err) + return + } + h.renderWGKeyPicker(w, r, name) +} + +// WizardGenerateWGKey freshly generates a Curve25519 (X25519) keypair +// using the `wg genkey` tool already shipped on the target, wraps the +// 32-byte private key in PKCS8 DER via Go's stdlib (crypto/ecdh + +// x509.MarshalPKCS8PrivateKey), and stores it in the candidate keystore. +// Returns the refreshed WireGuard private-key picker. +// POST /configure/interfaces/wizard/wg-genkey +func (h *ConfigureInterfacesHandler) WizardGenerateWGKey(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := strings.TrimSpace(r.FormValue("key-name")) + if name == "" { + renderSaveError(w, fmt.Errorf("key name is required")) + return + } + privPEM, err := generateX25519PEM() + if err != nil { + log.Printf("wizard wg-genkey: %v", err) + renderSaveError(w, fmt.Errorf("generate X25519 key: %v", err)) + return + } + if err := h.putAsymKeyFromPEM(r.Context(), name, privPEM); err != nil { + log.Printf("wizard wg-genkey put %q: %v", name, err) + renderSaveError(w, err) + return + } + h.renderWGKeyPicker(w, r, name) +} + +// putAsymKeyFromPEM is the shared keystore-PUT path used by both the +// paste-PEM and generate flows. Decodes the PEM block, derives the +// public key when possible, and writes the entry to the candidate +// keystore. +func (h *ConfigureInterfacesHandler) putAsymKeyFromPEM(ctx context.Context, name, privPEM string) error { + block, _ := pem.Decode([]byte(privPEM)) + if block == nil { + return fmt.Errorf("invalid private key PEM: no PEM block found") + } + keyBody := map[string]any{ + "name": name, + "private-key-format": pemTypeToKeyFormat(block.Type), + "cleartext-private-key": base64.StdEncoding.EncodeToString(block.Bytes), + } + if pubPEM := derivePublicKeyFromDER(block.Bytes, block.Type); pubPEM != "" { + if err := applyPublicKey(keyBody, pubPEM); err != nil { + return err + } + } + body := map[string]any{"ietf-keystore:asymmetric-key": []map[string]any{keyBody}} + path := keystoreCandPath + "/asymmetric-keys/asymmetric-key=" + url.PathEscape(name) + return h.RC.Put(ctx, path, body) +} + +// generateX25519PEM runs `wg genkey` to source the 32 raw key bytes +// (random + Curve25519-clamped by wg's standard implementation) and +// wraps them in PKCS8 PEM via crypto/ecdh + x509.MarshalPKCS8PrivateKey. +// Returns the PEM-encoded private key. +func generateX25519PEM() (string, error) { + out, err := exec.Command("wg", "genkey").Output() + if err != nil { + return "", fmt.Errorf("wg genkey: %w", err) + } + priv64 := strings.TrimSpace(string(out)) + privRaw, err := base64.StdEncoding.DecodeString(priv64) + if err != nil { + return "", fmt.Errorf("decode wg key output: %w", err) + } + if len(privRaw) != 32 { + return "", fmt.Errorf("wg genkey returned %d bytes, expected 32", len(privRaw)) + } + ecdhPriv, err := ecdh.X25519().NewPrivateKey(privRaw) + if err != nil { + return "", fmt.Errorf("ecdh new key: %w", err) + } + der, err := x509.MarshalPKCS8PrivateKey(ecdhPriv) + if err != nil { + return "", fmt.Errorf("marshal pkcs8: %w", err) + } + return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})), nil +} + +func (h *ConfigureInterfacesHandler) renderPSKPicker(w http.ResponseWriter, r *http.Request, selected string) { + ks := h.fetchKeystore(r.Context()) + var entries []symKeyEntry + for _, k := range ks.Keystore.SymmetricKeys.SymmetricKey { + entries = append(entries, symKeyEntry{Name: k.Name, Value: decodeSymmetricValue(k)}) + } + sort.SliceStable(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name }) + if !containsSymKey(entries, selected) { + // Keystore fetch failed or candidate hasn't flushed yet — make + // the new key visible anyway so the picker remains usable. + entries = append([]symKeyEntry{{Name: selected}}, entries...) + } + w.Header().Set("HX-Trigger", `{"wifiPskCreated":true}`) + if err := h.Template.ExecuteTemplate(w, "wizard-psk-picker.html", map[string]any{ + "Keys": entries, + "Selected": selected, + "ID": r.FormValue("picker-id"), + }); err != nil { + log.Printf("psk picker fragment: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +func (h *ConfigureInterfacesHandler) renderWGKeyPicker(w http.ResponseWriter, r *http.Request, selected string) { + ks := h.fetchKeystore(r.Context()) + var names []string + for _, k := range ks.Keystore.AsymmetricKeys.AsymmetricKey { + names = append(names, k.Name) + } + sort.Strings(names) + if !containsString(names, selected) { + names = append([]string{selected}, names...) + } + w.Header().Set("HX-Trigger", `{"wgKeyCreated":true}`) + if err := h.Template.ExecuteTemplate(w, "wizard-wgkey-picker.html", map[string]any{ + "Keys": names, + "Selected": selected, + }); err != nil { + log.Printf("wg picker fragment: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// fetchKeystore tries the candidate datastore first (so post-PUT refreshes +// see the just-created key) and falls back to /data/ on 404. +func (h *ConfigureInterfacesHandler) fetchKeystore(ctx context.Context) keystoreWrapper { + var ks keystoreWrapper + if err := h.RC.Get(ctx, keystoreCandPath, &ks); err != nil { + if restconf.IsNotFound(err) { + if fallErr := h.RC.Get(ctx, "/data/ietf-keystore:keystore", &ks); fallErr != nil { + log.Printf("wizard keystore refresh fallback: %v", fallErr) + } + } else { + log.Printf("wizard keystore refresh: %v", err) + } + } + return ks +} + +func containsSymKey(entries []symKeyEntry, want string) bool { + for _, e := range entries { + if e.Name == want { + return true + } + } + return false +} + +func containsString(xs []string, want string) bool { + for _, x := range xs { + if x == want { + return true + } + } + return false +} + +// WizardCreateRadio adds (or replaces) a WiFi radio hardware component +// in the candidate ietf-hardware and returns the refreshed Radio picker +// for the wizard's WiFi fieldset. Mirrors the "+ New" pattern of the +// keystore-creation handlers. +// POST /configure/interfaces/wizard/radio +func (h *ConfigureInterfacesHandler) WizardCreateRadio(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := strings.TrimSpace(r.FormValue("radio-name")) + country := strings.TrimSpace(r.FormValue("country-code")) + band := strings.TrimSpace(r.FormValue("band")) + channel := strings.TrimSpace(r.FormValue("channel")) + if name == "" { + renderSaveError(w, fmt.Errorf("radio name is required")) + return + } + if country == "" { + renderSaveError(w, fmt.Errorf("country code is required")) + return + } + radio := map[string]any{"country-code": country} + if band != "" { + radio["band"] = band + } + if channel == "" { + channel = "auto" + } + if channel == "auto" { + radio["channel"] = "auto" + } else { + n, err := strconv.Atoi(channel) + if err != nil || n < 1 || n > 196 { + renderSaveError(w, fmt.Errorf("channel must be 'auto' or a number 1–196")) + return + } + radio["channel"] = n + } + comp := map[string]any{ + "name": name, + "class": "infix-hardware:wifi", + "infix-hardware:wifi-radio": radio, + } + body := map[string]any{"ietf-hardware:component": []map[string]any{comp}} + path := candidatePath + "/ietf-hardware:hardware/component=" + url.PathEscape(name) + if err := h.RC.Put(r.Context(), path, body); err != nil { + log.Printf("wizard create radio %q: %v", name, err) + renderSaveError(w, err) + return + } + h.renderRadioPicker(w, r, name) +} + +func (h *ConfigureInterfacesHandler) renderRadioPicker(w http.ResponseWriter, r *http.Request, selected string) { + var hwCand hardwareWrapper + if err := h.RC.Get(r.Context(), candidatePath+"/ietf-hardware:hardware", &hwCand); err != nil { + log.Printf("wizard radio refresh: %v", err) + } + radios := buildWifiRadioOptions(hwCand.Hardware.Component) + if !containsRadioName(radios, selected) { + // Race / fetch failure — surface the new radio anyway. + radios = append([]wifiRadioOption{{Name: selected, Label: selected}}, radios...) + } + w.Header().Set("HX-Trigger", `{"wifiRadioCreated":true}`) + data := map[string]any{ + "Radios": radios, + "Selected": selected, + "ID": r.FormValue("picker-id"), + } + if err := h.Template.ExecuteTemplate(w, "wizard-radio-picker.html", data); err != nil { + log.Printf("radio picker fragment: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +func containsRadioName(rs []wifiRadioOption, want string) bool { + for _, r := range rs { + if r.Name == want { + return true + } + } + return false +} + +// DeleteInterface removes an interface from running config. For virtual +// interfaces (bridge, lag, vlan, dummy, …) confd unbinds and tears down the +// netdev; for physical interfaces it just sets them administratively DOWN. +// Loopback is rejected at this layer — the UI guards it by TypeSlug too +// (configure-interfaces.html), but a direct DELETE shouldn't slip through. +// DELETE /configure/interfaces/{name} +func (h *ConfigureInterfacesHandler) DeleteInterface(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if name == "lo" || name == "loopback" { + renderSaveError(w, fmt.Errorf("loopback interface cannot be deleted")) + return + } + // Clear other interfaces' references to this one first: detach enslaved + // bridge/LAG members, and refuse outright if a VLAN is stacked on top. + // Otherwise the dangling leafref fails the next Apply with an opaque + // "Invalid input data" and no hint which node broke. + if err := h.resolveDependents(r.Context(), name); err != nil { + log.Printf("configure interfaces %s resolve dependents: %v", name, err) + renderSaveError(w, err) + return + } + if err := h.RC.Delete(r.Context(), ifacePath(name)); err != nil && !restconf.IsNotFound(err) { + log.Printf("configure interfaces %s delete: %v", name, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, name+" deleted", "/configure/interfaces") +} + +// resolveDependents prepares the named interface for deletion by clearing every +// other interface's reference to it, so the delete doesn't leave a dangling +// leafref that fails the next Apply with an opaque "Invalid input data" and no +// hint which node broke. +// +// - Enslaved bridge/LAG members are detached; they survive as standalone +// interfaces, only the membership is dropped. +// - VLANs stacked on the master via lower-layer-if can't survive without it, +// so rather than silently deleting an interface the user didn't name, the +// delete is refused and the VLANs named — removing them first is the user's +// call. +// +// The VLAN check runs before any detach, so a refused delete leaves the +// candidate untouched. +func (h *ConfigureInterfacesHandler) resolveDependents(ctx context.Context, master string) error { + ifaces, err := h.fetchAllInterfaces(ctx) + if err != nil { + return err + } + + var vlans []string + for _, iface := range ifaces { + if iface.Vlan != nil && iface.Vlan.LowerLayerIf == master { + vlans = append(vlans, iface.Name) + } + } + if len(vlans) > 0 { + noun, verb, pron := "VLAN", "is", "it" + if len(vlans) > 1 { + noun, verb, pron = "VLANs", "are", "them" + } + return fmt.Errorf("cannot delete %s: %s %s %s built on it — delete %s first", + master, noun, strings.Join(vlans, ", "), verb, pron) + } + + for _, iface := range ifaces { + switch { + case iface.BridgePort != nil && iface.BridgePort.Bridge == master: + if err := h.removeInterfaceAugment(ctx, iface.Name, "infix-interfaces:bridge-port"); err != nil { + return fmt.Errorf("detach %s from bridge %s: %w", iface.Name, master, err) + } + case iface.LagPort != nil && iface.LagPort.LAG == master: + if err := h.removeInterfaceAugment(ctx, iface.Name, "infix-interfaces:lag-port"); err != nil { + return fmt.Errorf("detach %s from lag %s: %w", iface.Name, master, err) + } + } + } + return nil +} + +// SaveGeneral saves description, enabled, and optional custom MAC for any +// interface. Empty MAC clears the override (DELETE on custom-phys-address); +// non-empty installs it as a static override. +// POST /configure/interfaces/{name} +func (h *ConfigureInterfacesHandler) SaveGeneral(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := r.PathValue("name") + enabled := r.FormValue("enabled") != "false" + // PATCH on a list element needs the entry wrapped in a single-element + // array, not a bare object — a bare object fails YANG validation (LY_EVALID). + body := map[string]any{ + "ietf-interfaces:interface": []map[string]any{{ + "name": name, + "enabled": enabled, + "description": strings.TrimSpace(r.FormValue("description")), + }}, + } + if err := h.RC.Patch(r.Context(), ifacePath(name), body); err != nil { + log.Printf("configure interfaces %s general: %v", name, err) + renderSaveError(w, err) + return + } + + macPath := ifacePath(name) + "/infix-interfaces:custom-phys-address" + if mac := strings.TrimSpace(r.FormValue("mac")); mac != "" { + macBody := map[string]any{ + "infix-interfaces:custom-phys-address": map[string]any{"static": mac}, + } + if err := h.RC.Put(r.Context(), macPath, macBody); err != nil { + log.Printf("configure interfaces %s mac: %v", name, err) + renderSaveError(w, err) + return + } + } else { + if err := h.RC.Delete(r.Context(), macPath); err != nil && !restconf.IsNotFound(err) { + log.Printf("configure interfaces %s mac clear: %v", name, err) + renderSaveError(w, err) + return + } + } + + renderSaved(w, "Saved") +} + +// AddIPv4 adds an IPv4 address to an interface. +// POST /configure/interfaces/{name}/ipv4 +func (h *ConfigureInterfacesHandler) AddIPv4(w http.ResponseWriter, r *http.Request) { + h.addAddr(w, r, "ipv4", "IPv4", "iface-ipv4-block") +} + +// DeleteIPv4 removes an IPv4 address from an interface. +// DELETE /configure/interfaces/{name}/ipv4/{ip} +func (h *ConfigureInterfacesHandler) DeleteIPv4(w http.ResponseWriter, r *http.Request) { + h.deleteAddr(w, r, "ipv4", "IPv4", "iface-ipv4-block") +} + +// AddIPv6 adds an IPv6 address to an interface. +// POST /configure/interfaces/{name}/ipv6 +func (h *ConfigureInterfacesHandler) AddIPv6(w http.ResponseWriter, r *http.Request) { + h.addAddr(w, r, "ipv6", "IPv6", "iface-ipv6-block") +} + +// DeleteIPv6 removes an IPv6 address from an interface. +// DELETE /configure/interfaces/{name}/ipv6/{ip} +func (h *ConfigureInterfacesHandler) DeleteIPv6(w http.ResponseWriter, r *http.Request) { + h.deleteAddr(w, r, "ipv6", "IPv6", "iface-ipv6-block") +} + +// SaveBridgePort assigns or updates an interface's bridge membership. +// PUT-replaces the bridge-port augment, so the form must submit every +// field it wants preserved. Changing the `bridge` field from one name +// to another effectively moves the port — the PUT is atomic. +// POST /configure/interfaces/{name}/bridge-port +// SaveEthernet writes ethernet settings via a sequence of PATCH and DELETE +// ops rather than a single PUT. A PUT that includes every leaf in the +// ethernet container silently drops the duplex leaf on rousette/confd +// (verified with curl); the per-leaf approach matches what manual PATCH +// curls land reliably. Tri-state fields (duplex, mdi-x) DELETE on "Auto" +// so the candidate ends with an absent leaf, which is how the YANG model +// expresses the default. data-missing on DELETE is swallowed for +// idempotency. +// POST /configure/interfaces/{name}/ethernet +func (h *ConfigureInterfacesHandler) SaveEthernet(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + ctx := r.Context() + name := r.PathValue("name") + base := ifacePath(name) + "/ieee802-ethernet-interface:ethernet" + + autoneg := r.FormValue("autoneg") == "on" + var adv []string + for _, v := range r.Form["advertised"] { + if v != "" { + adv = append(adv, v) + } + } + + // auto-negotiation: PUT the whole container so an empty advertised list + // actually clears stale entries. DELETE on an unqualified leaf-list + // path fails ("requires exactly one key") under RFC 8040, so omitting + // the leaf-list from a container PUT is the cleanest clear. Safe here + // because the container only carries enable + advertised-pmd-types + // (negotiation-status is deviate-not-supported in Infix). + an := map[string]any{"enable": autoneg} + if len(adv) > 0 { + an["infix-ethernet-interface:advertised-pmd-types"] = adv + } + body := map[string]any{"ieee802-ethernet-interface:auto-negotiation": an} + if err := h.RC.Put(ctx, base+"/auto-negotiation", body); err != nil { + log.Printf("configure interfaces %s ethernet autoneg: %v", name, err) + renderSaveError(w, err) + return + } + + // duplex: PATCH if user picked full/half, DELETE on Auto + if d := r.FormValue("duplex"); d != "" { + body := map[string]any{ + "ieee802-ethernet-interface:ethernet": map[string]any{"duplex": d}, + } + if err := h.RC.Patch(ctx, base, body); err != nil { + log.Printf("configure interfaces %s ethernet duplex: %v", name, err) + renderSaveError(w, err) + return + } + } else if err := h.RC.Delete(ctx, base+"/duplex"); err != nil && !restconf.IsDataMissing(err) { + log.Printf("configure interfaces %s ethernet duplex delete: %v", name, err) + renderSaveError(w, err) + return + } + + // mdi-x: only valid when autoneg is off (YANG when). On autoneg=true + // or user picks Auto-MDIX, DELETE so the leaf stays absent. + mdix := r.FormValue("mdix") + if !autoneg && (mdix == "true" || mdix == "false") { + body := map[string]any{ + "ieee802-ethernet-interface:ethernet": map[string]any{ + "infix-ethernet-interface:mdi-x": mdix == "true", + }, + } + if err := h.RC.Patch(ctx, base, body); err != nil { + log.Printf("configure interfaces %s ethernet mdi-x: %v", name, err) + renderSaveError(w, err) + return + } + } else if err := h.RC.Delete(ctx, base+"/infix-ethernet-interface:mdi-x"); err != nil && !restconf.IsDataMissing(err) { + log.Printf("configure interfaces %s ethernet mdi-x delete: %v", name, err) + renderSaveError(w, err) + return + } + + renderSaved(w, "Ethernet saved") +} + +// ResetEthernetAdvertised clears the advertised-pmd-types leaf-list while +// preserving auto-negotiation/enable. Routed off the per-row reset +// button because the generic /configure/leaf path can't DELETE a leaf-list +// without per-entry key predicates. +// DELETE /configure/interfaces/{name}/ethernet/advertised +func (h *ConfigureInterfacesHandler) ResetEthernetAdvertised(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + base := ifacePath(name) + "/ieee802-ethernet-interface:ethernet" + + var resp struct { + AN struct { + Enable *bool `json:"enable"` + } `json:"ieee802-ethernet-interface:auto-negotiation"` + } + enable := true // YANG default + if err := h.RC.Get(r.Context(), base+"/auto-negotiation", &resp); err == nil { + if resp.AN.Enable != nil { + enable = *resp.AN.Enable + } + } else if !restconf.IsNotFound(err) { + log.Printf("configure interfaces %s reset advertised get: %v", name, err) + renderSaveError(w, err) + return + } + + body := map[string]any{ + "ieee802-ethernet-interface:auto-negotiation": map[string]any{"enable": enable}, + } + if err := h.RC.Put(r.Context(), base+"/auto-negotiation", body); err != nil { + log.Printf("configure interfaces %s reset advertised put: %v", name, err) + renderSaveError(w, err) + return + } + renderSaved(w, "Reset to default") +} + +func (h *ConfigureInterfacesHandler) SaveBridgePort(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := r.PathValue("name") + bridge := strings.TrimSpace(r.FormValue("bridge")) + if bridge == "" { + renderSaveError(w, fmt.Errorf("bridge name is required")) + return + } + bp := buildBridgePortBody(r, bridge) + body := map[string]any{"infix-interfaces:bridge-port": bp} + if err := h.RC.Put(r.Context(), ifacePath(name)+"/infix-interfaces:bridge-port", body); err != nil { + log.Printf("configure interfaces %s bridge-port: %v", name, err) + renderSaveError(w, err) + return + } + renderSaved(w, "Bridge port saved") +} + +// buildBridgePortBody assembles the bridge-port augment from form +// values for both Save and Move. Bridge is the caller-resolved name +// (the form's "bridge" field for Save, the move destination for Move). +func buildBridgePortBody(r *http.Request, bridge string) map[string]any { + bp := map[string]any{"bridge": bridge} + if pvid := r.FormValue("pvid"); pvid != "" { + if v, err := strconv.Atoi(pvid); err == nil && v > 0 { + bp["pvid"] = v + } + } + flood := map[string]any{} + for _, k := range []string{"broadcast", "unicast", "multicast"} { + flood[k] = r.FormValue("flood-"+k) == "on" + } + bp["flood"] = flood + mc := map[string]any{} + if mr := r.FormValue("mc-router"); mr != "" { + mc["router"] = mr + } + // Fast-leave is the access-port knob: cut multicast groups the + // moment we see an IGMP leave (don't wait for the group-specific + // query timeout). Submit only when the form carried the checkbox. + if _, ok := r.Form["mc-fast-leave-present"]; ok { + mc["fast-leave"] = r.FormValue("mc-fast-leave") == "on" + } + if len(mc) > 0 { + bp["multicast"] = mc + } + return bp +} + +// DeleteBridgePort detaches an interface from its bridge. +// DELETE /configure/interfaces/{name}/bridge-port +func (h *ConfigureInterfacesHandler) DeleteBridgePort(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if err := h.removeInterfaceAugment(r.Context(), name, "infix-interfaces:bridge-port"); err != nil { + log.Printf("configure interfaces %s bridge-port delete: %v", name, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Removed from bridge", "/configure/interfaces") +} + +// removeInterfaceAugment drops a top-level augment (bridge-port, +// lag-port, …) from an interface entry via GET-modify-PUT on the +// candidate. RESTCONF DELETE on a non-presence container returns 502 +// from rousette (the augment has no presence resource to remove), so +// we have to rewrite the parent instead — same workaround as +// yang-tree's DeleteContainer, scoped to interfaces here. +func (h *ConfigureInterfacesHandler) removeInterfaceAugment(ctx context.Context, name, augKey string) error { + path := ifacePath(name) + raw, err := h.RC.GetRaw(ctx, path) + if err != nil { + return fmt.Errorf("fetch interface %s: %w", name, err) + } + var doc map[string]any + if err := json.Unmarshal(raw, &doc); err != nil { + return fmt.Errorf("decode interface %s: %w", name, err) + } + iface, err := unwrapSingleInterface(doc) + if err != nil { + return err + } + if _, ok := iface[augKey]; !ok { + return fmt.Errorf("%s not present on interface %s", augKey, name) + } + delete(iface, augKey) + body := map[string]any{"ietf-interfaces:interface": []map[string]any{iface}} + return h.RC.Put(ctx, path, body) +} + +// unwrapSingleInterface peels the RESTCONF envelope for a list-instance +// GET on /ietf-interfaces:interfaces/interface=<name>. The response is +// always {"ietf-interfaces:interfaces":{"interface":[{...}]}} with one +// entry; we return that entry's object. +func unwrapSingleInterface(doc map[string]any) (map[string]any, error) { + wrap, ok := doc["ietf-interfaces:interfaces"].(map[string]any) + if !ok { + return nil, fmt.Errorf("response missing ietf-interfaces:interfaces wrapper") + } + list, ok := wrap["interface"].([]any) + if !ok || len(list) == 0 { + return nil, fmt.Errorf("response has no interface entry") + } + iface, ok := list[0].(map[string]any) + if !ok { + return nil, fmt.Errorf("interface entry not an object") + } + return iface, nil +} + +// SaveBridge saves bridge type and member ports in one round trip from the +// unified "Bridge Settings" form. STP/multicast keep their own foldout +// forms; this handler covers what used to be two side-by-side forms. +// POST /configure/interfaces/{name}/bridge +func (h *ConfigureInterfacesHandler) SaveBridge(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := r.PathValue("name") + bridge := map[string]any{} + + stp := map[string]any{} + if v := r.FormValue("stp-force-protocol"); v != "" { + stp["force-protocol"] = v + } + for _, pair := range []struct{ form, yang string }{ + {"stp-hello-time", "hello-time"}, + {"stp-forward-delay", "forward-delay"}, + {"stp-max-age", "max-age"}, + {"stp-transmit-hold-count", "transmit-hold-count"}, + {"stp-max-hops", "max-hops"}, + } { + if v, err := strconv.Atoi(r.FormValue(pair.form)); err == nil { + stp[pair.yang] = v + } + } + if len(stp) > 0 { + bridge["stp"] = stp + } + + if len(bridge) > 0 { + body := map[string]any{"infix-interfaces:bridge": bridge} + if err := h.RC.Patch(r.Context(), ifacePath(name)+"/infix-interfaces:bridge", body); err != nil { + log.Printf("configure interfaces %s bridge: %v", name, err) + renderSaveError(w, err) + return + } + } + + // The bridge type choice is expressed via the vlans presence container: + // 802.1Q = vlans container present; 802.1D = vlans container absent. + vlansPath := ifacePath(name) + "/infix-interfaces:bridge/vlans" + if r.FormValue("bridge-type") == "ieee8021q" { + body := map[string]any{"vlans": map[string]any{}} + if err := h.RC.Put(r.Context(), vlansPath, body); err != nil { + log.Printf("configure interfaces %s bridge type 8021q: %v", name, err) + renderSaveError(w, err) + return + } + } else { + if err := h.RC.Delete(r.Context(), vlansPath); err != nil { + // 404 is fine — vlans already absent (802.1D) + log.Printf("configure interfaces %s bridge type 8021d (delete vlans): %v", name, err) + } + } + + if err := h.applyMembersDiff(r, name, "bridge", + func(iface ifaceJSON, master string) bool { + return iface.BridgePort != nil && iface.BridgePort.Bridge == master + }); err != nil { + renderSaveError(w, err) + return + } + + renderSaved(w, "Bridge saved") +} + +// AddVLAN creates a new VLAN on an ieee8021q bridge. +// POST /configure/interfaces/{name}/bridge/vlans +func (h *ConfigureInterfacesHandler) AddVLAN(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := r.PathValue("name") + vid, err := strconv.Atoi(r.FormValue("vid")) + if err != nil || vid < 1 || vid > 4094 { + renderSaveError(w, fmt.Errorf("VID must be 1–4094")) + return + } + vlan := map[string]any{"vid": vid} + if untagged := r.Form["untagged"]; len(untagged) > 0 { + vlan["untagged"] = untagged + } + if tagged := r.Form["tagged"]; len(tagged) > 0 { + vlan["tagged"] = tagged + } + body := map[string]any{"infix-interfaces:vlan": []map[string]any{vlan}} + path := ifacePath(name) + "/infix-interfaces:bridge/vlans/vlan=" + strconv.Itoa(vid) + if err := h.RC.Put(r.Context(), path, body); err != nil { + log.Printf("configure interfaces %s vlan add %d: %v", name, vid, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "VLAN added", "/configure/interfaces") +} + +// SaveVLAN updates the untagged/tagged port sets for an existing VLAN. +// POST /configure/interfaces/{name}/bridge/vlans/{vid} +func (h *ConfigureInterfacesHandler) SaveVLAN(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := r.PathValue("name") + vidStr := r.PathValue("vid") + vid, err := strconv.Atoi(vidStr) + if err != nil { + http.Error(w, "invalid vid", http.StatusBadRequest) + return + } + untagged := r.Form["untagged"] + if untagged == nil { + untagged = []string{} + } + tagged := r.Form["tagged"] + if tagged == nil { + tagged = []string{} + } + vlan := map[string]any{ + "vid": vid, + "untagged": untagged, + "tagged": tagged, + } + body := map[string]any{"infix-interfaces:vlan": []map[string]any{vlan}} + path := ifacePath(name) + "/infix-interfaces:bridge/vlans/vlan=" + vidStr + if err := h.RC.Put(r.Context(), path, body); err != nil { + log.Printf("configure interfaces %s vlan save %d: %v", name, vid, err) + renderSaveError(w, err) + return + } + renderSaved(w, "VLAN saved") +} + +// DeleteVLAN removes a VLAN from an ieee8021q bridge. +// DELETE /configure/interfaces/{name}/bridge/vlans/{vid} +func (h *ConfigureInterfacesHandler) DeleteVLAN(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + vidStr := r.PathValue("vid") + path := ifacePath(name) + "/infix-interfaces:bridge/vlans/vlan=" + vidStr + if err := h.RC.Delete(r.Context(), path); err != nil { + log.Printf("configure interfaces %s vlan delete %s: %v", name, vidStr, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "VLAN deleted", "/configure/interfaces") +} + +// SaveLagPort assigns an interface to a LAG. +// POST /configure/interfaces/{name}/lag-port +func (h *ConfigureInterfacesHandler) SaveLagPort(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := r.PathValue("name") + lagName := strings.TrimSpace(r.FormValue("lag")) + if lagName == "" { + renderSaveError(w, fmt.Errorf("LAG name is required")) + return + } + body := map[string]any{ + "infix-interfaces:lag-port": map[string]any{"lag": lagName}, + } + if err := h.RC.Put(r.Context(), ifacePath(name)+"/infix-interfaces:lag-port", body); err != nil { + log.Printf("configure interfaces %s lag-port: %v", name, err) + renderSaveError(w, err) + return + } + renderSaved(w, "LAG port saved") +} + +// SaveBridgeSTP PATCHes the bridge STP container. Split out from +// SaveBridge so the STP fold-out can carry its own Save button (the +// matching pattern the bridge-type radios stay outside). +// POST /configure/interfaces/{name}/bridge/stp +func (h *ConfigureInterfacesHandler) SaveBridgeSTP(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := r.PathValue("name") + stp := map[string]any{} + if v := r.FormValue("stp-force-protocol"); v != "" { + stp["force-protocol"] = v + } + for _, pair := range []struct{ form, yang string }{ + {"stp-hello-time", "hello-time"}, + {"stp-forward-delay", "forward-delay"}, + {"stp-max-age", "max-age"}, + {"stp-transmit-hold-count", "transmit-hold-count"}, + {"stp-max-hops", "max-hops"}, + } { + if v, err := strconv.Atoi(r.FormValue(pair.form)); err == nil { + stp[pair.yang] = v + } + } + body := map[string]any{"infix-interfaces:stp": stp} + path := ifacePath(name) + "/infix-interfaces:bridge/stp" + if err := h.RC.Patch(r.Context(), path, body); err != nil { + log.Printf("configure interfaces %s bridge stp: %v", name, err) + renderSaveError(w, err) + return + } + renderSaved(w, "STP saved") +} + +// SaveBridgeMulticast PATCHes the bridge multicast snooping container. +// Snooping toggle + querier mode are first-class fields; query-interval +// is the one advanced timing knob exposed for now. +// POST /configure/interfaces/{name}/bridge/multicast +func (h *ConfigureInterfacesHandler) SaveBridgeMulticast(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := r.PathValue("name") + mc := map[string]any{ + "snooping": r.FormValue("snooping") == "on", + } + if q := r.FormValue("querier"); q != "" { + mc["querier"] = q + } + if v, err := strconv.Atoi(r.FormValue("query-interval")); err == nil && v > 0 { + mc["query-interval"] = v + } + body := map[string]any{"infix-interfaces:multicast": mc} + path := ifacePath(name) + "/infix-interfaces:bridge/multicast" + if err := h.RC.Patch(r.Context(), path, body); err != nil { + log.Printf("configure interfaces %s bridge multicast: %v", name, err) + renderSaveError(w, err) + return + } + renderSaved(w, "Multicast saved") +} + +// SaveWifi PATCHes the WiFi interface container plus, when the form +// also carries radio fields, the mirrored wifi-radio component — both +// in a single PATCH on the candidate root so the interface SSID/sec +// and the radio's country/band/channel land atomically. The mode +// (station vs access-point) is fixed at wizard-create time and not +// switched here. +// POST /configure/interfaces/{name}/wifi +func (h *ConfigureInterfacesHandler) SaveWifi(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := r.PathValue("name") + mode := r.FormValue("mode") + if mode != "station" && mode != "access-point" { + renderSaveError(w, fmt.Errorf("mode must be 'station' or 'access-point'")) + return + } + leaf := map[string]any{"ssid": r.FormValue("ssid")} + if secMode := r.FormValue("sec-mode"); secMode != "" { + sec := map[string]any{"mode": secMode} + if secret := r.FormValue("secret"); secret != "" { + sec["secret"] = secret + } + leaf["security"] = sec + } + if mode == "access-point" { + leaf["hidden"] = r.FormValue("hidden") == "on" + } + wifi := map[string]any{mode: leaf} + // Picker change re-binds the wifi/radio leaf so the user can move + // the interface to a different (already-configured) radio. + if radioRef := strings.TrimSpace(r.FormValue("radio")); radioRef != "" { + wifi["radio"] = radioRef + } + iface := map[string]any{ + "name": name, + "infix-interfaces:wifi": wifi, + } + body := map[string]any{ + "ietf-interfaces:interfaces": map[string]any{ + "interface": []map[string]any{iface}, + }, + } + // Radio half of the atomic write — only when the form actually + // carried a country (the wifi-radio container's mandatory leaf). + // Without it parseWiFiRadio would reject a form whose user only + // touched the WiFi side and left the radio fields untouched-empty. + radio := strings.TrimSpace(r.FormValue("radio")) + if radio != "" && strings.TrimSpace(r.FormValue("country-code")) != "" { + rc, err := parseWiFiRadio(r) + if err != nil { + renderSaveError(w, err) + return + } + body["ietf-hardware:hardware"] = map[string]any{ + "component": []map[string]any{{ + "name": radio, + "class": "infix-hardware:wifi", + "infix-hardware:wifi-radio": rc, + }}, + } + } + if err := h.RC.Patch(r.Context(), candidatePath, body); err != nil { + log.Printf("configure interfaces %s wifi: %v", name, err) + renderSaveError(w, err) + return + } + renderSaved(w, "WiFi saved") +} + +// DeleteLagPort detaches an interface from its LAG. +// DELETE /configure/interfaces/{name}/lag-port +func (h *ConfigureInterfacesHandler) DeleteLagPort(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if err := h.removeInterfaceAugment(r.Context(), name, "infix-interfaces:lag-port"); err != nil { + log.Printf("configure interfaces %s lag-port delete: %v", name, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Removed from LAG", "/configure/interfaces") +} + +// SaveLAG saves LAG mode and LACP settings. +// POST /configure/interfaces/{name}/lag +func (h *ConfigureInterfacesHandler) SaveLAG(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := r.PathValue("name") + lag := map[string]any{ + "mode": r.FormValue("mode"), + } + if hash := r.FormValue("hash"); hash != "" { + lag["hash"] = hash + } + if r.FormValue("mode") == "lacp" { + lacp := map[string]any{ + "mode": r.FormValue("lacp-mode"), + "rate": r.FormValue("lacp-rate"), + } + if v, err := strconv.Atoi(r.FormValue("lacp-system-priority")); err == nil { + lacp["system-priority"] = v + } + lag["lacp"] = lacp + } + body := map[string]any{"infix-interfaces:lag": lag} + if err := h.RC.Patch(r.Context(), ifacePath(name)+"/infix-interfaces:lag", body); err != nil { + log.Printf("configure interfaces %s lag: %v", name, err) + renderSaveError(w, err) + return + } + renderSaved(w, "LAG saved") +} + +// SaveLAGMembers performs a diff-and-write to set the LAG's member ports. +// POST /configure/interfaces/{name}/lag/members +func (h *ConfigureInterfacesHandler) SaveLAGMembers(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + h.saveMembersDiff(w, r, r.PathValue("name"), "lag", + func(iface ifaceJSON, master string) bool { + return iface.LagPort != nil && iface.LagPort.LAG == master + }, "LAG members saved") +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +func ifacePath(name string) string { + return ifaceCandPath + "/interface=" + url.PathEscape(name) +} + +// indexWifiRadios picks WiFi radio components out of the hardware +// candidate tree and returns the minimal subset the WiFi interface +// editor mirrors (name, country, band, channel). Components without +// a wifi-radio container are skipped. +func indexWifiRadios(comps []hwComponentJSON) map[string]*ifaceRadioMirror { + out := make(map[string]*ifaceRadioMirror, len(comps)) + for _, c := range comps { + if c.WiFiRadio == nil { + continue + } + m := &ifaceRadioMirror{ + Name: c.Name, + CountryCode: c.WiFiRadio.CountryCode, + Band: c.WiFiRadio.Band, + } + if ch, ok := c.WiFiRadio.Channel.(float64); ok && ch > 0 { + v := int(ch) + m.Channel = &v + } + out[c.Name] = m + } + return out +} + +// saveMembersDiff syncs bridge/lag membership by diffing submitted form values +// against the current state: add port-kind for new members, remove for ex-members. +// kind is "bridge" or "lag"; it determines the YANG augment path and body key. +func (h *ConfigureInterfacesHandler) saveMembersDiff(w http.ResponseWriter, r *http.Request, + masterName, kind string, isMember func(ifaceJSON, string) bool, successMsg string) { + if err := h.applyMembersDiff(r, masterName, kind, isMember); err != nil { + renderSaveError(w, err) + return + } + renderSaved(w, successMsg) +} + +// applyMembersDiff is the no-response-writing core of saveMembersDiff so it +// can be reused by callers that compose multiple save steps (e.g. SaveBridge +// which writes type + members in one form submission). +func (h *ConfigureInterfacesHandler) applyMembersDiff(r *http.Request, + masterName, kind string, isMember func(ifaceJSON, string) bool) error { + + ifaces, err := h.fetchAllInterfaces(r.Context()) + if err != nil { + return err + } + + submitted := make(map[string]bool) + for _, m := range r.Form["members"] { + submitted[m] = true + } + + portKey := "infix-interfaces:" + kind + "-port" + for _, iface := range ifaces { + if typeSlug(iface.Type) == kind { + continue // skip master interface itself + } + currentlyMember := isMember(iface, masterName) + wantMember := submitted[iface.Name] + portPath := ifacePath(iface.Name) + "/" + portKey + + if wantMember && !currentlyMember { + body := map[string]any{portKey: map[string]any{kind: masterName}} + if err := h.RC.Put(r.Context(), portPath, body); err != nil { + log.Printf("configure interfaces %s members add %s→%s: %v", kind, iface.Name, masterName, err) + return err + } + } else if !wantMember && currentlyMember { + if err := h.RC.Delete(r.Context(), portPath); err != nil { + log.Printf("configure interfaces %s members remove %s from %s: %v", kind, iface.Name, masterName, err) + return err + } + } + } + return nil +} + +// unconfiguredPhysical returns physical Ethernet interfaces present in +// operational but absent from running config — the Add Interface row's +// datalist suggestions. +func unconfiguredPhysical(running, oper []ifaceJSON) []string { + inRunning := make(map[string]bool, len(running)) + for _, iface := range running { + inRunning[iface.Name] = true + } + var out []string + for _, iface := range oper { + if inRunning[iface.Name] { + continue + } + if typeSlug(iface.Type) != ifTypeEthernet { + continue + } + out = append(out, iface.Name) + } + sort.Strings(out) + return out +} + +func (h *ConfigureInterfacesHandler) fetchAllInterfaces(ctx context.Context) ([]ifaceJSON, error) { + var wrap interfacesWrapper + if err := h.RC.Get(ctx, ifaceCandPath, &wrap); err != nil { + // Fall back to running only on 404 (candidate has no interfaces configured yet). + // Any other error (validation failure, server error) is surfaced directly to + // avoid silently showing stale running data while the candidate is in bad shape. + if !restconf.IsNotFound(err) { + return nil, err + } + log.Printf("configure interfaces: candidate returned 404, using running datastore") + if err2 := h.RC.Get(ctx, "/data/ietf-interfaces:interfaces", &wrap); err2 != nil { + return nil, err2 + } + } + return wrap.Interfaces.Interface, nil +} + +type membership struct{ kind, master string } + +func (h *ConfigureInterfacesHandler) buildRows(ifaces []ifaceJSON, oper []ifaceJSON) []cfgIfaceRow { + // Index operational so each row can pull supported-pmd-types — that + // leaf is config false, so the candidate read doesn't carry it. + operByName := make(map[string]*ifaceJSON, len(oper)) + for i := range oper { + operByName[oper[i].Name] = &oper[i] + } + // Build a set of current bridge/lag members for fast lookup. + memberOf := make(map[string]membership, len(ifaces)) + for _, iface := range ifaces { + if iface.BridgePort != nil && iface.BridgePort.Bridge != "" { + memberOf[iface.Name] = membership{"bridge", iface.BridgePort.Bridge} + } else if iface.LagPort != nil && iface.LagPort.LAG != "" { + memberOf[iface.Name] = membership{"lag", iface.LagPort.LAG} + } + } + + // Pre-compute bridge member sets and a "bridge name → is 802.1Q?" + // lookup so each port row can hide PVID when its parent isn't + // VLAN-filtering (PVID only applies in that mode). + bridgeMembers := make(map[string][]string) + bridgeIs8021Q := make(map[string]bool) + for _, iface := range ifaces { + if typeSlug(iface.Type) == "bridge" && iface.Bridge != nil && iface.Bridge.VLANs != nil { + bridgeIs8021Q[iface.Name] = true + } + if m, ok := memberOf[iface.Name]; ok && m.kind == "bridge" { + bridgeMembers[m.master] = append(bridgeMembers[m.master], iface.Name) + } + } + lagMembers := make(map[string][]string) + for _, iface := range ifaces { + if m, ok := memberOf[iface.Name]; ok && m.kind == "lag" { + lagMembers[m.master] = append(lagMembers[m.master], iface.Name) + } + } + + rows := make([]cfgIfaceRow, 0, len(ifaces)) + for _, iface := range ifaces { + slug := typeSlug(iface.Type) + row := cfgIfaceRow{ + ifaceJSON: iface, + TypeSlug: slug, + TypeDisplay: typeDisplay(slug), + AdminEnabled: iface.Enabled == nil || *iface.Enabled, + IsBridge: slug == "bridge", + IsBridgePort: iface.BridgePort != nil, + IsLag: slug == "lag", + IsLagPort: iface.LagPort != nil, + IsVlan: slug == "vlan", + IsWifi: slug == "wifi", + } + if iface.WiFi != nil { + switch { + case iface.WiFi.AccessPoint != nil: + row.WifiMode = "access-point" + case iface.WiFi.Station != nil: + row.WifiMode = "station" + } + } + row.EthAutoneg = true // YANG default when no candidate value is set + if iface.Ethernet != nil { + row.EthDuplex = iface.Ethernet.Duplex + if iface.Ethernet.AutoNegotiation != nil { + row.EthAutoneg = iface.Ethernet.AutoNegotiation.Enable + row.EthAdvertised = iface.Ethernet.AutoNegotiation.AdvertisedPMDs + } + if iface.Ethernet.MDIX != nil { + if *iface.Ethernet.MDIX { + row.MDIXState = "true" + } else { + row.MDIXState = "false" + } + } + } + if op := operByName[iface.Name]; op != nil && op.Ethernet != nil { + row.EthSupported = op.Ethernet.SupportedPMDs + } + if m, ok := memberOf[iface.Name]; ok { + row.MemberOf = m.master + } + if row.IsBridgePort && iface.BridgePort != nil { + row.ParentBridgeIs8021Q = bridgeIs8021Q[iface.BridgePort.Bridge] + } + row.HasIP = !row.IsBridgePort && !row.IsLagPort + // The DHCP/DHCPv6 foldouts are always rendered so users can + // discover the settings form before enabling the client. The + // foldout body iterates .IPv4.DHCP / .IPv6.DHCPv6 — populate + // placeholders here so the template can use .IPv4.DHCP.X without + // a forest of nil guards. Whether DHCP is actually configured is + // captured separately on .DHCPv4Enabled / .DHCPv6Enabled so the + // checkbox state and foldout-hidden gate still reflect candidate. + if row.HasIP { + if row.IPv4 == nil { + row.IPv4 = &ipCfg{} + } else { + row.DHCPv4Enabled = row.IPv4.DHCP != nil + } + if row.IPv4.DHCP == nil { + row.IPv4.DHCP = &dhcpv4CfgJSON{} + } + if row.IPv6 == nil { + row.IPv6 = &ipCfg{} + } else { + row.DHCPv6Enabled = row.IPv6.DHCPv6 != nil + } + if row.IPv6.DHCPv6 == nil { + row.IPv6.DHCPv6 = &dhcpv6CfgJSON{} + } + } + row.AddrSummary = addrSummary(iface) + row.ConfigTags = configSummary(&row) + + if row.IsBridge { + members := bridgeMembers[iface.Name] + sort.Strings(members) + row.BridgeMembers = members + row.BridgeMemberSet = make(map[string]bool, len(members)) + for _, m := range members { + row.BridgeMemberSet[m] = true + } + row.PortCandidates = portCandidatesFor(iface.Name, ifaces, memberOf) + if iface.Bridge != nil && iface.Bridge.VLANs != nil { + row.BridgeIs8021Q = true + if q := iface.Bridge.VLANs; q != nil { + for _, v := range q.VLANs { + untaggedSet := make(map[string]bool, len(v.Untagged)) + for _, u := range v.Untagged { + untaggedSet[u] = true + } + taggedSet := make(map[string]bool, len(v.Tagged)) + for _, t := range v.Tagged { + taggedSet[t] = true + } + row.VLANRows = append(row.VLANRows, cfgVLANRow{ + VID: v.VID, + UntaggedTxt: strings.Join(v.Untagged, ", "), + TaggedTxt: strings.Join(v.Tagged, ", "), + UntaggedSet: untaggedSet, + TaggedSet: taggedSet, + }) + } + } + } + } + + if row.IsLag { + members := lagMembers[iface.Name] + sort.Strings(members) + row.BridgeMembers = members // reuse field — LAG members shown same way + row.BridgeMemberSet = make(map[string]bool, len(members)) + for _, m := range members { + row.BridgeMemberSet[m] = true + } + row.PortCandidates = portCandidatesFor(iface.Name, ifaces, memberOf) + } + + rows = append(rows, row) + } + + sort.Slice(rows, func(i, j int) bool { + ri, rj := rows[i], rows[j] + // Sort order: bridge/lag first, then by type, then by name. + orderI, orderJ := typeOrder(ri.TypeSlug), typeOrder(rj.TypeSlug) + if orderI != orderJ { + return orderI < orderJ + } + return ri.Name < rj.Name + }) + return rows +} + +// portCandidatesFor returns sorted candidate port names for a given bridge or LAG. +// Included: ports that are free (no master) or already a member of masterName. +// Excluded: bridge, lag, loopback, dummy, wireguard, tunnel types, and ports +// enslaved to a different master. +func portCandidatesFor(masterName string, ifaces []ifaceJSON, memberOf map[string]membership) []string { + var out []string + for _, iface := range ifaces { + switch typeSlug(iface.Type) { + case "bridge", "lag", "loopback", "dummy", "wireguard", "gre", "gretap", "vxlan": + continue + } + m := memberOf[iface.Name] + if m.master == "" || m.master == masterName { + out = append(out, iface.Name) + } + } + sort.Strings(out) + return out +} + +func (h *ConfigureInterfacesHandler) addAddr(w http.ResponseWriter, r *http.Request, family, famCap, frag string) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := r.PathValue("name") + ip := strings.TrimSpace(r.FormValue("ip")) + prefixStr := r.FormValue("prefix-length") + prefix, err := strconv.Atoi(prefixStr) + if err != nil || ip == "" { + renderSaveError(w, fmt.Errorf("valid IP address and prefix length required")) + return + } + // A PUT to a list element needs the entry wrapped in a single-element + // array, not a bare object — a bare object fails YANG validation (LY_EVALID). + body := map[string]any{ + "ietf-ip:address": []map[string]any{{ + "ip": ip, + "prefix-length": prefix, + }}, + } + path := ifacePath(name) + "/ietf-ip:" + family + "/address=" + restconf.EscapeKey(ip) + if err := h.RC.Put(r.Context(), path, body); err != nil { + log.Printf("configure interfaces %s add %s addr: %v", name, family, err) + renderSaveError(w, err) + return + } + // Re-render the IP block in place so the new address appears without + // collapsing the interface foldout — ready to add another straight away. + h.renderIPBlock(w, r, name, famCap, frag, "Address added") +} + +func (h *ConfigureInterfacesHandler) deleteAddr(w http.ResponseWriter, r *http.Request, family, famCap, frag string) { + name := r.PathValue("name") + ip := r.PathValue("ip") + path := ifacePath(name) + "/ietf-ip:" + family + "/address=" + restconf.EscapeKey(ip) + if err := h.RC.Delete(r.Context(), path); err != nil { + log.Printf("configure interfaces %s delete %s addr %s: %v", name, family, ip, err) + renderSaveError(w, err) + return + } + h.renderIPBlock(w, r, name, famCap, frag, "Address removed") +} + +// SaveIPv4Settings PATCHes the per-interface IPv4 group settings — forwarding +// leaf plus the DHCP-client and link-local autoconf presence containers — in +// a single round trip from the IPv4 settings form. Each presence container is +// PUT (enable) or DELETE (disable) per checkbox state; forwarding is PATCHed. +// POST /configure/interfaces/{name}/ipv4/settings +func (h *ConfigureInterfacesHandler) SaveIPv4Settings(w http.ResponseWriter, r *http.Request) { + h.saveIPSettings(w, r, "ietf-ip:ipv4", "IPv4", "iface-ipv4-block", map[string]string{ + "dhcp": "infix-dhcp-client:dhcp", + "autoconf": "infix-ip:autoconf", + }) +} + +// SaveIPv6Settings is the IPv6 counterpart of SaveIPv4Settings. SLAAC lives at +// the standard ietf-ip "autoconf" container; DHCPv6 is the Infix augment. +// POST /configure/interfaces/{name}/ipv6/settings +func (h *ConfigureInterfacesHandler) SaveIPv6Settings(w http.ResponseWriter, r *http.Request) { + h.saveIPSettings(w, r, "ietf-ip:ipv6", "IPv6", "iface-ipv6-block", map[string]string{ + "dhcp": "infix-dhcpv6-client:dhcp", + "slaac": "autoconf", + }) +} + +// saveIPSettings is the shared body of SaveIPv4Settings / SaveIPv6Settings. +// presenceMap maps form-field names (e.g. "dhcp") to their YANG presence +// container key (e.g. "infix-dhcp-client:dhcp"); each one is PUT when checked +// and DELETEd otherwise. Forwarding is always PATCHed. +func (h *ConfigureInterfacesHandler) saveIPSettings(w http.ResponseWriter, r *http.Request, container, family, fragName string, presenceMap map[string]string) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := r.PathValue("name") + base := ifacePath(name) + "/" + container + + forwarding := r.FormValue("forwarding") == "true" + // PATCH the interface (which always exists) with the family container + // nested inside, so the container is created on first use. PATCHing + // `base` (the ipv4/ipv6 container) directly 400s "Target resource does + // not exist" on a fresh interface that has no L3 config yet — which is + // exactly the case when enabling DHCP for the first time. The list + // entry must be a single-element array: rousette rejects the bare-object + // form with LY_EVALID once an augmented container (ietf-ip:ipv4) is + // nested inside. + body := map[string]any{ + "ietf-interfaces:interface": []any{ + map[string]any{ + "name": name, + container: map[string]any{"forwarding": forwarding}, + }, + }, + } + if err := h.RC.Patch(r.Context(), ifacePath(name), body); err != nil { + log.Printf("configure interfaces %s %s settings: forwarding: %v", name, family, err) + renderSaveError(w, err) + return + } + + for field, child := range presenceMap { + path := base + "/" + child + if r.FormValue(field) == "true" { + b := map[string]any{child: map[string]any{}} + if err := h.RC.Put(r.Context(), path, b); err != nil { + log.Printf("configure interfaces %s %s settings: enable %s: %v", name, family, field, err) + renderSaveError(w, err) + return + } + } else { + if err := h.RC.Delete(r.Context(), path); err != nil && !restconf.IsNotFound(err) { + log.Printf("configure interfaces %s %s settings: disable %s: %v", name, family, field, err) + renderSaveError(w, err) + return + } + } + } + + // Re-render just this interface's IP block from the fresh candidate so + // confd-inferred values (the DHCP option list, etc.) appear in place, + // without collapsing the page. fragName empty → fall back to a toast + // (e.g. families whose block fragment isn't wired up yet). + if fragName != "" { + h.renderIPBlock(w, r, name, family, fragName, family+" settings saved") + return + } + renderSaved(w, family+" settings saved") +} + +// renderIPBlock re-renders one interface's IPv4/IPv6 block fragment from the +// fresh candidate. Falls back to a plain saved-toast if the interface or +// schema can't be read, so a save is never reported as failed just because +// the in-place refresh couldn't be built. +func (h *ConfigureInterfacesHandler) renderIPBlock(w http.ResponseWriter, r *http.Request, name, family, fragName, savedMsg string) { + ifaces, err := h.fetchAllInterfaces(r.Context()) + if err != nil { + renderSaved(w, savedMsg) + return + } + rows := h.buildRows(ifaces, nil) + var row *cfgIfaceRow + for i := range rows { + if rows[i].Name == name { + row = &rows[i] + break + } + } + if row == nil { + renderSaved(w, savedMsg) + return + } + if mgr := h.Schema.Manager(); mgr != nil { + ifPath := "/ietf-interfaces:interfaces/interface" + ip4, ip6 := "/ietf-ip:ipv4", "/ietf-ip:ipv6" + row.DHCPv4Options = schema.OptionsFor(mgr, ifPath+ip4+"/infix-dhcp-client:dhcp/option/id") + row.DHCPv6Options = schema.OptionsFor(mgr, ifPath+ip6+"/infix-dhcpv6-client:dhcp/option/id") + row.Desc = map[string]string{ + "ipv4-address": schema.DescriptionOf(mgr, ifPath+ip4+"/address/ip"), + "ipv4-dhcp": schema.DescriptionOf(mgr, ifPath+ip4+"/infix-dhcp-client:dhcp"), + "ipv4-autoconf": schema.DescriptionOf(mgr, ifPath+ip4+"/infix-ip:autoconf"), + "ipv4-forwarding": schema.DescriptionOf(mgr, ifPath+ip4+"/forwarding"), + "ipv6-address": schema.DescriptionOf(mgr, ifPath+ip6+"/address/ip"), + "ipv6-dhcp": schema.DescriptionOf(mgr, ifPath+ip6+"/infix-dhcpv6-client:dhcp"), + "ipv6-slaac": schema.DescriptionOf(mgr, ifPath+ip6+"/autoconf"), + "ipv6-forwarding": schema.DescriptionOf(mgr, ifPath+ip6+"/forwarding"), + } + } + row.JustSaved = true // expand the DHCP foldout to reveal inferred options + row.SavedMsg = savedMsg + // The block is swapped via outerHTML, so the confirmation is rendered into + // the block footer (above) rather than chased by JS across the swap; the + // log-only event just records it in the Configure activity panel. + msgJSON, _ := json.Marshal(savedMsg) + w.Header().Set("HX-Trigger", `{"cfgLogged":`+string(msgJSON)+`}`) + if err := h.Template.ExecuteTemplate(w, fragName, row); err != nil { + log.Printf("configure interfaces %s: render %s: %v", name, fragName, err) + } +} + +func (h *ConfigureInterfacesHandler) SaveIPv4DHCPSettings(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := r.PathValue("name") + body := map[string]any{} + if v := r.FormValue("client-id"); v != "" { + body["client-id"] = v + } + if v := r.FormValue("arping"); v != "" { + body["arping"] = v == "true" + } + if v := r.FormValue("route-preference"); v != "" { + if n, err := strconv.ParseUint(v, 10, 32); err == nil { + body["route-preference"] = n + } + } + path := ifacePath(name) + "/ietf-ip:ipv4/infix-dhcp-client:dhcp" + if err := h.RC.Patch(r.Context(), path, map[string]any{"infix-dhcp-client:dhcp": body}); err != nil { + log.Printf("configure interfaces dhcpv4 settings %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSaved(w, "DHCP settings saved") +} + +func (h *ConfigureInterfacesHandler) addDHCPOption(w http.ResponseWriter, r *http.Request, ipPath, bodyKey string) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := r.PathValue("name") + id := strings.TrimSpace(r.FormValue("id")) + if id == "" { + renderSaveError(w, fmt.Errorf("option id is required")) + return + } + entry := map[string]any{"id": id} + if v := strings.TrimSpace(r.FormValue("value")); v != "" { + entry["value"] = v + } + path := ifacePath(name) + "/" + ipPath + "/option=" + url.PathEscape(id) + if err := h.RC.Put(r.Context(), path, map[string]any{bodyKey: []map[string]any{entry}}); err != nil { + log.Printf("configure interfaces dhcp option add %q/%q: %v", name, id, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Option added", "/configure/interfaces") +} + +func (h *ConfigureInterfacesHandler) deleteDHCPOption(w http.ResponseWriter, r *http.Request, ipPath string) { + name := r.PathValue("name") + id := r.PathValue("id") + path := ifacePath(name) + "/" + ipPath + "/option=" + url.PathEscape(id) + if err := h.RC.Delete(r.Context(), path); err != nil { + log.Printf("configure interfaces dhcp option delete %q/%q: %v", name, id, err) + renderSaveError(w, err) + return + } + w.WriteHeader(http.StatusOK) +} + +func (h *ConfigureInterfacesHandler) AddIPv4DHCPOption(w http.ResponseWriter, r *http.Request) { + h.addDHCPOption(w, r, "ietf-ip:ipv4/infix-dhcp-client:dhcp", "infix-dhcp-client:option") +} + +func (h *ConfigureInterfacesHandler) DeleteIPv4DHCPOption(w http.ResponseWriter, r *http.Request) { + h.deleteDHCPOption(w, r, "ietf-ip:ipv4/infix-dhcp-client:dhcp") +} + +func (h *ConfigureInterfacesHandler) SaveIPv6DHCPSettings(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := r.PathValue("name") + body := map[string]any{} + if v := r.FormValue("duid"); v != "" { + body["duid"] = v + } + if v := r.FormValue("information-only"); v != "" { + body["information-only"] = v == "true" + } + if v := r.FormValue("route-preference"); v != "" { + if n, err := strconv.ParseUint(v, 10, 32); err == nil { + body["route-preference"] = n + } + } + path := ifacePath(name) + "/ietf-ip:ipv6/infix-dhcpv6-client:dhcp" + if err := h.RC.Patch(r.Context(), path, map[string]any{"infix-dhcpv6-client:dhcp": body}); err != nil { + log.Printf("configure interfaces dhcpv6 settings %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSaved(w, "DHCPv6 settings saved") +} + +func (h *ConfigureInterfacesHandler) AddIPv6DHCPOption(w http.ResponseWriter, r *http.Request) { + h.addDHCPOption(w, r, "ietf-ip:ipv6/infix-dhcpv6-client:dhcp", "infix-dhcpv6-client:option") +} + +func (h *ConfigureInterfacesHandler) DeleteIPv6DHCPOption(w http.ResponseWriter, r *http.Request) { + h.deleteDHCPOption(w, r, "ietf-ip:ipv6/infix-dhcpv6-client:dhcp") +} + +func typeSlug(yangType string) string { + s := schema.StripModulePrefix(yangType) + // Normalise iana-if-type identities to infix slugs where relevant. + switch s { + case "bridge": + return "bridge" + case "ieee8023adLag": + return "lag" + case "ethernetCsmacd": + return "ethernet" + case "l2vlan": + return "vlan" + case "softwareLoopback": + return "loopback" + } + return s +} + +func typeDisplay(slug string) string { + switch slug { + case "bridge": + return "Bridge" + case "lag": + return "LAG" + case "ethernet": + return "Ethernet" + case "vlan": + return "VLAN" + case "loopback": + return "Loopback" + case "dummy": + return "Dummy" + case "wireguard": + return "WireGuard" + case "veth": + return "veth" + case "gre", "gretap": + return strings.ToUpper(slug) + case "vxlan": + return "VXLAN" + } + return slug +} + +func typeOrder(slug string) int { + switch slug { + case "bridge": + return 0 + case "lag": + return 1 + case "ethernet": + return 2 + case "vlan": + return 3 + case "loopback": + return 4 + default: + return 5 + } +} + +// ifaceTypeOption is one option in the Add Interface modal's type pulldown. +type ifaceTypeOption struct { + Slug string + Value string // YANG-qualified identity, e.g. "infix-if-type:bridge" + Label string +} + +// symKeyEntry is one option in the wizard's WiFi PSK picker. Value is +// the decoded cleartext (decoded from the keystore's base64-encoded +// cleartext-symmetric-key) so the Edit affordance can pre-fill the +// passphrase input. Empty when the key isn't printable ASCII (e.g. a +// raw octet-string secret). +type symKeyEntry struct { + Name string + Value string +} + +// wifiRadioOption is one entry in the wizard's WiFi radio picker. APReady +// reflects the YANG must-clauses that require band+channel+country-code on +// the radio component before an access-point interface can be attached. +// Country/Band/Channel are surfaced as data-* attributes so the Edit +// affordance can pre-fill the inline form from the currently-selected +// option. +type wifiRadioOption struct { + Name string + Label string // e.g. "phy0 — 5GHz ch36 SE" + Country string + Band string + Channel string + APReady bool +} + +// buildIfaceTypeList returns all identities derived from +// infix-if-type:infix-interface-type for the Add Interface modal's +// pulldown. Sorted by typeOrder then alphabetical. Includes types whose +// Create path is not yet implemented (gre, gretap, vxlan, wireguard, +// wifi, …) — the modal's "unsupported" panel handles those by pointing +// the user at the Edit-all YANG tree. +// buildWifiRadioOptions filters detected hardware components down to +// configured WiFi radios (class=wifi with a wifi-radio container present +// in running config) and returns picker entries with a label that hints +// at band/channel/country. APReady reflects the YANG must-clauses on +// access-point — band, channel, and country-code all set, with country +// != "00" (world regulatory domain is rejected for AP mode). +func buildWifiRadioOptions(comps []hwComponentJSON) []wifiRadioOption { + var out []wifiRadioOption + for _, c := range comps { + if shortClass(c.Class) != classWiFi || c.WiFiRadio == nil { + continue + } + ch := wifiChannelString(c.WiFiRadio.Channel) + opt := wifiRadioOption{ + Name: c.Name, + Country: c.WiFiRadio.CountryCode, + Band: c.WiFiRadio.Band, + Channel: ch, + } + var bits []string + if opt.Band != "" { + bits = append(bits, opt.Band) + } + if ch != "" { + bits = append(bits, "ch"+ch) + } + if opt.Country != "" { + bits = append(bits, opt.Country) + } + if len(bits) > 0 { + opt.Label = c.Name + " — " + strings.Join(bits, " ") + } else { + opt.Label = c.Name + } + opt.APReady = opt.Band != "" && ch != "" && opt.Country != "" && opt.Country != "00" + out = append(out, opt) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// descOr returns the YANG description at path, falling back to the +// given literal when the schema lookup returns an empty string (which +// today happens for any leaf reached through an infix-interfaces:* +// augment — goyang doesn't currently traverse the augment into the +// ietf-interfaces:interface Entry tree). +func descOr(mgr *schema.Manager, path, fallback string) string { + if d := schema.DescriptionOf(mgr, path); d != "" { + return d + } + return fallback +} + +func buildIfaceTypeList(mgr *schema.Manager) []ifaceTypeOption { + names := mgr.IdentitiesOf("infix-interface-type") + opts := make([]ifaceTypeOption, 0, len(names)) + for _, name := range names { + opts = append(opts, ifaceTypeOption{ + Slug: name, + Value: ifTypePrefix + name, + Label: typeDisplay(name), + }) + } + sort.SliceStable(opts, func(i, j int) bool { + oa, ob := typeOrder(opts[i].Slug), typeOrder(opts[j].Slug) + if oa != ob { + return oa < ob + } + return opts[i].Slug < opts[j].Slug + }) + return opts +} + +// buildWizardNames precomputes the next-free name suggestion for every +// type whose name has a natural numeric prefix. The veth peer suggestion +// lives under key "veth-peer" so callers see a single uniform map. +// Ethernet is intentionally absent — its name comes from the datalist +// of unconfigured interfaces, not a numeric prefix. +func buildWizardNames(taken map[string]bool) map[string]string { + vethA, vethB := nextFreeVethPair(taken) + return map[string]string{ + "bridge": nextFreeName("br", taken), + "lag": nextFreeName("lag", taken), + "vlan": nextFreeName("vlan", taken), + "dummy": nextFreeName("dummy", taken), + "loopback": nextFreeName("lo", taken), + "veth": vethA, + "veth-peer": vethB, + "wireguard": nextFreeName("wg", taken), + "gre": nextFreeName("gre", taken), + "gretap": nextFreeName("gretap", taken), + "vxlan": nextFreeName("vxlan", taken), + "wifi": nextFreeName("wifi", taken), + } +} + +// nextFreeName returns the smallest N (starting at 0) where <prefix><N> +// is not in `taken`. Used by the wizard to pre-fill the Name field with +// a sensible default. +func nextFreeName(prefix string, taken map[string]bool) string { + for i := 0; i < 1000; i++ { + name := fmt.Sprintf("%s%d", prefix, i) + if !taken[name] { + return name + } + } + return prefix + "0" +} + +// nextFreeVethPair returns (veth<N>a, veth<N>b) for the smallest N where +// neither side is taken. Matches the user's preferred a/b suffix +// convention for VETH pairs. +func nextFreeVethPair(taken map[string]bool) (string, string) { + for i := 0; i < 1000; i++ { + a := fmt.Sprintf("veth%da", i) + b := fmt.Sprintf("veth%db", i) + if !taken[a] && !taken[b] { + return a, b + } + } + return "veth0a", "veth0b" +} + +func addrSummary(iface ifaceJSON) string { + var addrs []string + if iface.IPv4 != nil { + for _, a := range iface.IPv4.Address { + addrs = append(addrs, fmt.Sprintf("%s/%d", a.IP, int(a.PrefixLength))) + } + } + if iface.IPv6 != nil { + for _, a := range iface.IPv6.Address { + addrs = append(addrs, fmt.Sprintf("%s/%d", a.IP, int(a.PrefixLength))) + } + } + switch len(addrs) { + case 0: + return "" + case 1: + return addrs[0] + default: + return fmt.Sprintf("%d addresses", len(addrs)) + } +} + +// configSummary builds the type-aware overview pills for an interface in the +// Configure list. It mirrors the Status > Interfaces "Data" column but is +// slanted to configured intent rather than operational result: each role +// contributes the properties worth seeing without unfolding the row — IP +// acquisition (DHCP, SLAAC, zeroconf) for L3 interfaces, the tag id for VLANs, +// the radio mode for WiFi, and 802.1Q for VLAN-filtering bridges. Static +// addresses are rendered separately via AddrSummary. +func configSummary(row *cfgIfaceRow) []string { + var tags []string + + if row.IsVlan && row.Vlan != nil { + tags = append(tags, fmt.Sprintf("vid %d", row.Vlan.ID)) + } + if row.IsWifi && row.WifiMode != "" { + tags = append(tags, row.WifiMode) + } + if row.IsBridge && row.BridgeIs8021Q { + tags = append(tags, "802.1Q") + } + if row.HasIP { + if row.DHCPv4Enabled { + tags = append(tags, "DHCPv4") + } + if row.IPv4 != nil && row.IPv4.Autoconf != nil { + tags = append(tags, "Zeroconf") + } + if row.DHCPv6Enabled { + tags = append(tags, "DHCPv6") + } + if row.IPv6 != nil && row.IPv6.SLAACv6 != nil { + tags = append(tags, "SLAAC") + } + } + + return tags +} diff --git a/src/webui/internal/handlers/configure_keystore.go b/src/webui/internal/handlers/configure_keystore.go new file mode 100644 index 00000000..9ca36f53 --- /dev/null +++ b/src/webui/internal/handlers/configure_keystore.go @@ -0,0 +1,503 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "crypto/ecdsa" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "html/template" + "log" + "net/http" + "net/url" + "strings" + + "infix/webui/internal/restconf" + "infix/webui/internal/schema" +) + +const keystorePath = candidatePath + "/ietf-keystore:keystore" + +// ─── Template data ──────────────────────────────────────────────────────────── + +type cfgKeystorePageData struct { + PageData + Loading bool + SymmetricKeys []cfgSymKeyEntry + AsymmetricKeys []cfgAsymKeyEntry + SymKeyFormats []schema.IdentityOption + Error string +} + +type cfgSymKeyEntry struct { + Name string + Format string // short label shown in the read-only column ("passphrase") + FormatID string // raw identityref ("infix-crypto-types:passphrase-key-format") + // — Format is shortFormat-stripped for display, FormatID is what the + // edit-row <select> needs to match its <option value=...> against. + // Comparing the two in the template silently mismatches and the + // browser falls back to the first <option>, rewriting the key with + // the wrong format on Save. + Value string +} + +type cfgCertEntry struct { + Name string + PEM string // DER re-encoded as PEM for display +} + +type cfgAsymKeyEntry struct { + Name string + Algorithm string + PublicKeyPEM string + PrivateKeyPEM string + Certificates []cfgCertEntry +} + +// ─── Handler ────────────────────────────────────────────────────────────────── + +type ConfigureKeystoreHandler struct { + Template *template.Template + RC restconf.Fetcher + Schema *schema.Cache +} + +// Overview renders the Configure > Keystore page reading from the candidate. +// GET /configure/keystore +func (h *ConfigureKeystoreHandler) Overview(w http.ResponseWriter, r *http.Request) { + data := cfgKeystorePageData{ + PageData: newPageData(w, r, "configure-keystore", "Keystore"), + } + + var ks keystoreWrapper + if err := h.RC.Get(r.Context(), keystorePath, &ks); err != nil { + if !restconf.IsNotFound(err) { + log.Printf("configure keystore: %v", err) + data.Error = "Could not read keystore" + } else if fallErr := h.RC.Get(r.Context(), "/data/ietf-keystore:keystore", &ks); fallErr != nil && !restconf.IsNotFound(fallErr) { + log.Printf("configure keystore (running fallback): %v", fallErr) + data.Error = "Could not read keystore" + } + } + + for _, k := range ks.Keystore.SymmetricKeys.SymmetricKey { + data.SymmetricKeys = append(data.SymmetricKeys, cfgSymKeyEntry{ + Name: k.Name, + Format: shortFormat(k.KeyFormat), + FormatID: k.KeyFormat, + Value: decodeSymmetricValue(k), + }) + } + for _, k := range ks.Keystore.AsymmetricKeys.AsymmetricKey { + entry := cfgAsymKeyEntry{ + Name: k.Name, + Algorithm: asymAlgorithm(k), + PublicKeyPEM: derBase64ToPEM(k.PublicKey, pemBlockType(k.PublicKeyFormat)), + PrivateKeyPEM: derBase64ToPEM(k.CleartextPrivateKey, pemBlockType(k.PrivateKeyFormat)), + } + for _, c := range k.Certificates.Certificate { + entry.Certificates = append(entry.Certificates, cfgCertEntry{ + Name: c.Name, + PEM: derBase64ToPEM(c.CertData, "CERTIFICATE"), + }) + } + data.AsymmetricKeys = append(data.AsymmetricKeys, entry) + } + + mgr := h.Schema.Manager() + data.Loading = mgr == nil + if mgr != nil { + data.SymKeyFormats = schema.OptionsFor(mgr, "/ietf-keystore:keystore/symmetric-keys/symmetric-key/key-format") + } + + tmplName := "configure-keystore.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// AddSymKey adds a symmetric key to the candidate. +// POST /configure/keystore/symmetric +func (h *ConfigureKeystoreHandler) AddSymKey(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := strings.TrimSpace(r.FormValue("name")) + value := r.FormValue("value") + format := r.FormValue("format") + if format == "" { + format = "infix-crypto-types:passphrase-key-format" + } + if name == "" { + renderSaveError(w, fmt.Errorf("name is required")) + return + } + + // Passphrase values are base64-encoded plaintext in the YANG model. + keyB64 := base64.StdEncoding.EncodeToString([]byte(value)) + + body := map[string]any{ + "ietf-keystore:symmetric-key": []map[string]any{{ + "name": name, + "key-format": format, + "cleartext-symmetric-key": keyB64, + }}, + } + path := keystorePath + "/symmetric-keys/symmetric-key=" + url.PathEscape(name) + if err := h.RC.Put(r.Context(), path, body); err != nil { + log.Printf("configure keystore add sym %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Symmetric key added", "/configure/keystore") +} + +// DeleteSymKey removes a symmetric key from the candidate. +// DELETE /configure/keystore/symmetric/{name} +func (h *ConfigureKeystoreHandler) DeleteSymKey(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + path := keystorePath + "/symmetric-keys/symmetric-key=" + url.PathEscape(name) + if err := h.RC.Delete(r.Context(), path); err != nil { + log.Printf("configure keystore delete sym %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Symmetric key deleted", "/configure/keystore") +} + +// AddAsymKey adds an asymmetric key from a PEM-encoded private key. +// POST /configure/keystore/asymmetric +func (h *ConfigureKeystoreHandler) AddAsymKey(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := strings.TrimSpace(r.FormValue("name")) + privPEM := r.FormValue("private_key") + pubPEM := strings.TrimSpace(r.FormValue("public_key")) + if name == "" { + renderSaveError(w, fmt.Errorf("name is required")) + return + } + if privPEM == "" { + renderSaveError(w, fmt.Errorf("private key is required")) + return + } + + block, _ := pem.Decode([]byte(privPEM)) + if block == nil { + renderSaveError(w, fmt.Errorf("invalid private key PEM: no PEM block found")) + return + } + privB64 := base64.StdEncoding.EncodeToString(block.Bytes) + + keyBody := map[string]any{ + "name": name, + "private-key-format": pemTypeToKeyFormat(block.Type), + "cleartext-private-key": privB64, + } + + // Use explicitly provided public key, or derive from private key (avoids re-parsing PEM). + if pubPEM == "" { + pubPEM = derivePublicKeyFromDER(block.Bytes, block.Type) + } + if pubPEM != "" { + if err := applyPublicKey(keyBody, pubPEM); err != nil { + renderSaveError(w, err) + return + } + } + + body := map[string]any{"ietf-keystore:asymmetric-key": []map[string]any{keyBody}} + path := keystorePath + "/asymmetric-keys/asymmetric-key=" + url.PathEscape(name) + if err := h.RC.Put(r.Context(), path, body); err != nil { + log.Printf("configure keystore add asym %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Asymmetric key added", "/configure/keystore") +} + +// derivePublicKeyFromDER extracts the public key from DER-encoded private key bytes +// and returns it as a PKIX PEM block. Returns empty string if derivation fails. +func derivePublicKeyFromDER(der []byte, pemType string) string { + var pubKey any + switch strings.ToUpper(pemType) { + case "EC PRIVATE KEY": + k, err := x509.ParseECPrivateKey(der) + if err != nil { + return "" + } + pubKey = &k.PublicKey + case "RSA PRIVATE KEY": + k, err := x509.ParsePKCS1PrivateKey(der) + if err != nil { + return "" + } + pubKey = &k.PublicKey + default: // PKCS#8 / "PRIVATE KEY" + k, err := x509.ParsePKCS8PrivateKey(der) + if err != nil { + return "" + } + switch v := k.(type) { + case *ecdsa.PrivateKey: + pubKey = &v.PublicKey + case *rsa.PrivateKey: + pubKey = &v.PublicKey + default: + return "" + } + } + + pubDER, err := x509.MarshalPKIXPublicKey(pubKey) + if err != nil { + return "" + } + return string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER})) +} + +// applyPublicKey parses pubPEM and sets public-key and public-key-format in keyBody. +func applyPublicKey(keyBody map[string]any, pubPEM string) error { + b64, pemType, err := parsePEMBody(pubPEM) + if err != nil { + return fmt.Errorf("invalid public key PEM: %w", err) + } + keyBody["public-key-format"] = pemTypeToKeyFormat(pemType) + keyBody["public-key"] = b64 + return nil +} + +// DeleteAsymKey removes an asymmetric key from the candidate. +// DELETE /configure/keystore/asymmetric/{name} +func (h *ConfigureKeystoreHandler) DeleteAsymKey(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + path := keystorePath + "/asymmetric-keys/asymmetric-key=" + url.PathEscape(name) + if err := h.RC.Delete(r.Context(), path); err != nil { + log.Printf("configure keystore delete asym %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Asymmetric key deleted", "/configure/keystore") +} + +// AddCert adds a certificate to an asymmetric key entry. +// POST /configure/keystore/asymmetric/{name}/certs +func (h *ConfigureKeystoreHandler) AddCert(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + keyName := r.PathValue("name") + certName := strings.TrimSpace(r.FormValue("cert_name")) + pemData := r.FormValue("cert_data") + if certName == "" || pemData == "" { + renderSaveError(w, fmt.Errorf("certificate name and data are required")) + return + } + + certB64, _, err := parsePEMBody(pemData) + if err != nil { + renderSaveError(w, fmt.Errorf("invalid PEM: %w", err)) + return + } + + body := map[string]any{ + "ietf-keystore:certificate": []map[string]any{{ + "name": certName, + "cert-data": certB64, + }}, + } + path := keystorePath + "/asymmetric-keys/asymmetric-key=" + url.PathEscape(keyName) + + "/certificates/certificate=" + url.PathEscape(certName) + if err := h.RC.Put(r.Context(), path, body); err != nil { + log.Printf("configure keystore add cert %q/%q: %v", keyName, certName, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Certificate added", "/configure/keystore") +} + +// UpdateCert replaces the PEM data of an existing certificate. +// POST /configure/keystore/asymmetric/{name}/certs/{certname} +func (h *ConfigureKeystoreHandler) UpdateCert(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + keyName := r.PathValue("name") + certName := r.PathValue("certname") + pemData := r.FormValue("cert_data") + if pemData == "" { + renderSaveError(w, fmt.Errorf("certificate PEM data is required")) + return + } + + certB64, _, err := parsePEMBody(pemData) + if err != nil { + renderSaveError(w, fmt.Errorf("invalid PEM: %w", err)) + return + } + + body := map[string]any{ + "ietf-keystore:certificate": []map[string]any{{ + "name": certName, + "cert-data": certB64, + }}, + } + path := keystorePath + "/asymmetric-keys/asymmetric-key=" + url.PathEscape(keyName) + + "/certificates/certificate=" + url.PathEscape(certName) + if err := h.RC.Patch(r.Context(), path, body); err != nil { + log.Printf("configure keystore update cert %q/%q: %v", keyName, certName, err) + renderSaveError(w, err) + return + } + renderSaved(w, "Certificate updated") +} + +// DeleteCert removes a certificate from an asymmetric key entry. +// DELETE /configure/keystore/asymmetric/{name}/certs/{certname} +func (h *ConfigureKeystoreHandler) DeleteCert(w http.ResponseWriter, r *http.Request) { + keyName := r.PathValue("name") + certName := r.PathValue("certname") + path := keystorePath + "/asymmetric-keys/asymmetric-key=" + url.PathEscape(keyName) + + "/certificates/certificate=" + url.PathEscape(certName) + if err := h.RC.Delete(r.Context(), path); err != nil { + log.Printf("configure keystore delete cert %q/%q: %v", keyName, certName, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Certificate deleted", "/configure/keystore") +} + +// derBase64ToPEM converts a base64-encoded DER blob (as returned by RESTCONF +// for YANG binary fields) back into a PEM-encoded string for display. +func derBase64ToPEM(b64, blockType string) string { + b64 = strings.ReplaceAll(b64, "\n", "") + der, err := base64.StdEncoding.DecodeString(b64) + if err != nil || len(der) == 0 { + return "" + } + return string(pem.EncodeToMemory(&pem.Block{Type: blockType, Bytes: der})) +} + +// UpdateSymKey changes the value of an existing symmetric key in the candidate. +// POST /configure/keystore/symmetric/{name} +func (h *ConfigureKeystoreHandler) UpdateSymKey(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := r.PathValue("name") + value := r.FormValue("value") + format := r.FormValue("format") + if format == "" { + format = "infix-crypto-types:passphrase-key-format" + } + keyB64 := base64.StdEncoding.EncodeToString([]byte(value)) + body := map[string]any{ + "ietf-keystore:symmetric-key": []map[string]any{{ + "name": name, + "key-format": format, + "cleartext-symmetric-key": keyB64, + }}, + } + path := keystorePath + "/symmetric-keys/symmetric-key=" + url.PathEscape(name) + if err := h.RC.Patch(r.Context(), path, body); err != nil { + log.Printf("configure keystore update sym %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSaved(w, "Key updated") +} + +// UpdateAsymKey updates the private and/or public key of an asymmetric key entry. +// POST /configure/keystore/asymmetric/{name} +func (h *ConfigureKeystoreHandler) UpdateAsymKey(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := r.PathValue("name") + privPEM := strings.TrimSpace(r.FormValue("private_key")) + pubPEM := strings.TrimSpace(r.FormValue("public_key")) + if privPEM == "" && pubPEM == "" { + renderSaveError(w, fmt.Errorf("at least one key field is required")) + return + } + + keyBody := map[string]any{"name": name} + if privPEM != "" { + block, _ := pem.Decode([]byte(privPEM)) + if block == nil { + renderSaveError(w, fmt.Errorf("invalid private key PEM: no PEM block found")) + return + } + keyBody["private-key-format"] = pemTypeToKeyFormat(block.Type) + keyBody["cleartext-private-key"] = base64.StdEncoding.EncodeToString(block.Bytes) + if pubPEM == "" { + pubPEM = derivePublicKeyFromDER(block.Bytes, block.Type) + } + } + if pubPEM != "" { + if err := applyPublicKey(keyBody, pubPEM); err != nil { + renderSaveError(w, err) + return + } + } + + body := map[string]any{"ietf-keystore:asymmetric-key": []map[string]any{keyBody}} + path := keystorePath + "/asymmetric-keys/asymmetric-key=" + url.PathEscape(name) + if err := h.RC.Patch(r.Context(), path, body); err != nil { + log.Printf("configure keystore update asym %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSaved(w, "Key updated") +} + +// pemBlockType maps a YANG key-format identity string to the corresponding PEM block type. +func pemBlockType(format string) string { + switch shortFormat(format) { + case "ec": + return "EC PRIVATE KEY" + case "rsa": + return "RSA PRIVATE KEY" + case "subject-public-key-info": + return "PUBLIC KEY" + default: // one-asymmetric (PKCS#8) and anything else + return "PRIVATE KEY" + } +} + +// parsePEMBody decodes a PEM block and returns the DER content re-encoded as +// standard base64 (no line breaks), plus the PEM block type string. +func parsePEMBody(s string) (b64 string, pemType string, err error) { + block, _ := pem.Decode([]byte(s)) + if block == nil { + return "", "", fmt.Errorf("no PEM block found") + } + return base64.StdEncoding.EncodeToString(block.Bytes), block.Type, nil +} + +// pemTypeToKeyFormat maps a PEM block type to the appropriate ietf-crypto-types identity. +func pemTypeToKeyFormat(pemType string) string { + switch strings.ToUpper(pemType) { + case "EC PRIVATE KEY": + return "ietf-crypto-types:ec-private-key-format" + case "RSA PRIVATE KEY": + return "ietf-crypto-types:rsa-private-key-format" + case "PUBLIC KEY": + return "ietf-crypto-types:subject-public-key-info-format" + default: // "PRIVATE KEY" (PKCS#8 / one-asymmetric-key) and anything else + return "ietf-crypto-types:one-asymmetric-key-format" + } +} diff --git a/src/webui/internal/handlers/configure_routes.go b/src/webui/internal/handlers/configure_routes.go new file mode 100644 index 00000000..e1f679d0 --- /dev/null +++ b/src/webui/internal/handlers/configure_routes.go @@ -0,0 +1,309 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "context" + "fmt" + "html/template" + "log" + "net/http" + "strings" + + "infix/webui/internal/restconf" + "infix/webui/internal/schema" +) + +// ─── RESTCONF paths ────────────────────────────────────────────────────────── + +const ( + staticCPPSuffix = "/ietf-routing:routing/control-plane-protocols/control-plane-protocol=infix-routing%3Astatic,static" + staticRtSuffix = staticCPPSuffix + "/static-routes" + staticIPv4Suffix = staticRtSuffix + "/ietf-ipv4-unicast-routing:ipv4" + staticIPv6Suffix = staticRtSuffix + "/ietf-ipv6-unicast-routing:ipv6" +) + +// ─── RESTCONF JSON types ────────────────────────────────────────────────────── + +type staticCPPWrapper struct { + Routing struct { + CPPs struct { + CPP []staticCPPJSON `json:"control-plane-protocol"` + } `json:"control-plane-protocols"` + } `json:"ietf-routing:routing"` +} + +// staticCPPJSON mirrors control-plane-protocol. The ipv4/ipv6 containers are +// augmented into the child static-routes container, not directly into the CPP. +type staticCPPJSON struct { + Type string `json:"type"` + Name string `json:"name"` + StaticRoutes staticRoutesJSON `json:"static-routes"` +} + +type staticRoutesJSON struct { + IPv4 *staticIPJSON `json:"ietf-ipv4-unicast-routing:ipv4,omitempty"` + IPv6 *staticIPJSON `json:"ietf-ipv6-unicast-routing:ipv6,omitempty"` +} + +type staticIPJSON struct { + Routes []staticRouteJSON `json:"route"` +} + +type staticRouteJSON struct { + Prefix string `json:"destination-prefix"` + NextHop staticNextHopJSON `json:"next-hop"` +} + +type staticNextHopJSON struct { + Address string `json:"next-hop-address,omitempty"` + Interface string `json:"outgoing-interface,omitempty"` +} + +// ─── Template data ──────────────────────────────────────────────────────────── + +type cfgRouteEntry struct { + Prefix string + NextHop string + Interface string +} + +type cfgRoutesPageData struct { + PageData + Loading bool + IPv4Routes []cfgRouteEntry + IPv6Routes []cfgRouteEntry + Desc map[string]string + Error string +} + +// ─── Handler ───────────────────────────────────────────────────────────────── + +// ConfigureRoutesHandler serves the Configure > Routing page. +type ConfigureRoutesHandler struct { + Template *template.Template + RC restconf.Fetcher + Schema *schema.Cache +} + +// Overview renders the Configure > Routing page reading from the candidate. +// GET /configure/routes +func (h *ConfigureRoutesHandler) Overview(w http.ResponseWriter, r *http.Request) { + data := cfgRoutesPageData{ + PageData: newPageData(w, r, "configure-routes", "Routing"), + } + + mgr := h.Schema.Manager() + data.Loading = mgr == nil + if mgr != nil { + rt := "/ietf-routing:routing/control-plane-protocols/control-plane-protocol/static-routes/ietf-ipv4-unicast-routing:ipv4/route" + data.Desc = map[string]string{ + "prefix": schema.DescriptionOf(mgr, rt+"/destination-prefix"), + "nexthop": schema.DescriptionOf(mgr, rt+"/next-hop/next-hop-address"), + "interface": schema.DescriptionOf(mgr, rt+"/next-hop/outgoing-interface"), + } + } + + cpp, err := h.fetchStaticCPP(r.Context()) + if err != nil { + log.Printf("configure routes: %v", err) + data.Error = "Could not read static routes" + } + if len(cpp.Routing.CPPs.CPP) > 0 { + entry := cpp.Routing.CPPs.CPP[0] + if entry.StaticRoutes.IPv4 != nil { + for _, rt := range entry.StaticRoutes.IPv4.Routes { + data.IPv4Routes = append(data.IPv4Routes, cfgRouteEntry{ + Prefix: rt.Prefix, + NextHop: rt.NextHop.Address, + Interface: rt.NextHop.Interface, + }) + } + } + if entry.StaticRoutes.IPv6 != nil { + for _, rt := range entry.StaticRoutes.IPv6.Routes { + data.IPv6Routes = append(data.IPv6Routes, cfgRouteEntry{ + Prefix: rt.Prefix, + NextHop: rt.NextHop.Address, + Interface: rt.NextHop.Interface, + }) + } + } + } + + tmplName := "configure-routes.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// AddRoute adds a static route to the candidate datastore. +// POST /configure/routes +func (h *ConfigureRoutesHandler) AddRoute(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + prefix := strings.TrimSpace(r.FormValue("prefix")) + nexthop := strings.TrimSpace(r.FormValue("nexthop")) + iface := strings.TrimSpace(r.FormValue("interface")) + + ipKey, _, ok := familyKeys(r.FormValue("family")) + if !ok { + renderSaveError(w, fmt.Errorf("invalid address family")) + return + } + if prefix == "" { + renderSaveError(w, fmt.Errorf("destination prefix is required")) + return + } + if nexthop == "" && iface == "" { + renderSaveError(w, fmt.Errorf("next-hop address or outgoing interface is required")) + return + } + + nhMap := map[string]any{} + if nexthop != "" { + nhMap["next-hop-address"] = nexthop + } + if iface != "" { + nhMap["outgoing-interface"] = iface + } + + // PATCH at the routing root so the CPP is created if absent (merge semantics). + // ipv4/ipv6 containers live under the static-routes intermediate container. + body := map[string]any{ + "ietf-routing:routing": map[string]any{ + "control-plane-protocols": map[string]any{ + "control-plane-protocol": []map[string]any{{ + "type": "infix-routing:static", + "name": "static", + "static-routes": map[string]any{ + ipKey: map[string]any{ + "route": []map[string]any{{ + "destination-prefix": prefix, + "next-hop": nhMap, + }}, + }, + }, + }}, + }, + }, + } + if err := h.RC.Patch(r.Context(), candidatePath+"/ietf-routing:routing", body); err != nil { + log.Printf("configure routes add %q: %v", prefix, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Route added", "/configure/routes") +} + +// UpdateRoute replaces the next-hop of an existing static route. +// PUT /configure/routes +func (h *ConfigureRoutesHandler) UpdateRoute(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + prefix := strings.TrimSpace(r.FormValue("prefix")) + nexthop := strings.TrimSpace(r.FormValue("nexthop")) + iface := strings.TrimSpace(r.FormValue("interface")) + + _, suffix, ok := familyKeys(r.FormValue("family")) + if !ok || prefix == "" { + renderSaveError(w, fmt.Errorf("invalid address family or prefix")) + return + } + if nexthop == "" && iface == "" { + renderSaveError(w, fmt.Errorf("next-hop address or outgoing interface is required")) + return + } + + nhMap := map[string]any{} + if nexthop != "" { + nhMap["next-hop-address"] = nexthop + } + if iface != "" { + nhMap["outgoing-interface"] = iface + } + + var routeKey string + switch r.FormValue("family") { + case "ipv4": + routeKey = "ietf-ipv4-unicast-routing:route" + case "ipv6": + routeKey = "ietf-ipv6-unicast-routing:route" + } + + path := candidatePath + suffix + "/route=" + restconf.EscapeKey(prefix) + body := map[string]any{ + routeKey: []map[string]any{{ + "destination-prefix": prefix, + "next-hop": nhMap, + }}, + } + if err := h.RC.Put(r.Context(), path, body); err != nil { + log.Printf("configure routes update %q: %v", prefix, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Route updated", "/configure/routes") +} + +// DeleteRoute removes a static route from the candidate datastore. +// DELETE /configure/routes?family=ipv4&prefix=10.0.0.0/24 +func (h *ConfigureRoutesHandler) DeleteRoute(w http.ResponseWriter, r *http.Request) { + prefix := r.URL.Query().Get("prefix") + _, suffix, ok := familyKeys(r.URL.Query().Get("family")) + if !ok || prefix == "" { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + path := candidatePath + suffix + "/route=" + restconf.EscapeKey(prefix) + if err := h.RC.Delete(r.Context(), path); err != nil { + log.Printf("configure routes delete %q: %v", prefix, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Route deleted", "/configure/routes") +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +// familyKeys maps an address family string ("ipv4" or "ipv6") to the +// YANG module-qualified ipv4/ipv6 key and the RESTCONF path suffix for that +// family's route list. ok is false for unknown family values. +func familyKeys(family string) (ipKey, suffix string, ok bool) { + switch family { + case "ipv4": + return "ietf-ipv4-unicast-routing:ipv4", staticIPv4Suffix, true + case "ipv6": + return "ietf-ipv6-unicast-routing:ipv6", staticIPv6Suffix, true + } + return "", "", false +} + +// fetchStaticCPP reads the static control-plane-protocol entry from the +// candidate datastore, falling back to running on 404. +func (h *ConfigureRoutesHandler) fetchStaticCPP(ctx context.Context) (staticCPPWrapper, error) { + var cpp staticCPPWrapper + err := h.RC.Get(ctx, candidatePath+staticCPPSuffix, &cpp) + if err == nil { + return cpp, nil + } + if !restconf.IsNotFound(err) { + return cpp, err + } + runErr := h.RC.Get(ctx, "/data"+staticCPPSuffix, &cpp) + if runErr == nil || restconf.IsNotFound(runErr) { + // 404 on fallback = no static routes configured, not an error + return cpp, nil + } + return cpp, runErr +} diff --git a/src/webui/internal/handlers/configure_system.go b/src/webui/internal/handlers/configure_system.go new file mode 100644 index 00000000..6e1d0f62 --- /dev/null +++ b/src/webui/internal/handlers/configure_system.go @@ -0,0 +1,626 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "encoding/json" + "fmt" + "html/template" + "log" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + + "infix/webui/internal/restconf" + "infix/webui/internal/schema" +) + +// ─── RESTCONF JSON types (candidate datastore) ──────────────────────────────── + +type cfgSystemWrapper struct { + System cfgSystemJSON `json:"ietf-system:system"` +} + +type cfgSystemJSON struct { + Contact string `json:"contact"` + Hostname string `json:"hostname"` + Location string `json:"location"` + Clock cfgClockJSON `json:"clock"` + NTP cfgNTPJSON `json:"ntp"` + DNS cfgDNSJSON `json:"dns-resolver"` + MotdBanner []byte `json:"infix-system:motd-banner,omitempty"` + TextEditor string `json:"infix-system:text-editor,omitempty"` +} + +type cfgClockJSON struct { + TimezoneName string `json:"timezone-name"` +} + +type cfgNTPJSON struct { + Enabled bool `json:"enabled"` + Servers []cfgNTPServerJSON `json:"server"` +} + +type cfgNTPServerJSON struct { + Name string `json:"name"` + UDP cfgNTPUDPJSON `json:"udp"` + AssociationType string `json:"association-type,omitempty"` + IBurst bool `json:"iburst,omitempty"` + Prefer bool `json:"prefer,omitempty"` +} + +type cfgNTPUDPJSON struct { + Address string `json:"address"` + Port uint16 `json:"port,omitempty"` +} + +type cfgDNSJSON struct { + Search []string `json:"search"` + Servers []cfgDNSServerJSON `json:"server"` +} + +type cfgDNSServerJSON struct { + Name string `json:"name"` + UDPAndTCP cfgDNSAddrJSON `json:"udp-and-tcp"` +} + +type cfgDNSAddrJSON struct { + Address string `json:"address"` + Port uint16 `json:"port,omitempty"` +} + +// ─── Template data ──────────────────────────────────────────────────────────── + +type cfgSystemPageData struct { + PageData + Loading bool // true while YANG schema is still downloading + Error string + Hostname string + Contact string + Location string + Timezone string + CurrentDatetime string // device clock from system-state, empty when unavailable + MotdBanner string // decoded from YANG binary + TextEditor string // e.g. "infix-system:emacs" + + // Schema-enriched fields — only populated when Loading is false. + TextEditorOptions []schema.IdentityOption + TimezoneOptions []string // bare timezone names for select + Desc map[string]string // leaf name → YANG description +} + +type cfgNTPPageData struct { + PageData + Error string + NTP cfgNTPJSON + AssocTypes []string // association-type enum: server / pool / peer + Desc map[string]string // leaf name → YANG description (field-info ⓘ) +} + +type cfgDNSPageData struct { + PageData + Error string + DNS cfgDNSJSON + Desc map[string]string // leaf name → YANG description (field-info ⓘ) +} + +// ─── Handler ───────────────────────────────────────────────────────────────── + +// ConfigureSystemHandler serves the Configure > General, NTP Client, and +// DNS Client pages, all of which share the same candidate-datastore source. +type ConfigureSystemHandler struct { + Template *template.Template + NTPTemplate *template.Template + DNSTemplate *template.Template + RC restconf.Fetcher + Schema *schema.Cache +} + +const candidatePath = "/ds/ietf-datastores:candidate" + +// loadSystem reads /ietf-system:system from the candidate datastore, falling +// back to running when candidate is uninitialised. The returned errMsg is +// non-empty only for real errors that should surface to the user. +func (h *ConfigureSystemHandler) loadSystem(r *http.Request) (cfgSystemJSON, string) { + var raw cfgSystemWrapper + if err := h.RC.Get(r.Context(), candidatePath+"/ietf-system:system", &raw); err != nil { + if !restconf.IsNotFound(err) { + log.Printf("configure system: %v", err) + return cfgSystemJSON{}, "Could not read candidate configuration" + } + if fallErr := h.RC.Get(r.Context(), "/data/ietf-system:system", &raw); fallErr != nil && !restconf.IsNotFound(fallErr) { + log.Printf("configure system (running fallback): %v", fallErr) + return cfgSystemJSON{}, "Could not read system configuration" + } + } + return raw.System, "" +} + +// render swaps the root template for "content" on HTMX requests so a partial +// reply skips the base shell. pageName is the same key passed to newPageData +// (e.g. "configure-ntp") — the corresponding template file is "<pageName>.html". +func (h *ConfigureSystemHandler) render(w http.ResponseWriter, r *http.Request, tmpl *template.Template, pageName string, data any) { + tmplName := pageName + ".html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := tmpl.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// Overview renders the Configure > General page (identity, clock, preferences). +// GET /configure/system +func (h *ConfigureSystemHandler) Overview(w http.ResponseWriter, r *http.Request) { + data := cfgSystemPageData{ + PageData: newPageData(w, r, "configure-system", "General"), + } + + // Fetch candidate config and operational clock in parallel — they hit + // different RESTCONF resources and the round-trips are independent. + var ( + s cfgSystemJSON + errMsg string + clockResp struct { + SystemState struct { + Clock struct { + CurrentDatetime string `json:"current-datetime"` + } `json:"clock"` + } `json:"ietf-system:system-state"` + } + clockErr error + wg sync.WaitGroup + ) + wg.Add(2) + go func() { defer wg.Done(); s, errMsg = h.loadSystem(r) }() + go func() { + defer wg.Done() + clockErr = h.RC.Get(r.Context(), "/data/ietf-system:system-state/clock", &clockResp) + }() + wg.Wait() + + data.Error = errMsg + if errMsg == "" { + data.Hostname = s.Hostname + data.Contact = s.Contact + data.Location = s.Location + data.Timezone = s.Clock.TimezoneName + data.MotdBanner = string(s.MotdBanner) + data.TextEditor = s.TextEditor + } + + // Operational clock for the Date & Time card. Best-effort; the + // template renders "Device clock unavailable" when CurrentDatetime + // stays empty. Truncate the RFC 3339 offset; the Timezone row + // below gives the user the zone context. + if clockErr == nil { + dt := clockResp.SystemState.Clock.CurrentDatetime + if len(dt) > 19 { + dt = dt[:19] + } + data.CurrentDatetime = dt + } + + mgr := h.Schema.Manager() + data.Loading = mgr == nil + if mgr != nil { + const sys = "/ietf-system:system" + data.TextEditorOptions = schema.OptionsFor(mgr, sys+"/infix-system:text-editor") + if data.TextEditor == "" { + for _, opt := range data.TextEditorOptions { + if opt.IsDefault { + data.TextEditor = opt.Value + break + } + } + } + data.Desc = map[string]string{ + "hostname": schema.DescriptionOf(mgr, sys+"/hostname"), + "contact": schema.DescriptionOf(mgr, sys+"/contact"), + "location": schema.DescriptionOf(mgr, sys+"/location"), + "timezone": schema.DescriptionOf(mgr, sys+"/clock/timezone-name"), + "text-editor": schema.DescriptionOf(mgr, sys+"/infix-system:text-editor"), + "motd-banner": schema.DescriptionOf(mgr, sys+"/infix-system:motd-banner"), + } + for _, opt := range schema.OptionsFor(mgr, sys+"/clock/timezone-name") { + data.TimezoneOptions = append(data.TimezoneOptions, schema.StripModulePrefix(opt.Value)) + } + } + + h.render(w, r, h.Template, "configure-system", data) +} + +// OverviewNTP renders the Configure > NTP Client page. +// GET /configure/ntp +func (h *ConfigureSystemHandler) OverviewNTP(w http.ResponseWriter, r *http.Request) { + data := cfgNTPPageData{ + PageData: newPageData(w, r, "configure-ntp", "NTP Client"), + } + data.AssocTypes = []string{"server", "pool", "peer"} + if mgr := h.Schema.Manager(); mgr != nil { + srv := "/ietf-system:system/ntp/server" + data.Desc = map[string]string{ + "name": schema.DescriptionOf(mgr, srv+"/name"), + "address": schema.DescriptionOf(mgr, srv+"/udp/address"), + "port": schema.DescriptionOf(mgr, srv+"/udp/port"), + "assoc": schema.DescriptionOf(mgr, srv+"/association-type"), + "iburst": schema.DescriptionOf(mgr, srv+"/iburst"), + "prefer": schema.DescriptionOf(mgr, srv+"/prefer"), + } + } + s, errMsg := h.loadSystem(r) + data.Error = errMsg + if errMsg == "" { + data.NTP = s.NTP + } + h.render(w, r, h.NTPTemplate, "configure-ntp", data) +} + +// OverviewDNS renders the Configure > DNS Client page. +// GET /configure/dns +func (h *ConfigureSystemHandler) OverviewDNS(w http.ResponseWriter, r *http.Request) { + data := cfgDNSPageData{ + PageData: newPageData(w, r, "configure-dns", "DNS Client"), + } + if mgr := h.Schema.Manager(); mgr != nil { + dr := "/ietf-system:system/dns-resolver" + data.Desc = map[string]string{ + "search": schema.DescriptionOf(mgr, dr+"/search"), + "name": schema.DescriptionOf(mgr, dr+"/server/name"), + "address": schema.DescriptionOf(mgr, dr+"/server/udp-and-tcp/address"), + "port": schema.DescriptionOf(mgr, dr+"/server/udp-and-tcp/port"), + } + } + s, errMsg := h.loadSystem(r) + data.Error = errMsg + if errMsg == "" { + data.DNS = s.DNS + } + h.render(w, r, h.DNSTemplate, "configure-dns", data) +} + +// SaveIdentity patches hostname / contact / location to the candidate datastore. +// POST /configure/system/identity +func (h *ConfigureSystemHandler) SaveIdentity(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + body := map[string]any{ + "ietf-system:system": map[string]any{ + "hostname": r.FormValue("hostname"), + "contact": r.FormValue("contact"), + "location": r.FormValue("location"), + }, + } + if err := h.RC.Patch(r.Context(), candidatePath+"/ietf-system:system", body); err != nil { + log.Printf("configure system identity: %v", err) + renderSaveError(w, err) + return + } + renderSaved(w, "Identity saved") +} + +// SaveClock patches the timezone to the candidate datastore. +// POST /configure/system/clock +func (h *ConfigureSystemHandler) SaveClock(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + // Empty value = the "UTC (default)" placeholder. Treat it as a leaf + // delete so the candidate matches Infix's "unset means UTC" convention; + // swallow data-missing for idempotency (the leaf may already be absent). + if r.FormValue("timezone") == "" { + err := h.RC.Delete(r.Context(), candidatePath+"/ietf-system:system/clock/timezone-name") + if err != nil && !restconf.IsDataMissing(err) { + log.Printf("configure system clock: %v", err) + renderSaveError(w, err) + return + } + renderSaved(w, "Timezone saved") + return + } + + body := map[string]any{ + "ietf-system:system": map[string]any{ + "clock": map[string]any{ + "timezone-name": r.FormValue("timezone"), + }, + }, + } + if err := h.RC.Patch(r.Context(), candidatePath+"/ietf-system:system", body); err != nil { + log.Printf("configure system clock: %v", err) + renderSaveError(w, err) + return + } + renderSaved(w, "Timezone saved") +} + +// SaveNTP replaces the NTP server list in the candidate datastore. +// ntpCandPath is the NTP container in the candidate datastore. +const ntpCandPath = candidatePath + "/ietf-system:system/ietf-system:ntp" + +func ntpServerCandPath(name string) string { + return ntpCandPath + "/server=" + url.PathEscape(name) +} + +// ntpServerFromForm builds the RESTCONF body for one server, omitting the +// udp transport when no address is given (empty inet:host is invalid) — so a +// server can be added by name and have its address filled in via the +// expand-to-edit form afterwards. +func ntpServerFromForm(r *http.Request, name string) map[string]any { + srv := map[string]any{ + "name": name, + "association-type": defaultStr(r.FormValue("association-type"), "server"), + "iburst": r.FormValue("iburst") == "true", + "prefer": r.FormValue("prefer") == "true", + } + if addr := strings.TrimSpace(r.FormValue("address")); addr != "" { + udp := map[string]any{"address": addr} + if p, err := strconv.ParseUint(strings.TrimSpace(r.FormValue("port")), 10, 16); err == nil && p > 0 { + udp["port"] = p + } + srv["udp"] = udp + } + return srv +} + +func defaultStr(v, def string) string { + if strings.TrimSpace(v) == "" { + return def + } + return v +} + +// AddNTPServer adds an NTP server, creating + enabling the NTP container on +// first use, then re-renders the page. +// POST /configure/system/ntp/servers +func (h *ConfigureSystemHandler) AddNTPServer(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := strings.TrimSpace(r.FormValue("name")) + if name == "" { + renderSaveError(w, fmt.Errorf("server name is required")) + return + } + // PATCH the system (always present) with the NTP container nested: it is + // created + enabled on first use and the server merges into the list. + body := map[string]any{ + "ietf-system:system": map[string]any{ + "ietf-system:ntp": map[string]any{ + "enabled": true, + "server": []any{ntpServerFromForm(r, name)}, + }, + }, + } + if err := h.RC.Patch(r.Context(), candidatePath+"/ietf-system:system", body); err != nil { + log.Printf("configure ntp add server: %v", err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "NTP server added", "/configure/ntp") +} + +// SaveNTPServer replaces one NTP server's configuration (per-row edit). +// POST /configure/system/ntp/servers/{name} +func (h *ConfigureSystemHandler) SaveNTPServer(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := r.PathValue("name") + body := map[string]any{"ietf-system:server": []any{ntpServerFromForm(r, name)}} + if err := h.RC.Put(r.Context(), ntpServerCandPath(name), body); err != nil { + log.Printf("configure ntp save server %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSaved(w, "Server saved") +} + +// DeleteNTPServer removes an NTP server. +// DELETE /configure/system/ntp/servers/{name} +func (h *ConfigureSystemHandler) DeleteNTPServer(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if err := h.RC.Delete(r.Context(), ntpServerCandPath(name)); err != nil && !restconf.IsNotFound(err) { + log.Printf("configure ntp delete server %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "NTP server deleted", "/configure/ntp") +} + +// dnsCandPath is the DNS resolver container in the candidate datastore. +const dnsCandPath = candidatePath + "/ietf-system:system/ietf-system:dns-resolver" + +func dnsServerCandPath(name string) string { + return dnsCandPath + "/server=" + url.PathEscape(name) +} + +func dnsServerFromForm(r *http.Request, name string) map[string]any { + srv := map[string]any{"name": name} + if addr := strings.TrimSpace(r.FormValue("address")); addr != "" { + ut := map[string]any{"address": addr} + if p, err := strconv.ParseUint(strings.TrimSpace(r.FormValue("port")), 10, 16); err == nil && p > 0 { + ut["port"] = p + } + srv["udp-and-tcp"] = ut + } + return srv +} + +// AddDNSServer adds a DNS server, creating the resolver container on first +// use, then re-renders the page. +// POST /configure/system/dns/servers +func (h *ConfigureSystemHandler) AddDNSServer(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := strings.TrimSpace(r.FormValue("name")) + if name == "" { + renderSaveError(w, fmt.Errorf("server name is required")) + return + } + body := map[string]any{ + "ietf-system:system": map[string]any{ + "ietf-system:dns-resolver": map[string]any{"server": []any{dnsServerFromForm(r, name)}}, + }, + } + if err := h.RC.Patch(r.Context(), candidatePath+"/ietf-system:system", body); err != nil { + log.Printf("configure dns add server: %v", err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "DNS server added", "/configure/dns") +} + +// SaveDNSServer replaces one DNS server's configuration (per-row edit). +// POST /configure/system/dns/servers/{name} +func (h *ConfigureSystemHandler) SaveDNSServer(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := r.PathValue("name") + body := map[string]any{"ietf-system:server": []any{dnsServerFromForm(r, name)}} + if err := h.RC.Put(r.Context(), dnsServerCandPath(name), body); err != nil { + log.Printf("configure dns save server %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSaved(w, "Server saved") +} + +// DeleteDNSServer removes a DNS server. +// DELETE /configure/system/dns/servers/{name} +func (h *ConfigureSystemHandler) DeleteDNSServer(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if err := h.RC.Delete(r.Context(), dnsServerCandPath(name)); err != nil && !restconf.IsNotFound(err) { + log.Printf("configure dns delete server %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "DNS server deleted", "/configure/dns") +} + +// AddDNSSearch appends a search domain to the resolver's search leaf-list. +// POST /configure/system/dns/search +func (h *ConfigureSystemHandler) AddDNSSearch(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + domain := strings.TrimSpace(r.FormValue("domain")) + if domain == "" { + renderSaveError(w, fmt.Errorf("domain is required")) + return + } + // POST appends to the leaf-list; if the resolver container doesn't exist + // yet, create it with this domain via a merge on the system. + err := h.RC.PostJSON(r.Context(), dnsCandPath, map[string]any{"ietf-system:search": []any{domain}}) + if restconf.IsNotFound(err) { + err = h.RC.Patch(r.Context(), candidatePath+"/ietf-system:system", map[string]any{ + "ietf-system:system": map[string]any{ + "ietf-system:dns-resolver": map[string]any{"search": []any{domain}}, + }, + }) + } + if err != nil { + log.Printf("configure dns add search: %v", err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Search domain added", "/configure/dns") +} + +// DeleteDNSSearch removes a search domain. +// DELETE /configure/system/dns/search/{domain} +func (h *ConfigureSystemHandler) DeleteDNSSearch(w http.ResponseWriter, r *http.Request) { + domain := r.PathValue("domain") + path := dnsCandPath + "/search=" + url.PathEscape(domain) + if err := h.RC.Delete(r.Context(), path); err != nil && !restconf.IsNotFound(err) { + log.Printf("configure dns delete search %q: %v", domain, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Search domain removed", "/configure/dns") +} + +// ─── Form parsing helpers ───────────────────────────────────────────────────── + + +// SavePreferences patches infix-system augmented fields (motd-banner, text-editor). +// POST /configure/system/preferences +func (h *ConfigureSystemHandler) SavePreferences(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + sysPatch := map[string]any{} + if motd := r.FormValue("motd_banner"); motd != "" { + sysPatch["infix-system:motd-banner"] = []byte(motd) + } + if editor := r.FormValue("text_editor"); editor != "" { + sysPatch["infix-system:text-editor"] = editor + } + if len(sysPatch) == 0 { + renderSaved(w, "Preferences saved") + return + } + + body := map[string]any{"ietf-system:system": sysPatch} + if err := h.RC.Patch(r.Context(), candidatePath+"/ietf-system:system", body); err != nil { + log.Printf("configure system preferences: %v", err) + renderSaveError(w, err) + return + } + renderSaved(w, "Preferences saved") +} + +// ─── Response helpers ───────────────────────────────────────────────────────── + +// renderSaved writes a success indicator for HTMX to swap into the Save button. +func renderSaved(w http.ResponseWriter, msg string) { + w.Header().Set("Content-Type", "text/html") + w.Header().Set("HX-Trigger", `{"cfgSaved":"`+msg+`"}`) + w.WriteHeader(http.StatusOK) +} + +// renderSavedRedirect logs a cfgSaved activity entry and then navigates HTMX to +// the given page path (targeting #content). Use this instead of a bare HX-Location +// for Add/Delete operations that redirect back to the listing page after success. +func renderSavedRedirect(w http.ResponseWriter, msg, path string) { + b, _ := json.Marshal(msg) + w.Header().Set("HX-Trigger", `{"cfgSaved":`+string(b)+`}`) + w.Header().Set("HX-Location", `{"path":"`+path+`","target":"#content"}`) + w.WriteHeader(http.StatusNoContent) +} + +// renderSaveError writes an inline error for HTMX. HX-Trigger ensures forms with +// hx-swap="none" still receive the cfgError event (body swap alone would be silenced). +// RESTCONF errors surface their server-side Message; all other errors fall back to +// err.Error() so handler-level validation messages reach the user instead of being +// flattened to a generic "Save failed". +func renderSaveError(w http.ResponseWriter, err error) { + msg := "Save failed" + if re, ok := err.(*restconf.Error); ok && re.Message != "" { + msg = re.Message + } else if err != nil && err.Error() != "" { + msg = err.Error() + } + w.Header().Set("Content-Type", "text/html") + b, _ := json.Marshal(msg) + w.Header().Set("HX-Trigger", `{"cfgError":`+string(b)+`}`) + w.WriteHeader(http.StatusUnprocessableEntity) + w.Write([]byte(`<span class="cfg-save-error">` + template.HTMLEscapeString(msg) + `</span>`)) +} diff --git a/src/webui/internal/handlers/configure_users.go b/src/webui/internal/handlers/configure_users.go new file mode 100644 index 00000000..4d674576 --- /dev/null +++ b/src/webui/internal/handlers/configure_users.go @@ -0,0 +1,530 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "context" + "encoding/base64" + "fmt" + "html/template" + "log" + "net/http" + "net/url" + "strings" + + "infix/webui/internal/restconf" + "infix/webui/internal/schema" +) + +// ─── RESTCONF JSON types ────────────────────────────────────────────────────── + +// cfgAuthWrapper reads the authentication container via the full system path, +// which avoids sub-path encoding ambiguities and matches what configure-system uses. +type cfgAuthWrapper struct { + System struct { + Auth cfgAuthJSON `json:"authentication"` + } `json:"ietf-system:system"` +} + +type cfgAuthJSON struct { + Users []cfgUserJSON `json:"user"` +} + +type cfgUserJSON struct { + Name string `json:"name"` + Password string `json:"password,omitempty"` + Shell string `json:"infix-system:shell,omitempty"` + AuthorizedKeys []cfgKeyJSON `json:"authorized-key,omitempty"` +} + +type cfgKeyJSON struct { + Name string `json:"name"` + Algorithm string `json:"algorithm"` + KeyData []byte `json:"key-data"` +} + +// ─── Display helpers ────────────────────────────────────────────────────────── + +type cfgUserDisplay struct { + cfgUserJSON + ShellLabel string + KeyCount int +} + +type cfgGroupDisplay struct { + nacmGroupJSON + MembersSummary string + Available []string // users not currently in this group +} + +// ─── Template data ──────────────────────────────────────────────────────────── + +type cfgUsersPageData struct { + PageData + Loading bool + Users []cfgUserDisplay + Groups []cfgGroupDisplay + Error string + ShellOptions []schema.IdentityOption +} + +// ─── Handler ───────────────────────────────────────────────────────────────── + +// ConfigureUsersHandler serves the Configure > Users page. +type ConfigureUsersHandler struct { + Template *template.Template + RC restconf.Fetcher + Schema *schema.Cache +} + +const authPath = candidatePath + "/ietf-system:system/authentication" +const nacmGroupsPath = candidatePath + "/ietf-netconf-acm:nacm/groups" + +// Overview renders the Configure > Users page reading from the candidate. +// GET /configure/users +func (h *ConfigureUsersHandler) Overview(w http.ResponseWriter, r *http.Request) { + data := cfgUsersPageData{ + PageData: newPageData(w, r, "configure-users", "Users & Groups"), + } + + // Read via the full system path (same as configure-system) to avoid + // sub-path encoding issues. Fall back to running if candidate is empty. + sysPath := candidatePath + "/ietf-system:system" + var raw cfgAuthWrapper + if err := h.RC.Get(r.Context(), sysPath, &raw); err != nil { + if !restconf.IsNotFound(err) { + log.Printf("configure users: %v", err) + data.Error = "Could not read user configuration" + } else if fallErr := h.RC.Get(r.Context(), "/data/ietf-system:system", &raw); fallErr != nil && !restconf.IsNotFound(fallErr) { + // Candidate not initialised — fall back to running. + log.Printf("configure users (running fallback): %v", fallErr) + data.Error = "Could not read user configuration" + } + } + const shellPath = "/ietf-system:system/authentication/user/infix-system:shell" + mgr := h.Schema.Manager() + data.Loading = mgr == nil + if mgr != nil { + data.ShellOptions = schema.OptionsFor(mgr, shellPath) + } + for _, u := range raw.System.Auth.Users { + data.Users = append(data.Users, cfgUserDisplay{ + cfgUserJSON: u, + ShellLabel: schema.StripModulePrefix(u.Shell), + KeyCount: len(u.AuthorizedKeys), + }) + } + + groups, err := h.fetchAllGroups(r.Context()) + if err != nil { + log.Printf("configure users groups: %v", err) + } + allNames := make([]string, 0, len(data.Users)) + for _, u := range data.Users { + allNames = append(allNames, u.Name) + } + for _, g := range groups { + memberSet := make(map[string]bool, len(g.UserNames)) + for _, u := range g.UserNames { + memberSet[u] = true + } + avail := make([]string, 0) + for _, u := range allNames { + if !memberSet[u] { + avail = append(avail, u) + } + } + summary := strings.Join(g.UserNames, ", ") + if summary == "" { + summary = "(none)" + } + data.Groups = append(data.Groups, cfgGroupDisplay{ + nacmGroupJSON: g, + MembersSummary: summary, + Available: avail, + }) + } + + tmplName := "configure-users.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// AddUser creates a new user in the candidate datastore. +// POST /configure/users +func (h *ConfigureUsersHandler) AddUser(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + name := strings.TrimSpace(r.FormValue("username")) + password := r.FormValue("password") + shell := r.FormValue("shell") + if name == "" { + renderSaveError(w, fmt.Errorf("username is required")) + return + } + + hash, err := HashPassword(password) + if err != nil { + log.Printf("configure users add: hash: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + user := map[string]any{ + "ietf-system:user": []map[string]any{{ + "name": name, + "password": hash, + "infix-system:shell": shell, + }}, + } + path := authPath + "/user=" + url.PathEscape(name) + if err := h.RC.Put(r.Context(), path, user); err != nil { + log.Printf("configure users add %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "User added", "/configure/users") +} + +// DeleteUser removes a user from the candidate datastore. +// DELETE /configure/users/{name} +func (h *ConfigureUsersHandler) DeleteUser(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + path := authPath + "/user=" + url.PathEscape(name) + if err := h.RC.Delete(r.Context(), path); err != nil { + log.Printf("configure users delete %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "User deleted", "/configure/users") +} + +// UpdateShell changes a user's login shell in the candidate datastore. +// POST /configure/users/{name}/shell +func (h *ConfigureUsersHandler) UpdateShell(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := r.PathValue("name") + shell := r.FormValue("shell") + body := map[string]any{ + "ietf-system:user": []map[string]any{{ + "name": name, + "infix-system:shell": shell, + }}, + } + path := authPath + "/user=" + url.PathEscape(name) + if err := h.RC.Patch(r.Context(), path, body); err != nil { + log.Printf("configure users shell %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSaved(w, "Shell updated") +} + +// ChangePassword sets a new hashed password for a user in the candidate. +// POST /configure/users/{name}/password +func (h *ConfigureUsersHandler) ChangePassword(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := r.PathValue("name") + password := r.FormValue("password") + if password == "" { + renderSaveError(w, fmt.Errorf("password cannot be empty")) + return + } + + hash, err := HashPassword(password) + if err != nil { + log.Printf("configure users password %q: hash: %v", name, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + body := map[string]any{ + "ietf-system:user": []map[string]any{{ + "name": name, + "password": hash, + }}, + } + path := authPath + "/user=" + url.PathEscape(name) + if err := h.RC.Patch(r.Context(), path, body); err != nil { + log.Printf("configure users password %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSaved(w, "Password changed") +} + +// AddKey adds an SSH authorized key for a user in the candidate. +// POST /configure/users/{name}/keys +func (h *ConfigureUsersHandler) AddKey(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := r.PathValue("name") + keyName := strings.TrimSpace(r.FormValue("key_name")) + keyLine := strings.TrimSpace(r.FormValue("key_data")) + + if keyName == "" || keyLine == "" { + renderSaveError(w, fmt.Errorf("key name and public key are required")) + return + } + + // Parse "algorithm base64data [comment]" from an OpenSSH public key line. + parts := strings.Fields(keyLine) + if len(parts) < 2 { + renderSaveError(w, fmt.Errorf("invalid SSH public key format")) + return + } + algorithm := parts[0] + keyBytes, err := base64.StdEncoding.DecodeString(parts[1]) + if err != nil { + renderSaveError(w, fmt.Errorf("invalid SSH key data: %w", err)) + return + } + + // PATCH at the system root so libyang has full ancestor-key context. + // Patching at user=admin or authorized-key leaves libyang without the + // parent list-key context and produces "List requires N keys" errors. + body := map[string]any{ + "ietf-system:system": map[string]any{ + "authentication": map[string]any{ + "user": []map[string]any{{ + "name": name, + "authorized-key": []map[string]any{{ + "name": keyName, + "algorithm": algorithm, + "key-data": keyBytes, + }}, + }}, + }, + }, + } + path := candidatePath + "/ietf-system:system" + if err := h.RC.Patch(r.Context(), path, body); err != nil { + log.Printf("configure users key add %q/%q: %v", name, keyName, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "SSH key added", "/configure/users") +} + +// DeleteKey removes an SSH authorized key for a user in the candidate. +// DELETE /configure/users/{name}/keys/{keyname} +// +// Direct DELETE to the authorized-key path fails when the key name contains +// characters like '@' that libyang interprets as module@revision syntax in path +// predicates. Work around by GET + filter + PUT at the user level instead. +func (h *ConfigureUsersHandler) DeleteKey(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + keyName := r.PathValue("keyname") + + sysPath := candidatePath + "/ietf-system:system" + var raw cfgAuthWrapper + if err := h.RC.Get(r.Context(), sysPath, &raw); err != nil { + log.Printf("configure users key delete %q/%q: GET: %v", name, keyName, err) + renderSaveError(w, err) + return + } + + var userEntry map[string]any + for _, u := range raw.System.Auth.Users { + if u.Name != name { + continue + } + filteredKeys := make([]map[string]any, 0, len(u.AuthorizedKeys)) + found := false + for _, k := range u.AuthorizedKeys { + if k.Name == keyName { + found = true + continue + } + filteredKeys = append(filteredKeys, map[string]any{ + "name": k.Name, + "algorithm": k.Algorithm, + "key-data": k.KeyData, + }) + } + if !found { + w.WriteHeader(http.StatusOK) + return + } + userEntry = map[string]any{ + "name": u.Name, + "authorized-key": filteredKeys, + } + if u.Password != "" { + userEntry["password"] = u.Password + } + if u.Shell != "" { + userEntry["infix-system:shell"] = u.Shell + } + break + } + if userEntry == nil { + w.WriteHeader(http.StatusOK) + return + } + + // PUT at the user level replaces only this user's entry (including its key + // list), which avoids the path-predicate Syntax error while not touching + // other users or system config. + putPath := authPath + "/user=" + url.PathEscape(name) + body := map[string]any{ + "ietf-system:user": []map[string]any{userEntry}, + } + if err := h.RC.Put(r.Context(), putPath, body); err != nil { + log.Printf("configure users key delete %q/%q: PUT: %v", name, keyName, err) + renderSaveError(w, err) + return + } + w.WriteHeader(http.StatusOK) +} + +// fetchAllGroups reads NACM groups from candidate, falling back to running on 404. +func (h *ConfigureUsersHandler) fetchAllGroups(ctx context.Context) ([]nacmGroupJSON, error) { + var raw nacmWrapper + if err := h.RC.Get(ctx, candidatePath+"/ietf-netconf-acm:nacm", &raw); err != nil { + if !restconf.IsNotFound(err) { + return nil, err + } + if err2 := h.RC.Get(ctx, "/data/ietf-netconf-acm:nacm", &raw); err2 != nil && !restconf.IsNotFound(err2) { + return nil, err2 + } + } + return raw.NACM.Groups.Group, nil +} + +// putGroupMembers overwrites the member list of a single NACM group. +func (h *ConfigureUsersHandler) putGroupMembers(ctx context.Context, groupName string, members []string) error { + body := map[string]any{ + "ietf-netconf-acm:group": []map[string]any{{ + "name": groupName, + "user-name": members, + }}, + } + path := nacmGroupsPath + "/group=" + url.PathEscape(groupName) + return h.RC.Put(ctx, path, body) +} + +// AddGroupMembers adds one or more users to a NACM group, moving them out of +// any previous group to maintain the one-group-per-user invariant. +// POST /configure/users/groups/{name}/members +func (h *ConfigureUsersHandler) AddGroupMembers(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + groupName := r.PathValue("name") + toAdd := r.Form["members"] + if len(toAdd) == 0 { + renderSavedRedirect(w, "No users selected", "/configure/users") + return + } + + groups, err := h.fetchAllGroups(r.Context()) + if err != nil { + renderSaveError(w, err) + return + } + + toAddSet := make(map[string]bool, len(toAdd)) + for _, u := range toAdd { + toAddSet[u] = true + } + + updates := make(map[string][]string) + var current []string + for _, g := range groups { + if g.Name == groupName { + current = g.UserNames + continue + } + changed := false + members := make([]string, 0, len(g.UserNames)) + for _, u := range g.UserNames { + if toAddSet[u] { + changed = true + continue + } + members = append(members, u) + } + if changed { + updates[g.Name] = members + } + } + + inTarget := make(map[string]bool, len(current)) + for _, u := range current { + inTarget[u] = true + } + newTarget := append([]string{}, current...) + for _, u := range toAdd { + if !inTarget[u] { + newTarget = append(newTarget, u) + } + } + updates[groupName] = newTarget + + for gName, members := range updates { + if err := h.putGroupMembers(r.Context(), gName, members); err != nil { + log.Printf("configure users groups add: write %q: %v", gName, err) + renderSaveError(w, err) + return + } + } + renderSavedRedirect(w, "Members updated", "/configure/users") +} + +// RemoveGroupMember removes a single user from a NACM group. +// DELETE /configure/users/groups/{name}/members/{user} +func (h *ConfigureUsersHandler) RemoveGroupMember(w http.ResponseWriter, r *http.Request) { + groupName := r.PathValue("name") + userName := r.PathValue("user") + + groups, err := h.fetchAllGroups(r.Context()) + if err != nil { + renderSaveError(w, err) + return + } + + newMembers := make([]string, 0) + found := false + for _, g := range groups { + if g.Name != groupName { + continue + } + for _, u := range g.UserNames { + if u == userName { + found = true + continue + } + newMembers = append(newMembers, u) + } + break + } + if !found { + w.WriteHeader(http.StatusOK) + return + } + + if err := h.putGroupMembers(r.Context(), groupName, newMembers); err != nil { + log.Printf("configure users groups remove %q/%q: %v", groupName, userName, err) + renderSaveError(w, err) + return + } + w.WriteHeader(http.StatusOK) +} diff --git a/src/webui/internal/handlers/containers.go b/src/webui/internal/handlers/containers.go new file mode 100644 index 00000000..14d55216 --- /dev/null +++ b/src/webui/internal/handlers/containers.go @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "context" + "fmt" + "html/template" + "log" + "net/http" + "net/url" + "sync" + + "infix/webui/internal/restconf" +) + +// containerJSON matches the RESTCONF JSON for a single container entry. +type containerJSON struct { + Name string `json:"name"` + Image string `json:"image"` + Running yangBool `json:"running"` + Status string `json:"status"` + Network struct { + Publish []string `json:"publish"` + } `json:"network"` + ResourceUsage containerResourceUsageJSON `json:"resource-usage"` + ResourceLimit containerResourceLimitJSON `json:"resource-limit"` +} + +// containerResourceUsageJSON matches the RESTCONF JSON for resource-usage. +type containerResourceUsageJSON struct { + Memory yangInt64 `json:"memory"` // KiB + CPU yangFloat64 `json:"cpu"` // percent +} + +// containerResourceLimitJSON matches the RESTCONF JSON for resource-limit. +type containerResourceLimitJSON struct { + Memory yangInt64 `json:"memory"` // KiB +} + +// containerListWrapper wraps the top-level RESTCONF containers response. +// The server returns the full "containers" object; the list lives inside it. +type containerListWrapper struct { + Containers struct { + Container []containerJSON `json:"container"` + } `json:"infix-containers:containers"` +} + +// containerResourceUsageWrapper wraps the RESTCONF resource-usage response. +type containerResourceUsageWrapper struct { + ResourceUsage containerResourceUsageJSON `json:"infix-containers:resource-usage"` +} + +// ContainerEntry holds display-ready data for a single container row. +type ContainerEntry struct { + Name string + Image string + Status string + Running bool + CPUPct int + MemUsed string + MemLimit string + MemPct int + Uptime string + Ports []string +} + +// containersData is the template data for the containers page. +type containersData struct { + PageData + Containers []ContainerEntry + Error string +} + +// ContainersHandler serves the containers status page. +type ContainersHandler struct { + Template *template.Template + RC *restconf.Client +} + +// Overview renders the containers list page. +func (h *ContainersHandler) Overview(w http.ResponseWriter, r *http.Request) { + data := containersData{ + PageData: newPageData(w, r, "containers", "Containers"), + } + + // Detach from the request context so that RESTCONF calls survive + // browser connection resets. + ctx := context.WithoutCancel(r.Context()) + + var listResp containerListWrapper + if err := h.RC.Get(ctx, "/data/infix-containers:containers", &listResp); err != nil { + log.Printf("restconf containers list: %v", err) + data.Error = "Could not fetch container information" + } else { + containers := listResp.Containers.Container + + // Fetch resource-usage for each container concurrently. + usages := make([]containerResourceUsageJSON, len(containers)) + var mu sync.Mutex + var wg sync.WaitGroup + + for i, c := range containers { + wg.Add(1) + go func(idx int, name string) { + defer wg.Done() + path := fmt.Sprintf("/data/infix-containers:containers/container=%s/resource-usage", + url.PathEscape(name)) + var w containerResourceUsageWrapper + if err := h.RC.Get(ctx, path, &w); err != nil { + log.Printf("restconf resource-usage %s: %v", name, err) + return + } + mu.Lock() + usages[idx] = w.ResourceUsage + mu.Unlock() + }(i, c.Name) + } + wg.Wait() + + for i, c := range containers { + entry := ContainerEntry{ + Name: c.Name, + Image: c.Image, + Status: c.Status, + Running: bool(c.Running), + Ports: c.Network.Publish, + } + + // CPU usage — round to int. + entry.CPUPct = int(float64(usages[i].CPU) + 0.5) + if entry.CPUPct > 100 { + entry.CPUPct = 100 + } + + // Memory usage — resource-usage.memory is in KiB. + memUsedKiB := int64(usages[i].Memory) + if memUsedKiB > 0 { + entry.MemUsed = humanBytes(memUsedKiB * 1024) + } + + // Memory limit — resource-limit.memory is in KiB. + memLimitKiB := int64(c.ResourceLimit.Memory) + if memLimitKiB > 0 { + entry.MemLimit = humanBytes(memLimitKiB * 1024) + if memUsedKiB > 0 { + entry.MemPct = int(float64(memUsedKiB) / float64(memLimitKiB) * 100) + if entry.MemPct > 100 { + entry.MemPct = 100 + } + } + } + + // Uptime: extract from status string (e.g., "Up About a minute", "Up 3 hours"). + entry.Uptime = extractUptime(c.Status) + + data.Containers = append(data.Containers, entry) + } + } + + tmplName := "containers.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// extractUptime returns the uptime portion of a container status string. +// E.g., "Up About a minute" → "About a minute", "Up 3 hours" → "3 hours", +// "Exited (0) 2 hours ago" → "". +func extractUptime(status string) string { + const prefix = "Up " + if len(status) > len(prefix) && status[:len(prefix)] == prefix { + return status[len(prefix):] + } + return "" +} diff --git a/src/webui/internal/handlers/containers_test.go b/src/webui/internal/handlers/containers_test.go new file mode 100644 index 00000000..ce910e8e --- /dev/null +++ b/src/webui/internal/handlers/containers_test.go @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "html/template" + "net/http" + "net/http/httptest" + "testing" + + "infix/webui/internal/restconf" + "infix/webui/internal/security" +) + +var minimalContainersTmpl = template.Must(template.New("containers.html").Parse( + `{{define "containers.html"}}count={{len .Containers}}{{end}}` + + `{{define "content"}}{{len .Containers}}{{end}}`, +)) + +func TestContainersOverview_ReturnsOK(t *testing.T) { + rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false) + h := &ContainersHandler{Template: minimalContainersTmpl, RC: rc} + + req := httptest.NewRequest(http.MethodGet, "/containers", nil) + ctx := restconf.ContextWithCredentials(req.Context(), restconf.Credentials{ + Username: "admin", + Password: "admin", + }) + ctx = security.WithToken(ctx, "test-csrf-token") + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + h.Overview(w, req) + + if w.Code != http.StatusOK { + t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String()) + } + + body := w.Body.String() + if body == "" { + t.Error("expected non-empty response body") + } +} + +func TestContainersOverview_HTMXPartial(t *testing.T) { + rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false) + h := &ContainersHandler{Template: minimalContainersTmpl, RC: rc} + + req := httptest.NewRequest(http.MethodGet, "/containers", nil) + req.Header.Set("HX-Request", "true") + ctx := restconf.ContextWithCredentials(req.Context(), restconf.Credentials{ + Username: "admin", + Password: "admin", + }) + ctx = security.WithToken(ctx, "test-csrf-token") + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + h.Overview(w, req) + + if w.Code != http.StatusOK { + t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String()) + } + + body := w.Body.String() + if body == "" { + t.Error("expected non-empty response body for htmx partial") + } +} diff --git a/src/webui/internal/handlers/crypt.go b/src/webui/internal/handlers/crypt.go new file mode 100644 index 00000000..430ac96a --- /dev/null +++ b/src/webui/internal/handlers/crypt.go @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "fmt" + "os/exec" + "strings" +) + +// HashPassword returns a yescrypt crypt hash of password using mkpasswd(1). +// mkpasswd is available on Infix target systems and uses the system's libcrypt, +// so the output matches whatever the device expects (default: yescrypt $y$). +func HashPassword(password string) (string, error) { + path, err := exec.LookPath("mkpasswd") + if err != nil { + return "", fmt.Errorf("mkpasswd not found: %w", err) + } + cmd := exec.Command(path, "--method=yescrypt", "--password-fd=0") + cmd.Stdin = strings.NewReader(password) + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("mkpasswd: %w", err) + } + return strings.TrimSpace(string(out)), nil +} diff --git a/src/webui/internal/handlers/dashboard.go b/src/webui/internal/handlers/dashboard.go new file mode 100644 index 00000000..5c1c0e08 --- /dev/null +++ b/src/webui/internal/handlers/dashboard.go @@ -0,0 +1,806 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "html/template" + "log" + "math" + "net" + "net/http" + "os/exec" + "strconv" + "strings" + "sync" + "time" + + "infix/webui/internal/restconf" +) + +// yangInt64 unmarshals a YANG numeric value that RESTCONF encodes as a +// JSON string (e.g. "1024000") or, occasionally, as a bare number. +type yangInt64 int64 + +func (y *yangInt64) UnmarshalJSON(b []byte) error { + var s string + if json.Unmarshal(b, &s) == nil { + v, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return err + } + *y = yangInt64(v) + return nil + } + var v int64 + if err := json.Unmarshal(b, &v); err != nil { + return err + } + *y = yangInt64(v) + return nil +} + +// yangBool unmarshals a YANG boolean that RESTCONF may encode as a +// JSON string ("true"/"false") or as a bare boolean. +type yangBool bool + +func (y *yangBool) UnmarshalJSON(b []byte) error { + var s string + if json.Unmarshal(b, &s) == nil { + v, err := strconv.ParseBool(s) + if err != nil { + return err + } + *y = yangBool(v) + return nil + } + var v bool + if err := json.Unmarshal(b, &v); err != nil { + return err + } + *y = yangBool(v) + return nil +} + +// yangFloat64 unmarshals a YANG decimal value that RESTCONF may encode +// as a JSON string (e.g. "0.12") or as a bare number. +type yangFloat64 float64 + +func (y *yangFloat64) UnmarshalJSON(b []byte) error { + var s string + if json.Unmarshal(b, &s) == nil { + v, err := strconv.ParseFloat(s, 64) + if err != nil { + return err + } + *y = yangFloat64(v) + return nil + } + var v float64 + if err := json.Unmarshal(b, &v); err != nil { + return err + } + *y = yangFloat64(v) + return nil +} + +// RESTCONF JSON structures for ietf-system:system-state. + +type systemStateWrapper struct { + SystemState systemState `json:"ietf-system:system-state"` +} + +type systemState struct { + Platform platform `json:"platform"` + Clock clock `json:"clock"` + Software software `json:"infix-system:software"` + Resource resourceUsage `json:"infix-system:resource-usage"` + DNS *dnsResolver `json:"infix-system:dns-resolver"` + NTP *ntpSources `json:"infix-system:ntp"` +} + +// dnsResolver mirrors the effective resolver state — servers carry an +// origin (static vs dhcp) and, for dhcp, the interface they arrived on. +type dnsResolver struct { + Server []dnsServer `json:"server"` + Search []string `json:"search"` +} + +type dnsServer struct { + Address string `json:"address"` + Origin string `json:"origin"` + Interface string `json:"interface"` +} + +type ntpSources struct { + Sources struct { + Source []ntpSource `json:"source"` + } `json:"sources"` +} + +type ntpSource struct { + Address string `json:"address"` + State string `json:"state"` +} + +type platform struct { + OSName string `json:"os-name"` + OSVersion string `json:"os-version"` + Machine string `json:"machine"` +} + +type clock struct { + BootDatetime string `json:"boot-datetime"` + CurrentDatetime string `json:"current-datetime"` +} + +type software struct { + Booted string `json:"booted"` + Slot []softwareSlot `json:"slot"` +} + +type softwareSlot struct { + Name string `json:"name"` + Version string `json:"version"` +} + +type resourceUsage struct { + Memory memoryInfo `json:"memory"` + LoadAverage loadAverage `json:"load-average"` + Filesystem []filesystemFS `json:"filesystem"` +} + +type memoryInfo struct { + Total yangInt64 `json:"total"` + Free yangInt64 `json:"free"` + Available yangInt64 `json:"available"` +} + +type loadAverage struct { + Load1min yangFloat64 `json:"load-1min"` + Load5min yangFloat64 `json:"load-5min"` + Load15min yangFloat64 `json:"load-15min"` +} + +type filesystemFS struct { + MountPoint string `json:"mount-point"` + Size yangInt64 `json:"size"` + Used yangInt64 `json:"used"` + Available yangInt64 `json:"available"` +} + +// RESTCONF JSON structures for ietf-hardware:hardware. + +// Short forms of hardware-class identities — see shortClass(). Kept here so +// the dashboard and Status > Hardware handlers route the same way. +const ( + classChassis = "chassis" // ietf-hardware:chassis + classUSB = "usb" // infix-hardware:usb + classWiFi = "wifi" // infix-hardware:wifi + classGPS = "gps" // infix-hardware:gps +) + +// sensorStatusOK matches the ietf-hardware sensor-status "ok" enum value. +const sensorStatusOK = "ok" + +// admin-state values for configurable ietf-hardware components. The infix +// deviation in infix-hardware.yang restricts the base type to these two. +const ( + adminStateLocked = "locked" + adminStateUnlocked = "unlocked" +) + +type hardwareWrapper struct { + Hardware struct { + Component []hwComponentJSON `json:"component"` + } `json:"ietf-hardware:hardware"` +} + +type hwComponentJSON struct { + Name string `json:"name"` + Class string `json:"class"` + Description string `json:"description"` + Parent string `json:"parent"` + MfgName string `json:"mfg-name"` + ModelName string `json:"model-name"` + SerialNum string `json:"serial-num"` + HardwareRev string `json:"hardware-rev"` + PhysAddress string `json:"infix-hardware:phys-address"` + WiFiRadio *wifiRadioHWJSON `json:"infix-hardware:wifi-radio"` + GPSReceiver *struct{} `json:"infix-hardware:gps-receiver"` + SensorData *struct { + ValueType string `json:"value-type"` + Value yangInt64 `json:"value"` + ValueScale string `json:"value-scale"` + OperStatus string `json:"oper-status"` + } `json:"sensor-data"` + State *struct { + AdminState string `json:"admin-state"` + OperState string `json:"oper-state"` + } `json:"state"` +} + +// Template data structures. + +type dashboardData struct { + PageData + Hostname string + Contact string + Location string + OSName string + OSVersion string + Machine string + CurrentTime string + Software string + Uptime string + MemTotal int64 + MemUsed int64 + MemPercent int + MemClass string + Load1 string + Load5 string + Load15 string + CPUClass string + Disks []diskEntry + Board boardInfo + KeyVitals []sensorEntry // Overview's at-a-glance subset: CPU/SoC + wifi-radio temperatures and fan RPMs. Status > Hardware has the full inventory. + // Connectivity card. + Gateways []gatewayEntry + InternetProbe string // address pinged for the Internet reachability row + DNSServers []dnsServer + DNSSearch []string + NTPSync string // "" / the selected NTP source address + // Addresses card. + Addresses []ifaceAddrEntry + Error string +} + +// gatewayEntry is a default route's next-hop. +type gatewayEntry struct { + Addr string + Iface string +} + +// ifaceAddrEntry is one L3 interface's addresses for the Addresses card. +type ifaceAddrEntry struct { + Name string + Addrs []string + Up bool +} + +type boardInfo struct { + Model string + Manufacturer string + SerialNum string + HardwareRev string + BaseMAC string +} + +type sensorEntry struct { + Name string + Value string + Type string // "temperature", "fan", "voltage", etc. +} + +type wifiEntry struct { + Name string + Manufacturer string + Bands string // all supported bands, e.g. "2.4 GHz, 5 GHz" + Standards string + MaxAP int +} + +type diskEntry struct { + Mount string + Size string + Available string + Percent int + Class string // "" / "is-warn" / "is-crit" + ReadOnly bool +} + +// internetProbe is the address the Connectivity card pings for its Internet +// reachability row — a well-known, stable anycast resolver. +const internetProbe = "1.1.1.1" + +// DashboardHandler serves the main dashboard page. +type DashboardHandler struct { + Template *template.Template + RC *restconf.Client +} + +// Index renders the dashboard (GET /). +func (h *DashboardHandler) Index(w http.ResponseWriter, r *http.Request) { + data := dashboardData{ + PageData: newPageData(w, r, "dashboard", "Overview"), + } + + // Detach from the request context so that RESTCONF calls survive + // browser connection resets (common during login redirects). + // The RESTCONF client's own 10 s timeout still bounds each call. + ctx := context.WithoutCancel(r.Context()) + var ( + state systemStateWrapper + hw hardwareWrapper + sysConf struct { + System struct { + Hostname string `json:"hostname"` + Contact string `json:"contact"` + Location string `json:"location"` + } `json:"ietf-system:system"` + } + ifaces interfacesWrapper + routes ribWrapper + stateErr, hwErr, confErr error + wg sync.WaitGroup + ) + + wg.Add(5) + go func() { + defer wg.Done() + stateErr = h.RC.Get(ctx, "/data/ietf-system:system-state", &state) + }() + go func() { + defer wg.Done() + hwErr = h.RC.Get(ctx, "/data/ietf-hardware:hardware", &hw) + }() + go func() { + defer wg.Done() + confErr = h.RC.Get(ctx, "/data/ietf-system:system", &sysConf) + }() + // Connectivity/Addresses cards are best-effort: a failure here logs but + // doesn't fault the whole dashboard, so the card simply renders empty. + go func() { + defer wg.Done() + if err := h.RC.Get(ctx, "/data/ietf-interfaces:interfaces", &ifaces); err != nil { + log.Printf("restconf interfaces: %v", err) + } + }() + go func() { + defer wg.Done() + if err := h.RC.Get(ctx, "/data/ietf-routing:routing", &routes); err != nil { + log.Printf("restconf routing: %v", err) + } + }() + wg.Wait() + + if stateErr != nil { + log.Printf("restconf system-state: %v", stateErr) + data.Error = "Could not fetch system information — retrying…" + // Post-upgrade / fresh-boot race: yanger or sysrepo not ready + // yet. Schedule a page-level meta-refresh so the dashboard + // self-recovers instead of stranding the user on a stale + // error banner. + data.RetryAfter = 5 + } else { + ss := state.SystemState + data.OSName = ss.Platform.OSName + data.OSVersion = ss.Platform.OSVersion + data.Machine = ss.Platform.Machine + if data.Machine == "arm64" { + data.Machine = "aarch64" + } + data.Software = softwareVersion(ss.Software) + data.Uptime = computeUptime(ss.Clock.BootDatetime, ss.Clock.CurrentDatetime) + data.CurrentTime = formatCurrentTime(ss.Clock.CurrentDatetime) + + total := int64(ss.Resource.Memory.Total) + avail := int64(ss.Resource.Memory.Available) + data.MemTotal = total / 1024 // KiB → MiB + data.MemUsed = (total - avail) / 1024 + if total > 0 { + data.MemPercent = int(float64(total-avail) / float64(total) * 100) + } + + switch { + case data.MemPercent >= 90: + data.MemClass = "is-crit" + case data.MemPercent >= 70: + data.MemClass = "is-warn" + default: + data.MemClass = "" + } + + la := ss.Resource.LoadAverage + if la1 := float64(la.Load1min); la1 >= 0.9 { + data.CPUClass = "is-crit" + } else if la1 >= 0.7 { + data.CPUClass = "is-warn" + } + + data.Load1 = strconv.FormatFloat(float64(la.Load1min), 'f', 2, 64) + data.Load5 = strconv.FormatFloat(float64(la.Load5min), 'f', 2, 64) + data.Load15 = strconv.FormatFloat(float64(la.Load15min), 'f', 2, 64) + + for _, fs := range ss.Resource.Filesystem { + size := int64(fs.Size) + used := int64(fs.Used) + avail := int64(fs.Available) + pct := 0 + if size > 0 { + pct = int(float64(used) / float64(size) * 100) + } + // Read-only signature: used == size, no slack at all. + // Squashfs/erofs rootfs reports this — pinning it at 100 % + // for the lifetime of the running image, with nothing the + // operator can do about it. Skip the crit/warn coloring so + // it doesn't read as an actionable alert. + readOnly := size > 0 && used == size && avail == 0 + diskClass := "" + if !readOnly { + switch { + case pct >= 90: + diskClass = "is-crit" + case pct >= 70: + diskClass = "is-warn" + } + } + data.Disks = append(data.Disks, diskEntry{ + Mount: fs.MountPoint, + Size: humanKiB(size), + Available: humanKiB(avail), + Percent: pct, + Class: diskClass, + ReadOnly: readOnly, + }) + } + } + + if hwErr != nil { + log.Printf("restconf hardware: %v", hwErr) + } else { + // Two passes: first build a name → class map so keyVital can tell + // whether a celsius sensor lives on a wifi component (and thus + // belongs in Key Vitals). + classByName := make(map[string]string, len(hw.Hardware.Component)) + for _, c := range hw.Hardware.Component { + classByName[c.Name] = shortClass(c.Class) + } + for _, c := range hw.Hardware.Component { + if shortClass(c.Class) == classChassis { + data.Board = boardInfo{ + Model: c.ModelName, + Manufacturer: c.MfgName, + SerialNum: c.SerialNum, + HardwareRev: c.HardwareRev, + BaseMAC: c.PhysAddress, + } + } + if v, ok := keyVital(c, classByName); ok { + data.KeyVitals = append(data.KeyVitals, v) + } + } + } + + if confErr != nil { + log.Printf("restconf system config: %v", confErr) + } else { + data.Hostname = sysConf.System.Hostname + data.Contact = sysConf.System.Contact + data.Location = sysConf.System.Location + } + + // Connectivity & Addresses cards (best-effort, independent of the above). + data.Gateways = defaultGateways(routes) + data.InternetProbe = internetProbe + if stateErr == nil { + if dns := state.SystemState.DNS; dns != nil { + data.DNSServers = dns.Server + data.DNSSearch = dns.Search + } + if ntp := state.SystemState.NTP; ntp != nil { + for _, s := range ntp.Sources.Source { + if s.State == "selected" { + data.NTPSync = s.Address + break + } + } + } + } + data.Addresses = ifaceAddresses(ifaces) + + tmplName := "dashboard.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// defaultGateways extracts the installed default-route next-hops (v4 and v6). +func defaultGateways(rw ribWrapper) []gatewayEntry { + var ribs []ribJSON + if rw.Routing != nil { + ribs = rw.Routing.Ribs.Rib + } else if rw.Ribs != nil { + ribs = rw.Ribs.Rib + } + var gws []gatewayEntry + for _, rib := range ribs { + for _, rt := range rib.Routes.Route { + if rt.Active == nil { + continue // only installed routes + } + switch rt.destinationPrefix() { + case "0.0.0.0/0", "::/0": + default: + continue + } + iface, addr := rt.NextHop.resolve() + if addr == "" { + continue + } + gws = append(gws, gatewayEntry{Addr: addr, Iface: iface}) + } + } + return gws +} + +// ifaceAddresses lists every interface carrying an IP address, loopback last. +func ifaceAddresses(iw interfacesWrapper) []ifaceAddrEntry { + var l3, lo []ifaceAddrEntry + for _, ifc := range iw.Interfaces.Interface { + var addrs []string + for _, ip := range []*ipCfg{ifc.IPv4, ifc.IPv6} { + if ip == nil { + continue + } + for _, a := range ip.Address { + addrs = append(addrs, fmt.Sprintf("%s/%d", a.IP, int(a.PrefixLength))) + } + } + if len(addrs) == 0 { + continue + } + e := ifaceAddrEntry{Name: ifc.Name, Addrs: addrs, Up: ifc.OperStatus == "up"} + if prettyIfType(ifc.Type) == ifTypeLoopback { + lo = append(lo, e) + } else { + l3 = append(l3, e) + } + } + return append(l3, lo...) +} + +// Reachability pings an address and returns a tiny indicator — a pulsing green +// dot when it replies, a red ✗ otherwise. Connectivity-card slots load it +// async (hx-trigger="load") so the dashboard render isn't blocked on a probe. +// The target is validated as a literal IP so the ping argument can never become +// an arbitrary host; a link-local IPv6 is scoped with its egress interface. +func (h *DashboardHandler) Reachability(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + ip := r.URL.Query().Get("ip") + addr := net.ParseIP(ip) + if addr == nil { + fmt.Fprint(w, `<span class="status-dot reach-pending" title="unknown"></span>`) + return + } + target := ip + if iface := r.URL.Query().Get("iface"); iface != "" && addr.IsLinkLocalUnicast() && validZone(iface) { + target = ip + "%" + iface + } + + ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) + defer cancel() + if err := exec.CommandContext(ctx, "ping", "-c", "1", "-W", "1", target).Run(); err != nil { + fmt.Fprintf(w, `<span class="reach-x" title="No reply from %s">✗</span>`, + template.HTMLEscapeString(ip)) + return + } + fmt.Fprintf(w, `<span class="status-dot status-up reach-pulse" title="%s replied"></span>`, + template.HTMLEscapeString(ip)) +} + +// validZone guards the IPv6 zone (interface name) passed to ping: a plain +// interface name, no shell metacharacters (exec args aren't shell-parsed, but +// keep it tight). +func validZone(s string) bool { + for _, c := range s { + ok := c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || + c >= '0' && c <= '9' || c == '.' || c == '_' || c == '-' + if !ok { + return false + } + } + return s != "" +} + +// softwareVersion returns the version string for the booted software slot. +func softwareVersion(sw software) string { + for _, slot := range sw.Slot { + if slot.Name == sw.Booted { + return slot.Version + } + } + return "" +} + +// computeUptime returns a human-readable uptime string from RFC3339 timestamps. +func computeUptime(boot, now string) string { + bootT, err := time.Parse(time.RFC3339, boot) + if err != nil { + return "" + } + nowT, err := time.Parse(time.RFC3339, now) + if err != nil { + nowT = time.Now() + } + + d := nowT.Sub(bootT) + days := int(d.Hours()) / 24 + hours := int(d.Hours()) % 24 + mins := int(d.Minutes()) % 60 + + switch { + case days > 0: + return fmt.Sprintf("%dd %dh %dm", days, hours, mins) + case hours > 0: + return fmt.Sprintf("%dh %dm", hours, mins) + default: + return fmt.Sprintf("%dm", mins) + } +} + +// formatCurrentTime formats an RFC3339 timestamp as "2006-01-02 15:04:05 +00:00". +func formatCurrentTime(s string) string { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return "" + } + return t.UTC().Format("2006-01-02 15:04:05 +00:00") +} + +// keyVital picks the dashboard's "Key Vitals" rows out of the hardware +// component stream — the small at-a-glance subset for the Overview page: +// CPU/SoC/core temperatures, wifi-radio temperatures, SFP temperatures, +// and any fan RPM. Everything else (board temps, voltages, per-port +// sensors, …) lives on Status > Hardware. classByName maps every +// component's Name → short class so we can identify a celsius sensor's +// parent without a second scan per call. +func keyVital(c hwComponentJSON, classByName map[string]string) (sensorEntry, bool) { + if c.SensorData == nil || c.SensorData.OperStatus != sensorStatusOK { + return sensorEntry{}, false + } + switch c.SensorData.ValueType { + case "celsius": + switch { + case c.Name == "cpu", c.Name == "soc", c.Name == "core": + // CPU / SoC / core temperatures. + case classByName[c.Parent] == classWiFi: + // WiFi radio temperatures whose sensor-data lives under + // the radio component as a child. + case strings.HasPrefix(c.Name, "radio"), + strings.HasPrefix(c.Name, "phy"): + // WiFi radio temperatures whose sensor-data is a + // standalone iana-hardware:sensor (yanger labels these + // radio0, phy0, … per its normaliser). The parent-class + // branch above won't catch them. + case strings.HasPrefix(c.Name, "sfp"): + // SFP module temperatures (per ietf_hardware.py + // normalisation, names canonicalise to sfp0, sfp1, ...). + default: + return sensorEntry{}, false + } + case "rpm": + // every fan qualifies + default: + return sensorEntry{}, false + } + return sensorEntry{ + Name: c.Name, + Value: formatSensor(c.SensorData.ValueType, int64(c.SensorData.Value), c.SensorData.ValueScale), + Type: c.SensorData.ValueType, + }, true +} + +// summarizeWiFiRadio collapses an ietf-hardware component's wifi-radio +// container into the row-shaped wifiEntry used by both the dashboard and +// the Status > Hardware page. Detail beyond this summary (per-channel, +// per-band capabilities) lives on the dedicated /wifi page. +func summarizeWiFiRadio(c hwComponentJSON) wifiEntry { + e := wifiEntry{Name: c.Name, Manufacturer: c.MfgName} + if c.WiFiRadio == nil { + return e + } + var bandNames []string + var ht, vht, he bool + for _, b := range c.WiFiRadio.Bands { + ht = ht || b.HTCapable + vht = vht || b.VHTCapable + he = he || b.HECapable + name := b.Name + if name == "" { + name = b.Band + } + if name != "" { + bandNames = append(bandNames, name) + } + } + if len(bandNames) == 0 && c.WiFiRadio.Band != "" { + bandNames = append(bandNames, c.WiFiRadio.Band) + } + e.Bands = strings.Join(bandNames, ", ") + var stds []string + if ht { + stds = append(stds, "11n") + } + if vht { + stds = append(stds, "11ac") + } + if he { + stds = append(stds, "11ax") + } + e.Standards = strings.Join(stds, "/") + if c.WiFiRadio.MaxInterfaces != nil { + e.MaxAP = c.WiFiRadio.MaxInterfaces.AP + } + return e +} + +// shortClass strips the YANG module prefix from a hardware class identity. +func shortClass(full string) string { + if i := strings.LastIndex(full, ":"); i >= 0 { + return full[i+1:] + } + return full +} + +// formatSensor converts a raw sensor value to a human-readable string, +// matching the formatting used by cli_pretty. +func formatSensor(valueType string, value int64, scale string) string { + v := float64(value) + switch scale { + case "milli": + v /= 1000 + case "micro": + v /= 1000000 + } + switch valueType { + case "celsius": + return fmt.Sprintf("%.1f\u00b0C", v) + case "rpm": + return fmt.Sprintf("%.0f RPM", v) + case "volts-DC": + return fmt.Sprintf("%.2f VDC", v) + case "amperes": + return fmt.Sprintf("%.2f A", v) + case "watts": + return fmt.Sprintf("%.2f W", v) + default: + return fmt.Sprintf("%.1f", v) + } +} + +// humanBytes converts bytes to a human-readable string (B, KiB, MiB, GiB, TiB). +func humanBytes(b int64) string { + v := float64(b) + for _, unit := range []string{"B", "KiB", "MiB", "GiB", "TiB"} { + if v < 1024 || unit == "TiB" { + if v == math.Trunc(v) { + return fmt.Sprintf("%.0f %s", v, unit) + } + return fmt.Sprintf("%.1f %s", v, unit) + } + v /= 1024 + } + return fmt.Sprintf("%.1f PiB", v) +} + +// humanKiB converts KiB to a human-readable string using binary (IEC) units. +func humanKiB(kib int64) string { + v := float64(kib) + for _, unit := range []string{"KiB", "MiB", "GiB", "TiB"} { + if v < 1024 || unit == "TiB" { + if v == math.Trunc(v) { + return fmt.Sprintf("%.0f %s", v, unit) + } + return fmt.Sprintf("%.1f %s", v, unit) + } + v /= 1024 + } + return fmt.Sprintf("%.1f PiB", v) +} diff --git a/src/webui/internal/handlers/dashboard_test.go b/src/webui/internal/handlers/dashboard_test.go new file mode 100644 index 00000000..152acb31 --- /dev/null +++ b/src/webui/internal/handlers/dashboard_test.go @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "html/template" + "net/http" + "net/http/httptest" + "testing" + + "infix/webui/internal/restconf" + "infix/webui/internal/security" +) + +var minimalDashTmpl = template.Must(template.New("dashboard.html").Parse( + `{{define "dashboard.html"}}hostname={{.Hostname}} error={{.Error}}{{end}}` + + `{{define "content"}}{{.Hostname}}{{end}}`, +)) + +func TestDashboardIndex_ReturnsOK(t *testing.T) { + rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false) + h := &DashboardHandler{Template: minimalDashTmpl, RC: rc} + + req := httptest.NewRequest(http.MethodGet, "/", nil) + ctx := restconf.ContextWithCredentials(req.Context(), restconf.Credentials{ + Username: "testuser", + Password: "testpass", + }) + ctx = security.WithToken(ctx, "test-csrf-token") + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + h.Index(w, req) + + if w.Code != http.StatusOK { + t.Errorf("want 200 got %d", w.Code) + } +} + +func TestDashboardIndex_ShowsErrorOnRESTCONFFailure(t *testing.T) { + rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false) + h := &DashboardHandler{Template: minimalDashTmpl, RC: rc} + + req := httptest.NewRequest(http.MethodGet, "/", nil) + ctx := restconf.ContextWithCredentials(req.Context(), restconf.Credentials{ + Username: "admin", + Password: "admin", + }) + ctx = security.WithToken(ctx, "tok") + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + h.Index(w, req) + + if w.Code != http.StatusOK { + t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String()) + } + + body := w.Body.String() + if body == "" { + t.Error("expected non-empty response body") + } +} + +func TestDashboardIndex_HTMXPartial(t *testing.T) { + rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false) + h := &DashboardHandler{Template: minimalDashTmpl, RC: rc} + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("HX-Request", "true") + ctx := restconf.ContextWithCredentials(req.Context(), restconf.Credentials{ + Username: "admin", + Password: "admin", + }) + ctx = security.WithToken(ctx, "tok") + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + h.Index(w, req) + + if w.Code != http.StatusOK { + t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String()) + } +} diff --git a/src/webui/internal/handlers/dhcp.go b/src/webui/internal/handlers/dhcp.go new file mode 100644 index 00000000..c4c525c4 --- /dev/null +++ b/src/webui/internal/handlers/dhcp.go @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "context" + "fmt" + "html/template" + "log" + "net/http" + "time" + + "infix/webui/internal/restconf" +) + +// ─── DHCP types ────────────────────────────────────────────────────────────── + +// DHCPLease is a single active DHCP lease. +type DHCPLease struct { + Address string + MAC string + Hostname string + Expires string // relative or "never" + ClientID string +} + +// DHCPStats holds DHCP packet counters. +type DHCPStats struct { + InDiscoveries int64 + InRequests int64 + InReleases int64 + OutOffers int64 + OutAcks int64 + OutNaks int64 +} + +// DHCPData is the parsed DHCP server state. +type DHCPData struct { + Enabled bool + Leases []DHCPLease + Stats DHCPStats +} + +// ─── Page data ─────────────────────────────────────────────────────────────── + +type dhcpPageData struct { + PageData + DHCP *DHCPData + Error string +} + +// ─── Handler ───────────────────────────────────────────────────────────────── + +// DHCPHandler serves the DHCP status page. +type DHCPHandler struct { + Template *template.Template + RC *restconf.Client +} + +// Overview renders the DHCP page (GET /dhcp). +func (h *DHCPHandler) Overview(w http.ResponseWriter, r *http.Request) { + data := dhcpPageData{ + PageData: newPageData(w, r, "dhcp", "DHCP"), + } + + ctx := context.WithoutCancel(r.Context()) + + var raw struct { + DHCP struct { + Enabled yangBool `json:"enabled"` + Leases struct { + Lease []struct { + Address string `json:"address"` + PhysAddr string `json:"phys-address"` + Hostname string `json:"hostname"` + Expires string `json:"expires"` + ClientID string `json:"client-id"` + } `json:"lease"` + } `json:"leases"` + Statistics struct { + OutOffers yangInt64 `json:"out-offers"` + OutAcks yangInt64 `json:"out-acks"` + OutNaks yangInt64 `json:"out-naks"` + InDiscoveries yangInt64 `json:"in-discovers"` + InRequests yangInt64 `json:"in-requests"` + InReleases yangInt64 `json:"in-releases"` + } `json:"statistics"` + } `json:"infix-dhcp-server:dhcp-server"` + } + if err := h.RC.Get(ctx, "/data/infix-dhcp-server:dhcp-server", &raw); err != nil { + log.Printf("restconf dhcp-server: %v", err) + data.Error = "Failed to fetch DHCP data" + } else { + d := raw.DHCP + dhcp := &DHCPData{ + Enabled: bool(d.Enabled), + Stats: DHCPStats{ + InDiscoveries: int64(d.Statistics.InDiscoveries), + InRequests: int64(d.Statistics.InRequests), + InReleases: int64(d.Statistics.InReleases), + OutOffers: int64(d.Statistics.OutOffers), + OutAcks: int64(d.Statistics.OutAcks), + OutNaks: int64(d.Statistics.OutNaks), + }, + } + for _, l := range d.Leases.Lease { + dhcp.Leases = append(dhcp.Leases, DHCPLease{ + Address: l.Address, + MAC: l.PhysAddr, + Hostname: l.Hostname, + Expires: formatDHCPExpiry(l.Expires), + ClientID: l.ClientID, + }) + } + data.DHCP = dhcp + } + + tmplName := "dhcp.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +// formatDHCPExpiry converts a YANG date-and-time or "never" string to a +// human-readable relative expiry string. +func formatDHCPExpiry(s string) string { + if s == "" || s == "never" { + return "never" + } + t, err := time.Parse(time.RFC3339, s) + if err != nil { + // Try without timezone + t, err = time.Parse("2006-01-02T15:04:05", s) + if err != nil { + return s + } + } + d := time.Until(t) + if d < 0 { + d = -d + return "expired " + formatRelDuration(d) + " ago" + } + return "in " + formatRelDuration(d) +} + +// formatRelDuration formats a time.Duration in a compact human-readable form. +func formatRelDuration(d time.Duration) string { + switch { + case d >= 24*time.Hour: + return fmt.Sprintf("%dd", int(d.Hours())/24) + case d >= time.Hour: + return fmt.Sprintf("%dh%dm", int(d.Hours()), int(d.Minutes())%60) + case d >= time.Minute: + return fmt.Sprintf("%dm", int(d.Minutes())) + default: + return fmt.Sprintf("%ds", int(d.Seconds())) + } +} diff --git a/src/webui/internal/handlers/diagnostics.go b/src/webui/internal/handlers/diagnostics.go new file mode 100644 index 00000000..6b499bcf --- /dev/null +++ b/src/webui/internal/handlers/diagnostics.go @@ -0,0 +1,495 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "html/template" + "io" + "log" + "net/http" + "net/url" + "os" + "os/exec" + "strconv" + "strings" + "time" + + "infix/webui/internal/restconf" +) + +// DiagnosticsHandler serves the Maintenance > Diagnostics page: ping, +// traceroute, mtr (interactive hop table) and DNS lookup. Long-running +// tools stream over SSE, mirroring the software-progress and logs-tail +// handlers; client disconnect (the Stop button closing the EventSource) +// cancels the request context, which CommandContext turns into a kill + +// reap of the spawned tool. +type DiagnosticsHandler struct { + RC *restconf.Client + Template *template.Template +} + +type diagPageData struct { + PageData + Interfaces []string + Active string +} + +// Overview renders the Diagnostics page with Ping pre-selected. +// GET /maintenance/diagnostics +func (h *DiagnosticsHandler) Overview(w http.ResponseWriter, r *http.Request) { + data := diagPageData{ + PageData: newPageData(w, r, "diagnostics", "Diagnostics"), + Interfaces: h.interfaceNames(r), + Active: "ping", + } + tmplName := "diagnostics.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// interfaceNames returns configured interface names for the source +// dropdown, best-effort: an error just yields an empty list (the form +// still offers "Auto"). +func (h *DiagnosticsHandler) interfaceNames(r *http.Request) []string { + if h.RC == nil { + return nil + } + var wrap interfacesWrapper + if err := h.RC.Get(r.Context(), "/data/ietf-interfaces:interfaces", &wrap); err != nil { + log.Printf("diagnostics: interface list: %v", err) + return nil + } + names := make([]string, 0, len(wrap.Interfaces.Interface)) + for _, i := range wrap.Interfaces.Interface { + names = append(names, i.Name) + } + return names +} + +// validIface returns iface only if it's a real configured interface; +// otherwise "" (treated as Auto). An allow-list is the safe way to let +// a user-supplied string reach a -I/-i flag. +func (h *DiagnosticsHandler) validIface(r *http.Request, iface string) string { + if iface == "" { + return "" + } + for _, n := range h.interfaceNames(r) { + if n == iface { + return iface + } + } + return "" +} + +// ─── Input validation ──────────────────────────────────────────────── + +// validTarget guards the host/target argument before it reaches a +// spawned tool. Rejects empty input, anything with whitespace, and a +// leading '-' (which a tool would parse as a flag — argument injection). +// Args are always passed as an explicit slice, never a shell string, so +// this plus the leading-dash check is sufficient. +func validTarget(s string) bool { + if s == "" || len(s) > 255 { + return false + } + if strings.HasPrefix(s, "-") { + return false + } + for _, r := range s { + if r <= ' ' || r == 0x7f { + return false + } + } + return true +} + +func clampInt(s string, def, min, max int) int { + n, err := strconv.Atoi(s) + if err != nil { + return def + } + if n < min { + return min + } + if n > max { + return max + } + return n +} + +// ─── SSE helpers ───────────────────────────────────────────────────── + +func sseLine(w io.Writer, f http.Flusher, s string) { + // SSE data fields must be single-line; collapse any CR/LF. + s = strings.ReplaceAll(strings.ReplaceAll(s, "\r", ""), "\n", " ") + fmt.Fprintf(w, "event: line\ndata: %s\n\n", s) + f.Flush() +} + +func sseDone(w io.Writer, f http.Flusher) { + fmt.Fprint(w, "event: done\ndata: end\n\n") + f.Flush() +} + +// ─── Run ───────────────────────────────────────────────────────────── + +// diagCommand maps the request to a binary + argument slice, or ok=false +// for an unknown tool. +// +// Address family is forced with an explicit -4/-6 flag on the BASE binary +// rather than via the ping6/traceroute6 name. A dual-stack hostname (A + +// AAAA) otherwise follows the system's getaddrinfo preference — usually +// IPv6 — so "IPv4" never actually took effect on hosts that only have v4 +// routes. ping, traceroute and mtr all accept -4/-6; nmap defaults to v4 +// and only needs -6. "auto" passes no flag and lets the tool decide +// (IPv6 literals and v4-only names resolve correctly without help). +func diagCommand(tool, target, family, iface string, q url.Values) (bin string, args []string, ok bool) { + fam := "" + switch family { + case "4": + fam = "-4" + case "6": + fam = "-6" + } + + switch tool { + case "ping": + bin = "ping" + args = []string{"-c", strconv.Itoa(clampInt(q.Get("count"), 3, 1, 100))} + if fam != "" { + args = append(args, fam) + } + if sz := q.Get("size"); sz != "" { + args = append(args, "-s", strconv.Itoa(clampInt(sz, 56, 0, 65500))) + } + if iface != "" { + args = append(args, "-I", iface) + } + args = append(args, "-W", "2", target) + return bin, args, true + + case "traceroute": + bin = "traceroute" + args = []string{"-n", "-m", strconv.Itoa(clampInt(q.Get("maxhops"), 30, 1, 64))} + if fam != "" { + args = append(args, fam) + } + if iface != "" { + args = append(args, "-i", iface) + } + args = append(args, target) + return bin, args, true + + case "mtr": + bin = "mtr" + args = []string{"--raw", "-n"} + if fam != "" { + args = append(args, fam) + } + // A count means the user opted out of the default run-forever + // mode; otherwise mtr streams continuously until Stop. + if c := q.Get("count"); c != "" { + args = append(args, "-c", strconv.Itoa(clampInt(c, 10, 1, 1000))) + } + if sz := q.Get("size"); sz != "" { + args = append(args, "-s", strconv.Itoa(clampInt(sz, 56, 0, 65500))) + } + if iface != "" { + args = append(args, "-I", iface) + } + args = append(args, target) + return bin, args, true + + case "nmap": + bin = "nmap" + // -n: skip rDNS so scans stay quick and don't hang on slow + // resolvers. Scan profiles map to a fixed flag set — we never + // pass free-form nmap options from the client. + args = []string{"-n"} + if family == "6" { + args = append(args, "-6") + } + switch q.Get("scan") { + case "quick": + args = append(args, "-F") // top 100 ports + case "services": + args = append(args, "-sV") // service/version detection + case "ping": + args = append(args, "-sn") // host discovery only + case "standard": + // default top-1000 port scan, no extra flag + } + if iface != "" { + args = append(args, "-e", iface) + } + args = append(args, target) + return bin, args, true + } + return "", nil, false +} + +// Run streams tool output as SSE. Event protocol: +// +// event: line — a text line to append (ping, traceroute) +// event: hop — JSON hop stats to upsert into the mtr table +// event: done — the tool exited; client closes the stream +// : comment — keep-alive +// +// GET /maintenance/diagnostics/run?tool=&target=&iface=&family=&... +func (h *DiagnosticsHandler) Run(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", http.StatusInternalServerError) + return + } + + q := r.URL.Query() + tool := q.Get("tool") + target := q.Get("target") + family := q.Get("family") + if family == "" { + family = "auto" + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + // A few bytes up front so the proxy commits and EventSource.onopen + // fires promptly even before the tool emits anything. + fmt.Fprint(w, ": stream opened\n\n") + flusher.Flush() + + if !validTarget(target) { + sseLine(w, flusher, "Error: invalid target") + sseDone(w, flusher) + return + } + iface := h.validIface(r, q.Get("iface")) + + bin, args, ok := diagCommand(tool, target, family, iface, q) + if !ok { + sseLine(w, flusher, "Error: unknown tool") + sseDone(w, flusher) + return + } + + if tool == "mtr" { + h.streamMtr(w, r, flusher, bin, args) + return + } + h.streamLines(w, r, flusher, bin, args, func(line string) { + sseLine(w, flusher, line) + }) +} + +// streamLines spawns bin/args and invokes onLine for each output line, +// emitting a done event on exit. stdout and stderr are merged so tool +// errors ("Network is unreachable", "unknown host") reach the user. +func (h *DiagnosticsHandler) streamLines(w http.ResponseWriter, r *http.Request, flusher http.Flusher, bin string, args []string, onLine func(string)) { + ctx, cancel := context.WithCancel(r.Context()) + defer cancel() + + cmd := exec.CommandContext(ctx, bin, args...) + + // Real OS pipe (not io.Pipe): the write-end fd is handed directly to + // the child, so there's no os/exec copy goroutine that could deadlock + // when the reader stops draining. When the child dies — including + // the CommandContext SIGKILL on ctx cancel — every write end closes + // and our read sees EOF, no matter whether anyone is reading. + pr, pw, err := os.Pipe() + if err != nil { + sseLine(w, flusher, "Error: "+err.Error()) + sseDone(w, flusher) + return + } + cmd.Stdout = pw + cmd.Stderr = pw + if err := cmd.Start(); err != nil { + pw.Close() + pr.Close() + sseLine(w, flusher, "Error: "+err.Error()) + sseDone(w, flusher) + return + } + pw.Close() // child holds the only write end now + defer pr.Close() // unblocks the scanner if we leave first + // Reap the child so it doesn't linger as a zombie; ctx cancel kills + // it, Wait then returns. + go cmd.Wait() //nolint:errcheck + + lines := make(chan string, 128) + go func() { + defer close(lines) + sc := bufio.NewScanner(pr) + sc.Buffer(make([]byte, 64*1024), 256*1024) + for sc.Scan() { + select { + case lines <- sc.Text(): + case <-ctx.Done(): + return + } + } + }() + + heartbeat := time.NewTicker(15 * time.Second) + defer heartbeat.Stop() + for { + select { + case <-ctx.Done(): + return + case line, ok := <-lines: + if !ok { + sseDone(w, flusher) + return + } + onLine(line) + case <-heartbeat.C: + fmt.Fprint(w, ": keep-alive\n\n") + flusher.Flush() + } + } +} + +// mtrHop accumulates per-hop statistics from the raw record stream. +type mtrHop struct { + host string + sent, recv int + last, best, worst float64 + sum float64 +} + +// streamMtr runs mtr --raw and translates its record stream into hop +// updates. Infix's mtr emits an `x <idx> <txid>` record for every probe +// SENT (not just replies), so loss is exact: 100*(sent-recv)/sent. +// +// x <idx> <txid> probe sent for hop idx +// h <idx> <address> host discovered at hop idx +// p <idx> <usec> <txid> reply for hop idx, RTT in microseconds +func (h *DiagnosticsHandler) streamMtr(w http.ResponseWriter, r *http.Request, flusher http.Flusher, bin string, args []string) { + hops := map[int]*mtrHop{} + get := func(idx int) *mtrHop { + hop := hops[idx] + if hop == nil { + hop = &mtrHop{host: "???"} + hops[idx] = hop + } + return hop + } + emit := func(idx int, hop *mtrHop) { + loss := 0.0 + if hop.sent > 0 { + loss = 100 * float64(hop.sent-hop.recv) / float64(hop.sent) + } + avg := 0.0 + if hop.recv > 0 { + avg = hop.sum / float64(hop.recv) + } + payload, _ := json.Marshal(map[string]any{ + "idx": idx, "host": hop.host, "loss": loss, "snt": hop.sent, + "last": hop.last, "avg": avg, "best": hop.best, "worst": hop.worst, + }) + fmt.Fprintf(w, "event: hop\ndata: %s\n\n", payload) + flusher.Flush() + } + + h.streamLines(w, r, flusher, bin, args, func(line string) { + f := strings.Fields(line) + if len(f) < 2 { + return + } + idx, err := strconv.Atoi(f[1]) + if err != nil { + return + } + switch f[0] { + case "x": // probe sent + hop := get(idx) + hop.sent++ + emit(idx, hop) + case "h": // host discovered + if len(f) >= 3 { + get(idx).host = f[2] + } + case "p": // reply received + if len(f) >= 3 { + usec, err := strconv.Atoi(f[2]) + if err != nil { + return + } + rtt := float64(usec) / 1000 + hop := get(idx) + hop.recv++ + hop.last = rtt + hop.sum += rtt + if hop.recv == 1 || rtt < hop.best { + hop.best = rtt + } + if rtt > hop.worst { + hop.worst = rtt + } + emit(idx, hop) + } + } + }) +} + +// ─── DNS lookup ────────────────────────────────────────────────────── + +// Resolve does a one-shot name lookup via getent and returns an HTML +// fragment for the client to swap into the result pane. +// GET /maintenance/diagnostics/resolve?name=&family= +func (h *DiagnosticsHandler) Resolve(w http.ResponseWriter, r *http.Request) { + name := r.URL.Query().Get("name") + render := func(data map[string]any) { + if err := h.Template.ExecuteTemplate(w, "diag-dns-result", data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } + } + if !validTarget(name) { + render(map[string]any{"Name": name, "Error": "invalid name"}) + return + } + + prog := "ahosts" + switch r.URL.Query().Get("family") { + case "4": + prog = "ahostsv4" + case "6": + prog = "ahostsv6" + } + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + out, _ := exec.CommandContext(ctx, "getent", prog, name).Output() + + // getent ahosts prints each address three times (STREAM/DGRAM/RAW); + // keep the first occurrence of each, preserving order. + var addrs []string + seen := map[string]bool{} + sc := bufio.NewScanner(strings.NewReader(string(out))) + for sc.Scan() { + fields := strings.Fields(sc.Text()) + if len(fields) == 0 || seen[fields[0]] { + continue + } + seen[fields[0]] = true + addrs = append(addrs, fields[0]) + } + + if len(addrs) == 0 { + render(map[string]any{"Name": name, "Error": "no addresses found"}) + return + } + render(map[string]any{"Name": name, "Addrs": addrs}) +} diff --git a/src/webui/internal/handlers/firewall.go b/src/webui/internal/handlers/firewall.go new file mode 100644 index 00000000..9d2737ac --- /dev/null +++ b/src/webui/internal/handlers/firewall.go @@ -0,0 +1,324 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "html/template" + "log" + "net/http" + "sort" + "strings" + + "infix/webui/internal/restconf" +) + +// RESTCONF JSON structures for infix-firewall:firewall. + +type firewallWrapper struct { + Firewall firewallJSON `json:"infix-firewall:firewall"` +} + +type firewallJSON struct { + Enabled *yangBool `json:"enabled"` // YANG default: true; nil means enabled + Default string `json:"default"` + Logging string `json:"logging"` + Lockdown yangBool `json:"lockdown"` + Zone []zoneJSON `json:"zone"` + Policy []policyJSON `json:"policy"` + Service []fwServiceJSON `json:"service"` +} + +// fwServiceJSON models a user-defined firewall service (port + protocol bundle). +// Custom services appear in the zone-service dropdown alongside the YANG-defined +// well-known identities (ssh, http, …). +type fwServiceJSON struct { + Name string `json:"name"` + Description string `json:"description"` + Destination string `json:"destination"` + Port []fwServicePortJSON `json:"port"` +} + +type fwServicePortJSON struct { + Lower yangInt64 `json:"lower"` + Upper yangInt64 `json:"upper"` + Proto string `json:"proto"` +} + +type zoneJSON struct { + Name string `json:"name"` + Action string `json:"action"` + Description string `json:"description"` + Interface []string `json:"interface"` + Network []string `json:"network"` + Service []string `json:"service"` + PortForward []portForwardJSON `json:"port-forward"` + Immutable bool `json:"immutable"` +} + +type portForwardJSON struct { + Lower yangInt64 `json:"lower"` + Upper yangInt64 `json:"upper"` + Proto string `json:"proto"` + To *portForwardTo `json:"to"` +} + +type portForwardTo struct { + Addr string `json:"addr"` + Port yangInt64 `json:"port"` +} + +type policyJSON struct { + Name string `json:"name"` + Action string `json:"action"` + Description string `json:"description"` + Priority yangInt64 `json:"priority"` + Ingress []string `json:"ingress"` + Egress []string `json:"egress"` + Service []string `json:"service"` + Masquerade bool `json:"masquerade"` + Immutable bool `json:"immutable"` +} + +// Template data structures. + +type firewallData struct { + PageData + Enabled bool + EnabledText string + DefaultZone string + Lockdown bool + Logging string + ZoneNames []string + Matrix []matrixRow + Zones []zoneEntry + Policies []policyEntry + Error string +} + +type matrixRow struct { + Zone string + Cells []matrixCell +} + +type matrixCell struct { + Class string + Symbol string + Verdict string // "allow", "deny", "conditional", "self" + From string + To string + Detail string // human-readable reason for the drill-down panel +} + +type zoneEntry struct { + Name string + Action string + Interfaces string + Networks string + Services string // services allowed to HOST from this zone +} + +type policyEntry struct { + Name string + Action string + Priority int64 + Ingress string + Egress string + Services string + Masquerade bool + Immutable bool + Description string +} + +// FirewallHandler serves the firewall overview page. +type FirewallHandler struct { + Template *template.Template + RC *restconf.Client +} + +// Overview renders the firewall overview (GET /firewall). +func (h *FirewallHandler) Overview(w http.ResponseWriter, r *http.Request) { + data := firewallData{ + PageData: newPageData(w, r, "firewall", "Firewall"), + } + + var fw firewallWrapper + err := h.RC.Get(r.Context(), "/data/infix-firewall:firewall", &fw) + if err != nil { + if restconf.IsNotFound(err) { + // Firewall module not active — show disabled state, not an error. + data.EnabledText = "Inactive" + } else { + log.Printf("restconf firewall: %v", err) + data.Error = "Could not fetch firewall configuration" + } + } + if err == nil { + f := fw.Firewall + data.Enabled = f.Enabled == nil || bool(*f.Enabled) + if data.Enabled { + data.EnabledText = "Active" + } else { + data.EnabledText = "Inactive" + } + data.DefaultZone = f.Default + data.Lockdown = bool(f.Lockdown) + data.Logging = f.Logging + if data.Logging == "" { + data.Logging = "off" + } + + for _, z := range f.Zone { + data.Zones = append(data.Zones, zoneEntry{ + Name: z.Name, + Action: z.Action, + Interfaces: strings.Join(z.Interface, ", "), + Networks: strings.Join(z.Network, ", "), + Services: strings.Join(z.Service, ", "), + }) + } + + for _, p := range f.Policy { + data.Policies = append(data.Policies, policyEntry{ + Name: p.Name, + Action: p.Action, + Priority: int64(p.Priority), + Ingress: strings.Join(p.Ingress, ", "), + Egress: strings.Join(p.Egress, ", "), + Services: strings.Join(p.Service, ", "), + Masquerade: p.Masquerade, + Immutable: p.Immutable, + Description: p.Description, + }) + } + + sort.Slice(data.Policies, func(i, j int) bool { + return data.Policies[i].Priority < data.Policies[j].Priority + }) + + data.ZoneNames, data.Matrix = buildMatrix(f.Zone, f.Policy) + } + + tmplName := "firewall.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// buildMatrix creates the zone-to-zone traffic flow matrix. +// Zones are listed along both axes with HOST (the device itself) prepended. +// Each cell shows whether traffic from the row zone to the column zone +// is allowed, denied, or conditional. +func buildMatrix(zones []zoneJSON, policies []policyJSON) ([]string, []matrixRow) { + if len(zones) == 0 { + return nil, nil + } + + names := []string{"HOST"} + zoneByName := map[string]zoneJSON{} + for _, z := range zones { + names = append(names, z.Name) + zoneByName[z.Name] = z + } + + // Sort policies by priority for evaluation. + sorted := make([]policyJSON, len(policies)) + copy(sorted, policies) + sort.Slice(sorted, func(i, j int) bool { + return int64(sorted[i].Priority) < int64(sorted[j].Priority) + }) + + rows := make([]matrixRow, len(names)) + for i, src := range names { + rows[i] = matrixRow{Zone: src, Cells: make([]matrixCell, len(names))} + for j, dst := range names { + var cell matrixCell + switch { + case src == dst: + cell = matrixCell{Class: "matrix-self", Symbol: "—", Verdict: "self"} + case src == "HOST": + // Traffic from the device to any zone is always allowed. + cell = matrixCell{Class: "matrix-allow", Symbol: "✓", Verdict: "allow", + Detail: "HOST can reach all zones"} + case dst == "HOST": + // Input to device: governed by zone action + zone services. + cell = zoneToHost(zoneByName[src]) + default: + // Forwarding between zones: governed by policies. + cell = evalForward(src, dst, sorted) + } + cell.From = src + cell.To = dst + rows[i].Cells[j] = cell + } + } + + return names, rows +} + +// zoneToHost determines traffic flow from a zone to the device (HOST). +// This mirrors the CLI: it is based solely on the zone's action and services, +// not on policies (per cli_pretty.py traffic_flow logic). +func zoneToHost(zone zoneJSON) matrixCell { + if zone.Action == "accept" { + return matrixCell{Class: "matrix-allow", Symbol: "✓", Verdict: "allow", + Detail: "Zone default action: accept"} + } + if len(zone.Service) > 0 || len(zone.PortForward) > 0 { + if len(zone.Service) > 0 { + return matrixCell{Class: "matrix-cond", Symbol: "⚠", Verdict: "conditional", + Detail: "Services: " + strings.Join(zone.Service, ", ")} + } + return matrixCell{Class: "matrix-cond", Symbol: "⚠", Verdict: "conditional", + Detail: "Port-forwarding rules apply"} + } + return matrixCell{Class: "matrix-deny", Symbol: "✗", Verdict: "deny", + Detail: "Zone default action: " + zone.Action} +} + +// evalForward determines traffic flow between two different zones via policies. +func evalForward(src, dst string, policies []policyJSON) matrixCell { + if v, name := evalPolicies(src, dst, policies); v != "" { + return makeCell(v, name) + } + return matrixCell{Class: "matrix-deny", Symbol: "✗", Verdict: "deny", + Detail: "No policy — default deny"} +} + +// evalPolicies walks the sorted policy list and returns the first terminal +// verdict (accept/reject/drop) for traffic from src to dst, plus the policy name. +// "continue" policies are skipped (they don't produce a final verdict). +func evalPolicies(src, dst string, policies []policyJSON) (verdict, name string) { + for _, p := range policies { + if !matchesZone(src, p.Ingress) || !matchesZone(dst, p.Egress) { + continue + } + if p.Action == "continue" { + continue + } + return p.Action, p.Name + } + return "", "" +} + +// matchesZone checks whether zone appears in list, treating "ANY" as a wildcard. +func matchesZone(zone string, list []string) bool { + for _, z := range list { + if z == zone || z == "ANY" { + return true + } + } + return false +} + +func makeCell(verdict, policyName string) matrixCell { + if verdict == "accept" { + return matrixCell{Class: "matrix-allow", Symbol: "✓", Verdict: "allow", + Detail: "Policy: " + policyName + " (accept)"} + } + return matrixCell{Class: "matrix-deny", Symbol: "✗", Verdict: "deny", + Detail: "Policy: " + policyName + " (" + verdict + ")"} +} diff --git a/src/webui/internal/handlers/hardware.go b/src/webui/internal/handlers/hardware.go new file mode 100644 index 00000000..1ad93230 --- /dev/null +++ b/src/webui/internal/handlers/hardware.go @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "context" + "html/template" + "log" + "net/http" + "sort" + + "infix/webui/internal/restconf" +) + +// Hardware page data — populated from /ietf-hardware:hardware. +// Mirrors CLI `show hardware`, but with browser affordances: sensor groups +// keep their child rows together, USB ports get a state column, and any +// uncategorised components fall through to a "Other" section so the page +// shows everything ietf-hardware exposes. + +type hardwarePageData struct { + PageData + Board boardInfo + USBPorts []usbPortEntry + WiFiRadios []wifiEntry + GPSReceivers []gpsEntry + SensorGroups []hwSensorGroup + OtherComps []otherCompEntry + Error string +} + +type usbPortEntry struct { + Name string + Description string + AdminState string // locked / unlocked + OperState string // enabled / disabled / etc. +} + +type gpsEntry struct { + Name string + Manufacturer string + ModelName string + HardwareRev string +} + +type hwSensorGroup struct { + Parent string + Sensors []hwSensorEntry +} + +type hwSensorEntry struct { + Name string + Value string + Type string // temperature / fan / volts-DC / etc. + OperStatus string // ok / unavailable / nonoperational +} + +type otherCompEntry struct { + Name string + Class string + Parent string + Description string + Manufacturer string + ModelName string + SerialNum string + HardwareRev string +} + +// HardwareHandler serves the Status → Hardware page (GET /hardware). +type HardwareHandler struct { + Template *template.Template + RC *restconf.Client +} + +func (h *HardwareHandler) Overview(w http.ResponseWriter, r *http.Request) { + data := hardwarePageData{ + PageData: newPageData(w, r, "hardware", "Hardware"), + } + + // Detach from r.Context() so the RESTCONF call (and yanger behind it) + // survives a client disconnect mid-fetch (e.g. login redirect). The + // RESTCONF client's own 10s timeout still bounds each call. + ctx := context.WithoutCancel(r.Context()) + + var hw hardwareWrapper + if err := h.RC.Get(ctx, "/data/ietf-hardware:hardware", &hw); err != nil { + if !restconf.IsNotFound(err) { + log.Printf("restconf hardware: %v", err) + data.Error = "Could not fetch hardware information" + } + } else { + buildHardwarePage(&data, hw.Hardware.Component) + } + + tmplName := "hardware.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// buildHardwarePage walks the ietf-hardware components and routes each to +// the section it belongs in. Components with sensor-data show up under +// SensorGroups regardless of class (a chassis can carry a temp sensor too); +// any component not handled by an explicit section falls through to +// OtherComps so the page is exhaustive. +// +// Unlike the dashboard's curated view, this one keeps non-ok sensors +// visible — the redesigned Overview (Phase 2) will surface those via a +// summary indicator instead of hiding them. +func buildHardwarePage(data *hardwarePageData, comps []hwComponentJSON) { + sensorMap := make(map[string][]hwSensorEntry) + var sensorParents []string + + for _, c := range comps { + class := shortClass(c.Class) + handled := true + switch class { + case classChassis: + data.Board = boardInfo{ + Model: c.ModelName, + Manufacturer: c.MfgName, + SerialNum: c.SerialNum, + HardwareRev: c.HardwareRev, + BaseMAC: c.PhysAddress, + } + case classUSB: + data.USBPorts = append(data.USBPorts, usbPortEntry{ + Name: c.Name, + Description: c.Description, + AdminState: compAdminState(c), + OperState: compOperState(c), + }) + case classWiFi: + data.WiFiRadios = append(data.WiFiRadios, summarizeWiFiRadio(c)) + case classGPS: + data.GPSReceivers = append(data.GPSReceivers, gpsEntry{ + Name: c.Name, + Manufacturer: c.MfgName, + ModelName: c.ModelName, + HardwareRev: c.HardwareRev, + }) + default: + handled = false + } + + if c.SensorData != nil { + entry := hwSensorEntry{ + Name: c.Name, + Value: formatSensor(c.SensorData.ValueType, int64(c.SensorData.Value), c.SensorData.ValueScale), + Type: c.SensorData.ValueType, + OperStatus: c.SensorData.OperStatus, + } + if _, ok := sensorMap[c.Parent]; !ok { + sensorParents = append(sensorParents, c.Parent) + } + sensorMap[c.Parent] = append(sensorMap[c.Parent], entry) + handled = true + } + + if !handled { + data.OtherComps = append(data.OtherComps, otherCompEntry{ + Name: c.Name, + Class: class, + Parent: c.Parent, + Description: c.Description, + Manufacturer: c.MfgName, + ModelName: c.ModelName, + SerialNum: c.SerialNum, + HardwareRev: c.HardwareRev, + }) + } + } + + sort.Strings(sensorParents) + for _, p := range sensorParents { + data.SensorGroups = append(data.SensorGroups, hwSensorGroup{ + Parent: p, + Sensors: sensorMap[p], + }) + } + sort.Slice(data.USBPorts, func(i, j int) bool { return data.USBPorts[i].Name < data.USBPorts[j].Name }) + sort.Slice(data.OtherComps, func(i, j int) bool { + if data.OtherComps[i].Class != data.OtherComps[j].Class { + return data.OtherComps[i].Class < data.OtherComps[j].Class + } + return data.OtherComps[i].Name < data.OtherComps[j].Name + }) +} + +func compAdminState(c hwComponentJSON) string { + if c.State == nil { + return "" + } + return c.State.AdminState +} + +func compOperState(c hwComponentJSON) string { + if c.State == nil { + return "" + } + return c.State.OperState +} diff --git a/src/webui/internal/handlers/interfaces.go b/src/webui/internal/handlers/interfaces.go new file mode 100644 index 00000000..5307a6bb --- /dev/null +++ b/src/webui/internal/handlers/interfaces.go @@ -0,0 +1,1228 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "fmt" + "html/template" + "log" + "math" + "net/http" + "slices" + "sort" + "strconv" + "strings" + "sync" + + "infix/webui/internal/restconf" + "infix/webui/internal/schema" +) + +// RESTCONF JSON structures for ietf-interfaces:interfaces. + +type interfacesWrapper struct { + Interfaces struct { + Interface []ifaceJSON `json:"interface"` + } `json:"ietf-interfaces:interfaces"` +} + +type ifaceJSON struct { + Name string `json:"name"` + Description string `json:"description"` + Type string `json:"type"` + Enabled *bool `json:"enabled"` + OperStatus string `json:"oper-status"` + PhysAddress string `json:"phys-address"` + CustomPhysAddress *customPhysAddress `json:"infix-interfaces:custom-phys-address"` + IfIndex int `json:"if-index"` + // Operational link rate from ietf-interfaces (yang:gauge64 bits/s). + // The same-named leaf inside the ethernet container is obsolete. + Speed yangInt64 `json:"speed"` + IPv4 *ipCfg `json:"ietf-ip:ipv4"` + IPv6 *ipCfg `json:"ietf-ip:ipv6"` + Statistics *ifaceStats `json:"statistics"` + Ethernet *ethernetJSON `json:"ieee802-ethernet-interface:ethernet"` + Bridge *bridgeCfgJSON `json:"infix-interfaces:bridge"` + BridgePort *bridgePortJSON `json:"infix-interfaces:bridge-port"` + Lag *lagCfgJSON `json:"infix-interfaces:lag"` + LagPort *lagPortCfgJSON `json:"infix-interfaces:lag-port"` + Vlan *vlanCfgJSON `json:"infix-interfaces:vlan"` + WiFi *wifiJSON `json:"infix-interfaces:wifi"` + WireGuard *wireGuardJSON `json:"infix-interfaces:wireguard"` +} + +// customPhysAddress mirrors infix-interfaces:custom-phys-address. Only the +// static-MAC case is exposed in the WebUI; chassis-derived addresses are +// uncommon and editable from the Advanced YANG tree. +type customPhysAddress struct { + Static string `json:"static"` +} + +type vlanCfgJSON struct { + ID int `json:"id"` + TagType string `json:"tag-type"` + LowerLayerIf string `json:"lower-layer-if"` +} + +type bridgePortJSON struct { + Bridge string `json:"bridge"` + PVID *int `json:"pvid"` + Flood *bridgePortFlood `json:"flood"` + Multicast *bridgePortMcast `json:"multicast"` + STP *struct { + CIST *struct { + State string `json:"state"` + } `json:"cist"` + } `json:"stp"` +} + +type bridgePortFlood struct { + Broadcast *bool `json:"broadcast"` + Unicast *bool `json:"unicast"` + Multicast *bool `json:"multicast"` +} + +type bridgePortMcast struct { + FastLeave *bool `json:"fast-leave"` + Router string `json:"router"` +} + +type wifiJSON struct { + Radio string `json:"radio"` + AccessPoint *wifiAPJSON `json:"access-point"` + Station *wifiStationJSON `json:"station"` +} + +type wifiAPJSON struct { + SSID string `json:"ssid"` + Hidden *bool `json:"hidden"` + Security *wifiSecJSON `json:"security"` + Stations struct { + Station []wifiStaJSON `json:"station"` + } `json:"stations"` +} + +type wifiSecJSON struct { + Mode string `json:"mode"` + Secret string `json:"secret"` +} + +type wifiStaJSON struct { + MACAddress string `json:"mac-address"` + SignalStrength *int `json:"signal-strength"` + ConnectedTime yangInt64 `json:"connected-time"` + RxPackets yangInt64 `json:"rx-packets"` + TxPackets yangInt64 `json:"tx-packets"` + RxBytes yangInt64 `json:"rx-bytes"` + TxBytes yangInt64 `json:"tx-bytes"` + RxSpeed yangInt64 `json:"rx-speed"` + TxSpeed yangInt64 `json:"tx-speed"` +} + +type wifiStationJSON struct { + SSID string `json:"ssid"` + Security *wifiSecJSON `json:"security"` + SignalStrength *int `json:"signal-strength"` + RxSpeed yangInt64 `json:"rx-speed"` + TxSpeed yangInt64 `json:"tx-speed"` + ScanResults []wifiScanResultJSON `json:"scan-results"` +} + +type wifiScanResultJSON struct { + SSID string `json:"ssid"` + BSSID string `json:"bssid"` + SignalStrength *int `json:"signal-strength"` + Channel int `json:"channel"` + Encryption []string `json:"encryption"` +} + +// WiFi radio survey RESTCONF structures (from ietf-hardware:hardware). + +type wifiRadioJSON struct { + Survey *wifiSurveyJSON `json:"survey"` +} + +type wifiSurveyJSON struct { + Channel []surveyChanJSON `json:"channel"` +} + +type surveyChanJSON struct { + Frequency int `json:"frequency"` + InUse yangBool `json:"in-use"` + Noise int `json:"noise"` + ActiveTime int `json:"active-time"` + BusyTime int `json:"busy-time"` + ReceiveTime int `json:"receive-time"` + TransmitTime int `json:"transmit-time"` +} + +type wireGuardJSON struct { + PeerStatus *struct { + Peer []wgPeerJSON `json:"peer"` + } `json:"peer-status"` +} + +type wgPeerJSON struct { + PublicKey string `json:"public-key"` + ConnectionStatus string `json:"connection-status"` + EndpointAddress string `json:"endpoint-address"` + EndpointPort int `json:"endpoint-port"` + LatestHandshake string `json:"latest-handshake"` + Transfer *struct { + TxBytes yangInt64 `json:"tx-bytes"` + RxBytes yangInt64 `json:"rx-bytes"` + } `json:"transfer"` +} + +type dhcpOptionJSON struct { + ID string `json:"id"` + Value string `json:"value,omitempty"` + Hex string `json:"hex,omitempty"` +} + +type dhcpv4CfgJSON struct { + ClientID string `json:"client-id,omitempty"` + Arping *bool `json:"arping,omitempty"` + RoutePreference *uint32 `json:"route-preference,omitempty"` + Options []dhcpOptionJSON `json:"option,omitempty"` +} + +type dhcpv6CfgJSON struct { + DUID string `json:"duid,omitempty"` + InformationOnly *bool `json:"information-only,omitempty"` + RoutePreference *uint32 `json:"route-preference,omitempty"` + Options []dhcpOptionJSON `json:"option,omitempty"` +} + +type ipCfg struct { + Address []ipAddr `json:"address"` + MTU int `json:"mtu"` + Forwarding bool `json:"forwarding"` + DHCP *dhcpv4CfgJSON `json:"infix-dhcp-client:dhcp"` + Autoconf *struct{} `json:"infix-ip:autoconf"` + SLAACv6 *struct{} `json:"autoconf"` + DHCPv6 *dhcpv6CfgJSON `json:"infix-dhcpv6-client:dhcp"` +} + +type ipAddr struct { + IP string `json:"ip"` + PrefixLength yangInt64 `json:"prefix-length"` + Origin string `json:"origin"` +} + +type ifaceStats struct { + InOctets yangInt64 `json:"in-octets"` + OutOctets yangInt64 `json:"out-octets"` + InUnicastPkts yangInt64 `json:"in-unicast-pkts"` + InBroadcastPkts yangInt64 `json:"in-broadcast-pkts"` + InMulticastPkts yangInt64 `json:"in-multicast-pkts"` + InDiscards yangInt64 `json:"in-discards"` + InErrors yangInt64 `json:"in-errors"` + OutUnicastPkts yangInt64 `json:"out-unicast-pkts"` + OutBroadcastPkts yangInt64 `json:"out-broadcast-pkts"` + OutMulticastPkts yangInt64 `json:"out-multicast-pkts"` + OutDiscards yangInt64 `json:"out-discards"` + OutErrors yangInt64 `json:"out-errors"` +} + +type ethernetJSON struct { + Duplex string `json:"duplex"` + PhyType string `json:"phy-type"` + PMDType string `json:"pmd-type"` + SupportedPMDs []string `json:"infix-ethernet-interface:supported-pmd-types"` + // *bool because the YANG model uses absent as the Auto-MDIX signal — + // collapsing nil and false here would lose the tri-state. + MDIX *bool `json:"infix-ethernet-interface:mdi-x"` + AutoNegotiation *struct { + Enable bool `json:"enable"` + AdvertisedPMDs []string `json:"infix-ethernet-interface:advertised-pmd-types"` + } `json:"auto-negotiation"` + Statistics *struct { + Frame *ethFrameStats `json:"frame"` + } `json:"statistics"` +} + +type ethFrameStats struct { + InTotalPkts yangInt64 `json:"in-total-pkts"` + InTotalOctets yangInt64 `json:"in-total-octets"` + InGoodPkts yangInt64 `json:"in-good-pkts"` + InGoodOctets yangInt64 `json:"in-good-octets"` + InBroadcast yangInt64 `json:"in-broadcast"` + InMulticast yangInt64 `json:"in-multicast"` + InErrorFCS yangInt64 `json:"in-error-fcs"` + InErrorUndersize yangInt64 `json:"in-error-undersize"` + InErrorOversize yangInt64 `json:"in-error-oversize"` + InErrorMACInternal yangInt64 `json:"in-error-mac-internal"` + OutTotalPkts yangInt64 `json:"out-total-pkts"` + OutTotalOctets yangInt64 `json:"out-total-octets"` + OutGoodPkts yangInt64 `json:"out-good-pkts"` + OutGoodOctets yangInt64 `json:"out-good-octets"` + OutBroadcast yangInt64 `json:"out-broadcast"` + OutMulticast yangInt64 `json:"out-multicast"` +} + +// Template data structures. + +type interfacesData struct { + PageData + Interfaces []ifaceEntry + Error string +} + +type ifaceEntry struct { + HasMembers bool // is a bridge/LAG master with child ports + IsMember bool // is a bridge port or LAG member + IsLastMember bool // is the last child in its group + Forwarding bool // IP forwarding enabled (⇅ flag) + GroupID string // bridge/LAG name — set on parent and all its members + Name string + Type string + Status string + StatusUp bool + PhysAddr string + Addresses []addrEntry + Detail string // extra info: wifi AP, wireguard peers, etc. + Link string // ethernet speed + duplex e.g. "1 Gbps full"; empty for non-ethernet + RxBytes string + TxBytes string +} + +type addrEntry struct { + Address string + Origin string +} + +// InterfacesHandler serves the interfaces pages. +type InterfacesHandler struct { + Template *template.Template + DetailTemplate *template.Template + CountersTemplate *template.Template + RC *restconf.Client + Schema *schema.Cache +} + +// Overview renders the interfaces page (GET /interfaces). +func (h *InterfacesHandler) Overview(w http.ResponseWriter, r *http.Request) { + data := interfacesData{ + PageData: newPageData(w, r, "interfaces", "Interfaces"), + } + + var ( + ifaces interfacesWrapper + ri struct { + Routing struct { + Interfaces struct { + Interface []string `json:"interface"` + } `json:"interfaces"` + } `json:"ietf-routing:routing"` + } + ifaceErr error + wg sync.WaitGroup + ) + wg.Add(2) + go func() { + defer wg.Done() + ifaceErr = h.RC.Get(r.Context(), "/data/ietf-interfaces:interfaces", &ifaces) + }() + go func() { + defer wg.Done() + // Best-effort: ignore errors (routing may not be configured). + // Fetch the full routing object — the /interfaces sub-path returns empty + // on Infix even when the data is present in the parent resource. + h.RC.Get(r.Context(), "/data/ietf-routing:routing", &ri) //nolint:errcheck + }() + wg.Wait() + + if ifaceErr != nil { + log.Printf("restconf interfaces: %v", ifaceErr) + data.Error = "Could not fetch interface information" + } else { + fwdSet := make(map[string]bool, len(ri.Routing.Interfaces.Interface)) + for _, name := range ri.Routing.Interfaces.Interface { + fwdSet[name] = true + } + data.Interfaces = buildIfaceList(ifaces.Interfaces.Interface, fwdSet) + } + + tmplName := "interfaces.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +const ( + ifTypeEthernet = "ethernet" + ifTypeLoopback = "loopback" + ifTypePrefix = "infix-if-type:" // YANG module prefix for short-slug identities +) + +// prettyIfType converts a YANG interface type identity to the display +// name used by the Infix CLI (cli_pretty). +func prettyIfType(full string) string { + pretty := map[string]string{ + "bridge": "bridge", + "dummy": "dummy", + "ethernet": "ethernet", + "gre": "gre", + "gretap": "gretap", + "vxlan": "vxlan", + "wireguard": "wireguard", + "lag": "lag", + "loopback": "loopback", + "veth": "veth", + "vlan": "vlan", + "wifi": "wifi", + "other": "other", + "ethernetCsmacd": "ethernet", + "softwareLoopback": "loopback", + "l2vlan": "vlan", + "ieee8023adLag": "lag", + "ieee80211": "wifi", + "ilan": "veth", + } + + if i := strings.LastIndex(full, ":"); i >= 0 { + full = full[i+1:] + } + if name, ok := pretty[full]; ok { + return name + } + return full +} + +// buildIfaceList converts raw RESTCONF interface data into a flat, +// hierarchically ordered display list. Bridge/LAG members are grouped +// under their parent. fwdSet contains the names of interfaces with IP +// forwarding enabled (the ⇅ flag). +func buildIfaceList(raw []ifaceJSON, fwdSet map[string]bool) []ifaceEntry { + byName := map[string]*ifaceJSON{} + children := map[string][]string{} + childSet := map[string]bool{} + + for i := range raw { + iface := &raw[i] + byName[iface.Name] = iface + if iface.BridgePort != nil && iface.BridgePort.Bridge != "" { + parent := iface.BridgePort.Bridge + children[parent] = append(children[parent], iface.Name) + childSet[iface.Name] = true + } + } + + // Collect top-level interfaces (not bridge/LAG members) and sort them: + // loopback first, then alphabetically. + var topLevel []ifaceJSON + for _, iface := range raw { + if !childSet[iface.Name] { + topLevel = append(topLevel, iface) + } + } + sort.Slice(topLevel, func(i, j int) bool { + li := prettyIfType(topLevel[i].Type) == ifTypeLoopback + lj := prettyIfType(topLevel[j].Type) == ifTypeLoopback + if li != lj { + return li + } + return topLevel[i].Name < topLevel[j].Name + }) + + // Sort children of each bridge/LAG alphabetically. + for parent := range children { + sort.Strings(children[parent]) + } + + var result []ifaceEntry + + for _, iface := range topLevel { + e := makeIfaceEntry(iface, fwdSet) + members := children[iface.Name] + e.HasMembers = len(members) > 0 + if e.HasMembers { + e.GroupID = iface.Name + } + result = append(result, e) + + for i, childName := range members { + child, ok := byName[childName] + if !ok { + continue + } + me := makeIfaceEntry(*child, fwdSet) + me.IsMember = true + me.IsLastMember = i == len(members)-1 + me.GroupID = iface.Name + if child.BridgePort != nil && child.BridgePort.STP != nil && + child.BridgePort.STP.CIST != nil && child.BridgePort.STP.CIST.State != "" { + me.Status = child.BridgePort.STP.CIST.State + me.StatusUp = me.Status == "forwarding" + } + result = append(result, me) + } + } + + return result +} + +func makeIfaceEntry(iface ifaceJSON, fwdSet map[string]bool) ifaceEntry { + e := ifaceEntry{ + Forwarding: fwdSet[iface.Name], + Name: iface.Name, + Type: prettyIfType(iface.Type), + Status: iface.OperStatus, + StatusUp: iface.OperStatus == "up", + PhysAddr: iface.PhysAddress, + } + + if prettyIfType(iface.Type) == ifTypeEthernet { + duplex := "" + if iface.Ethernet != nil { + duplex = iface.Ethernet.Duplex + } + e.Link = formatEthernetLink(uint64(iface.Speed), duplex) + } + + if iface.Statistics != nil { + e.RxBytes = humanBytes(int64(iface.Statistics.InOctets)) + e.TxBytes = humanBytes(int64(iface.Statistics.OutOctets)) + } + + if iface.IPv4 != nil { + for _, a := range iface.IPv4.Address { + e.Addresses = append(e.Addresses, addrEntry{ + Address: fmt.Sprintf("%s/%d", a.IP, int(a.PrefixLength)), + Origin: a.Origin, + }) + } + } + if iface.IPv6 != nil { + for _, a := range iface.IPv6.Address { + e.Addresses = append(e.Addresses, addrEntry{ + Address: fmt.Sprintf("%s/%d", a.IP, int(a.PrefixLength)), + Origin: a.Origin, + }) + } + } + + if v := iface.Vlan; v != nil { + if v.LowerLayerIf != "" { + e.Detail = fmt.Sprintf("vid %d (%s)", v.ID, v.LowerLayerIf) + } else { + e.Detail = fmt.Sprintf("vid %d", v.ID) + } + } + + if iface.WiFi != nil { + if ap := iface.WiFi.AccessPoint; ap != nil { + n := len(ap.Stations.Station) + e.Detail = fmt.Sprintf("AP, ssid: %s, stations: %d", ap.SSID, n) + } else if st := iface.WiFi.Station; st != nil { + e.Detail = fmt.Sprintf("Station, ssid: %s", st.SSID) + } + } + + if wg := iface.WireGuard; wg != nil && wg.PeerStatus != nil { + total := len(wg.PeerStatus.Peer) + up := 0 + for _, p := range wg.PeerStatus.Peer { + if p.ConnectionStatus == "up" { + up++ + } + } + e.Detail = fmt.Sprintf("%d peers (%d up)", total, up) + } + + return e +} + +// Template data for the interface detail page. +type ifaceDetailData struct { + PageData + Name string + Type string + Status string + StatusUp bool + PhysAddr string + IfIndex int + MTU int + Speed string + Duplex string + AutoNeg string + PhyType string // e.g. "1000BASE-T" + PMDType string // e.g. "1000BASE-T" + SupportedPMDs []string // short names (what the PHY can do) + AdvertisedPMDs []string // short names (what autoneg announces) + Addresses []addrEntry + WiFiMode string // "Access Point" or "Station" + WiFiSSID string + WiFiSignal string + WiFiRxSpeed string + WiFiTxSpeed string + WiFiStationCount string // e.g. "3" for AP mode + WGPeerSummary string // e.g. "3 peers (2 up)" + Counters ifaceCounters + EthFrameStats []kvEntry + WGPeers []wgPeerEntry + WiFiStations []wifiStaEntry + ScanResults []wifiScanEntry + Desc map[string]string +} + +type ifaceCounters struct { + RxBytes string + RxUnicast string + RxBroadcast string + RxMulticast string + RxDiscards string + RxErrors string + TxBytes string + TxUnicast string + TxBroadcast string + TxMulticast string + TxDiscards string + TxErrors string +} + +type kvEntry struct { + Key string + Value string +} + +type wgPeerEntry struct { + PublicKey string + Status string + StatusUp bool + Endpoint string + Handshake string + TxBytes string + RxBytes string +} + +type wifiStaEntry struct { + MAC string + Signal string + SignalCSS string // "excellent", "good", "poor", "bad" + Time string + RxPkts string + TxPkts string + RxBytes string + TxBytes string + RxSpeed string + TxSpeed string +} + +type wifiScanEntry struct { + SSID string + BSSID string + Signal string + SignalCSS string + Channel string + Encryption string +} + +// fetchInterface retrieves a single interface by name from RESTCONF. +func (h *InterfacesHandler) fetchInterface(r *http.Request, name string) (*ifaceJSON, error) { + var all interfacesWrapper + if err := h.RC.Get(r.Context(), "/data/ietf-interfaces:interfaces", &all); err != nil { + return nil, err + } + for i := range all.Interfaces.Interface { + if all.Interfaces.Interface[i].Name == name { + return &all.Interfaces.Interface[i], nil + } + } + return nil, fmt.Errorf("interface %q not found", name) +} + +// buildDetailData converts raw RESTCONF interface data to template data. +func buildDetailData(r *http.Request, iface *ifaceJSON) ifaceDetailData { + d := ifaceDetailData{ + Name: iface.Name, + Type: prettyIfType(iface.Type), + Status: iface.OperStatus, + StatusUp: iface.OperStatus == "up", + PhysAddr: iface.PhysAddress, + IfIndex: iface.IfIndex, + } + + if iface.IPv4 != nil { + if iface.IPv4.MTU > 0 { + d.MTU = iface.IPv4.MTU + } + for _, a := range iface.IPv4.Address { + d.Addresses = append(d.Addresses, addrEntry{ + Address: fmt.Sprintf("%s/%d", a.IP, int(a.PrefixLength)), + Origin: a.Origin, + }) + } + } + if iface.IPv6 != nil { + for _, a := range iface.IPv6.Address { + d.Addresses = append(d.Addresses, addrEntry{ + Address: fmt.Sprintf("%s/%d", a.IP, int(a.PrefixLength)), + Origin: a.Origin, + }) + } + } + + if prettyIfType(iface.Type) == ifTypeEthernet { + d.Speed = formatEthernetLink(uint64(iface.Speed), "") + } + if iface.Ethernet != nil && prettyIfType(iface.Type) == ifTypeEthernet { + d.Duplex = iface.Ethernet.Duplex + d.PhyType = ShortenPMD(iface.Ethernet.PhyType) + d.PMDType = ShortenPMD(iface.Ethernet.PMDType) + d.SupportedPMDs = shortenPMDs(iface.Ethernet.SupportedPMDs) + if iface.Ethernet.AutoNegotiation != nil { + if iface.Ethernet.AutoNegotiation.Enable { + d.AutoNeg = "on" + } else { + d.AutoNeg = "off" + } + d.AdvertisedPMDs = shortenPMDs(iface.Ethernet.AutoNegotiation.AdvertisedPMDs) + } + if iface.Ethernet.Statistics != nil && iface.Ethernet.Statistics.Frame != nil { + d.EthFrameStats = buildEthFrameStats(iface.Ethernet.Statistics.Frame) + } + } + + if iface.Statistics != nil { + d.Counters = buildCounters(iface.Statistics) + } + + if iface.WiFi != nil { + if ap := iface.WiFi.AccessPoint; ap != nil { + d.WiFiMode = "Access Point" + d.WiFiSSID = ap.SSID + d.WiFiStationCount = fmt.Sprintf("%d", len(ap.Stations.Station)) + for _, s := range ap.Stations.Station { + d.WiFiStations = append(d.WiFiStations, buildWifiStaEntry(s)) + } + } else if st := iface.WiFi.Station; st != nil { + d.WiFiMode = "Station" + d.WiFiSSID = st.SSID + if st.SignalStrength != nil { + d.WiFiSignal = fmt.Sprintf("%d dBm", *st.SignalStrength) + } + if st.RxSpeed > 0 { + d.WiFiRxSpeed = fmt.Sprintf("%.1f Mbps", float64(st.RxSpeed)/10) + } + if st.TxSpeed > 0 { + d.WiFiTxSpeed = fmt.Sprintf("%.1f Mbps", float64(st.TxSpeed)/10) + } + for _, sr := range st.ScanResults { + d.ScanResults = append(d.ScanResults, buildWifiScanEntry(sr)) + } + } + } + + if wg := iface.WireGuard; wg != nil && wg.PeerStatus != nil { + total := len(wg.PeerStatus.Peer) + up := 0 + for _, p := range wg.PeerStatus.Peer { + pe := wgPeerEntry{ + PublicKey: p.PublicKey, + Status: p.ConnectionStatus, + StatusUp: p.ConnectionStatus == "up", + } + if p.EndpointAddress != "" { + pe.Endpoint = fmt.Sprintf("%s:%d", p.EndpointAddress, p.EndpointPort) + } + if p.LatestHandshake != "" { + pe.Handshake = p.LatestHandshake + } + if p.Transfer != nil { + pe.TxBytes = humanBytes(int64(p.Transfer.TxBytes)) + pe.RxBytes = humanBytes(int64(p.Transfer.RxBytes)) + } + if p.ConnectionStatus == "up" { + up++ + } + d.WGPeers = append(d.WGPeers, pe) + } + d.WGPeerSummary = fmt.Sprintf("%d peers (%d up)", total, up) + } + + return d +} + +func buildCounters(s *ifaceStats) ifaceCounters { + return ifaceCounters{ + RxBytes: humanBytes(int64(s.InOctets)), + RxUnicast: formatCount(int64(s.InUnicastPkts)), + RxBroadcast: formatCount(int64(s.InBroadcastPkts)), + RxMulticast: formatCount(int64(s.InMulticastPkts)), + RxDiscards: formatCount(int64(s.InDiscards)), + RxErrors: formatCount(int64(s.InErrors)), + TxBytes: humanBytes(int64(s.OutOctets)), + TxUnicast: formatCount(int64(s.OutUnicastPkts)), + TxBroadcast: formatCount(int64(s.OutBroadcastPkts)), + TxMulticast: formatCount(int64(s.OutMulticastPkts)), + TxDiscards: formatCount(int64(s.OutDiscards)), + TxErrors: formatCount(int64(s.OutErrors)), + } +} + +func buildEthFrameStats(f *ethFrameStats) []kvEntry { + return []kvEntry{ + {"eth-in-frames", formatCount(int64(f.InTotalPkts))}, + {"eth-in-octets", humanBytes(int64(f.InTotalOctets))}, + {"eth-in-good-frames", formatCount(int64(f.InGoodPkts))}, + {"eth-in-good-octets", humanBytes(int64(f.InGoodOctets))}, + {"eth-in-broadcast", formatCount(int64(f.InBroadcast))}, + {"eth-in-multicast", formatCount(int64(f.InMulticast))}, + {"eth-in-fcs-error", formatCount(int64(f.InErrorFCS))}, + {"eth-in-undersize", formatCount(int64(f.InErrorUndersize))}, + {"eth-in-oversize", formatCount(int64(f.InErrorOversize))}, + {"eth-in-mac-error", formatCount(int64(f.InErrorMACInternal))}, + {"eth-out-frames", formatCount(int64(f.OutTotalPkts))}, + {"eth-out-octets", humanBytes(int64(f.OutTotalOctets))}, + {"eth-out-good-frames", formatCount(int64(f.OutGoodPkts))}, + {"eth-out-good-octets", humanBytes(int64(f.OutGoodOctets))}, + {"eth-out-broadcast", formatCount(int64(f.OutBroadcast))}, + {"eth-out-multicast", formatCount(int64(f.OutMulticast))}, + } +} + +// ShortenPMD turns a PHY/PMD identityref into its IEEE shorthand, +// e.g. "ieee802-ethernet-phy-type:pmd-type-1000BASE-T" → "1000BASE-T". +// Exported so the configure-interfaces template can call it via funcmap. +func ShortenPMD(s string) string { + s = schema.StripModulePrefix(s) + for _, prefix := range []string{"pmd-type-", "phy-type-"} { + if strings.HasPrefix(s, prefix) { + return s[len(prefix):] + } + } + return s +} + +func shortenPMDs(pmds []string) []string { + if len(pmds) == 0 { + return nil + } + out := make([]string, len(pmds)) + for i, p := range pmds { + out[i] = ShortenPMD(p) + } + // Sort by line rate so Supported and Advertised line up + // row-by-row in the comparison view — ethtool --json doesn't + // guarantee a consistent order between the two lists. + slices.SortStableFunc(out, func(a, b string) int { + return pmdSpeedMbps(a) - pmdSpeedMbps(b) + }) + return out +} + +// pmdSpeedMbps extracts the line rate in megabits per second from a short +// PMD name like "10BASE-T", "2.5GBASE-T", or "100GBASE-CR4". Unknown +// strings sort as 0. +func pmdSpeedMbps(name string) int { + i := 0 + for i < len(name) && (name[i] >= '0' && name[i] <= '9' || name[i] == '.') { + i++ + } + if i == 0 { + return 0 + } + n, err := strconv.ParseFloat(name[:i], 64) + if err != nil { + return 0 + } + if i < len(name) && name[i] == 'G' { + n *= 1000 + } + return int(n) +} + +// formatEthernetLink renders the ietf-interfaces:speed (yang:gauge64 bits/s) +// plus duplex as "1 Gbps full" / "100 Mbps half"; empty when speed is 0. +func formatEthernetLink(bps uint64, duplex string) string { + if bps == 0 { + return "" + } + var s string + switch { + case bps >= 1_000_000_000: + gbps := float64(bps) / 1_000_000_000 + s = strconv.FormatFloat(gbps, 'f', -1, 64) + " Gbps" + case bps >= 1_000_000: + s = strconv.FormatUint(bps/1_000_000, 10) + " Mbps" + default: + s = strconv.FormatUint(bps/1_000, 10) + " kbps" + } + if duplex != "" { + s += " " + duplex + } + return s +} + +func buildWifiStaEntry(s wifiStaJSON) wifiStaEntry { + e := wifiStaEntry{ + MAC: s.MACAddress, + Time: formatDuration(int64(s.ConnectedTime)), + RxPkts: formatCount(int64(s.RxPackets)), + TxPkts: formatCount(int64(s.TxPackets)), + RxBytes: humanBytes(int64(s.RxBytes)), + TxBytes: humanBytes(int64(s.TxBytes)), + RxSpeed: fmt.Sprintf("%.1f Mbps", float64(s.RxSpeed)/10), + TxSpeed: fmt.Sprintf("%.1f Mbps", float64(s.TxSpeed)/10), + } + if s.SignalStrength != nil { + sig := *s.SignalStrength + e.Signal = fmt.Sprintf("%d dBm", sig) + switch { + case sig >= -50: + e.SignalCSS = "excellent" + case sig >= -60: + e.SignalCSS = "good" + case sig >= -70: + e.SignalCSS = "poor" + default: + e.SignalCSS = "bad" + } + } + return e +} + +func buildWifiScanEntry(sr wifiScanResultJSON) wifiScanEntry { + e := wifiScanEntry{ + SSID: sr.SSID, + BSSID: sr.BSSID, + Channel: fmt.Sprintf("%d", sr.Channel), + } + if len(sr.Encryption) > 0 { + e.Encryption = strings.Join(sr.Encryption, ", ") + } else { + e.Encryption = "Open" + } + if sr.SignalStrength != nil { + sig := *sr.SignalStrength + e.Signal = fmt.Sprintf("%d dBm", sig) + switch { + case sig >= -50: + e.SignalCSS = "excellent" + case sig >= -60: + e.SignalCSS = "good" + case sig >= -70: + e.SignalCSS = "poor" + default: + e.SignalCSS = "bad" + } + } + return e +} + +func formatDuration(secs int64) string { + if secs < 60 { + return fmt.Sprintf("%ds", secs) + } + if secs < 3600 { + return fmt.Sprintf("%dm %ds", secs/60, secs%60) + } + h := secs / 3600 + m := (secs % 3600) / 60 + return fmt.Sprintf("%dh %dm", h, m) +} + +// formatCount formats a packet/frame count with thousand separators. +func formatCount(n int64) string { + if n == 0 { + return "0" + } + s := fmt.Sprintf("%d", n) + // Insert thousand separators from the right. + var result []byte + for i, c := range s { + if i > 0 && (len(s)-i)%3 == 0 { + result = append(result, ',') + } + result = append(result, byte(c)) + } + return string(result) +} + +// Detail renders the interface detail page (GET /interfaces/{name}). +func (h *InterfacesHandler) Detail(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + + iface, err := h.fetchInterface(r, name) + if err != nil { + log.Printf("restconf interface %s: %v", name, err) + http.Error(w, "Interface not found", http.StatusNotFound) + return + } + + data := buildDetailData(r, iface) + data.PageData = newPageData(w, r, "interfaces", name) + data.Desc = h.fieldDescriptions() + + tmplName := "iface-detail.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.DetailTemplate.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// fieldDescriptions returns YANG-sourced hover descriptions for the rows +// rendered on the interface-detail page. The keys correspond to the +// field-info template invocations in iface-detail.html. +func (h *InterfacesHandler) fieldDescriptions() map[string]string { + mgr := h.Schema.Manager() + if mgr == nil { + return nil + } + ifPath := "/ietf-interfaces:interfaces/interface" + ethPath := ifPath + "/ieee802-ethernet-interface:ethernet" + return map[string]string{ + "mtu": schema.DescriptionOf(mgr, ifPath+"/ietf-ip:ipv4/mtu"), + "speed": schema.DescriptionOf(mgr, ifPath+"/speed"), + "duplex": schema.DescriptionOf(mgr, ethPath+"/duplex"), + "autoneg": schema.DescriptionOf(mgr, ethPath+"/auto-negotiation/enable"), + "phy-type": schema.DescriptionOf(mgr, ethPath+"/phy-type"), + "pmd-type": schema.DescriptionOf(mgr, ethPath+"/pmd-type"), + "supported": schema.DescriptionOf(mgr, ethPath+"/infix-ethernet-interface:supported-pmd-types"), + "advertised": schema.DescriptionOf(mgr, ethPath+"/auto-negotiation/infix-ethernet-interface:advertised-pmd-types"), + } +} + +// Counters renders the counters fragment for htmx polling (GET /interfaces/{name}/counters). +func (h *InterfacesHandler) Counters(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + + iface, err := h.fetchInterface(r, name) + if err != nil { + log.Printf("restconf interface %s counters: %v", name, err) + http.Error(w, "Interface not found", http.StatusNotFound) + return + } + + data := buildDetailData(r, iface) + + if err := h.CountersTemplate.ExecuteTemplate(w, "iface-counters", data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// freqToChannel converts a WiFi center frequency (MHz) to a channel number. +func freqToChannel(freq int) int { + switch { + case freq == 2484: + return 14 + case freq >= 2412 && freq <= 2472: + return (freq - 2407) / 5 + case freq >= 5180 && freq <= 5885: + return (freq - 5000) / 5 + case freq >= 5955 && freq <= 7115: + return (freq - 5950) / 5 + default: + return 0 + } +} + +// renderSurveySVG generates an inline SVG bar chart visualizing WiFi channel +// survey data. Each channel gets a stacked bar showing receive, transmit, +// and other busy time as a percentage of active time. A dashed noise-floor +// line is overlaid with a right-side dBm axis. The in-use channel is marked +// with a triangle. +func renderSurveySVG(channels []surveyChanJSON) template.HTML { + n := len(channels) + if n == 0 { + return "" + } + + sorted := make([]surveyChanJSON, n) + copy(sorted, channels) + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Frequency < sorted[j].Frequency + }) + + // Layout constants. + const chartH = 200 + padL, padR, padT, padB := 44, 48, 28, 58 + + slotW := 600.0 / float64(n) + if slotW > 44 { + slotW = 44 + } + if slotW < 16 { + slotW = 16 + } + barW := slotW * 0.65 + chartW := slotW * float64(n) + svgW := int(chartW) + padL + padR + svgH := chartH + padT + padB + + // Noise range for right axis. + hasNoise := false + noiseMin, noiseMax := 0, 0 + for i, ch := range sorted { + if ch.Noise != 0 { + if !hasNoise { + noiseMin, noiseMax = ch.Noise, ch.Noise + hasNoise = true + } + if ch.Noise < noiseMin { + noiseMin = ch.Noise + } + if ch.Noise > noiseMax { + noiseMax = ch.Noise + } + } + _ = i + } + nFloor := int(math.Floor(float64(noiseMin)/5))*5 - 5 + nCeil := int(math.Ceil(float64(noiseMax)/5))*5 + 5 + nRange := float64(nCeil - nFloor) + if nRange == 0 { + nRange = 10 + } + + var b strings.Builder + + fmt.Fprintf(&b, `<svg viewBox="0 0 %d %d" xmlns="http://www.w3.org/2000/svg" class="survey-chart">`, + svgW, svgH) + + // Y-axis grid lines and labels (utilization %). + for _, pct := range []int{0, 25, 50, 75, 100} { + y := padT + chartH - pct*chartH/100 + fmt.Fprintf(&b, `<line x1="%d" y1="%d" x2="%.0f" y2="%d" stroke="#e5e7eb" stroke-width="1"/>`, + padL, y, float64(padL)+chartW, y) + fmt.Fprintf(&b, `<text x="%d" y="%d" text-anchor="end" fill="#9ca3af" font-size="10">%d%%</text>`, + padL-4, y+4, pct) + } + + // Right Y-axis labels (noise dBm). + if hasNoise { + nMid := (nFloor + nCeil) / 2 + for _, db := range []int{nFloor + 5, nMid, nCeil - 5} { + ny := float64(padT+chartH) - float64(db-nFloor)/nRange*float64(chartH) + fmt.Fprintf(&b, `<text x="%.0f" y="%.0f" text-anchor="start" `+ + `fill="#ef4444" font-size="10" opacity="0.8">%d</text>`, + float64(padL)+chartW+4, ny+3, db) + } + } + + // Draw bars and collect noise line points. + var noisePts []string + + for i, ch := range sorted { + cx := float64(padL) + float64(i)*slotW + slotW/2 + bx := cx - barW/2 + + var rxPct, txPct, otherPct float64 + if ch.ActiveTime > 0 { + act := float64(ch.ActiveTime) + rxPct = float64(ch.ReceiveTime) / act * 100 + txPct = float64(ch.TransmitTime) / act * 100 + otherPct = float64(ch.BusyTime)/act*100 - rxPct - txPct + if otherPct < 0 { + otherPct = 0 + } + if total := rxPct + txPct + otherPct; total > 100 { + s := 100 / total + rxPct *= s + txPct *= s + otherPct *= s + } + } + + baseY := float64(padT + chartH) + + // Stacked: other busy (bottom), transmit (middle), receive (top). + if otherPct > 0.5 { + h := otherPct / 100 * float64(chartH) + fmt.Fprintf(&b, `<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="#f59e0b" rx="1.5"/>`, + bx, baseY-h, barW, h) + baseY -= h + } + if txPct > 0.5 { + h := txPct / 100 * float64(chartH) + fmt.Fprintf(&b, `<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="#22c55e" rx="1.5"/>`, + bx, baseY-h, barW, h) + baseY -= h + } + if rxPct > 0.5 { + h := rxPct / 100 * float64(chartH) + fmt.Fprintf(&b, `<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="#3b82f6" rx="1.5"/>`, + bx, baseY-h, barW, h) + } + + // In-use marker (triangle above bar). + if ch.InUse { + totalPct := rxPct + txPct + otherPct + topY := float64(padT+chartH) - totalPct/100*float64(chartH) + fmt.Fprintf(&b, `<polygon points="%.1f,%.1f %.1f,%.1f %.1f,%.1f" fill="#2563eb"/>`, + cx, topY-3, cx-4, topY-11, cx+4, topY-11) + } + + // Channel label on X-axis. + chNum := freqToChannel(ch.Frequency) + label := fmt.Sprintf("%d", chNum) + if chNum == 0 { + label = fmt.Sprintf("%d", ch.Frequency) + } + fmt.Fprintf(&b, `<text x="%.1f" y="%d" text-anchor="middle" fill="#6b7280" font-size="10">%s</text>`, + cx, padT+chartH+14, label) + + // Noise line point. + if hasNoise && ch.Noise != 0 { + ny := float64(padT+chartH) - float64(ch.Noise-nFloor)/nRange*float64(chartH) + noisePts = append(noisePts, fmt.Sprintf("%.1f,%.1f", cx, ny)) + } + } + + // Draw noise floor line. + if len(noisePts) > 1 { + fmt.Fprintf(&b, `<polyline points="%s" fill="none" stroke="#ef4444" `+ + `stroke-width="1.5" stroke-dasharray="4,3" opacity="0.7"/>`, + strings.Join(noisePts, " ")) + for _, pt := range noisePts { + parts := strings.SplitN(pt, ",", 2) + fmt.Fprintf(&b, `<circle cx="%s" cy="%s" r="2.5" fill="#ef4444" opacity="0.7"/>`, + parts[0], parts[1]) + } + } + + // Legend row. + ly := svgH - 8 + lx := float64(padL) + + for _, item := range []struct{ color, label string }{ + {"#3b82f6", "Rx"}, + {"#22c55e", "Tx"}, + {"#f59e0b", "Other"}, + } { + fmt.Fprintf(&b, `<rect x="%.0f" y="%d" width="10" height="10" fill="%s" rx="1.5"/>`, + lx, ly-9, item.color) + fmt.Fprintf(&b, `<text x="%.0f" y="%d" fill="#6b7280" font-size="10">%s</text>`, + lx+13, ly, item.label) + lx += 13 + float64(len(item.label))*7 + 10 + } + + if hasNoise { + fmt.Fprintf(&b, `<line x1="%.0f" y1="%d" x2="%.0f" y2="%d" `+ + `stroke="#ef4444" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.7"/>`, + lx, ly-4, lx+14, ly-4) + lx += 18 + fmt.Fprintf(&b, `<text x="%.0f" y="%d" fill="#ef4444" font-size="10" opacity="0.8">Noise (dBm)</text>`, + lx, ly) + lx += 80 + } + + // In-use legend marker. + fmt.Fprintf(&b, `<polygon points="%.0f,%d %.0f,%d %.0f,%d" fill="#2563eb"/>`, + lx+5, ly-9, lx+1, ly-1, lx+9, ly-1) + fmt.Fprintf(&b, `<text x="%.0f" y="%d" fill="#6b7280" font-size="10">In use</text>`, + lx+14, ly) + + b.WriteString(`</svg>`) + return template.HTML(b.String()) +} diff --git a/src/webui/internal/handlers/keystore.go b/src/webui/internal/handlers/keystore.go new file mode 100644 index 00000000..0b86b3c1 --- /dev/null +++ b/src/webui/internal/handlers/keystore.go @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "encoding/base64" + "strings" +) + +// RESTCONF JSON structures for ietf-keystore:keystore. + +type keystoreWrapper struct { + Keystore keystoreJSON `json:"ietf-keystore:keystore"` +} + +type keystoreJSON struct { + SymmetricKeys symmetricKeysJSON `json:"symmetric-keys"` + AsymmetricKeys asymmetricKeysJSON `json:"asymmetric-keys"` +} + +type symmetricKeysJSON struct { + SymmetricKey []symmetricKeyJSON `json:"symmetric-key"` +} + +type symmetricKeyJSON struct { + Name string `json:"name"` + KeyFormat string `json:"key-format"` + CleartextSymmetricKey string `json:"cleartext-symmetric-key"` +} + +type asymmetricKeysJSON struct { + AsymmetricKey []asymmetricKeyJSON `json:"asymmetric-key"` +} + +type asymmetricKeyJSON struct { + Name string `json:"name"` + PrivateKeyFormat string `json:"private-key-format"` + PublicKeyFormat string `json:"public-key-format"` + PublicKey string `json:"public-key"` + CleartextPrivateKey string `json:"cleartext-private-key"` + Certificates certificatesJSON `json:"certificates"` +} + +type certificatesJSON struct { + Certificate []certificateJSON `json:"certificate"` +} + +type certificateJSON struct { + Name string `json:"name"` + CertData string `json:"cert-data"` +} + +// shortFormat strips the YANG module prefix and "-key-format" suffix. +// e.g. "ietf-crypto-types:octet-string-key-format" → "octet-string" +func shortFormat(full string) string { + if i := strings.LastIndex(full, ":"); i >= 0 { + full = full[i+1:] + } + full = strings.TrimSuffix(full, "-key-format") + full = strings.TrimSuffix(full, "-private-key-format") + full = strings.TrimSuffix(full, "-public-key-format") + return full +} + +// asymAlgorithm derives the key algorithm from the format fields. +func asymAlgorithm(k asymmetricKeyJSON) string { + for _, fmt := range []string{k.PrivateKeyFormat, k.PublicKeyFormat} { + name := shortFormat(fmt) + if name != "" { + return name + } + } + return "" +} + +// decodeSymmetricValue returns the displayable value for a symmetric key. +// Passphrases are base64-decoded to plaintext; others shown as-is. +func decodeSymmetricValue(k symmetricKeyJSON) string { + val := k.CleartextSymmetricKey + if val == "" { + return "-" + } + if shortFormat(k.KeyFormat) == "passphrase" { + if decoded, err := base64.StdEncoding.DecodeString(val); err == nil { + return string(decoded) + } + } + return val +} diff --git a/src/webui/internal/handlers/lldp.go b/src/webui/internal/handlers/lldp.go new file mode 100644 index 00000000..7893d18c --- /dev/null +++ b/src/webui/internal/handlers/lldp.go @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "context" + "encoding/json" + "html/template" + "log" + "net/http" + "strings" + + "infix/webui/internal/restconf" +) + +// ─── LLDP types ────────────────────────────────────────────────────────────── + +// LLDPNeighbor is a remote system seen via LLDP. +type LLDPNeighbor struct { + LocalPort string + ChassisID string + SystemName string + PortID string + PortDesc string + SystemDesc string + Capabilities string // comma-separated + MgmtAddress string +} + +// ─── Page data ─────────────────────────────────────────────────────────────── + +type lldpPageData struct { + PageData + Neighbors []LLDPNeighbor + Error string +} + +// ─── Handler ───────────────────────────────────────────────────────────────── + +// LLDPHandler serves the LLDP neighbors page. +type LLDPHandler struct { + Template *template.Template + RC *restconf.Client +} + +// Overview renders the LLDP page (GET /lldp). +func (h *LLDPHandler) Overview(w http.ResponseWriter, r *http.Request) { + data := lldpPageData{ + PageData: newPageData(w, r, "lldp", "LLDP"), + } + + ctx := context.WithoutCancel(r.Context()) + + var raw struct { + LLDP struct { + Port []struct { + Name string `json:"name"` + DestMACAddress string `json:"dest-mac-address"` + RemoteSystemsData []struct { + ChassisID string `json:"chassis-id"` + PortID string `json:"port-id"` + PortDesc string `json:"port-desc"` + SystemName string `json:"system-name"` + SystemDescription string `json:"system-description"` + SystemCapabilitiesEnabled json.RawMessage `json:"system-capabilities-enabled"` + ManagementAddress []struct { + Address string `json:"address"` + } `json:"management-address"` + } `json:"remote-systems-data"` + } `json:"port"` + } `json:"ieee802-dot1ab-lldp:lldp"` + } + if err := h.RC.Get(ctx, "/data/ieee802-dot1ab-lldp:lldp", &raw); err != nil { + log.Printf("restconf lldp: %v", err) + data.Error = "Failed to fetch LLDP data" + } else { + for _, port := range raw.LLDP.Port { + for _, rs := range port.RemoteSystemsData { + mgmt := "" + if len(rs.ManagementAddress) > 0 { + mgmt = rs.ManagementAddress[0].Address + } + data.Neighbors = append(data.Neighbors, LLDPNeighbor{ + LocalPort: port.Name, + ChassisID: rs.ChassisID, + SystemName: rs.SystemName, + PortID: rs.PortID, + PortDesc: rs.PortDesc, + SystemDesc: rs.SystemDescription, + Capabilities: parseLLDPCapabilities(rs.SystemCapabilitiesEnabled), + MgmtAddress: mgmt, + }) + } + } + } + + tmplName := "lldp.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +// parseLLDPCapabilities turns the YANG system-capabilities-enabled bits +// value into a readable comma-separated string. +func parseLLDPCapabilities(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + // Try plain string first (some implementations encode as "bridge router") + var s string + if json.Unmarshal(raw, &s) == nil { + parts := strings.Fields(s) + return strings.Join(parts, ", ") + } + // Try array of strings + var arr []string + if json.Unmarshal(raw, &arr) == nil { + return strings.Join(arr, ", ") + } + // Fallback: return raw minus braces + trimmed := strings.TrimSpace(string(raw)) + if trimmed == "{}" || trimmed == "null" || trimmed == "[]" { + return "" + } + return trimmed +} diff --git a/src/webui/internal/handlers/logs.go b/src/webui/internal/handlers/logs.go new file mode 100644 index 00000000..740c4dc6 --- /dev/null +++ b/src/webui/internal/handlers/logs.go @@ -0,0 +1,556 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "bufio" + "compress/gzip" + "context" + "fmt" + "html/template" + "io" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "time" +) + +// LogsHandler serves the Maintenance > Logs page. Reads from a fixed +// allow-list of /var/log files; the file path is never user-controlled, +// so no traversal risk. Live tail spawns `tail -F` whose lifetime is +// tied to the SSE request context — client disconnect kills the child. +type LogsHandler struct { + Template *template.Template +} + +// ─── Source allow-list ─────────────────────────────────────────────── + +type logSource struct { + Key string + Label string + Path string +} + +var logSources = []logSource{ + {"messages", "Messages", "/var/log/messages"}, + {"syslog", "Syslog", "/var/log/syslog"}, + {"kern", "Kernel", "/var/log/kern.log"}, + {"auth", "Auth", "/var/log/auth.log"}, + {"routing", "Routing", "/var/log/routing"}, + {"firewall", "Firewall", "/var/log/firewall.log"}, + {"upgrade", "Upgrade", "/var/log/upgrade.log"}, + {"debug", "Debug", "/var/log/debug"}, + {"container", "Container", "/var/log/container"}, + {"mail", "Mail", "/var/log/mail.log"}, +} + +const ( + initialLines = 250 + earlierLines = 250 + tailFlushDelay = 200 * time.Millisecond + tailMaxBatch = 50 + tailHeartbeatMs = 15 * time.Second +) + +func lookupSource(key string) (logSource, bool) { + for _, s := range logSources { + if s.Key == key { + return s, true + } + } + return logSource{}, false +} + +// ─── Template data ─────────────────────────────────────────────────── + +type logsPageData struct { + PageData + Sources []sourceMeta + Active string + Source sourceContent +} + +type sourceMeta struct { + Key string + Label string + Path string + Empty bool +} + +// loadCursor describes the next Load earlier / Load previous action. +// File is "current" while still paging within the active log file, or +// "rot0", "rot1", … once the user has crossed into older rotations. +// FileLabel is the rotation file's basename, shown on the button when +// IsPrev is true. A nil cursor means "no more older content." +type loadCursor struct { + File string + Skip int + FileLabel string + IsPrev bool +} + +type sourceContent struct { + sourceMeta + Lines []logLine + Next *loadCursor // nil when no older content remains +} + +type logLine struct { + Text string + Class string +} + +// earlierData mirrors sourceContent's field names for the logs-earlier +// template so the same fragment renders both the initial buffer and +// the Earlier-handler response. +type earlierData struct { + Key string + Lines []logLine + Next *loadCursor +} + +// ─── Classification ────────────────────────────────────────────────── + +func classify(text string) string { + lower := strings.ToLower(text) + for _, kw := range []string{"panic", "kernel: bug", "fatal", "error", "fail", "crit"} { + if strings.Contains(lower, kw) { + return "err" + } + } + for _, kw := range []string{"warning", "warn:", "warn ", "deprecated"} { + if strings.Contains(lower, kw) { + return "warn" + } + } + return "" +} + +func classifyLines(raw []string) []logLine { + out := make([]logLine, len(raw)) + for i, t := range raw { + out[i] = logLine{Text: t, Class: classify(t)} + } + return out +} + +// ─── File reading ──────────────────────────────────────────────────── + +func openReader(path string) (io.Reader, func(), error) { + f, err := os.Open(path) + if err != nil { + return nil, nil, err + } + if strings.HasSuffix(path, ".gz") { + zr, err := gzip.NewReader(f) + if err != nil { + f.Close() + return nil, nil, err + } + return zr, func() { zr.Close(); f.Close() }, nil + } + return f, func() { f.Close() }, nil +} + +func readAllLines(path string) ([]string, error) { + r, done, err := openReader(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + defer done() + + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 256*1024), 1024*1024) + + var lines []string + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + return lines, scanner.Err() +} + +// rotationsFor returns rotation files for a source, newest first. The +// current file is NOT included. Suffix order matches logrotate +// convention: .0 = most recent rotation, .1 / .1.gz next, and so on. +// Files that don't match the numeric suffix pattern are ignored. +func rotationsFor(path string) []string { + matches, err := filepath.Glob(path + ".*") + if err != nil { + return nil + } + type rot struct { + Path string + N int + } + var rots []rot + for _, m := range matches { + rest := strings.TrimPrefix(m, path+".") + rest = strings.TrimSuffix(rest, ".gz") + if n, err := strconv.Atoi(rest); err == nil { + rots = append(rots, rot{Path: m, N: n}) + } + } + sort.Slice(rots, func(i, j int) bool { return rots[i].N < rots[j].N }) + + out := make([]string, len(rots)) + for i, r := range rots { + out[i] = r.Path + } + return out +} + +// resolveCursor maps a cursor's File field to the actual filesystem +// path. Returns the empty string if the cursor points past the end of +// available history (e.g. "rot3" on a source with only two rotations). +func resolveCursor(src logSource, file string) string { + if file == "" || file == "current" { + return src.Path + } + if !strings.HasPrefix(file, "rot") { + return "" + } + idx, err := strconv.Atoi(strings.TrimPrefix(file, "rot")) + if err != nil || idx < 0 { + return "" + } + rots := rotationsFor(src.Path) + if idx >= len(rots) { + return "" + } + return rots[idx] +} + +// nextCursor computes the cursor for the click AFTER the one we're +// about to serve. `file` is the file just read; `skip+returned` is +// where in that file the next paging click would resume. When the +// file is exhausted we cross into the next older rotation; when no +// more rotations exist, returns nil. +func nextCursor(src logSource, file string, skip, returned, total int) *loadCursor { + nextSkip := skip + returned + if nextSkip < total { + // Same file — continuing back through it. + c := &loadCursor{File: file, Skip: nextSkip} + if file != "current" { + c.IsPrev = true + c.FileLabel = filepath.Base(resolveCursor(src, file)) + } + return c + } + + // Current/this file exhausted. Cross into next older rotation. + var nextIdx int + if file == "current" { + nextIdx = 0 + } else { + idx, _ := strconv.Atoi(strings.TrimPrefix(file, "rot")) + nextIdx = idx + 1 + } + rots := rotationsFor(src.Path) + if nextIdx >= len(rots) { + return nil + } + return &loadCursor{ + File: "rot" + strconv.Itoa(nextIdx), + Skip: 0, + FileLabel: filepath.Base(rots[nextIdx]), + IsPrev: true, + } +} + +// readFileWindow returns up to `limit` lines from the end of `path`, +// skipping the last `skip` lines, plus the file's total line count. +func readFileWindow(path string, skip, limit int) (lines []string, total int, err error) { + all, err := readAllLines(path) + if err != nil { + return nil, 0, err + } + total = len(all) + end := total - skip + if end < 0 { + end = 0 + } + start := end - limit + if start < 0 { + start = 0 + } + return all[start:end], total, nil +} + +func sourceMetas() []sourceMeta { + out := make([]sourceMeta, 0, len(logSources)) + for _, s := range logSources { + empty := true + if info, err := os.Stat(s.Path); err == nil { + empty = info.Size() == 0 + } + out = append(out, sourceMeta{Key: s.Key, Label: s.Label, Path: s.Path, Empty: empty}) + } + return out +} + +func buildContent(src logSource) sourceContent { + lines, total, err := readFileWindow(src.Path, 0, initialLines) + if err != nil { + log.Printf("logs read %s: %v", src.Key, err) + } + return sourceContent{ + sourceMeta: sourceMeta{Key: src.Key, Label: src.Label, Path: src.Path, Empty: total == 0}, + Lines: classifyLines(lines), + Next: nextCursor(src, "current", 0, len(lines), total), + } +} + +// ─── Handlers ──────────────────────────────────────────────────────── + +func (h *LogsHandler) Overview(w http.ResponseWriter, r *http.Request) { + src, _ := lookupSource("messages") + data := logsPageData{ + PageData: newPageData(w, r, "logs", "Logs"), + Sources: sourceMetas(), + Active: "messages", + Source: buildContent(src), + } + tmplName := "logs.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +func (h *LogsHandler) Fragment(w http.ResponseWriter, r *http.Request) { + key := r.PathValue("name") + src, ok := lookupSource(key) + if !ok { + http.NotFound(w, r) + return + } + data := logsPageData{ + Sources: sourceMetas(), + Active: key, + Source: buildContent(src), + } + if err := h.Template.ExecuteTemplate(w, "logs-card", data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// Earlier returns the next page of older lines plus the button to load +// the page after that. The button does an outerHTML swap on itself so +// the OLD button is replaced by [NEW button, NEW lines], keeping +// chronological order in the DOM. +// GET /maintenance/logs/{name}/earlier?file=current|rot0|…&skip=N +func (h *LogsHandler) Earlier(w http.ResponseWriter, r *http.Request) { + key := r.PathValue("name") + src, ok := lookupSource(key) + if !ok { + http.NotFound(w, r) + return + } + file := r.URL.Query().Get("file") + if file == "" { + file = "current" + } + path := resolveCursor(src, file) + if path == "" { + http.NotFound(w, r) + return + } + skip, _ := strconv.Atoi(r.URL.Query().Get("skip")) + + lines, total, err := readFileWindow(path, skip, earlierLines) + if err != nil { + log.Printf("logs earlier %s: %v", path, err) + } + data := earlierData{ + Key: key, + Lines: classifyLines(lines), + Next: nextCursor(src, file, skip, len(lines), total), + } + if err := h.Template.ExecuteTemplate(w, "logs-earlier", data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// Tail streams new lines as Server-Sent Events. `tail -F -n 0` +// handles rotation reopens and starts from "now" — the client already +// has the existing buffer rendered. Lines are debounced and emitted +// in HTML fragments matching the regular logs-line markup, so the +// client just appends. +// GET /maintenance/logs/{name}/tail +func (h *LogsHandler) Tail(w http.ResponseWriter, r *http.Request) { + key := r.PathValue("name") + src, ok := lookupSource(key) + if !ok { + http.NotFound(w, r) + return + } + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + flusher.Flush() + + // Send an immediate comment so the response body has a few bytes on + // the wire right away. Some intermediate proxies hold the headers + // until they see enough body content to commit to a buffering + // strategy; without this nudge, EventSource onopen can wait many + // seconds before firing on the client side. + fmt.Fprint(w, ": stream opened\n\n") + flusher.Flush() + + ctx, cancel := context.WithCancel(r.Context()) + defer cancel() + + cmd := exec.CommandContext(ctx, "tail", "-F", "-n", "0", src.Path) + stdout, err := cmd.StdoutPipe() + if err != nil { + log.Printf("logs tail stdout %s: %v", key, err) + return + } + if err := cmd.Start(); err != nil { + log.Printf("logs tail start %s: %v", key, err) + return + } + // Without this Wait, every client reconnect leaves the killed tail + // child as a zombie until webui itself exits — the SSE auto-reconnect + // cycle accumulated 50+ <defunct> tails in a few minutes of idle. + // cmd.Wait blocks until the process is reaped; CommandContext SIGKILLs + // it on ctx cancel, so Wait returns "signal: killed" which we ignore. + defer func() { _ = cmd.Wait() }() + + linesCh := make(chan string, 200) + go func() { + defer close(linesCh) + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 256*1024), 1024*1024) + for scanner.Scan() { + select { + case linesCh <- scanner.Text(): + case <-ctx.Done(): + return + } + } + }() + + flush := func(pending []string) { + if len(pending) == 0 { + return + } + var buf strings.Builder + for _, text := range pending { + cls := classify(text) + if cls != "" { + fmt.Fprintf(&buf, `<div class="logs-line logs-%s">%s</div>`, cls, template.HTMLEscapeString(text)) + } else { + fmt.Fprintf(&buf, `<div class="logs-line">%s</div>`, template.HTMLEscapeString(text)) + } + } + payload := strings.ReplaceAll(buf.String(), "\n", " ") + fmt.Fprintf(w, "event: lines\ndata: %s\n\n", payload) + flusher.Flush() + } + + heartbeat := time.NewTicker(tailHeartbeatMs) + defer heartbeat.Stop() + + var pending []string + var batchTimer *time.Timer + var batchC <-chan time.Time + + for { + select { + case <-ctx.Done(): + return + case text, ok := <-linesCh: + if !ok { + flush(pending) + return + } + pending = append(pending, text) + if len(pending) >= tailMaxBatch { + if batchTimer != nil { + batchTimer.Stop() + batchTimer = nil + batchC = nil + } + flush(pending) + pending = pending[:0] + } else if batchTimer == nil { + batchTimer = time.NewTimer(tailFlushDelay) + batchC = batchTimer.C + } + case <-batchC: + batchTimer = nil + batchC = nil + flush(pending) + pending = pending[:0] + case <-heartbeat.C: + fmt.Fprint(w, ": keep-alive\n\n") + flusher.Flush() + } + } +} + +// Download streams the full archive for a source as a single +// text/plain attachment — every rotation file (oldest first, gunzipped +// on the fly) followed by the current file. Users running this from a +// web browser want one click and a complete chronological log; if they +// need just the current file there's always `cat /var/log/X` over SSH. +// GET /maintenance/logs/{name}/download +func (h *LogsHandler) Download(w http.ResponseWriter, r *http.Request) { + key := r.PathValue("name") + src, ok := lookupSource(key) + if !ok { + http.NotFound(w, r) + return + } + + hostname, _ := os.Hostname() + if hostname == "" { + hostname = "device" + } + ts := time.Now().UTC().Format("20060102-1504") + fname := fmt.Sprintf("%s-%s-%s.log", key, hostname, ts) + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, fname)) + + // Concatenate oldest-first: highest-N rotation down through .0, then + // current. rotationsFor returns newest-first so iterate in reverse. + rots := rotationsFor(src.Path) + for i := len(rots) - 1; i >= 0; i-- { + streamFile(w, rots[i]) + } + streamFile(w, src.Path) +} + +func streamFile(w io.Writer, path string) { + rd, done, err := openReader(path) + if err != nil { + if !os.IsNotExist(err) { + log.Printf("logs stream %s: %v", path, err) + } + return + } + defer done() + io.Copy(w, rd) +} diff --git a/src/webui/internal/handlers/mdns.go b/src/webui/internal/handlers/mdns.go new file mode 100644 index 00000000..97b61119 --- /dev/null +++ b/src/webui/internal/handlers/mdns.go @@ -0,0 +1,287 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "fmt" + "html/template" + "log" + "net/http" + "net/url" + "sort" + "strings" + + "infix/webui/internal/restconf" +) + +// ─── RESTCONF JSON types ────────────────────────────────────────────────────── + +type mdnsWrapper struct { + MDNS mdnsJSON `json:"infix-services:mdns"` +} + +type mdnsJSON struct { + Enabled *yangBool `json:"enabled"` + Domain string `json:"domain"` + Hostname string `json:"hostname"` + Interfaces mdnsIfacesJSON `json:"interfaces"` + Reflector mdnsReflJSON `json:"reflector"` + Neighbors mdnsNeighborsJSON `json:"neighbors"` +} + +type mdnsIfacesJSON struct { + Allow []string `json:"allow"` + Deny []string `json:"deny"` +} + +type mdnsReflJSON struct { + Enabled *yangBool `json:"enabled"` + ServiceFilter []string `json:"service-filter"` +} + +type mdnsNeighborsJSON struct { + Neighbor []mdnsNeighborJSON `json:"neighbor"` +} + +type mdnsNeighborJSON struct { + Hostname string `json:"hostname"` + Address []string `json:"address"` + LastSeen string `json:"last-seen"` + Service []mdnsServiceJSON `json:"service"` +} + +type mdnsServiceJSON struct { + Name string `json:"name"` + Type string `json:"type"` + Port uint16 `json:"port"` + Txt []string `json:"txt"` +} + +// ─── Template data ──────────────────────────────────────────────────────────── + +type mdnsPageData struct { + PageData + Enabled bool + EnabledText string + Domain string + Allow string + Deny string + Reflector bool + SvcFilter string + Neighbors []mdnsNeighborEntry + Error string +} + +type mdnsNeighborEntry struct { + Hostname string + PrimaryAddr string + ExtraAddrs []string // additional addresses shown in fold-out + LastSeen string // HH:MM:SS + Services []mdnsSvcEntry +} + +type mdnsSvcEntry struct { + Label string // "https", "ssh", etc. + Port uint16 + URL template.URL // non-empty for http/https — safe to use in href +} + +// ─── Handler ───────────────────────────────────────────────────────────────── + +// MDNSHandler serves the mDNS overview page. +type MDNSHandler struct { + Template *template.Template + RC *restconf.Client +} + +// Overview renders the mDNS page (GET /mdns). +func (h *MDNSHandler) Overview(w http.ResponseWriter, r *http.Request) { + data := mdnsPageData{ + PageData: newPageData(w, r, "mdns", "mDNS"), + } + + var raw mdnsWrapper + if err := h.RC.Get(r.Context(), "/data/infix-services:mdns", &raw); err != nil { + log.Printf("restconf mdns: %v", err) + data.Error = "Could not fetch mDNS data" + } else { + m := raw.MDNS + data.Enabled = m.Enabled == nil || bool(*m.Enabled) + if data.Enabled { + data.EnabledText = "Active" + } else { + data.EnabledText = "Inactive" + } + data.Domain = m.Domain + if data.Domain == "" { + data.Domain = "local" + } + data.Allow = strings.Join(m.Interfaces.Allow, ", ") + data.Deny = strings.Join(m.Interfaces.Deny, ", ") + data.Reflector = m.Reflector.Enabled != nil && bool(*m.Reflector.Enabled) + data.SvcFilter = strings.Join(m.Reflector.ServiceFilter, ", ") + + for _, n := range m.Neighbors.Neighbor { + data.Neighbors = append(data.Neighbors, buildMDNSNeighbor(n)) + } + sort.Slice(data.Neighbors, func(i, j int) bool { + return data.Neighbors[i].Hostname < data.Neighbors[j].Hostname + }) + } + + tmplName := "mdns.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +func buildMDNSNeighbor(n mdnsNeighborJSON) mdnsNeighborEntry { + // mdnsSortAddrs puts IPv4 first, then global IPv6, then link-local — so + // addrs[0] is already the best address for URL construction. + addrs := mdnsSortAddrs(n.Address) + + primary := "" + if len(addrs) > 0 { + primary = addrs[0] + } + + entry := mdnsNeighborEntry{ + Hostname: n.Hostname, + PrimaryAddr: primary, + LastSeen: mdnsLastSeen(n.LastSeen), + } + if len(addrs) > 1 { + entry.ExtraAddrs = addrs[1:] + } + + for _, svc := range n.Service { + entry.Services = append(entry.Services, buildMDNSSvc(svc, primary, n.Hostname)) + } + return entry +} + +func buildMDNSSvc(svc mdnsServiceJSON, addr, host string) mdnsSvcEntry { + meta := mdnsParseTxt(svc.Txt) + + var rawURL string + switch svc.Type { + case "_http._tcp": + if meta.adminurl != "" { + rawURL = rebaseURLHost(meta.adminurl, host) + } else { + rawURL = fmt.Sprintf("http://%s:%d%s", addr, svc.Port, meta.path) + } + case "_https._tcp": + if meta.adminurl != "" { + rawURL = rebaseURLHost(meta.adminurl, host) + } else { + rawURL = fmt.Sprintf("https://%s:%d%s", addr, svc.Port, meta.path) + } + } + + // Use the DNS-SD instance name for clickable services so that two https + // entries on different ports ("Infix NOS" vs "ttyd") are distinguishable. + label := mdnsSvcLabel(svc.Type) + if rawURL != "" && svc.Name != "" { + label = svc.Name + } + + return mdnsSvcEntry{ + Label: label, + Port: svc.Port, + URL: template.URL(rawURL), // #nosec G203 — URL built from trusted operational data + } +} + +// rebaseURLHost rewrites the host of raw to host, keeping scheme, port, and +// path. It corrects stale adminurl TXT records: avahi resolves hostname +// conflicts at runtime by appending -2, -3, … but the advertiser writes the +// adminurl with its configured hostname and never updates it, so a device +// renamed infix-2.local still advertises adminurl=http://infix.local — which +// resolves to the *other* device. The mDNS-resolved hostname is authoritative. +// A no-op when raw is unparseable, hostless, or already points at host. +func rebaseURLHost(raw, host string) string { + if host == "" { + return raw + } + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return raw + } + if port := u.Port(); port != "" { + u.Host = host + ":" + port + } else { + u.Host = host + } + return u.String() +} + +// "_https._tcp" → "https", "_netconf-ssh._tcp" → "netconf-ssh" +func mdnsSvcLabel(stype string) string { + s := strings.TrimPrefix(stype, "_") + if idx := strings.Index(s, "._"); idx >= 0 { + s = s[:idx] + } + return s +} + +func mdnsSortAddrs(addrs []string) []string { + out := make([]string, len(addrs)) + copy(out, addrs) + sort.SliceStable(out, func(i, j int) bool { + return mdnsAddrPrio(out[i]) < mdnsAddrPrio(out[j]) + }) + return out +} + +func mdnsAddrPrio(a string) int { + if !strings.Contains(a, ":") { + return 0 // IPv4 + } + if strings.HasPrefix(strings.ToLower(a), "fe80:") { + return 2 // link-local IPv6 + } + return 1 // global IPv6 +} + +func mdnsLastSeen(ts string) string { + if ts == "" { + return "" + } + parts := strings.SplitN(ts, "T", 2) + if len(parts) < 2 { + return ts + } + t := parts[1] + if len(t) >= 8 { + return t[:8] + } + return t +} + +type mdnsTxtMeta struct { + path string + adminurl string +} + +func mdnsParseTxt(records []string) mdnsTxtMeta { + var m mdnsTxtMeta + for _, r := range records { + switch { + case m.path == "" && strings.HasPrefix(r, "path="): + m.path = r[5:] + case m.adminurl == "" && strings.HasPrefix(r, "adminurl="): + m.adminurl = r[9:] + } + if m.path != "" && m.adminurl != "" { + break + } + } + return m +} diff --git a/src/webui/internal/handlers/nacm.go b/src/webui/internal/handlers/nacm.go new file mode 100644 index 00000000..0f84dc37 --- /dev/null +++ b/src/webui/internal/handlers/nacm.go @@ -0,0 +1,343 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "html/template" + "log" + "net/http" + "strings" + + "infix/webui/internal/restconf" +) + +// ─── RESTCONF JSON types ────────────────────────────────────────────────────── + +type nacmWrapper struct { + NACM nacmJSON `json:"ietf-netconf-acm:nacm"` +} + +type nacmJSON struct { + EnableNACM bool `json:"enable-nacm"` + ReadDefault string `json:"read-default"` + WriteDefault string `json:"write-default"` + ExecDefault string `json:"exec-default"` + DeniedOperations uint32 `json:"denied-operations"` + DeniedDataWrites uint32 `json:"denied-data-writes"` + DeniedNotifications uint32 `json:"denied-notifications"` + Groups nacmGroupsJSON `json:"groups"` + RuleList []nacmRuleListJSON `json:"rule-list"` +} + +type nacmGroupsJSON struct { + Group []nacmGroupJSON `json:"group"` +} + +type nacmGroupJSON struct { + Name string `json:"name"` + UserNames []string `json:"user-name"` +} + +type nacmRuleListJSON struct { + Name string `json:"name"` + Group []string `json:"group"` + Rule []nacmRuleJSON `json:"rule"` +} + +type nacmRuleJSON struct { + Name string `json:"name"` + ModuleName string `json:"module-name"` + Path string `json:"path"` + AccessOperations string `json:"access-operations"` + Action string `json:"action"` +} + +type nacmAuthWrapper struct { + System struct { + Authentication struct { + User []nacmUserJSON `json:"user"` + } `json:"authentication"` + } `json:"ietf-system:system"` +} + +type nacmUserJSON struct { + Name string `json:"name"` + Password string `json:"password"` + Shell string `json:"infix-system:shell"` + AuthorizedKey []interface{} `json:"authorized-key"` +} + +// ─── Template data ──────────────────────────────────────────────────────────── + +type nacmPageData struct { + PageData + Error string + + // Summary card + Enabled string + ReadDefault string + WriteDefault string + ExecDefault string + DeniedOperations uint32 + DeniedDataWrites uint32 + DeniedNotifications uint32 + + // Permission matrix + Matrix []nacmGroupPerm + + // Users and Groups tables + Users []nacmUserEntry + Groups []nacmGroupEntry +} + +type nacmGroupPerm struct { + Name string + Read nacmCell + Write nacmCell + Exec nacmCell + Restrictions []string +} + +type nacmCell struct { + Class string // "nacm-full" | "nacm-restricted" | "nacm-denied" + Symbol string // "✓" | "⚠" | "✗" +} + +type nacmUserEntry struct { + Name string + Shell string + Login string +} + +type nacmGroupEntry struct { + Name string + Members string +} + +// ─── Handler ───────────────────────────────────────────────────────────────── + +// NACMHandler serves the NACM page. +type NACMHandler struct { + Template *template.Template + RC *restconf.Client +} + +// Overview renders the NACM page (GET /nacm). +func (h *NACMHandler) Overview(w http.ResponseWriter, r *http.Request) { + data := nacmPageData{ + PageData: newPageData(w, r, "nacm", "Users & Groups"), + } + + var nacmRaw nacmWrapper + nacmErr := h.RC.Get(r.Context(), "/data/ietf-netconf-acm:nacm", &nacmRaw) + + var authRaw nacmAuthWrapper + authErr := h.RC.Get(r.Context(), "/data/ietf-system:system/authentication", &authRaw) + + if nacmErr != nil { + log.Printf("restconf nacm: %v", nacmErr) + data.Error = "Could not fetch NACM data" + } else { + n := nacmRaw.NACM + if n.ReadDefault == "" { + n.ReadDefault = "permit" + } + if n.WriteDefault == "" { + n.WriteDefault = "deny" + } + if n.ExecDefault == "" { + n.ExecDefault = "permit" + } + + if n.EnableNACM { + data.Enabled = "yes" + } else { + data.Enabled = "no" + } + data.ReadDefault = n.ReadDefault + data.WriteDefault = n.WriteDefault + data.ExecDefault = n.ExecDefault + data.DeniedOperations = n.DeniedOperations + data.DeniedDataWrites = n.DeniedDataWrites + data.DeniedNotifications = n.DeniedNotifications + + data.Matrix = analyzeNACMPermissions(n) + + for _, g := range n.Groups.Group { + data.Groups = append(data.Groups, nacmGroupEntry{ + Name: g.Name, + Members: strings.Join(g.UserNames, " "), + }) + } + } + + if authErr != nil { + log.Printf("restconf nacm auth: %v", authErr) + } else { + for _, u := range authRaw.System.Authentication.User { + data.Users = append(data.Users, buildNACMUserEntry(u)) + } + } + + tmplName := "nacm.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// ─── Permission matrix logic ────────────────────────────────────────────────── +// Mirrors cli_pretty._analyze_group_permissions exactly. + +func analyzeNACMPermissions(n nacmJSON) []nacmGroupPerm { + readDefault := n.ReadDefault == "permit" + writeDefault := n.WriteDefault == "permit" + execDefault := n.ExecDefault == "permit" + + // Collect deny rules that apply to "*" (all groups) — these become restrictions. + var globalDenials []nacmRuleJSON + for _, rl := range n.RuleList { + for _, g := range rl.Group { + if g == "*" { + for _, rule := range rl.Rule { + if rule.Action == "deny" { + globalDenials = append(globalDenials, rule) + } + } + break + } + } + } + + var results []nacmGroupPerm + + for _, group := range n.Groups.Group { + canRead := readDefault + canWrite := writeDefault + canExec := execDefault + hasPermitAll := false + var restrictions []string + + // Process rule-lists in order; only those that name this group specifically. + for _, rl := range n.RuleList { + applies := false + for _, g := range rl.Group { + if g == group.Name { + applies = true + break + } + } + if !applies { + continue + } + + for _, rule := range rl.Rule { + action := rule.Action + accessOps := strings.ToLower(rule.AccessOperations) + moduleName := rule.ModuleName + + // permit-all: module-name="*" AND access-operations="*" + if action == "permit" && moduleName == "*" && accessOps == "*" { + hasPermitAll = true + break + } + + // Explicit deny for write/exec operations + if action == "deny" && moduleName == "*" { + if strings.Contains(accessOps, "create") && + strings.Contains(accessOps, "update") && + strings.Contains(accessOps, "delete") { + canWrite = false + } + if strings.Contains(accessOps, "exec") { + canExec = false + } + } + } + + if hasPermitAll { + break + } + } + + // permit-all overrides everything, including unfavourable global defaults. + if hasPermitAll { + canRead = true + canWrite = true + canExec = true + } + + // Global denials create restrictions for groups that don't have permit-all. + if !hasPermitAll { + seen := map[string]bool{} + for _, rule := range globalDenials { + var restriction string + if rule.Path != "" { + parts := strings.Split(strings.TrimRight(rule.Path, "/"), "/") + restriction = parts[len(parts)-1] + } else if rule.ModuleName != "" { + restriction = strings.TrimPrefix(rule.ModuleName, "ietf-") + } + if restriction != "" && !seen[restriction] { + seen[restriction] = true + restrictions = append(restrictions, restriction) + } + } + } + + results = append(results, nacmGroupPerm{ + Name: group.Name, + Read: makeNACMCell(canRead, len(restrictions) > 0), + Write: makeNACMCell(canWrite, len(restrictions) > 0), + Exec: makeNACMCell(canExec, len(restrictions) > 0), + Restrictions: restrictions, + }) + } + + return results +} + +func makeNACMCell(hasAccess, hasRestrictions bool) nacmCell { + if !hasAccess { + return nacmCell{Class: "nacm-denied", Symbol: "✗"} + } + if hasRestrictions { + return nacmCell{Class: "nacm-restricted", Symbol: "⚠"} + } + return nacmCell{Class: "nacm-full", Symbol: "✓"} +} + +// ─── User entry helper ──────────────────────────────────────────────────────── + +func buildNACMUserEntry(u nacmUserJSON) nacmUserEntry { + shell := u.Shell + if idx := strings.LastIndex(shell, ":"); idx >= 0 { + shell = shell[idx+1:] + } + if shell == "" || shell == "false" { + shell = "-" + } + + var login string + hasPassword := u.Password != "" + hasKeys := len(u.AuthorizedKey) > 0 + switch { + case hasPassword && hasKeys: + login = "password+key" + case hasPassword: + login = "password" + case hasKeys: + login = "key" + default: + login = "-" + } + + return nacmUserEntry{ + Name: u.Name, + Shell: shell, + Login: login, + } +} diff --git a/src/webui/internal/handlers/ntp.go b/src/webui/internal/handlers/ntp.go new file mode 100644 index 00000000..047c3729 --- /dev/null +++ b/src/webui/internal/handlers/ntp.go @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "html/template" + "log" + "net/http" + "strings" + + "infix/webui/internal/restconf" +) + +// ─── NTP types ─────────────────────────────────────────────────────────────── + +// NTPAssoc is a single NTP association/peer. +type NTPAssoc struct { + Address string + Stratum int + RefID string + Reach string // octal string + Poll int + Offset string // ms + Delay string // ms +} + +// NTPData is the parsed NTP state. +type NTPData struct { + Synchronized bool + Stratum int + RefID string + Offset string // ms + RootDelay string // ms + Associations []NTPAssoc +} + +// ─── Page data ─────────────────────────────────────────────────────────────── + +type ntpPageData struct { + PageData + NTP *NTPData + Error string +} + +// ─── Handler ───────────────────────────────────────────────────────────────── + +// NTPHandler serves the NTP status page. +type NTPHandler struct { + Template *template.Template + RC *restconf.Client +} + +// Overview renders the NTP page (GET /ntp). +func (h *NTPHandler) Overview(w http.ResponseWriter, r *http.Request) { + data := ntpPageData{ + PageData: newPageData(w, r, "ntp", "NTP"), + } + + ctx := context.WithoutCancel(r.Context()) + + var raw struct { + NTP struct { + ClockState struct { + SystemStatus struct { + ClockState string `json:"clock-state"` + ClockStratum int `json:"clock-stratum"` + ClockRefID json.RawMessage `json:"clock-refid"` + ClockOffset yangFloat64 `json:"clock-offset"` + RootDelay yangFloat64 `json:"root-delay"` + } `json:"system-status"` + } `json:"clock-state"` + Associations struct { + Association []struct { + Address string `json:"address"` + Stratum int `json:"stratum"` + RefID json.RawMessage `json:"refid"` + Reach uint8 `json:"reach"` + Poll int `json:"poll"` + Offset yangFloat64 `json:"offset"` + Delay yangFloat64 `json:"delay"` + } `json:"association"` + } `json:"associations"` + } `json:"ietf-ntp:ntp"` + } + if err := h.RC.Get(ctx, "/data/ietf-ntp:ntp", &raw); err != nil { + // 404 = NTP container absent (not configured). Render the empty-state + // section instead of a red error banner. + if !restconf.IsNotFound(err) { + log.Printf("restconf ntp: %v", err) + data.Error = "Failed to fetch NTP data" + } + } else { + ss := raw.NTP.ClockState.SystemStatus + synced := strings.Contains(ss.ClockState, "synchronized") && + !strings.Contains(ss.ClockState, "unsynchronized") + ntp := &NTPData{ + Synchronized: synced, + Stratum: ss.ClockStratum, + RefID: rawJSONString(ss.ClockRefID), + Offset: fmt.Sprintf("%.3f ms", float64(ss.ClockOffset)), + RootDelay: fmt.Sprintf("%.3f ms", float64(ss.RootDelay)), + } + for _, a := range raw.NTP.Associations.Association { + ntp.Associations = append(ntp.Associations, NTPAssoc{ + Address: a.Address, + Stratum: a.Stratum, + RefID: rawJSONString(a.RefID), + Reach: fmt.Sprintf("%o", a.Reach), + Poll: a.Poll, + Offset: fmt.Sprintf("%.3f ms", float64(a.Offset)), + Delay: fmt.Sprintf("%.3f ms", float64(a.Delay)), + }) + } + data.NTP = ntp + } + + tmplName := "ntp.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +// rawJSONString extracts the unquoted string value from a JSON raw message +// that may be a string, number, or other scalar. +func rawJSONString(b json.RawMessage) string { + if len(b) == 0 { + return "" + } + // Try string + var s string + if json.Unmarshal(b, &s) == nil { + return s + } + // Fall back to raw bytes (number, etc.) + return strings.Trim(string(b), `"`) +} diff --git a/src/webui/internal/handlers/routing.go b/src/webui/internal/handlers/routing.go new file mode 100644 index 00000000..fe96af0b --- /dev/null +++ b/src/webui/internal/handlers/routing.go @@ -0,0 +1,375 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "html/template" + "log" + "net/http" + "strings" + "sync" + "time" + + "infix/webui/internal/restconf" +) + +type RouteEntry struct { + DestPrefix string + NextHopIface string + NextHopAddr string + Protocol string + Preference string // formatted as "distance/metric" + Uptime string + Active bool +} + +type OSPFNeighbor struct { + RouterID string + Priority int + State string // includes role suffix, e.g. "Full/DR" + Uptime string + Address string + Interface string + Area string +} + +type OSPFIface struct { + Name string + Area string + Type string + State string + Cost int + Priority int + DR string + BDR string + NbrCount int +} + +type routingData struct { + PageData + Routes []RouteEntry + OSPFNeighbors []OSPFNeighbor + OSPFIfaces []OSPFIface + HasOSPF bool + Error string +} + +type RoutingHandler struct { + Template *template.Template + RC *restconf.Client +} + +type ribWrapper struct { + Routing *struct { + Ribs struct { + Rib []ribJSON `json:"rib"` + } `json:"ribs"` + } `json:"ietf-routing:routing"` + Ribs *struct { + Rib []ribJSON `json:"rib"` + } `json:"ietf-routing:ribs"` +} + +type ribJSON struct { + Name string `json:"name"` + Routes ribRoutesJSON `json:"routes"` +} + +type ribRoutesJSON struct { + Route []ribRouteJSON `json:"route"` +} + +type ribRouteJSON struct { + DestPrefix4 string `json:"ietf-ipv4-unicast-routing:destination-prefix"` + DestPrefix6 string `json:"ietf-ipv6-unicast-routing:destination-prefix"` + DestPrefix string `json:"destination-prefix"` + NextHop ribNextHopJSON `json:"next-hop"` + SourceProtocol string `json:"source-protocol"` + Active *json.RawMessage `json:"active"` + RoutePreference int `json:"route-preference"` + OspfMetric int `json:"ietf-ospf:metric"` + LastUpdated string `json:"last-updated"` +} + +func (r ribRouteJSON) destinationPrefix() string { + if r.DestPrefix4 != "" { + return r.DestPrefix4 + } + if r.DestPrefix6 != "" { + return r.DestPrefix6 + } + return r.DestPrefix +} + +type ribNextHopJSON struct { + OutgoingInterface string `json:"outgoing-interface"` + NextHopAddress string `json:"next-hop-address"` + NextHopList *ribNextHopList `json:"next-hop-list"` +} + +type ribNextHopList struct { + NextHop []ribNextHopEntry `json:"next-hop"` +} + +type ribNextHopEntry struct { + OutgoingInterface string `json:"outgoing-interface"` + Address4 string `json:"ietf-ipv4-unicast-routing:address"` + Address6 string `json:"ietf-ipv6-unicast-routing:address"` + NextHopAddress string `json:"next-hop-address"` +} + +func (nh ribNextHopJSON) resolve() (iface, addr string) { + if nh.OutgoingInterface != "" || nh.NextHopAddress != "" { + return nh.OutgoingInterface, nh.NextHopAddress + } + if nh.NextHopList != nil && len(nh.NextHopList.NextHop) > 0 { + e := nh.NextHopList.NextHop[0] + addr = e.NextHopAddress + if addr == "" { + addr = e.Address4 + } + if addr == "" { + addr = e.Address6 + } + return e.OutgoingInterface, addr + } + return "", "" +} + +type ospfCPPWrapper struct { + Routing struct { + CPP struct { + Protocol []ospfProtocolJSON `json:"control-plane-protocol"` + } `json:"control-plane-protocols"` + } `json:"ietf-routing:routing"` +} + +type ospfProtocolJSON struct { + Type string `json:"type"` + Name string `json:"name"` + OSPF *ospfJSON `json:"ietf-ospf:ospf"` +} + +type ospfJSON struct { + Areas struct { + Area []ospfAreaJSON `json:"area"` + } `json:"areas"` +} + +type ospfAreaJSON struct { + AreaID string `json:"area-id"` + Interfaces struct { + Interface []ospfIfaceJSON `json:"interface"` + } `json:"interfaces"` +} + +type ospfIfaceJSON struct { + Name string `json:"name"` + State string `json:"state"` + Cost int `json:"cost"` + Priority int `json:"priority"` + InterfaceType string `json:"interface-type"` + DRRouterID string `json:"dr-router-id"` + BDRRouterID string `json:"bdr-router-id"` + Neighbors struct { + Neighbor []ospfNeighborJSON `json:"neighbor"` + } `json:"neighbors"` +} + +type ospfNeighborJSON struct { + NeighborRouterID string `json:"neighbor-router-id"` + Address string `json:"address"` + Priority int `json:"priority"` + State string `json:"state"` + Role string `json:"infix-routing:role"` + InterfaceName string `json:"infix-routing:interface-name"` + Uptime uint32 `json:"infix-routing:uptime"` +} + +func (h *RoutingHandler) Overview(w http.ResponseWriter, r *http.Request) { + data := routingData{ + PageData: newPageData(w, r, "routing", "Routes"), + } + + ctx := context.WithoutCancel(r.Context()) + + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + raw, err := h.RC.GetRaw(ctx, "/data/ietf-routing:routing/ribs") + if err != nil { + log.Printf("restconf rib: %v", err) + return + } + var rib ribWrapper + if err := json.Unmarshal(raw, &rib); err != nil { + log.Printf("restconf rib unmarshal: %v", err) + return + } + var ribs []ribJSON + if rib.Routing != nil { + ribs = rib.Routing.Ribs.Rib + } else if rib.Ribs != nil { + ribs = rib.Ribs.Rib + } + for _, rb := range ribs { + for _, route := range rb.Routes.Route { + iface, addr := route.NextHop.resolve() + data.Routes = append(data.Routes, RouteEntry{ + DestPrefix: route.destinationPrefix(), + NextHopIface: iface, + NextHopAddr: addr, + Protocol: shortProto(route.SourceProtocol), + Preference: fmt.Sprintf("%d/%d", route.RoutePreference, route.OspfMetric), + Uptime: uptimeFromTimestamp(route.LastUpdated), + Active: route.Active != nil, + }) + } + } + }() + + go func() { + defer wg.Done() + var cpp ospfCPPWrapper + if err := h.RC.Get(ctx, + "/data/ietf-routing:routing/control-plane-protocols", + &cpp); err != nil { + log.Printf("restconf ospf (ignored): %v", err) + return + } + for _, proto := range cpp.Routing.CPP.Protocol { + if proto.OSPF == nil { + continue + } + data.HasOSPF = true + for _, area := range proto.OSPF.Areas.Area { + for _, iface := range area.Interfaces.Interface { + data.OSPFIfaces = append(data.OSPFIfaces, OSPFIface{ + Name: iface.Name, + Area: area.AreaID, + Type: ospfIfaceType(iface.InterfaceType), + State: ospfIfaceState(iface.State), + Cost: iface.Cost, + Priority: iface.Priority, + DR: iface.DRRouterID, + BDR: iface.BDRRouterID, + NbrCount: len(iface.Neighbors.Neighbor), + }) + for _, nbr := range iface.Neighbors.Neighbor { + stateStr := capitalize(nbr.State) + if nbr.Role != "" && nbr.State == "full" { + stateStr = "Full/" + nbr.Role + } + ifaceName := nbr.InterfaceName + if ifaceName == "" { + ifaceName = iface.Name + } + data.OSPFNeighbors = append(data.OSPFNeighbors, OSPFNeighbor{ + RouterID: nbr.NeighborRouterID, + Priority: nbr.Priority, + State: stateStr, + Uptime: formatUptime(nbr.Uptime), + Address: nbr.Address, + Interface: ifaceName, + Area: area.AreaID, + }) + } + } + } + } + }() + + wg.Wait() + + tmplName := "routing.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +func uptimeFromTimestamp(s string) string { + if s == "" { + return "" + } + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return "" + } + return formatUptime(uint32(time.Since(t).Seconds())) +} + +func ospfIfaceType(t string) string { + switch t { + case "broadcast": + return "Broadcast" + case "point-to-point": + return "P2P" + case "point-to-multipoint": + return "P2MP" + case "non-broadcast": + return "NBMA" + case "virtual-link": + return "VLink" + default: + if t != "" { + return capitalize(t) + } + return "" + } +} + +func ospfIfaceState(s string) string { + switch s { + case "dr": + return "DR" + case "bdr": + return "BDR" + case "dr-other": + return "DROther" + default: + return capitalize(s) + } +} + +func capitalize(s string) string { + if len(s) == 0 { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +func shortProto(proto string) string { + if i := strings.LastIndex(proto, ":"); i >= 0 { + return proto[i+1:] + } + return proto +} + +func formatUptime(sec uint32) string { + if sec == 0 { + return "" + } + days := sec / 86400 + hours := (sec % 86400) / 3600 + mins := (sec % 3600) / 60 + secs := sec % 60 + switch { + case days > 0: + return fmt.Sprintf("%dd %dh %dm", days, hours, mins) + case hours > 0: + return fmt.Sprintf("%dh %dm", hours, mins) + case mins > 0: + return fmt.Sprintf("%dm %ds", mins, secs) + default: + return fmt.Sprintf("%ds", secs) + } +} diff --git a/src/webui/internal/handlers/routing_test.go b/src/webui/internal/handlers/routing_test.go new file mode 100644 index 00000000..a9e5e1ce --- /dev/null +++ b/src/webui/internal/handlers/routing_test.go @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "html/template" + "net/http" + "net/http/httptest" + "testing" + + "infix/webui/internal/restconf" + "infix/webui/internal/security" +) + +var minimalRoutingTmpl = template.Must(template.New("routing.html").Parse( + `{{define "routing.html"}}routes={{len .Routes}}{{end}}` + + `{{define "content"}}{{len .Routes}}{{end}}`, +)) + +func TestRoutingOverview_ReturnsOK(t *testing.T) { + rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false) + h := &RoutingHandler{Template: minimalRoutingTmpl, RC: rc} + + req := httptest.NewRequest(http.MethodGet, "/routing", nil) + ctx := restconf.ContextWithCredentials(req.Context(), restconf.Credentials{ + Username: "admin", + Password: "admin", + }) + ctx = security.WithToken(ctx, "test-csrf-token") + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + h.Overview(w, req) + + if w.Code != http.StatusOK { + t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String()) + } + + body := w.Body.String() + if body == "" { + t.Error("expected non-empty response body") + } +} + +func TestRoutingOverview_HTMXPartial(t *testing.T) { + rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false) + h := &RoutingHandler{Template: minimalRoutingTmpl, RC: rc} + + req := httptest.NewRequest(http.MethodGet, "/routing", nil) + req.Header.Set("HX-Request", "true") + ctx := restconf.ContextWithCredentials(req.Context(), restconf.Credentials{ + Username: "admin", + Password: "admin", + }) + ctx = security.WithToken(ctx, "test-csrf-token") + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + h.Overview(w, req) + + if w.Code != http.StatusOK { + t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String()) + } + + body := w.Body.String() + if body == "" { + t.Error("expected non-empty response body for htmx partial") + } +} diff --git a/src/webui/internal/handlers/services.go b/src/webui/internal/handlers/services.go new file mode 100644 index 00000000..f16dc413 --- /dev/null +++ b/src/webui/internal/handlers/services.go @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "encoding/json" + "fmt" + "html/template" + "log" + "net/http" + "sort" + "strconv" + + "infix/webui/internal/restconf" +) + +// ─── RESTCONF JSON types ────────────────────────────────────────────────────── + +type servicesWrapper struct { + SystemState struct { + Services struct { + Service []serviceJSON `json:"service"` + } `json:"infix-system:services"` + } `json:"ietf-system:system-state"` +} + +type serviceJSON struct { + Name string `json:"name"` + PID uint32 `json:"pid"` + Description string `json:"description"` + Status string `json:"status"` + Statistics serviceStatsJSON `json:"statistics"` +} + +// memory-usage and uptime are marshalled as strings by the statd Python layer. +type serviceStatsJSON struct { + MemoryUsage json.Number `json:"memory-usage"` + Uptime json.Number `json:"uptime"` + RestartCount uint32 `json:"restart-count"` +} + +func (s serviceStatsJSON) memoryBytes() uint64 { + v, _ := strconv.ParseUint(s.MemoryUsage.String(), 10, 64) + return v +} + +func (s serviceStatsJSON) uptimeSeconds() uint64 { + v, _ := strconv.ParseUint(s.Uptime.String(), 10, 64) + return v +} + +// ─── Template data ──────────────────────────────────────────────────────────── + +type servicesPageData struct { + PageData + Services []serviceEntry + Error string +} + +type serviceEntry struct { + Name string + Status string + StatusClass string // "svc-running", "svc-stopped", "svc-error", "svc-done" + PID string + Memory string + Uptime string + RestartCount uint32 + Description string +} + +// ─── Handler ───────────────────────────────────────────────────────────────── + +// ServicesHandler serves the system services page. +type ServicesHandler struct { + Template *template.Template + RC *restconf.Client +} + +// Overview renders the services page (GET /services). +func (h *ServicesHandler) Overview(w http.ResponseWriter, r *http.Request) { + data := servicesPageData{ + PageData: newPageData(w, r, "services", "Services"), + } + + var raw servicesWrapper + if err := h.RC.Get(r.Context(), "/data/ietf-system:system-state/infix-system:services", &raw); err != nil { + log.Printf("restconf services: %v", err) + data.Error = "Could not fetch services data" + } else { + for _, svc := range raw.SystemState.Services.Service { + data.Services = append(data.Services, buildServiceEntry(svc)) + } + sort.Slice(data.Services, func(i, j int) bool { + return data.Services[i].Name < data.Services[j].Name + }) + } + + tmplName := "services.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +func buildServiceEntry(svc serviceJSON) serviceEntry { + pid := "" + if svc.PID > 0 { + pid = fmt.Sprintf("%d", svc.PID) + } + + return serviceEntry{ + Name: svc.Name, + Status: svc.Status, + StatusClass: serviceStatusClass(svc.Status), + PID: pid, + Memory: formatServiceMemory(svc.Statistics.memoryBytes()), + Uptime: formatServiceUptime(svc.Statistics.uptimeSeconds()), + RestartCount: svc.Statistics.RestartCount, + Description: svc.Description, + } +} + +func serviceStatusClass(status string) string { + switch status { + case "running", "active", "done": + return "svc-running" // green + case "crashed", "failed", "halted", "missing", "dead", "conflict": + return "svc-error" // red + default: + // stopped, paused, and anything else → yellow + return "svc-stopped" + } +} + +// formatServiceMemory mirrors the CLI's format_memory_bytes. +func formatServiceMemory(b uint64) string { + switch { + case b == 0: + return "" + case b < 1024: + return fmt.Sprintf("%dB", b) + case b < 1024*1024: + return fmt.Sprintf("%dK", b/1024) + case b < 1024*1024*1024: + return fmt.Sprintf("%.1fM", float64(b)/float64(1024*1024)) + default: + return fmt.Sprintf("%.1fG", float64(b)/float64(1024*1024*1024)) + } +} + +// formatServiceUptime mirrors the CLI's format_uptime_seconds. +func formatServiceUptime(s uint64) string { + switch { + case s == 0: + return "" + case s < 60: + return fmt.Sprintf("%ds", s) + case s < 3600: + return fmt.Sprintf("%dm", s/60) + case s < 86400: + return fmt.Sprintf("%dh", s/3600) + default: + return fmt.Sprintf("%dd", s/86400) + } +} diff --git a/src/webui/internal/handlers/services_test.go b/src/webui/internal/handlers/services_test.go new file mode 100644 index 00000000..699d359e --- /dev/null +++ b/src/webui/internal/handlers/services_test.go @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "html/template" + "net/http" + "net/http/httptest" + "testing" + + "infix/webui/internal/restconf" + "infix/webui/internal/security" +) + +var minimalDHCPTmpl = template.Must(template.New("dhcp.html").Parse( + `{{define "dhcp.html"}}dhcp={{.DHCP}}{{end}}` + + `{{define "content"}}{{.DHCP}}{{end}}`, +)) + +var minimalNTPTmpl = template.Must(template.New("ntp.html").Parse( + `{{define "ntp.html"}}ntp={{.NTP}}{{end}}` + + `{{define "content"}}{{.NTP}}{{end}}`, +)) + +var minimalLLDPTmpl = template.Must(template.New("lldp.html").Parse( + `{{define "lldp.html"}}lldp={{.Neighbors}}{{end}}` + + `{{define "content"}}{{.Neighbors}}{{end}}`, +)) + +func testCtx(req *http.Request) *http.Request { + ctx := restconf.ContextWithCredentials(req.Context(), restconf.Credentials{ + Username: "admin", + Password: "admin", + }) + ctx = security.WithToken(ctx, "test-csrf-token") + return req.WithContext(ctx) +} + +func TestDHCPOverview_ReturnsOK(t *testing.T) { + rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false) + h := &DHCPHandler{Template: minimalDHCPTmpl, RC: rc} + + req := httptest.NewRequest(http.MethodGet, "/dhcp", nil) + req = testCtx(req) + + w := httptest.NewRecorder() + h.Overview(w, req) + + if w.Code != http.StatusOK { + t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String()) + } + if w.Body.String() == "" { + t.Error("expected non-empty response body") + } +} + +func TestDHCPOverview_HTMXPartial(t *testing.T) { + rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false) + h := &DHCPHandler{Template: minimalDHCPTmpl, RC: rc} + + req := httptest.NewRequest(http.MethodGet, "/dhcp", nil) + req.Header.Set("HX-Request", "true") + req = testCtx(req) + + w := httptest.NewRecorder() + h.Overview(w, req) + + if w.Code != http.StatusOK { + t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String()) + } + if w.Body.String() == "" { + t.Error("expected non-empty response body for htmx partial") + } +} + +func TestNTPOverview_ReturnsOK(t *testing.T) { + rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false) + h := &NTPHandler{Template: minimalNTPTmpl, RC: rc} + + req := httptest.NewRequest(http.MethodGet, "/ntp", nil) + req = testCtx(req) + + w := httptest.NewRecorder() + h.Overview(w, req) + + if w.Code != http.StatusOK { + t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String()) + } + if w.Body.String() == "" { + t.Error("expected non-empty response body") + } +} + +func TestLLDPOverview_ReturnsOK(t *testing.T) { + rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false) + h := &LLDPHandler{Template: minimalLLDPTmpl, RC: rc} + + req := httptest.NewRequest(http.MethodGet, "/lldp", nil) + req = testCtx(req) + + w := httptest.NewRecorder() + h.Overview(w, req) + + if w.Code != http.StatusOK { + t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String()) + } + if w.Body.String() == "" { + t.Error("expected non-empty response body") + } +} diff --git a/src/webui/internal/handlers/sibling_test.go b/src/webui/internal/handlers/sibling_test.go new file mode 100644 index 00000000..afce95f4 --- /dev/null +++ b/src/webui/internal/handlers/sibling_test.go @@ -0,0 +1,106 @@ +package handlers + +import ( + "encoding/json" + "reflect" + "testing" +) + +func TestUnwrapForPutContainer(t *testing.T) { + raw := []byte(`{"infix-services:web":{"certificate":"gencert","enabled":true}}`) + var doc map[string]any + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatalf("unmarshal: %v", err) + } + obj, wrap, err := unwrapForPut(doc, "/infix-services:web") + if err != nil { + t.Fatalf("unwrap: %v", err) + } + if wrap != "infix-services:web" { + t.Errorf("wrap = %q, want %q", wrap, "infix-services:web") + } + if obj["certificate"] != "gencert" { + t.Errorf("certificate = %v, want gencert", obj["certificate"]) + } +} + +func TestUnwrapForPutListInstance(t *testing.T) { + raw := []byte(`{"ietf-interfaces:interfaces":{"interface":[{"name":"lan3","type":"ethernet","infix-interfaces:bridge-port":{"bridge":"br0"}}]}}`) + var doc map[string]any + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatalf("unmarshal: %v", err) + } + obj, wrap, err := unwrapForPut(doc, "/ietf-interfaces:interfaces/interface=lan3") + if err != nil { + t.Fatalf("unwrap: %v", err) + } + if wrap != "interface" { + t.Errorf("wrap = %q, want %q", wrap, "interface") + } + if obj["name"] != "lan3" { + t.Errorf("name = %v, want lan3", obj["name"]) + } + if _, ok := obj["infix-interfaces:bridge-port"]; !ok { + t.Errorf("expected bridge-port key in unwrapped entry, got: %v", obj) + } +} + +func TestUnwrapForPutEmpty(t *testing.T) { + doc := map[string]any{} + if _, _, err := unwrapForPut(doc, "/x"); err == nil { + t.Errorf("expected error for empty doc") + } +} + +func TestExtractFieldValuesWithSibling(t *testing.T) { + data := []byte(`{ + "ietf-interfaces:interfaces": { + "interface": [ + {"name": "lan1", "type": "ethernet"}, + {"name": "br0", "type": "bridge", "infix-interfaces:bridge": {"vlans": {}}}, + {"name": "br1", "type": "bridge", "infix-interfaces:bridge": {}}, + {"name": "lan3", "type": "ethernet", "infix-interfaces:bridge-port": {"bridge":"br0"}} + ] + } + }`) + got := extractFieldValuesWithSibling(data, "name", "bridge") + want := []string{"br0", "br1"} + if !reflect.DeepEqual(got, want) { + t.Errorf("got %v, want %v", got, want) + } +} + +// TestExtractFieldValuesNestedRecord guards against the bug where a +// leafref dropdown for /ietf-keystore:keystore/asymmetric-keys/ +// asymmetric-key/name picked up "self-signed" — which lives in the +// nested certificates/certificate[]/name list inside each asymmetric +// key entry. The walker has to treat the asymmetric-key map as a +// record boundary and not descend into siblings once it has found +// the field there. +func TestExtractFieldValuesNestedRecord(t *testing.T) { + data := []byte(`{ + "ietf-keystore:keystore": { + "asymmetric-keys": { + "asymmetric-key": [ + { + "name": "gencert", + "certificates": { + "certificate": [ + {"name": "self-signed"} + ] + } + }, + { + "name": "genkey", + "certificates": {} + } + ] + } + } + }`) + got := extractFieldValues(data, "name") + want := []string{"gencert", "genkey"} + if !reflect.DeepEqual(got, want) { + t.Errorf("got %v, want %v", got, want) + } +} diff --git a/src/webui/internal/handlers/system.go b/src/webui/internal/handlers/system.go new file mode 100644 index 00000000..f7a20dec --- /dev/null +++ b/src/webui/internal/handlers/system.go @@ -0,0 +1,872 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "html/template" + "io" + "log" + "net/http" + "os" + "os/exec" + "slices" + "strings" + "sync" + "time" + + "infix/webui/internal/restconf" +) + +// raucInstallationStatus reads RAUC's Operation/Progress/LastError D-Bus +// properties directly via the rauc-installation-status helper. Used during +// installs because the RESTCONF path goes through the operational-state +// machinery, which runs `rauc status` and blocks while RAUC is busy. +func raucInstallationStatus(ctx context.Context) (swInstallerState, error) { + var inst swInstallerState + out, err := exec.CommandContext(ctx, "/usr/bin/rauc-installation-status").Output() + if err != nil { + return inst, err + } + err = json.Unmarshal(out, &inst) + return inst, err +} + +// SystemHandler provides reboot, config download, and software install actions. +type SystemHandler struct { + RC *restconf.Client + Template *template.Template // software page template + SysCtrlTmpl *template.Template // system control page template + BackupTmpl *template.Template // backup & restore page template + IdentityTmpl *template.Template // topbar device-identity fragment + + // swSlots caches the last successfully-fetched Software card payload + // so /software?installing=1 can keep rendering slot details — the + // RESTCONF path blocks on `rauc status` while RAUC is busy, and the + // user wants to read (and adjust) boot order even between install + // attempts. + swSlots swSlotSnapshot +} + +// swSlotSnapshot is a tiny RWMutex-guarded copy of the Software page's +// card body. Mirrors the schema.Cache shape (rw lock + payload) from +// internal/schema/refresh.go. +type swSlotSnapshot struct { + mu sync.RWMutex + bootOrder []string + slots []slotEntry +} + +// raucBusy reports whether RAUC has an install in flight. Best-effort: +// helper-binary failures fall through to "not busy" so the caller's +// pre-flight check doesn't false-positive on an unrelated D-Bus glitch. +func (h *SystemHandler) raucBusy(ctx context.Context) bool { + inst, err := raucInstallationStatus(ctx) + if err != nil { + return false + } + return !inst.IsIdle() +} + +// Reboot triggers a device restart via the ietf-system:system-restart RPC +// and returns a spinner fragment that polls until the device is back. +func (h *SystemHandler) Reboot(w http.ResponseWriter, r *http.Request) { + err := h.RC.Post(r.Context(), "/operations/ietf-system:system-restart") + if err != nil { + log.Printf("reboot: %v", err) + http.Error(w, "reboot failed", http.StatusBadGateway) + return + } + + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, rebootSpinnerHTML) +} + +const rebootSpinnerHTML = `<div class="reboot-overlay"> + <div class="reboot-spinner"></div> + <p class="reboot-message">Rebooting…</p> + <p class="reboot-status" id="reboot-status">Waiting for device to shut down…</p> +</div>` + +type systemControlData struct { + PageData +} + +// SystemControl renders the System Control maintenance page. +func (h *SystemHandler) SystemControl(w http.ResponseWriter, r *http.Request) { + data := systemControlData{ + PageData: newPageData(w, r, "system-control", "System Control"), + } + + tmplName := "system-control.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.SysCtrlTmpl.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("system-control template: %v", err) + } +} + +// SetDatetime sets the device clock via the ietf-system:set-current-datetime RPC. +// The form value is a datetime-local string (YYYY-MM-DDTHH:MM) treated as UTC. +func (h *SystemHandler) SetDatetime(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + raw := r.FormValue("datetime") // YYYY-MM-DDTHH:MM or :SS, ISO 24h + if raw == "" { + http.Error(w, "datetime required", http.StatusBadRequest) + return + } + // Accept either YYYY-MM-DDTHH:MM (16 chars) or with :SS (19 chars). + if len(raw) == 16 { + raw += ":00" + } + + body := map[string]map[string]string{ + "ietf-system:input": {"current-datetime": raw + "+00:00"}, + } + if err := h.RC.PostJSON(r.Context(), "/operations/ietf-system:set-current-datetime", body); err != nil { + msg := err.Error() + if strings.Contains(msg, "ntp-active") { + msg = "NTP is active — disable NTP first under Configure > System" + } + log.Printf("set datetime: %v", err) + b, _ := json.Marshal(msg) + w.Header().Set("HX-Trigger", `{"cfgError":`+string(b)+`}`) + w.WriteHeader(http.StatusUnprocessableEntity) + return + } + w.Header().Set("HX-Trigger", `{"cfgSaved":"System time updated"}`) + w.WriteHeader(http.StatusOK) +} + +// Shutdown triggers a device power-off via the ietf-system:system-shutdown RPC. +func (h *SystemHandler) Shutdown(w http.ResponseWriter, r *http.Request) { + if err := h.RC.Post(r.Context(), "/operations/ietf-system:system-shutdown"); err != nil { + log.Printf("shutdown: %v", err) + http.Error(w, "shutdown failed", http.StatusBadGateway) + return + } + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, shutdownHTML) +} + +// shutdownHTML is the overlay shown after a shutdown RPC succeeds. +// Uses .reboot-overlay so the 60 s hard-cap redirect fires (session is dead anyway), +// but omits #reboot-status so the JS does not update the message to "coming back…". +const shutdownHTML = `<div class="reboot-overlay"> + <div class="reboot-spinner"></div> + <p class="reboot-message">Shutting down…</p> + <p class="reboot-status">The device is powering off.</p> +</div>` + +// FactoryDefault resets the running datastore to factory defaults without rebooting. +// Uses the infix-factory-default:factory-default RPC. +func (h *SystemHandler) FactoryDefault(w http.ResponseWriter, r *http.Request) { + if err := h.RC.Post(r.Context(), "/operations/infix-factory-default:factory-default"); err != nil { + log.Printf("factory-default: %v", err) + w.Header().Set("Content-Type", "text/html") + fmt.Fprintf(w, `<span class="sc-fd-err">Failed: %s</span>`, + template.HTMLEscapeString(err.Error())) + return + } + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, `<span class="sc-fd-ok">✓ Running config reset to factory defaults</span>`) +} + +// FactoryReset wipes all datastores and non-volatile storage, then reboots. +// Uses the ietf-factory-default:factory-reset RPC. +func (h *SystemHandler) FactoryReset(w http.ResponseWriter, r *http.Request) { + if err := h.RC.Post(r.Context(), "/operations/ietf-factory-default:factory-reset"); err != nil { + log.Printf("factory-reset: %v", err) + http.Error(w, "factory reset failed", http.StatusBadGateway) + return + } + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, factoryResetSpinnerHTML) +} + +const factoryResetSpinnerHTML = `<div class="reboot-overlay"> + <div class="reboot-spinner"></div> + <p class="reboot-message">Factory reset in progress…</p> + <p class="reboot-status" id="reboot-status">Wiping configuration and rebooting…</p> +</div>` + +// Backup renders the Backup & Restore maintenance page. +func (h *SystemHandler) Backup(w http.ResponseWriter, r *http.Request) { + data := newPageData(w, r, "backup", "Backup & Support") + tmplName := "backup.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.BackupTmpl.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("backup template: %v", err) + } +} + +// SupportBundle runs the on-device `support collect` tool and streams the +// resulting archive back as a download. The WebUI runs as root, so the +// collection is complete (dmesg, ethtool, etc.). An optional password +// encrypts the archive via the tool's GPG support and is fed on stdin so +// it never lands in the process list. +// +// Collection emits nothing on stdout until it finishes (~50 s), then the +// whole archive at once. We buffer it and only commit response headers +// once the tool exits successfully, so a mid-collection failure becomes a +// clean 500 rather than a truncated download. --work-dir /tmp keeps the +// transient files in tmpfs; the tool cleans up after itself. +// POST /maintenance/support-bundle +func (h *SystemHandler) SupportBundle(w http.ResponseWriter, r *http.Request) { + // Collection blocks ~50 s with no output, but the server's 15 s + // WriteTimeout would close the connection long before then (nginx + // then logs a 502 "upstream prematurely closed connection"). Push + // the write deadline out for this long-running download. + if err := http.NewResponseController(w).SetWriteDeadline(time.Now().Add(4 * time.Minute)); err != nil { + log.Printf("support bundle: extend write deadline: %v", err) + } + + password := r.FormValue("password") + encrypt := password != "" + + args := []string{"--work-dir", "/tmp", "collect"} + ext, ctype := "tar.gz", "application/gzip" + if encrypt { + args = append(args, "-p") + ext, ctype = "tar.gz.gpg", "application/pgp-encrypted" + } + + ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute) + defer cancel() + + cmd := exec.CommandContext(ctx, "/usr/sbin/support", args...) + if encrypt { + cmd.Stdin = strings.NewReader(password + "\n") + } + out, err := cmd.Output() + if err != nil { + stderr := "" + if ee, ok := err.(*exec.ExitError); ok { + stderr = strings.TrimSpace(string(ee.Stderr)) + } + log.Printf("support bundle: %v: %s", err, stderr) + http.Error(w, "Failed to collect support bundle", http.StatusInternalServerError) + return + } + + hostname, _ := os.Hostname() + if hostname == "" { + hostname = "device" + } + fname := fmt.Sprintf("support-%s-%s.%s", hostname, time.Now().UTC().Format("20060102-1504"), ext) + + w.Header().Set("Content-Type", ctype) + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", fname)) + w.Header().Set("Content-Length", fmt.Sprint(len(out))) + w.Write(out) //nolint:errcheck +} + +// RestoreConfig accepts a multipart-uploaded JSON config file and applies it. +// target="running" (default): PUT to running so changes take effect immediately; +// sets the cfg-unsaved cookie so the persistent notification prompts a save. +// target="startup": PUT to startup only; reboot required to apply. +// migrateConfig runs an uploaded .cfg backup through the on-target migrate(1) +// tool so a config saved by an older release is brought up to the running +// syntax before it is applied. The config is written to a temp file and +// migrated in place — migrate only reads stdin from a TTY, so the file path is +// the reliable interface. A config already at the current version comes back +// unchanged. An error carrying migrate's stderr is returned for a config newer +// than the system supports (downgrade) or any other migrate failure. +func migrateConfig(ctx context.Context, raw []byte) ([]byte, error) { + f, err := os.CreateTemp("", "restore-*.cfg") + if err != nil { + return nil, err + } + tmp := f.Name() + defer os.Remove(tmp) + + if _, err := f.Write(raw); err != nil { + f.Close() + return nil, err + } + if err := f.Close(); err != nil { + return nil, err + } + + var stderr bytes.Buffer + cmd := exec.CommandContext(ctx, "/usr/sbin/migrate", "-i", "-e", tmp) + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(stderr.String()) + msg = strings.ReplaceAll(msg, tmp+": ", "") + msg = strings.TrimPrefix(msg, "Error: ") + if msg == "" { + msg = err.Error() + } + return nil, fmt.Errorf("%s", msg) + } + + if notes := strings.TrimSpace(stderr.String()); notes != "" { + log.Printf("restore: migrated uploaded config:\n%s", notes) + } + + return os.ReadFile(tmp) +} + +// Identity renders the topbar device-identity widget — hostname, plus an +// optional location/contact hover popover. It is loaded asynchronously via +// hx-trigger="load" so it stays out of the per-page data path; the topbar +// persists across htmx content swaps, so this fetches once per full page load. +// On any fetch error it renders nothing, leaving the topbar slot empty. +func (h *SystemHandler) Identity(w http.ResponseWriter, r *http.Request) { + var resp struct { + System struct { + Hostname string `json:"hostname"` + Location string `json:"location"` + Contact string `json:"contact"` + } `json:"ietf-system:system"` + } + if err := h.RC.Get(r.Context(), "/data/ietf-system:system", &resp); err != nil { + log.Printf("identity: %v", err) + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := h.IdentityTmpl.ExecuteTemplate(w, "topbar-identity", resp.System); err != nil { + log.Printf("identity: render: %v", err) + } +} + +func (h *SystemHandler) RestoreConfig(w http.ResponseWriter, r *http.Request) { + if err := r.ParseMultipartForm(10 << 20); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + defer func() { + if r.MultipartForm != nil { + r.MultipartForm.RemoveAll() + } + }() + + file, _, err := r.FormFile("config") + if err != nil { + http.Error(w, "config file required", http.StatusBadRequest) + return + } + defer file.Close() + + raw, err := io.ReadAll(file) + if err != nil { + http.Error(w, "read error", http.StatusInternalServerError) + return + } + + var check json.RawMessage + if err := json.Unmarshal(raw, &check); err != nil { + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, `<span class="sc-fd-err">Invalid JSON file.</span>`) + return + } + + // Bring an older backup up to the running syntax before applying it. + migrated, err := migrateConfig(r.Context(), raw) + if err != nil { + log.Printf("restore: migrate: %v", err) + w.Header().Set("Content-Type", "text/html") + fmt.Fprintf(w, `<span class="sc-fd-err">Restore failed: %s</span>`, + template.HTMLEscapeString(err.Error())) + return + } + + target := "running" + if r.FormValue("save-to-startup") == "on" { + target = "startup" + } + + if err := h.RC.PutDatastore(r.Context(), target, json.RawMessage(migrated)); err != nil { + log.Printf("restore(%s): %v", target, err) + w.Header().Set("Content-Type", "text/html") + fmt.Fprintf(w, `<span class="sc-fd-err">Restore failed: %s</span>`, + template.HTMLEscapeString(err.Error())) + return + } + + if target == "running" { + setCfgUnsaved(w) + w.Header().Set("HX-Refresh", "true") + w.WriteHeader(http.StatusNoContent) + return + } + + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, `<span class="sc-fd-ok">✓ Startup configuration restored. Reboot to apply.</span>`) +} + +// DownloadConfig serves the startup datastore as a JSON file download. +// Filename includes the device hostname and current UTC date+time. +func (h *SystemHandler) DownloadConfig(w http.ResponseWriter, r *http.Request) { + data, err := h.RC.GetRaw(r.Context(), "/ds/ietf-datastores:startup") + if err != nil { + log.Printf("config download: %v", err) + http.Error(w, "failed to fetch config", http.StatusBadGateway) + return + } + + hostname := "device" + var sysResp struct { + System struct { + Hostname string `json:"hostname"` + } `json:"ietf-system:system"` + } + if err := h.RC.Get(r.Context(), "/data/ietf-system:system", &sysResp); err == nil { + if hn := sysResp.System.Hostname; hn != "" { + hostname = hn + } + } + ts := time.Now().UTC().Format("20060102-1504") + filename := fmt.Sprintf("startup-config-%s-%s.cfg", hostname, ts) + + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, filename)) + w.Write(data) +} + +// RESTCONF JSON structures for infix-system:software state. + +type swStateWrapper struct { + SystemState struct { + Software swState `json:"infix-system:software"` + } `json:"ietf-system:system-state"` +} + +type swState struct { + Compatible string `json:"compatible"` + Variant string `json:"variant"` + Booted string `json:"booted"` + BootOrder []string `json:"boot-order"` + Installer swInstallerState `json:"installer"` + Slots []swSlot `json:"slot"` +} + +type swInstallerState struct { + Operation string `json:"operation"` + Progress swInstallerProgress `json:"progress"` + LastError string `json:"last-error"` +} + +// IsIdle reports whether RAUC has no install in flight. The YANG model leaves +// Operation empty when no install has run yet and "idle" once one has completed. +func (s swInstallerState) IsIdle() bool { + return s.Operation == "" || s.Operation == "idle" +} + +type swInstallerProgress struct { + Percentage int `json:"percentage"` + Message string `json:"message"` +} + +type swSlot struct { + Name string `json:"name"` + BootName string `json:"bootname"` + Class string `json:"class"` + State string `json:"state"` + Bundle swSlotBundle `json:"bundle"` + Installed struct { + Datetime string `json:"datetime"` + } `json:"installed"` +} + +type swSlotBundle struct { + Compatible string `json:"compatible"` + Version string `json:"version"` +} + +// Template data for the software page. + +type softwareData struct { + PageData + BootOrder []string + Slots []slotEntry + Installer *installerEntry + Installing bool // install was triggered this session; keep card visible during RAUC phase gaps + AutoReboot bool + Error string + Message string +} + +type slotEntry struct { + Name string // bootname: primary, secondary, etc. + State string + Version string + InstallDate string + Booted bool +} + +type installerEntry struct { + Operation string + Percentage int + Message string + LastError string + Active bool + Done bool // idle after an install ran (percentage>0 or error set) + Success bool // Done with no error +} + +// Software renders the software overview page (GET /software). +func (h *SystemHandler) Software(w http.ResponseWriter, r *http.Request) { + data := softwareData{ + PageData: newPageData(w, r, "software", "Software"), + Message: r.URL.Query().Get("msg"), + Installing: r.URL.Query().Get("installing") == "1", + AutoReboot: r.URL.Query().Get("auto-reboot") == "1", + } + + // When an install is in progress, the RESTCONF path blocks on + // `rauc status` until RAUC is done. Skip the slow path and read + // the installer state directly so the progress card can render + // immediately; SSE then drives the visual update during the + // install. The Software card body falls back to the last cached + // slot snapshot so it doesn't go blank. + if data.Installing { + if inst, err := raucInstallationStatus(r.Context()); err == nil { + data.Installer = newInstallerEntry(inst) + } else { + log.Printf("software page (installing): %v", err) + } + h.swSlots.mu.RLock() + data.BootOrder = slices.Clone(h.swSlots.bootOrder) + data.Slots = slices.Clone(h.swSlots.slots) + h.swSlots.mu.RUnlock() + } else { + var sw swStateWrapper + err := h.RC.Get(r.Context(), "/data/ietf-system:system-state", &sw) + if err != nil { + log.Printf("restconf software: %v", err) + data.Error = "Could not fetch software status" + } else { + data.BootOrder = sw.SystemState.Software.BootOrder + for _, s := range sw.SystemState.Software.Slots { + if s.Class != "rootfs" { + continue + } + name := s.BootName + if name == "" { + name = s.Name + } + date := s.Installed.Datetime + if len(date) > 19 { + date = date[:19] + } + data.Slots = append(data.Slots, slotEntry{ + Name: name, + State: s.State, + Version: s.Bundle.Version, + InstallDate: date, + Booted: s.BootName == sw.SystemState.Software.Booted, + }) + } + + data.Installer = newInstallerEntry(sw.SystemState.Software.Installer) + + h.swSlots.mu.Lock() + h.swSlots.bootOrder = slices.Clone(data.BootOrder) + h.swSlots.slots = slices.Clone(data.Slots) + h.swSlots.mu.Unlock() + } + } + + tmplName := "software.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// SetBootOrder calls the infix-system:set-boot-order RPC with the ordered +// boot-order form values submitted by the boot order card. +// On success it refreshes the page so the Software card badges update. +func (h *SystemHandler) SetBootOrder(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + order := r.Form["boot-order"] + if len(order) == 0 || len(order) > 3 { + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, `<span class="sc-fd-err">Invalid boot order.</span>`) + return + } + body := map[string]any{ + "infix-system:input": map[string]any{ + "boot-order": order, + }, + } + if err := h.RC.PostJSON(r.Context(), "/operations/infix-system:set-boot-order", body); err != nil { + log.Printf("set-boot-order: %v", err) + w.Header().Set("Content-Type", "text/html") + fmt.Fprintf(w, `<span class="sc-fd-err">Failed: %s</span>`, + template.HTMLEscapeString(err.Error())) + return + } + w.Header().Set("HX-Refresh", "true") + w.WriteHeader(http.StatusNoContent) +} + +// SoftwareUpload accepts a .pkg file upload, saves it to a temp file, and +// kicks off the install-bundle RPC asynchronously so the response (a +// plain-text redirect target) reaches the browser before RAUC starts +// writing slots. Upload size is capped at the nginx layer. +func (h *SystemHandler) SoftwareUpload(w http.ResponseWriter, r *http.Request) { + if h.raucBusy(r.Context()) { + http.Error(w, "software install already in progress", http.StatusConflict) + return + } + + // 1 MiB in-RAM threshold; larger parts spill to $TMPDIR (/var/tmp on + // the target, eMMC-backed) instead of the RAM-backed /tmp. + if err := r.ParseMultipartForm(1 << 20); err != nil { + http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest) + return + } + defer func() { + if r.MultipartForm != nil { + r.MultipartForm.RemoveAll() + } + }() + + file, _, err := r.FormFile("pkg") + if err != nil { + http.Error(w, "pkg file required", http.StatusBadRequest) + return + } + defer file.Close() + + tmp, err := os.CreateTemp("", "webui-bundle-*.pkg") + if err != nil { + log.Printf("software upload: create temp: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + tmpPath := tmp.Name() + + if _, err := io.Copy(tmp, file); err != nil { + tmp.Close() + os.Remove(tmpPath) + log.Printf("software upload: write: %v", err) + http.Error(w, "failed to save bundle", http.StatusInternalServerError) + return + } + tmp.Close() + + body := map[string]map[string]string{ + "infix-system:input": {"url": tmpPath}, + } + creds := restconf.CredentialsFromContext(r.Context()) + go h.runInstall(creds, body, tmpPath) + + target := "/software?installing=1" + if r.FormValue("auto-reboot") == "1" { + target += "&auto-reboot=1" + } + w.Header().Set("Content-Type", "text/plain") + fmt.Fprint(w, target) +} + +// runInstall fires the install-bundle RPC and then waits for RAUC to finish +// before deleting the uploaded .pkg. The 30-minute outer timeout is the +// safety net for a stuck RAUC. +func (h *SystemHandler) runInstall(creds restconf.Credentials, body any, tmpPath string) { + ctx, cancel := context.WithTimeout( + restconf.ContextWithCredentials(context.Background(), creds), + 30*time.Minute, + ) + defer cancel() + defer os.Remove(tmpPath) + + if err := h.RC.PostJSON(ctx, "/operations/infix-system:install-bundle", body); err != nil { + log.Printf("software upload: install-bundle: %v", err) + return + } + + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + inst, err := raucInstallationStatus(ctx) + if err != nil { + continue + } + if inst.IsIdle() { + return + } + } + } +} + +// SoftwareInstall triggers a bundle install via the install-bundle RPC (POST /software/install). +func (h *SystemHandler) SoftwareInstall(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + url := r.FormValue("url") + if url == "" { + http.Error(w, "url is required", http.StatusBadRequest) + return + } + + body := map[string]map[string]string{ + "infix-system:input": { + "url": url, + }, + } + + err := h.RC.PostJSON(r.Context(), "/operations/infix-system:install-bundle", body) + if err != nil { + log.Printf("software install: %v", err) + w.Header().Set("HX-Redirect", "/software?msg=Install+failed:+"+err.Error()) + w.WriteHeader(http.StatusNoContent) + return + } + + target := "/software?installing=1" + if r.FormValue("auto-reboot") == "1" { + target += "&auto-reboot=1" + } + w.Header().Set("HX-Redirect", target) + w.WriteHeader(http.StatusNoContent) +} + +// SoftwareProgress streams installer status as SSE so the Go server does the +// polling and the browser just receives rendered HTML fragments. +// GET /software/progress +func (h *SystemHandler) SoftwareProgress(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", http.StatusInternalServerError) + return + } + + autoReboot := r.URL.Query().Get("auto-reboot") == "1" + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + flusher.Flush() + + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + + var lastKey string // change-detection: suppress redundant SSE frames + lastFrame := time.Now() + + for { + select { + case <-r.Context().Done(): + return + case <-ticker.C: + data := h.installerSnapshot(r, autoReboot) + + // Build a cheap key for change detection; skip frames with identical state. + var key string + if data.Installer != nil { + key = fmt.Sprintf("%s|%d|%s|%s", data.Installer.Operation, data.Installer.Percentage, data.Installer.Message, data.Installer.LastError) + } + if key == lastKey && key != "" { + // RAUC sometimes parks Progress at the same percentage + // for tens of seconds (e.g., "Checking bundle" while + // verifying signatures, or quiet during slot write). + // Emit a comment as a keep-alive every 15 s so nginx's + // proxy_read_timeout and the browser EventSource both + // keep the stream warm — otherwise the connection gets + // torn down mid-install and the UI freezes on the last + // rendered frame until a manual page reload. + if time.Since(lastFrame) > 15*time.Second { + fmt.Fprint(w, ": keep-alive\n\n") + flusher.Flush() + lastFrame = time.Now() + } + continue + } + lastKey = key + lastFrame = time.Now() + + var buf bytes.Buffer + if err := h.Template.ExecuteTemplate(&buf, "sw-progress-body", data); err != nil { + log.Printf("software progress template: %v", err) + continue + } + + // SSE data must not contain raw newlines; collapse to spaces. + line := strings.ReplaceAll(buf.String(), "\n", " ") + + eventName := "progress" + if data.Installer != nil && data.Installer.Done { + if autoReboot && data.Installer.Success { + eventName = "reboot" + } else { + eventName = "done" + } + } + + fmt.Fprintf(w, "event: %s\ndata: %s\n\n", eventName, line) + flusher.Flush() + + if data.Installer != nil && data.Installer.Done { + return + } + } + } +} + +// installerSnapshot reads RAUC's installer state via rauc-installation-status +// (direct D-Bus property read) and builds the template data for the +// sw-progress-body fragment. The RESTCONF path is avoided because it runs +// `rauc status`, which blocks while an install is in progress. +func (h *SystemHandler) installerSnapshot(r *http.Request, autoReboot bool) softwareProgressData { + data := softwareProgressData{ + AutoReboot: autoReboot, + } + + inst, err := raucInstallationStatus(r.Context()) + if err != nil { + // Leave Installer nil so the template renders an indeterminate + // "Installing…" state on transient failures. + log.Printf("software progress poll: %v", err) + return data + } + data.Installer = newInstallerEntry(inst) + return data +} + +// newInstallerEntry converts a raw YANG installer state to the template-facing struct. +func newInstallerEntry(inst swInstallerState) *installerEntry { + idle := inst.IsIdle() + done := idle && (inst.Progress.Percentage > 0 || inst.LastError != "") + return &installerEntry{ + Operation: inst.Operation, + Percentage: inst.Progress.Percentage, + Message: inst.Progress.Message, + LastError: inst.LastError, + Active: !idle, + Done: done, + Success: done && inst.LastError == "", + } +} + +// softwareProgressData is the template data for the sw-progress-body fragment. +type softwareProgressData struct { + AutoReboot bool + Installer *installerEntry +} diff --git a/src/webui/internal/handlers/vpn.go b/src/webui/internal/handlers/vpn.go new file mode 100644 index 00000000..a00cac4e --- /dev/null +++ b/src/webui/internal/handlers/vpn.go @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "context" + "fmt" + "html/template" + "log" + "net/http" + "sync" + "time" + + "infix/webui/internal/restconf" +) + +// wgConfigJSON is a local extension for fetching WireGuard configuration +// fields (like listen-port) that are not in the shared wireGuardJSON type. +type wgConfigJSON struct { + ListenPort int `json:"listen-port"` +} + +// wgIfaceConfigWrapper is used to fetch per-interface WireGuard config. +type wgIfaceConfigWrapper struct { + WireGuard *wgConfigJSON `json:"infix-interfaces:wireguard"` +} + +// WGPeer holds display-ready data for a single WireGuard peer. +type WGPeer struct { + PublicKey string + PublicKeyShort string // first 8 chars + "..." + Endpoint string // "IP:port" or empty + Status string // "up" or "down" + LastHandshake string // relative time, e.g. "2 min ago" or "never" + RxBytes string // human-readable + TxBytes string // human-readable +} + +// WGTunnel holds display-ready data for a single WireGuard interface. +type WGTunnel struct { + Name string + ListenPort int + Addresses []string // IP addresses from ietf-ip + OperStatus string + Peers []WGPeer +} + +// vpnData is the template data struct for the WireGuard status page. +type vpnData struct { + PageData + Tunnels []WGTunnel + Error string +} + +// VPNHandler serves the WireGuard status page. +type VPNHandler struct { + Template *template.Template + RC *restconf.Client +} + +// Overview renders the WireGuard page (GET /vpn). +func (h *VPNHandler) Overview(w http.ResponseWriter, r *http.Request) { + data := vpnData{ + PageData: newPageData(w, r, "vpn", "WireGuard"), + } + + // Detach from the request context so that RESTCONF calls survive + // browser connection resets (common during login redirects). + ctx := context.WithoutCancel(r.Context()) + + var ifaces interfacesWrapper + if err := h.RC.Get(ctx, "/data/ietf-interfaces:interfaces", &ifaces); err != nil { + log.Printf("restconf interfaces (vpn): %v", err) + data.Error = "Could not fetch interface information" + h.render(w, r, data) + return + } + + // Filter for WireGuard interfaces only. + var wgIfaces []ifaceJSON + for _, iface := range ifaces.Interfaces.Interface { + if iface.Type == "infix-if-type:wireguard" { + wgIfaces = append(wgIfaces, iface) + } + } + + if len(wgIfaces) == 0 { + h.render(w, r, data) + return + } + + // Fetch per-interface WireGuard config concurrently. + tunnels := make([]WGTunnel, len(wgIfaces)) + var wg sync.WaitGroup + + for i, iface := range wgIfaces { + wg.Add(1) + go func(idx int, iface ifaceJSON) { + defer wg.Done() + tunnels[idx] = buildWGTunnel(ctx, h.RC, iface) + }(i, iface) + } + + wg.Wait() + data.Tunnels = tunnels + h.render(w, r, data) +} + +// buildWGTunnel constructs a WGTunnel from an interface and optional config fetch. +func buildWGTunnel(ctx context.Context, rc *restconf.Client, iface ifaceJSON) WGTunnel { + tunnel := WGTunnel{ + Name: iface.Name, + OperStatus: iface.OperStatus, + } + + // Collect IP addresses. + if iface.IPv4 != nil { + for _, a := range iface.IPv4.Address { + tunnel.Addresses = append(tunnel.Addresses, fmt.Sprintf("%s/%d", a.IP, int(a.PrefixLength))) + } + } + if iface.IPv6 != nil { + for _, a := range iface.IPv6.Address { + tunnel.Addresses = append(tunnel.Addresses, fmt.Sprintf("%s/%d", a.IP, int(a.PrefixLength))) + } + } + + // Fetch ListenPort from config endpoint (separate from oper-state). + var cfgWrap wgIfaceConfigWrapper + path := fmt.Sprintf("/data/ietf-interfaces:interfaces/interface=%s/infix-interfaces:wireguard", iface.Name) + if err := rc.Get(ctx, path, &cfgWrap); err == nil && cfgWrap.WireGuard != nil { + tunnel.ListenPort = cfgWrap.WireGuard.ListenPort + } + + // Build peers from embedded peer-status. + if iface.WireGuard != nil && iface.WireGuard.PeerStatus != nil { + for _, p := range iface.WireGuard.PeerStatus.Peer { + peer := WGPeer{ + PublicKey: p.PublicKey, + PublicKeyShort: shortKey(p.PublicKey), + Status: p.ConnectionStatus, + LastHandshake: relativeTime(p.LatestHandshake), + } + if p.EndpointAddress != "" { + peer.Endpoint = fmt.Sprintf("%s:%d", p.EndpointAddress, p.EndpointPort) + } + if p.Transfer != nil { + peer.RxBytes = humanBytes(int64(p.Transfer.RxBytes)) + peer.TxBytes = humanBytes(int64(p.Transfer.TxBytes)) + } else { + peer.RxBytes = "0 B" + peer.TxBytes = "0 B" + } + tunnel.Peers = append(tunnel.Peers, peer) + } + } + + return tunnel +} + +// shortKey returns the first 8 characters of a WireGuard public key followed by "...". +func shortKey(key string) string { + if len(key) <= 8 { + return key + } + return key[:8] + "..." +} + +// relativeTime converts an RFC3339 timestamp to a human-readable relative time string. +// Returns "never" if the timestamp is empty or cannot be parsed. +func relativeTime(ts string) string { + if ts == "" { + return "never" + } + t, err := time.Parse(time.RFC3339, ts) + if err != nil { + // Try RFC3339Nano as fallback. + t, err = time.Parse(time.RFC3339Nano, ts) + if err != nil { + return "never" + } + } + if t.IsZero() { + return "never" + } + + d := time.Since(t) + switch { + case d < 0: + return "just now" + case d < time.Minute: + return fmt.Sprintf("%d sec ago", int(d.Seconds())) + case d < time.Hour: + mins := int(d.Minutes()) + if mins == 1 { + return "1 min ago" + } + return fmt.Sprintf("%d min ago", mins) + case d < 24*time.Hour: + hours := int(d.Hours()) + if hours == 1 { + return "1 hour ago" + } + return fmt.Sprintf("%d hours ago", hours) + default: + days := int(d.Hours()) / 24 + if days == 1 { + return "1 day ago" + } + return fmt.Sprintf("%d days ago", days) + } +} + +// render executes the correct template based on whether it's an htmx request. +func (h *VPNHandler) render(w http.ResponseWriter, r *http.Request, data vpnData) { + tmplName := "vpn.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("template error (vpn): %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} diff --git a/src/webui/internal/handlers/vpn_test.go b/src/webui/internal/handlers/vpn_test.go new file mode 100644 index 00000000..2a464f4f --- /dev/null +++ b/src/webui/internal/handlers/vpn_test.go @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "html/template" + "net/http" + "net/http/httptest" + "testing" + + "infix/webui/internal/restconf" + "infix/webui/internal/security" +) + +var minimalVPNTmpl = template.Must(template.New("vpn.html").Parse( + `{{define "vpn.html"}}tunnels={{len .Tunnels}}{{end}}` + + `{{define "content"}}{{len .Tunnels}}{{end}}`, +)) + +func TestVPNOverview_ReturnsOK(t *testing.T) { + rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false) + h := &VPNHandler{Template: minimalVPNTmpl, RC: rc} + + req := httptest.NewRequest(http.MethodGet, "/vpn", nil) + ctx := restconf.ContextWithCredentials(req.Context(), restconf.Credentials{ + Username: "admin", + Password: "admin", + }) + ctx = security.WithToken(ctx, "test-csrf-token") + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + h.Overview(w, req) + + if w.Code != http.StatusOK { + t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String()) + } + + body := w.Body.String() + if body == "" { + t.Error("expected non-empty response body") + } +} + +func TestVPNOverview_HTMXPartial(t *testing.T) { + rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false) + h := &VPNHandler{Template: minimalVPNTmpl, RC: rc} + + req := httptest.NewRequest(http.MethodGet, "/vpn", nil) + req.Header.Set("HX-Request", "true") + ctx := restconf.ContextWithCredentials(req.Context(), restconf.Credentials{ + Username: "admin", + Password: "admin", + }) + ctx = security.WithToken(ctx, "test-csrf-token") + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + h.Overview(w, req) + + if w.Code != http.StatusOK { + t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String()) + } + + body := w.Body.String() + if body == "" { + t.Error("expected non-empty response body for htmx partial") + } +} diff --git a/src/webui/internal/handlers/wifi.go b/src/webui/internal/handlers/wifi.go new file mode 100644 index 00000000..16d52be9 --- /dev/null +++ b/src/webui/internal/handlers/wifi.go @@ -0,0 +1,420 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "context" + "fmt" + "html/template" + "log" + "net/http" + "sync" + + "infix/webui/internal/restconf" +) + +// wifiRadioHWJSON extends the hardware component wifi-radio container with +// operational fields from infix-hardware YANG that are not in wifiRadioJSON. +// wifiRadioJSON (defined in interfaces.go) only covers survey data; this +// struct captures the full operational state returned by RESTCONF. +// wifiMaxIfJSON maps the max-interfaces container from infix-hardware YANG. +type wifiMaxIfJSON struct { + AP int `json:"ap"` + Station int `json:"station"` + Monitor int `json:"monitor"` +} + +type wifiRadioHWJSON struct { + CountryCode string `json:"country-code"` // ISO 3166-1, rw + Channel interface{} `json:"channel"` // uint16 or "auto", rw + Band string `json:"band"` // rw + Frequency int `json:"frequency"` // MHz, operational + Noise int `json:"noise"` // dBm, operational + Driver string `json:"driver"` + Bands []wifiBandJSON `json:"bands"` + MaxInterfaces *wifiMaxIfJSON `json:"max-interfaces"` + Survey *wifiSurveyJSON `json:"survey"` +} + +type wifiBandJSON struct { + Band string `json:"band"` + Name string `json:"name"` + HTCapable bool `json:"ht-capable"` + VHTCapable bool `json:"vht-capable"` + HECapable bool `json:"he-capable"` +} + +// hwComponentWiFiJSON is a minimal hardware component used for the wifi page. +// We reuse hardwareWrapper but need to decode wifi-radio with the richer struct. +type hwComponentWiFiJSON struct { + Name string `json:"name"` + Class string `json:"class"` + MfgName string `json:"mfg-name"` + WiFiRadio *wifiRadioHWJSON `json:"infix-hardware:wifi-radio"` +} + +type hardwareWiFiWrapper struct { + Hardware struct { + Component []hwComponentWiFiJSON `json:"component"` + } `json:"ietf-hardware:hardware"` +} + +// WiFiRadio is the template data for a single physical radio. +type WiFiRadio struct { + Name string + Channel string + Band string + Frequency int + Noise int + Driver string + Manufacturer string + Standards string + MaxAP string + HTCapable bool + VHTCapable bool + HECapable bool + Bands []WiFiBand + SurveySVG template.HTML + Interfaces []WiFiInterface +} + +type WiFiBand struct { + Band string + Name string + HTCapable bool + VHTCapable bool + HECapable bool +} + +// ChannelSurvey holds processed survey data for one channel. +type ChannelSurvey struct { + Frequency int + Channel int + InUse bool + Noise int + ActiveTime int64 + BusyTime int64 + UtilPct int // BusyTime/ActiveTime * 100 +} + +// WiFiInterface is the template data for a virtual WiFi interface. +type WiFiInterface struct { + Name string + Mode string // "ap" or "station" + SSID string + OperStatus string + StatusUp bool + // AP mode + APClients []WiFiClient + // Station mode + Signal string + SignalCSS string + RxSpeed string + TxSpeed string + ScanResults []WiFiScan +} + +// WiFiClient is the template data for a connected station client. +type WiFiClient struct { + MAC string + Signal string + SignalCSS string + ConnTime string + RxBytes string + TxBytes string + RxSpeed string + TxSpeed string +} + +// WiFiScan is the template data for a scan result entry. +type WiFiScan struct { + SSID string + BSSID string + Signal string + SignalCSS string + Channel string + Encryption string +} + +// wifiData is the top-level template data for the WiFi page. +type wifiData struct { + PageData + Radios []WiFiRadio + Error string +} + +// WiFiHandler serves the WiFi status page. +type WiFiHandler struct { + Template *template.Template + RC *restconf.Client +} + +// Overview renders the WiFi page (GET /wifi). +func (h *WiFiHandler) Overview(w http.ResponseWriter, r *http.Request) { + data := wifiData{ + PageData: newPageData(w, r, "wifi", "WiFi"), + } + + // Detach from the request context so that RESTCONF calls survive + // browser connection resets. + ctx := context.WithoutCancel(r.Context()) + + var ( + hw hardwareWiFiWrapper + ifaces interfacesWrapper + hwErr, ifErr error + wg sync.WaitGroup + ) + + wg.Add(2) + go func() { + defer wg.Done() + hwErr = h.RC.Get(ctx, "/data/ietf-hardware:hardware", &hw) + }() + go func() { + defer wg.Done() + ifErr = h.RC.Get(ctx, "/data/ietf-interfaces:interfaces", &ifaces) + }() + wg.Wait() + + if hwErr != nil { + log.Printf("wifi: restconf hardware: %v", hwErr) + data.Error = "Could not fetch hardware information" + } + if ifErr != nil { + log.Printf("wifi: restconf interfaces: %v", ifErr) + if data.Error == "" { + data.Error = "Could not fetch interface information" + } + } + + if hwErr == nil && ifErr == nil { + data.Radios = buildWiFiRadios(hw.Hardware.Component, ifaces.Interfaces.Interface) + } + + tmplName := "wifi.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("wifi: template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// buildWiFiRadios assembles the WiFiRadio slice from hardware components +// and interface data, matching interfaces to their radio by name. +func buildWiFiRadios(components []hwComponentWiFiJSON, ifaces []ifaceJSON) []WiFiRadio { + var radios []WiFiRadio + + for _, c := range components { + if c.WiFiRadio == nil { + continue + } + r := c.WiFiRadio + + radio := WiFiRadio{ + Name: c.Name, + Band: r.Band, + Frequency: r.Frequency, + Noise: r.Noise, + Driver: r.Driver, + Channel: wifiChannelString(r.Channel), + Manufacturer: c.MfgName, + } + + // Capability flags: check per-band capabilities; if any band supports + // HT/VHT/HE, mark the radio as capable. + var bandNames []string + for _, b := range r.Bands { + name := b.Name + if name == "" { + name = b.Band + } + radio.Bands = append(radio.Bands, WiFiBand{ + Band: b.Band, + Name: name, + HTCapable: b.HTCapable, + VHTCapable: b.VHTCapable, + HECapable: b.HECapable, + }) + if b.Name != "" { + bandNames = append(bandNames, b.Name) + } + if b.HTCapable { + radio.HTCapable = true + } + if b.VHTCapable { + radio.VHTCapable = true + } + if b.HECapable { + radio.HECapable = true + } + } + + // Derive standards string from aggregated capabilities (matches CLI). + var standards []string + if radio.HTCapable { + standards = append(standards, "11n") + } + if radio.VHTCapable { + standards = append(standards, "11ac") + } + if radio.HECapable { + standards = append(standards, "11ax") + } + if len(standards) > 0 { + radio.Standards = joinStrings(standards, "/") + } + + // Max AP count from max-interfaces container. + if r.MaxInterfaces != nil && r.MaxInterfaces.AP > 0 { + radio.MaxAP = fmt.Sprintf("%d", r.MaxInterfaces.AP) + } + + // Generate channel survey SVG if survey data exists. + if r.Survey != nil && len(r.Survey.Channel) > 0 { + radio.SurveySVG = renderSurveySVG(r.Survey.Channel) + } + + // Attach wifi interfaces that reference this radio. + radio.Interfaces = buildWiFiInterfaces(c.Name, ifaces) + + radios = append(radios, radio) + } + + return radios +} + +// buildWiFiInterfaces returns the WiFiInterface entries for all virtual +// interfaces that reference the given radio name. +func buildWiFiInterfaces(radioName string, ifaces []ifaceJSON) []WiFiInterface { + var result []WiFiInterface + + for _, iface := range ifaces { + if iface.WiFi == nil || iface.WiFi.Radio != radioName { + continue + } + + wi := WiFiInterface{ + Name: iface.Name, + OperStatus: iface.OperStatus, + StatusUp: iface.OperStatus == "up", + } + + if ap := iface.WiFi.AccessPoint; ap != nil { + wi.Mode = "ap" + wi.SSID = ap.SSID + for _, s := range ap.Stations.Station { + wi.APClients = append(wi.APClients, buildWiFiClient(s)) + } + } else if st := iface.WiFi.Station; st != nil { + wi.Mode = "station" + wi.SSID = st.SSID + if st.SignalStrength != nil { + sig := *st.SignalStrength + wi.Signal = fmt.Sprintf("%d dBm", sig) + wi.SignalCSS = wifiSignalCSS(sig) + } + if st.RxSpeed > 0 { + wi.RxSpeed = fmt.Sprintf("%.1f Mbps", float64(st.RxSpeed)/10) + } + if st.TxSpeed > 0 { + wi.TxSpeed = fmt.Sprintf("%.1f Mbps", float64(st.TxSpeed)/10) + } + for _, sr := range st.ScanResults { + wi.ScanResults = append(wi.ScanResults, buildWiFiScan(sr)) + } + } + + result = append(result, wi) + } + + return result +} + +// buildWiFiClient converts a wifiStaJSON to a WiFiClient template entry. +func buildWiFiClient(s wifiStaJSON) WiFiClient { + c := WiFiClient{ + MAC: s.MACAddress, + ConnTime: formatDuration(int64(s.ConnectedTime)), + RxBytes: humanBytes(int64(s.RxBytes)), + TxBytes: humanBytes(int64(s.TxBytes)), + RxSpeed: fmt.Sprintf("%.1f Mbps", float64(s.RxSpeed)/10), + TxSpeed: fmt.Sprintf("%.1f Mbps", float64(s.TxSpeed)/10), + } + if s.SignalStrength != nil { + sig := *s.SignalStrength + c.Signal = fmt.Sprintf("%d dBm", sig) + c.SignalCSS = wifiSignalCSS(sig) + } + return c +} + +// buildWiFiScan converts a wifiScanResultJSON to a WiFiScan template entry. +func buildWiFiScan(sr wifiScanResultJSON) WiFiScan { + enc := "Open" + if len(sr.Encryption) > 0 { + parts := make([]string, 0, len(sr.Encryption)) + for _, e := range sr.Encryption { + parts = append(parts, e) + } + enc = joinStrings(parts, ", ") + } + s := WiFiScan{ + SSID: sr.SSID, + BSSID: sr.BSSID, + Channel: fmt.Sprintf("%d", sr.Channel), + Encryption: enc, + } + if sr.SignalStrength != nil { + sig := *sr.SignalStrength + s.Signal = fmt.Sprintf("%d dBm", sig) + s.SignalCSS = wifiSignalCSS(sig) + } + return s +} + +// wifiSignalCSS returns a CSS class based on signal strength in dBm. +func wifiSignalCSS(sig int) string { + switch { + case sig >= -50: + return "signal-excellent" + case sig >= -60: + return "signal-good" + case sig >= -70: + return "signal-ok" + default: + return "signal-poor" + } +} + +// wifiChannelString converts the YANG channel union value to a display string. +// The JSON may arrive as a float64 (number) or string ("auto"). +func wifiChannelString(v interface{}) string { + if v == nil { + return "" + } + switch val := v.(type) { + case string: + return val + case float64: + return fmt.Sprintf("%d", int(val)) + case int: + return fmt.Sprintf("%d", val) + default: + return fmt.Sprintf("%v", val) + } +} + +// joinStrings joins a slice of strings with a separator. +func joinStrings(parts []string, sep string) string { + result := "" + for i, p := range parts { + if i > 0 { + result += sep + } + result += p + } + return result +} diff --git a/src/webui/internal/handlers/wifi_test.go b/src/webui/internal/handlers/wifi_test.go new file mode 100644 index 00000000..b4b2ee7a --- /dev/null +++ b/src/webui/internal/handlers/wifi_test.go @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "html/template" + "net/http" + "net/http/httptest" + "testing" + + "infix/webui/internal/restconf" + "infix/webui/internal/security" +) + +var minimalWiFiTmpl = template.Must(template.New("wifi.html").Parse( + `{{define "wifi.html"}}radios={{len .Radios}}{{end}}` + + `{{define "content"}}{{len .Radios}}{{end}}`, +)) + +func TestWiFiOverview_ReturnsOK(t *testing.T) { + rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false) + h := &WiFiHandler{Template: minimalWiFiTmpl, RC: rc} + + req := httptest.NewRequest(http.MethodGet, "/wifi", nil) + ctx := restconf.ContextWithCredentials(req.Context(), restconf.Credentials{ + Username: "admin", + Password: "admin", + }) + ctx = security.WithToken(ctx, "test-csrf-token") + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + h.Overview(w, req) + + if w.Code != http.StatusOK { + t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String()) + } + + body := w.Body.String() + if body == "" { + t.Error("expected non-empty response body") + } +} + +func TestWiFiOverview_HTMXPartial(t *testing.T) { + rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false) + h := &WiFiHandler{Template: minimalWiFiTmpl, RC: rc} + + req := httptest.NewRequest(http.MethodGet, "/wifi", nil) + req.Header.Set("HX-Request", "true") + ctx := restconf.ContextWithCredentials(req.Context(), restconf.Credentials{ + Username: "admin", + Password: "admin", + }) + ctx = security.WithToken(ctx, "test-csrf-token") + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + h.Overview(w, req) + + if w.Code != http.StatusOK { + t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String()) + } + + body := w.Body.String() + if body == "" { + t.Error("expected non-empty response body for htmx partial") + } +} diff --git a/src/webui/internal/handlers/yang_data.go b/src/webui/internal/handlers/yang_data.go new file mode 100644 index 00000000..bc6c6337 --- /dev/null +++ b/src/webui/internal/handlers/yang_data.go @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "strings" + + "infix/webui/internal/restconf" + "infix/webui/internal/schema" +) + +const candidateDS = "/ds/ietf-datastores:candidate" + +// DataHandler serves GET /api/data — raw RESTCONF JSON for a path. +// PUT and DELETE are handled by TreeHandler to share template rendering. +type DataHandler struct { + RC restconf.Fetcher + Schema *schema.Cache +} + +// Get serves GET /api/data?path=... +// Returns the raw RESTCONF JSON subtree from candidate (falls back to running). +func (h *DataHandler) Get(w http.ResponseWriter, r *http.Request) { + path := r.URL.Query().Get("path") + if path == "" { + http.Error(w, "path required", http.StatusBadRequest) + return + } + + data, err := h.RC.GetRaw(r.Context(), candidateDS+path) + if err != nil { + data, err = h.RC.GetRaw(r.Context(), "/data"+path) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + } + + w.Header().Set("Content-Type", "application/json") + w.Write(data) +} + +// navigateToNode traverses a RESTCONF JSON response using path segments to +// reach the target node. The server always wraps responses in the full +// module-root hierarchy ({"module:root": {"list": [{...}]}}), so we walk each +// segment; for list segments with a key predicate (e.g. "interface=eth0") we +// enter the array and take the first (only) element. +// Returns nil when any segment cannot be found. +func navigateToNode(data []byte, path string) json.RawMessage { + segs := strings.Split(strings.TrimPrefix(path, "/"), "/") + current := json.RawMessage(data) + for _, seg := range segs { + hasPred := strings.ContainsAny(seg, "[=") + _, localName := splitModPrefix(stripModPredicate(seg)) + var obj map[string]json.RawMessage + if err := json.Unmarshal(current, &obj); err != nil { + return nil + } + var found json.RawMessage + for k, v := range obj { + _, local := splitModPrefix(k) + if local == localName { + found = v + break + } + } + if found == nil { + return nil + } + if hasPred { + var arr []json.RawMessage + if err := json.Unmarshal(found, &arr); err != nil || len(arr) == 0 { + return nil + } + // Extract key value from predicate: "name=eth0" → "eth0". + keyVal := "" + if i := strings.IndexByte(seg, '='); i >= 0 { + keyVal = seg[i+1:] + } + matched := arr[0] // fallback: first element + if keyVal != "" { + for _, elem := range arr { + var row map[string]json.RawMessage + if json.Unmarshal(elem, &row) != nil { + continue + } + for _, v := range row { + var s string + if json.Unmarshal(v, &s) == nil && s == keyVal { + matched = elem + goto nextSeg + } + } + } + } + nextSeg: + found = matched + } + current = found + } + return current +} + +// flattenNodeValues extracts direct scalar leaf values from a JSON object, +// returning a map of bare-name → string. Nested objects and arrays (which +// represent sub-containers and sub-lists) are silently skipped. +func flattenNodeValues(raw json.RawMessage) map[string]string { + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + return nil + } + result := make(map[string]string, len(obj)) + for k, v := range obj { + if len(v) > 0 && v[0] == '[' { + // YANG empty type is encoded as [null] in JSON (RFC 7951 §6.9). + // Represent presence as "true"; skip real arrays (sub-lists). + trimmed := bytes.TrimSpace(v) + if bytes.Equal(trimmed, []byte("[null]")) { + _, local := splitModPrefix(k) + result[local] = "true" + } + continue + } + if len(v) > 0 && v[0] == '{' { + continue // sub-container — not a direct leaf + } + _, local := splitModPrefix(k) + result[local] = extractScalar(v) + } + return result +} + +// stripModPredicate removes both a module prefix and a RESTCONF key predicate +// from a path segment, e.g. "ietf-interfaces:interface=eth0" → "interface". +func stripModPredicate(seg string) string { + if i := strings.IndexByte(seg, '['); i >= 0 { + seg = seg[:i] + } + if i := strings.IndexByte(seg, '='); i >= 0 { + seg = seg[:i] + } + return seg +} + +// extractLeafValue unwraps the single-key RESTCONF JSON envelope that wraps a +// leaf value: {"module:name": <value>} → string representation of <value>. +// Recursively unwraps single-key nested objects so that a response like +// {"module:parent": {"certificate": "gencert"}} → "gencert". +func extractLeafValue(data []byte) string { + var m map[string]json.RawMessage + if err := json.Unmarshal(data, &m); err != nil { + return "" + } + for _, raw := range m { + return extractScalar(raw) + } + return "" +} + +// extractScalar converts a JSON value to a display string. +// Single-key objects are recursively unwrapped (RESTCONF sometimes wraps leaf +// values in a choice/case or container envelope). +func extractScalar(raw json.RawMessage) string { + // Plain string. + var s string + if json.Unmarshal(raw, &s) == nil { + return s + } + // Bool or number (unquoted JSON token). + v := string(raw) + if v == "true" || v == "false" || (len(v) > 0 && (v[0] == '-' || (v[0] >= '0' && v[0] <= '9'))) { + return v + } + // Single-key object: unwrap one level and recurse. + var nested map[string]json.RawMessage + if err := json.Unmarshal(raw, &nested); err == nil && len(nested) == 1 { + for _, inner := range nested { + return extractScalar(inner) + } + } + // Fallback: return the raw token (strips outer quotes if present). + if len(v) >= 2 && v[0] == '"' && v[len(v)-1] == '"' { + return v[1 : len(v)-1] + } + return v +} + +// coerceLeafValue converts the raw form string to the JSON type that RESTCONF +// expects for the leaf based on the schema Node type. +func coerceLeafValue(raw string, node *schema.Node) any { + if node == nil || node.Type == nil { + return raw + } + switch node.Type.Kind { + case "boolean": + return raw == "on" || raw == "true" + case "int8", "int16", "int32", "int64", + "uint8", "uint16", "uint32", "uint64": + var n int64 + if _, err := fmt.Sscanf(raw, "%d", &n); err == nil { + return n + } + case "binary": + // Strip whitespace that textarea input may add (trailing newlines, spaces). + // If the cleaned value is valid base64, send it as-is; otherwise encode. + cleaned := strings.Map(func(r rune) rune { + if r == ' ' || r == '\t' || r == '\n' || r == '\r' { + return -1 + } + return r + }, raw) + if _, err := base64.StdEncoding.DecodeString(cleaned); err == nil { + return cleaned + } + return base64.StdEncoding.EncodeToString([]byte(raw)) + } + return raw +} diff --git a/src/webui/internal/handlers/yang_tree.go b/src/webui/internal/handlers/yang_tree.go new file mode 100644 index 00000000..e5fe380f --- /dev/null +++ b/src/webui/internal/handlers/yang_tree.go @@ -0,0 +1,1741 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "html/template" + "log" + "net/http" + "net/url" + "sort" + "strings" + "unicode" + "unicode/utf8" + + "infix/webui/internal/restconf" + "infix/webui/internal/schema" +) + +// SchemaHandler serves YANG schema queries as JSON (used by the tree UI and +// for direct API access / testing). +type SchemaHandler struct { + Cache *schema.Cache +} + +// Schema serves GET /api/schema?path=<restconf-path> +// Returns a single Node (without children) as JSON. +func (h *SchemaHandler) Schema(w http.ResponseWriter, r *http.Request) { + mgr := h.Cache.Manager() + if mgr == nil { + http.Error(w, "schema not yet loaded", http.StatusServiceUnavailable) + return + } + + path := r.URL.Query().Get("path") + if path == "" { + path = "/" + } + + node, err := mgr.NodeAt(path) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(node) +} + +// Children serves GET /api/schema/children?path=<restconf-path> +// Returns a JSON array of direct child Nodes. +func (h *SchemaHandler) Children(w http.ResponseWriter, r *http.Request) { + mgr := h.Cache.Manager() + if mgr == nil { + http.Error(w, "schema not yet loaded", http.StatusServiceUnavailable) + return + } + + path := r.URL.Query().Get("path") + if path == "" { + path = "/" + } + + nodes, err := mgr.Children(path) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(nodes) +} + +// ─── Tree UI handler ───────────────────────────────────────────────────────── + +// TreeHandler serves the YANG tree navigation UI pages and HTMX fragments. +type TreeHandler struct { + Cache *schema.Cache + RC restconf.Fetcher + PageTmpl *template.Template + FragTmpl *template.Template + ReadOnly bool // true for the status/operational tree; suppresses all writes +} + +// treeNodeData wraps a schema.Node with live presence-state from the datastore. +// Exists is only meaningful when Node.Presence != "". +// TreeBase is the URL prefix for tree HTMX requests ("/configure/tree" or "/status/tree"). +type treeNodeData struct { + *schema.Node + Exists bool + TreeBase string +} + +type yangTreePageData struct { + PageData + Nodes []*treeNodeData + Loading bool + InitialPath string // non-empty: auto-load this node in the right pane on page load + ReadOnly bool // true for the operational status tree + TreeBase string // URL prefix: "/configure/tree" or "/status/tree" +} + +// nodeDetailData is the template data for the yang-node-detail fragment. +type nodeDetailData struct { + *schema.Node + CurrentValue string + UsingDefault bool // true when CurrentValue is the YANG default, not an explicit candidate value + IsBinary bool // true when the leaf is binary type and CurrentValue holds decoded text + LeafrefValues []string + SavedOK bool + Error string + ReadOnly bool +} + +// leafGroupData is the template data for yang-leaf-group: a container or +// list-instance rendered as an auto-generated "level page". It shows direct +// leaf children as an inline editable form, and structural children (sub-containers +// and lists) as clickable navigation items below. +type leafGroupData struct { + Path string + ParentPath string // set when rendered as an inline sub-container + Name string // display name of the parent node + Kind string // "container" or "list-instance" + Presence string // non-empty for presence containers (YANG presence stmt) + Exists bool // only meaningful when Presence != ""; true = present in datastore + // CanDelete is true for any non-presence container that currently + // has data and isn't being rendered read-only. The template renders + // a "Delete" button in the card header that DELETEs the whole + // subtree — the only way out for non-presence containers with a + // mandatory leaf (bridge-port, lag-port), and a generally useful + // affordance for clearing IPv4/IPv6 etc. wholesale. + CanDelete bool + Leaves []*leafGroupItem + InlineLists []*listTableData // simple sub-lists shown inline as tables + InlineContainers []*leafGroupData // flat sub-containers (all-leaf) shown inline + SubNodes []*schema.Node // complex containers/lists → navigation buttons + SavedOK bool + Error string + ReadOnly bool +} + +// listTableColumn is a schema Node with an optional display-name override, +// used so column headers can differ from the YANG leaf name (e.g. "hidden-*" +// presence leaves are shown without the "hidden-" prefix in the heading). +type listTableColumn struct { + *schema.Node + DisplayName string +} + +// listTableData is the template data for yang-list-table. +type listTableData struct { + Path string + ParentPath string // set when rendered inline inside a container leaf-group + Name string + Keys []string + Columns []*listTableColumn // display columns (binary excluded to keep table readable) + FormColumns []*listTableColumn // all leaf columns including binary, used by the add-row form + Rows []listTableRow + Complex bool // has nested containers/lists; rows navigate to a full detail page + SavedOK bool + Error string + ReadOnly bool +} + +// listTableRow holds one instance's display path and column values. +type listTableRow struct { + InstancePath string + InstanceName string // key value(s) for display + Values map[string]string +} + +// listAddData is the template data for yang-list-add: the add-row form. +type listAddData struct { + Path string + ParentPath string // set when opened from an inline list + Name string + Keys []string + Columns []*schema.Node + Error string +} + +// leafGroupItem holds a single leaf's schema node and its current candidate value. +type leafGroupItem struct { + *schema.Node + CurrentValue string + UsingDefault bool + IsBinary bool // leaf has binary type + HasBinary bool // a value is present (even if non-decodable to text, e.g. DER keys) + RawBase64 string // raw RESTCONF base64 value, populated when HasBinary && !CurrentValue + LeafrefValues []string +} + +// Overview serves GET /configure/tree (or /status/tree for ReadOnly mode). +// When path is set the right pane auto-loads the node on page load. +func (h *TreeHandler) Overview(w http.ResponseWriter, r *http.Request) { + activePage := "configure-tree" + title := "Edit all" + if h.ReadOnly { + activePage = "status-tree" + title = "View all" + } + base := h.treeBase() + data := yangTreePageData{ + PageData: newPageData(w, r, activePage, title), + InitialPath: r.URL.Query().Get("path"), + ReadOnly: h.ReadOnly, + TreeBase: base, + } + + mgr := h.Cache.Manager() + if mgr == nil { + data.Loading = true + } else { + nodes, err := h.childrenFunc(mgr)("/") + if err == nil { + treeNodes := make([]*treeNodeData, len(nodes)) + for i, n := range nodes { + treeNodes[i] = &treeNodeData{Node: n, Exists: true, TreeBase: base} + } + data.Nodes = treeNodes + } + } + + if r.Header.Get("HX-Request") == "true" { + h.PageTmpl.ExecuteTemplate(w, "content", data) + } else { + h.PageTmpl.ExecuteTemplate(w, "yang-tree.html", data) + } +} + +// TreeChildren serves GET /configure/tree/children?path=... +// For plain list paths (no key predicate) it returns the actual instances from +// the candidate datastore. For everything else it returns schema children. +func (h *TreeHandler) TreeChildren(w http.ResponseWriter, r *http.Request) { + mgr := h.Cache.Manager() + if mgr == nil { + http.Error(w, "schema not yet loaded", http.StatusServiceUnavailable) + return + } + + path := r.URL.Query().Get("path") + if path == "" { + path = "/" + } + + // For a plain list path (no key predicate in the *last* segment), show data + // instances rather than the schema template. Check only the last segment so + // that paths like /…/interface=br0/ietf-ip:ipv4/address are not excluded. + lastSeg := path + if i := strings.LastIndexByte(path, '/'); i >= 0 { + lastSeg = path[i+1:] + } + base := h.treeBase() + if !strings.ContainsAny(lastSeg, "[=") { + if node, err := mgr.NodeAt(path); err == nil && node.Kind == "list" { + instances := h.fetchListInstances(r, path, node) + treeNodes := make([]*treeNodeData, len(instances)) + for i, n := range instances { + treeNodes[i] = &treeNodeData{Node: n, Exists: true, TreeBase: base} + } + h.FragTmpl.ExecuteTemplate(w, "yang-tree-nodes", treeNodes) + return + } + } + + childFn := h.childrenFunc(mgr) + nodes, err := childFn(path) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + // For list instances, fetch live data so when conditions can be evaluated. + // For bare schema paths (no instance key) values stays nil → conservative. + var values map[string]string + if strings.ContainsAny(lastSeg, "[=") { + values = h.fetchNodeValues(r, path) + } + + // Filter out nodes that are inlined in the detail pane (simple lists and + // flat containers) so they don't clutter the tree. Also hide nodes whose + // when condition is not satisfied by the current instance data. + var visible []*schema.Node + for _, n := range nodes { + if n.When != "" && !schema.EvaluateWhen(mgr, n.When, values) { + continue + } + if n.Kind == "list" || n.Kind == "container" { + kids, _ := childFn(n.Path) + if isSimpleList(kids) { + continue + } + } + visible = append(visible, n) + } + h.FragTmpl.ExecuteTemplate(w, "yang-tree-nodes", h.resolvePresenceState(r, path, visible)) +} + +// resolvePresenceState wraps schema nodes in treeNodeData. +// Existence is not checked here — presence toggle lives in the right-pane card header +// and is only resolved lazily when a node is selected (see TreeNode/checkPresenceExists). +func (h *TreeHandler) resolvePresenceState(r *http.Request, parentPath string, nodes []*schema.Node) []*treeNodeData { + base := h.treeBase() + result := make([]*treeNodeData, len(nodes)) + for i, n := range nodes { + result[i] = &treeNodeData{Node: n, TreeBase: base} + } + return result +} + +// treeBase returns the URL prefix for this handler's tree routes. +func (h *TreeHandler) treeBase() string { + if h.ReadOnly { + return "/status/tree" + } + return "/configure/tree" +} + +func (h *TreeHandler) childrenFunc(mgr *schema.Manager) func(string) ([]*schema.Node, error) { + if h.ReadOnly { + return mgr.ChildrenAll + } + return mgr.Children +} + +// fetchData fetches from the candidate datastore in config mode, or from /data +// (operational) in ReadOnly mode. In config mode it falls back to /data on error. +func (h *TreeHandler) fetchData(r *http.Request, path string) ([]byte, error) { + if h.ReadOnly { + return h.RC.GetRaw(r.Context(), "/data"+path) + } + data, err := h.RC.GetRaw(r.Context(), candidateDS+path) + if err != nil { + return h.RC.GetRaw(r.Context(), "/data"+path) + } + return data, nil +} + +// checkPresenceExists reports whether the YANG presence container at path +// currently exists in the candidate (or running) datastore. +func (h *TreeHandler) checkPresenceExists(r *http.Request, path string) bool { + _, err := h.fetchData(r, path) + return err == nil +} + +// fetchListInstances queries the candidate (fallback: running) for a list +// node and returns one schema.Node per instance, using the key values to +// build RESTCONF key predicates for the path. +// +// rousette always wraps GET responses in the full module-root hierarchy, so +// we can GET the parent path and then use navigateToNode with the full list +// path to reach the list array directly. +func (h *TreeHandler) fetchListInstances(r *http.Request, path string, listNode *schema.Node) []*schema.Node { + segs := strings.Split(strings.TrimPrefix(path, "/"), "/") + parentPath := "/" + strings.Join(segs[:len(segs)-1], "/") + if len(segs) < 2 { + parentPath = path + } + + data, err := h.fetchData(r, parentPath) + if err != nil { + log.Printf("yang-tree: list GET %s: %v", parentPath, err) + return nil + } + + // Navigate directly to the list array using the full path. An absent key + // in the RESTCONF response just means the list has no entries — silent + // return, not an error. + rawItems := navigateToNode(data, path) + if rawItems == nil { + return nil + } + + var items []map[string]json.RawMessage + if err := json.Unmarshal(rawItems, &items); err != nil { + log.Printf("yang-tree: list items unmarshal %s: %v", path, err) + return nil + } + + var nodes []*schema.Node + for _, item := range items { + // Build RESTCONF key predicate: "=val" or "=val1,val2". + pred := buildKeyPredicate(item, listNode.Keys) + instancePath := path + pred + // Display name is the key value(s) without the leading "=". + displayName := pred[1:] + + nodes = append(nodes, &schema.Node{ + Path: instancePath, + Name: displayName, + Kind: "list-instance", + Config: listNode.Config, + Keys: listNode.Keys, + }) + } + // Natural sort: eth2 before eth10. + sort.Slice(nodes, func(i, j int) bool { + return naturalLess(nodes[i].Name, nodes[j].Name) + }) + return nodes +} + +// buildKeyPredicate constructs a RESTCONF list-key predicate from a JSON +// object and a list of key names (RFC 8040 §3.5.3). +// Single key: "=eth0" +// Composite key: "=default,ipv4" +func buildKeyPredicate(item map[string]json.RawMessage, keys []string) string { + var vals []string + for _, key := range keys { + raw, ok := item[key] + if !ok { + // Try with module prefix (RESTCONF may qualify key names). + for k, v := range item { + if k == key || strings.HasSuffix(k, ":"+key) { + raw = v + ok = true + break + } + } + } + s := "?" + if ok { + if json.Unmarshal(raw, &s) != nil { + s = strings.Trim(string(raw), `"`) + } + s = url.PathEscape(s) + } + vals = append(vals, s) + } + if len(vals) == 0 { + return "=?" + } + return "=" + strings.Join(vals, ",") +} + +// TreeNode serves GET /configure/tree/node?path=... +// For containers/list-instances whose children are all leaves it renders an +// inline leaf-group form. For individual leaves it renders the leaf detail form. +func (h *TreeHandler) TreeNode(w http.ResponseWriter, r *http.Request) { + mgr := h.Cache.Manager() + if mgr == nil { + http.Error(w, "schema not yet loaded", http.StatusServiceUnavailable) + return + } + + path := r.URL.Query().Get("path") + if path == "" { + http.Error(w, "path required", http.StatusBadRequest) + return + } + // parent= is set when navigating from an inline list row so that saving + // re-renders the parent container page instead of the current one. + // When absent (direct navigation to a node), derive the immediate + // parent path from the current path so the Back button and any + // container-Delete action still know where to land. + parentPath := r.URL.Query().Get("parent") + if parentPath == "" { + parentPath = parentOfPath(path) + } + + node, err := mgr.NodeAt(path) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + // Containers and list-instances always render as a level page — direct leaves + // as an editable form, structural children as navigation items below. + // NodeAt strips key predicates so a list instance always comes back as "list"; + // detect an instance by checking ONLY the last path segment for a key predicate, + // so that nested paths like /…/interface=br0/ietf-ip:ipv4/address are not + // mistakenly treated as instances due to a predicate in a parent segment. + lastSeg := path + if i := strings.LastIndexByte(path, '/'); i >= 0 { + lastSeg = path[i+1:] + } + isInstance := node.Kind == "list" && strings.ContainsAny(lastSeg, "[=") + + // Bare list node (no key predicate): show a table of all instances when the + // list is "simple" (only leaf children — no nested containers or lists). + if node.Kind == "list" && !isInstance { + if td := h.buildListTable(r, mgr, path, node); td != nil { + if parentPath != "" { + td.ParentPath = parentPath + } + h.FragTmpl.ExecuteTemplate(w, "yang-list-table", td) + return + } + } + + if node.Kind == "container" || node.Kind == "list-instance" || isInstance { + dispKind := node.Kind + if isInstance { + dispKind = "list-instance" + } + gd := h.buildLeafGroup(r, mgr, path, node.Name, dispKind) + if gd == nil && node.Presence == "" { + // Non-presence empty container — fall through to node detail view. + } else { + if gd == nil { + gd = &leafGroupData{Path: path, Name: node.Name, Kind: dispKind} + } + if parentPath != "" { + gd.ParentPath = parentPath + } + if node.Presence != "" { + gd.Presence = node.Presence + gd.Exists = h.checkPresenceExists(r, path) + } else if !h.ReadOnly && node.Kind == "container" && parentPath != "" { + // Any populated non-presence container can be wiped out + // as a unit. Read-only Status view doesn't get the button. + gd.CanDelete = h.checkPresenceExists(r, path) + } + gd.ReadOnly = h.ReadOnly + h.FragTmpl.ExecuteTemplate(w, "yang-leaf-group", gd) + return + } + } + + data := &nodeDetailData{Node: node, ReadOnly: h.ReadOnly} + if node.Kind == "leaf" || node.Kind == "leaf-list" { + item := &leafGroupItem{Node: node} + resolveLeafItem(item, h.fetchLeafValue(r, path)) + data.CurrentValue = item.CurrentValue + data.UsingDefault = item.UsingDefault + data.IsBinary = item.IsBinary + data.LeafrefValues = h.fetchLeafrefValues(r, mgr, node) + } + + h.FragTmpl.ExecuteTemplate(w, "yang-node-detail", data) +} + +// buildLeafGroup builds a level-page for any container or list-instance. +// It fetches the whole node once (one HTTP round-trip) and extracts leaf +// values from the response, rather than fetching each leaf individually. +// Structural children (sub-containers, lists) are listed as navigation items. +// Returns nil only when the schema has no children (empty container). +func (h *TreeHandler) buildLeafGroup(r *http.Request, mgr *schema.Manager, path, name, kind string) *leafGroupData { + childFn := h.childrenFunc(mgr) + children, err := childFn(path) + if err != nil || len(children) == 0 { + return nil + } + gd := &leafGroupData{Path: path, Name: name, Kind: kind, ReadOnly: h.ReadOnly} + values := h.fetchNodeValues(r, path) + + // For list instances: enrich the card heading ("interface wan") and build a + // key-name set so key leaves can be sorted first in the form. + var keySet map[string]bool + if kind == "list-instance" { + schemaPath := stripKeyPredicate(path) + if listNode, lerr := mgr.NodeAt(schemaPath); lerr == nil && len(listNode.Keys) > 0 { + keySet = make(map[string]bool, len(listNode.Keys)) + for _, k := range listNode.Keys { + keySet[k] = true + } + _, lastSeg := splitLastSegment(path) + if i := strings.IndexByte(lastSeg, '='); i >= 0 { + if keyVal, uerr := url.PathUnescape(lastSeg[i+1:]); uerr == nil && keyVal != "" { + gd.Name = name + " " + keyVal + } + } + } + } + + // mgr.Children always returns schema paths (no key predicates). When path + // is a list instance (e.g. /…/user=admin), we must rebuild each child's + // path using the instance path as prefix so that RESTCONF requests and form + // actions address the correct instance, not a bare list path. + schemaBase := stripKeyPredicate(path) // path with last key predicate removed + + for _, c := range children { + // Skip nodes whose when condition is false for the current data values. + if c.When != "" && !schema.EvaluateWhen(mgr, c.When, values) { + continue + } + + childPath := c.Path + if schemaBase != path && strings.HasPrefix(c.Path, schemaBase+"/") { + childPath = path + c.Path[len(schemaBase):] + } + + switch c.Kind { + case "leaf", "leaf-list": + item := &leafGroupItem{Node: c} + val := "" + if values != nil { + val = values[c.Name] + } + resolveLeafItem(item, val) + item.LeafrefValues = h.fetchLeafrefValues(r, mgr, c) + gd.Leaves = append(gd.Leaves, item) + case "list": + if td := h.buildListTable(r, mgr, childPath, c); td != nil { + td.ParentPath = path + gd.InlineLists = append(gd.InlineLists, td) + } + case "container": + kids2, err2 := childFn(c.Path) + // Don't inline NPC containers whose presence is signalled by + // a mandatory leaf (bridge-port, lag-port). Those containers + // represent a discrete "the interface is also a bridge port" + // concept and deserve their own card — both for visual + // consistency (bridge-port always shows as a sub-section + // because of its nested flood/stp/multicast children, while + // lag-port has only the `lag` leaf in candidate config) and + // because their card header is where the Delete button lives. + if err2 == nil && isSimpleList(kids2) && !hasMandatoryLeafChild(kids2) { + sub := h.buildLeafGroup(r, mgr, childPath, c.Name, "container") + if sub != nil { + sub.ParentPath = path + gd.InlineContainers = append(gd.InlineContainers, sub) + break + } + } + subNode := c + if childPath != c.Path { + cn := *c + cn.Path = childPath + subNode = &cn + } + gd.SubNodes = append(gd.SubNodes, subNode) + default: + subNode := c + if childPath != c.Path { + cn := *c + cn.Path = childPath + subNode = &cn + } + gd.SubNodes = append(gd.SubNodes, subNode) + } + } + + // Sort key leaves to the top of the form (schema order preserved within groups). + if len(keySet) > 0 { + sort.SliceStable(gd.Leaves, func(i, j int) bool { + return keySet[gd.Leaves[i].Name] && !keySet[gd.Leaves[j].Name] + }) + } + + return gd +} + +// isSimpleList returns true when every direct child of the list is a leaf or +// leaf-list — no nested containers or sub-lists. Used to decide whether to +// render a bare list node as a data table rather than a tree expansion. +func isSimpleList(children []*schema.Node) bool { + if len(children) == 0 { + return false + } + for _, c := range children { + if c.Kind != "leaf" && c.Kind != "leaf-list" { + return false + } + } + return true +} + +// hasMandatoryLeafChild reports whether any direct child node is a +// mandatory leaf or leaf-list — the YANG "implicit presence via +// mandatory leaf" idiom. Containers carrying one act as discrete +// sub-sections in the UI rather than being collapsed inline. +func hasMandatoryLeafChild(children []*schema.Node) bool { + for _, c := range children { + if (c.Kind == "leaf" || c.Kind == "leaf-list") && c.Mandatory { + return true + } + } + return false +} + +// buildListTable builds a listTableData for a bare (no-predicate) list node. +// Simple lists (all-leaf children) show all leaf columns. +// Complex lists (with nested containers/lists) show only leaf columns and set +// Complex=true so the template renders rows as click-through navigation items +// instead of an inline add form. +// Returns nil only if children cannot be resolved. +func (h *TreeHandler) buildListTable(r *http.Request, mgr *schema.Manager, path string, listNode *schema.Node) *listTableData { + children, err := h.childrenFunc(mgr)(path) + if err != nil { + return nil + } + + simple := isSimpleList(children) + + // Keys first, then up to 4 non-key LEAF columns so the table stays readable. + // For complex lists we skip containers and sub-lists from the column set. + // FormColumns tracks all leaf columns (including binary) for use by the add-row + // form — binary is excluded from Columns only to keep the table display readable. + keySet := make(map[string]bool, len(listNode.Keys)) + for _, k := range listNode.Keys { + keySet[k] = true + } + var keyNodes, otherNodes, formNodes []*listTableColumn + for _, c := range children { + if c.Kind != "leaf" && c.Kind != "leaf-list" { + continue // skip containers/sub-lists from column display + } + col := &listTableColumn{Node: c, DisplayName: c.Name} + // Strip "hidden-" prefix from the display name (e.g. "hidden-private-key" + // → "private-key") so the heading names the data concept, not the YANG case. + if c.Type != nil && c.Type.Kind == "empty" && strings.HasPrefix(c.Name, "hidden-") { + col.DisplayName = strings.TrimPrefix(c.Name, "hidden-") + } + formNodes = append(formNodes, col) + if keySet[c.Name] { + keyNodes = append(keyNodes, col) + } else if c.Type == nil || (c.Type.Kind != "binary") { + // Skip binary columns — unreadable in a table and blow out column widths. + otherNodes = append(otherNodes, col) + } + } + const maxOther = 4 + if len(otherNodes) > maxOther { + otherNodes = otherNodes[:maxOther] + } + columns := append(keyNodes, otherNodes...) + if len(columns) == 0 { + return nil + } + + instances := h.fetchListInstances(r, path, listNode) + td := &listTableData{ + Path: path, + Name: listNode.Name, + Keys: listNode.Keys, + Columns: columns, + FormColumns: formNodes, + Complex: !simple, + ReadOnly: h.ReadOnly, + } + for _, inst := range instances { + rawVals := h.fetchNodeValues(r, inst.Path) + display := make(map[string]string, len(rawVals)) + for _, col := range columns { + v := rawVals[col.Name] + switch { + case col.Type != nil && col.Type.Kind == "identityref": + // Strip module prefix for display. + if i := strings.LastIndexByte(v, ':'); i >= 0 { + v = v[i+1:] + } + case col.Type != nil && col.Type.Kind == "empty": + // YANG empty type: "true" means present (from [null] in JSON). + // Hidden-* leaves indicate the value is stored but not exported. + if v == "true" { + if strings.HasPrefix(col.Name, "hidden-") { + v = "Hidden" + } else { + v = "✓" + } + } else { + v = "" + } + } + display[col.Name] = v + } + td.Rows = append(td.Rows, listTableRow{ + InstancePath: inst.Path, + InstanceName: inst.Name, + Values: display, + }) + } + return td +} + +// stripKeyPredicate removes the key predicate from the last segment of a path, +// e.g. "/interfaces/interface=eth0" → "/interfaces/interface". +// splitLastSegment splits "/a/b/c=d" into ("/a/b", "c=d"). +func splitLastSegment(path string) (parent, last string) { + i := strings.LastIndexByte(path, '/') + if i < 0 { + return "", path + } + return path[:i], path[i+1:] +} + +// buildRootedPatch returns the top-level module container path and a body +// suitable for PATCH /candidateDS+topPath that nests entry inside the full +// schema path. Patching at the module root (like configure-system.go does +// for hostname/NTP) gives libyang the complete ancestor-key context it needs +// to validate nested list entries — patching at a sub-path like user=admin +// leaves libyang without parent-list key context. +func buildRootedPatch(mgr *schema.Manager, listPath string, listNode *schema.Node, entry map[string]any) (topPath string, body map[string]any) { + segs := strings.Split(strings.TrimPrefix(listPath, "/"), "/") + if len(segs) == 0 { + qn, _ := mgr.ModuleQualifiedName(listPath) + return listPath, map[string]any{qn: []map[string]any{entry}} + } + + topSeg := segs[0] // e.g. "ietf-system:system" + topModName, _ := splitModPrefix(topSeg) + + // qualName returns the bare name if the node is in the same module as + // topMod, or "module:name" if it's in a different module. + qualName := func(nodePath, name string) string { + mod, err := mgr.ModuleName(nodePath) + if err == nil && mod != "" && mod != topModName { + return mod + ":" + name + } + return name + } + + // Start with the innermost: the target list → [entry]. + innerVal := map[string]any{qualName(listPath, listNode.Name): []map[string]any{entry}} + + // Walk upward through intermediate segments, wrapping at each level. + for i := len(segs) - 2; i >= 1; i-- { + seg := segs[i] + segPath := "/" + strings.Join(segs[:i+1], "/") + eqIdx := strings.IndexByte(seg, '=') + if eqIdx >= 0 { + // List instance (e.g. "user=admin"): build a list entry that + // includes the key value(s) extracted from the path predicate. + listName := seg[:eqIdx] + predVals := strings.SplitN(seg[eqIdx+1:], ",", 16) + schemaPath := stripKeyPredicate(segPath) + schemaNode, err := mgr.NodeAt(schemaPath) + listEntry := make(map[string]any) + if err == nil { + for j, k := range schemaNode.Keys { + if j < len(predVals) { + v, _ := url.PathUnescape(predVals[j]) + listEntry[k] = v + } + } + } + for k, v := range innerVal { + listEntry[k] = v + } + innerVal = map[string]any{qualName(schemaPath, listName): []map[string]any{listEntry}} + } else { + // Container segment: wrap innerVal in the container object. + innerVal = map[string]any{qualName(segPath, seg): innerVal} + } + } + + return "/" + topSeg, map[string]any{topSeg: innerVal} +} + +func stripKeyPredicate(path string) string { + segs := strings.Split(strings.TrimPrefix(path, "/"), "/") + if len(segs) == 0 { + return path + } + last := segs[len(segs)-1] + if i := strings.IndexByte(last, '='); i >= 0 { + segs[len(segs)-1] = last[:i] + } + return "/" + strings.Join(segs, "/") +} + +// AddListRowForm serves GET /configure/tree/list-add?path=...&parent=... +// Renders a blank add-row form for the list at path. +// parent is set when the list is embedded inside a container leaf-group. +func (h *TreeHandler) AddListRowForm(w http.ResponseWriter, r *http.Request) { + mgr := h.Cache.Manager() + if mgr == nil { + http.Error(w, "schema not yet loaded", http.StatusServiceUnavailable) + return + } + path := r.URL.Query().Get("path") + parent := r.URL.Query().Get("parent") + node, err := mgr.NodeAt(path) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + children, _ := mgr.Children(path) + var leafCols []*schema.Node + for _, c := range children { + if c.Kind == "leaf" || c.Kind == "leaf-list" { + leafCols = append(leafCols, c) + } + } + h.FragTmpl.ExecuteTemplate(w, "yang-list-add", &listAddData{ + Path: path, + ParentPath: parent, + Name: node.Name, + Keys: node.Keys, + Columns: leafCols, + }) +} + +// SaveListRow serves POST /configure/tree/list-row?path=...&parent=... +// Creates a new list instance from posted form values, then re-renders. +// If parent is set, re-renders the parent container leaf-group (inline mode). +func (h *TreeHandler) SaveListRow(w http.ResponseWriter, r *http.Request) { + mgr := h.Cache.Manager() + if mgr == nil { + http.Error(w, "schema not yet loaded", http.StatusServiceUnavailable) + return + } + path := r.URL.Query().Get("path") + parent := r.URL.Query().Get("parent") + listNode, err := mgr.NodeAt(path) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + r.ParseForm() + children, _ := mgr.Children(path) + + // Build RESTCONF body from all posted leaf values. + // Use bare field names (no module prefix) inside the entry; cross-module + // augmented fields still need their module prefix. + listModName, _ := mgr.ModuleName(path) + entry := make(map[string]any) + for _, child := range children { + if child.Kind != "leaf" && child.Kind != "leaf-list" { + continue + } + raw := r.FormValue(child.Name) + if raw == "" { + continue + } + fieldModName, _ := mgr.ModuleName(child.Path) + fieldKey := child.Name + if fieldModName != listModName { + fieldKey = fieldModName + ":" + child.Name + } + entry[fieldKey] = coerceLeafValue(raw, child) + } + + // PATCH at the top-level module container (e.g. ietf-system:system) with + // the full nested structure so libyang has complete ancestor-key context. + // Patching at a sub-path leaves libyang without parent list-key context + // and produces "List requires N keys" errors. + topPath, rootBody := buildRootedPatch(mgr, path, listNode, entry) + putErr := h.RC.Patch(r.Context(), candidateDS+topPath, rootBody) + + if parent != "" { + parentNode, pErr := mgr.NodeAt(parent) + if pErr == nil { + gd := h.buildLeafGroup(r, mgr, parent, parentNode.Name, parentNode.Kind) + if gd != nil { + if putErr != nil { + gd.Error = putErr.Error() + } else { + gd.SavedOK = true + } + h.FragTmpl.ExecuteTemplate(w, "yang-leaf-group", gd) + return + } + } + } + + td := h.buildListTable(r, mgr, path, listNode) + if td == nil { + td = &listTableData{Path: path, Name: listNode.Name} + } + if putErr != nil { + td.Error = putErr.Error() + } else { + td.SavedOK = true + } + h.FragTmpl.ExecuteTemplate(w, "yang-list-table", td) +} + +// DeleteListRow serves DELETE /configure/tree/list-row?path=...&parent=... +// Deletes a list instance and re-renders. If parent is set, re-renders the +// parent container leaf-group (inline mode); otherwise re-renders the list table. +func (h *TreeHandler) DeleteListRow(w http.ResponseWriter, r *http.Request) { + mgr := h.Cache.Manager() + if mgr == nil { + http.Error(w, "schema not yet loaded", http.StatusServiceUnavailable) + return + } + path := r.URL.Query().Get("path") + parent := r.URL.Query().Get("parent") + listPath := stripKeyPredicate(path) + listNode, err := mgr.NodeAt(listPath) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + errMsg := "" + // Skip direct DELETE when the path contains '@': libyang treats it as a + // module@revision separator in path predicates and always returns 400. + useParentPut := strings.ContainsRune(path, '@') + if !useParentPut { + if delErr := h.RC.Delete(r.Context(), candidateDS+path); delErr != nil { + useParentPut = true + } + } + if useParentPut { + if fbErr := h.deleteViaParentPut(r, mgr, path, listPath, listNode); fbErr != nil { + errMsg = fbErr.Error() + } + } + + // Inline list delete (parent set): the button uses hx-swap="delete" to remove + // the row from the DOM — no full re-render needed. Return a minimal signal. + if parent != "" { + if errMsg != "" { + renderSaveError(w, errors.New(errMsg)) + } else { + renderSaved(w, "Deleted") + } + return + } + + td := h.buildListTable(r, mgr, listPath, listNode) + if td == nil { + td = &listTableData{Path: listPath, Name: listNode.Name} + } + td.Error = errMsg + h.FragTmpl.ExecuteTemplate(w, "yang-list-table", td) +} + +// deleteViaParentPut removes a list instance without using a DELETE to the +// instance path. It GETs the parent, filters the list, and PUTs the parent +// back — identical in spirit to the curated DeleteKey workaround. +// Used when a direct DELETE fails (e.g. '@' in key value causes libyang to +// treat it as a module@revision separator and return "Syntax error"). +func (h *TreeHandler) deleteViaParentPut(r *http.Request, mgr *schema.Manager, instancePath, listPath string, listNode *schema.Node) error { + // Parent of the list (e.g. user=admin for authorized-key). + listParentPath, _ := splitLastSegment(listPath) + if listParentPath == "" { + return fmt.Errorf("no parent for %s", listPath) + } + + // GET parent — rousette returns full module-root hierarchy. + data, err := h.fetchData(r, listParentPath) + if err != nil { + return err + } + + // Navigate to the parent instance. + parentRaw := navigateToNode(data, listParentPath) + if parentRaw == nil { + return nil // already absent + } + var parentObj map[string]json.RawMessage + if err := json.Unmarshal(parentRaw, &parentObj); err != nil { + return err + } + + // Find the list array by bare name inside the parent object. + _, listBareName := splitModPrefix(listPath[strings.LastIndexByte(listPath, '/')+1:]) + var listKey string + var rawItems json.RawMessage + for k, v := range parentObj { + _, local := splitModPrefix(k) + if local == listBareName { + listKey, rawItems = k, v + break + } + } + if rawItems == nil { + return nil // list absent, nothing to do + } + + var items []map[string]json.RawMessage + if err := json.Unmarshal(rawItems, &items); err != nil { + return err + } + + // Key values from instance path predicate (after the '=', comma-separated). + _, instanceSeg := splitLastSegment(instancePath) + eqIdx := strings.IndexByte(instanceSeg, '=') + if eqIdx < 0 { + return fmt.Errorf("no predicate in %s", instancePath) + } + delVals := strings.Split(instanceSeg[eqIdx+1:], ",") + + // Filter: keep all entries except the one matching the deletion key. + var kept []json.RawMessage + for _, item := range items { + pred := buildKeyPredicate(item, listNode.Keys) + itemVals := strings.Split(pred[1:], ",") // strip leading "=" + match := len(delVals) == len(itemVals) + for i := range delVals { + if !match { + break + } + d1, _ := url.PathUnescape(delVals[i]) + d2, _ := url.PathUnescape(itemVals[i]) + if d1 != d2 { + match = false + } + } + if !match { + b, _ := json.Marshal(item) + kept = append(kept, b) + } + } + + filteredJSON, _ := json.Marshal(kept) + parentObj[listKey] = json.RawMessage(filteredJSON) + + // Convert parent object back to map[string]any for the PUT body. + parentAny := make(map[string]any, len(parentObj)) + for k, v := range parentObj { + var val any + json.Unmarshal(v, &val) //nolint:errcheck + parentAny[k] = val + } + + // Wrap in the module-qualified list-instance envelope expected by RESTCONF. + _, parentLastSeg := splitLastSegment(listParentPath) + _, parentBareName := splitModPrefix(stripModPredicate(parentLastSeg)) + parentModName, _ := mgr.ModuleName(listParentPath) + body := map[string]any{ + parentModName + ":" + parentBareName: []any{parentAny}, + } + return h.RC.Put(r.Context(), candidateDS+listParentPath, body) +} + +// fetchNodeValues fetches a container or list-instance and returns a flat map +// of bare-leaf-name → string for its direct scalar children. +// +// When the path contains characters like '@' that rousette/libyang rejects in +// URL path predicates, a direct GET fails. We fall back to GETting the parent +// path — rousette always returns the full module-root hierarchy, so +// navigateToNode can still walk down to the target node. +func (h *TreeHandler) fetchNodeValues(r *http.Request, path string) map[string]string { + // If the last segment contains percent-encoded characters (e.g. %40 for @), + // Go's HTTP client decodes them before sending and libyang then rejects the + // path with "Syntax error". Skip directly to the parent-path fallback. + _, lastSeg := splitLastSegment(path) + + var data []byte + var err error + if !strings.ContainsRune(lastSeg, '%') { + data, err = h.fetchData(r, path) + } + if data == nil { + parentPath, _ := splitLastSegment(path) + if parentPath == "" { + return nil + } + data, err = h.fetchData(r, parentPath) + if err != nil { + return nil + } + } + raw := navigateToNode(data, path) + if raw == nil { + return nil + } + return flattenNodeValues(raw) +} + +// SaveGroup serves PUT /configure/tree/group?path=... +// Saves every leaf in the group form to the candidate datastore. +// Returns HX-Trigger cfgSaved/cfgError so the form shows inline feedback +// without a full re-render (forms use hx-swap="none"). +func (h *TreeHandler) SaveGroup(w http.ResponseWriter, r *http.Request) { + mgr := h.Cache.Manager() + if mgr == nil { + http.Error(w, "schema not yet loaded", http.StatusServiceUnavailable) + return + } + + path := r.URL.Query().Get("path") + if path == "" { + http.Error(w, "path required", http.StatusBadRequest) + return + } + + node, err := mgr.NodeAt(path) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + r.ParseForm() + + children, _ := mgr.Children(path) + var firstErr string + + for _, child := range children { + if child.Kind != "leaf" && child.Kind != "leaf-list" { + continue + } + rawValue := r.FormValue(child.Name) + qualName, qErr := mgr.ModuleQualifiedName(child.Path) + if qErr != nil { + qualName = child.Name + } + body := map[string]any{qualName: coerceLeafValue(rawValue, child)} + if putErr := h.RC.Put(r.Context(), candidateDS+child.Path, body); putErr != nil && firstErr == "" { + firstErr = child.Name + ": " + putErr.Error() + } + } + + if firstErr != "" { + w.Header().Set("HX-Trigger", `{"cfgError":"`+firstErr+`"}`) + w.WriteHeader(http.StatusUnprocessableEntity) + return + } + w.Header().Set("HX-Trigger", `{"cfgSaved":"Saved `+node.Name+` to candidate"}`) + w.WriteHeader(http.StatusNoContent) +} + +// SaveLeaf serves PUT /configure/tree/node?path=... +// Writes the form value to the candidate datastore and re-renders the detail pane. +func (h *TreeHandler) SaveLeaf(w http.ResponseWriter, r *http.Request) { + mgr := h.Cache.Manager() + if mgr == nil { + http.Error(w, "schema not yet loaded", http.StatusServiceUnavailable) + return + } + + path := r.URL.Query().Get("path") + if path == "" { + http.Error(w, "path required", http.StatusBadRequest) + return + } + + node, err := mgr.NodeAt(path) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + rawValue := r.FormValue("value") + qualName, err := mgr.ModuleQualifiedName(path) + if err != nil { + log.Printf("yang: qualified name for %s: %v", path, err) + qualName = node.Name + } + + body := map[string]any{qualName: coerceLeafValue(rawValue, node)} + + // Emit cfgSaved/cfgError like SaveGroup and let the client re-fetch the + // whole current page from the fresh candidate (so confd inference and + // normalisation surface) — don't echo the raw input back, which hid it. + if putErr := h.RC.Put(r.Context(), candidateDS+path, body); putErr != nil { + w.Header().Set("HX-Trigger", `{"cfgError":"`+node.Name+": "+putErr.Error()+`"}`) + w.WriteHeader(http.StatusUnprocessableEntity) + return + } + w.Header().Set("HX-Trigger", `{"cfgSaved":"Saved `+node.Name+` to candidate"}`) + w.WriteHeader(http.StatusNoContent) +} + +// DeleteLeaf serves DELETE /configure/tree/node?path=... +// Removes the node from the candidate datastore; returns 204. +func (h *TreeHandler) DeleteLeaf(w http.ResponseWriter, r *http.Request) { + path := r.URL.Query().Get("path") + if path == "" { + http.Error(w, "path required", http.StatusBadRequest) + return + } + + if err := h.RC.Delete(r.Context(), candidateDS+path); err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// DeleteContainer serves DELETE /configure/tree/container?path=...&parent=... +// for any non-presence container the user wants to wipe out as a unit. +// Covers the NPC + mandatory leaf idiom (bridge-port, lag-port — where +// the leaf can't be cleared individually) and the generic "drop the +// whole IPv4 / IPv6 / DHCP block" case. After the deletion it re-renders +// the parent's leaf-group so the user lands on the parent view with +// the container gone. +// +// Implementation: rousette returns 502 on RESTCONF DELETE for non-presence +// containers (they have no resource to "remove"), so we GET the parent +// from the candidate, drop the target child from its JSON body, and PUT +// the parent back. PUT replaces the resource, which is the only RESTCONF +// idiom that actually removes an NPC subtree. +func (h *TreeHandler) DeleteContainer(w http.ResponseWriter, r *http.Request) { + path := r.URL.Query().Get("path") + parent := r.URL.Query().Get("parent") + if path == "" || parent == "" { + http.Error(w, "path and parent required", http.StatusBadRequest) + return + } + mgr := h.Cache.Manager() + if mgr == nil { + http.Error(w, "schema not yet loaded", http.StatusServiceUnavailable) + return + } + if err := h.deleteNPCContainer(r, mgr, path, parent); err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + // No setCfgUnsaved here — this only modifies candidate. The cfg- + // unsaved banner is specifically about "running differs from + // startup," set after Apply / RestoreConfig-to-running. + + parentSchema, err := mgr.NodeAt(stripKeyPredicate(parent)) + if err != nil || parentSchema == nil { + http.Error(w, "parent schema lookup failed", http.StatusInternalServerError) + return + } + kind := entryKindForGroup(parentSchema) + gd := h.buildLeafGroup(r, mgr, parent, parentSchema.Name, kind) + if gd == nil { + gd = &leafGroupData{Path: parent, Name: parentSchema.Name, Kind: kind} + } + if grand := parentOfPath(parent); grand != "" { + gd.ParentPath = grand + } + gd.ReadOnly = h.ReadOnly + h.FragTmpl.ExecuteTemplate(w, "yang-leaf-group", gd) +} + +// deleteNPCContainer removes the subtree at `path` from the candidate +// datastore by rewriting its parent: GET parent, strip the target key +// from the JSON, PUT parent. Required because rousette refuses DELETE +// on non-presence containers — they have no explicit resource handle. +func (h *TreeHandler) deleteNPCContainer(r *http.Request, mgr *schema.Manager, path, parent string) error { + // Target's wrap key in JSON: last path segment, with any key + // predicate stripped. Bridge-port's segment is "infix-interfaces: + // bridge-port" — that's exactly what shows up as a key in the + // parent's JSON object. + targetKey := lastSegment(path) + if i := strings.IndexByte(targetKey, '='); i >= 0 { + targetKey = targetKey[:i] + } + if targetKey == "" { + return fmt.Errorf("delete container: empty target segment") + } + + raw, err := h.fetchData(r, parent) + if err != nil { + return fmt.Errorf("fetch parent %s: %w", parent, err) + } + var doc map[string]any + if err := json.Unmarshal(raw, &doc); err != nil { + return fmt.Errorf("decode parent %s: %w", parent, err) + } + + // Locate the parent's data object within the wrapped response and + // figure out the wrap key to use for the PUT body. + parentObj, putWrap, err := unwrapForPut(doc, parent) + if err != nil { + return err + } + if _, ok := parentObj[targetKey]; !ok { + return fmt.Errorf("delete container: %s not present in parent %s", targetKey, parent) + } + delete(parentObj, targetKey) + + var body map[string]any + if isListInstancePath(parent) { + body = map[string]any{putWrap: []any{parentObj}} + } else { + body = map[string]any{putWrap: parentObj} + } + return h.RC.Put(r.Context(), candidateDS+parent, body) +} + +// unwrapForPut peels the RESTCONF GET envelope until it lands on the +// parent's own data object. For a container path it descends one level +// (outer container → object). For a list-instance path it descends two +// (outer container → list array → entry[0]). The returned wrap key is +// the qualifier the PUT body needs at the parent path: container-name +// for a container, list-name for a list-instance. +func unwrapForPut(doc map[string]any, parentPath string) (map[string]any, string, error) { + _, outerVal, err := singleEntry(doc) + if err != nil { + return nil, "", fmt.Errorf("unwrap parent: %w", err) + } + if !isListInstancePath(parentPath) { + obj, ok := outerVal.(map[string]any) + if !ok { + return nil, "", fmt.Errorf("unwrap parent: container value not an object") + } + // The wrap key for a container PUT is the container's own qualified + // name, which is also the outer map's only key. + var key string + for k := range doc { + key = k + } + return obj, key, nil + } + inner, ok := outerVal.(map[string]any) + if !ok { + return nil, "", fmt.Errorf("unwrap parent: list container value not an object") + } + listKey, arr, err := singleEntry(inner) + if err != nil { + return nil, "", fmt.Errorf("unwrap list: %w", err) + } + entries, ok := arr.([]any) + if !ok || len(entries) == 0 { + return nil, "", fmt.Errorf("unwrap list: empty or non-array value") + } + entry, ok := entries[0].(map[string]any) + if !ok { + return nil, "", fmt.Errorf("unwrap list: first entry not an object") + } + return entry, listKey, nil +} + +// singleEntry returns the only key/value in m, or an error when m has +// none or more than one — RESTCONF GET responses are always wrapped in +// a single top-level qualifier, so any other shape is bug-worthy. +func singleEntry(m map[string]any) (string, any, error) { + if len(m) != 1 { + return "", nil, fmt.Errorf("expected single entry, got %d", len(m)) + } + for k, v := range m { + return k, v, nil + } + return "", nil, nil // unreachable +} + +func isListInstancePath(p string) bool { + return strings.ContainsAny(lastSegment(p), "[=") +} + +func lastSegment(p string) string { + if i := strings.LastIndexByte(p, '/'); i >= 0 { + return p[i+1:] + } + return p +} + +// entryKindForGroup maps a schema node to the display Kind expected by +// the leaf-group template ("list-instance" when the node carries keys, +// "container" otherwise). +func entryKindForGroup(n *schema.Node) string { + if len(n.Keys) > 0 { + return "list-instance" + } + return "container" +} + +// parentOfPath strips the last segment from a RESTCONF path. Returns "" +// when there is no parent (root or a single segment). +func parentOfPath(p string) string { + segs := strings.Split(strings.TrimPrefix(p, "/"), "/") + if len(segs) <= 1 { + return "" + } + return "/" + strings.Join(segs[:len(segs)-1], "/") +} + +// fetchLeafrefValues resolves a leafref schema path against the candidate +// (fallback: running) datastore and returns the available key/leaf values. +// Returns nil when the leafref cannot be resolved or the datastore is empty. +// When the leaf carries a `must "deref(.)/../<sibling>"` constraint, the +// result is filtered to entries whose parent object exposes that sibling +// — the YANG idiom for "this leafref must point at an object of kind X". +func (h *TreeHandler) fetchLeafrefValues(r *http.Request, mgr *schema.Manager, node *schema.Node) []string { + if node.Type == nil || node.Type.Kind != "leafref" || node.Type.Leafref == "" { + return nil + } + absPath := mgr.ResolveLeafref(node.Type.Leafref, node.Path) + if absPath == "" { + return nil + } + + // Split off the target leaf name, then walk up the schema until we + // hit a non-list ancestor — RESTCONF refuses to GET a bare list path + // without a key selector ("List '…' requires 1 keys"), so the actual + // fetch has to target the list's enclosing container. The collector + // below recurses into the container and pulls the leaf value out of + // every list entry. + segs := strings.Split(strings.TrimPrefix(absPath, "/"), "/") + if len(segs) < 2 { + return nil + } + _, targetLeaf := splitModPrefix(segs[len(segs)-1]) + parentSegs := segs[:len(segs)-1] + for len(parentSegs) > 0 { + candidate := "/" + strings.Join(parentSegs, "/") + n, err := mgr.NodeAt(candidate) + if err != nil || n == nil || n.Kind != "list" { + break + } + parentSegs = parentSegs[:len(parentSegs)-1] + } + if len(parentSegs) == 0 { + return nil + } + parentPath := "/" + strings.Join(parentSegs, "/") + + data, err := h.fetchData(r, parentPath) + if err != nil { + return nil + } + if node.Type.LeafrefSibling != "" { + return extractFieldValuesWithSibling(data, targetLeaf, node.Type.LeafrefSibling) + } + return extractFieldValues(data, targetLeaf) +} + +// splitModPrefix splits "module:name" into ("module", "name"). +// If there is no prefix it returns ("", name). +func splitModPrefix(s string) (string, string) { + if i := strings.IndexByte(s, ':'); i >= 0 { + return s[:i], s[i+1:] + } + return "", s +} + +// extractFieldValues walks a RESTCONF JSON response and collects every string +// value whose key (stripped of module prefix) matches fieldName. +// Handles both single-object and array envelopes. +func extractFieldValues(data []byte, fieldName string) []string { + var raw interface{} + if err := json.Unmarshal(data, &raw); err != nil { + return nil + } + var vals []string + collectField(raw, fieldName, &vals) + sort.Slice(vals, func(i, j int) bool { return naturalLess(vals[i], vals[j]) }) + return vals +} + +// extractFieldValuesWithSibling is like extractFieldValues but only keeps +// matches where the same parent object also has a child whose key strips +// to siblingName. Implements the must "deref(.)/../<sibling>" filter +// idiom for leafref dropdowns. +func extractFieldValuesWithSibling(data []byte, fieldName, siblingName string) []string { + var raw interface{} + if err := json.Unmarshal(data, &raw); err != nil { + return nil + } + var vals []string + collectFieldWithSibling(raw, fieldName, siblingName, &vals) + sort.Slice(vals, func(i, j int) bool { return naturalLess(vals[i], vals[j]) }) + return vals +} + +func collectFieldWithSibling(v interface{}, field, sibling string, out *[]string) { + switch t := v.(type) { + case map[string]interface{}: + var fieldVal string + var haveField, haveSibling bool + for k, child := range t { + _, local := splitModPrefix(k) + switch local { + case field: + if s, ok := child.(string); ok { + haveField, fieldVal = true, s + } + case sibling: + haveSibling = true + } + } + // Same record-boundary rule as collectField: if the field is + // present at THIS level, stop here. Append only when the sibling + // is also present; otherwise skip without descending so we don't + // pick up an inner object's field as a spurious match. + if haveField { + if haveSibling { + *out = append(*out, fieldVal) + } + return + } + for _, child := range t { + collectFieldWithSibling(child, field, sibling, out) + } + case []interface{}: + for _, item := range t { + collectFieldWithSibling(item, field, sibling, out) + } + } +} + +func collectField(v interface{}, field string, out *[]string) { + switch t := v.(type) { + case map[string]interface{}: + // First pass: if the field is present at THIS level, treat the + // map as a leafref-target record and extract here without + // recursing into siblings. This stops spurious matches from + // nested objects that happen to share the field name — e.g. an + // asymmetric-key entry has its own `name` AND a nested + // `certificates/certificate[]/name`; only the entry-level name + // is a valid leafref target. + for k, child := range t { + _, local := splitModPrefix(k) + if local == field { + if s, ok := child.(string); ok { + *out = append(*out, s) + } + return + } + } + // Field not at this level — descend. + for _, child := range t { + collectField(child, field, out) + } + case []interface{}: + for _, item := range t { + collectField(item, field, out) + } + } +} + +// naturalLess compares two strings with numeric-aware ordering so that +// "eth2" < "eth10" (matching faux_str_numcmp in klish-plugin-sysrepo). +func naturalLess(a, b string) bool { + for len(a) > 0 && len(b) > 0 { + ra, rb := rune(a[0]), rune(b[0]) + if unicode.IsDigit(ra) && unicode.IsDigit(rb) { + // Collect digit runs from both strings. + na, nb := 0, 0 + ia, ib := 0, 0 + for ia < len(a) && unicode.IsDigit(rune(a[ia])) { + na = na*10 + int(a[ia]-'0') + ia++ + } + for ib < len(b) && unicode.IsDigit(rune(b[ib])) { + nb = nb*10 + int(b[ib]-'0') + ib++ + } + if na != nb { + return na < nb + } + a, b = a[ia:], b[ib:] + continue + } + if ra != rb { + return ra < rb + } + a, b = a[1:], b[1:] + } + return len(a) < len(b) +} + +// fetchLeafValue reads the current leaf value from the candidate datastore +// (falling back to running) and returns a display string. +// For paths through list instances the server wraps the response in the full +// module hierarchy; navigateToNode is used to reach the value in that case, +// with a fallback to the simpler single-key envelope unwrap. +func (h *TreeHandler) fetchLeafValue(r *http.Request, path string) string { + var data []byte + var err error + _, lastSeg := splitLastSegment(path) + if !strings.ContainsRune(lastSeg, '%') { + data, err = h.fetchData(r, path) + } + if data == nil { + parentPath, _ := splitLastSegment(path) + if parentPath == "" { + return "" + } + data, err = h.fetchData(r, parentPath) + if err != nil { + return "" + } + } + if raw := navigateToNode(data, path); raw != nil { + return extractScalar(raw) + } + return extractLeafValue(data) +} + +// resolveLeafItem populates CurrentValue, UsingDefault, and IsBinary for a leafGroupItem. +// For boolean leaves with no stored value and no YANG default, absent == false. +// For binary leaves the RESTCONF value is base64; we decode it for display as +// plain text. If decoding fails or the result is not valid UTF-8 the leaf is +// still marked IsBinary=true but CurrentValue is left empty so the template +// can show a "non-text binary data" placeholder instead. +func resolveLeafItem(item *leafGroupItem, val string) { + node := item.Node + if node.Type != nil && node.Type.Kind == "binary" { + item.IsBinary = true + if val != "" { + item.HasBinary = true + if decoded, err := base64.StdEncoding.DecodeString(val); err == nil && utf8.Valid(decoded) { + item.CurrentValue = string(decoded) + } else { + item.RawBase64 = val // non-decodable (DER); show raw base64 for display + } + } + return + } + if val == "" && node.Default != "" { + item.CurrentValue = node.Default + item.UsingDefault = true + } else if val == "" && node.Type != nil && node.Type.Kind == "boolean" { + item.CurrentValue = "false" + item.UsingDefault = true + } else { + item.CurrentValue = val + } +} + +// TogglePresence serves PUT and DELETE /configure/tree/presence?path=... +// PUT creates the presence container with an empty body; DELETE removes it. +// Returns the updated yang-tree-node HTML fragment for HTMX outerHTML swap. +func (h *TreeHandler) TogglePresence(w http.ResponseWriter, r *http.Request) { + if h.ReadOnly { + http.Error(w, "read-only tree", http.StatusMethodNotAllowed) + return + } + mgr := h.Cache.Manager() + if mgr == nil { + http.Error(w, "schema not yet loaded", http.StatusServiceUnavailable) + return + } + path := r.URL.Query().Get("path") + if path == "" { + http.Error(w, "path required", http.StatusBadRequest) + return + } + + node, err := mgr.NodeAt(path) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + var exists bool + if r.Method == http.MethodDelete { + if delErr := h.RC.Delete(r.Context(), candidateDS+path); delErr != nil { + http.Error(w, delErr.Error(), http.StatusBadGateway) + return + } + exists = false + } else { + qualName, qErr := mgr.ModuleQualifiedName(path) + if qErr != nil { + http.Error(w, qErr.Error(), http.StatusBadRequest) + return + } + if putErr := h.RC.Put(r.Context(), candidateDS+path, map[string]any{qualName: map[string]any{}}); putErr != nil { + http.Error(w, putErr.Error(), http.StatusBadGateway) + return + } + exists = true + } + + setCfgUnsaved(w) + + // Re-render the right-pane leaf group with the updated presence state. + gd := h.buildLeafGroup(r, mgr, path, node.Name, "container") + if gd == nil { + gd = &leafGroupData{Path: path, Name: node.Name, Kind: "container"} + } + gd.Presence = node.Presence + gd.Exists = exists + h.FragTmpl.ExecuteTemplate(w, "yang-leaf-group", gd) +} diff --git a/src/webui/internal/restconf/client.go b/src/webui/internal/restconf/client.go new file mode 100644 index 00000000..0f647090 --- /dev/null +++ b/src/webui/internal/restconf/client.go @@ -0,0 +1,298 @@ +// SPDX-License-Identifier: MIT + +package restconf + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// Credentials holds username/password for Basic Auth. +// Stored in request contexts by the auth middleware. +type Credentials struct { + Username string + Password string +} + +type ctxKey struct{} + +// ContextWithCredentials returns a child context carrying creds. +func ContextWithCredentials(ctx context.Context, c Credentials) context.Context { + return context.WithValue(ctx, ctxKey{}, c) +} + +// CredentialsFromContext extracts credentials set by the auth middleware. +func CredentialsFromContext(ctx context.Context) Credentials { + c, _ := ctx.Value(ctxKey{}).(Credentials) + return c +} + +// Client talks to the rousette RESTCONF server. +type Client struct { + baseURL string + httpClient *http.Client +} + +// NewClient creates a RESTCONF client pointing at baseURL +// (e.g. "https://192.168.1.1/restconf" or "https://127.0.0.1/restconf"). +// When insecureTLS is true, TLS certificate verification is disabled. +func NewClient(baseURL string, insecureTLS bool) *Client { + var tlsConfig *tls.Config + if insecureTLS { + tlsConfig = &tls.Config{InsecureSkipVerify: true} + } + return &Client{ + baseURL: strings.TrimRight(escapeZoneID(baseURL), "/"), + httpClient: &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: tlsConfig, + }, + }, + } +} + +// doRequest builds and executes an HTTP request against the RESTCONF server, +// setting Accept and Basic Auth from the context. The caller must close Body. +func (c *Client) doRequest(ctx context.Context, method, path string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/yang-data+json") + creds := CredentialsFromContext(ctx) + req.SetBasicAuth(creds.Username, creds.Password) + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("restconf request failed: %w", err) + } + return resp, nil +} + +// Get fetches a RESTCONF resource, decoding the JSON response into target. +// User credentials are taken from the request context (set by auth middleware). +func (c *Client) Get(ctx context.Context, path string, target any) error { + resp, err := c.doRequest(ctx, http.MethodGet, path) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return parseError(resp) + } + return json.NewDecoder(resp.Body).Decode(target) +} + +// Post sends a POST request to a RESTCONF RPC endpoint. +// Used for operations like system-restart that return no body. +func (c *Client) Post(ctx context.Context, path string) error { + resp, err := c.doRequest(ctx, http.MethodPost, path) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + return parseError(resp) + } + return nil +} + +// PostJSON sends a POST request with a JSON body to a RESTCONF RPC endpoint. +// Used for RPCs that require input parameters (e.g. install-bundle). +func (c *Client) PostJSON(ctx context.Context, path string, body any) error { + return c.writeJSON(ctx, http.MethodPost, path, body) +} + +// Put replaces a RESTCONF config resource with the given value. +func (c *Client) Put(ctx context.Context, path string, body any) error { + return c.writeJSON(ctx, http.MethodPut, path, body) +} + +// Patch merges the given value into a RESTCONF config resource. +func (c *Client) Patch(ctx context.Context, path string, body any) error { + return c.writeJSON(ctx, http.MethodPatch, path, body) +} + +// Delete removes a RESTCONF config resource. +func (c *Client) Delete(ctx context.Context, path string) error { + resp, err := c.doRequest(ctx, http.MethodDelete, path) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + return parseError(resp) + } + return nil +} + +// GetDatastore fetches the full contents of the named NMDA datastore as raw JSON. +// datastore is one of "running", "candidate", "operational", "startup", "factory-default". +func (c *Client) GetDatastore(ctx context.Context, datastore string) (json.RawMessage, error) { + return c.GetRaw(ctx, "/ds/ietf-datastores:"+datastore) +} + +// PutDatastore replaces the named NMDA datastore with the given raw JSON. +func (c *Client) PutDatastore(ctx context.Context, datastore string, body json.RawMessage) error { + return c.writeJSON(ctx, http.MethodPut, "/ds/ietf-datastores:"+datastore, body) +} + +// CopyDatastore copies the full contents of src into dst. +// Mirrors the Infamy test framework's copy() operation. +func (c *Client) CopyDatastore(ctx context.Context, src, dst string) error { + data, err := c.GetDatastore(ctx, src) + if err != nil { + return fmt.Errorf("copy datastore: read %s: %w", src, err) + } + if err := c.PutDatastore(ctx, dst, data); err != nil { + return fmt.Errorf("copy datastore: write %s: %w", dst, err) + } + return nil +} + +// writeJSON encodes body as JSON and sends it with the given HTTP method. +func (c *Client) writeJSON(ctx context.Context, method, path string, body any) error { + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(body); err != nil { + return fmt.Errorf("encoding request body: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, &buf) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/yang-data+json") + req.Header.Set("Accept", "application/yang-data+json") + creds := CredentialsFromContext(ctx) + req.SetBasicAuth(creds.Username, creds.Password) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("restconf request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && + resp.StatusCode != http.StatusCreated && + resp.StatusCode != http.StatusNoContent { + return parseError(resp) + } + return nil +} + +// GetYANG downloads a YANG module file. YANG files are served outside the +// RESTCONF tree at /yang/{name}@{revision}.yang on the device host — mirroring +// the approach used by the Infamy test framework (yang_url = base_url + "/yang"). +func (c *Client) GetYANG(ctx context.Context, name, revision string) ([]byte, error) { + // Strip the /restconf suffix to get the host-level base URL. + yangBase := c.baseURL + if i := strings.LastIndex(yangBase, "/restconf"); i >= 0 { + yangBase = yangBase[:i] + } + + path := "/yang/" + name + if revision != "" { + path += "@" + revision + } + path += ".yang" + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, yangBase+path, nil) + if err != nil { + return nil, err + } + creds := CredentialsFromContext(ctx) + req.SetBasicAuth(creds.Username, creds.Password) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("restconf request failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, parseError(resp) + } + return io.ReadAll(resp.Body) +} + +// GetRaw fetches a RESTCONF resource and returns the raw JSON bytes. +func (c *Client) GetRaw(ctx context.Context, path string) ([]byte, error) { + resp, err := c.doRequest(ctx, http.MethodGet, path) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, parseError(resp) + } + return io.ReadAll(resp.Body) +} + +// CheckAuth verifies that the given credentials are accepted by rousette. +// It does a simple GET against /data/ietf-system:system with Basic Auth. +func (c *Client) CheckAuth(username, password string) error { + req, err := http.NewRequestWithContext( + context.Background(), + http.MethodGet, + c.baseURL+"/data/ietf-system:system", + nil, + ) + if err != nil { + return err + } + + req.Header.Set("Accept", "application/yang-data+json") + req.SetBasicAuth(username, password) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("restconf request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + return &AuthError{Code: resp.StatusCode} + } + if resp.StatusCode != http.StatusOK { + return parseError(resp) + } + + ct := resp.Header.Get("Content-Type") + if !strings.Contains(ct, "yang-data+json") { + return fmt.Errorf("unexpected content-type from RESTCONF server: %q", ct) + } + + return nil +} + +// escapeZoneID replaces bare "%" in IPv6 zone IDs with "%25" so that +// Go's url.Parse doesn't reject them as invalid percent-encoding. +// e.g. "https://[ff02::1%qtap1]/restconf" → "https://[ff02::1%25qtap1]/restconf" +func escapeZoneID(rawURL string) string { + open := strings.Index(rawURL, "[") + close := strings.Index(rawURL, "]") + if open < 0 || close < 0 || close < open { + return rawURL + } + + host := rawURL[open:close] + if pct := strings.Index(host, "%"); pct >= 0 && !strings.HasPrefix(host[pct:], "%25") { + return rawURL[:open+pct] + "%25" + rawURL[open+pct+1:] + } + return rawURL +} + +// EscapeKey percent-encodes a RESTCONF list-key value for use in a URL path. +// url.PathEscape leaves ':' unescaped, but rousette parses ':' in a path +// segment as the module:node separator — so an IPv6 key like 2001:db8::1 must +// have its colons encoded or the request fails with a URI "Syntax error". +func EscapeKey(s string) string { + return strings.ReplaceAll(url.PathEscape(s), ":", "%3A") +} diff --git a/src/webui/internal/restconf/errors.go b/src/webui/internal/restconf/errors.go new file mode 100644 index 00000000..f96da246 --- /dev/null +++ b/src/webui/internal/restconf/errors.go @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: MIT + +package restconf + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" +) + +// IsNotFound reports whether err is a RESTCONF error with HTTP status 404. +// Used to distinguish "data resource absent" (expected for delete-or-create +// flows) from real failures. +func IsNotFound(err error) bool { + var e *Error + return errors.As(err, &e) && e.StatusCode == http.StatusNotFound +} + +// IsDataMissing reports whether err carries the RESTCONF "data-missing" +// error-tag, returned when an operation targets a leaf or container that +// isn't present in the datastore (e.g. a reset on a leaf that was never +// set). Callers use this to swallow no-op failures so the UI stays +// consistent regardless of whether the leaf was already absent. +func IsDataMissing(err error) bool { + var e *Error + return errors.As(err, &e) && e.Tag == "data-missing" +} + +// AuthError is returned when RESTCONF rejects credentials (401/403). +type AuthError struct { + Code int +} + +func (e *AuthError) Error() string { + return fmt.Sprintf("authentication failed (HTTP %d)", e.Code) +} + +// Error represents a RESTCONF error response. +type Error struct { + StatusCode int + Type string + Tag string + Message string +} + +func (e *Error) Error() string { + if e.Message != "" { + return fmt.Sprintf("restconf %d: %s", e.StatusCode, e.Message) + } + return fmt.Sprintf("restconf %d: %s", e.StatusCode, e.Tag) +} + +// parseError reads a RESTCONF error response body and returns an *Error. +func parseError(resp *http.Response) error { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192)) + + re := &Error{StatusCode: resp.StatusCode} + + // Try to parse the standard RESTCONF error envelope. + var envelope struct { + Errors struct { + Error []struct { + ErrorType string `json:"error-type"` + ErrorTag string `json:"error-tag"` + ErrorPath string `json:"error-path"` + ErrorMessage string `json:"error-message"` + ErrorInfo any `json:"error-info"` + } `json:"error"` + } `json:"ietf-restconf:errors"` + } + + if json.Unmarshal(body, &envelope) == nil && len(envelope.Errors.Error) > 0 { + var parts []string + for _, e := range envelope.Errors.Error { + msg := e.ErrorMessage + if msg == "" { + msg = e.ErrorTag + } + if e.ErrorPath != "" { + msg += " (path: " + e.ErrorPath + ")" + } + parts = append(parts, msg) + } + re.Type = envelope.Errors.Error[0].ErrorType + re.Tag = envelope.Errors.Error[0].ErrorTag + re.Message = strings.Join(parts, "; ") + } else { + re.Message = http.StatusText(resp.StatusCode) + } + + return re +} diff --git a/src/webui/internal/restconf/fetcher.go b/src/webui/internal/restconf/fetcher.go new file mode 100644 index 00000000..9c5f455d --- /dev/null +++ b/src/webui/internal/restconf/fetcher.go @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT + +package restconf + +import ( + "context" + "encoding/json" +) + +// Fetcher is the RESTCONF client interface used by handlers. +// *Client satisfies this interface; testutil.MockFetcher provides a test double. +type Fetcher interface { + Get(ctx context.Context, path string, target any) error + GetRaw(ctx context.Context, path string) ([]byte, error) + // GetYANG downloads a YANG module file from /yang/{name}@{revision}.yang + // on the device host (outside the /restconf tree). + GetYANG(ctx context.Context, name, revision string) ([]byte, error) + Post(ctx context.Context, path string) error + PostJSON(ctx context.Context, path string, body any) error + + Put(ctx context.Context, path string, body any) error + Patch(ctx context.Context, path string, body any) error + Delete(ctx context.Context, path string) error + + GetDatastore(ctx context.Context, datastore string) (json.RawMessage, error) + PutDatastore(ctx context.Context, datastore string, body json.RawMessage) error + CopyDatastore(ctx context.Context, src, dst string) error +} + +var _ Fetcher = (*Client)(nil) diff --git a/src/webui/internal/schema/fetch.go b/src/webui/internal/schema/fetch.go new file mode 100644 index 00000000..41a55fbc --- /dev/null +++ b/src/webui/internal/schema/fetch.go @@ -0,0 +1,125 @@ +package schema + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + + "infix/webui/internal/restconf" +) + +// ModuleInfo identifies a YANG module by name and revision. +type ModuleInfo struct { + Name string + Revision string +} + +func (m ModuleInfo) filename() string { + if m.Revision == "" { + return m.Name + ".yang" + } + return m.Name + "@" + m.Revision + ".yang" +} + +// rfc7895ModulesState is the RFC 7895 /modules-state response structure. +type rfc7895ModulesState struct { + ModulesState struct { + Module []struct { + Name string `json:"name"` + Revision string `json:"revision"` + Submodule []struct { + Name string `json:"name"` + Revision string `json:"revision"` + } `json:"submodule"` + } `json:"module"` + } `json:"ietf-yang-library:modules-state"` +} + +// rfc8525YangLibrary is the RFC 8525 /yang-library response structure (fallback). +type rfc8525YangLibrary struct { + YangLibrary struct { + ModuleSet []struct { + Module []struct { + Name string `json:"name"` + Revision string `json:"revision"` + } `json:"module"` + } `json:"module-set"` + } `json:"ietf-yang-library:yang-library"` +} + +// FetchModules downloads any YANG files not already cached in cacheDir. +// It first tries the RFC 7895 modules-state endpoint, then falls back to +// the RFC 8525 yang-library endpoint (same as capabilities.go). +// Each module and its submodules are downloaded from /yang/{name}@{rev}.yang. +func FetchModules(ctx context.Context, rc restconf.Fetcher, cacheDir string) ([]ModuleInfo, error) { + if err := os.MkdirAll(cacheDir, 0750); err != nil { + return nil, fmt.Errorf("schema: create cache dir: %w", err) + } + + modules, err := listModules(ctx, rc) + if err != nil { + return nil, err + } + + var downloaded []ModuleInfo + for _, m := range modules { + if err := downloadIfMissing(ctx, rc, cacheDir, m); err != nil { + log.Printf("schema: skip %s: %v", m.filename(), err) + continue + } + downloaded = append(downloaded, m) + } + return downloaded, nil +} + +// listModules queries the device for the list of implemented YANG modules. +func listModules(ctx context.Context, rc restconf.Fetcher) ([]ModuleInfo, error) { + // Try RFC 7895 modules-state first. + var ms rfc7895ModulesState + if err := rc.Get(ctx, "/data/ietf-yang-library:modules-state", &ms); err == nil { + var mods []ModuleInfo + for _, m := range ms.ModulesState.Module { + mods = append(mods, ModuleInfo{Name: m.Name, Revision: m.Revision}) + for _, sub := range m.Submodule { + mods = append(mods, ModuleInfo{Name: sub.Name, Revision: sub.Revision}) + } + } + if len(mods) > 0 { + return mods, nil + } + } + + // Fall back to RFC 8525 yang-library. + var yl rfc8525YangLibrary + if err := rc.Get(ctx, "/data/ietf-yang-library:yang-library", &yl); err != nil { + return nil, fmt.Errorf("schema: list modules: %w", err) + } + var mods []ModuleInfo + for _, ms := range yl.YangLibrary.ModuleSet { + for _, m := range ms.Module { + mods = append(mods, ModuleInfo{Name: m.Name, Revision: m.Revision}) + } + } + return mods, nil +} + +// downloadIfMissing fetches a single YANG file from the device if not cached. +func downloadIfMissing(ctx context.Context, rc restconf.Fetcher, cacheDir string, m ModuleInfo) error { + dest := filepath.Join(cacheDir, m.filename()) + if _, err := os.Stat(dest); err == nil { + return nil // already cached + } + + data, err := rc.GetYANG(ctx, m.Name, m.Revision) + if err != nil { + return fmt.Errorf("GET /yang/%s: %w", m.filename(), err) + } + + if err := os.WriteFile(dest, data, 0640); err != nil { + return fmt.Errorf("write %s: %w", dest, err) + } + log.Printf("schema: cached %s", m.filename()) + return nil +} diff --git a/src/webui/internal/schema/helpers.go b/src/webui/internal/schema/helpers.go new file mode 100644 index 00000000..94d1a85f --- /dev/null +++ b/src/webui/internal/schema/helpers.go @@ -0,0 +1,55 @@ +package schema + +import "strings" + +// IdentityOption is a schema-resolved identity/enum value for use in select dropdowns. +type IdentityOption struct { + Value string // full identity, e.g. "infix-system:clish" — submitted to RESTCONF + Label string // display label with module prefix stripped, e.g. "clish" + IsDefault bool // true if this matches the leaf's YANG default +} + +// StripModulePrefix strips the "module:" prefix from an identity or enum value. +func StripModulePrefix(v string) string { + if i := strings.LastIndex(v, ":"); i >= 0 { + return v[i+1:] + } + return v +} + +// OptionsFor returns IdentityOption entries for the identityref or enumeration +// leaf at path. Returns nil when schema is unavailable or the leaf has no options. +func OptionsFor(mgr *Manager, path string) []IdentityOption { + if mgr == nil { + return nil + } + node, err := mgr.NodeAt(path) + if err != nil || node == nil || node.Type == nil { + return nil + } + values := node.Type.Identities + if len(values) == 0 { + values = node.Type.Enums + } + opts := make([]IdentityOption, 0, len(values)) + for _, v := range values { + opts = append(opts, IdentityOption{ + Value: v, + Label: StripModulePrefix(v), + IsDefault: node.Default != "" && v == node.Default, + }) + } + return opts +} + +// DescriptionOf returns the YANG description for the leaf at path, or "". +func DescriptionOf(mgr *Manager, path string) string { + if mgr == nil { + return "" + } + node, err := mgr.NodeAt(path) + if err != nil || node == nil { + return "" + } + return node.Description +} diff --git a/src/webui/internal/schema/leafref_test.go b/src/webui/internal/schema/leafref_test.go new file mode 100644 index 00000000..c598f445 --- /dev/null +++ b/src/webui/internal/schema/leafref_test.go @@ -0,0 +1,166 @@ +package schema + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +const testTargetYang = ` +module test-target { + yang-version 1.1; + namespace "urn:test:target"; + prefix tt; + + container interfaces { + list interface { + key name; + leaf name { type string; } + leaf type { type string; } + } + } +} +` + +const testReferYang = ` +module test-refer { + yang-version 1.1; + namespace "urn:test:refer"; + prefix tr; + + import test-target { prefix tt; } + + container config { + leaf port { + type leafref { + path "/tt:interfaces/tt:interface/tt:name"; + } + must "deref(.)/../tt:type" { + error-message "target must have type"; + } + } + } +} +` + +func TestLeafrefCanonicalize(t *testing.T) { + dir := t.TempDir() + mustWrite(t, dir, "test-target.yang", testTargetYang) + mustWrite(t, dir, "test-refer.yang", testReferYang) + + mgr, err := Load(dir) + if err != nil { + t.Fatalf("new manager: %v", err) + } + + n, err := mgr.NodeAt("/test-refer:config/port") + if err != nil { + t.Fatalf("NodeAt: %v", err) + } + if n == nil || n.Type == nil { + t.Fatalf("missing node or type: %+v", n) + } + t.Logf("Leafref path = %q", n.Type.Leafref) + t.Logf("LeafrefSibling = %q", n.Type.LeafrefSibling) + + if strings.Contains(n.Type.Leafref, "tt:") { + t.Errorf("Leafref still has unresolved prefix: %q", n.Type.Leafref) + } + want := "/test-target:interfaces/interface/name" + if n.Type.Leafref != want { + t.Errorf("Leafref = %q, want %q (RESTCONF canonical, no repeated module prefixes)", n.Type.Leafref, want) + } + if n.Type.LeafrefSibling != "type" { + t.Errorf("LeafrefSibling = %q, want \"type\"", n.Type.LeafrefSibling) + } +} + +func mustWrite(t *testing.T, dir, name, content string) { + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil { + t.Fatalf("write %s: %v", name, err) + } +} + +// TestLeafrefParentIsList verifies that NodeAt reports the correct Kind +// for each segment of a canonical leafref path so fetchLeafrefValues can +// walk up past the list to its container parent (RESTCONF refuses bare +// list paths). Mirrors the bridge-port/bridge case where the path +// ends in /interfaces/interface/name and we have to GET /interfaces. +func TestLeafrefParentIsList(t *testing.T) { + dir := t.TempDir() + mustWrite(t, dir, "test-target.yang", testTargetYang) + mgr, err := Load(dir) + if err != nil { + t.Fatalf("load: %v", err) + } + cases := []struct{ path, kind string }{ + {"/test-target:interfaces", "container"}, + {"/test-target:interfaces/interface", "list"}, + {"/test-target:interfaces/interface/name", "leaf"}, + } + for _, tc := range cases { + n, err := mgr.NodeAt(tc.path) + if err != nil || n == nil { + t.Fatalf("NodeAt(%q): %v", tc.path, err) + } + if n.Kind != tc.kind { + t.Errorf("NodeAt(%q).Kind = %q, want %q", tc.path, n.Kind, tc.kind) + } + } +} + +const testBridgeYang = ` +module test-br { + yang-version 1.1; + namespace "urn:test:br"; + prefix tb; + + import test-target { prefix tt; } + + grouping br-port { + leaf bridge { + type leafref { + path "/tt:interfaces/tt:interface/tt:name"; + } + must "deref(.)/../bridge and not(. = ../../tt:name)"; + } + } + + augment "/tt:interfaces/tt:interface" { + container bridge-port { + uses br-port; + } + } +} +` + +func TestLeafrefBridgeAugment(t *testing.T) { + dir := t.TempDir() + mustWrite(t, dir, "test-target.yang", testTargetYang) + mustWrite(t, dir, "test-br.yang", testBridgeYang) + mgr, err := Load(dir) + if err != nil { + t.Fatalf("load: %v", err) + } + n, err := mgr.NodeAt("/test-target:interfaces/interface/test-br:bridge-port/bridge") + if err != nil { + t.Logf("first NodeAt failed: %v", err) + n, err = mgr.NodeAt("/test-target:interfaces/test-target:interface/test-br:bridge-port/test-br:bridge") + if err != nil { + t.Fatalf("NodeAt: %v", err) + } + } + if n == nil || n.Type == nil { + t.Fatalf("missing: %+v", n) + } + t.Logf("Leafref = %q", n.Type.Leafref) + t.Logf("LeafrefSibling = %q", n.Type.LeafrefSibling) + if n.Type.LeafrefSibling != "bridge" { + t.Errorf("LeafrefSibling = %q, want \"bridge\"", n.Type.LeafrefSibling) + } + want := "/test-target:interfaces/interface/name" + if n.Type.Leafref != want { + t.Errorf("Leafref = %q, want %q", n.Type.Leafref, want) + } +} diff --git a/src/webui/internal/schema/manager.go b/src/webui/internal/schema/manager.go new file mode 100644 index 00000000..b888cf74 --- /dev/null +++ b/src/webui/internal/schema/manager.go @@ -0,0 +1,899 @@ +package schema + +import ( + "fmt" + "log" + "os" + "path/filepath" + "reflect" + "regexp" + "sort" + "strings" + + "github.com/openconfig/goyang/pkg/yang" +) + +// Manager holds a fully processed goyang module set and provides schema queries. +// All modules are loaded before Process() is called so that cross-module +// identityref resolution and augments work correctly. +type Manager struct { + ms *yang.Modules +} + +// Load parses all .yang files in yangDir and returns a Manager. +// Errors from Process() that are non-fatal (e.g. unresolved augments for +// modules that were not downloaded) are logged but do not abort loading. +func Load(yangDir string) (*Manager, error) { + ms := yang.NewModules() + ms.Path = []string{yangDir} + + entries, err := os.ReadDir(yangDir) + if err != nil { + return nil, fmt.Errorf("schema: read yang dir: %w", err) + } + + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".yang") { + continue + } + if err := ms.Read(filepath.Join(yangDir, e.Name())); err != nil { + // Non-fatal: goyang may still register the module partially. + log.Printf("schema: parse (non-fatal) %s: %v", e.Name(), err) + } + } + + errs := ms.Process() + for _, err := range errs { + // goyang v1.6.3 has known YANG 1.1 gaps (must/when substatements, + // duplicate augments). These are non-fatal; log at debug level. + log.Printf("schema: process (non-fatal): %v", err) + } + + return &Manager{ms: ms}, nil +} + +// Children returns the direct config-relevant child Nodes of the node at path. +// Use path "/" for the top-level module list. +// choice/case nodes are inlined transparently (their children are promoted). +func (m *Manager) Children(path string) ([]*Node, error) { + if path == "" || path == "/" { + return m.topLevelNodes(), nil + } + e, err := m.entryAt(path) + if err != nil { + return nil, err + } + if e.Dir == nil { + return nil, nil + } + return dirToNodes(e.Dir, path), nil +} + +// ChildrenAll is like Children but includes config:false nodes, making it +// suitable for the operational/status tree where state data reuses read-write +// schema nodes. Only RPCs, notifications, anydata/anyxml, and deprecated or +// obsolete nodes are excluded. +func (m *Manager) ChildrenAll(path string) ([]*Node, error) { + if path == "" || path == "/" { + return m.topLevelNodesAll(), nil + } + e, err := m.entryAt(path) + if err != nil { + return nil, err + } + if e.Dir == nil { + return nil, nil + } + return dirToNodesAll(e.Dir, path), nil +} + +// NodeAt returns a Node for the YANG schema node at path (without children). +func (m *Manager) NodeAt(path string) (*Node, error) { + if path == "" || path == "/" { + return &Node{Path: "/", Name: "", Kind: "container", Config: true}, nil + } + e, err := m.entryAt(path) + if err != nil { + return nil, err + } + return entryToNode(e, path), nil +} + +// IdentitiesOf returns the names of all identities derived (directly or +// transitively) from baseName. baseName may be "name" or "module:name". +// Searches across all loaded modules. +func (m *Manager) IdentitiesOf(baseName string) []string { + // Strip module prefix if present; search by identity name. + _, localName := splitPrefix(baseName) + + for _, mod := range m.ms.Modules { + for _, id := range mod.Identities() { + if id.Name == localName { + var names []string + for _, v := range id.Values { + names = append(names, v.Name) + } + sort.Strings(names) + return names + } + } + } + return nil +} + +// ResolveLeafref converts a YANG leafref path expression to an absolute schema +// path that can be fetched via RESTCONF. contextPath is the schema path of the +// leaf that holds the leafref (needed for relative `../` resolution). +// Returns empty string if the path cannot be resolved. +func (m *Manager) ResolveLeafref(leafrefPath, contextPath string) string { + if strings.HasPrefix(leafrefPath, "/") { + return normalizeLeafrefPath(leafrefPath) + } + // Relative path: resolve `../` against the context, stripping the leaf itself. + parts := strings.Split(strings.TrimPrefix(contextPath, "/"), "/") + if len(parts) > 0 { + parts = parts[:len(parts)-1] // remove the leaf; start from its parent + } + for _, seg := range strings.Split(leafrefPath, "/") { + switch seg { + case "..": + if len(parts) > 0 { + parts = parts[:len(parts)-1] + } + case ".", "": + // skip + default: + parts = append(parts, stripPredicate(seg)) + } + } + if len(parts) == 0 { + return "" + } + return "/" + strings.Join(parts, "/") +} + +// normalizeLeafrefPath strips XPath predicates from each segment of an +// absolute YANG leafref path so it can be used as a schema lookup path. +func normalizeLeafrefPath(p string) string { + segs := strings.Split(strings.TrimPrefix(p, "/"), "/") + for i, s := range segs { + segs[i] = stripPredicate(s) + } + return "/" + strings.Join(segs, "/") +} + +// canonicalizeLeafrefPath rewrites a YANG leafref path into the RESTCONF +// canonical form (RFC 8040 §3.5.3): each segment's prefix alias is +// resolved to its canonical module name, and the "module:" qualifier +// is dropped from segments whose module matches the preceding +// qualified segment. Unknown prefixes are left untouched. +// +// Example: "/if:interfaces/if:interface/if:name" with the `if` prefix +// pointing at `ietf-interfaces` becomes +// "/ietf-interfaces:interfaces/interface/name", which is what +// nodeSegment() produces for ordinary paths and what RESTCONF expects. +func canonicalizeLeafrefPath(node yang.Node, p string) string { + if node == nil || p == "" { + return p + } + segs := strings.Split(p, "/") + var lastMod string + for i, seg := range segs { + if seg == "" || seg == ".." || seg == "." { + continue + } + bare, pred := seg, "" + if b := strings.IndexByte(seg, '['); b >= 0 { + bare, pred = seg[:b], seg[b:] + } + colon := strings.IndexByte(bare, ':') + if colon < 0 { + // Unprefixed segments inherit the prevailing module. + continue + } + prefix, rest := bare[:colon], bare[colon+1:] + mod := yang.FindModuleByPrefix(node, prefix) + if mod == nil { + continue + } + modName := mod.Name + if mod.BelongsTo != nil { + modName = mod.BelongsTo.Name + } + if modName == lastMod { + segs[i] = rest + pred + } else { + segs[i] = modName + ":" + rest + pred + lastMod = modName + } + } + return strings.Join(segs, "/") +} + +// derefSiblingPattern captures the `deref(.)/../<sibling>` XPath idiom +// used by Infix YANG must statements to constrain a leafref's target +// to objects that carry a specific sibling node (e.g. `bridge-port/ +// bridge`'s must says the referenced interface must have a `bridge` +// container — i.e. it has to be a bridge). The capture group is the +// local sibling name; any prefix (`tt:type`) is matched non-capturing +// so callers don't have to strip it. +var derefSiblingPattern = regexp.MustCompile(`deref\s*\(\s*\.\s*\)\s*/\s*\.\.\s*/\s*(?:[a-zA-Z][\w-]*:)?([a-zA-Z][\w-]*)`) + +// leafrefSiblingFilter looks at the leaf's must statements (only Leaf +// nodes carry must in goyang's Entry tree) for the deref(.)/../X +// pattern and returns X, or "" when no such constraint exists. +func leafrefSiblingFilter(e *yang.Entry) string { + leaf, ok := e.Node.(*yang.Leaf) + if !ok { + return "" + } + for _, m := range leaf.Must { + if m == nil { + continue + } + if match := derefSiblingPattern.FindStringSubmatch(m.Name); len(match) == 2 { + return match[1] + } + } + return "" +} + +// Default returns the YANG default value for the leaf at path, if any. +func (m *Manager) Default(path string) (string, bool) { + e, err := m.entryAt(path) + if err != nil { + return "", false + } + return e.SingleDefaultValue() +} + +// ModuleName returns the module name for the schema node at path. +func (m *Manager) ModuleName(path string) (string, error) { + e, err := m.entryAt(path) + if err != nil { + return "", err + } + return e.InstantiatingModule() +} + +// ModuleQualifiedName returns "module:name" for the schema node at path. +// This is the JSON object key used when PUT/PATCHing that node directly via +// RESTCONF (RFC 7951 §4 — namespace-qualified name at module boundaries). +func (m *Manager) ModuleQualifiedName(path string) (string, error) { + e, err := m.entryAt(path) + if err != nil { + return "", err + } + modName, err := e.InstantiatingModule() + if err != nil { + return "", err + } + return modName + ":" + e.Name, nil +} + +// internalOperationalTopNodes lists bare node names that must not appear at +// the top level of the operational/status tree. These are all transport- or +// server-monitoring nodes of no interest to end users. They are already +// absent from the configure tree because they are config:false; this list +// applies the same exclusion to topLevelNodesAll. +var internalOperationalTopNodes = map[string]bool{ + "netconf": true, // NETCONF session/lock monitoring + "netconf-state": true, // ietf-netconf-monitoring (RFC 6022) + "notification": true, // nc-notifications stream container + "restconf-state": true, // ietf-restconf-monitoring (RFC 8527) + "supported-algorithms": true, // SSH/TLS algorithm capability advertisement + "system-capabilities": true, // ietf-system-capabilities (RFC 9196) +} + +// internalModules is the deny-list of YANG modules that are infrastructure-only +// and must not appear in the user-facing configure tree. +// Mirrors sr_module_is_internal() from klish-plugin-sysrepo/src/pline.c. +// Note: ietf-netconf-acm is intentionally NOT listed here — Infix exposes NACM +// configuration to users. +var internalModules = map[string]bool{ + // libyang built-ins + "ietf-yang-metadata": true, + "yang": true, + "ietf-inet-types": true, + "ietf-yang-types": true, + // YANG library / schema mount + "ietf-datastores": true, + "ietf-yang-schema-mount": true, + "ietf-yang-library": true, + // NETCONF infrastructure + "ietf-netconf": true, + "ietf-netconf-with-defaults": true, + "ietf-origin": true, + "ietf-netconf-notifications": true, + // sysrepo internals + "sysrepo": true, + "sysrepo-monitoring": true, + "sysrepo-plugind": true, + // Infix test/debug subtree and meta-data — not user-facing + "infix-test": true, + "infix-meta": true, + // NETCONF server / transport infrastructure — managed by the system, not users + "ietf-netconf-server": true, + "libnetconf2-netconf-server": true, + "ietf-truststore": true, + // Notification subscriptions and filters — not user-configurable via WebUI + "ietf-subscribed-notifications": true, + // ACL, key-chains, network-instances — not exposed in WebUI yet + "ietf-access-control-list": true, + "ietf-key-chain": true, + "ietf-network-instance": true, +} + +// topLevelNodes returns a Node for each config-relevant top-level schema node +// across all loaded modules. Submodules, versioned aliases, internal modules, +// and config:false top-level nodes are all excluded. +func (m *Manager) topLevelNodes() []*Node { + var nodes []*Node + for key, mod := range m.ms.Modules { + if strings.Contains(key, "@") { + continue // versioned alias (e.g. "ieee802-dot1ab-lldp@2022-03-15") — duplicate + } + if mod.BelongsTo != nil { + continue // submodule — content appears under the parent module + } + if internalModules[key] { + continue + } + e := yang.ToEntry(mod) + if e == nil || e.Dir == nil { + continue + } + for _, child := range sortedEntries(e.Dir) { + if isNonConfigNode(child) { + continue + } + nodePath := "/" + key + ":" + child.Name + if isContainerList(child) { + if lc := listChildOf(child); lc != nil { + nodes = append(nodes, entryToNode(lc, nodePath+"/"+nodeSegment(lc, key))) + } + continue + } + nodes = append(nodes, entryToNode(child, nodePath)) + } + } + // Sort by node name (not module-qualified path) for a clean alphabetical list. + sort.Slice(nodes, func(i, j int) bool { return nodes[i].Name < nodes[j].Name }) + return nodes +} + +// entryAt resolves a RESTCONF-style path to a goyang Entry. +// Path format: /module:top-node/child[key='val']/grandchild +// Key predicates ([key='val']) are stripped — they carry instance identity, +// not schema identity. choice/case nodes are skipped during traversal. +func (m *Manager) entryAt(path string) (*yang.Entry, error) { + path = strings.TrimPrefix(path, "/") + parts := strings.SplitN(path, "/", 2) + + head := stripPredicate(parts[0]) + moduleName, nodeName := splitPrefix(head) + + if moduleName == "" { + return nil, fmt.Errorf("schema: path must start with module prefix: %s", path) + } + + mod, ok := m.ms.Modules[moduleName] + if !ok { + return nil, fmt.Errorf("schema: module not found: %s", moduleName) + } + + root := yang.ToEntry(mod) + if root == nil { + return nil, fmt.Errorf("schema: no entry for module: %s", moduleName) + } + + e := findInDir(root.Dir, nodeName) + if e == nil { + return nil, fmt.Errorf("schema: %s not found in %s", nodeName, moduleName) + } + + if len(parts) == 1 { + return e, nil + } + + // Traverse remaining path segments, stripping key predicates. + for _, seg := range strings.Split(parts[1], "/") { + if seg == "" { + continue + } + _, localName := splitPrefix(stripPredicate(seg)) + child := findInDir(e.Dir, localName) + if child == nil { + return nil, fmt.Errorf("schema: %s not found under %s", localName, e.Name) + } + e = child + } + return e, nil +} + +// stripPredicate removes a key predicate from a path segment. +// Handles both RESTCONF ("interface=eth0" → "interface") and +// XPath ("interface[name='eth0']" → "interface") forms. +func stripPredicate(seg string) string { + if i := strings.IndexByte(seg, '['); i >= 0 { + return seg[:i] + } + if i := strings.IndexByte(seg, '='); i >= 0 { + return seg[:i] + } + return seg +} + +// findInDir looks up name in a dir map, transparently descending into +// choice/case nodes which are not part of the RESTCONF path. +func findInDir(dir map[string]*yang.Entry, name string) *yang.Entry { + if dir == nil { + return nil + } + // Direct match — but if it is a choice/case, descend into it because YANG + // commonly names a case identically to the leaf it contains (e.g. the + // ietf-system timezone-name case wrapping the timezone-name leaf). + if e, ok := dir[name]; ok { + if !e.IsChoice() && !e.IsCase() { + return e + } + if found := findInDir(e.Dir, name); found != nil { + return found + } + } + // Search inside all choice/case children. + for _, e := range dir { + if e.IsChoice() || e.IsCase() { + if found := findInDir(e.Dir, name); found != nil { + return found + } + } + } + return nil +} + +// isContainerList returns true for containers that wrap exactly one list and +// nothing else — the classic YANG container/list idiom (e.g. /interfaces wrapping +// /interfaces/interface). Mirrors klysc_is_container_list() in klish-plugin-sysrepo +// exactly: ALL direct children are inspected, including config:false ones. Any +// non-list child (a leaf, another container, …) prevents collapsing. This is why +// "hardware" does not collapse — it has a config:false "last-change" leaf alongside +// "component", so the default case fires and returns false. +// +// Hard exceptions: +// - "static-routes" always collapses (ietf-routing, asymmetric naming) +// - "mdb" never collapses +// - "ipv4", "ipv6" never collapse (would confuse routing subtrees) +func isContainerList(e *yang.Entry) bool { + if !e.IsContainer() || e.Dir == nil { + return false + } + if e.Name == "mdb" || e.Name == "ipv4" || e.Name == "ipv6" { + return false + } + if e.Name == "static-routes" { + return true + } + listCount := 0 + for _, child := range e.Dir { + if !child.IsList() { + return false // any non-list child (even config:false) prevents collapse + } + listCount++ + } + return listCount == 1 +} + +// listChildOf returns the single list child of a collapsible container (panics if +// called on a non-collapsible container — callers must guard with isContainerList). +func listChildOf(e *yang.Entry) *yang.Entry { + for _, child := range e.Dir { + if child.IsList() && !isNonConfigNode(child) { + return child + } + } + return nil +} + +// listChildOfAll is like listChildOf but uses the data-node filter (includes +// config:false lists), used by dirToNodesAll / the operational status tree. +func listChildOfAll(e *yang.Entry) *yang.Entry { + for _, child := range e.Dir { + if child.IsList() && !isNonDataNode(child) { + return child + } + } + return nil +} + +// topLevelNodesAll is like topLevelNodes but includes config:false top-level +// nodes, for use by the operational status tree. +func (m *Manager) topLevelNodesAll() []*Node { + var nodes []*Node + for key, mod := range m.ms.Modules { + if strings.Contains(key, "@") { + continue + } + if mod.BelongsTo != nil { + continue + } + if internalModules[key] { + continue + } + e := yang.ToEntry(mod) + if e == nil || e.Dir == nil { + continue + } + for _, child := range sortedEntries(e.Dir) { + if isNonDataNode(child) { + continue + } + if internalOperationalTopNodes[child.Name] { + continue + } + nodePath := "/" + key + ":" + child.Name + if isContainerList(child) { + if lc := listChildOfAll(child); lc != nil { + nodes = append(nodes, entryToNode(lc, nodePath+"/"+nodeSegment(lc, key))) + } + continue + } + nodes = append(nodes, entryToNode(child, nodePath)) + } + } + sort.Slice(nodes, func(i, j int) bool { return nodes[i].Name < nodes[j].Name }) + return nodes +} + +// dirToNodesAll is like dirToNodes but uses isNonDataNode, including +// config:false leaves and containers in the result. +func dirToNodesAll(dir map[string]*yang.Entry, parentPath string) []*Node { + parentMod := extractModuleFromPath(parentPath) + var nodes []*Node + for _, e := range sortedEntries(dir) { + if e.IsChoice() || e.IsCase() { + caseWhen := extractWhen(e) + for _, child := range dirToNodesAll(e.Dir, parentPath) { + if caseWhen != "" && child.When == "" { + child.When = caseWhen + } + nodes = append(nodes, child) + } + continue + } + if isNonDataNode(e) { + continue + } + nodePath := parentPath + "/" + nodeSegment(e, parentMod) + if isContainerList(e) { + if lc := listChildOfAll(e); lc != nil { + nodes = append(nodes, entryToNode(lc, nodePath+"/"+nodeSegment(lc, parentMod))) + } + continue + } + nodes = append(nodes, entryToNode(e, nodePath)) + } + return nodes +} + +// dirToNodes converts a goyang Dir map to a sorted slice of Nodes. +// choice/case children are inlined (their contents promoted to this level). +// Collapsible container-list wrappers are transparent: the list child is surfaced +// directly under the parent with the full (un-collapsed) RESTCONF path. +// RPC, notification, anydata, anyxml and config:false nodes are excluded. +// +// RESTCONF requires module qualification ("module:name") whenever a node's +// module differs from its parent's — the common case being augmented nodes. +// e.g. infix-lldp augments ieee802-dot1ab-lldp:lldp, so the path is +// /ieee802-dot1ab-lldp:lldp/infix-lldp:enabled, not /…/enabled. +func dirToNodes(dir map[string]*yang.Entry, parentPath string) []*Node { + parentMod := extractModuleFromPath(parentPath) + var nodes []*Node + for _, e := range sortedEntries(dir) { + if e.IsChoice() || e.IsCase() { + // Extract when from the choice/case before inlining its children. + // Cases contributed by augments carry the augment's when (e.g. + // lag-port only for ethernetCsmacd, bridge-port only for bridge). + // Propagate that constraint to each promoted child that has no when of + // its own; if the child already has one, leave it alone. + caseWhen := extractWhen(e) + for _, child := range dirToNodes(e.Dir, parentPath) { + if caseWhen != "" && child.When == "" { + child.When = caseWhen + } + nodes = append(nodes, child) + } + continue + } + if isNonConfigNode(e) { + continue + } + nodePath := parentPath + "/" + nodeSegment(e, parentMod) + if isContainerList(e) { + if lc := listChildOf(e); lc != nil { + // Container is collapsed; qualify list child against parentMod. + nodes = append(nodes, entryToNode(lc, nodePath+"/"+nodeSegment(lc, parentMod))) + } + continue + } + nodes = append(nodes, entryToNode(e, nodePath)) + } + return nodes +} + +// nodeSegment returns the path segment for e, qualified as "module:name" when +// e's instantiating module differs from parentMod (RESTCONF RFC 8040 §3.5.3). +func nodeSegment(e *yang.Entry, parentMod string) string { + mod, err := e.InstantiatingModule() + if err != nil || mod == parentMod { + return e.Name + } + return mod + ":" + e.Name +} + +// extractModuleFromPath returns the module from the rightmost module-qualified +// segment in a RESTCONF path, e.g. "/ieee802-dot1ab-lldp:lldp/port" → "ieee802-dot1ab-lldp". +func extractModuleFromPath(path string) string { + segs := strings.Split(strings.TrimPrefix(path, "/"), "/") + for i := len(segs) - 1; i >= 0; i-- { + seg := stripPredicate(segs[i]) + if j := strings.IndexByte(seg, ':'); j > 0 { + return seg[:j] + } + } + return "" +} + +// isNonConfigNode returns true for schema nodes that do not belong in the +// configuration tree: RPCs, notifications, anydata/anyxml, config:false subtrees, +// and nodes with YANG status deprecated or obsolete. +// goyang propagates config:false from parent to children during Process(), so a +// single check at each level is sufficient to prune entire read-only subtrees. +func isNonConfigNode(e *yang.Entry) bool { + return e.RPC != nil || + e.Kind == yang.NotificationEntry || + e.Kind == yang.AnyDataEntry || + e.Kind == yang.AnyXMLEntry || + e.Config == yang.TSFalse || + isDeprecatedOrObsolete(e) +} + +// isNonDataNode is like isNonConfigNode but keeps config:false nodes. +// Used by ChildrenAll / the operational status tree which shows all data — +// operational state reuses read-write schema nodes alongside state-only leaves. +func isNonDataNode(e *yang.Entry) bool { + return e.RPC != nil || + e.Kind == yang.NotificationEntry || + e.Kind == yang.AnyDataEntry || + e.Kind == yang.AnyXMLEntry || + isDeprecatedOrObsolete(e) +} + +// entryPresence returns the YANG presence statement string for e, or "". +// Presence is stored on the underlying Container Node via reflection because +// yang.Entry does not expose it directly. +func entryPresence(e *yang.Entry) string { + if e.Node == nil { + return "" + } + v := reflect.ValueOf(e.Node) + if v.Kind() == reflect.Ptr { + v = v.Elem() + } + f := v.FieldByName("Presence") + if !f.IsValid() || f.Kind() != reflect.Ptr || f.IsNil() { + return "" + } + name := f.Elem().FieldByName("Name") + if !name.IsValid() { + return "" + } + return name.String() +} + +// isDeprecatedOrObsolete returns true when the YANG node carries +// "status deprecated" or "status obsolete". goyang does not expose status +// directly on Entry, so we reach through to the underlying Node via reflection. +// All concrete yang node types (Container, Leaf, List, …) have Status *Value. +func isDeprecatedOrObsolete(e *yang.Entry) bool { + if e.Node == nil { + return false + } + v := reflect.ValueOf(e.Node) + if v.Kind() == reflect.Ptr { + v = v.Elem() + } + f := v.FieldByName("Status") + if !f.IsValid() || f.Kind() != reflect.Ptr || f.IsNil() { + return false + } + name := f.Elem().FieldByName("Name") + if !name.IsValid() { + return false + } + s := name.String() + return s == "deprecated" || s == "obsolete" +} + +// entryToNode converts a goyang Entry to a schema Node (no children populated). +func entryToNode(e *yang.Entry, path string) *Node { + n := &Node{ + Path: path, + Name: e.Name, + Kind: entryKind(e), + Description: e.Description, + Config: e.Config != yang.TSFalse, + Mandatory: e.Mandatory == yang.TSTrue, + When: extractWhen(e), + Presence: entryPresence(e), + } + + if def, ok := e.SingleDefaultValue(); ok { + n.Default = def + } + + if strings.Contains(e.Key, " ") || e.Key != "" { + n.Keys = strings.Fields(e.Key) + } + + if e.IsLeaf() || e.IsLeafList() { + n.Type = yangTypeInfo(e) + } + + return n +} + +// prefixInXPath matches a "prefix:X" token where X is a letter or underscore, +// the first character of an identifier. The matched letter is included so +// ReplaceAllStringFunc can reattach it after resolving the prefix. +var prefixInXPath = regexp.MustCompile(`[a-zA-Z][a-zA-Z0-9_\-]*:[a-zA-Z_]`) + +// extractWhen returns the pre-resolved YANG when expression for e, or "". +// It first checks e itself (when directly on the node), then checks parent +// augments — the Infix convention is to put when on the augment, not on the +// top-level container inside the augment. +func extractWhen(e *yang.Entry) string { + if xpath, ok := e.GetWhenXPath(); ok && xpath != "" { + return resolveWhenPrefixes(e.Node, xpath) + } + // Check parent's augment list: the augment may carry the when expression + // even though the individual container inside it does not. + if e.Parent == nil { + return "" + } + for _, aug := range e.Parent.Augmented { + if _, found := aug.Dir[e.Name]; !found { + continue + } + if xpath, ok := aug.GetWhenXPath(); ok && xpath != "" { + return resolveWhenPrefixes(aug.Node, xpath) + } + } + return "" +} + +// resolveWhenPrefixes replaces "prefix:x" tokens in an XPath expression with +// the canonical "module:x" form using FindModuleByPrefix on the given node. +// Unknown prefixes (not imported by the node's module) are left unchanged. +func resolveWhenPrefixes(node yang.Node, xpath string) string { + return prefixInXPath.ReplaceAllStringFunc(xpath, func(m string) string { + colon := strings.IndexByte(m, ':') + prefix := m[:colon] + rest := m[colon+1:] // the single identifier-start character + mod := yang.FindModuleByPrefix(node, prefix) + if mod == nil { + return m + } + modName := mod.Name + if mod.BelongsTo != nil { + modName = mod.BelongsTo.Name + } + return modName + ":" + rest + }) +} + +// entryKind maps a goyang Entry to a kind string. +func entryKind(e *yang.Entry) string { + switch { + case e.IsLeaf(): + return "leaf" + case e.IsLeafList(): + return "leaf-list" + case e.IsList(): + return "list" + case e.IsContainer(): + return "container" + case e.IsChoice(): + return "choice" + case e.IsCase(): + return "case" + case e.Kind == yang.AnyDataEntry: + return "anydata" + case e.Kind == yang.AnyXMLEntry: + return "anyxml" + case e.RPC != nil: + return "rpc" + default: + return "unknown" + } +} + +// yangTypeInfo builds a TypeInfo from a leaf's YangType. +func yangTypeInfo(e *yang.Entry) *TypeInfo { + t := e.Type + if t == nil { + return nil + } + + info := &TypeInfo{ + Kind: yang.TypeKindToName[t.Kind], + } + + switch t.Kind { + case yang.Yenum: + if t.Enum != nil { + info.Enums = t.Enum.Names() + sort.Strings(info.Enums) + } + case yang.Yidentityref: + if t.IdentityBase != nil { + for _, v := range t.IdentityBase.Values { + name := v.Name + if root := yang.RootNode(v); root != nil { + modName := root.Name + if root.Kind() == "submodule" && root.BelongsTo != nil { + modName = root.BelongsTo.Name + } + name = modName + ":" + v.Name + } + info.Identities = append(info.Identities, name) + } + sort.Strings(info.Identities) + } + case yang.Yleafref: + info.Leafref = canonicalizeLeafrefPath(e.Node, t.Path) + info.LeafrefSibling = leafrefSiblingFilter(e) + case yang.Yunion: + for _, sub := range t.Type { + if sub.Kind == yang.Yenum && sub.Enum != nil { + info.Enums = append(info.Enums, sub.Enum.Names()...) + } + } + if len(info.Enums) > 0 { + sort.Strings(info.Enums) + } + } + + if len(t.Pattern) > 0 { + info.Pattern = strings.Join(t.Pattern, "|") + } + + if len(t.Range) > 0 { + info.Range = t.Range.String() + } + + return info +} + +// splitPrefix splits "module:name" into ("module", "name"). +// If there is no prefix, it returns ("", name). +func splitPrefix(s string) (prefix, name string) { + if i := strings.Index(s, ":"); i >= 0 { + return s[:i], s[i+1:] + } + return "", s +} + +// sortedEntries returns the entries in a Dir map sorted by name. +func sortedEntries(dir map[string]*yang.Entry) []*yang.Entry { + keys := make([]string, 0, len(dir)) + for k := range dir { + keys = append(keys, k) + } + sort.Strings(keys) + result := make([]*yang.Entry, 0, len(keys)) + for _, k := range keys { + result = append(result, dir[k]) + } + return result +} diff --git a/src/webui/internal/schema/refresh.go b/src/webui/internal/schema/refresh.go new file mode 100644 index 00000000..86dfdb87 --- /dev/null +++ b/src/webui/internal/schema/refresh.go @@ -0,0 +1,126 @@ +package schema + +import ( + "context" + "fmt" + "log" + "os" + "sync" + + "infix/webui/internal/restconf" +) + +// Cache holds a lazily-loaded schema Manager and refreshes it at startup. +// All methods are safe for concurrent use. +type Cache struct { + mu sync.RWMutex + manager *Manager + syncing bool // guarded by mu + dir string + rc restconf.Fetcher +} + +// NewCache creates a Cache. +// Call LoadFromCacheBackground at startup, then RefreshBackground after login. +func NewCache(rc restconf.Fetcher, dir string) *Cache { + return &Cache{rc: rc, dir: dir} +} + +// LoadFromCache parses whatever .yang files are already in the cache +// directory. It makes no HTTP requests and needs no credentials. +// This is fast — suitable for server startup. If the directory is empty +// or has too few files to form a useful schema, the Manager is left nil. +func (c *Cache) LoadFromCache() error { + entries, err := os.ReadDir(c.dir) + if err != nil { + if os.IsNotExist(err) { + return nil // nothing cached yet — that is fine + } + return err + } + var count int + for _, e := range entries { + if !e.IsDir() && len(e.Name()) > 5 { // len(".yang") == 5 + if e.Name()[len(e.Name())-5:] == ".yang" { + count++ + } + } + } + if count == 0 { + return nil // empty cache — wait for first Refresh + } + + mgr, err := Load(c.dir) + if err != nil { + return fmt.Errorf("schema: load from cache: %w", err) + } + c.mu.Lock() + c.manager = mgr + c.mu.Unlock() + log.Printf("schema: loaded %d cached YANG file(s) from %s", count, c.dir) + return nil +} + +// LoadFromCacheBackground calls LoadFromCache in a goroutine. Errors are logged. +func (c *Cache) LoadFromCacheBackground() { + go func() { + if err := c.LoadFromCache(); err != nil { + log.Printf("schema: cache load failed: %v", err) + } + }() +} + +// Refresh downloads any missing YANG files from the device (credentials must +// be present in ctx) and then reloads the schema Manager. +// Only one refresh runs at a time; concurrent calls return immediately. +func (c *Cache) Refresh(ctx context.Context) error { + c.mu.Lock() + if c.syncing { + c.mu.Unlock() + return nil + } + c.syncing = true + c.mu.Unlock() + + defer func() { + c.mu.Lock() + c.syncing = false + c.mu.Unlock() + }() + + if _, err := FetchModules(ctx, c.rc, c.dir); err != nil { + return fmt.Errorf("schema refresh: fetch: %w", err) + } + + mgr, err := Load(c.dir) + if err != nil { + return fmt.Errorf("schema refresh: load: %w", err) + } + + c.mu.Lock() + c.manager = mgr + c.mu.Unlock() + + log.Printf("schema: refreshed successfully from %s", c.dir) + return nil +} + +// RefreshBackground calls Refresh in a goroutine. Errors are logged. +// The context's values (credentials) are preserved but its cancellation is +// detached so the goroutine is not killed when the originating HTTP request +// completes. +func (c *Cache) RefreshBackground(ctx context.Context) { + detached := context.WithoutCancel(ctx) + go func() { + if err := c.Refresh(detached); err != nil { + log.Printf("schema: background refresh failed: %v", err) + } + }() +} + +// Manager returns the current Manager, or nil if not yet loaded. +func (c *Cache) Manager() *Manager { + c.mu.RLock() + defer c.mu.RUnlock() + return c.manager +} diff --git a/src/webui/internal/schema/types.go b/src/webui/internal/schema/types.go new file mode 100644 index 00000000..2309e0b0 --- /dev/null +++ b/src/webui/internal/schema/types.go @@ -0,0 +1,41 @@ +package schema + +// Node is a JSON-serialisable representation of a single YANG schema node. +// Children are omitted (nil) by default; use Manager.Children to lazy-load them. +type Node struct { + Path string `json:"path"` + Name string `json:"name"` + Kind string `json:"kind"` // container|list|leaf|leaf-list|choice|case|rpc|notification + Description string `json:"description,omitempty"` + Keys []string `json:"keys,omitempty"` + Children []*Node `json:"children,omitempty"` + Config bool `json:"config"` + Mandatory bool `json:"mandatory"` + Default string `json:"default,omitempty"` + Type *TypeInfo `json:"type,omitempty"` + // When holds the pre-resolved YANG when expression (prefix aliases replaced + // by canonical module names). Empty when there is no constraint. + When string `json:"when,omitempty"` + // Presence is non-empty for presence containers; its value is the YANG + // presence statement string (describes what the container's existence means). + Presence string `json:"presence,omitempty"` +} + +// TypeInfo describes the type of a leaf or leaf-list node. +type TypeInfo struct { + Kind string `json:"kind"` // string|boolean|int8..uint64|enumeration|identityref|leafref|binary|empty|... + Enums []string `json:"enums,omitempty"` // enumeration values + Identities []string `json:"identities,omitempty"` // identityref derived identity names + Range string `json:"range,omitempty"` + Pattern string `json:"pattern,omitempty"` + // Leafref is the leafref target path with YANG prefix aliases + // resolved to canonical "module:name" form, so it can be fed + // straight to mgr.NodeAt / RESTCONF without further mapping. + Leafref string `json:"leafref,omitempty"` + // LeafrefSibling is the sibling-leaf or sibling-container name + // extracted from the leaf's `must "deref(.)/../<X>"` constraint, + // if any. Callers filter the dropdown to those leafref targets + // whose parent object exposes this sibling — the common idiom + // for "this leafref must point at an object of kind <X>". + LeafrefSibling string `json:"leafref-sibling,omitempty"` +} diff --git a/src/webui/internal/schema/when.go b/src/webui/internal/schema/when.go new file mode 100644 index 00000000..4821db62 --- /dev/null +++ b/src/webui/internal/schema/when.go @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: MIT + +package schema + +import "strings" + +// EvaluateWhen evaluates a pre-resolved YANG when XPath expression against +// the flat key→value map of the context node's parent data. +// +// Handles the practical subset used in Infix YANG models: +// - derived-from-or-self(path, 'module:identity') +// - derived-from(path, 'module:identity') +// - combinations joined with 'or' / 'and' +// +// Conservative: any unrecognised expression or absent data returns true (show). +func EvaluateWhen(mgr *Manager, expr string, values map[string]string) bool { + if expr == "" || values == nil { + return true + } + return evalOr(mgr, strings.TrimSpace(expr), values) +} + +func evalOr(mgr *Manager, expr string, values map[string]string) bool { + parts := splitOnKeyword(expr, "or") + for _, p := range parts { + if evalAnd(mgr, strings.TrimSpace(p), values) { + return true + } + } + return false +} + +func evalAnd(mgr *Manager, expr string, values map[string]string) bool { + parts := splitOnKeyword(expr, "and") + for _, p := range parts { + if !evalAtom(mgr, strings.TrimSpace(p), values) { + return false + } + } + return true +} + +func evalAtom(mgr *Manager, expr string, values map[string]string) bool { + if expr == "" { + return true // empty: conservative + } + if strings.HasPrefix(expr, "derived-from-or-self(") && strings.HasSuffix(expr, ")") { + inner := expr[len("derived-from-or-self(") : len(expr)-1] + return evalDerivedFrom(mgr, inner, values, true) + } + if strings.HasPrefix(expr, "derived-from(") && strings.HasSuffix(expr, ")") { + inner := expr[len("derived-from(") : len(expr)-1] + return evalDerivedFrom(mgr, inner, values, false) + } + return true // unknown expression: conservative show +} + +func evalDerivedFrom(mgr *Manager, inner string, values map[string]string, orSelf bool) bool { + comma := strings.Index(inner, ",") + if comma < 0 { + return true // malformed: conservative + } + xpathPath := strings.TrimSpace(inner[:comma]) + identity := strings.Trim(strings.TrimSpace(inner[comma+1:]), "'\"") + + leafName := xpathLeafName(xpathPath) + if leafName == "" { + return true // unresolvable path: conservative + } + + current := values[leafName] + if current == "" { + return true // no data: conservative show + } + return checkIdentity(mgr, current, identity, orSelf) +} + +// xpathLeafName extracts the bare leaf name from a simple XPath step. +// - "module:name" or "name" → "name" +// - "../module:name" → "name" (single parent step) +// - "../../../…" or path with "/" → "" (multi-level: conservative) +func xpathLeafName(path string) string { + if strings.HasPrefix(path, "../") { + path = path[3:] + // After one parent step, path must be a bare leaf name. + if strings.Contains(path, "/") || strings.HasPrefix(path, "..") { + return "" + } + } else if strings.Contains(path, "/") { + return "" // forward traversal: conservative + } + _, local := splitPrefix(strings.TrimSpace(path)) + return local +} + +// checkIdentity reports whether currentValue is equal to or derived from +// targetIdentity according to the YANG identity hierarchy. +func checkIdentity(mgr *Manager, currentValue, targetIdentity string, orSelf bool) bool { + _, currentLocal := splitPrefix(currentValue) + _, targetLocal := splitPrefix(targetIdentity) + + if orSelf && currentLocal == targetLocal { + return true + } + for _, d := range mgr.IdentitiesOf(targetLocal) { + if d == currentLocal { + return true + } + } + return false +} + +// splitOnKeyword splits expr on the XPath keyword kw at parenthesis depth 0, +// but NOT when kw is part of a compound name (e.g. "derived-from-or-self" +// contains "or"; "derived-from-and-…" would contain "and"). +// A keyword match is suppressed when immediately preceded by a hyphen. +// +// The Infix YANG files contain a concatenation bug where the 'or' keyword is +// immediately followed by the next function name without a separating space +// (e.g. "…') orderived-from-or-self(…)"). This splitter handles that case +// by treating any 'or'/'and' that is not preceded by '-' as a keyword. +func splitOnKeyword(expr, kw string) []string { + n := len(kw) + var parts []string + depth := 0 + start := 0 + for i := 0; i < len(expr); i++ { + switch expr[i] { + case '(': + depth++ + case ')': + depth-- + } + if depth != 0 || i+n > len(expr) || expr[i:i+n] != kw { + continue + } + // Suppress if immediately preceded by '-' (part of compound name). + if i > 0 && expr[i-1] == '-' { + continue + } + // Found keyword: save part, trim trailing space before keyword. + end := i + if end > start && expr[end-1] == ' ' { + end-- + } + parts = append(parts, strings.TrimSpace(expr[start:end])) + start = i + n + if start < len(expr) && expr[start] == ' ' { + start++ + } + i = start - 1 // loop will increment + } + parts = append(parts, strings.TrimSpace(expr[start:])) + return parts +} diff --git a/src/webui/internal/schema/when_test.go b/src/webui/internal/schema/when_test.go new file mode 100644 index 00000000..72042bb6 --- /dev/null +++ b/src/webui/internal/schema/when_test.go @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: MIT + +package schema + +import ( + "reflect" + "testing" +) + +func TestSplitOnKeyword(t *testing.T) { + tests := []struct { + name string + expr string + kw string + want []string + }{ + { + name: "single_term_no_split", + expr: "derived-from-or-self(if:type, 'gre')", + kw: "or", + want: []string{"derived-from-or-self(if:type, 'gre')"}, + }, + { + name: "two_terms_with_space", + expr: "derived-from-or-self(if:type, 'gre') or derived-from-or-self(if:type, 'gretap')", + kw: "or", + want: []string{ + "derived-from-or-self(if:type, 'gre')", + "derived-from-or-self(if:type, 'gretap')", + }, + }, + { + name: "two_terms_no_space_after_or", // Infix YANG concatenation bug + expr: "derived-from-or-self(if:type, 'gre') orderived-from-or-self(if:type, 'gretap')", + kw: "or", + want: []string{ + "derived-from-or-self(if:type, 'gre')", + "derived-from-or-self(if:type, 'gretap')", + }, + }, + { + name: "or_inside_parens_not_split", + expr: "derived-from-or-self(if:type, 'foo or bar')", + kw: "or", + want: []string{"derived-from-or-self(if:type, 'foo or bar')"}, + }, + { + name: "and_keyword", + expr: "cond-a and cond-b", + kw: "and", + want: []string{"cond-a", "cond-b"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := splitOnKeyword(tt.expr, tt.kw) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("splitOnKeyword(%q, %q) = %v; want %v", tt.expr, tt.kw, got, tt.want) + } + }) + } +} + +func TestXpathLeafName(t *testing.T) { + tests := []struct { + path string + want string + }{ + {"if:type", "type"}, + {"ietf-interfaces:type", "type"}, + {"type", "type"}, + {"../iehw:class", "class"}, + {"../infix-hardware:class", "class"}, + {"../../something", ""}, + {"../../../rt:address-family", ""}, + {"a/b", ""}, + {"/abs/path", ""}, + } + for _, tt := range tests { + got := xpathLeafName(tt.path) + if got != tt.want { + t.Errorf("xpathLeafName(%q) = %q; want %q", tt.path, got, tt.want) + } + } +} + +func TestEvaluateWhenConservative(t *testing.T) { + // nil values → conservative true + if !EvaluateWhen(nil, "derived-from-or-self(if:type, 'foo')", nil) { + t.Error("nil values should be conservative true") + } + // empty expr → true + if !EvaluateWhen(nil, "", map[string]string{"type": "bar"}) { + t.Error("empty expr should be conservative true") + } + // nil manager with unknown expr → true + if !EvaluateWhen(nil, "unknown-function(x)", map[string]string{"x": "y"}) { + t.Error("unknown expr should be conservative true") + } +} + +func TestEvaluateWhenDerivedFromOrSelf(t *testing.T) { + // Minimal mock manager: IdentitiesOf("gre") → ["gretap"] + mgr := &Manager{ms: nil} // ms is nil, but IdentitiesOf will handle it gracefully + + // We need a real identity hierarchy. Use a Manager with minimal modules. + // Since setting up goyang modules is complex, test via checkIdentity directly. + tests := []struct { + name string + current string + target string + orSelf bool + derived []string // what IdentitiesOf would return; we test checkIdentity + want bool + }{ + {"or-self exact match", "gre", "gre", true, nil, true}, + {"or-self different", "bridge", "gre", true, nil, false}, + {"derived match", "gretap", "gre", false, []string{"gretap"}, true}, + {"derived no match", "bridge", "gre", false, []string{"gretap"}, false}, + {"module-qualified current", "infix-if-type:gre", "gre", true, nil, true}, + {"module-qualified target", "gre", "infix-if-type:gre", true, nil, true}, + {"both qualified exact", "infix-if-type:gre", "infix-if-type:gre", true, nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Override IdentitiesOf by testing checkIdentity directly with a mock. + // Build a mini-mgr that returns tt.derived for any base name. + got := checkIdentityTest(tt.current, tt.target, tt.orSelf, tt.derived) + if got != tt.want { + t.Errorf("checkIdentity(%q, %q, orSelf=%v) = %v; want %v", + tt.current, tt.target, tt.orSelf, got, tt.want) + } + }) + } + _ = mgr +} + +// checkIdentityTest is a test helper that bypasses the Manager.IdentitiesOf call. +func checkIdentityTest(currentValue, targetIdentity string, orSelf bool, derivedNames []string) bool { + _, currentLocal := splitPrefix(currentValue) + _, targetLocal := splitPrefix(targetIdentity) + if orSelf && currentLocal == targetLocal { + return true + } + for _, d := range derivedNames { + if d == currentLocal { + return true + } + } + return false +} diff --git a/src/webui/internal/security/csrf.go b/src/webui/internal/security/csrf.go new file mode 100644 index 00000000..a022192d --- /dev/null +++ b/src/webui/internal/security/csrf.go @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: MIT + +package security + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "io" + "net/http" + "strings" +) + +const csrfCookieName = "csrf" + +type csrfKey struct{} + +// EnsureToken sets a CSRF cookie if missing (or preferred is provided) +// and returns the current token. +func EnsureToken(w http.ResponseWriter, r *http.Request, preferred string) string { + if token := strings.TrimSpace(preferred); token != "" { + http.SetCookie(w, &http.Cookie{ + Name: csrfCookieName, + Value: token, + Path: "/", + HttpOnly: true, + Secure: IsSecureRequest(r), + SameSite: http.SameSiteStrictMode, + }) + return token + } + + if c, err := r.Cookie(csrfCookieName); err == nil { + if token := strings.TrimSpace(c.Value); validToken(token) { + return token + } + } + + token, err := RandomToken() + if err != nil { + return "" + } + http.SetCookie(w, &http.Cookie{ + Name: csrfCookieName, + Value: token, + Path: "/", + HttpOnly: true, + Secure: IsSecureRequest(r), + SameSite: http.SameSiteStrictMode, + }) + return token +} + +// WithToken stores the token in the request context. +func WithToken(ctx context.Context, token string) context.Context { + return context.WithValue(ctx, csrfKey{}, token) +} + +// TokenFromContext returns the CSRF token from context, if set. +func TokenFromContext(ctx context.Context) string { + if v, ok := ctx.Value(csrfKey{}).(string); ok { + return v + } + return "" +} + +// RandomToken returns 32 bytes of crypto/rand, base64-url-encoded. +// Used for both CSRF tokens and session-store entries. +func RandomToken() (string, error) { + var b [32]byte + if _, err := io.ReadFull(rand.Reader, b[:]); err != nil { + return "", fmt.Errorf("read random: %w", err) + } + return base64.RawURLEncoding.EncodeToString(b[:]), nil +} + +func validToken(token string) bool { + if token == "" { + return false + } + _, err := base64.RawURLEncoding.DecodeString(token) + return err == nil +} + +// ClearToken removes the CSRF cookie. +func ClearToken(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{ + Name: csrfCookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: IsSecureRequest(r), + SameSite: http.SameSiteStrictMode, + }) +} + +// IsSecureRequest returns true for TLS or proxy-terminated HTTPS. +func IsSecureRequest(r *http.Request) bool { + if r.TLS != nil { + return true + } + if xf := r.Header.Get("X-Forwarded-Proto"); xf != "" { + parts := strings.Split(xf, ",") + return strings.EqualFold(strings.TrimSpace(parts[0]), "https") + } + return false +} diff --git a/src/webui/internal/server/middleware.go b/src/webui/internal/server/middleware.go new file mode 100644 index 00000000..1db03dee --- /dev/null +++ b/src/webui/internal/server/middleware.go @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: MIT + +package server + +import ( + "net/http" + "net/url" + "strings" + + "infix/webui/internal/auth" + "infix/webui/internal/handlers" + "infix/webui/internal/restconf" + "infix/webui/internal/security" +) + +const cookieName = "session" + +// authMiddleware checks the session cookie on every request, looks +// the token up in the in-memory store, and attaches the session's +// credentials to the context. Unauthenticated requests are +// redirected to /login (or get a 401 if the request comes from HTMX). +func authMiddleware(store *auth.SessionStore, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if isPublicPath(r.URL.Path) { + next.ServeHTTP(w, r) + return + } + + cookie, err := r.Cookie(cookieName) + if err != nil { + deny(w, r) + return + } + + username, password, csrf, features, ok := store.Lookup(cookie.Value) + if !ok { + deny(w, r) + return + } + + // Sliding window: extend the session on user-driven requests. + // Background polling endpoints are excluded so an idle user + // (just leaving the dashboard open in a tab) still hits the + // timeout. + if !isPollingPath(r.URL.Path) { + store.Refresh(cookie.Value) + } + + ctx := restconf.ContextWithCredentials(r.Context(), restconf.Credentials{ + Username: username, + Password: password, + }) + ctx = security.WithToken(ctx, csrf) + ctx = handlers.ContextWithCapabilities(ctx, handlers.NewCapabilities(features)) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func csrfMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token := security.TokenFromContext(r.Context()) + token = security.EnsureToken(w, r, token) + r = r.WithContext(security.WithToken(r.Context(), token)) + + switch r.Method { + case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace: + next.ServeHTTP(w, r) + return + } + + if !sameOrigin(r) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + if !validCSRF(r, token) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + next.ServeHTTP(w, r) + }) +} + +func sameOrigin(r *http.Request) bool { + host := r.Host + if xf := r.Header.Get("X-Forwarded-Host"); xf != "" { + parts := strings.Split(xf, ",") + host = strings.TrimSpace(parts[0]) + } + if host == "" { + return false + } + + origin := r.Header.Get("Origin") + if origin != "" { + u, err := url.Parse(origin) + if err != nil { + return false + } + return strings.EqualFold(u.Host, host) + } + + ref := r.Header.Get("Referer") + if ref != "" { + u, err := url.Parse(ref) + if err != nil { + return false + } + return strings.EqualFold(u.Host, host) + } + + return true +} + +func validCSRF(r *http.Request, token string) bool { + if token == "" { + return false + } + if hdr := r.Header.Get("X-CSRF-Token"); hdr != "" { + return subtleConstantTimeEquals(hdr, token) + } + if err := r.ParseForm(); err != nil { + return false + } + return subtleConstantTimeEquals(r.FormValue("csrf"), token) +} + +func subtleConstantTimeEquals(a, b string) bool { + if len(a) != len(b) { + return false + } + var diff byte + for i := 0; i < len(a); i++ { + diff |= a[i] ^ b[i] + } + return diff == 0 +} + +func securityHeadersMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") + w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()") + w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'") + if security.IsSecureRequest(r) { + w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") + } + next.ServeHTTP(w, r) + }) +} + +func isPublicPath(path string) bool { + return path == "/login" || strings.HasPrefix(path, "/assets/") +} + +func isPollingPath(path string) bool { + return path == "/device-status" || strings.HasSuffix(path, "/counters") +} + +func deny(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("HX-Request") == "true" { + // HX-Redirect is honored by HTMX even on a 401 response, so + // the page navigates to /login instead of leaving the user + // staring at a stale screen where every click silently fails. + w.Header().Set("HX-Redirect", "/login") + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + http.Redirect(w, r, "/login", http.StatusSeeOther) +} diff --git a/src/webui/internal/server/server.go b/src/webui/internal/server/server.go new file mode 100644 index 00000000..0db03db6 --- /dev/null +++ b/src/webui/internal/server/server.go @@ -0,0 +1,482 @@ +// SPDX-License-Identifier: MIT + +package server + +import ( + "context" + "fmt" + "html/template" + "io/fs" + "net/http" + + "infix/webui/internal/auth" + "infix/webui/internal/handlers" + "infix/webui/internal/restconf" + "infix/webui/internal/schema" +) + +// New creates a fully wired http.Handler with all routes and middleware. +func New( + store *auth.SessionStore, + rc *restconf.Client, + schemaCache *schema.Cache, + templateFS fs.FS, + staticFS fs.FS, +) (http.Handler, error) { + // Parse templates per page so each can define its own "content" block + // without collisions. + loginTmpl, err := template.ParseFS(templateFS, "layouts/icons.html", "pages/login.html") + if err != nil { + return nil, err + } + dashTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/dashboard.html") + if err != nil { + return nil, err + } + fwTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/firewall.html") + if err != nil { + return nil, err + } + ksTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "pages/configure-keystore.html") + if err != nil { + return nil, err + } + ifTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/interfaces.html") + if err != nil { + return nil, err + } + ifDetailTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/iface-detail.html", "fragments/iface-counters.html") + if err != nil { + return nil, err + } + ifCountersTmpl, err := template.ParseFS(templateFS, "fragments/iface-counters.html") + if err != nil { + return nil, err + } + swTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/software.html") + if err != nil { + return nil, err + } + sysCtrlTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/system-control.html") + if err != nil { + return nil, err + } + backupTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/backup.html") + if err != nil { + return nil, err + } + identityTmpl, err := template.ParseFS(templateFS, "fragments/topbar-identity.html") + if err != nil { + return nil, err + } + logsTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/logs.html") + if err != nil { + return nil, err + } + diagTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/diagnostics.html") + if err != nil { + return nil, err + } + routingTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/routing.html") + if err != nil { + return nil, err + } + hwTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/hardware.html") + if err != nil { + return nil, err + } + wifiTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/wifi.html") + if err != nil { + return nil, err + } + vpnTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/vpn.html") + if err != nil { + return nil, err + } + dhcpTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/dhcp.html") + if err != nil { + return nil, err + } + ntpTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/ntp.html") + if err != nil { + return nil, err + } + lldpTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/lldp.html") + if err != nil { + return nil, err + } + mdnsTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/mdns.html") + if err != nil { + return nil, err + } + nacmTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/nacm.html") + if err != nil { + return nil, err + } + servicesTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/services.html") + if err != nil { + return nil, err + } + containersTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/containers.html") + if err != nil { + return nil, err + } + cfgSysTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "pages/configure-system.html") + if err != nil { + return nil, err + } + cfgNTPTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "pages/configure-ntp.html") + if err != nil { + return nil, err + } + cfgDNSTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "pages/configure-dns.html") + if err != nil { + return nil, err + } + cfgUsersTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "pages/configure-users.html") + if err != nil { + return nil, err + } + cfgRoutesTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "pages/configure-routes.html") + if err != nil { + return nil, err + } + cfgFwTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "pages/configure-firewall.html") + if err != nil { + return nil, err + } + cfgHwTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "pages/configure-hardware.html") + if err != nil { + return nil, err + } + ifFuncs := template.FuncMap{ + "shortPMD": handlers.ShortenPMD, + "add": func(a, b int) int { return a + b }, + "deref": func(v any) any { + switch p := v.(type) { + case *bool: + if p != nil { + return *p + } + case *uint32: + if p != nil { + return *p + } + case *int: + if p != nil { + return *p + } + } + return nil + }, + // dict lets callers pass keyed args to nested templates, e.g. + // {{template "foo" (dict "Key" .X "Selected" "")}}. + "dict": func(values ...any) (map[string]any, error) { + if len(values)%2 != 0 { + return nil, fmt.Errorf("dict: odd argument count") + } + m := make(map[string]any, len(values)/2) + for i := 0; i < len(values); i += 2 { + k, ok := values[i].(string) + if !ok { + return nil, fmt.Errorf("dict: non-string key at position %d", i) + } + m[k] = values[i+1] + } + return m, nil + }, + } + cfgIfTmpl, err := template.New("").Funcs(ifFuncs).ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "fragments/wizard-psk-picker.html", "fragments/wizard-wgkey-picker.html", "fragments/wizard-radio-picker.html", "pages/configure-interfaces.html") + if err != nil { + return nil, err + } + yangTreeTmpl, err := template.ParseFS(templateFS, + "layouts/*.html", + "fragments/configure-toolbar.html", + "fragments/yang-tree-node.html", + "pages/yang-tree.html") + if err != nil { + return nil, err + } + yangFuncs := template.FuncMap{"stripPrefix": schema.StripModulePrefix} + yangFragTmpl, err := template.New("frag").Funcs(yangFuncs).ParseFS(templateFS, + "fragments/yang-tree-node.html", + "fragments/yang-node-detail.html", + "fragments/yang-leaf-group.html", + "fragments/yang-list-table.html", + "layouts/icons.html") + if err != nil { + return nil, err + } + login := &auth.LoginHandler{ + Store: store, + RC: rc, + Template: loginTmpl, + OnLogin: func(ctx context.Context) { + schemaCache.RefreshBackground(ctx) + }, + } + + dash := &handlers.DashboardHandler{ + Template: dashTmpl, + RC: rc, + } + + fw := &handlers.FirewallHandler{ + Template: fwTmpl, + RC: rc, + } + + cfgKs := &handlers.ConfigureKeystoreHandler{ + Template: ksTmpl, + RC: rc, + Schema: schemaCache, + } + + iface := &handlers.InterfacesHandler{ + Template: ifTmpl, + DetailTemplate: ifDetailTmpl, + CountersTemplate: ifCountersTmpl, + RC: rc, + Schema: schemaCache, + } + + sys := &handlers.SystemHandler{ + RC: rc, + Template: swTmpl, + SysCtrlTmpl: sysCtrlTmpl, + BackupTmpl: backupTmpl, + IdentityTmpl: identityTmpl, + } + logs := &handlers.LogsHandler{Template: logsTmpl} + diag := &handlers.DiagnosticsHandler{RC: rc, Template: diagTmpl} + + routing := &handlers.RoutingHandler{Template: routingTmpl, RC: rc} + wifi := &handlers.WiFiHandler{Template: wifiTmpl, RC: rc} + hw := &handlers.HardwareHandler{Template: hwTmpl, RC: rc} + vpn := &handlers.VPNHandler{Template: vpnTmpl, RC: rc} + dhcp := &handlers.DHCPHandler{Template: dhcpTmpl, RC: rc} + ntp := &handlers.NTPHandler{Template: ntpTmpl, RC: rc} + lldp := &handlers.LLDPHandler{Template: lldpTmpl, RC: rc} + mdns := &handlers.MDNSHandler{Template: mdnsTmpl, RC: rc} + nacm := &handlers.NACMHandler{Template: nacmTmpl, RC: rc} + services := &handlers.ServicesHandler{Template: servicesTmpl, RC: rc} + containers := &handlers.ContainersHandler{Template: containersTmpl, RC: rc} + cfg := &handlers.ConfigureHandler{RC: rc} + cfgSys := &handlers.ConfigureSystemHandler{ + Template: cfgSysTmpl, + NTPTemplate: cfgNTPTmpl, + DNSTemplate: cfgDNSTmpl, + RC: rc, + Schema: schemaCache, + } + cfgUsers := &handlers.ConfigureUsersHandler{Template: cfgUsersTmpl, RC: rc, Schema: schemaCache} + cfgRoutes := &handlers.ConfigureRoutesHandler{Template: cfgRoutesTmpl, RC: rc, Schema: schemaCache} + cfgFw := &handlers.ConfigureFirewallHandler{Template: cfgFwTmpl, RC: rc, Schema: schemaCache} + cfgHw := &handlers.ConfigureHardwareHandler{Template: cfgHwTmpl, RC: rc, Schema: schemaCache} + cfgIf := &handlers.ConfigureInterfacesHandler{Template: cfgIfTmpl, RC: rc, Schema: schemaCache} + schemaH := &handlers.SchemaHandler{Cache: schemaCache} + dataH := &handlers.DataHandler{RC: rc, Schema: schemaCache} + treeH := &handlers.TreeHandler{ + Cache: schemaCache, + RC: rc, + PageTmpl: yangTreeTmpl, + FragTmpl: yangFragTmpl, + } + statusTreeH := &handlers.TreeHandler{ + Cache: schemaCache, + RC: rc, + PageTmpl: yangTreeTmpl, + FragTmpl: yangFragTmpl, + ReadOnly: true, + } + + mux := http.NewServeMux() + + // Auth routes (public). + mux.HandleFunc("GET /login", login.ShowLogin) + mux.HandleFunc("POST /login", login.DoLogin) + mux.HandleFunc("POST /logout", login.DoLogout) + mux.HandleFunc("POST /session/timeout", login.SetSessionTimeout) + + // Static assets (public). + staticServer := http.FileServerFS(staticFS) + mux.Handle("GET /assets/", http.StripPrefix("/assets/", staticServer)) + + // Authenticated routes. + mux.HandleFunc("GET /{$}", dash.Index) + mux.HandleFunc("GET /dashboard/reachability", dash.Reachability) + mux.HandleFunc("GET /interfaces", iface.Overview) + mux.HandleFunc("GET /interfaces/{name}", iface.Detail) + mux.HandleFunc("GET /interfaces/{name}/counters", iface.Counters) + mux.HandleFunc("GET /firewall", fw.Overview) + mux.HandleFunc("GET /software", sys.Software) + mux.HandleFunc("GET /software/progress", sys.SoftwareProgress) + mux.HandleFunc("POST /software/install", sys.SoftwareInstall) + mux.HandleFunc("POST /software/upload", sys.SoftwareUpload) + mux.HandleFunc("POST /software/boot-order", sys.SetBootOrder) + mux.HandleFunc("POST /reboot", sys.Reboot) // kept for software page "Reboot to activate" + mux.HandleFunc("GET /config", sys.DownloadConfig) + mux.HandleFunc("GET /maintenance/logs", logs.Overview) + mux.HandleFunc("GET /maintenance/logs/{name}", logs.Fragment) + mux.HandleFunc("GET /maintenance/logs/{name}/earlier", logs.Earlier) + mux.HandleFunc("GET /maintenance/logs/{name}/tail", logs.Tail) + mux.HandleFunc("GET /maintenance/logs/{name}/download", logs.Download) + mux.HandleFunc("GET /maintenance/diagnostics", diag.Overview) + mux.HandleFunc("GET /maintenance/diagnostics/run", diag.Run) + mux.HandleFunc("GET /maintenance/diagnostics/resolve", diag.Resolve) + mux.HandleFunc("GET /maintenance/backup", sys.Backup) + mux.HandleFunc("POST /maintenance/backup/restore", sys.RestoreConfig) + mux.HandleFunc("POST /maintenance/support-bundle", sys.SupportBundle) + mux.HandleFunc("GET /maintenance/system", sys.SystemControl) + mux.HandleFunc("POST /maintenance/system/reboot", sys.Reboot) + mux.HandleFunc("POST /maintenance/system/shutdown", sys.Shutdown) + mux.HandleFunc("POST /maintenance/system/factory-default", sys.FactoryDefault) + mux.HandleFunc("POST /maintenance/system/factory-reset", sys.FactoryReset) + mux.HandleFunc("POST /maintenance/system/datetime", sys.SetDatetime) + mux.HandleFunc("GET /routing", routing.Overview) + mux.HandleFunc("GET /wifi", wifi.Overview) + mux.HandleFunc("GET /hardware", hw.Overview) + mux.HandleFunc("GET /vpn", vpn.Overview) + mux.HandleFunc("GET /dhcp", dhcp.Overview) + mux.HandleFunc("GET /ntp", ntp.Overview) + mux.HandleFunc("GET /lldp", lldp.Overview) + mux.HandleFunc("GET /mdns", mdns.Overview) + mux.HandleFunc("GET /nacm", nacm.Overview) + mux.HandleFunc("GET /services", services.Overview) + mux.HandleFunc("GET /containers", containers.Overview) + mux.HandleFunc("GET /identity", sys.Identity) + + // Configure routes. + mux.HandleFunc("POST /configure/enter", cfg.Enter) + mux.HandleFunc("POST /configure/apply", cfg.Apply) + mux.HandleFunc("POST /configure/apply-and-save", cfg.ApplyAndSave) + mux.HandleFunc("POST /configure/abort", cfg.Abort) + mux.HandleFunc("POST /configure/save", cfg.Save) + mux.HandleFunc("DELETE /configure/leaf", cfg.DeleteLeaf) + mux.HandleFunc("GET /configure/system", cfgSys.Overview) + mux.HandleFunc("GET /configure/ntp", cfgSys.OverviewNTP) + mux.HandleFunc("GET /configure/dns", cfgSys.OverviewDNS) + mux.HandleFunc("POST /configure/system/identity", cfgSys.SaveIdentity) + mux.HandleFunc("POST /configure/system/clock", cfgSys.SaveClock) + mux.HandleFunc("POST /configure/system/ntp/servers", cfgSys.AddNTPServer) + mux.HandleFunc("POST /configure/system/ntp/servers/{name}", cfgSys.SaveNTPServer) + mux.HandleFunc("DELETE /configure/system/ntp/servers/{name}", cfgSys.DeleteNTPServer) + mux.HandleFunc("POST /configure/system/dns/servers", cfgSys.AddDNSServer) + mux.HandleFunc("POST /configure/system/dns/servers/{name}", cfgSys.SaveDNSServer) + mux.HandleFunc("DELETE /configure/system/dns/servers/{name}", cfgSys.DeleteDNSServer) + mux.HandleFunc("POST /configure/system/dns/search", cfgSys.AddDNSSearch) + mux.HandleFunc("DELETE /configure/system/dns/search/{domain}", cfgSys.DeleteDNSSearch) + mux.HandleFunc("POST /configure/system/preferences", cfgSys.SavePreferences) + mux.HandleFunc("GET /configure/interfaces", cfgIf.Overview) + mux.HandleFunc("POST /configure/interfaces", cfgIf.CreateInterface) + mux.HandleFunc("POST /configure/interfaces/wizard/sym-key", cfgIf.WizardCreateSymKey) + mux.HandleFunc("POST /configure/interfaces/wizard/asym-key", cfgIf.WizardCreateAsymKey) + mux.HandleFunc("POST /configure/interfaces/wizard/wg-genkey", cfgIf.WizardGenerateWGKey) + mux.HandleFunc("POST /configure/interfaces/wizard/radio", cfgIf.WizardCreateRadio) + mux.HandleFunc("POST /configure/interfaces/{name}", cfgIf.SaveGeneral) + mux.HandleFunc("DELETE /configure/interfaces/{name}", cfgIf.DeleteInterface) + mux.HandleFunc("POST /configure/interfaces/{name}/ipv4", cfgIf.AddIPv4) + mux.HandleFunc("DELETE /configure/interfaces/{name}/ipv4/{ip}", cfgIf.DeleteIPv4) + mux.HandleFunc("POST /configure/interfaces/{name}/ipv4/settings", cfgIf.SaveIPv4Settings) + mux.HandleFunc("POST /configure/interfaces/{name}/ipv4/dhcp/settings", cfgIf.SaveIPv4DHCPSettings) + mux.HandleFunc("POST /configure/interfaces/{name}/ipv4/dhcp/options", cfgIf.AddIPv4DHCPOption) + mux.HandleFunc("DELETE /configure/interfaces/{name}/ipv4/dhcp/options/{id}", cfgIf.DeleteIPv4DHCPOption) + mux.HandleFunc("POST /configure/interfaces/{name}/ipv6", cfgIf.AddIPv6) + mux.HandleFunc("DELETE /configure/interfaces/{name}/ipv6/{ip}", cfgIf.DeleteIPv6) + mux.HandleFunc("POST /configure/interfaces/{name}/ipv6/settings", cfgIf.SaveIPv6Settings) + mux.HandleFunc("POST /configure/interfaces/{name}/ipv6/dhcp/settings", cfgIf.SaveIPv6DHCPSettings) + mux.HandleFunc("POST /configure/interfaces/{name}/ipv6/dhcp/options", cfgIf.AddIPv6DHCPOption) + mux.HandleFunc("DELETE /configure/interfaces/{name}/ipv6/dhcp/options/{id}", cfgIf.DeleteIPv6DHCPOption) + mux.HandleFunc("POST /configure/interfaces/{name}/ethernet", cfgIf.SaveEthernet) + mux.HandleFunc("DELETE /configure/interfaces/{name}/ethernet/advertised", cfgIf.ResetEthernetAdvertised) + mux.HandleFunc("POST /configure/interfaces/{name}/bridge-port", cfgIf.SaveBridgePort) + mux.HandleFunc("DELETE /configure/interfaces/{name}/bridge-port", cfgIf.DeleteBridgePort) + mux.HandleFunc("POST /configure/interfaces/{name}/wifi", cfgIf.SaveWifi) + mux.HandleFunc("POST /configure/interfaces/{name}/bridge", cfgIf.SaveBridge) + mux.HandleFunc("POST /configure/interfaces/{name}/bridge/stp", cfgIf.SaveBridgeSTP) + mux.HandleFunc("POST /configure/interfaces/{name}/bridge/multicast", cfgIf.SaveBridgeMulticast) + mux.HandleFunc("POST /configure/interfaces/{name}/bridge/vlans", cfgIf.AddVLAN) + mux.HandleFunc("POST /configure/interfaces/{name}/bridge/vlans/{vid}", cfgIf.SaveVLAN) + mux.HandleFunc("DELETE /configure/interfaces/{name}/bridge/vlans/{vid}", cfgIf.DeleteVLAN) + mux.HandleFunc("POST /configure/interfaces/{name}/lag", cfgIf.SaveLAG) + mux.HandleFunc("POST /configure/interfaces/{name}/lag/members", cfgIf.SaveLAGMembers) + mux.HandleFunc("POST /configure/interfaces/{name}/lag-port", cfgIf.SaveLagPort) + mux.HandleFunc("DELETE /configure/interfaces/{name}/lag-port", cfgIf.DeleteLagPort) + mux.HandleFunc("GET /configure/firewall", cfgFw.Overview) + mux.HandleFunc("POST /configure/firewall/enable", cfgFw.Enable) + mux.HandleFunc("POST /configure/firewall/settings", cfgFw.SaveSettings) + mux.HandleFunc("POST /configure/firewall/zones", cfgFw.AddZone) + mux.HandleFunc("POST /configure/firewall/zones/{name}", cfgFw.SaveZone) + mux.HandleFunc("DELETE /configure/firewall/zones/{name}", cfgFw.DeleteZone) + mux.HandleFunc("DELETE /configure/firewall/zones/{name}/interfaces", cfgFw.ResetZoneInterfaces) + mux.HandleFunc("DELETE /configure/firewall/zones/{name}/services", cfgFw.ResetZoneServices) + mux.HandleFunc("POST /configure/firewall/zones/{name}/port-forwards", cfgFw.AddPortForward) + mux.HandleFunc("DELETE /configure/firewall/zones/{name}/port-forwards/{lower}/{proto}", cfgFw.DeletePortForward) + mux.HandleFunc("POST /configure/firewall/policies", cfgFw.AddPolicy) + mux.HandleFunc("POST /configure/firewall/policies/{name}", cfgFw.SavePolicy) + mux.HandleFunc("DELETE /configure/firewall/policies/{name}", cfgFw.DeletePolicy) + mux.HandleFunc("POST /configure/firewall/services", cfgFw.AddService) + mux.HandleFunc("POST /configure/firewall/services/{name}", cfgFw.SaveService) + mux.HandleFunc("DELETE /configure/firewall/services/{name}", cfgFw.DeleteService) + mux.HandleFunc("GET /configure/hardware", cfgHw.Overview) + mux.HandleFunc("POST /configure/hardware", cfgHw.CreateHardware) + mux.HandleFunc("POST /configure/hardware/usb/{name}", cfgHw.SaveUSBPort) + mux.HandleFunc("POST /configure/hardware/wifi/{name}", cfgHw.SaveWiFiRadio) + mux.HandleFunc("POST /configure/hardware/gps/{name}", cfgHw.SaveGPS) + mux.HandleFunc("DELETE /configure/hardware/{name}", cfgHw.DeleteComponent) + mux.HandleFunc("GET /configure/routes", cfgRoutes.Overview) + mux.HandleFunc("POST /configure/routes", cfgRoutes.AddRoute) + mux.HandleFunc("PUT /configure/routes", cfgRoutes.UpdateRoute) + mux.HandleFunc("DELETE /configure/routes", cfgRoutes.DeleteRoute) + mux.HandleFunc("GET /configure/users", cfgUsers.Overview) + mux.HandleFunc("POST /configure/users", cfgUsers.AddUser) + mux.HandleFunc("DELETE /configure/users/{name}", cfgUsers.DeleteUser) + mux.HandleFunc("POST /configure/users/{name}/shell", cfgUsers.UpdateShell) + mux.HandleFunc("POST /configure/users/{name}/password", cfgUsers.ChangePassword) + mux.HandleFunc("POST /configure/users/{name}/keys", cfgUsers.AddKey) + mux.HandleFunc("DELETE /configure/users/{name}/keys/{keyname}", cfgUsers.DeleteKey) + mux.HandleFunc("POST /configure/users/groups/{name}/members", cfgUsers.AddGroupMembers) + mux.HandleFunc("DELETE /configure/users/groups/{name}/members/{user}", cfgUsers.RemoveGroupMember) + mux.HandleFunc("GET /configure/keystore", cfgKs.Overview) + mux.HandleFunc("POST /configure/keystore/symmetric", cfgKs.AddSymKey) + mux.HandleFunc("POST /configure/keystore/symmetric/{name}", cfgKs.UpdateSymKey) + mux.HandleFunc("DELETE /configure/keystore/symmetric/{name}", cfgKs.DeleteSymKey) + mux.HandleFunc("POST /configure/keystore/asymmetric", cfgKs.AddAsymKey) + mux.HandleFunc("POST /configure/keystore/asymmetric/{name}", cfgKs.UpdateAsymKey) + mux.HandleFunc("DELETE /configure/keystore/asymmetric/{name}", cfgKs.DeleteAsymKey) + mux.HandleFunc("POST /configure/keystore/asymmetric/{name}/certs", cfgKs.AddCert) + mux.HandleFunc("POST /configure/keystore/asymmetric/{name}/certs/{certname}", cfgKs.UpdateCert) + mux.HandleFunc("DELETE /configure/keystore/asymmetric/{name}/certs/{certname}", cfgKs.DeleteCert) + + // Schema API routes (authenticated). + mux.HandleFunc("GET /api/schema", schemaH.Schema) + mux.HandleFunc("GET /api/schema/children", schemaH.Children) + + // Data API route (authenticated) — raw RESTCONF JSON passthrough. + mux.HandleFunc("GET /api/data", dataH.Get) + + // YANG tree UI routes (authenticated). + mux.HandleFunc("GET /configure/tree", treeH.Overview) + mux.HandleFunc("GET /configure/tree/children", treeH.TreeChildren) + mux.HandleFunc("GET /configure/tree/node", treeH.TreeNode) + mux.HandleFunc("PUT /configure/tree/node", treeH.SaveLeaf) + mux.HandleFunc("DELETE /configure/tree/node", treeH.DeleteLeaf) + mux.HandleFunc("PUT /configure/tree/group", treeH.SaveGroup) + mux.HandleFunc("GET /configure/tree/list-add", treeH.AddListRowForm) + mux.HandleFunc("POST /configure/tree/list-row", treeH.SaveListRow) + mux.HandleFunc("DELETE /configure/tree/list-row", treeH.DeleteListRow) + mux.HandleFunc("PUT /configure/tree/presence", treeH.TogglePresence) + mux.HandleFunc("DELETE /configure/tree/presence", treeH.TogglePresence) + mux.HandleFunc("DELETE /configure/tree/container", treeH.DeleteContainer) + + // Status tree (read-only operational view). + mux.HandleFunc("GET /status/tree", statusTreeH.Overview) + mux.HandleFunc("GET /status/tree/children", statusTreeH.TreeChildren) + mux.HandleFunc("GET /status/tree/node", statusTreeH.TreeNode) + + handler := authMiddleware(store, mux) + handler = csrfMiddleware(handler) + handler = securityHeadersMiddleware(handler) + return handler, nil +} diff --git a/src/webui/internal/testutil/helpers.go b/src/webui/internal/testutil/helpers.go new file mode 100644 index 00000000..7309bc31 --- /dev/null +++ b/src/webui/internal/testutil/helpers.go @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: MIT + +package testutil + +import ( + "context" + "encoding/json" + "sync" +) + +type mockEntry struct { + body any + err error +} + +type MockFetcher struct { + mu sync.Mutex + responses map[string]mockEntry + errors map[string]error +} + +func NewMockFetcher() *MockFetcher { + return &MockFetcher{ + responses: make(map[string]mockEntry), + errors: make(map[string]error), + } +} + +func (m *MockFetcher) SetResponse(path string, body any) { + m.mu.Lock() + defer m.mu.Unlock() + m.responses[path] = mockEntry{body: body} +} + +func (m *MockFetcher) SetError(path string, err error) { + m.mu.Lock() + defer m.mu.Unlock() + m.errors[path] = err +} + +func (m *MockFetcher) Get(_ context.Context, path string, target any) error { + m.mu.Lock() + defer m.mu.Unlock() + + if err, ok := m.errors[path]; ok { + return err + } + + entry, ok := m.responses[path] + if !ok { + return nil + } + + raw, err := json.Marshal(entry.body) + if err != nil { + return err + } + return json.Unmarshal(raw, target) +} + +func (m *MockFetcher) GetRaw(_ context.Context, path string) ([]byte, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if err, ok := m.errors[path]; ok { + return nil, err + } + + entry, ok := m.responses[path] + if !ok { + return []byte("{}"), nil + } + + return json.Marshal(entry.body) +} + +func (m *MockFetcher) Post(_ context.Context, path string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if err, ok := m.errors[path]; ok { + return err + } + return nil +} + +func (m *MockFetcher) PostJSON(_ context.Context, path string, _ any) error { + m.mu.Lock() + defer m.mu.Unlock() + + if err, ok := m.errors[path]; ok { + return err + } + return nil +} + +func (m *MockFetcher) GetYANG(_ context.Context, _, _ string) ([]byte, error) { return nil, nil } +func (m *MockFetcher) Put(_ context.Context, _ string, _ any) error { return nil } +func (m *MockFetcher) Patch(_ context.Context, _ string, _ any) error { return nil } +func (m *MockFetcher) Delete(_ context.Context, _ string) error { return nil } + +func (m *MockFetcher) GetDatastore(_ context.Context, _ string) (json.RawMessage, error) { + return json.RawMessage("{}"), nil +} + +func (m *MockFetcher) PutDatastore(_ context.Context, _ string, _ json.RawMessage) error { + return nil +} + +func (m *MockFetcher) CopyDatastore(_ context.Context, _, _ string) error { return nil } diff --git a/src/webui/main.go b/src/webui/main.go new file mode 100644 index 00000000..6f252943 --- /dev/null +++ b/src/webui/main.go @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MIT + +package main + +import ( + "embed" + "flag" + "io/fs" + "log" + "net/http" + "os" + "strings" + "time" + + "infix/webui/internal/auth" + "infix/webui/internal/restconf" + "infix/webui/internal/schema" + "infix/webui/internal/server" +) + +//go:embed templates/* +var templateFS embed.FS + +//go:embed static/* +var staticFS embed.FS + +func main() { + defaultRC := "http://localhost:8080/restconf" + if env := os.Getenv("RESTCONF_URL"); env != "" { + defaultRC = env + } + + listen := flag.String("listen", ":10000", "address to listen on") + restconfURL := flag.String("restconf", defaultRC, "RESTCONF base URL") + insecureTLS := flag.Bool("insecure-tls", envBool("INSECURE_TLS"), "disable TLS certificate verification") + yangCacheDir := flag.String("yang-cache-dir", "/var/cache/webui/yang", "directory for cached YANG schema files") + flag.Parse() + + store := auth.NewSessionStore() + + rc := restconf.NewClient(*restconfURL, *insecureTLS) + + schemaCache := schema.NewCache(rc, *yangCacheDir) + schemaCache.LoadFromCacheBackground() // fast, no HTTP — uses whatever is already on disk + + tmplFS, err := fs.Sub(templateFS, "templates") + if err != nil { + log.Fatalf("template fs: %v", err) + } + + stFS, err := fs.Sub(staticFS, "static") + if err != nil { + log.Fatalf("static fs: %v", err) + } + + handler, err := server.New(store, rc, schemaCache, tmplFS, stFS) + if err != nil { + log.Fatalf("server setup: %v", err) + } + + log.Printf("listening on %s (restconf %s)", *listen, *restconfURL) + srv := &http.Server{ + Addr: *listen, + Handler: handler, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + if err := srv.ListenAndServe(); err != nil { + log.Fatalf("listen: %v", err) + } +} + +func envBool(key string) bool { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + return false + } + switch strings.ToLower(v) { + case "1", "true", "yes", "y", "on": + return true + default: + return false + } +} diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css new file mode 100644 index 00000000..e63cb44e --- /dev/null +++ b/src/webui/static/css/style.css @@ -0,0 +1,3374 @@ +/* ========================================================================== + Design System: Primitives & Tokens + ========================================================================== */ + +:root { + /* Native controls (scrollbars, inputs, date pickers) follow the OS + light/dark preference while on 'auto'; .dark/.light pin it explicitly. */ + color-scheme: light dark; + + /* --- Primitives: Slate (Neutral) --- */ + --slate-50: #f8fafc; + --slate-100: #f1f5f9; + --slate-200: #e2e8f0; + --slate-300: #cbd5e1; + --slate-400: #94a3b8; + --slate-500: #64748b; + --slate-600: #475569; + --slate-700: #334155; + --slate-800: #1e293b; + --slate-900: #0f172a; + --slate-950: #020617; + + /* --- Primitives: Colors --- */ + --blue-500: #3b82f6; + --blue-600: #2563eb; + --blue-700: #1d4ed8; + --green-500: #22c55e; + --green-600: #16a34a; + --amber-500: #f59e0b; + --amber-600: #d97706; + --red-500: #ef4444; + --red-600: #dc2626; + + /* --- Semantic Tokens: Light Mode (Default) --- */ + --bg: var(--slate-50); + --surface: #ffffff; + --fg: var(--slate-900); + --fg-muted: var(--slate-500); + --border: var(--slate-200); + --border-subtle: var(--slate-100); + + --primary: var(--blue-500); + --primary-hover: var(--blue-600); + + --success: var(--green-500); + --warning: var(--amber-500); + --danger: var(--red-500); + + --warning-bg: #fffbeb; + --warning-fg: #78350f; + --warning-border: #fcd34d; + + /* Sidebar — Light mode */ + --sidebar-bg: var(--slate-100); + --sidebar-fg: var(--slate-700); + --sidebar-hover: rgba(0, 0, 0, 0.07); + --sidebar-hover-fg: var(--primary); + --sidebar-border: rgba(0, 0, 0, 0.08); + --sidebar-width: 240px; + + /* Login page */ + --dot-color: rgba(0, 0, 0, 0.055); + + /* Layout */ + --topbar-height: 48px; + --radius: 8px; + --radius-sm: 4px; + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + --font-mono: "SF Mono", "Fira Code", "Fira Mono", Menlo, Consolas, monospace; + + --surface-alt: var(--slate-100); + + /* Legacy variable mapping */ + --card-bg: var(--surface); +} + +/* --- Dark Mode Overrides --- */ +@media (prefers-color-scheme: dark) { + :root { + --bg: var(--slate-900); + --surface: var(--slate-800); + --fg: var(--slate-100); + --fg-muted: var(--slate-400); + --border: var(--slate-700); + --border-subtle: var(--slate-800); + --surface-alt: var(--slate-900); + --sidebar-bg: var(--slate-800); + --sidebar-fg: var(--slate-200); + --sidebar-hover: rgba(255, 255, 255, 0.1); + --sidebar-hover-fg: #fff; + --sidebar-border: rgba(255, 255, 255, 0.1); + --dot-color: rgba(255, 255, 255, 0.045); + --warning-bg: #2d2007; + --warning-fg: #fde68a; + --warning-border: #92400e; + } +} + +/* Manual Dark Mode Toggle */ +.dark { + color-scheme: dark; + --bg: var(--slate-900); + --surface: var(--slate-800); + --fg: var(--slate-100); + --fg-muted: var(--slate-400); + --border: var(--slate-700); + --border-subtle: var(--slate-800); + --surface-alt: var(--slate-900); + --zebra-stripe: rgba(255,255,255,0.03); + --sidebar-bg: var(--slate-800); + --sidebar-fg: var(--slate-200); + --sidebar-hover: rgba(255, 255, 255, 0.1); + --sidebar-hover-fg: #fff; + --sidebar-border: rgba(255, 255, 255, 0.1); + --dot-color: rgba(255, 255, 255, 0.045); + --warning-bg: #2d2007; + --warning-fg: #fde68a; + --warning-border: #92400e; +} + +/* Force Light Mode (override system dark preference) */ +.light { + color-scheme: light; + --bg: var(--slate-50); + --surface: #ffffff; + --fg: var(--slate-900); + --fg-muted: var(--slate-500); + --border: var(--slate-200); + --border-subtle: var(--slate-100); + --surface-alt: var(--slate-100); + --sidebar-bg: var(--slate-100); + --sidebar-fg: var(--slate-700); + --sidebar-hover: rgba(0, 0, 0, 0.07); + --sidebar-hover-fg: var(--primary); + --sidebar-border: rgba(0, 0, 0, 0.08); + --dot-color: rgba(0, 0, 0, 0.055); + --warning-bg: #fffbeb; + --warning-fg: #78350f; + --warning-border: #fcd34d; +} +.light .alert-error { background: #fef2f2; color: #991b1b; border-color: #fecaca; } +.light .alert-info { background: #eff6ff; color: #1e40af; border-color: #bfdbfe; } +.light .health-bar-track { background-color: var(--slate-200); } +.light .zone-badge { + background-color: hsl(var(--zone-hue), 80%, 90%); + color: hsl(var(--zone-hue), 80%, 30%); + border-color: hsl(var(--zone-hue), 60%, 80%); +} +.light .matrix-allow { background: #dcfce7; color: #166534; } +.light .matrix-deny { background: #fef2f2; color: #991b1b; } +.light .matrix-self { background: var(--slate-100); color: var(--slate-400); } +.light .badge-accept { background: #dcfce7; color: #166534; } +.light .badge-reject { background: #fef2f2; color: #991b1b; } +.light .badge-drop { background: var(--slate-100); color: var(--slate-600); } +.light .badge-continue { background: #dbeafe; color: #1e40af; } + +/* ========================================================================== + Reset & Base + ========================================================================== */ + +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + height: 100%; + overflow: hidden; +} +body { + height: 100%; + font-family: var(--font-sans); + font-size: 15px; + line-height: 1.5; + color: var(--fg); + background: var(--bg); + -webkit-font-smoothing: antialiased; +} + +a { + color: var(--primary); + text-decoration: none; + transition: color 0.15s ease; +} + +a:hover { + text-decoration: underline; +} + +/* ========================================================================== + Layout + ========================================================================== */ + +.layout { + display: flex; + height: calc(100vh - var(--topbar-height)); + margin-top: var(--topbar-height); + overflow: hidden; +} + +#sidebar { + position: relative; + z-index: 150; + width: var(--sidebar-width); + background: var(--sidebar-bg); + color: var(--sidebar-fg); + display: flex; + flex-direction: column; + flex-shrink: 0; + border-right: 1px solid var(--sidebar-border); + overflow-y: auto; +} + +.main-column { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +#content { + flex: 1; + width: 100%; + padding: 2rem; + overflow-y: auto; + max-width: 1200px; + margin: 0 auto; + box-sizing: border-box; +} + +/* ========================================================================== + Sidebar Components + ========================================================================== */ + +.topbar-logo { + height: calc(var(--topbar-height) - 14px); + width: auto; + display: block; +} + +/* Device identity: hostname shown right of the logo, reading like a titlebar. + Location/contact (when set) appear in a hover/focus popover so the bar stays + uncluttered. */ +.topbar-identity { + position: absolute; + left: var(--sidebar-width); + top: 0; + bottom: 0; + display: flex; + align-items: center; + min-width: 0; + padding-left: 0.9rem; +} +/* Short divider pinned to the sidebar's right edge, so the hostname reads as + the start of the content area rather than part of the logo lockup. */ +.topbar-identity::before { + content: ""; + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + width: 1px; + height: 1.4rem; + background: var(--sidebar-border); +} +.topbar-identity-name { + display: inline-flex; + align-items: center; + gap: 0.25rem; + max-width: 22rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.875rem; + font-weight: 500; + color: var(--fg-muted); + text-decoration: none; + padding: 0.2rem 0.45rem; + border-radius: var(--radius-sm); + transition: color 0.15s, background 0.15s; +} +.topbar-identity-name:hover { background: var(--slate-100); color: var(--fg); } +.dark .topbar-identity-name:hover { background: var(--slate-800); color: var(--fg); } +.topbar-identity-caret { font-size: 0.6rem; opacity: 0.6; } + +.topbar-identity-pop { + display: none; + position: absolute; + top: 100%; + left: 0; + margin-top: 0.3rem; + padding: 0.5rem 0.7rem; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18); + white-space: nowrap; + z-index: 250; + font-size: 0.8rem; +} +.topbar-identity:hover .topbar-identity-pop, +.topbar-identity:focus-within .topbar-identity-pop { display: block; } +.topbar-identity-pop .ti-row { display: flex; gap: 0.6rem; } +.topbar-identity-pop .ti-row + .ti-row { margin-top: 0.25rem; } +.topbar-identity-pop .ti-label { + color: var(--fg-muted); + font-weight: 600; + min-width: 4.5rem; +} + +/* Narrow screens: the hamburger takes over — drop identity so it never + competes with the menu button. */ +@media (max-width: 1024px) { + .topbar-identity, + .topbar-identity-slot { display: none; } +} + +.sidebar-nav { + flex: 1; + padding: 0.5rem 0; + overflow-y: auto; +} + +/* Standalone nav link (Dashboard) */ +.nav-standalone { + margin: 0.25rem 0.5rem 0.5rem; +} + +/* Top-level accordion groups (Status / Configure / Maintenance) */ +details.nav-group-top { +} + +details.nav-group-top > summary.nav-group-summary-top { + list-style: none; + cursor: pointer; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--sidebar-fg); + opacity: 0.7; + padding: 0.75rem 1rem 0.375rem; + display: flex; + align-items: center; + gap: 0.35rem; + user-select: none; + transition: opacity 0.15s; +} +details.nav-group-top > summary.nav-group-summary-top:hover { opacity: 1; } +details.nav-group-top > summary.nav-group-summary-top::-webkit-details-marker { display: none; } +details.nav-group-top > summary.nav-group-summary-top::marker { display: none; } + +details.nav-group-top > summary.nav-group-summary-top::before { + content: '▸'; + font-size: 0.7rem; +} +details[open].nav-group-top > summary.nav-group-summary-top::before { + content: '▾'; +} + +/* Static sub-section labels within top-level accordion */ +.nav-section-label { + font-size: 0.6rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--sidebar-fg); + opacity: 0.45; + padding: 0.75rem 1rem 0.2rem; + user-select: none; +} + +.nav-group-items { + list-style: none; + padding-bottom: 0.25rem; +} + +.nav-icon { + display: inline-block; + width: 1rem; + height: 1rem; + background-color: currentColor; + -webkit-mask-image: var(--icon); + mask-image: var(--icon); + -webkit-mask-size: contain; + mask-size: contain; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; + flex-shrink: 0; +} + +.nav-link { + display: flex; + align-items: center; + gap: 0.625rem; + padding: 0.5rem 1rem; + color: var(--sidebar-fg); + text-decoration: none; + font-size: 0.875rem; + border-radius: 0.375rem; + margin: 0 0.5rem; + transition: background-color 0.15s, color 0.15s; + font-weight: 500; +} + +.nav-link:hover { + background-color: var(--sidebar-hover); + color: var(--sidebar-hover-fg); + text-decoration: none; +} + +.nav-link.active { + background-color: var(--primary); + color: #fff; + text-decoration: none; +} + +/* Button styled as a nav-link (for actions like Reboot) */ +.nav-link-btn { + width: 100%; + background: none; + border: none; + cursor: pointer; + text-align: left; + font-family: inherit; + font-size: inherit; +} + +.nav-link-danger:hover { + color: var(--danger); + background-color: var(--sidebar-hover); +} + +.sidebar-footer { + padding: 1.25rem; + border-top: 1px solid var(--sidebar-border); +} + +/* ========================================================================== + Buttons + ========================================================================== */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.5rem 1rem; + border: 1px solid transparent; + border-radius: var(--radius); + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + text-decoration: none; + transition: all 0.15s ease; + line-height: 1.25; +} + +.btn-primary { + background: var(--primary); + color: #fff; +} + +.btn-primary:hover { + background: var(--primary-hover); + text-decoration: none; +} + +.btn-secondary { + background: transparent; + color: var(--text-muted); + border-color: var(--border); +} +.btn-secondary:hover { + background: var(--border); + color: var(--text); + text-decoration: none; +} + +.btn-block { + display: flex; + width: 100%; +} + +/* Sidebar Actions */ +.btn-sidebar-action, .btn-logout { + display: flex; + width: 100%; + align-items: center; + justify-content: center; + background: transparent; + color: var(--sidebar-fg); + border: 1px solid rgba(255,255,255,0.2); + padding: 0.5rem 0.75rem; + border-radius: var(--radius); + cursor: pointer; + font-size: 0.85rem; + text-decoration: none; + margin-bottom: 0.5rem; + transition: all 0.15s ease; +} + +.btn-sidebar-action:hover, .btn-logout:hover { + background: var(--sidebar-hover); + border-color: rgba(255,255,255,0.3); + text-decoration: none; +} + +.btn-danger-hover:hover { + border-color: var(--danger); + color: #fca5a5; + background: rgba(220, 38, 38, 0.1); +} + +/* ========================================================================== + Cards & Content Containers + ========================================================================== */ + +.info-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 1.5rem; + margin-bottom: 1.5rem; + align-items: stretch; +} + +/* Cards inside a grid don't need their own bottom margin — the grid gap handles spacing */ +.info-grid > .info-card { margin-bottom: 0; } + +/* Full-width card spanning all grid columns */ +.info-grid-full { grid-column: 1 / -1; } + +/* Two-column card for wide tables — only kicks in once the grid has ≥2 columns */ +@media (min-width: 680px) { + .info-grid-span-2 { grid-column: span 2; } +} + +.info-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-sm); + margin-bottom: 1.5rem; + display: flex; + flex-direction: column; +} + +/* Legacy support: info-card often contains direct h2 */ +.info-card h2 { + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 600; + color: var(--fg-muted); + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--border); + margin: 0; + background: var(--bg); + border-radius: var(--radius) var(--radius) 0 0; +} + +.card-header { + padding: 0.75rem 1.25rem; + background: var(--bg); + border-bottom: 1px solid var(--border); + font-size: 0.85rem; + text-transform: uppercase; + border-radius: var(--radius) var(--radius) 0 0; + letter-spacing: 0.05em; + font-weight: 600; + color: var(--fg-muted); +} + +.card-body { + padding: 1.25rem; +} + + +/* ========================================================================== + Tables + ========================================================================== */ + +.info-table, .disk-table, .counters-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +.info-table th, .info-table td, +.disk-table th, .disk-table td, +.counters-table th, .counters-table td { + padding: 0.75rem 1.25rem; + border-bottom: 1px solid var(--border); +} + +.info-table th { + width: 1%; + white-space: nowrap; + color: var(--fg-muted); + font-weight: 500; + text-align: left; +} + +/* Disk usage widget (one entry per mount point) */ +.disk-item { + padding: 0.7rem 1.25rem; + border-bottom: 1px solid var(--border); +} +.disk-item:last-child { border-bottom: none; } +.disk-item-header { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 0.4rem; +} +.disk-mount { + font-family: var(--font-mono); + font-size: 0.85rem; + font-weight: 500; + color: var(--fg); +} +.disk-pct { font-size: 0.8rem; color: var(--fg-muted); } +.disk-tag { + font-family: var(--font-body); + font-size: 0.65rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--fg-muted); + border: 1px solid var(--border); + border-radius: 3px; + padding: 0 0.3rem; + margin-left: 0.4rem; + vertical-align: middle; +} +.disk-stats { + font-size: 0.78rem; + color: var(--fg-muted); + margin-top: 0.3rem; + font-variant-numeric: tabular-nums; +} + +.sensor-group-hdr th { + padding: 0.35rem 1.25rem 0.2rem; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--fg-muted); + background: var(--bg); + border-bottom: none; +} +.sensor-child th { + padding-left: 2rem; +} + +.data-table th.num, +.data-table td.num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.info-table tr:last-child th, .info-table tr:last-child td, +.disk-table tbody tr:last-child td, +.counters-table tbody tr:last-child td { + border-bottom: none; +} + +.info-table tr:nth-child(even), +.disk-table tbody tr:nth-child(even), +.counters-table tbody tr:nth-child(even) { + background-color: var(--zebra-stripe, rgba(0,0,0,0.025)); +} + +.info-table th { + text-align: left; + font-weight: 500; + color: var(--fg-muted); + width: 40%; + background: transparent; +} + +.disk-table thead th, .counters-table thead th { + background: var(--bg); + font-weight: 600; + color: var(--fg-muted); + text-align: left; + font-size: 0.8rem; + text-transform: uppercase; +} + +.counters-table td { + font-family: var(--font-mono); +} + +/* New Data Table Styles */ +.data-table-wrap { + overflow-x: auto; + border: 1px solid var(--border); + border-radius: var(--radius); + /* Scroll-shadow: a right-edge shadow appears automatically when the table + overflows, fading away once scrolled to the end. Pure CSS, no JS needed. + The `local` gradients act as white masks pinned to the scroll content; + the `scroll` gradients are the actual shadows fixed to the viewport edge. */ + background: + linear-gradient(to right, var(--surface), var(--surface)) left / 24px 100% no-repeat local, + linear-gradient(to left, var(--surface), var(--surface)) right / 24px 100% no-repeat local, + linear-gradient(to right, rgba(0,0,0,0.07), transparent) left / 8px 100% no-repeat scroll, + linear-gradient(to left, rgba(0,0,0,0.07), transparent) right / 8px 100% no-repeat scroll; + background-color: var(--surface); +} + +/* Remove the nested border when a table lives inside a card — the card already provides the frame */ +.info-card .data-table-wrap { + border: none; + border-radius: 0; +} + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; + white-space: nowrap; +} + +.data-table th, .data-table td { + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border); + text-align: left; + vertical-align: top; +} + +.data-table th { + background: var(--bg); + color: var(--fg-muted); + font-weight: 600; + font-size: 0.8rem; + text-transform: uppercase; +} + +.data-table tbody tr:nth-child(even) { + background-color: var(--zebra-stripe, rgba(0,0,0,0.025)); +} + +.data-table tbody tr:hover { background-color: var(--slate-100); } +@media (prefers-color-scheme: dark) { + .data-table tbody tr:hover { background-color: rgba(255,255,255,0.06); } +} +.dark .data-table tbody tr:hover { background-color: rgba(255,255,255,0.06); } + +/* ========================================================================== + Forms & Inputs + ========================================================================== */ + +.form-group { + margin-bottom: 1.25rem; +} + +.form-group label { + display: block; + font-size: 0.85rem; + font-weight: 600; + margin-bottom: 0.4rem; + color: var(--fg); +} + +.form-group input { + display: block; + width: 100%; + padding: 0.6rem 0.8rem; + background: var(--surface); + color: var(--fg); + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 0.9rem; + transition: all 0.15s ease; +} + +.form-group input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); +} + +/* ========================================================================== + Authentication + ========================================================================== */ + +@keyframes login-rise { + from { opacity: 0; transform: translateY(14px); } + to { opacity: 1; transform: translateY(0); } +} + +.login-wrapper { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background-color: var(--bg); + background-image: radial-gradient(var(--dot-color) 1.5px, transparent 1.5px); + background-size: 22px 22px; + padding: 1rem; +} + +.login-card { + background: var(--surface); + padding: 2.5rem; + border-radius: var(--radius); + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.07), 0 2px 4px -2px rgb(0 0 0 / 0.07), + 0 0 0 1px var(--border); + width: 100%; + max-width: 400px; + border-top: 2px solid var(--primary); + animation: login-rise 0.4s cubic-bezier(0.16, 1, 0.3, 1) both; +} + +.login-logo { + display: block; + max-width: 180px; + height: auto; + margin: 0 auto 2rem; +} + +/* ========================================================================== + Alerts & Banners + ========================================================================== */ + +/* Top progress bar — shown during htmx page navigations */ +.page-progress { + position: fixed; + top: 0; + left: 0; + height: 2px; + width: 0%; + background: var(--primary); + z-index: 9999; + opacity: 0; + pointer-events: none; +} + +.conn-banner { + background: var(--danger); + color: #fff; + text-align: center; + padding: 0.5rem; + font-size: 0.9rem; + font-weight: 600; + position: fixed; + top: var(--topbar-height); + /* #sidebar sits at z-index 150 (above this banner's 100), so a + left:0 banner gets its left edge clipped behind the sidebar and + the text drifts visually off-centre. Inset the banner past the + sidebar on desktop; on ≤1024px the sidebar slides off-screen + and the banner reclaims full width. */ + left: var(--sidebar-width); + right: 0; + z-index: 100; +} +@media (max-width: 1024px) { + .conn-banner { left: 0; } +} + +.alert { + padding: 1rem; + border-radius: var(--radius); + margin-bottom: 1.5rem; + font-size: 0.9rem; + border: 1px solid transparent; +} + +.alert-error { + background: #fef2f2; + color: var(--red-600); + border-color: #fecaca; +} + +.alert-info { + background: #eff6ff; + color: var(--blue-700); + border-color: #bfdbfe; +} + +/* Dark mode adjustments for alerts */ +@media (prefers-color-scheme: dark) { + .alert-error { + background: rgba(239, 68, 68, 0.1); + color: #fca5a5; + border-color: rgba(239, 68, 68, 0.2); + } + .alert-info { + background: rgba(59, 130, 246, 0.1); + color: #93c5fd; + border-color: rgba(59, 130, 246, 0.2); + } +} +.dark .alert-error { + background: rgba(239, 68, 68, 0.1); + color: #fca5a5; + border-color: rgba(239, 68, 68, 0.2); +} +.dark .alert-info { + background: rgba(59, 130, 246, 0.1); + color: #93c5fd; + border-color: rgba(59, 130, 246, 0.2); +} + +/* ========================================================================== + Status Indicators + ========================================================================== */ + +/* Legacy Status Dots */ +.status-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 0.5rem; + vertical-align: middle; +} + +.status-up { background: var(--success); } +.status-down { background: var(--danger); } + +/* New Interface Status Indicators */ +.iface-status { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + margin-right: 0.5rem; +} + +.iface-up { + background-color: var(--success); + box-shadow: 0 0 0 rgba(34, 197, 94, 0.4); + animation: pulse-green 2s infinite; +} + +.iface-down { + background-color: var(--danger); +} + +.iface-lower-down { + background-color: var(--warning); +} + +@keyframes pulse-green { + 0% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.7); } + 70% { box-shadow: 0 0 0 6px rgba(34, 197, 94, 0); } + 100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); } +} + + +/* ========================================================================== + Components: Health Bar + ========================================================================== */ + +.health-bar-track { + width: 100%; + height: 6px; + background-color: var(--slate-200); + border-radius: 99px; + overflow: hidden; +} + +.health-bar-fill { + height: 100%; + background-color: var(--success); + border-radius: 99px; + transition: width 0.5s ease; +} + +.health-bar-fill.is-warn { background-color: var(--warning); } +.health-bar-fill.is-crit { background-color: var(--danger); } + +/* Dark mode track */ +@media (prefers-color-scheme: dark) { + .health-bar-track { background-color: var(--slate-700); } +} +.dark .health-bar-track { background-color: var(--slate-700); } + +/* ========================================================================== + Components: Zone Badge + ========================================================================== */ + +.zone-badge { + /* Requires --zone-hue to be set inline or via utility */ + --zone-hue: 200; /* Default */ + display: inline-flex; + align-items: center; + padding: 0.25rem 0.6rem; + border-radius: var(--radius-sm); + font-size: 0.75rem; + font-weight: 600; + background-color: hsl(var(--zone-hue), 80%, 90%); + color: hsl(var(--zone-hue), 80%, 30%); + border: 1px solid hsl(var(--zone-hue), 60%, 80%); +} + +@media (prefers-color-scheme: dark) { + .zone-badge { + background-color: hsl(var(--zone-hue), 60%, 20%); + color: hsl(var(--zone-hue), 80%, 85%); + border-color: hsl(var(--zone-hue), 60%, 30%); + } +} +.dark .zone-badge { + background-color: hsl(var(--zone-hue), 60%, 20%); + color: hsl(var(--zone-hue), 80%, 85%); + border-color: hsl(var(--zone-hue), 60%, 30%); +} + +/* ========================================================================== + Utilities + ========================================================================== */ + +.num { + font-variant-numeric: tabular-nums; + font-family: var(--font-mono); +} + +.text-muted { color: var(--fg-muted); } + +/* Page title — used at the top of each content page */ +.page-title { + font-size: 1.375rem; + font-weight: 700; + color: var(--fg); + letter-spacing: -0.025em; + margin-bottom: 1.5rem; +} + +/* Removes the redundant wrapping div some pages use */ +.page-content { display: contents; } + +/* ========================================================================== + Domain Specific: Firewall, Keystore, Software + ========================================================================== */ + +/* Zone Matrix */ +.matrix-wrap { + overflow-x: auto; + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.zone-matrix { + border-collapse: collapse; + font-size: 0.9rem; + width: 100%; +} + +.zone-matrix th, .zone-matrix td { + padding: 0.75rem; + text-align: center; + border: 1px solid var(--border); + min-width: 90px; +} + +.zone-matrix thead th { + font-weight: 600; + color: var(--fg-muted); + background: var(--bg); +} + +.zone-matrix tbody th { + font-weight: 600; + text-align: right; + background: var(--bg); + color: var(--fg-muted); +} + +.matrix-corner { + color: var(--fg-muted); +} + +.matrix-allow { + background: #dcfce7; + color: #166534; + font-weight: 700; +} +.matrix-deny { + background: #fef2f2; + color: #991b1b; + font-weight: 700; +} +.matrix-cond { + background: #fef9c3; + color: #854d0e; + font-weight: 700; +} +.matrix-self { + background: var(--slate-100); + color: var(--slate-400); +} +/* Clickable cells */ +.matrix-cell[data-verdict] { + cursor: pointer; +} +.matrix-cell[data-verdict]:hover { + filter: brightness(0.93); +} + +@media (prefers-color-scheme: dark) { + .matrix-allow { background: rgba(22, 163, 74, 0.2); color: #86efac; } + .matrix-deny { background: rgba(220, 38, 38, 0.2); color: #fca5a5; } + .matrix-cond { background: rgba(234, 179, 8, 0.2); color: #fde047; } + .matrix-self { background: var(--slate-800); color: var(--slate-600); } +} +.dark .matrix-allow { background: rgba(22, 163, 74, 0.2); color: #86efac; } +.dark .matrix-deny { background: rgba(220, 38, 38, 0.2); color: #fca5a5; } +.dark .matrix-cond { background: rgba(234, 179, 8, 0.2); color: #fde047; } +.dark .matrix-self { background: var(--slate-800); color: var(--slate-600); } +.light .matrix-cond { background: #fef9c3; color: #854d0e; } + +/* Flow detail panel — shown below matrix when a cell is clicked. + display:flex overrides HTML [hidden], so we restore that here. */ +.fw-flow-detail { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: var(--surface-alt); + border-top: 1px solid var(--border); + font-size: 0.875rem; +} +.fw-flow-detail[hidden] { display: none !important; } +.fw-detail-flow { font-weight: 600; } +.fw-detail-sep { color: var(--fg-muted); } +.fw-detail-text { color: var(--fg-muted); } + +/* Policy immutable lock icon */ +.policy-lock-col { width: 1.5rem; min-width: 1.5rem; padding: 0 0.25rem !important; text-align: center; } +.policy-lock { color: var(--fg-muted); font-size: 0.85rem; user-select: none; } +.data-table td.policy-lock-col, +.data-table th.policy-lock-col { vertical-align: middle; } + +.matrix-legend { + display: flex; + gap: 1.5rem; + font-size: 0.85rem; + color: var(--fg-muted); + padding: 0.75rem 1rem; +} + +.legend-item { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.legend-swatch { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.25rem; + border-radius: 4px; + border: 1px solid var(--border); + font-weight: 700; + font-size: 0.75rem; +} + +/* Badges */ +.badge { + display: inline-block; + padding: 0.25rem 0.6rem; + border-radius: var(--radius-sm); + font-size: 0.75rem; + font-weight: 600; + text-transform: capitalize; +} + +.badge-accept { background: #dcfce7; color: #166534; } +.badge-reject { background: #fef2f2; color: #991b1b; } +.badge-drop { background: var(--slate-100); color: var(--slate-600); } +.badge-continue { background: #dbeafe; color: #1e40af; } + +/* Type-aware Config column pills in the Configure > Interfaces overview. */ +.iface-cfg-cell { white-space: normal; } +.iface-tag { + display: inline-block; + margin: 0.1rem 0.25rem 0.1rem 0; + padding: 0.05rem 0.4rem; + border-radius: var(--radius-sm); + background: var(--slate-100); + color: var(--slate-600); + font-size: 0.7rem; + font-weight: 600; + white-space: nowrap; +} +.iface-cfg-addr { color: var(--slate-600); } +.dark .iface-tag { background: var(--slate-800); color: var(--slate-300); } +@media (prefers-color-scheme: dark) { + .iface-tag { background: var(--slate-800); color: var(--slate-300); } +} + +@media (prefers-color-scheme: dark) { + .badge-accept { background: rgba(22, 163, 74, 0.2); color: #86efac; } + .badge-reject { background: rgba(220, 38, 38, 0.2); color: #fca5a5; } + .badge-drop { background: var(--slate-800); color: var(--slate-400); } + .badge-continue { background: rgba(59, 130, 246, 0.2); color: #93c5fd; } +} +.dark .badge-accept { background: rgba(22, 163, 74, 0.2); color: #86efac; } +.dark .badge-reject { background: rgba(220, 38, 38, 0.2); color: #fca5a5; } +.dark .badge-drop { background: var(--slate-800); color: var(--slate-400); } +.dark .badge-continue { background: rgba(59, 130, 246, 0.2); color: #93c5fd; } + +/* General-purpose status badges */ +.badge-up { background: #dcfce7; color: #166534; } +.badge-down { background: #fef2f2; color: #991b1b; } +.badge-warning { background: #fef9c3; color: #854d0e; } +.badge-info { background: #dbeafe; color: #1e40af; } +.badge-neutral { background: var(--slate-100); color: var(--slate-600); } + +@media (prefers-color-scheme: dark) { + .badge-up { background: rgba(22, 163, 74, 0.2); color: #86efac; } + .badge-down { background: rgba(220, 38, 38, 0.2); color: #fca5a5; } + .badge-warning { background: rgba(245, 158, 11, 0.2); color: #fcd34d; } + .badge-info { background: rgba(59, 130, 246, 0.2); color: #93c5fd; } + .badge-neutral { background: var(--slate-800); color: var(--slate-400); } +} +.dark .badge-up { background: rgba(22, 163, 74, 0.2); color: #86efac; } +.dark .badge-down { background: rgba(220, 38, 38, 0.2); color: #fca5a5; } +.dark .badge-warning { background: rgba(245, 158, 11, 0.2); color: #fcd34d; } +.dark .badge-info { background: rgba(59, 130, 246, 0.2); color: #93c5fd; } +.dark .badge-neutral { background: var(--slate-800); color: var(--slate-400); } +.light .badge-up { background: #dcfce7; color: #166534; } +.light .badge-down { background: #fef2f2; color: #991b1b; } +.light .badge-warning { background: #fef9c3; color: #854d0e; } +.light .badge-info { background: #dbeafe; color: #1e40af; } +.light .badge-neutral { background: var(--slate-100); color: var(--slate-600); } + +/* mDNS service chips — self-contained, no .badge inheritance */ +.mdns-svc { + display: inline-block; + font-size: 0.75rem; + font-weight: 500; + padding: 0.15rem 0.45rem; + border-radius: var(--radius-sm); + white-space: nowrap; + background: var(--slate-100); + color: var(--slate-600); +} +.mdns-svc-link { + background: #2563eb; + color: #ffffff; + text-decoration: none; +} +.mdns-svc-link:hover { background: #1d4ed8; color: #ffffff; } +@media (prefers-color-scheme: dark) { + .mdns-svc { background: var(--slate-800); color: var(--slate-400); } + .mdns-svc-link { background: rgba(59, 130, 246, 0.2); color: #93c5fd; } + .mdns-svc-link:hover { background: rgba(59, 130, 246, 0.35); color: #93c5fd; } +} +.dark .mdns-svc { background: var(--slate-800); color: var(--slate-400); } +.dark .mdns-svc-link { background: rgba(59, 130, 246, 0.2); color: #93c5fd; } +.dark .mdns-svc-link:hover { background: rgba(59, 130, 246, 0.35); color: #93c5fd; } +.light .mdns-svc { background: var(--slate-100); color: var(--slate-600); } +.light .mdns-svc-link { background: #2563eb; color: #ffffff; } +.light .mdns-svc-link:hover { background: #1d4ed8; color: #ffffff; } + +/* mDNS extra-address rows — hidden until toggle */ +.mdns-extra-row { display: none; } +.mdns-extra-addr { color: var(--fg-muted); padding-top: 0.1rem; padding-bottom: 0.1rem; } + +/* mDNS toggle column and chevron button */ +.mdns-toggle-col { width: 1.5rem; min-width: 1.5rem; padding: 0 0.25rem !important; text-align: center; } +.data-table td.mdns-toggle-col, +.data-table th.mdns-toggle-col { vertical-align: middle; } +.mdns-addr-toggle { + background: none; + border: none; + cursor: pointer; + padding: 0; + color: var(--fg-muted); + font-size: 0.75rem; + line-height: 1; +} +.mdns-addr-toggle[aria-expanded="true"] .mdns-addr-arrow { + display: inline-block; + transform: rotate(90deg); +} + +.mdns-lastseen { white-space: nowrap; } + +/* Services table */ +.svc-name { font-family: var(--font-mono); font-size: 0.85rem; white-space: nowrap; } +.svc-num { white-space: nowrap; color: var(--fg-muted); font-size: 0.85rem; } +.svc-desc { color: var(--fg-muted); font-size: 0.875rem; } +.svc-status { + font-size: 0.75rem; + font-weight: 600; + text-transform: lowercase; + white-space: nowrap; +} +.svc-running { color: #16a34a; } +.svc-stopped { color: #ca8a04; } +.svc-error { color: #dc2626; } +@media (prefers-color-scheme: dark) { + .svc-running { color: #86efac; } + .svc-stopped { color: #fde047; } + .svc-error { color: #fca5a5; } +} +.dark .svc-running { color: #86efac; } +.dark .svc-stopped { color: #fde047; } +.dark .svc-error { color: #fca5a5; } +.light .svc-running { color: #16a34a; } +.light .svc-stopped { color: #ca8a04; } +.light .svc-error { color: #dc2626; } + +/* ─── NACM matrix ─────────────────────────────────────────────────────────── */ +.nacm-matrix .nacm-col { text-align: center; width: 5rem; } +.nacm-group { font-family: var(--font-mono); font-size: 0.85rem; } +.nacm-cell { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 4px; + font-size: 1rem; + font-weight: 700; +} +.nacm-full { background: rgba(34, 197, 94, 0.2); color: #16a34a; } +.nacm-restricted { background: rgba(234, 179, 8, 0.2); color: #854d0e; } +.nacm-denied { background: rgba(239, 68, 68, 0.2); color: #dc2626; } +.nacm-restrictions-row td { padding-top: 0; } +.nacm-restrictions { font-size: 0.75rem; color: var(--fg-muted); font-style: italic; } +.nacm-legend { + padding: 0.5rem 1rem 0.75rem; + font-size: 0.8rem; + color: var(--fg-muted); + display: flex; + align-items: center; + gap: 0.35rem; +} +.nacm-legend .nacm-cell { width: 1.4rem; height: 1.4rem; font-size: 0.8rem; } +@media (prefers-color-scheme: dark) { + .nacm-full { background: rgba(34, 197, 94, 0.2); color: #86efac; } + .nacm-restricted { background: rgba(234, 179, 8, 0.2); color: #fde047; } + .nacm-denied { background: rgba(239, 68, 68, 0.2); color: #fca5a5; } +} +.dark .nacm-full { background: rgba(34, 197, 94, 0.2); color: #86efac; } +.dark .nacm-restricted { background: rgba(234, 179, 8, 0.2); color: #fde047; } +.dark .nacm-denied { background: rgba(239, 68, 68, 0.2); color: #fca5a5; } +.light .nacm-full { background: rgba(34, 197, 94, 0.15); color: #16a34a; } +.light .nacm-restricted { background: rgba(234, 179, 8, 0.15); color: #854d0e; } +.light .nacm-denied { background: rgba(239, 68, 68, 0.15); color: #dc2626; } + +/* Sub-heading within a card body (e.g. "Active Leases" in DHCP) */ +.section-subtitle { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--fg-muted); + padding: 1rem 1.25rem 0.5rem; + margin: 0; + border-top: 1px solid var(--border); +} + +/* Row of stat chips (e.g. DHCP counters) */ +.stats-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + padding: 0.75rem 1.25rem; + border-bottom: 1px solid var(--border); +} + +/* Stat chip: label + value pair */ +.stat-chip { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.3rem 0.65rem; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: 0.8rem; + white-space: nowrap; +} +.stat-chip-label { color: var(--fg-muted); font-weight: 500; } +.stat-chip-value { color: var(--fg); font-weight: 700; font-variant-numeric: tabular-nums; } + +/* Interfaces */ +.iface-name { white-space: nowrap; font-weight: 500; } +.iface-name a { color: var(--primary); text-decoration: none; } +.iface-name a:hover { text-decoration: underline; } + +/* Forwarding flag column */ +.iface-fwd-col { width: 1.5rem; min-width: 1.5rem; padding: 0 0.25rem !important; text-align: center; } +.iface-fwd-flag { font-size: 0.8rem; color: var(--primary); user-select: none; } + +/* Tree connector column — pseudo-elements draw the continuous vertical/horizontal lines. + Wide enough to be a comfortable click target for the collapse button on parent rows. */ +.iface-tree-col { + width: 1.75rem; + min-width: 1.75rem; + padding: 0 !important; + position: relative; + overflow: visible; + text-align: center; +} +/* These two narrow icon columns must stay middle-aligned even though the rest of the + table uses vertical-align: top. Use td.class for higher specificity (0-1-1 beats 0-1-0). */ +.data-table td.iface-tree-col, +.data-table td.iface-fwd-col, +.data-table th.iface-fwd-col { vertical-align: middle; } + +/* Parent row: draw a vertical line from row center down to the bottom edge */ +.iface-tree-col.tree-parent::after { + content: ''; + position: absolute; + left: 50%; top: 50%; bottom: 0; + width: 2px; margin-left: -1px; + background: var(--border); +} + +/* Middle member: full-height vertical line + horizontal arm right */ +.iface-tree-col.tree-mid::before { + content: ''; + position: absolute; + left: 50%; top: 0; bottom: 0; + width: 2px; margin-left: -1px; + background: var(--border); +} +.iface-tree-col.tree-mid::after { + content: ''; + position: absolute; + left: 50%; top: 50%; right: 0; + height: 2px; margin-top: -1px; + background: var(--border); +} + +/* Last member: vertical line top-to-center + horizontal arm right */ +.iface-tree-col.tree-last::before { + content: ''; + position: absolute; + left: 50%; top: 0; bottom: 50%; + width: 2px; margin-left: -1px; + background: var(--border); +} +.iface-tree-col.tree-last::after { + content: ''; + position: absolute; + left: 50%; top: 50%; right: 0; + height: 2px; margin-top: -1px; + background: var(--border); +} + +/* Member rows: subtle background tint; name indented to clear the tree arm */ +.iface-member { background: var(--surface-alt); } +/* Tighter left padding on the name cell so connector arms read as attached to the text */ +.iface-name { padding-left: 0.4rem !important; } +.iface-member-name { padding-left: 1rem !important; } + +/* Bridge/LAG collapse toggle — sits inside .iface-tree-col, centred over the connector line. + z-index: 1 ensures it is clickable above the ::after pseudo-element. */ +.bridge-toggle { + background: none; + border: none; + cursor: pointer; + padding: 0; + color: var(--fg-muted); + font-size: 0.65rem; + line-height: 1; + display: block; + width: 100%; + position: relative; + z-index: 1; +} +.bridge-toggle-arrow { + display: inline-block; + transition: transform 0.15s ease; +} +/* When the parent row is collapsed: rotate arrow and hide the connector line below */ +.bridge-collapsed .bridge-toggle-arrow { transform: rotate(-90deg); } +.bridge-collapsed .iface-tree-col.tree-parent::after { display: none; } + +.data-detail { color: var(--fg-muted); font-size: 0.85em; } + +.addr-origin { color: var(--fg-muted); font-size: 0.75rem; margin-left: 0.5rem; } + +.wg-peer { + padding: 1rem 0; + border-bottom: 1px solid var(--border); +} +.wg-peer:last-child { border-bottom: none; } + +.eth-stats { + display: grid; + grid-template-columns: 1fr 1fr; + font-size: 0.85rem; + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} +.eth-stats-row { + display: flex; + justify-content: space-between; + padding: 0.5rem 1rem; + border-bottom: 1px solid var(--border); + background: var(--surface); +} +.eth-stats dt { color: var(--fg-muted); font-family: var(--font-mono); } +.eth-stats dd { text-align: right; font-family: var(--font-mono); font-weight: 600; } + +/* Side-by-side comparison of supported vs advertised PMDs on iface-detail. + Equal-width columns keep the lists visually parallel even when one side + is empty (no auto-negotiation, or no module data yet). */ +.pmd-compare { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} +.pmd-col-head { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--fg-muted); + margin-bottom: 0.3rem; +} +.pmd-list { + list-style: none; + padding: 0; + margin: 0; + font-family: var(--font-mono); + font-size: 0.85rem; +} +.pmd-list li { + padding: 0.1rem 0; +} + +.iface-detail-header { margin-bottom: 1.5rem; border-bottom: 1px solid var(--border); padding-bottom: 1rem; } +.iface-detail-header h2 { font-size: 1.5rem; font-weight: 600; color: var(--fg); } +.back-link { display: inline-flex; align-items: center; margin-bottom: 0.5rem; font-size: 0.9rem; } + +.info-card h3 { + font-size: 0.85rem; + font-weight: 600; + text-transform: uppercase; + color: var(--fg-muted); + margin: 1.5rem 0 0.75rem; + padding-left: 0.5rem; + border-left: 3px solid var(--primary); +} + +/* Logs viewer. Tab strip selects the source file, content area is a + monospaced log buffer with client-side text/regex filter and best-effort + severity colouring (heuristic, since on-disk sysklogd doesn't store + <PRI>). Live tail, rotations and download wire up in the next step. */ +/* ========================================================================== + Maintenance > Diagnostics + ========================================================================== */ +.diag-card { display: flex; flex-direction: column; } +.diag-tabs { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + padding: 0.5rem 1rem; + border-bottom: 1px solid var(--border); + background: var(--surface); +} +.diag-tab { + padding: 0.3rem 0.75rem; + border: 1px solid var(--border); + border-radius: 4px; + background: transparent; + color: var(--fg-muted); + font-size: 0.85rem; + cursor: pointer; + transition: background 0.1s, color 0.1s, border-color 0.1s; +} +.diag-tab:hover { color: var(--fg); border-color: var(--fg-muted); } +.diag-tab.active { background: var(--primary); color: #fff; border-color: var(--primary); } + +.diag-form { padding: 0.9rem 1rem 0.4rem; } +/* The [hidden] attribute is how JS shows per-tool option fields, but the + author `display:flex` on .diag-field outranks the UA [hidden] rule, so + hidden fields would still render. Restore it for everything inside the + form (specificity class+attr beats the bare class). */ +.diag-form [hidden] { display: none; } +.diag-row { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-bottom: 0.75rem; } +.diag-row:empty { margin: 0; } +.diag-field { display: flex; flex-direction: column; gap: 0.25rem; } +.diag-field-grow { flex: 1 1 16rem; } +.diag-label { font-size: 0.78rem; color: var(--fg-muted); } +.diag-hint { font-size: 0.8rem; color: var(--fg-muted); align-self: center; } +.diag-opts:empty { display: none; } +.diag-count, .diag-size, .diag-maxhops, .diag-mtr-count, .diag-mtr-size { width: 7rem; } +.diag-check { display: inline-flex; align-items: center; gap: 0.35rem; font-size: 0.85rem; white-space: nowrap; padding: 0.15rem 0; } +.diag-check input { margin: 0; } + +.diag-actions { display: flex; align-items: center; gap: 0.5rem; padding: 0.25rem 0 0.5rem; } +.diag-status { font-size: 0.78rem; color: var(--fg-muted); margin-left: 0.4rem; letter-spacing: 0.02em; } +.diag-status:not(:empty)::before { content: "●"; margin-right: 0.25rem; font-size: 0.85em; } +.diag-status.pending::before { color: var(--warning, #c97a00); animation: logs-tail-pulse 1s ease-in-out infinite; } +.diag-status.err::before { color: var(--danger, #b03030); } + +/* Support bundle status pill — shares the diag-status visual language, + but sits on its own line below the button. */ +.support-status { display: block; font-size: 0.8rem; color: var(--fg-muted); margin: 0.5rem 0 0; } +.support-status:empty { margin: 0; } +.support-status:not(:empty)::before { content: "●"; margin-right: 0.25rem; font-size: 0.85em; } +.support-status.pending::before { color: var(--warning, #c97a00); animation: logs-tail-pulse 1s ease-in-out infinite; } +.support-status.err::before { color: var(--danger, #b03030); } +.sc-hint { font-size: 0.8rem; opacity: 0.8; margin-top: 0.4rem; } + +.diag-output { padding: 0.25rem 1rem 1rem; } +.diag-placeholder { color: var(--fg-muted); font-size: 0.85rem; padding: 1rem 0; } +.diag-text { + font-family: var(--font-mono); + font-size: 0.78rem; + line-height: 1.4; + background: var(--surface-deep, var(--surface)); + border: 1px solid var(--border); + border-radius: 4px; + padding: 0.5rem 0.75rem; + margin: 0; + max-height: 55vh; + overflow: auto; + white-space: pre-wrap; + word-break: break-all; +} +.diag-mtr { + width: 100%; + border-collapse: collapse; + font-size: 0.82rem; +} +.diag-mtr th, .diag-mtr td { + text-align: right; + padding: 0.3rem 0.6rem; + border-bottom: 1px solid var(--border); + font-variant-numeric: tabular-nums; +} +.diag-mtr th { color: var(--fg-muted); font-weight: 600; font-size: 0.76rem; } +.diag-mtr-hop { text-align: right; width: 3rem; } +.diag-mtr-host { text-align: left; font-family: var(--font-mono); } +.diag-mtr tbody tr:hover { background: var(--surface); } +.diag-loss-bad { color: var(--danger); font-weight: 600; } + +.diag-dns-head { font-size: 0.9rem; margin: 0.5rem 0; } +.diag-dns-list { list-style: none; padding: 0; margin: 0; } +.diag-dns-list li { padding: 0.2rem 0; font-family: var(--font-mono); font-size: 0.85rem; } +.diag-dns-err { color: var(--danger); font-size: 0.9rem; } + +/* On the logs page, #content holds only the logs card — turn it into a + flex column and clip its own overflow so the card fills the available + height and the *buffer* scrolls, not the page. :has scopes this to + the logs page without touching any other view. */ +#content:has(.logs-card) { + display: flex; + flex-direction: column; + overflow: hidden; +} +.logs-card { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + margin-bottom: 0; /* no trailing gap when the card fills #content */ +} +.logs-tabs { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + padding: 0.5rem 1rem; + border-bottom: 1px solid var(--border); + background: var(--surface); +} +.logs-tab { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.3rem 0.75rem; + border: 1px solid var(--border); + border-radius: 4px; + background: transparent; + color: var(--fg-muted); + font-size: 0.85rem; + cursor: pointer; + transition: background 0.1s, color 0.1s, border-color 0.1s; +} +.logs-tab:hover { color: var(--fg); border-color: var(--fg-muted); } +.logs-tab.active { + background: var(--primary); + color: white; + border-color: var(--primary); +} +.logs-tab-empty { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.05em; + opacity: 0.6; +} +.logs-body { + display: flex; + flex-direction: column; + flex: 1; /* fill the card below the tabs so .logs-content can grow */ + min-height: 0; + padding: 0.75rem 1rem; + gap: 0.6rem; +} +.logs-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.6rem; +} +.logs-toolbar .cfg-input { flex: 1; min-width: 14rem; } +.logs-toggle { + display: inline-flex; + align-items: center; + gap: 0.3rem; + font-size: 0.85rem; + color: var(--fg-muted); + cursor: pointer; + user-select: none; +} +.logs-toggle input[type="checkbox"]:disabled + * { opacity: 0.5; } +.logs-disabled-link { + pointer-events: none; + opacity: 0.5; +} +.logs-content { + font-family: var(--font-mono); + font-size: 0.78rem; + line-height: 1.35; + background: var(--surface-deep, var(--surface)); + border: 1px solid var(--border); + border-radius: 4px; + padding: 0.5rem 0.75rem; + /* Height is governed by the flex chain below (#content:has(.logs-card) + → .logs-card → .logs-body → here), not a viewport calc — so the + buffer fills exactly the space left by the card chrome and #content + never grows its own scrollbar. min-height:0 lets this flex item + shrink below its content size so overflow stays internal. */ + flex: 1; + min-height: 0; + overflow: auto; + /* No `white-space: pre` here — the rule belongs on `.logs-line` only, + because the parent's trailing newline text node (left over from the + range template) would otherwise render as a blank line above any + JS-appended live-tail content. */ +} +.logs-load-earlier { + display: block; + margin: 0 auto 0.5rem; +} +.logs-download { + display: inline-flex; + align-items: center; + gap: 0.35rem; + text-decoration: none; +} +.logs-download .nav-icon { + width: 0.9rem; + height: 0.9rem; +} +.logs-no-more { + display: block; + text-align: center; + font-size: 0.78rem; + color: var(--fg-muted); + font-family: var(--font-body); + padding: 0.25rem 0 0.5rem; +} +.logs-tail-status { + font-size: 0.72rem; + font-family: var(--font-body); + color: var(--fg-muted); + margin-left: 0.4rem; + letter-spacing: 0.02em; +} +.logs-tail-status:not(:empty)::before { + content: "●"; + margin-right: 0.25rem; + font-size: 0.85em; +} +.logs-tail-status.pending::before { color: var(--warning, #c97a00); animation: logs-tail-pulse 1s ease-in-out infinite; } +.logs-tail-status.live::before { color: var(--success, #2c8a4a); animation: logs-tail-pulse 1.6s ease-in-out infinite; } +.logs-tail-status.err::before { color: var(--danger, #b03030); } +@keyframes logs-tail-pulse { + 0%, 100% { opacity: 0.45; } + 50% { opacity: 1; } +} +.logs-line { + white-space: pre; + border-left: 3px solid transparent; + padding: 0 0 0 0.4rem; +} +.logs-line.logs-err { + color: var(--danger); + border-left-color: var(--danger); + background: color-mix(in srgb, var(--danger) 7%, transparent); +} +.logs-line.logs-warn { + color: var(--warning); + border-left-color: var(--warning); + background: color-mix(in srgb, var(--warning) 6%, transparent); +} +.logs-status { + font-size: 0.8rem; + color: var(--fg-muted); +} +.logs-status code { + font-family: var(--font-mono); + font-size: 0.78rem; + color: var(--fg); +} + +/* Software & Reboot */ +.sw-install-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: 1.5rem; + margin-top: 1.5rem; +} +.sw-install-grid > .info-card { margin: 0; } + +.sw-card-muted { opacity: 0.6; pointer-events: none; } + +.sw-help-text { + font-size: 0.875rem; + color: var(--fg-muted); + margin-bottom: 1rem; + line-height: 1.6; +} +.sw-help-text a { color: var(--primary); } +.sw-help-text code, .sw-hint-body code { font-family: var(--font-mono); font-size: 0.8em; } + +.sw-hint { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + margin-bottom: 1.25rem; + font-size: 0.875rem; +} +.sw-hint summary { + cursor: pointer; + padding: 0.55rem 0.8rem; + color: var(--fg-muted); + user-select: none; + list-style: none; + display: flex; + align-items: center; + gap: 0.4rem; +} +.sw-hint summary::-webkit-details-marker { display: none; } +.sw-hint summary::before { content: '›'; display: inline-block; transition: transform 0.15s; } +details.sw-hint[open] summary::before { transform: rotate(90deg); } +.sw-hint-body { + padding: 0.75rem 0.8rem; + border-top: 1px solid var(--border); +} +.sw-hint-body p { margin: 0 0 0.5rem; color: var(--fg-muted); font-size: 0.85rem; } +.sw-hint-body p:last-child { margin-bottom: 0; } +.sw-hint-code { + display: block; + font-family: var(--font-mono); + font-size: 0.8rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.6rem 0.75rem; + white-space: pre; + overflow-x: auto; + color: var(--fg); + margin-bottom: 0.5rem; +} + +.sw-boot-order-row { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.55rem 1.25rem; + border-bottom: 1px solid var(--border); + font-size: 0.8rem; +} +.sw-boot-order-label { + color: var(--fg-muted); + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: 600; + margin-right: 0.2rem; +} + +.sw-boot-slots { display: flex; gap: 0.3rem; align-items: center; flex: 1; } +.sw-boot-badge { cursor: grab; user-select: none; } +.sw-boot-badge.sw-boot-dragging { opacity: 0.35; } +.sw-boot-badge.sw-boot-drop-before { box-shadow: -3px 0 0 var(--primary); } + +.sw-slot-list { display: flex; flex-direction: column; } +.sw-slot-item { + padding: 0.75rem 1.25rem; + border-bottom: 1px solid var(--border); +} +.sw-slot-item:last-child { border-bottom: none; } +.sw-slot-primary { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.2rem; +} +.sw-slot-name { font-weight: 600; font-size: 0.9rem; } +.sw-slot-version { + font-family: var(--font-mono); + font-size: 0.8rem; + color: var(--fg-muted); +} +.sw-slot-date { + font-size: 0.75rem; + color: var(--fg-muted); + opacity: 0.75; + margin-top: 0.1rem; +} + +.sw-upload-placeholder { + border: 2px dashed var(--border); + border-radius: var(--radius); + padding: 2rem 1rem; + text-align: center; + color: var(--fg-muted); + margin-bottom: 1rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.6rem; + font-size: 0.875rem; +} + +.software-form .form-group { margin-bottom: 0.75rem; } +.software-form .sw-checkbox-row { margin-bottom: 0.75rem; } + +.sw-checkbox-row { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + color: var(--fg-muted); + cursor: pointer; + user-select: none; +} +.sw-checkbox-row input[type="checkbox"] { accent-color: var(--primary); cursor: pointer; } + +/* Multi-select dropdown built on <details> + checkboxes so the summary + row looks like a native <select> trigger and the body holds the + checkbox list. Native <select multiple size="1"> never renders as + a dropdown, hence the substitute. */ +.cfg-multi { + position: relative; + display: inline-block; + min-width: 14rem; +} +.cfg-multi-summary { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.4rem 0.6rem; + background: var(--surface); + color: var(--fg); + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 0.875rem; + cursor: pointer; + list-style: none; + user-select: none; +} +.cfg-multi-summary::-webkit-details-marker { display: none; } +.cfg-multi-summary::after { + content: '\25BE'; + margin-left: 0.5rem; + color: var(--fg-muted); + transition: transform 0.15s; +} +details[open] > .cfg-multi-summary::after { transform: rotate(180deg); } +details[open] > .cfg-multi-summary { + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); +} +.cfg-multi-body { + position: absolute; + top: 100%; + left: 0; + right: 0; + margin-top: 0.25rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.5rem 0.75rem; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 10; + max-height: 20rem; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.35rem; +} +.cfg-multi-item { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + white-space: nowrap; + cursor: pointer; +} +.cfg-multi-item input[type="checkbox"] { accent-color: var(--primary); cursor: pointer; } + +.sw-result { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 0.9rem; + font-weight: 500; +} +.sw-result svg { flex-shrink: 0; } +.sw-result-ok { color: var(--success); } +.sw-result-err { color: var(--danger); } +.sw-result-body { display: flex; flex-direction: column; } +.sw-result-actions { margin-top: 1rem; } + +/* Compact pill badges used in the software slot list and boot-order row. */ +.sw-install-grid .badge-neutral { + background: var(--slate-200); + font-size: 0.65rem; + font-weight: 600; + padding: 0.15em 0.5em; + border-radius: 999px; + text-transform: uppercase; + letter-spacing: 0.04em; + vertical-align: middle; +} +@media (prefers-color-scheme: dark) { + .sw-install-grid .badge-neutral { background: var(--slate-700); color: var(--slate-300); } +} +.dark .sw-install-grid .badge-neutral { background: var(--slate-700); color: var(--slate-300); } + +.progress-bar-wrap--flush { margin-top: 0; } + +.progress-bar-wrap { + background: var(--slate-200); + border-radius: var(--radius); + height: 1.25rem; + overflow: hidden; + margin-top: 0.5rem; +} +.progress-bar { + background: var(--primary); + height: 100%; + width: 0%; + transition: width 0.3s ease; +} +.progress-text { font-size: 0.85rem; color: var(--fg-muted); margin-top: 0.5rem; text-align: center; } + +.reboot-overlay { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 60vh; +} +.reboot-spinner { + width: 48px; + height: 48px; + border: 4px solid var(--slate-200); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 1.5rem; +} +@keyframes spin { to { transform: rotate(360deg); } } + +.reboot-message { font-size: 1.25rem; font-weight: 600; color: var(--fg); } +.reboot-status { font-size: 1rem; color: var(--fg-muted); margin-top: 0.5rem; } +.reboot-status.is-error { color: var(--danger); } + +/* Keystore */ +.empty-message { color: var(--fg-muted); font-style: italic; padding: 1rem; text-align: center; } + +/* Overview Connectivity card. Reach indicators reuse .status-dot for shape; + these add the neutral "pending" colour, a reply pulse, and a colour-blind-safe + ✗ (shape, not just colour). */ +.dns-origin { color: var(--fg-muted); font-size: 0.8rem; } +.reach-pending { background: var(--slate-300); } +.dark .reach-pending { background: var(--slate-600); } +.reach-pulse { animation: reach-pulse 1.6s ease-in-out infinite; } +@keyframes reach-pulse { + 0% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.5); } + 70% { box-shadow: 0 0 0 5px rgba(34, 197, 94, 0); } + 100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); } +} +.reach-x { + display: inline-block; + width: 8px; + margin-right: 0.5rem; + text-align: center; + vertical-align: middle; + color: var(--danger); + font-weight: 700; +} +@media (prefers-reduced-motion: reduce) { + .reach-pulse { animation: none; } +} + +.key-row-toggle { + background: none; + border: none; + padding: 0; + cursor: pointer; + color: var(--fg); + font: inherit; + display: inline-flex; + align-items: center; + gap: 0.4rem; + text-align: left; +} +.key-row-arrow { + font-size: 0.6rem; + color: var(--fg-muted); + display: inline-block; + transition: transform 0.15s ease; + flex-shrink: 0; +} +.key-row-toggle[aria-expanded="true"] .key-row-arrow { transform: rotate(90deg); } + +.key-detail-row { display: none; } +.key-detail-row.is-open { display: table-row; } +.key-detail-cell { + background: var(--surface-alt); + padding: 0.75rem 1rem 0.75rem 1.5rem !important; + border-top: none !important; + text-align: left !important; +} +.key-detail-body { display: flex; flex-direction: column; gap: 0.6rem; } +.key-field { display: flex; flex-direction: column; gap: 0.2rem; } +.key-field-label { font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--fg-muted); } +.key-field-value { + font-family: var(--font-mono); + font-size: 0.8rem; + line-height: 1.6; + word-break: break-all; + white-space: pre-wrap; + color: var(--fg); + background: var(--surface); + padding: 0.4rem 0.6rem; + border-radius: 4px; + max-width: 72ch; + display: block; +} + +/* Survey */ +.survey-chart { max-width: 100%; height: auto; display: block; } +.survey-hint { font-size: 0.8rem; color: var(--fg-muted); margin-bottom: 1rem; } + +/* WiFi */ +.wifi-radio-header { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; +} + +.wifi-caps { + display: flex; + gap: 0.375rem; + align-items: center; +} + +.wifi-survey-body { + padding: 1rem; + display: flex; + align-items: center; + justify-content: center; +} + +.wifi-survey-body .survey-chart { + max-height: 220px; + width: auto; +} + +.wifi-ssid { + font-weight: 400; + color: var(--fg-muted); + font-size: 0.85rem; + margin-left: 0.5rem; + font-family: var(--font-mono); +} + +.wifi-band-list { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.wifi-band-item { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.wifi-band-caps { + display: inline-flex; + gap: 0.25rem; +} + +/* Signal Strength */ +.signal-excellent { color: var(--success); font-weight: 600; } +.signal-good { color: var(--green-600); font-weight: 600; } +.signal-ok { color: var(--warning); font-weight: 600; } +.signal-poor { color: var(--danger); font-weight: 600; } + +/* ========================================================================== + Topbar & User Menu + ========================================================================== */ + +.topbar { + position: fixed; + top: 0; + left: 0; + right: 0; + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0 1rem; + height: var(--topbar-height); + background: var(--sidebar-bg); + border-bottom: 1px solid var(--sidebar-border); + z-index: 200; +} + +.topbar-right { + display: flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; +} + +.hamburger-btn { + background: none; + border: 1px solid var(--border); + border-radius: 0.375rem; + padding: 0.375rem; + cursor: pointer; + color: var(--fg-muted); + display: none; + align-items: center; +} +.hamburger-btn:hover { + color: var(--fg); + border-color: var(--fg-muted); +} + +/* External web-app shortcuts (console, network browser) in the topbar */ +.topbar-link { + display: flex; + align-items: center; + justify-content: center; + padding: 0.35rem; + border-radius: var(--radius); + color: var(--fg-muted); + text-decoration: none; + transition: color 0.15s, background 0.15s; +} +.topbar-link:hover { + color: var(--fg); + background: var(--border); +} + +/* User menu button */ +.user-menu { + position: relative; +} + +.user-menu-btn { + display: flex; + align-items: center; + gap: 0.4rem; + background: none; + border: none; + border-radius: var(--radius); + padding: 0.35rem 0.5rem; + font-size: 0.875rem; + font-weight: 500; + color: var(--fg-muted); + cursor: pointer; + transition: color 0.15s; +} +.user-menu-btn:hover { + color: var(--fg); +} +.user-menu-btn .chevron { + color: var(--fg-muted); + transition: transform 0.2s; +} +.user-menu-btn[aria-expanded="true"] .chevron { + transform: rotate(180deg); +} + +/* Dropdown panel — opens on hover */ +.user-dropdown { + position: absolute; + top: 100%; + right: 0; + min-width: 190px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 0.375rem 0; + z-index: 200; + /* Hidden by default; shown on hover or when button is aria-expanded */ + opacity: 0; + pointer-events: none; + transform: translateY(-4px); + transition: opacity 0.15s, transform 0.15s; +} +.user-menu:hover .user-dropdown, +.user-menu-btn[aria-expanded="true"] + .user-dropdown { + opacity: 1; + pointer-events: auto; + transform: translateY(0); +} + +.dropdown-section-label { + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--fg-muted); + padding: 0.25rem 0.875rem 0.125rem; +} + +.dropdown-divider { + border: none; + border-top: 1px solid var(--border); + margin: 0.375rem 0; +} + +.dropdown-item { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.45rem 0.875rem; + font-size: 0.875rem; + color: var(--fg-muted); + background: none; + border: none; + cursor: pointer; + text-decoration: none; + text-align: left; + transition: background 0.1s, color 0.1s; + box-sizing: border-box; +} +.dropdown-item:hover, +.dropdown-item:focus { + background: var(--border); + color: var(--fg); + text-decoration: none; +} +.dropdown-item svg { flex-shrink: 0; color: var(--fg-muted); transition: color 0.1s; } +.dropdown-item:hover svg, +.dropdown-item:focus svg { color: var(--fg); } +.dropdown-item-danger:hover, +.dropdown-item-danger:focus { color: var(--danger); } +.dropdown-item-danger:hover svg, +.dropdown-item-danger:focus svg { color: var(--danger); } + +/* Theme checkmark — hidden by default, shown on active option */ +.theme-check { margin-left: auto; flex-shrink: 0; opacity: 0; transition: opacity 0.1s; } +.theme-opt.is-active .theme-check, +.timeout-opt.is-active .theme-check { opacity: 1; } + +/* Login page floating theme button */ +.login-theme-btn { + position: fixed; + bottom: 1.25rem; + right: 1.25rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.45rem; + cursor: pointer; + color: var(--fg-muted); + display: flex; + align-items: center; + justify-content: center; + box-shadow: var(--shadow-sm); + transition: all 0.15s; +} +.login-theme-btn:hover { color: var(--fg); border-color: var(--fg-muted); } + +.login-header { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + margin-bottom: 1.75rem; + color: var(--fg-muted); +} + +.login-header h1 { + font-size: 1.25rem; + font-weight: 600; + color: var(--fg); + line-height: 1; +} + +/* ========================================================================== + Responsive Layout + ========================================================================== */ + +/* Narrow (≤1024px): Sidebar hidden behind hamburger overlay */ +@media (max-width: 1024px) { + #sidebar { + position: fixed; + top: var(--topbar-height); + left: 0; + height: calc(100vh - var(--topbar-height)); + z-index: 150; + transform: translateX(-100%); + transition: transform 0.25s ease; + width: var(--sidebar-width); + } + + body.sidebar-open #sidebar { + transform: translateX(0); + } + + body.sidebar-open::after { + content: ''; + position: fixed; + top: var(--topbar-height); + left: 0; + right: 0; + bottom: 0; + background: rgba(0,0,0,0.4); + z-index: 140; + } + + #content { + height: auto; + min-height: 100%; + } + + .hamburger-btn { + display: flex; + } +} + +/* Desktop (>1024px): hamburger hidden, sidebar always visible */ +@media (min-width: 1025px) { + .hamburger-btn { + display: none; + } +} + +/* Column priority hiding for data tables */ +@media (max-width: 600px) { + .col-priority-2 { display: none; } +} +@media (max-width: 480px) { + .col-priority-3 { display: none; } +} + +/* ─── Confirm dialog ──────────────────────────────────────────────────────── */ +#confirm-dialog { + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + color: var(--fg); + padding: 1.5rem 2rem 1.25rem; + min-width: 20rem; + max-width: 90vw; + box-shadow: var(--shadow); + /* Centre in the viewport regardless of scroll position */ + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + margin: 0; +} +#confirm-dialog::backdrop { + background: rgba(0, 0, 0, 0.45); +} +.dialog-message { + font-size: 0.95rem; + margin: 0 0 1.25rem; + line-height: 1.5; +} +.dialog-actions { + display: flex; + gap: 0.75rem; + justify-content: flex-end; +} +.btn-outline { + background: transparent; + color: var(--fg); + border: 1px solid var(--border); +} +.btn-outline:hover { background: var(--border); } +.btn-danger-outline { + background: transparent; + color: var(--danger); + border: 1px solid var(--danger); +} +.btn-danger-outline:hover { background: rgba(220, 38, 38, 0.08); } + +.btn-accept { + background: var(--success); + color: #fff; + border: 1px solid transparent; +} +.btn-accept:hover { background: var(--green-600); } + +/* ─── Configure toolbar ───────────────────────────────────────────────────── */ +.configure-toolbar { + position: fixed; + bottom: 0; + left: var(--sidebar-width); + right: 0; + z-index: 130; + background: var(--surface); + border-top: 1px solid var(--border); + padding: 0.75rem 1.5rem; + box-shadow: 0 -2px 8px rgba(0,0,0,0.08); +} +@media (max-width: 1024px) { + .configure-toolbar { left: 0; } +} +.configure-toolbar-inner { + display: flex; + gap: 0.75rem; +} +/* Push page content above the toolbar */ +.configure-page { padding-bottom: 4rem; } + +/* ─── Unsaved-changes banner ──────────────────────────────────────────────── */ +.cfg-unsaved-banner { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.5rem 1.25rem; + background: var(--warning-bg); + border-bottom: 1px solid var(--warning-border); + color: var(--warning-fg); + font-size: 0.875rem; + flex-shrink: 0; +} + +/* cfgError inline feedback */ +.cfg-save-status.error { color: var(--danger); cursor: pointer; } + +/* ─── Configure activity log ─────────────────────────────────────────────── */ +.cfg-log-wrap { position: relative; margin-left: auto; } +.cfg-log-btn { position: relative; } +.cfg-log-badge { + position: absolute; + top: -6px; right: -6px; + min-width: 16px; height: 16px; + padding: 0 3px; + background: var(--danger); + color: #fff; + border-radius: 8px; + font-size: 0.65rem; + line-height: 16px; + text-align: center; +} +.cfg-log-panel { + display: none; + position: absolute; + bottom: calc(100% + 8px); + right: 0; + width: 420px; + max-height: 300px; + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: 0 4px 16px rgba(0,0,0,0.15); + flex-direction: column; + z-index: 300; + font-size: 0.8rem; +} +.cfg-log-panel:not([hidden]) { display: flex; } +.cfg-log-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.4rem 0.75rem; + border-bottom: 1px solid var(--border); + font-weight: 600; + font-size: 0.8rem; +} +.cfg-log-close { background: none; border: none; cursor: pointer; color: var(--fg-muted); font-size: 0.9rem; } +.cfg-log-list { list-style: none; margin: 0; padding: 0; overflow-y: auto; flex: 1; } +.cfg-log-entry { padding: 0.3rem 0.75rem; border-bottom: 1px solid var(--border-subtle, var(--border)); } +.cfg-log-empty { padding: 0.5rem 0.75rem; color: var(--fg-muted); } +.cfg-log-ts { color: var(--fg-muted); margin-right: 0.4rem; } +.cfg-log-ok { color: var(--fg); } +.cfg-log-error { color: var(--danger); background: rgba(var(--danger-rgb, 220,38,38), 0.05); } + +/* ─── Editable form inputs inside cards ───────────────────────────────────── */ +.cfg-input { + width: 100%; + padding: 0.4rem 0.6rem; + background: var(--surface); + color: var(--fg); + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 0.875rem; + font-family: inherit; + transition: border-color 0.15s, box-shadow 0.15s; + box-sizing: border-box; +} +.cfg-input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); +} +select.cfg-input { + appearance: none; + -webkit-appearance: none; + padding-right: 2rem; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.6rem center; + cursor: pointer; +} +.cfg-input-sm { max-width: 8rem; } +/* Inputs in info-table cells must shrink to the column; without this the + intrinsic min-width of a datetime-local (wide in Firefox) forces the table + past the card edge. min-width:0 isn't enough on its own for datetime-local, + so cards with one use .info-table-fixed (table-layout: fixed + <colgroup>) + to cap the columns and make the input shrink. */ +.info-table .cfg-input { min-width: 0; } +.info-table-fixed { table-layout: fixed; } +.cfg-input-mono { font-family: var(--font-mono); font-size: 0.85rem; } +.cfg-reset-col { width: 1%; white-space: nowrap; padding-left: 0.25rem; } +.cfg-section-head { font-size: 0.85rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin: 0 0 0.4rem; padding-bottom: 0.25rem; border-bottom: 1px solid var(--border); } +.cfg-subsection-head { font-size: 0.8rem; font-weight: 600; color: var(--text-muted); margin: 0.5rem 0 0.25rem; } +.cfg-fold { margin: 0.5rem 0 0; } +.cfg-fold > summary { font-size: 0.85rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); padding-bottom: 0.25rem; border-bottom: 1px solid var(--border); cursor: pointer; list-style: none; display: flex; align-items: center; gap: 0.4rem; user-select: none; } +.cfg-fold > summary::-webkit-details-marker { display: none; } +.cfg-fold > summary::before { content: '›'; display: inline-block; transition: transform 0.15s; font-size: 1rem; } +details.cfg-fold[open] > summary::before { transform: rotate(90deg); } +.cfg-fold-body { padding-top: 0.5rem; } +.fw-check-scroll { max-height: 12rem; overflow-y: auto; border: 1px solid var(--border); border-radius: 4px; padding: 0.4rem 0.6rem; background: var(--input-bg, var(--surface)); } +.fw-check-grid { display: flex; flex-wrap: wrap; gap: 0.35rem 1.25rem; } +.fw-check-grid label { display: flex; align-items: center; gap: 0.35rem; font-weight: normal; cursor: pointer; } + +/* Editable table rows */ +.cfg-table td { padding: 0.45rem 0.75rem; vertical-align: middle; } +.cfg-table > tbody > tr > td:last-child { width: 3rem; text-align: center; } +/* Leading nav-chevron column for complex (drill-down) list rows */ +.yt-nav-col { width: 1.5rem; padding: 0.25rem !important; text-align: center; color: var(--fg-muted); } +.btn-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + border: none; + border-radius: var(--radius); + background: transparent; + color: var(--fg-muted); + cursor: pointer; + transition: background 0.1s, color 0.1s; + padding: 0; +} +.btn-icon:hover { background: var(--border); color: var(--fg); } +.btn-icon-danger:hover { color: var(--danger); background: rgba(220,38,38,0.08); } +.btn-add-row { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.8rem; + color: var(--primary); + background: none; + border: none; + cursor: pointer; + padding: 0.5rem 0.75rem; +} +.btn-add-row:hover { text-decoration: underline; } + +/* Inline forms inside users table */ +.user-shell-form, .user-add-inline { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} +.user-shell-form .cfg-input { flex: 1; min-width: 0; } +.user-add-inline .cfg-input { width: auto; flex: 1; min-width: 8rem; } + +/* Add Interface modal dialog */ +.iface-modal { + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + color: var(--fg); + padding: 0; + min-width: 42rem; + max-width: 90vw; + box-shadow: var(--shadow); + /* Native <dialog>.showModal() centers by default, but some browsers + drift on long content — pin the position to be safe. */ + position: fixed; + inset: 0; + margin: auto; +} +.iface-modal::backdrop { + background: rgba(0, 0, 0, 0.45); +} +.iface-modal-form { + display: flex; + flex-direction: column; +} +.iface-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border); +} +.iface-modal-header h3 { + margin: 0; + font-size: 1rem; +} +.iface-modal-header .btn-close { + background: none; + border: none; + font-size: 1.4rem; + line-height: 1; + cursor: pointer; + color: var(--fg-muted); + padding: 0 0.25rem; +} +.iface-modal-header .btn-close:hover { + color: var(--fg); +} +.iface-modal-body { + padding: 0.75rem 1rem; +} +.iface-modal-footer { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.6rem 1rem; + border-top: 1px solid var(--border); +} +.iface-fields { + border: none; + padding: 0; + margin: 0.5rem 0 0; +} +.iface-fields-unsupported { + padding: 0.5rem 0; +} +.cfg-hint { + font-size: 0.8rem; + color: var(--fg-muted); + margin-top: 0.25rem; +} + +/* Inline keystore "+ New" create-key form, used by the Add Interface + modal's WiFi PSK and WireGuard private-key rows. */ +.ks-picker-row { + display: flex; + align-items: center; + gap: 0.5rem; +} +.ks-picker-row > select { flex: 1; min-width: 0; } +.ks-create-form { + margin-top: 0.5rem; + padding: 0.5rem; + border: 1px dashed var(--border); + border-radius: 4px; + display: flex; + flex-direction: column; + gap: 0.35rem; + background: var(--card-bg); +} +/* `display: flex` above overrides the `[hidden]` attribute's UA-stylesheet + `display: none`, so the fold-out form was always shown. Restore the + collapsed-by-default behaviour. */ +.ks-create-form[hidden] { display: none; } +.ks-create-form > input, +.ks-create-form > textarea { width: 100%; } +.ks-create-actions { + display: flex; + gap: 0.5rem; +} +/* WiFi PSK passphrase — masked text input, NOT type="password". A real + password input pulls in browser password managers; the WiFi key is a + device-config value, not a per-site credential, so we mask with CSS + instead and let the user toggle visibility via the eye button. The + webkit property covers Chrome/Edge/Safari (>90% of browsers); Firefox + shows the value in plain text. */ +.cfg-input-mask { + -webkit-text-security: disc; + font-family: text-security-disc, inherit; + letter-spacing: 0.08em; +} +.cfg-input-mask.cfg-secret-shown { + -webkit-text-security: none; + font-family: inherit; + letter-spacing: normal; +} +.cfg-secret-wrap { + display: flex; + align-items: center; + gap: 0.35rem; +} +.cfg-secret-wrap > input { flex: 1; min-width: 0; } +.cfg-secret-toggle { + background: none; + border: 1px solid var(--border); + border-radius: 4px; + padding: 0.25rem 0.4rem; + cursor: pointer; + font-size: 0.9rem; + line-height: 1; +} + +/* Sub-table inside an inline create form. Same row layout as the rest + of the wizard (label on the left, value on the right) but indented a + touch so the user reads it as "fields belonging to the picker above". */ +.info-table-sub { + margin: 0; +} +.info-table-sub th { + padding-left: 0.75rem; + font-weight: normal; + font-size: 0.85rem; + color: var(--fg-muted); +} + +/* Card-level save feedback */ +.cfg-card-footer { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.6rem 1rem; + border-top: 1px solid var(--border); +} +.cfg-save-status { + font-size: 0.8rem; + color: var(--fg-muted); + min-height: 1.2em; +} +.cfg-save-status.saved { color: #16a34a; } +.cfg-save-error { font-size: 0.8rem; color: var(--danger); } +@media (prefers-color-scheme: dark) { + .cfg-save-status.saved { color: #86efac; } +} +.dark .cfg-save-status.saved { color: #86efac; } +.light .cfg-save-status.saved { color: #16a34a; } + +/* ========================================================================== + System Control page + ========================================================================== */ + +.sc-action-body { + display: flex; + flex-direction: column; + justify-content: space-between; + flex: 1; + gap: 1rem; +} + +.sc-desc { + color: var(--fg-muted); + font-size: 0.875rem; + line-height: 1.5; + margin: 0; +} + +.sc-danger-card { border-color: var(--warning-border); } + +.sc-danger-body { display: flex; flex-direction: column; } + +.sc-danger-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1.5rem; + padding: 1.25rem; + flex-wrap: wrap; +} + +.sc-danger-text { flex: 1; min-width: 0; } +.sc-danger-text strong { display: block; margin-bottom: 0.35rem; } + +.sc-danger-sep { height: 1px; background: var(--border); margin: 0 1.25rem; } + +.sc-fd-ok { color: var(--success); font-size: 0.875rem; } +.sc-fd-err { color: var(--danger); font-size: 0.875rem; } + +.sc-dt-row { + display: flex; + gap: 0.5rem; + align-items: center; + flex-wrap: wrap; + margin-bottom: 0.5rem; +} + +.sc-restore-label { + display: flex; + align-items: center; + gap: 0.4rem; + margin-top: 0.75rem; + cursor: pointer; +} + +/* Restore card: let the form fill the body so its file selector and checkbox + sit under the leading paragraph, with only the submit button anchored at the + bottom (aligned with the other action cards). */ +#restore-form { + display: flex; + flex-direction: column; + flex: 1; +} +.sc-form-actions { + margin-top: auto; + padding-top: 0.75rem; +} +.sc-restore-warn-text { + color: var(--warning-fg); + font-size: 0.875rem; +} + +/* ========================================================================== + YANG Tree Navigation + ========================================================================== */ + +/* Two-column layout: tree pane + detail pane */ +.yang-layout { + display: flex; + gap: 1.5rem; + align-items: flex-start; +} + +.yang-tree-pane { + width: 260px; + flex-shrink: 0; + overflow-y: auto; + max-height: calc(100vh - 14rem); + margin-bottom: 0; +} + +.yang-detail-pane { + flex: 1; + min-width: 0; +} + +/* Tree list */ +.yang-tree, +.yt-children { + list-style: none; + padding: 0; + margin: 0; +} + +.yang-tree { padding: 0.25rem 0; } + +.yt-children { + padding-left: 0; + margin-left: 1.1rem; +} + +.yt-children > li { + position: relative; + padding-left: 1rem; +} + +/* Horizontal arm */ +.yt-children > li::before { + content: ''; + position: absolute; + left: 0; + top: 0.85rem; + width: 0.75rem; + height: 1px; + background: var(--border); +} + +/* Vertical connector — runs full height for non-last, stops at arm for last */ +.yt-children > li::after { + content: ''; + position: absolute; + left: 0; + top: 0; + width: 1px; + background: var(--border); +} +.yt-children > li:not(:last-child)::after { + bottom: 0; +} +.yt-children > li:last-child::after { + height: calc(0.85rem + 1px); +} + +/* Style for the root "/" node */ +.yt-root { + font-family: var(--font-mono); + font-weight: 600; + color: var(--fg-muted); +} + +/* Expandable node (container / list) */ +.yt-node { + position: relative; +} + +/* Suppress the browser's default triangle on <summary> */ +.yt-node > summary { list-style: none; } +.yt-node > summary::-webkit-details-marker { display: none; } + +/* Shared label style for both <summary> and <button> */ +.yt-label { + display: flex; + align-items: center; + gap: 0.35rem; + width: 100%; + padding: 0.22rem 0.75rem; + background: none; + border: none; + text-align: left; + font-size: 0.8rem; + color: var(--fg); + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border-radius: var(--radius-sm); + transition: background 0.1s, color 0.1s; + line-height: 1.4; + font-family: inherit; +} +.yt-label:hover { + background: var(--border-subtle); + color: var(--primary); +} +/* Currently-selected node: set on click and when arriving via a status-page + "Configure →" deep link, so the left tree shows where the right pane is. */ +.yt-label.yt-active { + background: var(--border-subtle); + color: var(--primary); + font-weight: 600; + box-shadow: inset 2px 0 0 var(--primary); +} + +/* Chevron rotates on open */ +.yt-chevron { + flex-shrink: 0; + color: var(--fg-muted); + transition: transform 0.15s; +} +details[open] > summary .yt-chevron { transform: rotate(90deg); } + +/* Node name */ +.yt-name { + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + min-width: 0; +} + +/* Presence toggle — card header right side */ +.yt-presence-header { margin-left: auto; } +.yt-presence-toggle { + font-size: 0.75rem; + padding: 0.15em 0.6em; + border-radius: 2em; +} +.yt-presence-on { + color: var(--accept-fg, #1a7f37); + border: 1px solid var(--accept-fg, #1a7f37); + background: transparent; +} +.yt-presence-on:hover { opacity: 0.75; } +.yt-presence-off { + color: var(--fg-muted); + border: 1px solid var(--border); + background: transparent; +} +.yt-presence-off:hover { opacity: 0.75; } + +/* Read-only badge */ +.yt-ro { + font-size: 0.6rem; + font-weight: 600; + color: var(--fg-muted); + border: 1px solid var(--border); + border-radius: 3px; + padding: 0 0.25em; + flex-shrink: 0; +} + +/* Kind badges */ +.yt-badge { + font-size: 0.6rem; + font-weight: 700; + padding: 0.1em 0.3em; + border-radius: 3px; + flex-shrink: 0; + letter-spacing: 0.03em; + line-height: 1.4; +} +.yt-badge-lg { + font-size: 0.75rem; + padding: 0.15em 0.45em; +} +.yt-badge-container { background: rgba(59,130,246,0.12); color: var(--blue-500); }\n.yt-badge-list-instance { background: rgba(139,92,246,0.07); color: #8b5cf6; } +.yt-badge-list { background: rgba(139,92,246,0.12); color: #8b5cf6; } +.yt-badge-leaf { background: rgba(34,197,94,0.12); color: var(--green-600); font-size: 0.8rem; } +.yt-badge-leaf-list { background: rgba(34,197,94,0.12); color: var(--green-600); } +.yt-badge-rpc { background: rgba(245,158,11,0.12); color: var(--amber-500, #f59e0b); } +.yt-badge-anydata, +.yt-badge-anyxml { background: var(--border-subtle); color: var(--fg-muted); } + +/* Detail pane empty state */ +.yang-detail-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 200px; + color: var(--fg-muted); + font-size: 0.875rem; + border: 1px dashed var(--border); + border-radius: var(--radius); + text-align: center; + padding: 2rem; +} +.yang-detail-empty p { margin: 0; } + +/* Detail pane path */ +.yt-path { + font-size: 0.75rem; + font-family: var(--font-mono, monospace); + word-break: break-all; + color: var(--fg-muted); +} + +/* Enum / identity value chips */ +.yt-value-list { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; +} +.yt-enum { + font-size: 0.75rem; + font-family: var(--font-mono, monospace); + background: var(--border-subtle); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.1em 0.4em; +} + +/* Alert-info (used for loading state) */ +.alert-info { + background: rgba(59,130,246,0.08); + border: 1px solid rgba(59,130,246,0.25); + color: var(--blue-500, #3b82f6); + border-radius: var(--radius); + padding: 0.75rem 1rem; + font-size: 0.875rem; + margin-bottom: 1.5rem; +} + +/* Responsive: stack panes on narrow screens */ +@media (max-width: 680px) { + .yang-layout { flex-direction: column; } + .yang-tree-pane { width: 100%; max-height: 40vh; } +} + +/* ── YANG leaf edit form ──────────────────────────────────────────────────── */ +.yt-edit-form { border-top: 1px solid var(--border); margin-top: 0; } +.yt-bool-group { display: flex; gap: 1.5rem; align-items: center; margin-top: 0.25rem; flex-wrap: wrap; } +.yt-bool-group label { display: flex; align-items: center; gap: 0.4rem; font-weight: normal; cursor: pointer; } +.yt-unset-hint { font-size: 0.8rem; font-style: italic; color: var(--text-muted); } +.yt-default-hint { font-size: 0.8rem; color: var(--text-muted); } +.yt-value-label { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + font-weight: 500; + flex: 1; +} +.yt-value-label .cfg-input { flex: 1; } + +/* ── Schema details foldout (node detail pane, standalone card) ───────────── */ +.yt-schema-details { border-top: 1px solid var(--border); margin-top: 0; } +.yt-schema-details summary { + padding: 0.4rem 0.75rem; + font-size: 0.8rem; + color: var(--text-muted); + cursor: pointer; + user-select: none; + list-style: none; + display: flex; + align-items: center; + gap: 0.35rem; +} +.yt-schema-details summary::-webkit-details-marker { display: none; } +.yt-schema-details summary::before { content: '▸'; font-size: 0.7rem; transition: transform 0.15s; } +details[open].yt-schema-details summary::before { transform: rotate(90deg); } +.yt-schema-details summary:hover { color: var(--text); } +.yt-schema-body { padding: 0 1rem 0.75rem; } + +/* ── YANG leaf-group form (all-leaves container) ─────────────────────────── */ +.yt-group-form { margin-top: 0; } +.yt-group-list { display: flex; flex-direction: column; } + +.yt-leaf-item { border-bottom: 1px solid var(--border); } +.yt-leaf-item:last-child { border-bottom: none; } +.yt-leaf-item summary::-webkit-details-marker { display: none; } + +.yt-leaf-summary { + display: grid; + grid-template-columns: minmax(6rem, auto) 1fr auto; + align-items: start; + gap: 0.5rem; + padding: 0.45rem 0.75rem; + cursor: pointer; + list-style: none; + user-select: none; +} +.yt-leaf-summary:hover .yt-leaf-name { color: var(--link, var(--text)); } + +.yt-leaf-name { + display: flex; + align-items: center; + gap: 0.35rem; + white-space: nowrap; + font-weight: 500; +} +.yt-leaf-name::before { + content: '▸'; + font-size: 0.65rem; + color: var(--text-muted); + transition: transform 0.15s; + flex-shrink: 0; +} +.yt-leaf-no-expand::before { visibility: hidden; } +details[open].yt-leaf-item .yt-leaf-name::before { transform: rotate(90deg); } + +.yt-leaf-input { min-width: 0; } +.yt-leaf-input .cfg-input { width: 100%; } + +.yt-leaf-help { + padding: 0.5rem 0.75rem 0.65rem calc(0.75rem + 1rem); /* indent under name */ + background: var(--bg-subtle, color-mix(in srgb, var(--bg-secondary) 60%, transparent)); + border-top: 1px solid var(--border); +} + +.yt-schema-table { width: 100%; font-size: 0.8125rem; margin-top: 0.5rem; } +.yt-description { + font-size: 0.875rem; + color: var(--text); + line-height: 1.55; + margin: 0 0 0.5rem; + white-space: pre-wrap; +} +.yt-mandatory { color: var(--red-600, #dc2626); margin-left: 0.15rem; } +.btn-sm { padding: 0.2rem 0.5rem; font-size: 0.8rem; } + +/* ── ⓘ field-info tooltip ────────────────────────────────────────────────── */ +.field-info { + display: inline-block; + cursor: help; + font-size: 0.85em; + font-weight: normal; + color: var(--fg-muted); + margin-left: 0.3rem; + vertical-align: middle; + line-height: 1; + position: relative; + top: -2px; +} +#field-tip { + position: fixed; + background: var(--bg-tooltip, #1e1e2e); + color: var(--fg-tooltip, #cdd6f4); + border: 1px solid var(--border); + padding: 0.45rem 0.6rem; + border-radius: 5px; + font-size: 0.75rem; + line-height: 1.5; + white-space: pre-wrap; + max-width: 22rem; + width: max-content; + pointer-events: none; + z-index: 9999; + box-shadow: 0 4px 12px rgba(0,0,0,0.2); + display: none; +} + +/* ── Sub-sections navigation panel ───────────────────────────────────────── */ +.yt-subsections { border-top: 1px solid var(--border); padding: 0.25rem 0; } +.yt-subsections-header { + padding: 0.5rem 1rem 0.25rem; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); +} +.yt-subsection-item { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.45rem 1rem; + background: none; + border: none; + cursor: pointer; + text-align: left; + font-size: 0.875rem; + color: var(--text); + border-radius: 0; +} +.yt-subsection-item:hover { background: var(--hover-bg, rgba(0,0,0,0.04)); } +.yt-subsection-name { flex: 1; } +.yt-subsection-arrow { color: var(--text-muted); flex-shrink: 0; } + +/* Auto-generated list table */ +.yt-list-row { cursor: pointer; } +.yt-list-row:hover td { background: var(--border-subtle); } +.yt-list-row-nav { cursor: pointer; } +.yt-list-row-nav td { padding-right: 0.25rem; } +.yt-row-arrow { color: var(--fg-muted); vertical-align: middle; } +.yt-table-empty { text-align: center; color: var(--fg-muted); padding: 1.5rem; } +.cfg-action-col { width: 2rem; text-align: center; } + +/* Back button in card header when navigated from a parent list */ +.card-header { display: flex; align-items: center; gap: 0.5rem; } +.yt-back-btn { margin-right: 0.25rem; flex-shrink: 0; } +.card-header-action { margin-left: auto; flex-shrink: 0; white-space: nowrap; } + + +/* Inline list / flat container sections inside a leaf-group card */ +.yt-inline-list, +.yt-inline-container { border-top: 1px solid var(--border); } + +/* Success / error feedback banners inside detail pane */ +.alert-success { + background: rgba(34,197,94,0.1); + border: 1px solid rgba(34,197,94,0.3); + color: var(--green-600, #16a34a); + border-radius: var(--radius); + padding: 0.5rem 0.75rem; + font-size: 0.8125rem; + margin: 0.75rem 1rem 0; +} +.alert-error { + background: rgba(239,68,68,0.1); + border: 1px solid rgba(239,68,68,0.3); + color: var(--red-600, #dc2626); + border-radius: var(--radius); + padding: 0.5rem 0.75rem; + font-size: 0.8125rem; + margin: 0.75rem 1rem 0; +} diff --git a/src/webui/static/img/icons/README.md b/src/webui/static/img/icons/README.md new file mode 100644 index 00000000..299e7d30 --- /dev/null +++ b/src/webui/static/img/icons/README.md @@ -0,0 +1,62 @@ +# Sidebar icons + +All icons in this directory are from [Lucide][1] (ISC-licensed), +vendored locally so the WebUI works on air-gapped industrial sites +without an external CDN. + +Each file is the upstream `<lucide>/icons/<name>.svg` with the `width` +and `height` attributes stripped so the icon scales to its CSS-defined +size in the sidebar. Stroke styling (`fill="none"`, +`stroke="currentColor"`, `stroke-width="2"`, rounded caps/joins) is left +untouched. + +| File | Lucide source | +|-------------------|-----------------------| +| advanced.svg | folder-git-2 | +| backup.svg | archive-restore | +| console.svg | square-terminal | +| containers.svg | container | +| dashboard.svg | circle-gauge | +| dhcp.svg | network | +| diagnostics.svg | activity | +| dns.svg | globe | +| download.svg | download | +| firewall.svg | shield-check | +| hardware.svg | cpu | +| interfaces.svg | ethernet-port | +| keystore.svg | key-round | +| lldp.svg | radio-tower | +| logs.svg | scroll-text | +| mdns.svg | radio | +| nacm.svg | users | +| netbrowse.svg | radar | +| ntp.svg | clock | +| routes.svg | git-compare-arrows | +| routing.svg | git-compare-arrows | +| services.svg | library-big | +| software.svg | hard-drive-download | +| status-tree.svg | text-search | +| system-control.svg| power | +| system.svg | settings | +| wifi.svg | wifi | +| wireguard.svg | globe-lock | + +## Replacing or adding an icon + +1. Pick the icon at [lucide.dev/icons][2]. +2. Download the SVG (or copy from `lucide-icons/lucide`'s `icons/` + directory on GitHub). +3. Drop the `width` and `height` attributes from the opening `<svg>` + tag. +4. Save under the destination filename (the sidebar template references + files by purpose, not by upstream Lucide name). +5. Update the table above. + +## License + +Lucide is distributed under the [ISC license][3]. The license text is +preserved in the upstream repository. + +[1]: https://lucide.dev +[2]: https://lucide.dev/icons/ +[3]: https://github.com/lucide-icons/lucide/blob/main/LICENSE diff --git a/src/webui/static/img/icons/advanced.svg b/src/webui/static/img/icons/advanced.svg new file mode 100644 index 00000000..d16a1bbf --- /dev/null +++ b/src/webui/static/img/icons/advanced.svg @@ -0,0 +1,6 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <path d="M18 19a5 5 0 0 1-5-5v8" /> + <path d="M9 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v5" /> + <circle cx="13" cy="12" r="2" /> + <circle cx="20" cy="19" r="2" /> +</svg> diff --git a/src/webui/static/img/icons/backup.svg b/src/webui/static/img/icons/backup.svg new file mode 100644 index 00000000..67640e73 --- /dev/null +++ b/src/webui/static/img/icons/backup.svg @@ -0,0 +1,7 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <rect width="20" height="5" x="2" y="3" rx="1" /> + <path d="M4 8v11a2 2 0 0 0 2 2h2" /> + <path d="M20 8v11a2 2 0 0 1-2 2h-2" /> + <path d="m9 15 3-3 3 3" /> + <path d="M12 12v9" /> +</svg> diff --git a/src/webui/static/img/icons/console.svg b/src/webui/static/img/icons/console.svg new file mode 100644 index 00000000..8454b87c --- /dev/null +++ b/src/webui/static/img/icons/console.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 11 2-2-2-2"/><path d="M11 13h4"/><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/></svg> diff --git a/src/webui/static/img/icons/containers.svg b/src/webui/static/img/icons/containers.svg new file mode 100644 index 00000000..cee19d2f --- /dev/null +++ b/src/webui/static/img/icons/containers.svg @@ -0,0 +1,7 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <path d="M22 7.7c0-.6-.4-1.2-.8-1.5l-6.3-3.9a1.72 1.72 0 0 0-1.7 0l-10.3 6c-.5.2-.9.8-.9 1.4v6.6c0 .5.4 1.2.8 1.5l6.3 3.9a1.72 1.72 0 0 0 1.7 0l10.3-6c.5-.3.9-1 .9-1.5Z" /> + <path d="M10 21.9V14L2.1 9.1" /> + <path d="m10 14 11.9-6.9" /> + <path d="M14 19.8v-8.1" /> + <path d="M18 17.5V9.4" /> +</svg> diff --git a/src/webui/static/img/icons/dashboard.svg b/src/webui/static/img/icons/dashboard.svg new file mode 100644 index 00000000..3e5a8c62 --- /dev/null +++ b/src/webui/static/img/icons/dashboard.svg @@ -0,0 +1,5 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <path d="M15.6 2.7a10 10 0 1 0 5.7 5.7" /> + <circle cx="12" cy="12" r="2" /> + <path d="M13.4 10.6 19 5" /> +</svg> diff --git a/src/webui/static/img/icons/dhcp.svg b/src/webui/static/img/icons/dhcp.svg new file mode 100644 index 00000000..fe0c7d94 --- /dev/null +++ b/src/webui/static/img/icons/dhcp.svg @@ -0,0 +1,7 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <rect x="16" y="16" width="6" height="6" rx="1" /> + <rect x="2" y="16" width="6" height="6" rx="1" /> + <rect x="9" y="2" width="6" height="6" rx="1" /> + <path d="M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3" /> + <path d="M12 12V8" /> +</svg> diff --git a/src/webui/static/img/icons/diagnostics.svg b/src/webui/static/img/icons/diagnostics.svg new file mode 100644 index 00000000..903be414 --- /dev/null +++ b/src/webui/static/img/icons/diagnostics.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2"/></svg> diff --git a/src/webui/static/img/icons/dns.svg b/src/webui/static/img/icons/dns.svg new file mode 100644 index 00000000..fe14646e --- /dev/null +++ b/src/webui/static/img/icons/dns.svg @@ -0,0 +1,5 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <circle cx="12" cy="12" r="10" /> + <path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" /> + <path d="M2 12h20" /> +</svg> diff --git a/src/webui/static/img/icons/download.svg b/src/webui/static/img/icons/download.svg new file mode 100644 index 00000000..b4666418 --- /dev/null +++ b/src/webui/static/img/icons/download.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg> diff --git a/src/webui/static/img/icons/firewall.svg b/src/webui/static/img/icons/firewall.svg new file mode 100644 index 00000000..77114bfd --- /dev/null +++ b/src/webui/static/img/icons/firewall.svg @@ -0,0 +1,4 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /> + <path d="m9 12 2 2 4-4" /> +</svg> diff --git a/src/webui/static/img/icons/hardware.svg b/src/webui/static/img/icons/hardware.svg new file mode 100644 index 00000000..6220b461 --- /dev/null +++ b/src/webui/static/img/icons/hardware.svg @@ -0,0 +1,16 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <path d="M12 20v2" /> + <path d="M12 2v2" /> + <path d="M17 20v2" /> + <path d="M17 2v2" /> + <path d="M2 12h2" /> + <path d="M2 17h2" /> + <path d="M2 7h2" /> + <path d="M20 12h2" /> + <path d="M20 17h2" /> + <path d="M20 7h2" /> + <path d="M7 20v2" /> + <path d="M7 2v2" /> + <rect x="4" y="4" width="16" height="16" rx="2" /> + <rect x="8" y="8" width="8" height="8" rx="1" /> +</svg> diff --git a/src/webui/static/img/icons/interfaces.svg b/src/webui/static/img/icons/interfaces.svg new file mode 100644 index 00000000..d0842d7b --- /dev/null +++ b/src/webui/static/img/icons/interfaces.svg @@ -0,0 +1,7 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <path d="m15 20 3-3h2a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h2l3 3z" /> + <path d="M6 8v1" /> + <path d="M10 8v1" /> + <path d="M14 8v1" /> + <path d="M18 8v1" /> +</svg> diff --git a/src/webui/static/img/icons/keystore.svg b/src/webui/static/img/icons/keystore.svg new file mode 100644 index 00000000..2490743a --- /dev/null +++ b/src/webui/static/img/icons/keystore.svg @@ -0,0 +1,4 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z" /> + <circle cx="16.5" cy="7.5" r=".5" fill="currentColor" /> +</svg> diff --git a/src/webui/static/img/icons/lldp.svg b/src/webui/static/img/icons/lldp.svg new file mode 100644 index 00000000..33645edc --- /dev/null +++ b/src/webui/static/img/icons/lldp.svg @@ -0,0 +1,9 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <path d="M4.9 16.1C1 12.2 1 5.8 4.9 1.9" /> + <path d="M7.8 4.7a6.14 6.14 0 0 0-.8 7.5" /> + <circle cx="12" cy="9" r="2" /> + <path d="M16.2 4.8c2 2 2.26 5.11.8 7.47" /> + <path d="M19.1 1.9a9.96 9.96 0 0 1 0 14.1" /> + <path d="M9.5 18h5" /> + <path d="m8 22 4-11 4 11" /> +</svg> diff --git a/src/webui/static/img/icons/logs.svg b/src/webui/static/img/icons/logs.svg new file mode 100644 index 00000000..58935b78 --- /dev/null +++ b/src/webui/static/img/icons/logs.svg @@ -0,0 +1,6 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <path d="M15 12h-5" /> + <path d="M15 8h-5" /> + <path d="M19 17V5a2 2 0 0 0-2-2H4" /> + <path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3" /> +</svg> diff --git a/src/webui/static/img/icons/mdns.svg b/src/webui/static/img/icons/mdns.svg new file mode 100644 index 00000000..e675a1a7 --- /dev/null +++ b/src/webui/static/img/icons/mdns.svg @@ -0,0 +1,7 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <path d="M16.247 7.761a6 6 0 0 1 0 8.478" /> + <path d="M19.075 4.933a10 10 0 0 1 0 14.134" /> + <path d="M4.925 19.067a10 10 0 0 1 0-14.134" /> + <path d="M7.753 16.239a6 6 0 0 1 0-8.478" /> + <circle cx="12" cy="12" r="2" /> +</svg> diff --git a/src/webui/static/img/icons/nacm.svg b/src/webui/static/img/icons/nacm.svg new file mode 100644 index 00000000..070f3c4a --- /dev/null +++ b/src/webui/static/img/icons/nacm.svg @@ -0,0 +1,6 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /> + <path d="M16 3.128a4 4 0 0 1 0 7.744" /> + <path d="M22 21v-2a4 4 0 0 0-3-3.87" /> + <circle cx="9" cy="7" r="4" /> +</svg> diff --git a/src/webui/static/img/icons/netbrowse.svg b/src/webui/static/img/icons/netbrowse.svg new file mode 100644 index 00000000..b06537e3 --- /dev/null +++ b/src/webui/static/img/icons/netbrowse.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19.07 4.93A10 10 0 0 0 6.99 3.34"/><path d="M4 6h.01"/><path d="M2.29 9.62A10 10 0 1 0 21.31 8.35"/><path d="M16.24 7.76A6 6 0 1 0 8.23 16.67"/><path d="M12 18h.01"/><path d="M17.99 11.66A6 6 0 0 1 15.77 16.67"/><circle cx="12" cy="12" r="2"/><path d="m13.41 10.59 5.66-5.66"/></svg> diff --git a/src/webui/static/img/icons/ntp.svg b/src/webui/static/img/icons/ntp.svg new file mode 100644 index 00000000..5efa3567 --- /dev/null +++ b/src/webui/static/img/icons/ntp.svg @@ -0,0 +1,4 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <circle cx="12" cy="12" r="10" /> + <path d="M12 6v6l4 2" /> +</svg> diff --git a/src/webui/static/img/icons/routes.svg b/src/webui/static/img/icons/routes.svg new file mode 100644 index 00000000..d0745adc --- /dev/null +++ b/src/webui/static/img/icons/routes.svg @@ -0,0 +1,8 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <circle cx="5" cy="6" r="3" /> + <path d="M12 6h5a2 2 0 0 1 2 2v7" /> + <path d="m15 9-3-3 3-3" /> + <circle cx="19" cy="18" r="3" /> + <path d="M12 18H7a2 2 0 0 1-2-2V9" /> + <path d="m9 15 3 3-3 3" /> +</svg> diff --git a/src/webui/static/img/icons/routing.svg b/src/webui/static/img/icons/routing.svg new file mode 100644 index 00000000..d0745adc --- /dev/null +++ b/src/webui/static/img/icons/routing.svg @@ -0,0 +1,8 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <circle cx="5" cy="6" r="3" /> + <path d="M12 6h5a2 2 0 0 1 2 2v7" /> + <path d="m15 9-3-3 3-3" /> + <circle cx="19" cy="18" r="3" /> + <path d="M12 18H7a2 2 0 0 1-2-2V9" /> + <path d="m9 15 3 3-3 3" /> +</svg> diff --git a/src/webui/static/img/icons/services.svg b/src/webui/static/img/icons/services.svg new file mode 100644 index 00000000..1d8f82dd --- /dev/null +++ b/src/webui/static/img/icons/services.svg @@ -0,0 +1,5 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <rect width="8" height="18" x="3" y="3" rx="1" /> + <path d="M7 3v18" /> + <path d="M20.4 18.9c.2.5-.1 1.1-.6 1.3l-1.9.7c-.5.2-1.1-.1-1.3-.6L11.1 5.1c-.2-.5.1-1.1.6-1.3l1.9-.7c.5-.2 1.1.1 1.3.6Z" /> +</svg> diff --git a/src/webui/static/img/icons/software.svg b/src/webui/static/img/icons/software.svg new file mode 100644 index 00000000..451b8696 --- /dev/null +++ b/src/webui/static/img/icons/software.svg @@ -0,0 +1,7 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <path d="M12 2v8" /> + <path d="m16 6-4 4-4-4" /> + <rect width="20" height="8" x="2" y="14" rx="2" /> + <path d="M6 18h.01" /> + <path d="M10 18h.01" /> +</svg> diff --git a/src/webui/static/img/icons/status-tree.svg b/src/webui/static/img/icons/status-tree.svg new file mode 100644 index 00000000..558e5ffb --- /dev/null +++ b/src/webui/static/img/icons/status-tree.svg @@ -0,0 +1,7 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <path d="M21 5H3" /> + <path d="M10 12H3" /> + <path d="M10 19H3" /> + <circle cx="17" cy="15" r="3" /> + <path d="m21 19-1.9-1.9" /> +</svg> diff --git a/src/webui/static/img/icons/system-control.svg b/src/webui/static/img/icons/system-control.svg new file mode 100644 index 00000000..a651b647 --- /dev/null +++ b/src/webui/static/img/icons/system-control.svg @@ -0,0 +1,4 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <path d="M12 2v10" /> + <path d="M18.4 6.6a9 9 0 1 1-12.77.04" /> +</svg> diff --git a/src/webui/static/img/icons/system.svg b/src/webui/static/img/icons/system.svg new file mode 100644 index 00000000..d7c22591 --- /dev/null +++ b/src/webui/static/img/icons/system.svg @@ -0,0 +1,4 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" /> + <circle cx="12" cy="12" r="3" /> +</svg> diff --git a/src/webui/static/img/icons/wifi.svg b/src/webui/static/img/icons/wifi.svg new file mode 100644 index 00000000..16dbd603 --- /dev/null +++ b/src/webui/static/img/icons/wifi.svg @@ -0,0 +1,6 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <path d="M12 20h.01" /> + <path d="M2 8.82a15 15 0 0 1 20 0" /> + <path d="M5 12.859a10 10 0 0 1 14 0" /> + <path d="M8.5 16.429a5 5 0 0 1 7 0" /> +</svg> diff --git a/src/webui/static/img/icons/wireguard.svg b/src/webui/static/img/icons/wireguard.svg new file mode 100644 index 00000000..46ff2b6b --- /dev/null +++ b/src/webui/static/img/icons/wireguard.svg @@ -0,0 +1,6 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <path d="M15.686 15A14.5 14.5 0 0 1 12 22a14.5 14.5 0 0 1 0-20 10 10 0 1 0 9.542 13" /> + <path d="M2 12h8.5" /> + <path d="M20 6V4a2 2 0 1 0-4 0v2" /> + <rect width="8" height="5" x="14" y="6" rx="1" /> +</svg> diff --git a/src/webui/static/img/jack.png b/src/webui/static/img/jack.png new file mode 100644 index 00000000..b2026c16 Binary files /dev/null and b/src/webui/static/img/jack.png differ diff --git a/src/webui/static/img/logo.png b/src/webui/static/img/logo.png new file mode 100644 index 00000000..cfd199b9 Binary files /dev/null and b/src/webui/static/img/logo.png differ diff --git a/src/webui/static/js/app.js b/src/webui/static/js/app.js new file mode 100644 index 00000000..65f5716e --- /dev/null +++ b/src/webui/static/js/app.js @@ -0,0 +1,2816 @@ +(function () { + function getCSRFToken() { + var meta = document.querySelector('meta[name="csrf-token"]'); + return meta ? meta.getAttribute('content') : ''; + } + + function initCSRF() { + var token = getCSRFToken(); + if (!token || !window.htmx || !document.body) { + return; + } + document.body.addEventListener('htmx:configRequest', function (evt) { + evt.detail.headers['X-CSRF-Token'] = token; + }); + } + + // HTMX page navigation swaps only #content, leaving the <title> in <head> + // stale. Each rendered page emits a setPageTitle HX-Trigger event (set in + // handlers/common.go newPageData) carrying the full "Page · Context" title + // string ready to apply. + function initPageTitle() { + document.body.addEventListener('setPageTitle', function (evt) { + if (evt.detail && typeof evt.detail.value === 'string') { + document.title = evt.detail.value; + } + }); + } + + // The banner reflects htmx request outcomes — both user interactions and + // the `hx-trigger="every 5s"` watchdog div in base.html. + // + // The watchdog request gets a 5 s timeout (via configRequest), bounding + // disconnect detection at ~5 s + 5 s. Without it the XHR would sit on the + // OS TCP timeout (1–2 min) before any error fires. + // + // Once we see a failure, a dead-man timer counts down to a forced + // navigation to /login. The device may have been rebooted or upgraded + // out of band; in that case its in-memory session is gone and the next + // poll would 401 → HX-Redirect → /login automatically. But if the + // device is truly unreachable (cable pulled, route flap), no response + // ever comes, so we time out client-side instead of letting the user + // stare at a stale page. + function initDeviceStatusBanner() { + var banner = document.getElementById('conn-banner'); + if (!banner) return; + + var REDIRECT_AFTER_MS = 90 * 1000; + var firstFailureMs = null; + var tickerId = null; + + function tick() { + var remaining = Math.max(0, Math.ceil( + (firstFailureMs + REDIRECT_AFTER_MS - Date.now()) / 1000 + )); + banner.textContent = + 'Device unreachable — returning to login in ' + remaining + ' s'; + if (remaining === 0) { + clearInterval(tickerId); + window.location.replace('/login'); + } + } + + function show() { + banner.hidden = false; + if (tickerId !== null) return; + firstFailureMs = Date.now(); + tick(); + tickerId = setInterval(tick, 1000); + } + + // Reconnect: the device is answering again after a disconnect. It may + // have rebooted or been upgraded out of band, in which case + // running-config reverted to startup (activated-but-unsaved changes + // are gone), operational data is stale, and the in-memory session may + // no longer exist. So we do NOT resume the page the user was on — + // we reload it. A live session re-fetches true post-reboot state; a + // dead one makes the server 303-redirect the navigation to /login. + // The watchdog only fires `show()` after a >=5 s outage, so this never + // triggers on a sub-second blip — only on outages long enough to + // plausibly be a reboot. + function reconnect() { + if (firstFailureMs === null) return; // we were never down + banner.hidden = false; + banner.textContent = 'Device back online — reloading…'; + if (tickerId !== null) { + clearInterval(tickerId); + tickerId = null; + } + window.location.reload(); + } + + document.addEventListener('htmx:configRequest', function (evt) { + if (evt.detail && evt.detail.path === '/device-status') { + evt.detail.timeout = 5000; + } + }); + document.addEventListener('htmx:sendError', show); + document.addEventListener('htmx:timeout', show); + document.addEventListener('htmx:responseError', function (evt) { + var s = evt.detail && evt.detail.xhr && evt.detail.xhr.status; + if (s === 502 || s === 503 || s === 504) show(); + }); + document.addEventListener('htmx:afterRequest', function (evt) { + if (!evt.detail) return; + var xhr = evt.detail.xhr; + var status = xhr ? xhr.status : 0; + // Any HTTP response < 500 means the server is reachable again. + // status === 0 = no response; leave it to sendError / timeout. + if (status > 0 && status < 500) reconnect(); + }); + } + + function initProgressBars(root) { + var scope = root || document; + var bars = scope.querySelectorAll('.progress-bar[data-progress]'); + bars.forEach(function (bar) { + var val = parseInt(bar.getAttribute('data-progress'), 10); + if (!isNaN(val) && val >= 0) { + bar.style.width = Math.min(val, 100) + '%'; + } + }); + } + + function startRebootOverlay(root) { + var scope = root || document; + var overlay = scope.querySelector('.reboot-overlay'); + if (!overlay || overlay.dataset.init === 'true') { + return; + } + overlay.dataset.init = 'true'; + + var status = overlay.querySelector('#reboot-status'); + + // Hard cap: redirect home after 60 s regardless of poll outcome. + setTimeout(function () { window.location.replace('/'); }, 60000); + + // Wait 4 s for the device to shut down, then start polling for it to come back. + // This avoids the race where a fast reboot never shows as "down" on the 1 s poll. + setTimeout(function () { + if (status) status.textContent = 'Waiting for device to come back\u2026'; + function poll() { + fetch('/device-status').then(function (r) { + if (r.ok) { + window.location.replace('/'); + } else { + setTimeout(poll, 2000); + } + }).catch(function () { + setTimeout(poll, 2000); + }); + } + poll(); + }, 8000); + } + + function initDynamicUI(root) { + initProgressBars(root); + startRebootOverlay(root); + initDatetimePicker(root); + swBootInit(root); + swUploadInit(root); + initRestoreCheckbox(root); + initYangTree(root); + initMultiDropdown(root); + initLogsFilter(root); + initLogsTail(root); + } + + // Active EventSource for the live-tail stream, if any. Module-scoped + // so the htmx:afterSwap handler can tear it down on tab change — the + // old tail-checkbox is replaced by the new card, but the underlying + // stream would otherwise keep running until the server times out. + var logsTailES = null; + + // Latched true between a Load earlier click and the swap completing. + // Stays true for a 500ms grace window after the click, then auto-clears + // — htmx can fire htmx:afterSwap more than once per request (OOB swaps, + // settle, etc.), and consuming the flag on the first fire would leave + // a subsequent fire to scroll-to-bottom and undo the preservation. + // The grace window comfortably covers a normal request RTT but expires + // quickly enough that a real tab swap right afterwards behaves correctly. + var loadEarlierInFlight = false; + var loadEarlierClearTimer = null; + + function teardownLogsTail() { + if (logsTailES) { + logsTailES.close(); + logsTailES = null; + } + } + + // Helper: returns true when the user is at (or very near) the bottom + // of a scrollable element. Used so live-tail only auto-scrolls when + // the user is already reading the tail; if they've scrolled up to + // inspect old context, new lines append silently in the background. + function nearBottom(el) { + return (el.scrollHeight - el.scrollTop - el.clientHeight) < 40; + } + + function setTailStatus(label, text, cls) { + if (!label) return; + var status = label.querySelector('.logs-tail-status'); + if (!status) return; + status.textContent = text; + status.className = 'logs-tail-status' + (cls ? ' ' + cls : ''); + } + + function initLogsTail(root) { + var scope = root || document; + scope.querySelectorAll('.logs-tail-cb:not([data-init])').forEach(function (cb) { + cb.dataset.init = 'true'; + var label = cb.closest('label'); + + cb.addEventListener('change', function () { + teardownLogsTail(); + if (!cb.checked) { + setTailStatus(label, '', ''); + return; + } + + var body = cb.closest('.logs-body'); + var srcKey = body && body.getAttribute('data-source'); + var content = body && body.querySelector('.logs-content'); + if (!srcKey || !content) { + // Surface the failure rather than silently no-op — empty log + // files have no .logs-content element, and any future markup + // tweak that drops the data-source attribute would otherwise + // make tail appear broken without any clue why. + console.warn('[logs] live-tail aborted', { body: !!body, srcKey: srcKey, content: !!content }); + setTailStatus(label, 'nothing to tail', 'err'); + cb.checked = false; + return; + } + + // Toggling tail on always jumps to the end — the user opted in + // to "show me new stuff," even if they were scrolled up. Defer + // to the next frame so the jump lands after the layout settles + // (the status pill flips to "connecting…" on the same tick). + var stickToBottom = true; + requestAnimationFrame(function () { + content.scrollTop = content.scrollHeight; + }); + + setTailStatus(label, 'connecting…', 'pending'); + + logsTailES = new EventSource('/maintenance/logs/' + encodeURIComponent(srcKey) + '/tail'); + + // The native open event fires on successful HTTP handshake, + // which is enough to confirm the TCP/TLS path works even if no + // log lines are streaming yet. Useful when nginx buffering + // would otherwise leave the user staring at a blank tail. + logsTailES.onopen = function () { + setTailStatus(label, 'live', 'live'); + }; + logsTailES.addEventListener('lines', function (evt) { + // Stick to the bottom for the first batch unconditionally (the + // user just opted in), then fall back to the "only follow if + // already at the bottom" rule so scrolling up to read pauses + // the auto-follow. + var follow = stickToBottom || nearBottom(content); + stickToBottom = false; + content.insertAdjacentHTML('beforeend', evt.data); + if (follow) { + content.scrollTop = content.scrollHeight; + } + setTailStatus(label, 'live', 'live'); + }); + logsTailES.onerror = function () { + // EventSource auto-reconnects on transient drops — fired + // commonly under nginx proxy in front of our handler. Only + // touch the status when the browser has given up (CLOSED is + // terminal); leave "live" alone during the auto-reconnect + // cycle so the badge doesn't flicker between green and amber + // while new lines are still arriving correctly. + if (logsTailES && logsTailES.readyState === EventSource.CLOSED) { + teardownLogsTail(); + cb.checked = false; + setTailStatus(label, 'disconnected', 'err'); + } + }; + }); + }); + } + + // Pin the log buffer to the bottom on initial render and after every + // htmx swap (tab change). The Load earlier button sits at the top of + // the same scroll container, so this naturally hides it until the user + // scrolls back up. Always query against `document`: with our + // outerHTML tab swap, htmx hands us the OLD .logs-card as the scope, + // which is detached from the DOM by the time afterSwap fires. Note + // for the SSE wiring step: when the user toggles Live tail on, call + // this here too. + function scrollLogsToBottom() { + document.querySelectorAll('.logs-content').forEach(function (el) { + el.scrollTop = el.scrollHeight; + }); + } + + // Move keyboard focus onto the log buffer so PageUp / PageDown / + // Home / End / arrow keys all work without the user having to click + // first. Uses preventScroll because the buffer was just scrolled to + // the bottom and the default focus() would scroll it back to make + // the element-top visible. + function focusLogsContent() { + var content = document.querySelector('.logs-content'); + if (!content) return; + try { content.focus({ preventScroll: true }); } catch (_) { content.focus(); } + } + + // Steal Ctrl-F (and ⌘-F on macOS) when a log filter is on-screen so + // searching jumps into the filter input rather than opening the + // browser's native find — the filter actually understands the + // structured log buffer (regex, severity colors stay aligned). When + // no filter is in the DOM the binding does nothing and the browser + // behaves normally. + document.addEventListener('keydown', function (evt) { + if (!evt.key || evt.key.toLowerCase() !== 'f') return; + if (!(evt.ctrlKey || evt.metaKey)) return; + if (evt.altKey || evt.shiftKey) return; + var filter = document.querySelector('.logs-filter'); + if (!filter) return; + evt.preventDefault(); + filter.focus(); + filter.select(); + }); + + // Filter the visible log buffer client-side. Plain substring by default; + // /…/ delimiters switch to JS regex (case-insensitive). Empty query + // shows everything. Debounced so per-keystroke regex compile doesn't + // hitch the UI on a big buffer. + function initLogsFilter(root) { + var scope = root || document; + scope.querySelectorAll('.logs-filter:not([data-init])').forEach(function (input) { + input.dataset.init = 'true'; + var content = input.closest('.logs-body') && input.closest('.logs-body').querySelector('.logs-content'); + if (!content) return; + + function apply() { + var q = input.value; + var re = null; + var trimmed = q.trim(); + if (trimmed.length >= 2 && trimmed.charAt(0) === '/' && trimmed.charAt(trimmed.length - 1) === '/') { + try { re = new RegExp(trimmed.slice(1, -1), 'i'); } catch (_) { re = null; } + } + var sub = q.toLowerCase(); + content.querySelectorAll('.logs-line').forEach(function (line) { + var t = line.textContent; + var show; + if (re) show = re.test(t); + else if (q) show = t.toLowerCase().indexOf(sub) !== -1; + else show = true; + line.style.display = show ? '' : 'none'; + }); + } + + var timer = null; + input.addEventListener('input', function () { + clearTimeout(timer); + timer = setTimeout(apply, 120); + }); + }); + } + + // The <details>-based multi-select dropdown needs JS to behave like a + // real <select>: refresh the summary text when checkboxes change, and + // close when the user clicks outside the widget. + function initMultiDropdown(root) { + var scope = root || document; + scope.querySelectorAll('.cfg-multi:not([data-init])').forEach(function (det) { + det.dataset.init = 'true'; + var summary = det.querySelector('.cfg-multi-summary'); + var body = det.querySelector('.cfg-multi-body'); + if (!summary || !body) return; + + var allBox = body.querySelector('input[data-multi-all]'); + var itemBoxes = body.querySelectorAll('input[type="checkbox"]:not([data-multi-all])'); + + function refreshSummary() { + var labels = []; + itemBoxes.forEach(function (cb) { + if (!cb.checked) return; + var lab = cb.closest('label'); + labels.push(lab ? lab.textContent.trim() : cb.value); + }); + summary.textContent = labels.length ? labels.join(', ') : '(All)'; + } + body.addEventListener('change', function (evt) { + if (evt.target === allBox && allBox.checked) { + // (All) was just checked — clear specific selections. + itemBoxes.forEach(function (cb) { cb.checked = false; }); + } else if (evt.target !== allBox && evt.target.checked && allBox) { + // A specific PMD was checked — uncheck (All). + allBox.checked = false; + } else if (evt.target !== allBox && allBox) { + // A specific PMD was unchecked — if none remain, re-check (All). + var anyChecked = false; + itemBoxes.forEach(function (cb) { if (cb.checked) anyChecked = true; }); + if (!anyChecked) allBox.checked = true; + } + refreshSummary(); + }); + }); + } + + // Close any open .cfg-multi when the user clicks outside it. Single + // top-level listener — cheaper than per-widget bindings. + document.addEventListener('click', function (evt) { + document.querySelectorAll('.cfg-multi[open]').forEach(function (det) { + if (!det.contains(evt.target)) det.removeAttribute('open'); + }); + }); + + function initYangTree(scope) { + var root = scope || document; + // Stop <details> from toggling when clicking interactive elements inside <summary>. + // Also stops row-click navigation when action buttons inside rows are clicked. + root.querySelectorAll('.yt-sp').forEach(function (el) { + if (el.dataset.init) return; + el.dataset.init = 'true'; + el.addEventListener('click', function (e) { e.stopPropagation(); }); + }); + // Binary content show/hide eye button. + root.querySelectorAll('.yt-binary-eye').forEach(function (btn) { + if (btn.dataset.init) return; + btn.dataset.init = 'true'; + btn.addEventListener('click', function () { + var wrap = btn.closest('.yt-binary-wrap'); + if (wrap) wrap.classList.toggle('yt-revealed'); + }); + }); + } + + function swUploadInit(scope) { + var btn = (scope || document).querySelector('#sw-upload-btn'); + if (!btn || btn.dataset.init) return; + btn.dataset.init = 'true'; + btn.addEventListener('click', window.swUpload); + } + + function initRestoreCheckbox(scope) { + var cb = (scope || document).querySelector('#restore-startup-cb'); + if (!cb || cb.dataset.init) return; + cb.dataset.init = 'true'; + cb.addEventListener('change', function () { window.scRestoreCheckbox(cb); }); + } + + // ─── Software: boot order drag-and-drop ────────────────────────────────── + function swBootInit(scope) { + var slots = (scope || document).querySelector('#sw-boot-slots'); + if (!slots || slots.dataset.dndInit) return; + slots.dataset.dndInit = 'true'; + + // Stash the page-load order so Reset can restore it without a page refresh. + // Slot names are a fixed enum (primary | secondary | net), so comma-joining is safe. + var initialOrder = []; + slots.querySelectorAll('.sw-boot-badge').forEach(function (b) { initialOrder.push(b.dataset.slot); }); + slots.dataset.originalOrder = initialOrder.join(','); + + var dragging = null; + var insertRef = undefined; // node to insertBefore; undefined = not set, null = append + + function clearIndicators() { + slots.querySelectorAll('.sw-boot-drop-before').forEach(function (el) { + el.classList.remove('sw-boot-drop-before'); + }); + } + + slots.addEventListener('dragstart', function (e) { + dragging = e.target.closest('.sw-boot-badge'); + if (!dragging) return; + dragging.classList.add('sw-boot-dragging'); + e.dataTransfer.effectAllowed = 'move'; + }); + + slots.addEventListener('dragend', function () { + if (dragging) dragging.classList.remove('sw-boot-dragging'); + dragging = null; + insertRef = undefined; + clearIndicators(); + }); + + slots.addEventListener('dragenter', function (e) { e.preventDefault(); }); + + slots.addEventListener('dragover', function (e) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + if (!dragging) return; + var target = e.target.closest('.sw-boot-badge'); + clearIndicators(); + if (!target || target === dragging) return; + var rect = target.getBoundingClientRect(); + if (e.clientX < rect.left + rect.width / 2) { + insertRef = target; + target.classList.add('sw-boot-drop-before'); + } else { + insertRef = target.nextElementSibling || null; + if (insertRef && insertRef.classList.contains('sw-boot-badge')) { + insertRef.classList.add('sw-boot-drop-before'); + } + } + }); + + slots.addEventListener('drop', function (e) { + e.preventDefault(); + if (!dragging || insertRef === undefined) return; + slots.insertBefore(dragging, insertRef); // insertRef===null appends to end + clearIndicators(); + insertRef = undefined; + }); + + var saveBtn = document.getElementById('sw-boot-save-btn'); + if (saveBtn) saveBtn.addEventListener('click', function () { window.swBootSave(saveBtn); }); + + var resetBtn = document.getElementById('sw-boot-reset-btn'); + if (resetBtn) resetBtn.addEventListener('click', window.swBootReset); + } + + // Restore the boot-order row to the page-load order — i.e. what RAUC + // reported as the current device boot order before any drag/drop. + // Set will push the displayed order to the device; Reset just undoes + // local rearrangement without a server round-trip. + window.swBootReset = function () { + var slots = document.getElementById('sw-boot-slots'); + if (!slots) return; + var original = (slots.dataset.originalOrder || '').split(','); + var existing = {}; + slots.querySelectorAll('.sw-boot-badge').forEach(function (b) { + existing[b.dataset.slot] = b; + }); + original.forEach(function (slot) { + if (existing[slot]) slots.appendChild(existing[slot]); + }); + }; + + window.swBootSave = function (btn) { + var badges = document.querySelectorAll('#sw-boot-slots .sw-boot-badge'); + var params = new URLSearchParams(); + badges.forEach(function (b) { params.append('boot-order', b.dataset.slot); }); + + function btnSet(text, disabled) { + if (!btn) return; + btn.textContent = text; + btn.disabled = disabled; + } + + btnSet('Setting\u2026', true); + + fetch('/software/boot-order', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-CSRF-Token': getCSRFToken(), + }, + body: params.toString(), + }).then(function (r) { + if (r.ok) { + // Device now holds the displayed order; refresh the Reset baseline + // so a follow-up \u21ba doesn't revert to a state the device no longer has. + var slots = document.getElementById('sw-boot-slots'); + if (slots) { + var saved = []; + badges.forEach(function (b) { saved.push(b.dataset.slot); }); + slots.dataset.originalOrder = saved.join(','); + } + btnSet('\u2713 Set', true); + setTimeout(function () { btnSet('Set', false); }, 2000); + return; + } + return r.text().then(function (t) { + btnSet('\u2717 ' + (t.replace(/<[^>]*>/g, '').trim() || 'Failed'), false); + setTimeout(function () { btnSet('Set', false); }, 4000); + }); + }).catch(function () { + btnSet('\u2717 Failed', false); + setTimeout(function () { btnSet('Set', false); }, 4000); + }); + }; + + // ─── Configure > Date & Time card ──────────────────────────────────────── + // Two pieces working together: + // 1. #sc-dt-input is pre-filled with the browser's current local time so + // "Set now" defaults to "synchronize device to my clock". + // 2. #sc-system-time is a JS-driven live clock seeded from the server's + // data-server-time attribute (ISO string). We compute the offset + // between device clock and browser clock once, then tick locally and + // render via toLocaleString() so each user sees their own locale. + // After a successful POST /maintenance/system/datetime we recompute + // the offset from the value the user submitted so the visible clock + // jumps to the new time and continues ticking. + var systemTimeOffsetMs = null; // device - browser + + function localDatetimeLocal() { + // Format Date as YYYY-MM-DDTHH:MM:SS in *local* time, matching what + // <input type="datetime-local"> stores in its .value field. + var d = new Date(); + return d.getFullYear() + '-' + + String(d.getMonth() + 1).padStart(2, '0') + '-' + + String(d.getDate()).padStart(2, '0') + 'T' + + String(d.getHours()).padStart(2, '0') + ':' + + String(d.getMinutes()).padStart(2, '0') + ':' + + String(d.getSeconds()).padStart(2, '0'); + } + + function renderSystemTime(span) { + if (systemTimeOffsetMs === null) return; + var d = new Date(Date.now() + systemTimeOffsetMs); + span.textContent = d.toLocaleString(); + } + + function initDatetimePicker(root) { + // Always pre-fill an empty picker; htmx swaps replace #content so each + // visit gets a fresh element, and overwriting an empty value is a no-op. + var input = (root || document).querySelector('#sc-dt-input'); + if (input && !input.value) { + input.value = localDatetimeLocal(); + } + + var span = (root || document).querySelector('#sc-system-time'); + if (span && span.dataset.serverTime) { + // Server omits the timezone offset; treat the value as the device's + // local wall clock, which is what the Timezone row tells the user. + var deviceMs = new Date(span.dataset.serverTime).getTime(); + systemTimeOffsetMs = deviceMs - Date.now(); + renderSystemTime(span); + // Cancel any prior tick from an earlier htmx navigation — htmx + // replaces #content but doesn't notify JS to clean up timers, + // so without this each visit would leak a 1 Hz interval pinning + // an orphaned span. + if (window.scSystemTimeTimer) clearInterval(window.scSystemTimeTimer); + window.scSystemTimeTimer = setInterval(function () { renderSystemTime(span); }, 1000); + } + } + + // The <input type="datetime-local"> reports its value in the user's local + // time without a zone suffix; the server appends "+00:00" unconditionally, + // so without this hook a CEST user typing 18:55 would set the device to + // 18:55 UTC instead of 16:55 UTC. Convert the parameter to UTC just + // before it goes on the wire so the device clock matches the moment the + // user actually selected. Attached to `document` (not document.body) + // because this code runs at IIFE parse time before <body> exists. + document.addEventListener('htmx:configRequest', function (evt) { + if (!evt.detail || evt.detail.path !== '/maintenance/system/datetime') return; + var local = evt.detail.parameters && evt.detail.parameters.datetime; + if (!local) return; + var d = new Date(local); + if (isNaN(d.getTime())) return; + // toISOString gives "YYYY-MM-DDTHH:MM:SS.sssZ"; strip millis so the + // server's "+00:00" append produces valid RFC 3339. + evt.detail.parameters.datetime = d.toISOString().slice(0, 19); + }); + + // After a successful Set POST, re-sync the live clock to the value the + // user just submitted so the display jumps instead of waiting for the next + // page load. + document.addEventListener('htmx:afterRequest', function (evt) { + if (!evt.detail || !evt.detail.successful) return; + var path = evt.detail.requestConfig && evt.detail.requestConfig.path; + if (path !== '/maintenance/system/datetime') return; + var input = document.getElementById('sc-dt-input'); + var span = document.getElementById('sc-system-time'); + if (!input || !span || !input.value) return; + var submittedMs = new Date(input.value).getTime(); + if (isNaN(submittedMs)) return; + systemTimeOffsetMs = submittedMs - Date.now(); + renderSystemTime(span); + }); + + window.scRestoreCheckbox = function (cb) { + var form = document.getElementById('restore-form'); + if (!form) return; + form.setAttribute('hx-confirm', cb.checked + ? 'Save configuration to startup? Reboot required to apply.' + : 'Apply this configuration to the running system?'); + }; + + // Locate or inject the shared #sw-progress-card. Server-rendered when the + // page loads with ?installing=1; injected here when the upload flow starts + // from /software. The same DOM element later receives SSE-driven swaps. + function ensureProgressCard(headerText, message) { + var card = document.getElementById('sw-progress-card'); + if (!card) { + card = document.createElement('section'); + card.id = 'sw-progress-card'; + card.className = 'info-card'; + var grid = document.querySelector('.sw-install-grid'); + var parent = grid && grid.parentNode; + if (parent) parent.insertBefore(card, grid); + else (document.getElementById('content') || document.body).appendChild(card); + } + card.innerHTML = + '<div class="card-header">' + headerText + '</div>' + + '<div class="card-body">' + + ' <div class="progress-bar-wrap progress-bar-wrap--flush">' + + ' <div class="progress-bar" style="width:0%"></div>' + + ' </div>' + + ' <p class="progress-text">' + message + '</p>' + + '</div>'; + // Force re-init of the SSE stream if one was previously attached. + delete card.dataset.sseInit; + return card; + } + + window.swUpload = function () { + var fileInput = document.getElementById('sw-file'); + if (!fileInput || !fileInput.files.length) return; + if (!confirm('Upload and install this software bundle? The current installation may be overwritten.')) return; + + var file = fileInput.files[0]; + var autoReboot = !!document.querySelector('#sw-upload-auto-reboot:checked'); + var btn = document.getElementById('sw-upload-btn'); + + var sseURL = new URL((btn && btn.getAttribute('data-sse-url')) || '/software/progress', window.location.origin); + if (autoReboot) sseURL.searchParams.set('auto-reboot', '1'); + + var formData = new FormData(); + formData.append('pkg', file); + if (autoReboot) formData.append('auto-reboot', '1'); + + if (btn) btn.disabled = true; + var card = ensureProgressCard('Uploading Bundle', 'Uploading bundle… 0%'); + var bar = card.querySelector('.progress-bar'); + var text = card.querySelector('.progress-text'); + + var xhr = new XMLHttpRequest(); + + xhr.upload.onprogress = function (e) { + if (!e.lengthComputable) return; + var pct = Math.round(e.loaded / e.total * 100); + bar.style.width = pct + '%'; + text.textContent = 'Uploading bundle\u2026 ' + pct + '%'; + }; + + xhr.onload = function () { + if (xhr.status !== 200) { + text.textContent = 'Upload failed: ' + (xhr.responseText.replace(/<[^>]*>/g, '').trim() || 'unknown error'); + if (btn) btn.disabled = false; + return; + } + // pushState lets a mid-install reload resume the progress card. + var target = xhr.responseText.trim() || '/software?installing=1'; + if (window.history && window.history.pushState) { + window.history.pushState({}, '', target); + } + text.textContent = 'Starting installation\u2026'; + card.setAttribute('data-sse-src', sseURL.pathname + sseURL.search); + initSoftwareProgress(document); + }; + + xhr.onerror = function () { + text.textContent = 'Upload failed \u2014 network error.'; + if (btn) btn.disabled = false; + }; + + xhr.open('POST', '/software/upload'); + xhr.setRequestHeader('X-CSRF-Token', getCSRFToken()); + xhr.send(formData); + }; + + // SSE-driven software install progress card. + // The Go server polls RESTCONF and streams rendered HTML fragments; we just + // swap them into the card and let the server close the stream when done. + var swEventSource = null; + + function initSoftwareProgress(root) { + var scope = root || document; + var card = scope.querySelector('#sw-progress-card[data-sse-src]'); + if (!card || card.dataset.sseInit) return; + card.dataset.sseInit = 'true'; + + var src = card.getAttribute('data-sse-src'); + if (swEventSource) { swEventSource.close(); } + swEventSource = new EventSource(src); + + function swap(html) { + card.innerHTML = html; + initProgressBars(card); + if (window.htmx) htmx.process(card); + } + + function endStream() { + swEventSource.close(); + swEventSource = null; + // Drop the stream URL and re-arm the upload button so a follow-up + // install can run on the same page without a reload. + card.removeAttribute('data-sse-src'); + delete card.dataset.sseInit; + var btn = document.getElementById('sw-upload-btn'); + if (btn) btn.disabled = false; + } + + swEventSource.addEventListener('progress', function(e) { swap(e.data); }); + + swEventSource.addEventListener('done', function(e) { + swap(e.data); + endStream(); + }); + + swEventSource.addEventListener('reboot', function(e) { + swap(e.data); + endStream(); + // Auto-reboot: POST /reboot and show the reboot overlay. + fetch('/reboot', { method: 'POST', headers: { 'X-CSRF-Token': getCSRFToken() } }) + .then(function(r) { return r.text(); }) + .then(function(html) { + var content = document.getElementById('content'); + if (content) { + content.innerHTML = html; + initDynamicUI(content); + } + }); + }); + + // Let EventSource auto-reconnect on transient errors. Only tear down + // when the browser gives up (readyState === CLOSED) — otherwise an + // nginx read-timeout or a brief network blip would silently kill the + // stream and leave the progress card stuck on stale data. + swEventSource.onerror = function () { + if (swEventSource && swEventSource.readyState === EventSource.CLOSED) { + endStream(); + } + }; + } + + document.addEventListener('DOMContentLoaded', function() { + initCSRF(); + initPageTitle(); + initDeviceStatusBanner(); + initDynamicUI(document); + initSoftwareProgress(document); + // On first paint we want the buffer pinned to the most recent entries + // and keyboard focus on the buffer so PageUp/PageDown work straight away. + scrollLogsToBottom(); + focusLogsContent(); + + // Attach the htmx swap listener here, not at IIFE parse time — at parse + // time the script runs inside <head> before <body> exists, so the + // document.body guard would silently skip the listener and subsequent + // navigations wouldn't re-init dynamic UI. + // Set the Load earlier flag at click capture time — runs before htmx + // sees the click, before any other listener, and works regardless of + // whether htmx's beforeRequest event detail matches our expectations. + // While we're here, snapshot the scroll state on the .logs-content so + // afterSwap can preserve the user's visual position: the line they + // were looking at should stay put after new (older) content gets + // prepended, not jump to top or bottom. + document.body.addEventListener('click', function (evt) { + var btn = evt.target && evt.target.closest && evt.target.closest('.logs-load-earlier'); + if (!btn) return; + loadEarlierInFlight = true; + if (loadEarlierClearTimer) clearTimeout(loadEarlierClearTimer); + loadEarlierClearTimer = setTimeout(function () { + loadEarlierInFlight = false; + loadEarlierClearTimer = null; + }, 500); + var content = btn.closest('.logs-content'); + if (content) { + content._preLoadHeight = content.scrollHeight; + content._preLoadTop = content.scrollTop; + } + }, true); + + if (window.htmx) { + document.body.addEventListener('htmx:afterSwap', function (evt) { + // Close any open SSE stream if the software install progress card is no longer present. + if (swEventSource && !document.getElementById('sw-progress-card')) { + swEventSource.close(); + swEventSource = null; + } + + // Read but DON'T reset — the timeout in the click handler clears + // it after the grace window, so successive htmx:afterSwap fires + // within one Load earlier round trip all take the same branch. + var isLoadEarlier = loadEarlierInFlight; + + // Tab change replaces the whole logs card, which removes the + // tail checkbox without giving us a chance to react. Kill any + // open stream so it doesn't run on after navigation. Skip on + // Load earlier — that swap stays within the current tab and + // shouldn't touch the live-tail stream. + if (!isLoadEarlier) { + teardownLogsTail(); + } + + // Scope to document, not evt.detail.target: tab swaps use + // hx-swap="outerHTML", so afterSwap hands us the OLD (now detached) + // .logs-card as the target. Initialising against it misses the new + // tab's Live-tail checkbox — which is why tailing only worked on the + // tab rendered at page load. The init helpers all guard on + // :not([data-init]), so re-scanning the document is idempotent. + var scope = document; + initDynamicUI(scope); + + if (isLoadEarlier) { + // Content-anchored scroll preservation: keep the line the user + // was looking at in the same vertical position by shifting + // scrollTop by exactly the height of the newly-prepended + // content. The pre-swap measurements were captured in the + // click-capture handler so they reflect the buffer's state + // before htmx mutated it. Without this, the browser falls + // back to scrollTop=0 because the OLD anchor element (the + // clicked button) no longer exists in the DOM. Idempotent + // across multiple afterSwap fires — recomputing from saved + // snapshots always yields the same scrollTop. + document.querySelectorAll('.logs-content').forEach(function (content) { + if (typeof content._preLoadHeight !== 'number') return; + var added = content.scrollHeight - content._preLoadHeight; + content.scrollTop = content._preLoadTop + added; + }); + } else { + scrollLogsToBottom(); + focusLogsContent(); + } + + initSoftwareProgress(scope); + }); + } + }); +})(); + +// Active nav link — keep in sync with the current URL after htmx navigation. +// Also opens the top-level accordion section containing the active link, +// and immediately highlights the clicked link (optimistic active state). +(function() { + function updateActiveNav() { + var path = window.location.pathname; + var activeTopGroup = null; + + document.querySelectorAll('.nav-link').forEach(function(link) { + var href = link.getAttribute('href'); + var active = href === path; + link.classList.toggle('active', active); + if (active) { + var topGroup = link.closest('details.nav-group-top'); + if (topGroup) activeTopGroup = topGroup; + } + }); + + if (activeTopGroup) { + document.querySelectorAll('details.nav-group-top').forEach(function(d) { + if (d === activeTopGroup) { + // Don't auto-open Configure: its toggle handler fires enterConfigure(), + // which must only happen on explicit user interaction, not on URL sync. + if (d.id !== 'nav-configure' && !d.open) d.open = true; + } else { + if (d.open) d.open = false; + } + }); + } + } + + // Optimistic active state: highlight the link immediately on click so the + // sidebar doesn't lag while waiting for the server to respond. + document.addEventListener('click', function(e) { + var link = e.target.closest('a.nav-link[hx-get]'); + if (!link) return; + document.querySelectorAll('.nav-link').forEach(function(l) { l.classList.remove('active'); }); + link.classList.add('active'); + }); + + document.addEventListener('DOMContentLoaded', updateActiveNav); + document.addEventListener('htmx:pushedIntoHistory', updateActiveNav); + window.addEventListener('popstate', updateActiveNav); +})(); + +// Top progress bar during htmx navigations +(function() { + var bar, showTimer, hideTimer, inFlight = 0, visible = false; + + function getBar() { + if (!bar) bar = document.getElementById('page-progress'); + return bar; + } + + function show() { + visible = true; + var b = getBar(); + if (!b) return; + clearTimeout(hideTimer); hideTimer = null; + b.style.transition = 'none'; + b.style.width = '0%'; + b.style.opacity = '1'; + b.offsetWidth; // force reflow + b.style.transition = 'width 6s cubic-bezier(0.05, 0.8, 0.4, 1)'; + b.style.width = '85%'; + } + + function hide() { + visible = false; + var b = getBar(); + if (!b) return; + clearTimeout(hideTimer); + b.style.transition = 'width 0.15s ease'; + b.style.width = '100%'; + hideTimer = setTimeout(function() { + b.style.transition = 'opacity 0.25s ease'; + b.style.opacity = '0'; + hideTimer = setTimeout(function() { + b.style.transition = 'none'; + b.style.width = '0%'; + hideTimer = null; + }, 270); + }, 120); + } + + function start() { + inFlight++; + if (inFlight === 1 && !showTimer) { + // Only show after 150 ms — fast requests (saves, etc.) won't trigger it. + showTimer = setTimeout(function() { + showTimer = null; + if (inFlight > 0) show(); + }, 150); + } + } + + function finish() { + inFlight = Math.max(0, inFlight - 1); + if (inFlight > 0) return; // other requests still in flight + if (showTimer) { clearTimeout(showTimer); showTimer = null; return; } + if (visible) hide(); + } + + document.addEventListener('htmx:beforeSend', start); + // afterRequest fires for all types including hx-swap="none"; afterSettle does not. + document.addEventListener('htmx:afterRequest', finish); + document.addEventListener('htmx:responseError', finish); + document.addEventListener('htmx:sendError', finish); +})(); + +// Interface page glue (replaces inline hx-on / inline <script>, which CSP +// blocks under the `script-src 'self'` policy in middleware.go). +(function () { + // A checkbox marked [data-fold-target="<id>"] toggles the matching <details> + // (or any element with that id) immediately on change. Used for the DHCP / + // DHCPv6 settings foldouts on Configure → Interface: the foldout is always + // in the DOM but starts hidden when the client isn't enabled. This lets the + // user see the settings form before clicking Save IPvX Settings and confirms + // the section exists even when DHCP is off. + document.addEventListener('change', function (evt) { + var cb = evt.target; + if (!cb || !cb.matches || !cb.matches('input[type="checkbox"][data-fold-target]')) return; + var target = document.getElementById(cb.getAttribute('data-fold-target')); + if (target) target.hidden = !cb.checked; + }); + + // Add Interface modal — open via data-show-modal, close via + // data-close-modal. Mirrors the existing data-show/data-hide vocabulary + // for action-on-target attributes. Native <dialog>.showModal() gives us + // focus trap, ESC, and backdrop for free. + document.addEventListener('click', function (e) { + var open = e.target.closest && e.target.closest('[data-show-modal]'); + if (open) { + var dlg = document.getElementById(open.getAttribute('data-show-modal')); + if (dlg && dlg.showModal) { + // Reset to server-rendered defaults so the dialog is consistent + // on every open (a previous pick + Cancel would otherwise leave + // the wrong fieldset enabled). + var form = dlg.querySelector('form'); + if (form) form.reset(); + var vlanName = dlg.querySelector('#add-iface-vlan-name'); + if (vlanName) delete vlanName.dataset.userEdited; + var wifiName = dlg.querySelector('#add-iface-wifi-name'); + if (wifiName) delete wifiName.dataset.userEdited; + dlg.showModal(); + var sel = dlg.querySelector('#add-iface-type-select'); + if (sel) sel.dispatchEvent(new Event('change', { bubbles: true })); + } + } + var close = e.target.closest && e.target.closest('[data-close-modal]'); + if (close) { + var dlg2 = document.getElementById(close.getAttribute('data-close-modal')); + if (dlg2 && dlg2.close) dlg2.close(); + } + }); + + // Add Interface modal, WiFi fieldset: mode (Station/AP) drives which + // security-mode optgroup is exposed, which AP-only rows are visible, + // whether the PSK row applies (open/disabled hide it), and an -ap + // suffix on the Name. + function refreshWifiSec() { + var modeAP = document.getElementById('add-iface-wifi-mode-ap'); + var isAP = !!(modeAP && modeAP.checked); + var sec = document.getElementById('add-iface-wifi-sec'); + if (sec) { + var groups = sec.querySelectorAll('optgroup[data-mode-group]'); + groups.forEach(function (g) { + var match = g.getAttribute('data-mode-group') === (isAP ? 'access-point' : 'station'); + g.hidden = !match; + g.disabled = !match; + }); + // Reset to the first visible option of the active group if the + // current value belongs to the other mode. + var active = sec.options[sec.selectedIndex]; + if (!active || active.parentElement.disabled) { + for (var i = 0; i < sec.options.length; i++) { + if (!sec.options[i].parentElement.disabled) { sec.selectedIndex = i; break; } + } + } + } + var hiddenRow = document.getElementById('add-iface-wifi-hidden-row'); + if (hiddenRow) hiddenRow.hidden = !isAP; + refreshWifiPSK(); + refreshWifiName(); + } + // Auto-suffix the Name with "-ap" in AP mode and strip it in Station, + // unless the user has manually edited the Name. Matches the in-tree + // wifi naming convention (wifi0 / wifi0-ap). + function refreshWifiName() { + var name = document.getElementById('add-iface-wifi-name'); + if (!name || name.dataset.userEdited === '1') return; + var modeAP = document.getElementById('add-iface-wifi-mode-ap'); + var isAP = !!(modeAP && modeAP.checked); + var base = name.value.replace(/-ap$/, ''); + name.value = isAP ? base + '-ap' : base; + } + function refreshWifiPSK() { + var sec = document.getElementById('add-iface-wifi-sec'); + var pskRow = document.getElementById('add-iface-wifi-psk-row'); + var psk = document.getElementById('add-iface-wifi-psk'); + if (!sec || !pskRow || !psk) return; + var v = sec.value; + var needPSK = v !== 'disabled' && v !== 'open'; + pskRow.hidden = !needPSK; + psk.required = needPSK; + psk.disabled = !needPSK; + } + document.addEventListener('change', function (e) { + var t = e.target; + if (t.id === 'add-iface-wifi-mode-sta' || t.id === 'add-iface-wifi-mode-ap') { + refreshWifiSec(); + } else if (t.id === 'add-iface-wifi-sec') { + refreshWifiPSK(); + } + }); + // User-typed Name pins it (stops auto-suffix on mode change). + document.addEventListener('input', function (e) { + if (e.target.id === 'add-iface-wifi-name') { + e.target.dataset.userEdited = '1'; + } + }); + + // CSP-safe successor to inline hx-on:htmx:after-request. Save buttons + // in the inline "+ New keystore key" forms carry + // data-ks-create-success="<form-id>"; on a successful HTMX swap, hide + // the named form and clear its inputs so the user sees the picker + // with their new key selected. + document.addEventListener('htmx:afterRequest', function (evt) { + if (!evt.detail || !evt.detail.successful) return; + var btn = evt.detail.elt; + if (!btn || !btn.hasAttribute || !btn.hasAttribute('data-ks-create-success')) return; + var form = document.getElementById(btn.getAttribute('data-ks-create-success')); + if (!form) return; + form.hidden = true; + form.querySelectorAll('input, textarea').forEach(function (i) { i.value = ''; }); + resetMaskedInputs(form); + }); + + // Wizard "Edit" / "+ Add" buttons that share an inline create form. + // - data-add-form="<form-id>" clears every input/textarea before + // showing the form (handled here; data-show on the same button + // then reveals it). + // - data-edit-form="<form-id>" pre-fills the form from the picker's + // selected option. data-edit-source points at the <select>; + // data-edit-name-target (optional) names the input that should + // receive the picker's value (for keystore forms whose name input + // mirrors the picker). For the radio form, every <input>/<select> + // whose name matches a data-<name> attribute on the picker option + // is filled from that attribute (e.g. data-country fills + // name="country-code", data-band fills name="band", …). + function clearKsForm(formId) { + var form = document.getElementById(formId); + if (!form) return; + form.querySelectorAll('input, textarea').forEach(function (i) { i.value = ''; }); + form.querySelectorAll('select').forEach(function (s) { s.selectedIndex = 0; }); + resetMaskedInputs(form); + } + // Strip the `.cfg-secret-shown` (eye-toggled "show") class from any + // masked inputs inside `root`. Called whenever a fold-out form + // transitions (open / clear / prefill / after-save) so a previous + // session's "shown" state never leaks across the next interaction. + function resetMaskedInputs(root) { + if (!root) return; + root.querySelectorAll('.cfg-input-mask.cfg-secret-shown').forEach(function (i) { + i.classList.remove('cfg-secret-shown'); + }); + } + function prefillKsForm(btn) { + var formId = btn.getAttribute('data-edit-form'); + var srcId = btn.getAttribute('data-edit-source'); + var form = document.getElementById(formId); + var src = document.getElementById(srcId); + if (!form || !src) return; + resetMaskedInputs(form); + var opt = src.options[src.selectedIndex]; + if (!opt) return; + var ksNameTarget = btn.getAttribute('data-edit-name-target'); + if (ksNameTarget) { + var nameInput = document.getElementById(ksNameTarget); + if (nameInput) nameInput.value = opt.value; + } + // Generic mapping: every form field whose [name=X] matches a + // data-X attribute on the option gets that attribute's value. + form.querySelectorAll('[name]').forEach(function (field) { + var attr = 'data-' + field.getAttribute('name'); + if (opt.hasAttribute(attr)) field.value = opt.getAttribute(attr) || ''; + }); + // For the radio form, the picker's selected radio name needs to be + // present in the inner radio-name <select> so the Save POST carries + // it. The available-radios dropdown only lists unconfigured radios, + // so inject the selected name as an extra option if missing. + var radioNameSel = form.querySelector('select[name="radio-name"]'); + if (radioNameSel && opt.value) { + var found = false; + for (var i = 0; i < radioNameSel.options.length; i++) { + if (radioNameSel.options[i].value === opt.value) { + radioNameSel.selectedIndex = i; + found = true; + break; + } + } + if (!found) { + var injected = document.createElement('option'); + injected.value = opt.value; + injected.textContent = opt.value + ' (currently configured)'; + injected.dataset.injected = '1'; + radioNameSel.insertBefore(injected, radioNameSel.firstChild); + radioNameSel.selectedIndex = 0; + } + } + } + document.addEventListener('click', function (e) { + var addBtn = e.target.closest && e.target.closest('[data-add-form]'); + if (addBtn) { + clearKsForm(addBtn.getAttribute('data-add-form')); + } + var editBtn = e.target.closest && e.target.closest('[data-edit-form]'); + if (editBtn) { + // Clear any injected "currently configured" options from prior + // edits before re-prefilling. + var form = document.getElementById(editBtn.getAttribute('data-edit-form')); + if (form) form.querySelectorAll('option[data-injected]').forEach(function (o) { o.remove(); }); + prefillKsForm(editBtn); + } + }); + + // Show / hide the WiFi PSK passphrase. The input is type="text" with + // CSS masking (to dodge browser password-manager prompts); the eye + // button toggles a `.cfg-secret-shown` class that removes the mask. + document.addEventListener('click', function (e) { + var btn = e.target.closest && e.target.closest('[data-toggle-mask]'); + if (!btn) return; + var input = document.getElementById(btn.getAttribute('data-toggle-mask')); + if (!input) return; + input.classList.toggle('cfg-secret-shown'); + }); + + // Hide the Edit button when its picker has no real selection + // (placeholder option, or empty). Re-evaluated on every change to the + // picker, including the HTMX swap that follows a successful Save. + function syncKsEditButtons(root) { + var scope = root || document; + scope.querySelectorAll('[data-edit-source]').forEach(function (btn) { + var src = document.getElementById(btn.getAttribute('data-edit-source')); + if (!src) { btn.hidden = true; return; } + var opt = src.options[src.selectedIndex]; + btn.hidden = !opt || !opt.value || opt.disabled; + }); + } + document.addEventListener('change', function (e) { + if (e.target && e.target.tagName === 'SELECT') syncKsEditButtons(); + }); + document.addEventListener('htmx:afterSwap', function () { syncKsEditButtons(); }); + document.addEventListener('DOMContentLoaded', function () { syncKsEditButtons(); }); + // Sync once when the dialog opens so initial state matches the default + // mode (Station). The fieldset toggle below already fires on dialog + // open, so piggyback there. + + // Add Interface modal, VLAN fieldset: keep the Name input synced to + // <parent>.<vid> as the user changes either side. The user can still + // override by typing into Name; once they type a custom value, we stop + // auto-syncing for that session (tracked via .dataset.userEdited). + document.addEventListener('input', function (e) { + var nameInput = document.getElementById('add-iface-vlan-name'); + if (!nameInput) return; + var t = e.target; + if (t === nameInput) { + nameInput.dataset.userEdited = '1'; + return; + } + if (t.id !== 'add-iface-vlan-parent' && t.id !== 'add-iface-vlan-vid') return; + if (nameInput.dataset.userEdited === '1') return; + var parent = document.getElementById('add-iface-vlan-parent'); + var vid = document.getElementById('add-iface-vlan-vid'); + if (parent && vid) { + nameInput.value = (parent.value || '') + '.' + (vid.value || ''); + } + }); + + // On type select change in the Add Interface modal, show/enable the + // matching <fieldset> and hide/disable the rest. Disabling siblings is + // what keeps their inputs out of the form submission so only one + // `name="name"` reaches the server. Falls back to the "unsupported" + // panel for types whose Create path isn't implemented yet. + document.addEventListener('change', function (e) { + var sel = e.target.closest && e.target.closest('#add-iface-type-select'); + if (!sel) return; + var opt = sel.options[sel.selectedIndex]; + var slug = (opt && opt.getAttribute('data-slug')) || ''; + var fields = document.querySelectorAll('.iface-fields'); + var matched = null; + fields.forEach(function (fs) { + var isMatch = fs.id === 'iface-fields-' + slug; + fs.hidden = !isMatch; + fs.disabled = !isMatch; + if (isMatch) matched = fs; + }); + if (!matched) { + var unsupported = document.getElementById('iface-fields-unsupported'); + if (unsupported) { + unsupported.hidden = false; + unsupported.disabled = false; + } + } + var createBtn = document.getElementById('add-iface-create-btn'); + if (createBtn) createBtn.disabled = !matched; + if (matched && matched.id === 'iface-fields-wifi') refreshWifiSec(); + }); + + // Configure > Hardware "+ Add hardware" picker: sync the hidden class + // input from the selected option's data-class and reveal class-specific + // fields (currently WiFi country-code). + document.addEventListener('change', function (e) { + var sel = e.target.closest && e.target.closest('#add-hw-picker'); + if (!sel) return; + var opt = sel.options[sel.selectedIndex]; + var cls = (opt && opt.getAttribute('data-class')) || ''; + var classInput = document.getElementById('add-hw-class'); + if (classInput) classInput.value = cls; + var wifi = document.getElementById('add-hw-wifi-fields'); + if (wifi) wifi.hidden = (cls !== 'wifi'); + var country = document.getElementById('add-hw-wifi-country'); + if (country) country.required = (cls === 'wifi'); + }); +})(); + +// Theme (auto / light / dark) — shared by main app and login page +(function() { + function getTheme() { + var m = document.cookie.split(';').map(function(c){return c.trim();}).find(function(c){return c.indexOf('theme=')===0;}); + return m ? m.split('=')[1] : null; + } + + function applyTheme(mode) { + document.documentElement.classList.remove('dark', 'light'); + if (mode === 'dark') document.documentElement.classList.add('dark'); + else if (mode === 'light') document.documentElement.classList.add('light'); + updateDropdownCheck(mode || 'auto'); + updateLoginToggleIcon(mode || 'auto'); + } + + function updateDropdownCheck(mode) { + document.querySelectorAll('.theme-opt').forEach(function(btn) { + btn.classList.toggle('is-active', btn.getAttribute('data-theme') === mode); + }); + } + + function updateLoginToggleIcon(mode) { + // Login page floating toggle — cycle auto→light→dark + var btn = document.getElementById('login-theme-toggle'); + if (!btn) return; + var ids = {auto: 'lti-auto', light: 'lti-light', dark: 'lti-dark'}; + Object.keys(ids).forEach(function(k) { + var el = btn.querySelector('#' + ids[k]); + if (el) el.style.display = (k === mode) ? '' : 'none'; + }); + } + + function setTheme(mode) { + if (mode) { + document.cookie = 'theme=' + mode + '; path=/; max-age=31536000; samesite=lax'; + } else { + document.cookie = 'theme=; path=/; max-age=0; samesite=lax'; + } + applyTheme(mode); + } + + // Apply saved theme immediately (before DOMContentLoaded to avoid flash) + applyTheme(getTheme()); + + // Follow the OS when on 'auto'. Colours already track via CSS @media + + // color-scheme; re-applying on change keeps the menu indicators and any + // theme-derived state in sync. Ignored when a theme is pinned explicitly. + try { + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () { + if (!getTheme()) applyTheme(null); + }); + } catch (_) {} + + document.addEventListener('DOMContentLoaded', function() { + // Re-apply now that the DOM exists: applyTheme() ran in <head> before the + // dropdown and login toggle were parsed, so their indicators weren't set + // (the login toggle was stuck on its default 'auto' glyph). + applyTheme(getTheme()); + + // Dropdown theme options (main app) + document.querySelectorAll('.theme-opt').forEach(function(btn) { + btn.addEventListener('click', function() { + var t = btn.getAttribute('data-theme'); + setTheme(t === 'auto' ? null : t); + }); + }); + + // Login page floating toggle — cycles auto → light → dark → auto + var loginBtn = document.getElementById('login-theme-toggle'); + if (loginBtn) { + loginBtn.addEventListener('click', function() { + var cur = getTheme(); + if (!cur || cur === 'auto') setTheme('light'); + else if (cur === 'light') setTheme('dark'); + else setTheme(null); + }); + } + }); +})(); + +// Accordion nav group persistence +// Status and Maintenance persist open/closed state in localStorage. +// Configure is excluded from persistence: its state is controlled by the +// server template. Configure's Enter POST (running→candidate) is fired +// from JS the first time the accordion opens per page lifecycle. +(function() { + document.addEventListener('DOMContentLoaded', function() { + var groups = document.querySelectorAll('details.nav-group-top'); + var configureEntered = false; + + // Fire Configure Enter POST once per page lifecycle (initialises the + // candidate datastore). Called when the accordion is opened or when the + // page loads with the accordion already open (e.g. on a /configure/ page). + function enterConfigure() { + if (configureEntered) return; + configureEntered = true; + htmx.ajax('POST', '/configure/enter', {swap: 'none', target: document.body}); + } + + // If the Configure accordion is already open on page load, fire Enter now. + var configureDetails = document.getElementById('nav-configure'); + if (configureDetails && configureDetails.open) { + enterConfigure(); + } + + // Restore saved state — skip Configure so it never auto-reopens on page load. + // When closing an accordion, don't close it if the active page is inside it + // (updateActiveNav has already run at this point and set .nav-link.active). + // Mark elements being programmatically restored so the toggle handler below + // skips auto-navigation for these synthetic toggles (toggle fires async). + groups.forEach(function(d) { + if (d.id === 'nav-configure') return; + var label = d.querySelector(':scope > summary'); + if (!label) return; + var key = 'nav-top:' + label.textContent.trim(); + var saved = localStorage.getItem(key); + if (saved === 'open') { + d.dataset.navRestoring = 'true'; + d.setAttribute('open', ''); + } else if (saved === 'closed' && !d.querySelector('.nav-link.active')) { + d.removeAttribute('open'); + } + }); + + // Persistence + Configure enter-hook. + // + // Toggling a section header just expands or collapses that section — + // it does NOT close sibling sections, and it does NOT auto-navigate + // to the first page in the section. This lets the user browse the + // available pages under a header without paying for a navigation + // they didn't ask for. The actual mutual-exclusion (other sections + // collapse) happens in updateActiveNav after a real page-link click. + groups.forEach(function(d) { + var label = d.querySelector(':scope > summary'); + if (!label) return; + var key = 'nav-top:' + label.textContent.trim(); + d.addEventListener('toggle', function() { + // Skip synthetic toggles fired during page-load state restoration. + if (d.dataset.navRestoring) { + delete d.dataset.navRestoring; + return; + } + if (d.id !== 'nav-configure') { + localStorage.setItem(key, d.open ? 'open' : 'closed'); + } + if (d.open && d.id === 'nav-configure') { + enterConfigure(); + } + }); + }); + }); +})(); + +// User menu — hover (desktop) + click-toggle (touch) + keyboard +(function() { + document.addEventListener('DOMContentLoaded', function() { + var menu = document.getElementById('user-menu'); + var btn = document.getElementById('user-menu-btn'); + if (!menu || !btn) return; + + function setExpanded(val) { + btn.setAttribute('aria-expanded', val ? 'true' : 'false'); + } + + // Hover: keep aria-expanded in sync so CSS transition fires correctly + menu.addEventListener('mouseenter', function() { setExpanded(true); }); + menu.addEventListener('mouseleave', function() { setExpanded(false); }); + + // Click/tap: toggle (primary interaction on touch devices) + btn.addEventListener('click', function(e) { + e.stopPropagation(); + setExpanded(btn.getAttribute('aria-expanded') !== 'true'); + }); + + // Close when clicking outside (touch: tap outside) + document.addEventListener('click', function(e) { + if (!menu.contains(e.target)) setExpanded(false); + }); + + document.addEventListener('keydown', function(e) { + if (e.key === 'Escape') setExpanded(false); + }); + }); +})(); + +// Firewall zone matrix cell drill-down +(function() { + var verdictLabel = { allow: '✓ Allow', deny: '✗ Deny', conditional: '⚠ Conditional' }; + + document.addEventListener('click', function(e) { + var td = e.target.closest('.matrix-cell[data-verdict]'); + var panel = document.getElementById('fw-flow-detail'); + if (!panel) return; + + if (!td) { + panel.hidden = true; + return; + } + panel.querySelector('.fw-detail-flow').textContent = + td.getAttribute('data-from') + ' → ' + td.getAttribute('data-to'); + panel.querySelector('.fw-detail-verdict').textContent = + verdictLabel[td.getAttribute('data-verdict')] || td.getAttribute('data-verdict'); + panel.querySelector('.fw-detail-text').textContent = td.getAttribute('data-detail') || ''; + panel.hidden = false; + }); +})(); + +// Keystore key detail row toggle +(function() { + document.addEventListener('click', function(e) { + var btn = e.target.closest('.key-row-toggle'); + if (!btn) return; + var target = btn.getAttribute('data-target'); + var row = document.getElementById('key-detail-' + target); + if (!row) return; + var open = row.classList.toggle('is-open'); + btn.setAttribute('aria-expanded', open ? 'true' : 'false'); + }); +})(); + +// mDNS extra-address row toggle +(function() { + document.addEventListener('click', function(e) { + var btn = e.target.closest('.mdns-addr-toggle'); + if (!btn) return; + var group = btn.getAttribute('data-group'); + var expanded = btn.getAttribute('aria-expanded') === 'true'; + btn.setAttribute('aria-expanded', expanded ? 'false' : 'true'); + document.querySelectorAll('[data-mdns-extra="' + group + '"]').forEach(function(row) { + row.style.display = expanded ? '' : 'table-row'; + }); + }); +})(); + +// Bridge/LAG member row collapse toggle +(function() { + document.addEventListener('click', function(e) { + var btn = e.target.closest('.bridge-toggle'); + if (!btn) return; + var group = btn.getAttribute('data-group'); + var parentRow = btn.closest('tr'); + var collapsed = parentRow.classList.toggle('bridge-collapsed'); + btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); + document.querySelectorAll('[data-bridge-member="' + group + '"]').forEach(function(row) { + row.style.display = collapsed ? 'none' : ''; + }); + }); +})(); + +// Sidebar toggle (mobile) +(function() { + var MOBILE_BP = 1024; + + function closeSidebar(btn) { + document.body.classList.remove('sidebar-open'); + if (btn) btn.setAttribute('aria-expanded', 'false'); + } + + document.addEventListener('DOMContentLoaded', function() { + var btn = document.getElementById('hamburger-btn'); + if (!btn) return; + + // Open/close on hamburger click + btn.addEventListener('click', function() { + var open = document.body.classList.toggle('sidebar-open'); + btn.setAttribute('aria-expanded', open ? 'true' : 'false'); + }); + + // Close when clicking the overlay (::after pseudo-element covers body) + document.body.addEventListener('click', function(e) { + if (document.body.classList.contains('sidebar-open') && e.target === document.body) { + closeSidebar(btn); + } + }); + + // Close when navigating via htmx + document.addEventListener('htmx:beforeRequest', function() { + closeSidebar(btn); + }); + + // Close when viewport grows beyond the mobile breakpoint so the + // sidebar-open class and overlay don't linger at desktop widths. + var mq = window.matchMedia('(max-width: ' + MOBILE_BP + 'px)'); + function onBreakpointChange(e) { + if (!e.matches) closeSidebar(btn); + } + if (mq.addEventListener) { + mq.addEventListener('change', onBreakpointChange); + } else { + mq.addListener(onBreakpointChange); // Safari <14 fallback + } + }); +})(); + +// ─── data-autofocus: WM-friendly focus on visible ────────────────────────── +// Focus the [data-autofocus] element only when the page is actually +// visible — calling .focus() on a background tab/desktop is treated +// as an attention hint by Cinnamon/Muffin (and similar X11 WMs) +// and pulls the whole browser window to the foreground. +(function() { + function focusAutofocusWhenVisible() { + var el = document.querySelector('[data-autofocus]'); + if (!el) return; + var apply = function () { + if (document.visibilityState !== 'hidden') el.focus({ preventScroll: true }); + }; + if (document.visibilityState !== 'hidden') { + apply(); + return; + } + var handler = function () { + if (document.visibilityState !== 'hidden') { + document.removeEventListener('visibilitychange', handler); + apply(); + } + }; + document.addEventListener('visibilitychange', handler); + } + document.addEventListener('DOMContentLoaded', focusAutofocusWhenVisible); +})(); + +// ─── Login page: progress bar on submit ──────────────────────────────────── +// The login form is a native POST (not HTMX), so htmx:beforeSend never fires. +// Start the bar and disable the button to show the user something is happening. +(function() { + document.addEventListener('DOMContentLoaded', function() { + var form = document.querySelector('form[action="/login"]'); + if (!form) return; + form.addEventListener('submit', function() { + var bar = document.getElementById('page-progress'); + if (bar) { + bar.style.transition = 'none'; + bar.style.width = '0%'; + bar.style.opacity = '1'; + bar.offsetWidth; + bar.style.transition = 'width 8s cubic-bezier(0.05, 0.8, 0.4, 1)'; + bar.style.width = '85%'; + } + var btn = form.querySelector('button[type="submit"]'); + if (btn) { btn.disabled = true; btn.textContent = 'Logging in\u2026'; } + }); + }); +})(); + +// ─── Confirm dialog ──────────────────────────────────────────────────────── +// openModal(message, onConfirm) shows the shared <dialog> and calls onConfirm +// if the user clicks Confirm, or does nothing on Cancel. +// openModal shows the shared confirm dialog. With opts.alert it becomes a +// single-button alert (one "Dismiss", no Cancel) for errors the user can only +// acknowledge — e.g. a rejected Apply. The Cancel button and OK label are +// restored on close so the dialog returns to confirm() defaults. +function openModal(message, onConfirm, opts) { + opts = opts || {}; + var dlg = document.getElementById('confirm-dialog'); + if (!dlg) { // fallback if dialog missing + if (opts.alert) window.alert(message); + else if (onConfirm) onConfirm(); + return; + } + var msg = document.getElementById('dialog-message'); + var ok = document.getElementById('dialog-confirm'); + var no = document.getElementById('dialog-cancel'); + msg.textContent = message; + + var okLabel = ok.textContent; + if (opts.alert) { no.hidden = true; ok.textContent = 'Dismiss'; } + + function cleanup() { + ok.removeEventListener('click', handleOK); + no.removeEventListener('click', handleNo); + if (opts.alert) { no.hidden = false; ok.textContent = okLabel; } + dlg.close(); + } + function handleOK() { cleanup(); if (onConfirm) onConfirm(); } + function handleNo() { cleanup(); } + + ok.addEventListener('click', handleOK); + no.addEventListener('click', handleNo); + dlg.showModal(); +} + +// ─── data-close-detail: collapse a key-detail-row from inside ─────────────── +(function() { + document.addEventListener('click', function(e) { + var btn = e.target.closest('[data-close-detail]'); + if (!btn) return; + var target = btn.getAttribute('data-close-detail'); + var row = document.getElementById('key-detail-' + target); + if (row) row.classList.remove('is-open'); + var toggle = document.querySelector('[data-target="' + target + '"]'); + if (toggle) toggle.setAttribute('aria-expanded', 'false'); + }); +})(); + +// ─── data-show / data-hide: toggle element visibility ────────────────────── +(function() { + document.addEventListener('click', function(e) { + var show = e.target.closest('[data-show]'); + if (show) { + var el = document.getElementById(show.getAttribute('data-show')); + if (el) { el.hidden = false; el.querySelector('input,select,textarea') && el.querySelector('input,select,textarea').focus(); } + } + var hide = e.target.closest('[data-hide]'); + if (hide) { + var el = document.getElementById(hide.getAttribute('data-hide')); + if (el) el.hidden = true; + } + }); +})(); + +// ─── YANG tree: browser back/forward navigation for detail pane ───────────── +(function() { + var pendingPath = null; + var observedEl = null; + var observer = null; + + // Track which node path the user intends to navigate to. + document.addEventListener('click', function(e) { + var el = e.target.closest('[data-yang-path]'); + if (el) pendingPath = el.getAttribute('data-yang-path'); + }); + + // MutationObserver on #yang-detail: when its direct children change, a new + // node was loaded. Push a history entry only when a navigation click caused it. + function onDetailMutated() { + if (!pendingPath) return; + var path = pendingPath; + pendingPath = null; + history.pushState({ yangDetailPath: path }, '', + window.location.pathname + '?node=' + encodeURIComponent(path)); + } + + function attachObserver() { + var detail = document.getElementById('yang-detail'); + if (!detail || detail === observedEl) return; + if (observer) observer.disconnect(); + observedEl = detail; + observer = new MutationObserver(onDetailMutated); + observer.observe(detail, { childList: true }); + } + + window.addEventListener('popstate', function(e) { + var detail = document.getElementById('yang-detail'); + if (!detail) return; + if (e.state && e.state.yangDetailPath) { + if (window.htmx) { + htmx.ajax('GET', '/configure/tree/node?path=' + encodeURIComponent(e.state.yangDetailPath), + { target: '#yang-detail', swap: 'innerHTML' }); + } + } else { + detail.innerHTML = ''; + } + }); + + document.addEventListener('DOMContentLoaded', function() { + attachObserver(); + var detail = document.getElementById('yang-detail'); + if (!detail || !window.htmx) return; + var node = new URLSearchParams(window.location.search).get('node'); + if (node) { + htmx.ajax('GET', '/configure/tree/node?path=' + encodeURIComponent(node), + { target: '#yang-detail', swap: 'innerHTML' }); + } + }); + + // Re-attach after HTMX page navigation recreates #content (and #yang-detail). + document.addEventListener('htmx:afterSwap', attachObserver); +})(); + +// ─── Configure: dynamic list row add/delete ──────────────────────────────── +// Handles .btn-add-row (data-table, data-template) and .cfg-delete-row buttons. +// Templates are keyed by data-template attribute value. +(function () { + var rowTemplates = { + 'ntp': function(i) { + return '<tr>' + + '<td><input class="cfg-input" type="text" name="ntp_name_' + i + '" required></td>' + + '<td><input class="cfg-input" type="text" name="ntp_addr_' + i + '"></td>' + + '<td><input class="cfg-input cfg-input-sm" type="number" min="1" max="65535"' + + ' name="ntp_port_' + i + '" placeholder="123"></td>' + + '<td style="text-align:center"><input type="checkbox" name="ntp_prefer_' + i + '"></td>' + + '<td>' + deleteBtn() + '</td>' + + '</tr>'; + }, + 'dns-search': function(i) { + return '<tr>' + + '<td><input class="cfg-input" type="text" name="dns_search_' + i + '"></td>' + + '<td>' + deleteBtn() + '</td>' + + '</tr>'; + }, + 'dns-server': function(i) { + return '<tr>' + + '<td><input class="cfg-input" type="text" name="dns_name_' + i + '" required></td>' + + '<td><input class="cfg-input" type="text" name="dns_addr_' + i + '"></td>' + + '<td><input class="cfg-input cfg-input-sm" type="number" min="1" max="65535"' + + ' name="dns_port_' + i + '" placeholder="53"></td>' + + '<td>' + deleteBtn() + '</td>' + + '</tr>'; + } + }; + + function deleteBtn() { + return '<button type="button" class="btn-icon btn-icon-danger cfg-delete-row" title="Remove">' + + '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"' + + ' stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + + '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>' + + '</svg></button>'; + } + + // Re-index all inputs in a tbody so names stay sequential after add/delete. + function renumber(tbody) { + tbody.querySelectorAll('tr').forEach(function(row, i) { + row.querySelectorAll('input').forEach(function(inp) { + inp.name = inp.name.replace(/_\d+$/, '_' + i); + }); + }); + } + + document.addEventListener('click', function(e) { + var addBtn = e.target.closest('.btn-add-row'); + if (addBtn) { + var tbodyId = addBtn.getAttribute('data-table'); + var tmplKey = addBtn.getAttribute('data-template'); + var tbody = document.getElementById(tbodyId); + if (!tbody || !rowTemplates[tmplKey]) { return; } + var idx = tbody.querySelectorAll('tr').length; + tbody.insertAdjacentHTML('beforeend', rowTemplates[tmplKey](idx)); + var newInput = tbody.querySelector('tr:last-child input'); + if (newInput) { newInput.focus(); } + return; + } + + var delBtn = e.target.closest('.cfg-delete-row'); + if (delBtn) { + var row = delBtn.closest('tr'); + var tbody = row && row.closest('tbody'); + if (row) { row.remove(); } + if (tbody) { renumber(tbody); } + } + }); +})(); + +// ─── YANG tree accordion ─────────────────────────────────────────────────── +// When any node opens, collapse its siblings at the same level so only one +// subtree is expanded at a time. Works at every depth (top-level modules, +// list instances, nested containers). toggle doesn't bubble — use capture. +(function() { + document.addEventListener('toggle', function(e) { + var node = e.target; + if (!node.classList || !node.classList.contains('yt-node') || !node.open) return; + var li = node.parentElement; + var ul = li && li.parentElement; + if (!ul) return; + ul.querySelectorAll(':scope > li > details.yt-node').forEach(function(d) { + if (d !== node && d.open) d.removeAttribute('open'); + }); + }, true); +})(); + +// ─── YANG tree: selected-node highlight + deep-link reveal ────────────────── +// Two related orientation fixes for the tree editor: +// 1. Clicking a node loads it in the right pane but left no cue of where you +// are — mark the clicked summary .yt-active. +// 2. A status-page "Configure →" deep link (/configure/tree?path=…) only +// filled the right pane, leaving the left tree collapsed and the user +// disoriented. Walk the ancestor chain, opening each <details> in turn — +// children load lazily over htmx on toggle, so wait for each level's swap +// before descending — then highlight and scroll the target into view. +(function() { + function setActive(summary) { + document.querySelectorAll('.yt-label.yt-active').forEach(function (s) { + if (s !== summary) s.classList.remove('yt-active'); + }); + if (summary) summary.classList.add('yt-active'); + } + + document.addEventListener('click', function (e) { + var summary = e.target.closest && e.target.closest('summary[data-yang-path]'); + if (summary) setActive(summary); + }); + + // Load a node's children into its .yt-children with a direct htmx.ajax + // request, resolving with the children <ul>. We deliberately do NOT route + // through the node's lazy "toggle once" trigger: the toggle event from a + // programmatic .open is async and, on a freshly htmx-swapped tree, did not + // reliably reach htmx — the node opened but its body stayed empty until a + // manual reload. htmx.ajax sidesteps all of that. + function loadChildren(details) { + var childUl = details.querySelector(':scope > .yt-children'); + if (!childUl || childUl.children.length) return Promise.resolve(childUl); + var url = details.getAttribute('hx-get'); + if (!url || !window.htmx || !window.htmx.ajax) return Promise.resolve(childUl); + return window.htmx.ajax('GET', url, { target: childUl, swap: 'innerHTML' }) + .then(function () { return childUl; }); + } + + function step(ul, target) { + var best = null; + ul.querySelectorAll(':scope > li > details.yt-node > summary[data-yang-path]').forEach(function (s) { + var p = s.getAttribute('data-yang-path'); + if (p === target || target.indexOf(p + '/') === 0) { + if (!best || p.length > best.getAttribute('data-yang-path').length) best = s; + } + }); + if (!best) return; + + var details = best.parentElement; + var isTarget = best.getAttribute('data-yang-path') === target; + details.open = true; // chevron + reveal the children area + + if (isTarget) { + // Highlight and scroll right away — neither depends on the child load — + // then pull in the target's own children so its subtree shows. + setActive(best); + best.scrollIntoView({ block: 'center' }); + loadChildren(details); + return; + } + // Ancestor: load its children, then descend toward the target. + loadChildren(details).then(function (childUl) { + if (childUl) step(childUl, target); + }); + } + + function initReveal() { + var tree = document.querySelector('.yang-tree[data-initial-path]'); + if (!tree) return; + var target = tree.getAttribute('data-initial-path'); + tree.removeAttribute('data-initial-path'); // process once + var rootChildren = tree.querySelector('.yt-children'); + if (!target || !rootChildren) return; + // Defer a tick: on an htmx navigation the tree was just swapped in and htmx + // is still settling, so a child load triggered now is dropped (the node + // then highlights only after a manual reload). On a full reload it's + // already settled, so the tick is harmless. + setTimeout(function () { step(rootChildren, target); }, 0); + } + + document.addEventListener('DOMContentLoaded', initReveal); + document.addEventListener('htmx:afterSwap', initReveal); +})(); + +// ─── ⓘ field-info tooltip (position:fixed to escape overflow clipping) ─────── +(function() { + var tip = null; + + function getTip() { + if (!tip) { + tip = document.createElement('div'); + tip.id = 'field-tip'; + } + return tip; + } + + // A native <dialog>.showModal() renders in the browser's top layer, + // which is above z-index entirely; a tooltip appended to <body> ends + // up *below* the dialog regardless of z-index. Re-parent the tooltip + // into the active dialog so it shares the same top-layer context. + function parentFor(el) { + return el.closest('dialog[open]') || document.body; + } + + document.addEventListener('mouseover', function(e) { + var el = e.target.closest('.field-info[data-tip]'); + if (!el) return; + var t = getTip(); + var parent = parentFor(el); + if (t.parentElement !== parent) parent.appendChild(t); + t.textContent = el.getAttribute('data-tip'); + t.style.display = 'block'; + var r = el.getBoundingClientRect(); + // Position above the icon, centred; clamp to viewport edges. + var left = r.left + r.width / 2 - t.offsetWidth / 2; + var top = r.top - t.offsetHeight - 6; + if (left < 8) left = 8; + if (left + t.offsetWidth > window.innerWidth - 8) left = window.innerWidth - t.offsetWidth - 8; + if (top < 8) top = r.bottom + 6; // flip below if no room above + t.style.left = left + 'px'; + t.style.top = top + 'px'; + }); + + document.addEventListener('mouseout', function(e) { + var el = e.target.closest('.field-info[data-tip]'); + if (!el) return; + var t = getTip(); + t.style.display = 'none'; + }); +})(); + +// ─── Configure interaction log ───────────────────────────────────────────── +// Persists save/error events in sessionStorage so they survive page reloads +// (Apply/Abort both do HX-Refresh). Cleared on logout. +var cfgLogEntries = (function() { + try { return JSON.parse(sessionStorage.getItem('cfgLog') || '[]'); } catch(e) { return []; } +})(); + +function cfgIsoNow() { + var d = new Date(); + return d.getFullYear() + '-' + + String(d.getMonth() + 1).padStart(2, '0') + '-' + + String(d.getDate()).padStart(2, '0') + ' ' + + String(d.getHours()).padStart(2, '0') + ':' + + String(d.getMinutes()).padStart(2, '0') + ':' + + String(d.getSeconds()).padStart(2, '0'); +} + +function cfgLog(level, msg) { + cfgLogEntries.push({ level: level, msg: msg, ts: cfgIsoNow() }); + if (cfgLogEntries.length > 200) cfgLogEntries.shift(); + try { sessionStorage.setItem('cfgLog', JSON.stringify(cfgLogEntries)); } catch(e) {} + var badge = document.getElementById('cfg-log-badge'); + if (badge && level === 'error') { + badge.hidden = false; + badge.textContent = cfgLogEntries.filter(function(e) { return e.level === 'error'; }).length; + } +} + +// Restore error badge count after a page reload (entries came from sessionStorage). +document.addEventListener('DOMContentLoaded', function() { + var errCount = cfgLogEntries.filter(function(e) { return e.level === 'error'; }).length; + if (errCount > 0) { + var badge = document.getElementById('cfg-log-badge'); + if (badge) { badge.hidden = false; badge.textContent = errCount; } + } + // Clear log on logout so the next session starts fresh. + document.addEventListener('submit', function(e) { + if (e.target.closest('form[action="/logout"]')) { + try { sessionStorage.removeItem('cfgLog'); } catch(e2) {} + } + }); +}); + +function renderCfgLog() { + var panel = document.getElementById('cfg-log-panel'); + if (!panel) return; + if (cfgLogEntries.length === 0) { + panel.querySelector('.cfg-log-list').innerHTML = '<li class="cfg-log-empty">No activity yet.</li>'; + return; + } + var html = cfgLogEntries.slice().reverse().map(function(e) { + return '<li class="cfg-log-entry cfg-log-' + e.level + '">' + + '<span class="cfg-log-ts">' + e.ts + '</span> ' + + '<span class="cfg-log-msg">' + e.msg.replace(/</g, '<') + '</span></li>'; + }).join(''); + panel.querySelector('.cfg-log-list').innerHTML = html; +} + +// ─── Configure toolbar ───────────────────────────────────────────────────── +// Intercept HTMX's confirm event for toolbar buttons and show the custom modal. +// Apply / Apply & Save / Abort / Save-to-startup all respond with HX-Refresh. +(function () { + var toolbarLabels = { + 'cfg-apply-btn': 'Applied staged changes to running config', + 'cfg-apply-save-btn': 'Applied and saved to startup config', + 'cfg-abort-btn': 'Aborted: candidate reset to running config', + 'cfg-unsaved-save-btn': 'Saved running config to startup', + }; + document.addEventListener('htmx:confirm', function(e) { + var btn = e.detail.elt; + if (!btn) return; + var label = null; + for (var cls in toolbarLabels) { + if (btn.classList.contains(cls)) { label = toolbarLabels[cls]; break; } + } + if (!label) return; + e.preventDefault(); + openModal(e.detail.question, function() { + cfgLog('ok', label); + e.detail.issueRequest(true); + }); + }); + + // Log panel toggle / close. + document.addEventListener('click', function(e) { + if (e.target.closest('.cfg-log-close')) { + var panel = document.getElementById('cfg-log-panel'); + if (panel) panel.hidden = true; + return; + } + var btn = e.target.closest('#cfg-log-btn'); + if (!btn) return; + var panel = document.getElementById('cfg-log-panel'); + if (!panel) return; + panel.hidden = !panel.hidden; + if (!panel.hidden) { + renderCfgLog(); + var badge = document.getElementById('cfg-log-badge'); + if (badge) badge.hidden = true; + } + }); + + // findSaveStatusSpan locates the status span associated with the form that + // triggered an htmx event. Lookup order: + // 1. .cfg-save-status inside the form + // 2. [data-cfg-status-for="<form-id>"] anywhere on the page — used when the + // Save button is bound to the form via the HTML5 `form` attribute and + // lives outside the form element + // 3. .cfg-save-status inside the enclosing .info-card (shared feedback slot) + function findSaveStatusSpan(e) { + var form = e.target && e.target.closest('form'); + if (form) { + var inside = form.querySelector('.cfg-save-status'); + if (inside) return inside; + if (form.id) { + var bound = document.querySelector('.cfg-save-status[data-cfg-status-for="' + form.id + '"]'); + if (bound) return bound; + } + } + var card = e.target && e.target.closest('.info-card'); + return card ? card.querySelector('.cfg-save-status') : null; + } + + // cfgError: show error message in the .cfg-save-status span of the submitting form. + // Falls back to the enclosing .info-card when the form itself has no status + // span — useful when multiple forms in one card share a single feedback slot + // (e.g. Date & Time's Set-now and Save-timezone forms). + document.addEventListener('cfgError', function(e) { + var msg = e.detail && e.detail.value ? e.detail.value : 'Save failed'; + var span = findSaveStatusSpan(e); + if (span) { + span.textContent = '✗ ' + msg; + span.classList.add('error'); + cfgLog('error', msg); + // Keep error visible until dismissed (click) or 30 s timeout. + var tid = setTimeout(function() { + span.textContent = ''; + span.classList.remove('error'); + }, 30000); + span.addEventListener('click', function once() { + clearTimeout(tid); + span.textContent = ''; + span.classList.remove('error'); + span.removeEventListener('click', once); + }); + } + }); + + // Show "Saved ✓" feedback when a card Save succeeds. + (function() { + var LS_KEY = 'sw-url-history'; + var MAX_HIST = 10; + + function loadHistory() { + try { return JSON.parse(localStorage.getItem(LS_KEY) || '[]'); } + catch (e) { return []; } + } + + function saveURL(url) { + var hist = loadHistory().filter(function(u) { return u !== url; }); + hist.unshift(url); + if (hist.length > MAX_HIST) hist = hist.slice(0, MAX_HIST); + localStorage.setItem(LS_KEY, JSON.stringify(hist)); + } + + function populateDatalist() { + var dl = document.getElementById('sw-url-history'); + if (!dl) return; + dl.innerHTML = loadHistory().map(function(u) { + return '<option value="' + u.replace(/&/g, '&').replace(/"/g, '"') + '">'; + }).join(''); + } + + document.addEventListener('DOMContentLoaded', populateDatalist); + document.addEventListener('htmx:afterSettle', populateDatalist); + + document.addEventListener('submit', function(e) { + var form = e.target.closest('.software-form'); + if (!form) return; + var input = form.querySelector('input[name="url"]'); + if (input && input.value) saveURL(input.value); + }); + })(); + + // cfgLogged records a save in the activity panel only — used when the + // handler swaps the whole block via outerHTML and renders its own inline + // confirmation, so there's no span for the page JS to chase. + document.addEventListener('cfgLogged', function(e) { + cfgLog('ok', e.detail && e.detail.value ? e.detail.value : 'Saved'); + }); + + // cfgApplyError fires when Apply / Apply & Save reaches the device but the + // candidate is rejected (e.g. deleting a still-referenced interface). The + // backend returns 200 + this trigger instead of 502 so the connection + // monitor stays quiet — the box is fine, the config isn't. The toolbar + // optimistically logged success before issuing the request; drop that entry + // and show the real reason. + document.addEventListener('cfgApplyError', function(e) { + var msg = (e.detail && e.detail.value) || 'The device rejected the configuration.'; + if (cfgLogEntries.length && cfgLogEntries[cfgLogEntries.length - 1].level === 'ok') { + cfgLogEntries.pop(); + } + cfgLog('error', msg); + openModal('Configuration not applied: ' + msg, null, { alert: true }); + }); + + document.addEventListener('cfgSaved', function(e) { + var msg = e.detail && e.detail.value ? e.detail.value : 'Saved'; + cfgLog('ok', msg); + var span = findSaveStatusSpan(e); + if (span) { + span.textContent = '✓ ' + msg; + span.classList.add('saved'); + setTimeout(function() { + span.textContent = ''; + span.classList.remove('saved'); + }, 3000); + } + + // Re-render the whole current tree page from the (now fresh) candidate. + // The save handlers only WRITE — they don't echo back what confd + // inferred from the change (DHCP option lists, related leaves, + // normalised values), and inference can land outside the edited node. + // Re-fetching the page the user is on surfaces all of it. Guarded on + // #yang-detail so curated pages that also emit cfgSaved are unaffected. + var detail = document.getElementById('yang-detail'); + if (detail && window.htmx) { + var node = (history.state && history.state.yangDetailPath) || + new URLSearchParams(window.location.search).get('node'); + if (node) { + htmx.ajax('GET', '/configure/tree/node?path=' + encodeURIComponent(node), + { target: '#yang-detail', swap: 'innerHTML' }); + } + } + }); +})(); + +// ─── Inactivity auto-logout ──────────────────────────────────────────────── +// Submits POST /logout after a configurable period of inactivity. +// The chosen timeout is stored in localStorage; default is 15 min. +// Reset by deliberate user input and by HTMX requests that aren't +// background pollers. +(function() { + var LS_KEY = 'auto-logout'; + var DEFAULT = '900'; + var timerId = null; + + function getMs() { + var n = parseInt(localStorage.getItem(LS_KEY) || DEFAULT, 10); + return isNaN(n) ? parseInt(DEFAULT, 10) * 1000 : n * 1000; + } + + function updateOpts() { + var current = localStorage.getItem(LS_KEY) || DEFAULT; + document.querySelectorAll('.timeout-opt').forEach(function(btn) { + btn.classList.toggle('is-active', btn.getAttribute('data-timeout') === current); + }); + } + + function doLogout() { + // Tear down the server-side session in the background so it can't + // outlive the navigation. keepalive lets the request finish even + // if the user is mid-tab-close. getCSRFToken() from the early IIFE + // isn't in scope here — read the meta tag directly. + var fd = new FormData(); + var meta = document.querySelector('meta[name="csrf-token"]'); + if (meta) fd.append('csrf', meta.getAttribute('content') || ''); + fetch('/logout', { + method: 'POST', + body: fd, + credentials: 'same-origin', + keepalive: true, + }).catch(function () {}); + window.location.replace('/login'); + } + + function reset() { + clearTimeout(timerId); + var ms = getMs(); + if (ms > 0) timerId = setTimeout(doLogout, ms); + } + + // Mirror the chosen idle timeout to the server so the server-side session + // expiry matches the menu — including "Off" (0 = never). The server starts + // each session at its 1 h default; this re-asserts the user's preference on + // load and whenever they change it. + function syncServerTimeout(secs) { + var fd = new FormData(); + fd.append('timeout', secs); + var meta = document.querySelector('meta[name="csrf-token"]'); + if (meta) fd.append('csrf', meta.getAttribute('content') || ''); + fetch('/session/timeout', { method: 'POST', body: fd, credentials: 'same-origin' }).catch(function () {}); + } + + // Background pollers (watchdog, *counters refresh) must NOT extend + // the idle window — otherwise the timer never reaches the threshold. + function isPollingPath(p) { + return p === '/device-status' || (p && p.indexOf('/counters') !== -1); + } + + document.addEventListener('DOMContentLoaded', function() { + if (document.querySelector('form[action="/login"]')) return; + + updateOpts(); + reset(); + syncServerTimeout(localStorage.getItem(LS_KEY) || DEFAULT); + + // mousemove deliberately omitted: trackpad jitter from a neighbouring + // window would keep the session alive indefinitely. + ['mousedown', 'keypress', 'touchstart', 'scroll', 'click'].forEach(function(ev) { + window.addEventListener(ev, reset, { passive: true }); + }); + document.addEventListener('htmx:afterRequest', function(evt) { + var cfg = evt && evt.detail && evt.detail.requestConfig; + if (cfg && isPollingPath(cfg.path)) return; + reset(); + }); + + document.addEventListener('click', function(e) { + var btn = e.target.closest('.timeout-opt'); + if (!btn) return; + localStorage.setItem(LS_KEY, btn.getAttribute('data-timeout')); + updateOpts(); + reset(); + syncServerTimeout(btn.getAttribute('data-timeout')); + }); + + // Session expired server-side while we were idle: any HX request + // now returns 401. Send the user to /login so the page reflects + // reality instead of failing silently on every click. + document.addEventListener('htmx:responseError', function(evt) { + var s = evt && evt.detail && evt.detail.xhr && evt.detail.xhr.status; + if (s === 401) window.location.replace('/login'); + }); + + // Route the manual Logout button through doLogout so the session is + // torn down server-side before navigation, not after. + var logoutForm = document.querySelector('form[action="/logout"]'); + if (logoutForm) { + logoutForm.addEventListener('submit', function(e) { + e.preventDefault(); + doLogout(); + }); + } + }); +})(); + +// ─── Maintenance > Diagnostics ───────────────────────────────────────────── +// Ping / traceroute / mtr / DNS lookup. Tabs are client-side: switching +// tool only changes which option fields and which output surface show. +// Run opens an EventSource to /maintenance/diagnostics/run and renders +// streamed `line` / `hop` / `done` events; Stop closes it, which cancels +// the server request context and kills the spawned tool. DNS lookup is a +// one-shot fetch instead of a stream. +(function () { + var diagES = null; // active stream, module-scoped so afterSwap can kill it + + function diagTeardown() { + if (diagES) { + diagES.close(); + diagES = null; + } + } + + // Tools that take a network target + source interface (everything but + // DNS, which resolves a name and has no source-interface concept here). + function isNetTool(tool) { + return tool === 'ping' || tool === 'traceroute' || tool === 'mtr' || tool === 'nmap'; + } + + function setActiveTool(card, tool) { + card.dataset.active = tool; + + card.querySelectorAll('.diag-tab').forEach(function (t) { + t.classList.toggle('active', t.getAttribute('data-tool') === tool); + }); + + // Show only the option fields belonging to this tool. + card.querySelectorAll('.diag-opt').forEach(function (o) { + o.hidden = o.getAttribute('data-tool') !== tool; + }); + + // Source interface only applies to the network tools. + card.querySelectorAll('.diag-only-net').forEach(function (el) { + el.hidden = !isNetTool(tool); + }); + + // mtr's cycle/size fields only make sense when not running forever. + if (tool === 'mtr') applyMtrContinuous(card); + + // Retarget the input label/placeholder per tool. + var label = card.querySelector('[data-target-label]'); + var input = card.querySelector('.diag-target'); + if (tool === 'dns') { + if (label) label.textContent = 'Name to resolve'; + if (input) input.placeholder = 'hostname'; + } else if (tool === 'nmap') { + if (label) label.textContent = 'Target host, address, or CIDR'; + if (input) input.placeholder = 'host, IP, or 192.168.0.0/24'; + } else { + if (label) label.textContent = 'Target host or address'; + if (input) input.placeholder = 'hostname or IP'; + } + + // Reset to the ready state: kill any stream, hide all output + // surfaces, show the placeholder hint. The matching surface is + // revealed on Run. Switching tool is a context change, so dropping + // the previous tool's output is the expected "fresh start". + diagTeardown(); + card.querySelector('.diag-text').hidden = true; + card.querySelector('.diag-mtr').hidden = true; + card.querySelector('.diag-dns').hidden = true; + card.querySelector('.diag-placeholder').hidden = false; + diagSetStatus(card, '', ''); + diagSetRunning(card, false); + } + + function diagSetStatus(card, text, cls) { + var s = card.querySelector('.diag-status'); + if (!s) return; + s.textContent = text || ''; + s.className = 'diag-status' + (cls ? ' ' + cls : ''); + } + + function diagSetRunning(card, running) { + card.querySelector('.diag-run').hidden = running; + card.querySelector('.diag-stop').hidden = !running; + } + + // Return focus to the target field when a run finishes so the user can + // type the next address immediately. Guard on visibility so a run that + // completes while the tab is backgrounded doesn't yank the window + // forward (the run was started, then the user looked away). + function diagFocusTarget(card) { + var input = card.querySelector('.diag-target'); + if (input && document.visibilityState !== 'hidden') { + input.focus({ preventScroll: true }); + } + } + + // Hide mtr's Cycles/Size fields while "Run continuously" is checked. + function applyMtrContinuous(card) { + var cb = card.querySelector('.diag-mtr-continuous'); + var continuous = cb ? cb.checked : true; + card.querySelectorAll('.diag-mtr-param').forEach(function (el) { + el.hidden = continuous; + }); + } + + // ── Target history (shared across tools, persisted in localStorage) ── + var DIAG_HIST_KEY = 'infix.diag.history'; + + function diagLoadHistory() { + try { return JSON.parse(localStorage.getItem(DIAG_HIST_KEY)) || []; } + catch (_) { return []; } + } + + function diagRenderHistory() { + var dl = document.getElementById('diag-history'); + if (!dl) return; + dl.innerHTML = ''; + diagLoadHistory().forEach(function (h) { + var o = document.createElement('option'); + o.value = h; + dl.appendChild(o); + }); + } + + function diagPushHistory(target) { + if (!target) return; + var list = diagLoadHistory().filter(function (h) { return h !== target; }); + list.unshift(target); + if (list.length > 25) list = list.slice(0, 25); + try { localStorage.setItem(DIAG_HIST_KEY, JSON.stringify(list)); } catch (_) {} + diagRenderHistory(); + } + + // Upsert one mtr hop row keyed by hop index. + function diagUpsertHop(tbody, hop) { + var row = tbody.querySelector('tr[data-idx="' + hop.idx + '"]'); + if (!row) { + row = document.createElement('tr'); + row.setAttribute('data-idx', hop.idx); + row.innerHTML = + '<td class="diag-mtr-hop"></td><td class="diag-mtr-host"></td>' + + '<td class="diag-mtr-loss"></td><td></td><td></td><td></td><td></td><td></td>'; + // Keep rows ordered by hop index even if events arrive out of order. + var next = null; + tbody.querySelectorAll('tr').forEach(function (r) { + if (next === null && parseInt(r.getAttribute('data-idx'), 10) > hop.idx) next = r; + }); + tbody.insertBefore(row, next); + } + var c = row.children; + c[0].textContent = hop.idx + 1; + c[1].textContent = hop.host; + c[2].textContent = hop.loss.toFixed(1); + c[2].classList.toggle('diag-loss-bad', hop.loss > 0); + c[3].textContent = hop.snt; + c[4].textContent = hop.last.toFixed(1); + c[5].textContent = hop.avg.toFixed(1); + c[6].textContent = hop.best.toFixed(1); + c[7].textContent = hop.worst.toFixed(1); + } + + function diagBuildURL(card) { + var tool = card.dataset.active; + var q = new URLSearchParams(); + q.set('tool', tool); + q.set('target', card.querySelector('.diag-target').value.trim()); + q.set('family', card.querySelector('.diag-family').value); + if (isNetTool(tool)) { + q.set('iface', card.querySelector('.diag-iface').value); + } + if (tool === 'ping') { + var count = card.querySelector('.diag-count'); + var size = card.querySelector('.diag-size'); + if (count) q.set('count', count.value); + if (size) q.set('size', size.value); + } else if (tool === 'traceroute') { + var maxhops = card.querySelector('.diag-maxhops'); + if (maxhops) q.set('maxhops', maxhops.value); + } else if (tool === 'mtr') { + // Omitting count tells the backend to run forever; only send the + // cycle/size knobs when the user has opted out of continuous mode. + var cont = card.querySelector('.diag-mtr-continuous'); + if (cont && !cont.checked) { + var mc = card.querySelector('.diag-mtr-count'); + var ms = card.querySelector('.diag-mtr-size'); + if (mc) q.set('count', mc.value); + if (ms) q.set('size', ms.value); + } + } else if (tool === 'nmap') { + var scan = card.querySelector('.diag-scan'); + if (scan) q.set('scan', scan.value); + } + return '/maintenance/diagnostics/run?' + q.toString(); + } + + function diagRunDNS(card) { + var name = card.querySelector('.diag-target').value.trim(); + if (!name) { + diagSetStatus(card, 'enter a name', 'err'); + return; + } + var dns = card.querySelector('.diag-dns'); + var family = card.querySelector('.diag-family').value; + diagSetStatus(card, 'resolving…', 'pending'); + card.querySelector('.diag-placeholder').hidden = true; + dns.hidden = false; + fetch('/maintenance/diagnostics/resolve?name=' + encodeURIComponent(name) + + '&family=' + encodeURIComponent(family), { headers: { 'HX-Request': 'true' } }) + .then(function (r) { return r.text(); }) + .then(function (html) { dns.innerHTML = html; diagSetStatus(card, '', ''); diagFocusTarget(card); }) + .catch(function () { diagSetStatus(card, 'lookup failed', 'err'); }); + } + + function diagRunStream(card) { + var tool = card.dataset.active; + var target = card.querySelector('.diag-target').value.trim(); + if (!target) { + diagSetStatus(card, 'enter a target', 'err'); + return; + } + + diagTeardown(); + card.querySelector('.diag-placeholder').hidden = true; + + var textPane = card.querySelector('.diag-text'); + var mtrTable = card.querySelector('.diag-mtr'); + var mtrBody = mtrTable.querySelector('tbody'); + if (tool === 'mtr') { + mtrBody.innerHTML = ''; + mtrTable.hidden = false; + textPane.hidden = true; + } else { + textPane.textContent = ''; + textPane.hidden = false; + mtrTable.hidden = true; + } + + diagSetStatus(card, 'running…', 'pending'); + diagSetRunning(card, true); + + diagES = new EventSource(diagBuildURL(card)); + diagES.addEventListener('line', function (evt) { + textPane.textContent += evt.data + '\n'; + textPane.scrollTop = textPane.scrollHeight; + }); + diagES.addEventListener('hop', function (evt) { + try { diagUpsertHop(mtrBody, JSON.parse(evt.data)); } catch (_) {} + }); + diagES.addEventListener('done', function () { + diagTeardown(); + diagSetStatus(card, 'done', ''); + diagSetRunning(card, false); + diagFocusTarget(card); + }); + diagES.onerror = function () { + // Transient drops auto-reconnect; only react to a terminal close. + if (diagES && diagES.readyState === EventSource.CLOSED) { + diagTeardown(); + diagSetStatus(card, 'disconnected', 'err'); + diagSetRunning(card, false); + } + }; + } + + function diagRun(card) { + var input = card.querySelector('.diag-target'); + var target = input.value.trim(); + if (target) diagPushHistory(target); + // Blur the target so Firefox dismisses the datalist popup, which it + // otherwise leaves hovering over the results after a selection. + if (input) input.blur(); + if (card.dataset.active === 'dns') { + diagRunDNS(card); + } else { + diagRunStream(card); + } + } + + function diagStop(card) { + diagTeardown(); + diagSetStatus(card, 'stopped', ''); + diagSetRunning(card, false); + diagFocusTarget(card); + } + + function diagClear(card) { + card.querySelector('.diag-text').textContent = ''; + card.querySelector('.diag-mtr tbody').innerHTML = ''; + card.querySelector('.diag-dns').innerHTML = ''; + card.querySelector('.diag-text').hidden = true; + card.querySelector('.diag-mtr').hidden = true; + card.querySelector('.diag-dns').hidden = true; + card.querySelector('.diag-placeholder').hidden = false; + diagSetStatus(card, '', ''); + } + + function initDiagnostics(scope) { + var root = scope || document; + root.querySelectorAll('.diag-card:not([data-init])').forEach(function (card) { + card.dataset.init = 'true'; + + card.querySelectorAll('.diag-tab').forEach(function (tab) { + tab.addEventListener('click', function () { + setActiveTool(card, tab.getAttribute('data-tool')); + // Focus the target field so the user can type straight away. + var input = card.querySelector('.diag-target'); + if (input) input.focus(); + }); + }); + + card.querySelector('.diag-run').addEventListener('click', function () { diagRun(card); }); + card.querySelector('.diag-stop').addEventListener('click', function () { diagStop(card); }); + card.querySelector('.diag-clear').addEventListener('click', function () { diagClear(card); }); + + var cont = card.querySelector('.diag-mtr-continuous'); + if (cont) cont.addEventListener('change', function () { applyMtrContinuous(card); }); + + // Enter in ANY form field (target, count, family, …) runs the + // active tool — Run is the natural default action. Buttons are + // excluded so Enter on Run/Stop/Clear keeps their own behavior + // and doesn't fire twice. + card.querySelector('.diag-form').addEventListener('keydown', function (e) { + if (e.key === 'Enter' && e.target.tagName !== 'BUTTON') { + e.preventDefault(); + diagRun(card); + } + }); + + diagRenderHistory(); + setActiveTool(card, card.dataset.active || 'ping'); + + // Focus the target on load too (tab clicks already do). Guard on + // visibility so a background load doesn't yank the window forward + // under X11 WMs — same reasoning as the data-autofocus handler. + var target = card.querySelector('.diag-target'); + if (target && document.visibilityState !== 'hidden') { + target.focus({ preventScroll: true }); + } + }); + } + + document.addEventListener('DOMContentLoaded', function () { + initDiagnostics(document); + if (window.htmx) { + document.body.addEventListener('htmx:afterSwap', function (evt) { + // Navigating away from the diagnostics page removes the card; + // make sure any running stream is torn down server-side. + if (!document.querySelector('.diag-card')) diagTeardown(); + var s = (evt.detail && evt.detail.target) || document; + initDiagnostics(s); + }); + } + }); +})(); + +// ─── Maintenance > Backup & Support: support bundle download ──────────────── +// `support collect` takes up to a minute and returns the whole archive at +// once, so a plain <a download> would just hang with no feedback. Instead +// fetch the bundle as a blob with a visible "generating…" state, then save +// it client-side using the filename the server set in Content-Disposition. +(function () { + function filenameFromDisposition(cd, fallback) { + var m = /filename="?([^";]+)"?/.exec(cd || ''); + return m ? m[1] : fallback; + } + + function initSupportBundle(scope) { + (scope || document).querySelectorAll('.support-generate:not([data-init])').forEach(function (btn) { + btn.dataset.init = 'true'; + var card = btn.closest('.info-card'); + var pass = card.querySelector('.support-pass'); + var status = card.querySelector('.support-status'); + + function setStatus(text, cls) { + if (!status) return; + status.textContent = text || ''; + status.className = 'support-status' + (cls ? ' ' + cls : ''); + } + + btn.addEventListener('click', function () { + btn.disabled = true; + setStatus('Collecting… (up to a minute)', 'pending'); + + var body = new FormData(); + if (pass && pass.value) body.append('password', pass.value); + + fetch('/maintenance/support-bundle', { + method: 'POST', + headers: { 'X-CSRF-Token': btn.getAttribute('data-csrf') || '' }, + body: body + }).then(function (r) { + if (!r.ok) throw new Error('HTTP ' + r.status); + var name = filenameFromDisposition(r.headers.get('Content-Disposition'), 'support-bundle.tar.gz'); + return r.blob().then(function (blob) { return { blob: blob, name: name }; }); + }).then(function (res) { + var url = URL.createObjectURL(res.blob); + var a = document.createElement('a'); + a.href = url; + a.download = res.name; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(function () { URL.revokeObjectURL(url); }, 10000); + setStatus('Downloaded ' + res.name, ''); + if (pass) pass.value = ''; + }).catch(function (e) { + setStatus('Failed to generate bundle', 'err'); + if (window.console) console.warn('[support]', e); + }).finally(function () { + btn.disabled = false; + }); + }); + }); + } + + document.addEventListener('DOMContentLoaded', function () { + initSupportBundle(document); + if (window.htmx) { + document.body.addEventListener('htmx:afterSwap', function (evt) { + initSupportBundle((evt.detail && evt.detail.target) || document); + }); + } + }); +})(); + +// ─── Web console (ttyd) link ──────────────────────────────────────────────── +// The console runs on its own HTTPS port (7681), so the link target can't be +// a relative path — build it from the current hostname at load time. CSP +// (script-src 'self') forbids an inline onclick, hence this lives here. The +// topbar/sidebar only render on full page loads, so DOMContentLoaded covers it. +(function () { + document.addEventListener('DOMContentLoaded', function () { + var links = document.querySelectorAll('[data-console-link]'); + if (!links.length) return; + var url = 'https://' + window.location.hostname + ':7681/'; + links.forEach(function (a) { a.href = url; }); + }); +})(); diff --git a/src/webui/static/js/htmx.min.js b/src/webui/static/js/htmx.min.js new file mode 100644 index 00000000..59937d71 --- /dev/null +++ b/src/webui/static/js/htmx.min.js @@ -0,0 +1 @@ +var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.4"};Q.onLoad=j;Q.process=kt;Q.on=ye;Q.off=be;Q.trigger=he;Q.ajax=Rn;Q.find=u;Q.findAll=x;Q.closest=g;Q.remove=z;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=$e;Q.defineExtension=Fn;Q.removeExtension=Bn;Q.logAll=V;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:le,canAccessLocalStorage:B,findThisElement:Se,filterValues:hn,swap:$e,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:o,getExpressionVars:En,getHeaders:fn,getInputValues:cn,getInternalData:ie,getSwapSpecification:gn,getTriggerSpecs:st,getTarget:Ee,makeFragment:P,mergeObjects:ce,makeSettleInfo:xn,oobSwap:He,querySelectorExt:ae,settleImmediately:Kt,shouldCancel:ht,triggerEvent:he,triggerErrorEvent:fe,withExtensions:Ft};const r=["get","post","put","delete","patch"];const H=r.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function m(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function o(e,t){while(e&&!t(e)){e=c(e)}return e||null}function i(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;o(t,function(e){return!!(r=i(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function T(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function q(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function A(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function N(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(N(e)){const t=A(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){O(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i,"");const n=T(t);let r;if(n==="html"){r=new DocumentFragment;const i=q(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=q(t);L(r,i.body);r.title=i.title}else{const i=q('<body><template class="internal-htmx-wrapper">'+t+"</template></body>");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function D(e){return t(e,"Object")}function ie(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function M(t){const n=[];if(t){for(let e=0;e<t.length;e++){n.push(t[e])}}return n}function se(t,n){if(t){for(let e=0;e<t.length;e++){n(t[e])}}}function X(e){const t=e.getBoundingClientRect();const n=t.top;const r=t.bottom;return n<window.innerHeight&&r>=0}function le(e){return e.getRootNode({composed:true})===document}function F(e){return e.trim().split(/\s+/)}function ce(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function S(e){try{return JSON.parse(e)}catch(e){O(e);return null}}function B(){const e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function U(t){try{const e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function e(e){return vn(ne().body,function(){return eval(e)})}function j(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Q.logger=null}function u(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return u(ne(),e)}}function x(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return x(ne(),e)}}function E(){return window}function z(e,t){e=y(e);if(t){E().setTimeout(function(){z(e);e=null},t)}else{c(e).removeChild(e)}}function ue(e){return e instanceof Element?e:null}function $(e){return e instanceof HTMLElement?e:null}function J(e){return typeof e==="string"?e:null}function f(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function K(e,t,n){e=ue(y(e));if(!e){return}if(n){E().setTimeout(function(){K(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function G(e,t,n){let r=ue(y(e));if(!r){return}if(n){E().setTimeout(function(){G(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function W(e,t){e=y(e);e.classList.toggle(t)}function Z(e,t){e=y(e);se(e.parentElement.children,function(e){G(e,t)});K(ue(e),t)}function g(e,t){e=ue(y(e));if(e&&e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&ue(c(e)));return null}}function l(e,t){return e.substring(0,t.length)===t}function Y(e,t){return e.substring(e.length-t.length)===t}function ge(e){const t=e.trim();if(l(t,"<")&&Y(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function p(t,r,n){if(r.indexOf("global ")===0){return p(t,r.slice(7),true)}t=y(t);const o=[];{let t=0;let n=0;for(let e=0;e<r.length;e++){const l=r[e];if(l===","&&t===0){o.push(r.substring(n,e));n=e+1;continue}if(l==="<"){t++}else if(l==="/"&&e<r.length-1&&r[e+1]===">"){t--}}if(n<r.length){o.push(r.substring(n))}}const i=[];const s=[];while(o.length>0){const r=ge(o.shift());let e;if(r.indexOf("closest ")===0){e=g(ue(t),ge(r.substr(8)))}else if(r.indexOf("find ")===0){e=u(f(t),ge(r.substr(5)))}else if(r==="next"||r==="nextElementSibling"){e=ue(t).nextElementSibling}else if(r.indexOf("next ")===0){e=pe(t,ge(r.substr(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=ue(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=me(t,ge(r.substr(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=m(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const c=f(m(t,!!n));i.push(...M(c.querySelectorAll(e)))}return i}var pe=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=0;e<r.length;e++){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_PRECEDING){return o}}};var me=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=r.length-1;e>=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ae(e,t){if(typeof e!=="string"){return p(e,t)[0]}else{return p(ne().body,e)[0]}}function y(e,t){if(typeof e==="string"){return u(f(t)||document,e)}else{return e}}function xe(e,t,n,r){if(k(t)){return{target:ne().body,event:J(e),listener:t,options:n}}else{return{target:y(e),event:J(t),listener:n,options:r}}}function ye(t,n,r,o){Vn(function(){const e=xe(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=k(n);return e?n:r}function be(t,n,r){Vn(function(){const e=xe(t,n,r);e.target.removeEventListener(e.event,e.listener)});return k(n)?n:r}const ve=ne().createElement("output");function we(e,t){const n=re(e,t);if(n){if(n==="this"){return[Se(e,t)]}else{const r=p(e,n);if(r.length===0){O('The selector "'+n+'" on '+t+" returned no matches!");return[ve]}else{return r}}}}function Se(e,t){return ue(o(e,function(e){return te(ue(e),t)!=null}))}function Ee(e){const t=re(e,"hx-target");if(t){if(t==="this"){return Se(e,"hx-target")}else{return ae(e,t)}}else{const n=ie(e);if(n.boosted){return ne().body}else{return e}}}function Ce(t){const n=Q.config.attributesToSettle;for(let e=0;e<n.length;e++){if(t===n[e]){return true}}return false}function Oe(t,n){se(t.attributes,function(e){if(!n.hasAttribute(e.name)&&Ce(e.name)){t.removeAttribute(e.name)}});se(n.attributes,function(e){if(Ce(e.name)){t.setAttribute(e.name,e.value)}})}function Re(t,e){const n=Un(e);for(let e=0;e<n.length;e++){const r=n[e];try{if(r.isInlineSwap(t)){return true}}catch(e){O(e)}}return t==="outerHTML"}function He(e,o,i,t){t=t||ne();let n="#"+ee(o,"id");let s="outerHTML";if(e==="true"){}else if(e.indexOf(":")>0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=p(t,n,false);if(r){se(r,function(e){let t;const n=o.cloneNode(true);t=ne().createDocumentFragment();t.appendChild(n);if(!Re(s,e)){t=f(n)}const r={shouldSwap:true,target:e,fragment:t};if(!he(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){qe(t);_e(s,e,e,t,i);Te()}se(i.elts,function(e){he(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(ne().body,"htmx:oobErrorNoTarget",{content:o})}return e}function Te(){const e=u("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=u("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function qe(e){se(x(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=te(e,"id");const n=ne().getElementById(t);if(n!=null){if(e.moveBefore){let e=u("#--htmx-preserve-pantry--");if(e==null){ne().body.insertAdjacentHTML("afterend","<div id='--htmx-preserve-pantry--'></div>");e=u("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function Le(l,e,c){se(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=f(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Oe(t,i);c.tasks.push(function(){Oe(t,s)})}}})}function Ae(e){return function(){G(e,Q.config.addedClass);kt(ue(e));Ne(f(e));he(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=$(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function a(e,t,n,r){Le(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;K(ue(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ae(o))}}}function Ie(e,t){let n=0;while(n<e.length){t=(t<<5)-t+e.charCodeAt(n++)|0}return t}function Pe(t){let n=0;if(t.attributes){for(let e=0;e<t.attributes.length;e++){const r=t.attributes[e];if(r.value){n=Ie(r.name,n);n=Ie(r.value,n)}}}return n}function ke(t){const n=ie(t);if(n.onHandlers){for(let e=0;e<n.onHandlers.length;e++){const r=n.onHandlers[e];be(t,r.event,r.listener)}delete n.onHandlers}}function De(e){const t=ie(e);if(t.timeout){clearTimeout(t.timeout)}if(t.listenerInfos){se(t.listenerInfos,function(e){if(e.on){be(e.on,e.trigger,e.listener)}})}ke(e);se(Object.keys(t),function(e){if(e!=="firstInitCompleted")delete t[e]})}function b(e){he(e,"htmx:beforeCleanupElement");De(e);if(e.children){se(e.children,function(e){b(e)})}}function Me(t,e,n){if(t instanceof Element&&t.tagName==="BODY"){return Ve(t,e,n)}let r;const o=t.previousSibling;const i=c(t);if(!i){return}a(i,t,e,n);if(o==null){r=i.firstChild}else{r=o.nextSibling}n.elts=n.elts.filter(function(e){return e!==t});while(r&&r!==t){if(r instanceof Element){n.elts.push(r)}r=r.nextSibling}b(t);if(t instanceof Element){t.remove()}else{t.parentNode.removeChild(t)}}function Xe(e,t,n){return a(e,e.firstChild,t,n)}function Fe(e,t,n){return a(c(e),e,t,n)}function Be(e,t,n){return a(e,null,t,n)}function Ue(e,t,n){return a(c(e),e.nextSibling,t,n)}function je(e){b(e);const t=c(e);if(t){return t.removeChild(e)}}function Ve(e,t,n){const r=e.firstChild;a(e,r,t,n);if(r){while(r.nextSibling){b(r.nextSibling);e.removeChild(r.nextSibling)}b(r);e.removeChild(r)}}function _e(t,e,n,r,o){switch(t){case"none":return;case"outerHTML":Me(n,r,o);return;case"afterbegin":Xe(n,r,o);return;case"beforebegin":Fe(n,r,o);return;case"beforeend":Be(n,r,o);return;case"afterend":Ue(n,r,o);return;case"delete":je(n);return;default:var i=Un(e);for(let e=0;e<i.length;e++){const s=i[e];try{const l=s.handleSwap(t,n,r,o);if(l){if(Array.isArray(l)){for(let e=0;e<l.length;e++){const c=l[e];if(c.nodeType!==Node.TEXT_NODE&&c.nodeType!==Node.COMMENT_NODE){o.tasks.push(Ae(c))}}}return}}catch(e){O(e)}}if(t==="innerHTML"){Ve(n,r,o)}else{_e(Q.config.defaultSwapStyle,e,n,r,o)}}}function ze(e,n,r){var t=x(e,"[hx-swap-oob], [data-hx-swap-oob]");se(t,function(e){if(Q.config.allowNestedOobSwaps||e.parentElement===null){const t=te(e,"hx-swap-oob");if(t!=null){He(t,e,n,r)}}else{e.removeAttribute("hx-swap-oob");e.removeAttribute("data-hx-swap-oob")}});return t.length>0}function $e(e,t,r,o){if(!o){o={}}e=y(e);const i=o.contextElement?m(o.contextElement,false):ne();const n=document.activeElement;let s={};try{s={elt:n,start:n?n.selectionStart:null,end:n?n.selectionEnd:null}}catch(e){}const l=xn(e);if(r.swapStyle==="textContent"){e.textContent=t}else{let n=P(t);l.title=n.title;if(o.selectOOB){const u=o.selectOOB.split(",");for(let t=0;t<u.length;t++){const a=u[t].split(":",2);let e=a[0].trim();if(e.indexOf("#")===0){e=e.substring(1)}const f=a[1]||"true";const h=n.querySelector("#"+e);if(h){He(f,h,l,i)}}}ze(n,l,i);se(x(n,"template"),function(e){if(e.content&&ze(e.content,l,i)){e.remove()}});if(o.select){const d=ne().createDocumentFragment();se(n.querySelectorAll(o.select),function(e){d.appendChild(e)});n=d}qe(n);_e(r.swapStyle,o.contextElement,e,n,l);Te()}if(s.elt&&!le(s.elt)&&ee(s.elt,"id")){const g=document.getElementById(ee(s.elt,"id"));const p={preventScroll:r.focusScroll!==undefined?!r.focusScroll:!Q.config.defaultFocusScroll};if(g){if(s.start&&g.setSelectionRange){try{g.setSelectionRange(s.start,s.end)}catch(e){}}g.focus(p)}}e.classList.remove(Q.config.swappingClass);se(l.elts,function(e){if(e.classList){e.classList.add(Q.config.settlingClass)}he(e,"htmx:afterSwap",o.eventInfo)});if(o.afterSwapCallback){o.afterSwapCallback()}if(!r.ignoreTitle){kn(l.title)}const c=function(){se(l.tasks,function(e){e.call()});se(l.elts,function(e){if(e.classList){e.classList.remove(Q.config.settlingClass)}he(e,"htmx:afterSettle",o.eventInfo)});if(o.anchor){const e=ue(y("#"+o.anchor));if(e){e.scrollIntoView({block:"start",behavior:"auto"})}}yn(l.elts,r);if(o.afterSettleCallback){o.afterSettleCallback()}};if(r.settleDelay>0){E().setTimeout(c,r.settleDelay)}else{c()}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=S(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(D(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}he(n,i,e)}}}else{const s=r.split(",");for(let e=0;e<s.length;e++){he(n,s[e].trim(),[])}}}const Ke=/\s/;const v=/[\s,]/;const Ge=/[_$a-zA-Z]/;const We=/[_$a-zA-Z0-9]/;const Ze=['"',"'","/"];const w=/[^\s]/;const Ye=/[{(]/;const Qe=/[})]/;function et(e){const t=[];let n=0;while(n<e.length){if(Ge.exec(e.charAt(n))){var r=n;while(We.exec(e.charAt(n+1))){n++}t.push(e.substring(r,n+1))}else if(Ze.indexOf(e.charAt(n))!==-1){const o=e.charAt(n);var r=n;n++;while(n<e.length&&e.charAt(n)!==o){if(e.charAt(n)==="\\"){n++}n++}t.push(e.substring(r,n+1))}else{const i=e.charAt(n);t.push(i)}n++}return t}function tt(e,t,n){return Ge.exec(e.charAt(0))&&e!=="true"&&e!=="false"&&e!=="this"&&e!==n&&t!=="."}function nt(r,o,i){if(o[0]==="["){o.shift();let e=1;let t=" return (function("+i+"){ return (";let n=null;while(o.length>0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=vn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(ne().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function C(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=C(e,Qe).trim();e.shift()}else{t=C(e,v)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{C(o,w);const l=o.length;const c=C(o,/[,\[\s]/);if(c!==""){if(c==="every"){const u={trigger:"every"};C(o,w);u.pollInterval=d(C(o,/[,\[\s]/));C(o,w);var i=nt(e,o,"event");if(i){u.eventFilter=i}r.push(u)}else{const a={trigger:c};var i=nt(e,o,"event");if(i){a.eventFilter=i}C(o,w);while(o.length>0&&o[0]!==","){const f=o.shift();if(f==="changed"){a.changed=true}else if(f==="once"){a.once=true}else if(f==="consume"){a.consume=true}else if(f==="delay"&&o[0]===":"){o.shift();a.delay=d(C(o,v))}else if(f==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=C(o,v);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=rt(o);if(h.length>0){s+=" "+h}}}a.from=s}else if(f==="target"&&o[0]===":"){o.shift();a.target=rt(o)}else if(f==="throttle"&&o[0]===":"){o.shift();a.throttle=d(C(o,v))}else if(f==="queue"&&o[0]===":"){o.shift();a.queue=C(o,v)}else if(f==="root"&&o[0]===":"){o.shift();a[f]=rt(o)}else if(f==="threshold"&&o[0]===":"){o.shift();a[f]=C(o,v)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}r.push(a)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=te(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){ie(e).cancelled=true}function ct(e,t,n){const r=ie(e);r.timeout=E().setTimeout(function(){if(le(e)&&r.cancelled!==true){if(!gt(n,e,Mt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function ut(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function at(e){return g(e,Q.config.disableSelector)}function ft(t,n,e){if(t instanceof HTMLAnchorElement&&ut(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(o==null||o===""){o=ne().location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){pt(t,function(e,t){const n=ue(e);if(at(n)){b(n);return}de(r,o,n,t)},n,e,true)})}}function ht(e,t){const n=ue(t);if(!n){return false}if(e.type==="submit"||e.type==="click"){if(n.tagName==="FORM"){return true}if(h(n,'input[type="submit"], button')&&(h(n,"[form]")||g(n,"form")!==null)){return true}if(n instanceof HTMLAnchorElement&&n.href&&(n.getAttribute("href")==="#"||n.getAttribute("href").indexOf("#")!==0)){return true}}return false}function dt(e,t){return ie(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function gt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(ne().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function pt(l,c,e,u,a){const f=ie(l);let t;if(u.from){t=p(l,u.from)}else{t=[l]}if(u.changed){if(!("lastValue"in f)){f.lastValue=new WeakMap}t.forEach(function(e){if(!f.lastValue.has(u)){f.lastValue.set(u,new WeakMap)}f.lastValue.get(u).set(e,e.value)})}se(t,function(i){const s=function(e){if(!le(l)){i.removeEventListener(u.trigger,s);return}if(dt(l,e)){return}if(a||ht(e,l)){e.preventDefault()}if(gt(u,l,e)){return}const t=ie(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!h(ue(e.target),u.target)){return}}if(u.once){if(f.triggeredOnce){return}else{f.triggeredOnce=true}}if(u.changed){const n=event.target;const r=n.value;const o=f.lastValue.get(u);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(f.delayed){clearTimeout(f.delayed)}if(f.throttle){return}if(u.throttle>0){if(!f.throttle){he(l,"htmx:trigger");c(l,e);f.throttle=E().setTimeout(function(){f.throttle=null},u.throttle)}}else if(u.delay>0){f.delayed=E().setTimeout(function(){he(l,"htmx:trigger");c(l,e)},u.delay)}else{he(l,"htmx:trigger");c(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:s,on:i});i.addEventListener(u.trigger,s)})}let mt=false;let xt=null;function yt(){if(!xt){xt=function(){mt=true};window.addEventListener("scroll",xt);window.addEventListener("resize",xt);setInterval(function(){if(mt){mt=false;se(ne().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&X(e)){e.setAttribute("data-hx-revealed","true");const t=ie(e);if(t.initHash){he(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){he(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;he(e,"htmx:trigger");t(e)}};if(r>0){E().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;se(r,function(r){if(s(t,"hx-"+r)){const o=te(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ue(e);if(g(n,Q.config.disableSelector)){b(n);return}de(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){yt();pt(r,n,t,e);bt(ue(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ae(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e<t.length;e++){const n=t[e];if(n.isIntersecting){he(r,"intersect");break}}},o);i.observe(ue(r));pt(ue(r),n,t,e)}else if(!t.firstInitCompleted&&e.trigger==="load"){if(!gt(e,r,Mt("load",{elt:r}))){vt(ue(r),n,t,e.delay)}}else if(e.pollInterval>0){t.polling=true;ct(ue(r),n,e)}else{pt(r,n,t,e)}}function Et(e){const t=ue(e);if(!t){return false}const n=t.attributes;for(let e=0;e<n.length;e++){const r=n[e].name;if(l(r,"hx-on:")||l(r,"data-hx-on:")||l(r,"hx-on-")||l(r,"data-hx-on-")){return true}}return false}const Ct=(new XPathEvaluator).createExpression('.//*[@*[ starts-with(name(), "hx-on:") or starts-with(name(), "data-hx-on:") or'+' starts-with(name(), "hx-on-") or starts-with(name(), "data-hx-on-") ]]');function Ot(e,t){if(Et(e)){t.push(ue(e))}const n=Ct.evaluate(e);let r=null;while(r=n.iterateNext())t.push(ue(r))}function Rt(e){const t=[];if(e instanceof DocumentFragment){for(const n of e.childNodes){Ot(n,t)}}else{Ot(e,t)}return t}function Ht(e){if(e.querySelectorAll){const n=", [hx-boost] a, [data-hx-boost] a, a[hx-boost], a[data-hx-boost]";const r=[];for(const i in Mn){const s=Mn[i];if(s.getSelectors){var t=s.getSelectors();if(t){r.push(t)}}}const o=e.querySelectorAll(H+n+", form, [type='submit'],"+" [hx-ext], [data-hx-ext], [hx-trigger], [data-hx-trigger]"+r.flat().map(e=>", "+e).join(""));return o}else{return[]}}function Tt(e){const t=g(ue(e.target),"button, input[type='submit']");const n=Lt(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=Lt(e);if(t){t.lastButtonClicked=null}}function Lt(e){const t=g(ue(e.target),"button, input[type='submit']");if(!t){return}const n=y("#"+ee(t,"form"),t.getRootNode())||g(t,"form");if(!n){return}return ie(n)}function At(e){e.addEventListener("click",Tt);e.addEventListener("focusin",Tt);e.addEventListener("focusout",qt)}function Nt(t,e,n){const r=ie(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){vn(t,function(){if(at(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function It(t){ke(t);for(let e=0;e<t.attributes.length;e++){const n=t.attributes[e].name;const r=t.attributes[e].value;if(l(n,"hx-on")||l(n,"data-hx-on")){const o=n.indexOf("-on")+3;const i=n.slice(o,o+1);if(i==="-"||i===":"){let e=n.slice(o+1);if(l(e,":")){e="htmx"+e}else if(l(e,"-")){e="htmx:"+e.slice(1)}else if(l(e,"htmx-")){e="htmx:"+e.slice(5)}Nt(t,e,r)}}}}function Pt(t){if(g(t,Q.config.disableSelector)){b(t);return}const n=ie(t);const e=Pe(t);if(n.initHash!==e){De(t);n.initHash=e;he(t,"htmx:beforeProcessNode");const r=st(t);const o=wt(t,n,r);if(!o){if(re(t,"hx-boost")==="true"){ft(t,n,r)}else if(s(t,"hx-trigger")){r.forEach(function(e){St(t,e,n,function(){})})}}if(t.tagName==="FORM"||ee(t,"type")==="submit"&&s(t,"form")){At(t)}n.firstInitCompleted=true;he(t,"htmx:afterProcessNode")}}function kt(e){e=y(e);if(g(e,Q.config.disableSelector)){b(e);return}Pt(e);se(Ht(e),function(e){Pt(e)});se(Rt(e),It)}function Dt(e){return e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase()}function Mt(e,t){let n;if(window.CustomEvent&&typeof window.CustomEvent==="function"){n=new CustomEvent(e,{bubbles:true,cancelable:true,composed:true,detail:t})}else{n=ne().createEvent("CustomEvent");n.initCustomEvent(e,true,true,t)}return n}function fe(e,t,n){he(e,t,ce({error:t},n))}function Xt(e){return e==="htmx:afterProcessNode"}function Ft(e,t){se(Un(e),function(e){try{t(e)}catch(e){O(e)}})}function O(e){if(console.error){console.error(e)}else if(console.log){console.log("ERROR: ",e)}}function he(e,t,n){e=y(e);if(n==null){n={}}n.elt=e;const r=Mt(t,n);if(Q.logger&&!Xt(t)){Q.logger(e,t,n)}if(n.error){O(n.error);he(e,"htmx:error",{errorInfo:n})}let o=e.dispatchEvent(r);const i=Dt(t);if(o&&i!==t){const s=Mt(i,r.detail);o=o&&e.dispatchEvent(s)}Ft(ue(e),function(e){o=o&&(e.onEvent(t,r)!==false&&!r.defaultPrevented)});return o}let Bt=location.pathname+location.search;function Ut(){const e=ne().querySelector("[hx-history-elt],[data-hx-history-elt]");return e||ne().body}function jt(t,e){if(!B()){return}const n=_t(e);const r=ne().title;const o=window.scrollY;if(Q.config.historyCacheSize<=0){localStorage.removeItem("htmx-history-cache");return}t=U(t);const i=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e<i.length;e++){if(i[e].url===t){i.splice(e,1);break}}const s={url:t,content:n,title:r,scroll:o};he(ne().body,"htmx:historyItemCreated",{item:s,cache:i});i.push(s);while(i.length>Q.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(ne().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Vt(t){if(!B()){return null}t=U(t);const n=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e<n.length;e++){if(n[e].url===t){return n[e]}}return null}function _t(e){const t=Q.config.requestClass;const n=e.cloneNode(true);se(x(n,"."+t),function(e){G(e,t)});se(x(n,"[data-disabled-by-htmx]"),function(e){e.removeAttribute("disabled")});return n.innerHTML}function zt(){const e=Ut();const t=Bt||location.pathname+location.search;let n;try{n=ne().querySelector('[hx-history="false" i],[data-hx-history="false" i]')}catch(e){n=ne().querySelector('[hx-history="false"],[data-hx-history="false"]')}if(!n){he(ne().body,"htmx:beforeHistorySave",{path:t,historyElt:e});jt(t,e)}if(Q.config.historyEnabled)history.replaceState({htmx:true},ne().title,window.location.href)}function $t(e){if(Q.config.getCacheBusterParam){e=e.replace(/org\.htmx\.cache-buster=[^&]*&?/,"");if(Y(e,"&")||Y(e,"?")){e=e.slice(0,-1)}}if(Q.config.historyEnabled){history.pushState({htmx:true},"",e)}Bt=e}function Jt(e){if(Q.config.historyEnabled)history.replaceState({htmx:true},"",e);Bt=e}function Kt(e){se(e,function(e){e.call(undefined)})}function Gt(o){const e=new XMLHttpRequest;const i={path:o,xhr:e};he(ne().body,"htmx:historyCacheMiss",i);e.open("GET",o,true);e.setRequestHeader("HX-Request","true");e.setRequestHeader("HX-History-Restore-Request","true");e.setRequestHeader("HX-Current-URL",ne().location.href);e.onload=function(){if(this.status>=200&&this.status<400){he(ne().body,"htmx:historyCacheMissLoad",i);const e=P(this.response);const t=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;const n=Ut();const r=xn(n);kn(e.title);qe(e);Ve(n,t,r);Te();Kt(r.tasks);Bt=o;he(ne().body,"htmx:historyRestore",{path:o,cacheMiss:true,serverResponse:this.response})}else{fe(ne().body,"htmx:historyCacheMissLoadError",i)}};e.send()}function Wt(e){zt();e=e||location.pathname+location.search;const t=Vt(e);if(t){const n=P(t.content);const r=Ut();const o=xn(r);kn(t.title);qe(n);Ve(r,n,o);Te();Kt(o.tasks);E().setTimeout(function(){window.scrollTo(0,t.scroll)},0);Bt=e;he(ne().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{Gt(e)}}}function Zt(e){let t=we(e,"hx-indicator");if(t==null){t=[e]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function Yt(e){let t=we(e,"hx-disabled-elt");if(t==null){t=[]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")});return t}function Qt(e,t){se(e.concat(t),function(e){const t=ie(e);t.requestCount=(t.requestCount||1)-1});se(e,function(e){const t=ie(e);if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});se(t,function(e){const t=ie(e);if(t.requestCount===0){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function en(t,n){for(let e=0;e<t.length;e++){const r=t[e];if(r.isSameNode(n)){return true}}return false}function tn(e){const t=e;if(t.name===""||t.name==null||t.disabled||g(t,"fieldset[disabled]")){return false}if(t.type==="button"||t.type==="submit"||t.tagName==="image"||t.tagName==="reset"||t.tagName==="file"){return false}if(t.type==="checkbox"||t.type==="radio"){return t.checked}return true}function nn(t,e,n){if(t!=null&&e!=null){if(Array.isArray(e)){e.forEach(function(e){n.append(t,e)})}else{n.append(t,e)}}}function rn(t,n,r){if(t!=null&&n!=null){let e=r.getAll(t);if(Array.isArray(n)){e=e.filter(e=>n.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);se(e,e=>r.append(t,e))}}function on(t,n,r,o,i){if(o==null||en(t,o)){return}else{t.push(o)}if(tn(o)){const s=ee(o,"name");let e=o.value;if(o instanceof HTMLSelectElement&&o.multiple){e=M(o.querySelectorAll("option:checked")).map(function(e){return e.value})}if(o instanceof HTMLInputElement&&o.files){e=M(o.files)}nn(s,e,n);if(i){sn(o,r)}}if(o instanceof HTMLFormElement){se(o.elements,function(e){if(t.indexOf(e)>=0){rn(e.name,e.value,n)}else{t.push(e)}if(i){sn(e,r)}});new FormData(o).forEach(function(e,t){if(e instanceof File&&e.name===""){return}nn(t,e,n)})}}function sn(e,t){const n=e;if(n.willValidate){he(n,"htmx:validation:validate");if(!n.checkValidity()){t.push({elt:n,message:n.validationMessage,validity:n.validity});he(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})}}}function ln(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function cn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=ie(e);if(s.lastButtonClicked&&!le(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||te(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){on(n,o,i,g(e,"form"),l)}on(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const u=s.lastButtonClicked||e;const a=ee(u,"name");nn(a,u.value,o)}const c=we(e,"hx-include");se(c,function(e){on(n,r,i,ue(e),l);if(!h(e,"form")){se(f(e).querySelectorAll(ot),function(e){on(n,r,i,e,l)})}});ln(r,o);return{errors:i,formData:r,values:An(r)}}function un(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function an(e){e=qn(e);let n="";e.forEach(function(e,t){n=un(n,t,e)});return n}function fn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":ne().location.href};bn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(ie(e).boosted){r["HX-Boosted"]="true"}return r}function hn(n,e){const t=re(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){se(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;se(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function dn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function gn(e,t){const n=t||re(e,"hx-swap");const r={swapStyle:ie(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ie(e).boosted&&!dn(e)){r.show="top"}if(n){const s=F(n);if(s.length>0){for(let e=0;e<s.length;e++){const l=s[e];if(l.indexOf("swap:")===0){r.swapDelay=d(l.slice(5))}else if(l.indexOf("settle:")===0){r.settleDelay=d(l.slice(7))}else if(l.indexOf("transition:")===0){r.transition=l.slice(11)==="true"}else if(l.indexOf("ignoreTitle:")===0){r.ignoreTitle=l.slice(12)==="true"}else if(l.indexOf("scroll:")===0){const c=l.slice(7);var o=c.split(":");const u=o.pop();var i=o.length>0?o.join(":"):null;r.scroll=u;r.scrollTarget=i}else if(l.indexOf("show:")===0){const a=l.slice(5);var o=a.split(":");const f=o.pop();var i=o.length>0?o.join(":"):null;r.show=f;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{O("Unknown modifier in hx-swap: "+l)}}}}return r}function pn(e){return re(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function mn(t,n,r){let o=null;Ft(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(pn(n)){return ln(new FormData,qn(r))}else{return an(r)}}}function xn(e){return{tasks:[],elts:[e]}}function yn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ue(ae(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ue(ae(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function bn(r,e,o,i){if(i==null){i={}}if(r==null){return i}const s=te(r,e);if(s){let e=s.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=vn(r,function(){return Function("return ("+e+")")()},{})}else{n=S(e)}for(const l in n){if(n.hasOwnProperty(l)){if(i[l]==null){i[l]=n[l]}}}}return bn(ue(c(r)),e,o,i)}function vn(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function wn(e,t){return bn(e,"hx-vars",true,t)}function Sn(e,t){return bn(e,"hx-vals",false,t)}function En(e){return ce(wn(e),Sn(e))}function Cn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function On(t){if(t.responseURL&&typeof URL!=="undefined"){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(ne().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function R(e,t){return t.test(e.getAllResponseHeaders())}function Rn(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return de(t,n,null,null,{targetOverride:y(r)||ve,returnPromise:true})}else{let e=y(r.target);if(r.target&&!e||r.source&&!e&&!y(r.source)){e=ve}return de(t,n,y(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return de(t,n,null,null,{returnPromise:true})}}function Hn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function Tn(e,t,n){let r;let o;if(typeof URL==="function"){o=new URL(t,document.location.href);const i=document.location.origin;r=i===o.origin}else{o=t;r=l(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!r){return false}}return he(e,"htmx:validateUrl",ce({url:o,sameHost:r},n))}function qn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Ln(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function An(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}else{return e[t]}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Ln(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function de(t,n,r,o,i,D){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ne().body}const M=i.handler||Dn;const X=i.select||null;if(!le(r)){oe(s);return e}const c=i.targetOverride||ue(Ee(r));if(c==null||c==ve){fe(r,"htmx:targetError",{target:te(r,"hx-target")});oe(l);return e}let u=ie(r);const a=u.lastButtonClicked;if(a){const L=ee(a,"formaction");if(L!=null){n=L}const A=ee(a,"formmethod");if(A!=null){if(A.toLowerCase()!=="dialog"){t=A}}}const f=re(r,"hx-confirm");if(D===undefined){const K=function(e){return de(t,n,r,o,i,!!e)};const G={target:c,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:f};if(he(r,"htmx:confirm",G)===false){oe(s);return e}}let h=r;let d=re(r,"hx-sync");let g=null;let F=false;if(d){const N=d.split(":");const I=N[0].trim();if(I==="this"){h=Se(r,"hx-sync")}else{h=ue(ae(r,I))}d=(N[1]||"drop").trim();u=ie(h);if(d==="drop"&&u.xhr&&u.abortable!==true){oe(s);return e}else if(d==="abort"){if(u.xhr){oe(s);return e}else{F=true}}else if(d==="replace"){he(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");g=(W[1]||"last").trim()}}if(u.xhr){if(u.abortable){he(h,"htmx:abort")}else{if(g==null){if(o){const P=ie(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){g=P.triggerSpec.queue}}if(g==null){g="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(g==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="all"){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){de(t,n,r,o,i)})}oe(s);return e}}const p=new XMLHttpRequest;u.xhr=p;u.abortable=F;const m=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){const e=u.queuedRequests.shift();e()}};const B=re(r,"hx-prompt");if(B){var x=prompt(B);if(x===null||!he(r,"htmx:prompt",{prompt:x,target:c})){oe(s);m();return e}}if(f&&!D){if(!confirm(f)){oe(s);m();return e}}let y=fn(r,c,x);if(t!=="get"&&!pn(r)){y["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){y=ce(y,i.headers)}const U=cn(r,t);let b=U.errors;const j=U.formData;if(i.values){ln(j,qn(i.values))}const V=qn(En(r));const v=ln(j,V);let w=hn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(c,"id")||"true")}if(n==null||n===""){n=ne().location.href}const S=bn(r,"hx-request");const _=ie(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:_,useUrlParams:E,formData:w,parameters:An(w),unfilteredFormData:v,unfilteredParameters:An(v),headers:y,target:c,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!he(r,"htmx:configRequest",C)){oe(s);m();return e}n=C.path;t=C.verb;y=C.headers;w=qn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){he(r,"htmx:validation:halted",C);oe(s);m();return e}const z=n.split("#");const $=z[0];const O=z[1];let R=n;if(E){R=$;const Z=!w.keys().next().done;if(Z){if(R.indexOf("?")<0){R+="?"}else{R+="&"}R+=an(w);if(O){R+="#"+O}}}if(!Tn(r,R,C)){fe(r,"htmx:invalidPath",C);oe(l);return e}p.open(t.toUpperCase(),R,true);p.overrideMimeType("text/html");p.withCredentials=C.withCredentials;p.timeout=C.timeout;if(S.noHeaders){}else{for(const k in y){if(y.hasOwnProperty(k)){const Y=y[k];Cn(p,k,Y)}}}const H={xhr:p,target:c,requestConfig:C,etc:i,boosted:_,select:X,pathInfo:{requestPath:n,finalRequestPath:R,responsePath:null,anchor:O}};p.onload=function(){try{const t=Hn(r);H.pathInfo.responsePath=On(p);M(r,H);if(H.keepIndicators!==true){Qt(T,q)}he(r,"htmx:afterRequest",H);he(r,"htmx:afterOnLoad",H);if(!le(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(le(n)){e=n}}if(e){he(e,"htmx:afterRequest",H);he(e,"htmx:afterOnLoad",H)}}oe(s);m()}catch(e){fe(r,"htmx:onLoadError",ce({error:e},H));throw e}};p.onerror=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendError",H);oe(l);m()};p.onabort=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendAbort",H);oe(l);m()};p.ontimeout=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:timeout",H);oe(l);m()};if(!he(r,"htmx:beforeRequest",H)){oe(s);m();return e}var T=Zt(r);var q=Yt(r);se(["loadstart","loadend","progress","abort"],function(t){se([p,p.upload],function(e){e.addEventListener(t,function(e){he(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});he(r,"htmx:beforeSend",H);const J=E?null:mn(p,r,w);p.send(J);return e}function Nn(e,t){const n=t.xhr;let r=null;let o=null;if(R(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(R(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(R(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=re(e,"hx-push-url");const c=re(e,"hx-replace-url");const u=ie(e).boosted;let a=null;let f=null;if(l){a="push";f=l}else if(c){a="replace";f=c}else if(u){a="push";f=s||i}if(f){if(f==="false"){return{}}if(f==="true"){f=s||i}if(t.pathInfo.anchor&&f.indexOf("#")===-1){f=f+"#"+t.pathInfo.anchor}return{type:a,path:f}}else{return{}}}function In(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Pn(e){for(var t=0;t<Q.config.responseHandling.length;t++){var n=Q.config.responseHandling[t];if(In(n,e.status)){return n}}return{swap:false}}function kn(e){if(e){const t=u("title");if(t){t.innerHTML=e}else{window.document.title=e}}}function Dn(o,i){const s=i.xhr;let l=i.target;const e=i.etc;const c=i.select;if(!he(o,"htmx:beforeOnLoad",i))return;if(R(s,/HX-Trigger:/i)){Je(s,"HX-Trigger",o)}if(R(s,/HX-Location:/i)){zt();let e=s.getResponseHeader("HX-Location");var t;if(e.indexOf("{")===0){t=S(e);e=t.path;delete t.path}Rn("get",e,t).then(function(){$t(e)});return}const n=R(s,/HX-Refresh:/i)&&s.getResponseHeader("HX-Refresh")==="true";if(R(s,/HX-Redirect:/i)){i.keepIndicators=true;location.href=s.getResponseHeader("HX-Redirect");n&&location.reload();return}if(n){i.keepIndicators=true;location.reload();return}if(R(s,/HX-Retarget:/i)){if(s.getResponseHeader("HX-Retarget")==="this"){i.target=o}else{i.target=ue(ae(o,s.getResponseHeader("HX-Retarget")))}}const u=Nn(o,i);const r=Pn(s);const a=r.swap;let f=!!r.error;let h=Q.config.ignoreTitle||r.ignoreTitle;let d=r.select;if(r.target){i.target=ue(ae(o,r.target))}var g=e.swapOverride;if(g==null&&r.swapOverride){g=r.swapOverride}if(R(s,/HX-Retarget:/i)){if(s.getResponseHeader("HX-Retarget")==="this"){i.target=o}else{i.target=ue(ae(o,s.getResponseHeader("HX-Retarget")))}}if(R(s,/HX-Reswap:/i)){g=s.getResponseHeader("HX-Reswap")}var p=s.response;var m=ce({shouldSwap:a,serverResponse:p,isError:f,ignoreTitle:h,selectOverride:d,swapOverride:g},i);if(r.event&&!he(l,r.event,m))return;if(!he(l,"htmx:beforeSwap",m))return;l=m.target;p=m.serverResponse;f=m.isError;h=m.ignoreTitle;d=m.selectOverride;g=m.swapOverride;i.target=l;i.failed=f;i.successful=!f;if(m.shouldSwap){if(s.status===286){lt(o)}Ft(o,function(e){p=e.transformResponse(p,s,o)});if(u.type){zt()}var x=gn(o,g);if(!x.hasOwnProperty("ignoreTitle")){x.ignoreTitle=h}l.classList.add(Q.config.swappingClass);let n=null;let r=null;if(c){d=c}if(R(s,/HX-Reselect:/i)){d=s.getResponseHeader("HX-Reselect")}const y=re(o,"hx-select-oob");const b=re(o,"hx-select");let e=function(){try{if(u.type){he(ne().body,"htmx:beforeHistoryUpdate",ce({history:u},i));if(u.type==="push"){$t(u.path);he(ne().body,"htmx:pushedIntoHistory",{path:u.path})}else{Jt(u.path);he(ne().body,"htmx:replacedInHistory",{path:u.path})}}$e(l,p,x,{select:d||b,selectOOB:y,eventInfo:i,anchor:i.pathInfo.anchor,contextElement:o,afterSwapCallback:function(){if(R(s,/HX-Trigger-After-Swap:/i)){let e=o;if(!le(o)){e=ne().body}Je(s,"HX-Trigger-After-Swap",e)}},afterSettleCallback:function(){if(R(s,/HX-Trigger-After-Settle:/i)){let e=o;if(!le(o)){e=ne().body}Je(s,"HX-Trigger-After-Settle",e)}oe(n)}})}catch(e){fe(o,"htmx:swapError",i);oe(r);throw e}};let t=Q.config.globalViewTransitions;if(x.hasOwnProperty("transition")){t=x.transition}if(t&&he(o,"htmx:beforeTransition",i)&&typeof Promise!=="undefined"&&document.startViewTransition){const v=new Promise(function(e,t){n=e;r=t});const w=e;e=function(){document.startViewTransition(function(){w();return v})}}if(x.swapDelay>0){E().setTimeout(e,x.swapDelay)}else{e()}}if(f){fe(o,"htmx:responseError",ce({error:"Response Status Error Code "+s.status+" from "+i.pathInfo.requestPath},i))}}const Mn={};function Xn(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function Fn(e,t){if(t.init){t.init(n)}Mn[e]=ce(Xn(),t)}function Bn(e){delete Mn[e]}function Un(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=te(e,"hx-ext");if(t){se(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=Mn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return Un(ue(c(e)),n,r)}var jn=false;ne().addEventListener("DOMContentLoaded",function(){jn=true});function Vn(e){if(jn||ne().readyState==="complete"){e()}else{ne().addEventListener("DOMContentLoaded",e)}}function _n(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";ne().head.insertAdjacentHTML("beforeend","<style"+e+"> ."+Q.config.indicatorClass+"{opacity:0} ."+Q.config.requestClass+" ."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ."+Q.config.requestClass+"."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} </style>")}}function zn(){const e=ne().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function $n(){const e=zn();if(e){Q.config=ce(Q.config,e)}}Vn(function(){$n();_n();let e=ne().body;kt(e);const t=ne().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=ie(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){Wt();se(t,function(e){he(e,"htmx:restored",{document:ne(),triggerEvent:he})})}else{if(n){n(e)}}};E().setTimeout(function(){he(e,"htmx:load",{});e=null},0)});return Q}(); \ No newline at end of file diff --git a/src/webui/templates/fragments/configure-toolbar.html b/src/webui/templates/fragments/configure-toolbar.html new file mode 100644 index 00000000..244e901b --- /dev/null +++ b/src/webui/templates/fragments/configure-toolbar.html @@ -0,0 +1,38 @@ +{{define "configure-toolbar"}} +<div class="configure-toolbar"> + <div class="configure-toolbar-inner"> + <button type="button" class="btn btn-primary cfg-apply-btn" + title="Copy candidate to running config, activating staged changes. Unsaved changes will be lost on reboot." + hx-post="/configure/apply" + hx-swap="none" + hx-confirm="Apply staged changes to running configuration? Changes will be lost on reboot unless saved."> + Apply + </button> + <button type="button" class="btn btn-accept cfg-apply-save-btn" + title="Apply staged changes and immediately persist to startup config. Safe across reboots." + hx-post="/configure/apply-and-save" + hx-swap="none" + hx-confirm="Apply staged changes and save to startup configuration?"> + Apply & Save + </button> + <button type="button" class="btn btn-danger-outline cfg-abort-btn" + title="Discard all staged changes and reset candidate to match running config." + hx-post="/configure/abort" + hx-swap="none" + hx-confirm="Discard all staged changes?"> + Abort + </button> + <div class="cfg-log-wrap"> + <button type="button" id="cfg-log-btn" class="btn btn-outline btn-sm cfg-log-btn" + title="Show activity log"> + Log + <span id="cfg-log-badge" class="cfg-log-badge" hidden>0</span> + </button> + <div id="cfg-log-panel" class="cfg-log-panel" hidden> + <div class="cfg-log-header">Activity log <button type="button" class="cfg-log-close">✕</button></div> + <ul class="cfg-log-list"></ul> + </div> + </div> + </div> +</div> +{{end}} diff --git a/src/webui/templates/fragments/iface-counters.html b/src/webui/templates/fragments/iface-counters.html new file mode 100644 index 00000000..e995bb01 --- /dev/null +++ b/src/webui/templates/fragments/iface-counters.html @@ -0,0 +1,45 @@ +{{define "iface-counters"}} +<div id="iface-counters" hx-get="/interfaces/{{.Name}}/counters" hx-trigger="every 5s" hx-swap="outerHTML"> + <table class="counters-table"> + <thead> + <tr> + <th>Counter</th> + <th>RX</th> + <th>TX</th> + </tr> + </thead> + <tbody> + <tr> + <td>Bytes</td> + <td class="num">{{.Counters.RxBytes}}</td> + <td class="num">{{.Counters.TxBytes}}</td> + </tr> + <tr> + <td>Unicast</td> + <td class="num">{{.Counters.RxUnicast}}</td> + <td class="num">{{.Counters.TxUnicast}}</td> + </tr> + <tr> + <td>Broadcast</td> + <td class="num">{{.Counters.RxBroadcast}}</td> + <td class="num">{{.Counters.TxBroadcast}}</td> + </tr> + <tr> + <td>Multicast</td> + <td class="num">{{.Counters.RxMulticast}}</td> + <td class="num">{{.Counters.TxMulticast}}</td> + </tr> + <tr> + <td>Discards</td> + <td class="num">{{.Counters.RxDiscards}}</td> + <td class="num">{{.Counters.TxDiscards}}</td> + </tr> + <tr> + <td>Errors</td> + <td class="num">{{.Counters.RxErrors}}</td> + <td class="num">{{.Counters.TxErrors}}</td> + </tr> + </tbody> + </table> +</div> +{{end}} diff --git a/src/webui/templates/fragments/topbar-identity.html b/src/webui/templates/fragments/topbar-identity.html new file mode 100644 index 00000000..0a6bcf94 --- /dev/null +++ b/src/webui/templates/fragments/topbar-identity.html @@ -0,0 +1,17 @@ +{{define "topbar-identity"}} +{{if .Hostname}} +<div class="topbar-identity"> + <a class="topbar-identity-name" href="/configure/system" + hx-get="/configure/system" hx-target="#content" hx-push-url="true" + title="System identity"> + {{.Hostname}}{{if or .Location .Contact}}<span class="topbar-identity-caret" aria-hidden="true">▾</span>{{end}} + </a> + {{if or .Location .Contact}} + <div class="topbar-identity-pop" role="tooltip"> + {{if .Location}}<div class="ti-row"><span class="ti-label">Location</span><span>{{.Location}}</span></div>{{end}} + {{if .Contact}}<div class="ti-row"><span class="ti-label">Contact</span><span>{{.Contact}}</span></div>{{end}} + </div> + {{end}} +</div> +{{end}} +{{end}} diff --git a/src/webui/templates/fragments/wizard-psk-picker.html b/src/webui/templates/fragments/wizard-psk-picker.html new file mode 100644 index 00000000..6a71b61d --- /dev/null +++ b/src/webui/templates/fragments/wizard-psk-picker.html @@ -0,0 +1,10 @@ +{{define "wizard-psk-picker.html"}} +{{$id := "add-iface-wifi-psk"}}{{if .ID}}{{$id = .ID}}{{end}} +<select class="cfg-input" name="secret" id="{{$id}}"> + {{if .Keys}} + {{range .Keys}}<option value="{{.Name}}" data-key-value="{{.Value}}"{{if eq .Name $.Selected}} selected{{end}}>{{.Name}}</option>{{end}} + {{else}} + <option value="" disabled selected>No keystore symmetric keys</option> + {{end}} +</select> +{{end}} diff --git a/src/webui/templates/fragments/wizard-radio-picker.html b/src/webui/templates/fragments/wizard-radio-picker.html new file mode 100644 index 00000000..dd4e9818 --- /dev/null +++ b/src/webui/templates/fragments/wizard-radio-picker.html @@ -0,0 +1,14 @@ +{{define "wizard-radio-picker.html"}} +{{$id := "add-iface-wifi-radio"}}{{if .ID}}{{$id = .ID}}{{end}} +<select class="cfg-input" name="radio" id="{{$id}}" required> + {{if .Radios}} + {{range $i, $r := .Radios}}<option value="{{$r.Name}}" + data-ap-ready="{{$r.APReady}}" + data-country="{{$r.Country}}" + data-band="{{$r.Band}}" + data-channel="{{$r.Channel}}"{{if eq $r.Name $.Selected}} selected{{else if and (eq $.Selected "") (eq $i 0)}} selected{{end}}>{{$r.Label}}</option>{{end}} + {{else}} + <option value="" disabled selected>No configured WiFi radios</option> + {{end}} +</select> +{{end}} diff --git a/src/webui/templates/fragments/wizard-wgkey-picker.html b/src/webui/templates/fragments/wizard-wgkey-picker.html new file mode 100644 index 00000000..9a7d9d1a --- /dev/null +++ b/src/webui/templates/fragments/wizard-wgkey-picker.html @@ -0,0 +1,9 @@ +{{define "wizard-wgkey-picker.html"}} +<select class="cfg-input" name="private-key" id="add-iface-wg-key" required> + {{if .Keys}} + {{range .Keys}}<option value="{{.}}"{{if eq . $.Selected}} selected{{end}}>{{.}}</option>{{end}} + {{else}} + <option value="" disabled selected>No keystore keys</option> + {{end}} +</select> +{{end}} diff --git a/src/webui/templates/fragments/yang-leaf-group.html b/src/webui/templates/fragments/yang-leaf-group.html new file mode 100644 index 00000000..0bb3556b --- /dev/null +++ b/src/webui/templates/fragments/yang-leaf-group.html @@ -0,0 +1,397 @@ +{{/* Renders a container or list-instance as a level page. + Direct leaves appear as an inline editable form (or read-only in ReadOnly mode). + Structural children appear as navigation items below. + Data: *leafGroupData */}} +{{define "yang-leaf-group"}} +{{$ro := .ReadOnly}} +{{$nodeBase := "/configure/tree"}}{{if $ro}}{{$nodeBase = "/status/tree"}}{{end}} +<div class="info-card"> + <div class="card-header"> + {{if .ParentPath}} + <button type="button" class="btn btn-outline btn-sm yt-back-btn" + data-yang-path="{{.ParentPath}}" + hx-get="{{$nodeBase}}/node?path={{.ParentPath | urlquery}}" + hx-target="#yang-detail" + hx-swap="innerHTML">← Back</button> + {{end}} + {{.Name}} + {{if and .Presence (not $ro)}} + <div class="yt-presence-header"> + {{if .Exists}} + <button class="btn btn-sm yt-presence-toggle yt-presence-on" + hx-delete="/configure/tree/presence?path={{.Path | urlquery}}" + hx-target="#yang-detail" + hx-swap="innerHTML" + title="{{.Presence}} — click to disable">● Enabled</button> + {{else}} + <button class="btn btn-sm yt-presence-toggle yt-presence-off" + hx-put="/configure/tree/presence?path={{.Path | urlquery}}" + hx-target="#yang-detail" + hx-swap="innerHTML" + title="{{.Presence}} — click to enable">○ Disabled</button> + {{end}} + </div> + {{else if and .Presence $ro}} + <div class="yt-presence-header"> + <span class="yt-presence-toggle {{if .Exists}}yt-presence-on{{else}}yt-presence-off{{end}}"> + {{if .Exists}}● Enabled{{else}}○ Disabled{{end}} + </span> + </div> + {{end}} + {{/* Non-presence container with data: offer "Delete" so the user + can wipe out the whole subtree as a unit. Required for the + NPC + mandatory-leaf idiom (bridge-port / lag-port — the + mandatory leaf can't be cleared individually), and useful in + general for IPv4/IPv6/DHCP-style blocks. */}} + {{if and .CanDelete (not $ro)}} + <div class="yt-presence-header"> + <button class="btn btn-sm btn-outline" + hx-delete="/configure/tree/container?path={{.Path | urlquery}}&parent={{.ParentPath | urlquery}}" + hx-target="#yang-detail" + hx-swap="innerHTML" + hx-confirm="Delete {{.Name}}? This removes the whole subtree.">Delete</button> + </div> + {{end}} + </div> + + {{if .Error}} + <div class="alert alert-error">{{.Error}}</div> + {{end}} + + {{/* ── Direct leaves ────────────────────────────────────────────── */}} + {{if .Leaves}} + {{if not $ro}} + <form class="yt-group-form" + hx-put="/configure/tree/group?path={{.Path | urlquery}}{{if .ParentPath}}&parent={{.ParentPath | urlquery}}{{end}}" + hx-swap="none"> + {{end}} + <div class="yt-group-list"> + {{range .Leaves}} + {{$cv := .CurrentValue}} + {{$default := .Default}} + {{$hasHelp := or .Description .Default .Type}} + <details class="yt-leaf-item"> + <summary class="yt-leaf-summary"> + <span class="yt-leaf-name{{if not $hasHelp}} yt-leaf-no-expand{{end}}"> + {{.Name}}{{if .Mandatory}}<span class="yt-mandatory" title="mandatory">*</span>{{end}} + </span> + <span class="yt-leaf-input yt-sp"> + {{if or $ro (not .Config)}} + {{if and .IsBinary .HasBinary}} + <div class="yt-binary-wrap"> + <div class="yt-binary-row"> + <span class="yt-binary-hint">binary data</span> + <button type="button" class="btn-icon yt-binary-eye" title="Show/hide"> + {{template "icon-eye"}} + </button> + </div> + <code class="yt-binary-content yt-binary-pem">{{if $cv}}{{$cv}}{{else}}{{.RawBase64}}{{end}}</code> + </div> + {{else}} + <span class="{{if not .Config}}text-muted{{end}}">{{if $cv}}{{$cv}}{{else}}—{{end}}</span> + {{end}} + {{else if and .Type (eq .Type.Kind "boolean")}} + <div class="yt-bool-group"> + <label><input type="radio" name="{{.Name}}" value="true" + {{if eq $cv "true"}}checked{{end}}> true</label> + <label><input type="radio" name="{{.Name}}" value="false" + {{if eq $cv "false"}}checked{{end}}> false</label> + {{if and (eq $cv "") (not .UsingDefault)}}<span class="yt-unset-hint">(not configured)</span>{{end}} + </div> + {{else if and .Type (eq .Type.Kind "enumeration") .Type.Enums}} + <select class="cfg-input" name="{{.Name}}"> + {{range .Type.Enums}}<option value="{{.}}" {{if or (eq . $cv) (and (eq $cv "") (eq . $default))}}selected{{end}}>{{.}}{{if and $default (eq . $default)}} (default){{end}}</option>{{end}} + </select> + {{else if and .Type (eq .Type.Kind "identityref") .Type.Identities}} + <select class="cfg-input" name="{{.Name}}"> + {{range .Type.Identities}}<option value="{{.}}" {{if or (eq . $cv) (and (eq $cv "") (eq . $default))}}selected{{end}}>{{stripPrefix .}}{{if and $default (eq . $default)}} (default){{end}}</option>{{end}} + </select> + {{else if and .Type (or (eq .Type.Kind "int8") (eq .Type.Kind "int16") + (eq .Type.Kind "int32") (eq .Type.Kind "int64") + (eq .Type.Kind "uint8") (eq .Type.Kind "uint16") + (eq .Type.Kind "uint32") (eq .Type.Kind "uint64"))}} + <input class="cfg-input" type="number" name="{{.Name}}" value="{{$cv}}" + {{if and .Type .Type.Range}}title="Range: {{.Type.Range}}"{{end}}> + {{else if and .Type (eq .Type.Kind "leafref")}} + {{/* Always a select for leafref leaves, even when + LeafrefValues is empty (e.g. no LAGs configured yet + — the field is still semantically a picker). */}} + <select class="cfg-input" name="{{.Name}}"> + <option value="">(none)</option> + {{range .LeafrefValues}}<option value="{{.}}" {{if eq . $cv}}selected{{end}}>{{.}}{{if and $default (eq . $default)}} (default){{end}}</option>{{end}} + </select> + {{else if and .Type (eq .Type.Kind "binary")}} + <textarea class="cfg-input cfg-input-mono" name="{{.Name}}" + rows="6">{{if .RawBase64}}{{.RawBase64}}{{else}}{{$cv}}{{end}}</textarea> + {{else}} + <input class="cfg-input" type="text" name="{{.Name}}" + value="{{if not .UsingDefault}}{{$cv}}{{end}}" + {{if and .UsingDefault .Default}}placeholder="{{.Default}}"{{end}}> + {{end}} + </span> + {{if and (not $ro) .Config (not .Mandatory)}} + <button class="btn btn-secondary btn-sm yt-sp" type="button" + title="Reset to YANG default" + hx-delete="/configure/tree/node?path={{.Path | urlquery}}" + hx-target="#yang-detail" + hx-swap="innerHTML" + hx-confirm="Reset {{.Name}} to its YANG default?">{{template "icon-reset"}}</button> + {{end}} + </summary> + {{if $hasHelp}} + <div class="yt-leaf-help"> + {{if .Description}}<p class="yt-description">{{.Description}}</p>{{end}} + <table class="info-table yt-schema-table"> + {{if .Default}}<tr><th>Default</th><td><code>{{.Default}}</code></td></tr>{{end}} + {{if .Mandatory}}<tr><th>Mandatory</th><td>yes</td></tr>{{end}} + {{if .Type}}<tr><th>Type</th><td><code>{{.Type.Kind}}</code></td></tr>{{end}} + {{if and .Type .Type.Range}}<tr><th>Range</th><td><code>{{.Type.Range}}</code></td></tr>{{end}} + {{if and .Type .Type.Pattern}}<tr><th>Pattern</th><td><code>{{.Type.Pattern}}</code></td></tr>{{end}} + {{if and .Type .Type.Leafref}}<tr><th>Leafref</th><td><code class="yt-path">{{.Type.Leafref}}</code></td></tr>{{end}} + <tr><th>Path</th><td><code class="yt-path">{{.Path}}</code></td></tr> + </table> + </div> + {{end}} + </details> + {{end}} + </div> + {{if not $ro}} + <div class="cfg-card-footer"> + <button class="btn btn-primary" type="submit">Save</button> + <span class="cfg-save-status"></span> + </div> + </form> + {{end}} + {{end}} + + {{/* ── Inline simple sub-lists ────────────────────────────────── */}} + {{range $i, $list := .InlineLists}} + <div class="yt-inline-list"> + <div class="yt-subsections-header">{{$list.Name}}</div> + <div class="data-table-wrap"> + <table class="data-table cfg-table"> + <thead> + <tr> + {{if $list.Complex}}<th class="yt-nav-col"></th>{{end}} + {{range $list.Columns}}<th>{{.DisplayName}}</th>{{end}} + {{if not $list.ReadOnly}}<th></th>{{end}} + </tr> + </thead> + <tbody> + {{range $list.Rows}} + {{$row := .}} + <tr class="yt-list-row{{if $list.Complex}} yt-list-row-nav{{end}}" + data-yang-path="{{$row.InstancePath}}" + hx-get="{{$nodeBase}}/node?path={{$row.InstancePath | urlquery}}{{if $list.ParentPath}}&parent={{$list.ParentPath | urlquery}}{{end}}" + hx-target="#yang-detail" + hx-swap="innerHTML"> + {{if $list.Complex}} + <td class="yt-nav-col"> + {{template "icon-yt-row-arrow"}} + </td> + {{end}} + {{range $list.Columns}}<td>{{index $row.Values .Name}}</td>{{end}} + {{if not $list.ReadOnly}} + <td class="cfg-action-col"> + <button type="button" class="btn-icon btn-icon-danger yt-sp" + title="Delete {{$row.InstanceName}}" + hx-delete="/configure/tree/list-row?path={{$row.InstancePath | urlquery}}&parent={{$list.ParentPath | urlquery}}" + hx-target="closest tr" + hx-swap="delete" + hx-confirm="Delete {{$row.InstanceName}}?"> + {{template "icon-trash"}} + </button> + </td> + {{end}} + </tr> + {{end}} + {{if not $list.Rows}} + <tr><td class="yt-table-empty" colspan="99">No entries</td></tr> + {{end}} + {{if and (not $list.Complex) (not $list.ReadOnly)}} + {{/* Hidden inline add row — simple lists only */}} + <tr id="yt-add-row-{{$i}}" hidden> + <td colspan="99" class="key-detail-cell"> + <form class="user-add-inline" + hx-post="/configure/tree/list-row?path={{$list.Path | urlquery}}&parent={{$list.ParentPath | urlquery}}" + hx-target="#yang-detail" + hx-swap="innerHTML"> + {{range $list.FormColumns}} + {{if and .Type (eq .Type.Kind "enumeration") .Type.Enums}} + <select class="cfg-input" name="{{.Name}}" title="{{.Name}}"> + {{range .Type.Enums}}<option>{{.}}</option>{{end}} + </select> + {{else if and .Type (eq .Type.Kind "identityref") .Type.Identities}} + <select class="cfg-input" name="{{.Name}}" title="{{.Name}}"> + {{range .Type.Identities}}<option value="{{.}}">{{stripPrefix .}}</option>{{end}} + </select> + {{else if and .Type (or (eq .Type.Kind "int8") (eq .Type.Kind "int16") + (eq .Type.Kind "int32") (eq .Type.Kind "int64") + (eq .Type.Kind "uint8") (eq .Type.Kind "uint16") + (eq .Type.Kind "uint32") (eq .Type.Kind "uint64"))}} + <input class="cfg-input" type="number" name="{{.Name}}" + placeholder="{{.Name}}" + {{if and .Type .Type.Range}}title="Range: {{.Type.Range}}"{{end}} + {{if .Mandatory}}required{{end}}> + {{else if and .Type (eq .Type.Kind "binary")}} + <textarea class="cfg-input cfg-input-mono" name="{{.Name}}" + rows="1" placeholder="{{.Name}} (base64)" + title="{{.Name}}" + {{if .Mandatory}}required{{end}}></textarea> + {{else}} + <input class="cfg-input" type="text" name="{{.Name}}" + placeholder="{{.Name}}" + {{if .Mandatory}}required{{end}}> + {{end}} + {{end}} + <button class="btn btn-primary btn-sm" type="submit">Add</button> + <button type="button" class="btn btn-sm" data-hide="yt-add-row-{{$i}}">Cancel</button> + </form> + </td> + </tr> + {{end}} + </tbody> + </table> + </div> + {{if not $list.ReadOnly}} + <div style="padding: 0.35rem 0.75rem 0.5rem"> + {{if $list.Complex}} + <button class="btn btn-secondary btn-sm" + hx-get="/configure/tree/list-add?path={{$list.Path | urlquery}}&parent={{$list.ParentPath | urlquery}}" + hx-target="#yang-detail" + hx-swap="innerHTML">+ Add</button> + {{else}} + <button class="btn btn-secondary btn-sm" data-show="yt-add-row-{{$i}}">+ Add</button> + {{end}} + </div> + {{end}} + </div> + {{end}} + + {{/* ── Inline flat sub-containers ────────────────────────────── */}} + {{range .InlineContainers}} + {{$sub := .}} + <div class="yt-inline-container"> + <div class="yt-subsections-header">{{$sub.Name}}</div> + {{if $sub.Leaves}} + {{if not $sub.ReadOnly}} + <form class="yt-group-form" + hx-put="/configure/tree/group?path={{$sub.Path | urlquery}}&parent={{$sub.ParentPath | urlquery}}" + hx-swap="none"> + {{end}} + <div class="yt-group-list"> + {{range $sub.Leaves}} + {{$cv := .CurrentValue}} + {{$default := .Default}} + {{$hasHelp := or .Description .Default .Type}} + <details class="yt-leaf-item"> + <summary class="yt-leaf-summary"> + <span class="yt-leaf-name{{if not $hasHelp}} yt-leaf-no-expand{{end}}"> + {{.Name}}{{if .Mandatory}}<span class="yt-mandatory" title="mandatory">*</span>{{end}} + </span> + <span class="yt-leaf-input yt-sp"> + {{if or $sub.ReadOnly (not .Config)}} + {{if and .IsBinary .HasBinary}} + <div class="yt-binary-wrap"> + <div class="yt-binary-row"> + <span class="yt-binary-hint">binary data</span> + <button type="button" class="btn-icon yt-binary-eye" title="Show/hide"> + {{template "icon-eye"}} + </button> + </div> + <code class="yt-binary-content yt-binary-pem">{{if $cv}}{{$cv}}{{else}}{{.RawBase64}}{{end}}</code> + </div> + {{else}} + <span class="{{if not .Config}}text-muted{{end}}">{{if $cv}}{{$cv}}{{else}}—{{end}}</span> + {{end}} + {{else if and .Type (eq .Type.Kind "boolean")}} + <div class="yt-bool-group"> + <label><input type="radio" name="{{.Name}}" value="true" + {{if eq $cv "true"}}checked{{end}}> true</label> + <label><input type="radio" name="{{.Name}}" value="false" + {{if eq $cv "false"}}checked{{end}}> false</label> + {{if and (eq $cv "") (not .UsingDefault)}}<span class="yt-unset-hint">(not configured)</span>{{end}} + </div> + {{else if and .Type (eq .Type.Kind "enumeration") .Type.Enums}} + <select class="cfg-input" name="{{.Name}}"> + {{range .Type.Enums}}<option value="{{.}}" {{if or (eq . $cv) (and (eq $cv "") (eq . $default))}}selected{{end}}>{{.}}{{if and $default (eq . $default)}} (default){{end}}</option>{{end}} + </select> + {{else if and .Type (eq .Type.Kind "identityref") .Type.Identities}} + <select class="cfg-input" name="{{.Name}}"> + {{range .Type.Identities}}<option value="{{.}}" {{if or (eq . $cv) (and (eq $cv "") (eq . $default))}}selected{{end}}>{{stripPrefix .}}{{if and $default (eq . $default)}} (default){{end}}</option>{{end}} + </select> + {{else if and .Type (or (eq .Type.Kind "int8") (eq .Type.Kind "int16") + (eq .Type.Kind "int32") (eq .Type.Kind "int64") + (eq .Type.Kind "uint8") (eq .Type.Kind "uint16") + (eq .Type.Kind "uint32") (eq .Type.Kind "uint64"))}} + <input class="cfg-input" type="number" name="{{.Name}}" value="{{$cv}}" + {{if and .Type .Type.Range}}title="Range: {{.Type.Range}}"{{end}}> + {{else if and .Type (eq .Type.Kind "leafref")}} + <select class="cfg-input" name="{{.Name}}"> + <option value="">(none)</option> + {{range .LeafrefValues}}<option value="{{.}}" {{if eq . $cv}}selected{{end}}>{{.}}{{if and $default (eq . $default)}} (default){{end}}</option>{{end}} + </select> + {{else if and .Type (eq .Type.Kind "binary")}} + <textarea class="cfg-input cfg-input-mono" name="{{.Name}}" + rows="6">{{if .RawBase64}}{{.RawBase64}}{{else}}{{$cv}}{{end}}</textarea> + {{else}} + <input class="cfg-input" type="text" name="{{.Name}}" + value="{{if not .UsingDefault}}{{$cv}}{{end}}" + {{if and .UsingDefault .Default}}placeholder="{{.Default}}"{{end}}> + {{end}} + </span> + {{if and (not $sub.ReadOnly) .Config (not .Mandatory)}} + <button class="btn btn-secondary btn-sm yt-sp" type="button" + title="Reset to YANG default" + hx-delete="/configure/tree/node?path={{.Path | urlquery}}" + hx-target="#yang-detail" + hx-swap="innerHTML" + hx-confirm="Reset {{.Name}} to its YANG default?">{{template "icon-reset"}}</button> + {{end}} + </summary> + {{if $hasHelp}} + <div class="yt-leaf-help"> + {{if .Description}}<p class="yt-description">{{.Description}}</p>{{end}} + <table class="info-table yt-schema-table"> + {{if .Default}}<tr><th>Default</th><td><code>{{.Default}}</code></td></tr>{{end}} + {{if .Mandatory}}<tr><th>Mandatory</th><td>yes</td></tr>{{end}} + {{if .Type}}<tr><th>Type</th><td><code>{{.Type.Kind}}</code></td></tr>{{end}} + {{if and .Type .Type.Range}}<tr><th>Range</th><td><code>{{.Type.Range}}</code></td></tr>{{end}} + {{if and .Type .Type.Pattern}}<tr><th>Pattern</th><td><code>{{.Type.Pattern}}</code></td></tr>{{end}} + {{if and .Type .Type.Leafref}}<tr><th>Leafref</th><td><code class="yt-path">{{.Type.Leafref}}</code></td></tr>{{end}} + <tr><th>Path</th><td><code class="yt-path">{{.Path}}</code></td></tr> + </table> + </div> + {{end}} + </details> + {{end}} + </div> + {{if not $sub.ReadOnly}} + <div class="cfg-card-footer"> + <button class="btn btn-primary" type="submit">Save</button> + <span class="cfg-save-status"></span> + </div> + </form> + {{end}} + {{end}} + </div> + {{end}} + + {{/* ── Sub-sections ─────────────────────────────────────────────── */}} + {{if .SubNodes}} + <div class="yt-subsections"> + {{if .Leaves}}<div class="yt-subsections-header">Sub-sections</div>{{end}} + {{range .SubNodes}} + <button class="yt-subsection-item" + data-yang-path="{{.Path}}" + hx-get="{{$nodeBase}}/node?path={{.Path | urlquery}}" + hx-target="#yang-detail" + hx-swap="innerHTML"> + <span class="yt-subsection-name">{{.Name}}</span> + {{template "icon-yt-subsection-arrow"}} + </button> + {{end}} + </div> + {{end}} +</div> +{{end}} diff --git a/src/webui/templates/fragments/yang-list-table.html b/src/webui/templates/fragments/yang-list-table.html new file mode 100644 index 00000000..70af8a0d --- /dev/null +++ b/src/webui/templates/fragments/yang-list-table.html @@ -0,0 +1,173 @@ +{{/* Auto-generated table for a simple (all-leaf) YANG list node. + Data: *listTableData */}} +{{define "yang-list-table"}} +<div class="info-card"> + <div class="card-header">{{.Name}}</div> + {{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}} + {{if .SavedOK}}<div class="alert alert-success">Saved to candidate</div>{{end}} + <div class="data-table-wrap"> + <table class="data-table"> + {{$ro := .ReadOnly}} + {{$nodeBase := "/configure/tree"}}{{if $ro}}{{$nodeBase = "/status/tree"}}{{end}} + <thead> + <tr> + {{range .Columns}}<th>{{.DisplayName}}</th>{{end}} + {{if not $ro}}<th></th>{{end}} + </tr> + </thead> + <tbody> + {{range .Rows}} + {{$row := .}} + <tr class="yt-list-row" + data-yang-path="{{.InstancePath}}" + hx-get="{{$nodeBase}}/node?path={{.InstancePath | urlquery}}" + hx-target="#yang-detail" + hx-swap="innerHTML"> + {{range $.Columns}}<td>{{index $row.Values .Name}}</td>{{end}} + {{if not $ro}} + <td class="cfg-action-col"> + <button type="button" + class="btn-icon btn-icon-danger yt-sp" + title="Delete {{$row.InstanceName}}" + hx-delete="/configure/tree/list-row?path={{$row.InstancePath | urlquery}}" + hx-target="#yang-detail" + hx-swap="innerHTML" + hx-confirm="Delete {{$row.InstanceName}}?"> + {{template "icon-trash"}} + </button> + </td> + {{end}} + </tr> + {{end}} + {{if not .Rows}} + <tr><td class="yt-table-empty" colspan="99">No entries</td></tr> + {{end}} + {{/* Hidden inline add row — config mode only */}} + {{if not $ro}} + <tr id="yt-list-add-row" hidden> + <td colspan="99" class="key-detail-cell"> + <form class="user-add-inline" + hx-post="/configure/tree/list-row?path={{.Path | urlquery}}" + hx-target="#yang-detail" + hx-swap="innerHTML"> + {{range .FormColumns}} + {{if and .Type (eq .Type.Kind "enumeration") .Type.Enums}} + <select class="cfg-input" name="{{.Name}}" title="{{.Name}}"> + {{range .Type.Enums}}<option>{{.}}</option>{{end}} + </select> + {{else if and .Type (eq .Type.Kind "identityref") .Type.Identities}} + <select class="cfg-input" name="{{.Name}}" title="{{.Name}}"> + {{range .Type.Identities}}<option value="{{.}}">{{stripPrefix .}}</option>{{end}} + </select> + {{else if and .Type (eq .Type.Kind "boolean")}} + <select class="cfg-input" name="{{.Name}}" title="{{.Name}}"> + <option value="false">false</option> + <option value="true">true</option> + </select> + {{else if and .Type (or (eq .Type.Kind "int8") (eq .Type.Kind "int16") + (eq .Type.Kind "int32") (eq .Type.Kind "int64") + (eq .Type.Kind "uint8") (eq .Type.Kind "uint16") + (eq .Type.Kind "uint32") (eq .Type.Kind "uint64"))}} + <input class="cfg-input" type="number" name="{{.Name}}" + placeholder="{{.Name}}" + {{if and .Type .Type.Range}}title="Range: {{.Type.Range}}"{{end}} + {{if .Mandatory}}required{{end}}> + {{else if and .Type (eq .Type.Kind "binary")}} + <textarea class="cfg-input cfg-input-mono" name="{{.Name}}" + rows="1" placeholder="{{.Name}} (base64)" + title="{{.Name}}" + {{if .Mandatory}}required{{end}}></textarea> + {{else}} + <input class="cfg-input" type="text" name="{{.Name}}" + placeholder="{{.Name}}" + {{if .Mandatory}}required{{end}}> + {{end}} + {{end}} + <button class="btn btn-primary btn-sm" type="submit">Add</button> + <button type="button" class="btn btn-sm" data-hide="yt-list-add-row">Cancel</button> + </form> + </td> + </tr> + {{end}} + </tbody> + </table> + </div> + {{if not $ro}} + <div class="cfg-card-footer"> + <button class="btn btn-secondary" data-show="yt-list-add-row">+ Add</button> + </div> + {{end}} +</div> +{{end}} + +{{/* Blank add-row form for a simple list. + Data: *listAddData */}} +{{define "yang-list-add"}} +<div class="info-card"> + <div class="card-header">Add {{.Name}}</div> + {{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}} + <form hx-post="/configure/tree/list-row?path={{.Path | urlquery}}{{if .ParentPath}}&parent={{.ParentPath | urlquery}}{{end}}" + hx-target="#yang-detail" + hx-swap="innerHTML"> + <div class="yt-group-list"> + {{range .Columns}} + {{$hasHelp := or .Description .Default .Type}} + <details class="yt-leaf-item"> + <summary class="yt-leaf-summary"> + <span class="yt-leaf-name{{if not $hasHelp}} yt-leaf-no-expand{{end}}"> + {{.Name}}{{if .Mandatory}}<span class="yt-mandatory" title="mandatory">*</span>{{end}} + </span> + <span class="yt-leaf-input yt-sp"> + {{if and .Type (eq .Type.Kind "enumeration") .Type.Enums}} + <select class="cfg-input" name="{{.Name}}"> + {{range .Type.Enums}}<option>{{.}}</option>{{end}} + </select> + {{else if and .Type (eq .Type.Kind "identityref") .Type.Identities}} + <select class="cfg-input" name="{{.Name}}"> + {{range .Type.Identities}}<option value="{{.}}">{{stripPrefix .}}</option>{{end}} + </select> + {{else if and .Type (eq .Type.Kind "boolean")}} + <select class="cfg-input" name="{{.Name}}"> + <option value="false">false</option> + <option value="true">true</option> + </select> + {{else if and .Type (or (eq .Type.Kind "int8") (eq .Type.Kind "int16") + (eq .Type.Kind "int32") (eq .Type.Kind "int64") + (eq .Type.Kind "uint8") (eq .Type.Kind "uint16") + (eq .Type.Kind "uint32") (eq .Type.Kind "uint64"))}} + <input class="cfg-input" type="number" name="{{.Name}}" + {{if and .Type .Type.Range}}title="Range: {{.Type.Range}}"{{end}} + {{if .Mandatory}}required{{end}}> + {{else}} + <input class="cfg-input" type="text" name="{{.Name}}" + {{if .Default}}placeholder="{{.Default}}"{{end}} + {{if .Mandatory}}required{{end}}> + {{end}} + </span> + </summary> + {{if $hasHelp}} + <div class="yt-leaf-help"> + {{if .Description}}<p class="yt-description">{{.Description}}</p>{{end}} + <table class="info-table yt-schema-table"> + {{if .Default}}<tr><th>Default</th><td><code>{{.Default}}</code></td></tr>{{end}} + {{if .Mandatory}}<tr><th>Mandatory</th><td>yes</td></tr>{{end}} + {{if .Type}}<tr><th>Type</th><td><code>{{.Type.Kind}}</code></td></tr>{{end}} + {{if and .Type .Type.Range}}<tr><th>Range</th><td><code>{{.Type.Range}}</code></td></tr>{{end}} + {{if and .Type .Type.Pattern}}<tr><th>Pattern</th><td><code>{{.Type.Pattern}}</code></td></tr>{{end}} + <tr><th>Path</th><td><code class="yt-path">{{.Path}}</code></td></tr> + </table> + </div> + {{end}} + </details> + {{end}} + </div> + <div class="cfg-card-footer"> + <button class="btn btn-primary" type="submit">Add</button> + <button type="button" class="btn btn-outline" + hx-get="/configure/tree/node?path={{.Path | urlquery}}" + hx-target="#yang-detail" + hx-swap="innerHTML">Cancel</button> + </div> + </form> +</div> +{{end}} diff --git a/src/webui/templates/fragments/yang-node-detail.html b/src/webui/templates/fragments/yang-node-detail.html new file mode 100644 index 00000000..3238f917 --- /dev/null +++ b/src/webui/templates/fragments/yang-node-detail.html @@ -0,0 +1,140 @@ +{{/* Renders a *nodeDetailData as the right-pane detail panel. */}} +{{define "yang-node-detail"}} +<div class="info-card"> + <div class="card-header" title="{{.Path}}">{{.Name}}</div> + + {{if .Error}} + <div class="alert alert-error">{{.Error}}</div> + {{end}} + {{if .SavedOK}} + <div class="alert alert-success">Saved to candidate</div> + {{end}} + + {{/* ── Read-only value display for status tree ──────────────────── */}} + {{if and .ReadOnly (or (eq .Kind "leaf") (eq .Kind "leaf-list"))}} + <div class="cfg-card-footer"> + <span class="yt-value-label"> + {{if .CurrentValue}}{{.CurrentValue}}{{else}}<span class="text-muted">—</span>{{end}} + </span> + </div> + {{end}} + + {{/* ── Edit form — only for writable leaves in non-ReadOnly mode ── */}} + {{if and (not .ReadOnly) .Config (or (eq .Kind "leaf") (eq .Kind "leaf-list"))}} + {{/* hx-swap="none": SaveLeaf returns 204 + a cfgSaved trigger, and the + cfgSaved handler re-fetches the whole current tree page from the + fresh candidate so confd-inferred values surface. */}} + <form class="yt-edit-form" + hx-put="/configure/tree/node?path={{.Path | urlquery}}" + hx-swap="none"> + <div class="cfg-card-footer"> + <label class="yt-value-label"> + {{if .UsingDefault -}} + <span class="text-muted yt-default-hint" + title="No explicit value set — showing YANG schema default"> + Value <em>(default)</em> + </span> + {{- else -}} + Value + {{- end}} + {{if and .Type (eq .Type.Kind "boolean")}} + <div class="yt-bool-group"> + <label><input type="radio" name="value" value="true" + {{if eq .CurrentValue "true"}}checked{{end}}> true</label> + <label><input type="radio" name="value" value="false" + {{if eq .CurrentValue "false"}}checked{{end}}> false</label> + {{if and (eq .CurrentValue "") (not .UsingDefault) -}} + <span class="yt-unset-hint">(not configured)</span> + {{- end}} + </div> + {{else if and .Type (eq .Type.Kind "enumeration") .Type.Enums}} + {{$cv := .CurrentValue}}{{$def := .Default}} + <select class="cfg-input" name="value"> + {{range .Type.Enums}} + <option value="{{.}}" {{if or (eq . $cv) (and (eq $cv "") (eq . $def))}}selected{{end}}>{{.}}{{if and $def (eq . $def)}} (default){{end}}</option> + {{end}} + </select> + {{else if and .Type (eq .Type.Kind "identityref") .Type.Identities}} + {{$cv := .CurrentValue}}{{$def := .Default}} + <select class="cfg-input" name="value"> + {{range .Type.Identities}} + <option value="{{.}}" {{if or (eq . $cv) (and (eq $cv "") (eq . $def))}}selected{{end}}>{{stripPrefix .}}{{if and $def (eq . $def)}} (default){{end}}</option> + {{end}} + </select> + {{else if and .Type (or (eq .Type.Kind "int8") (eq .Type.Kind "int16") + (eq .Type.Kind "int32") (eq .Type.Kind "int64") + (eq .Type.Kind "uint8") (eq .Type.Kind "uint16") + (eq .Type.Kind "uint32") (eq .Type.Kind "uint64"))}} + <input class="cfg-input" type="number" name="value" value="{{.CurrentValue}}" + {{if and .Type .Type.Range}}title="Range: {{.Type.Range}}"{{end}}> + {{else if and .Type (eq .Type.Kind "leafref")}} + {{$cv := .CurrentValue}} + <select class="cfg-input" name="value"> + <option value="">(none)</option> + {{range .LeafrefValues}} + <option {{if eq . $cv}}selected{{end}}>{{.}}</option> + {{end}} + </select> + {{else if and .Type (eq .Type.Kind "binary")}} + {{if .IsBinary}} + <textarea class="cfg-input cfg-input-mono" name="value" rows="1">{{.CurrentValue}}</textarea> + {{else}} + <span class="text-muted">Binary data (non-text, cannot edit here)</span> + {{end}} + {{else}} + <input class="cfg-input" type="text" name="value" + value="{{if not .UsingDefault}}{{.CurrentValue}}{{end}}" + {{if and .UsingDefault .Default}}placeholder="{{.Default}}"{{end}}> + {{end}} + </label> + <button class="btn btn-primary" type="submit">Save</button> + {{if not .Mandatory}} + <button class="btn btn-secondary" type="button" + title="Reset to YANG default" + hx-delete="/configure/tree/node?path={{.Path | urlquery}}" + hx-target="#yang-detail" + hx-swap="innerHTML" + hx-confirm="Reset {{.Name}} to its YANG default?">Reset</button> + {{end}} + </div> + </form> + {{end}} + + {{/* ── Details foldout ────────────────────────────────────────────── */}} + <details class="yt-schema-details"> + <summary>Details</summary> + <div class="yt-schema-body"> + {{if .Description}} + <p class="yt-description">{{.Description}}</p> + {{end}} + <table class="info-table yt-schema-table"> + <tr><th>Path</th><td><code class="yt-path">{{.Path}}</code></td></tr> + <tr> + <th>Access</th> + <td>{{if .Config}}read-write{{else}}<span class="text-muted">read-only</span>{{end}}</td> + </tr> + {{if .Mandatory}}<tr><th>Mandatory</th><td>yes</td></tr>{{end}} + {{if .Default}}<tr><th>Default</th><td><code>{{.Default}}</code></td></tr>{{end}} + {{if .Keys}} + <tr> + <th>Keys</th> + <td>{{range $i,$k := .Keys}}{{if $i}}, {{end}}<code>{{$k}}</code>{{end}}</td> + </tr> + {{end}} + {{if .Type}} + <tr><th>Type</th><td><code>{{.Type.Kind}}</code></td></tr> + {{if .Type.Enums}} + <tr><th>Values</th><td><div class="yt-value-list">{{range .Type.Enums}}<code class="yt-enum">{{.}}</code>{{end}}</div></td></tr> + {{end}} + {{if .Type.Identities}} + <tr><th>Identities</th><td><div class="yt-value-list">{{range .Type.Identities}}<code class="yt-enum">{{.}}</code>{{end}}</div></td></tr> + {{end}} + {{if .Type.Leafref}}<tr><th>Leafref</th><td><code class="yt-path">{{.Type.Leafref}}</code></td></tr>{{end}} + {{if .Type.Range}}<tr><th>Range</th><td><code>{{.Type.Range}}</code></td></tr>{{end}} + {{if .Type.Pattern}}<tr><th>Pattern</th><td><code>{{.Type.Pattern}}</code></td></tr>{{end}} + {{end}} + </table> + </div> + </details> +</div> +{{end}} diff --git a/src/webui/templates/fragments/yang-tree-node.html b/src/webui/templates/fragments/yang-tree-node.html new file mode 100644 index 00000000..22edb052 --- /dev/null +++ b/src/webui/templates/fragments/yang-tree-node.html @@ -0,0 +1,27 @@ +{{/* Renders a slice of *treeNodeData as <li> elements (no wrapping <ul>). */}} +{{define "yang-tree-nodes"}} + {{range .}}{{template "yang-tree-node" .}}{{end}} +{{end}} + +{{/* Renders a single *treeNodeData as a <li>. */}} +{{define "yang-tree-node"}} + {{if or (eq .Kind "container") (eq .Kind "list") (eq .Kind "list-instance")}} + <li> + <details class="yt-node" + hx-get="{{.TreeBase}}/children?path={{.Path | urlquery}}" + hx-target="find .yt-children" + hx-trigger="toggle once" + hx-swap="innerHTML"> + <summary class="yt-label" + data-yang-path="{{.Path}}" + hx-get="{{.TreeBase}}/node?path={{.Path | urlquery}}" + hx-target="#yang-detail" + hx-swap="innerHTML"> + {{template "icon-yt-chevron"}} + <span class="yt-name">{{.Name}}</span> + </summary> + <ul class="yt-children"></ul> + </details> + </li> + {{end}} +{{end}} diff --git a/src/webui/templates/layouts/base.html b/src/webui/templates/layouts/base.html new file mode 100644 index 00000000..31ca736f --- /dev/null +++ b/src/webui/templates/layouts/base.html @@ -0,0 +1,163 @@ +{{define "base"}} +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="color-scheme" content="light dark"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <meta name="csrf-token" content="{{.CsrfToken}}"> + <meta name="htmx-config" content='{"includeIndicatorStyles": false}'> + {{if .RetryAfter}}<meta http-equiv="refresh" content="{{.RetryAfter}}">{{end}} + <title>{{.PageTitle}} + + + + + + + + + +
+ + + {{/* Device identity (hostname + location/contact popover), loaded async so + it stays out of every page's data path. Replaces itself on load. */}} +
+
+ {{if .Capabilities.Has "docs"}} + {{/* On-device User's Guide (static mkdocs site at /guide/). */}} + + {{template "icon-book"}} + + {{end}} + {{if .Capabilities.Has "netbrowse"}} + {{/* mDNS network browser — fixed network.local vhost, so a static + href (no JS, no CSP concern). */}} + + {{template "icon-radar"}} + + {{end}} + {{if .Capabilities.Has "console"}} + {{/* Web console (ttyd) on :7681. href is set by JS from the current + hostname since the port differs and CSP forbids inline script. */}} + + {{template "icon-terminal"}} + + {{end}} +
+ +
+ + + + + + + + + + + {{if or (.Capabilities.Has "docs") (.Capabilities.Has "netbrowse") (.Capabilities.Has "console")}} + + {{if .Capabilities.Has "docs"}} + + {{template "icon-book"}} + User Guide + + {{end}} + {{if .Capabilities.Has "netbrowse"}} + + {{template "icon-radar"}} + Network Browser + + {{end}} + {{if .Capabilities.Has "console"}} + {{/* href filled in by JS from the current hostname (ttyd on :7681). */}} + + {{template "icon-terminal"}} + Console + + {{end}} + {{end}} + +
+ + +
+
+
+
+
+ +
+ {{template "sidebar" .}} +
+ {{if .CfgUnsaved}} +
+ {{template "icon-triangle-alert"}} + Running config has unsaved changes — will be lost on reboot. + +
+ {{end}} +
+ {{block "content" .}}{{end}} +
+
+
+ +

+
+ + +
+
+ + +{{end}} diff --git a/src/webui/templates/layouts/icons.html b/src/webui/templates/layouts/icons.html new file mode 100644 index 00000000..935741a1 --- /dev/null +++ b/src/webui/templates/layouts/icons.html @@ -0,0 +1,36 @@ +{{/* Shared inline SVG icons. Each is one Lucide source baked at its + use-site size and CSS class so the call site stays a one-liner. + Vendored from https://lucide.dev (ISC). */}} + +{{/* ⓘ hover-help glyph. Pass the description string; renders nothing + if empty so callers don't need to guard. */}} +{{define "field-info"}}{{if .}} {{end}}{{end}} + + +{{define "icon-trash"}}{{end}} +{{define "icon-reset"}}{{end}} +{{define "icon-menu"}}{{end}} +{{define "icon-terminal"}}{{end}} +{{define "icon-radar"}}{{end}} +{{define "icon-book"}}{{end}} +{{define "icon-user"}}{{end}} +{{define "icon-user-lg"}}{{end}} +{{define "icon-chevron-down"}}{{end}} +{{define "icon-contrast"}}{{end}} +{{define "icon-check"}}{{end}} +{{define "icon-sun"}}{{end}} +{{define "icon-moon"}}{{end}} +{{define "icon-clock"}}{{end}} +{{define "icon-circle-slash"}}{{end}} +{{define "icon-log-out"}}{{end}} +{{define "icon-triangle-alert"}}{{end}} +{{define "icon-login-theme-auto"}}{{end}} +{{define "icon-login-theme-light"}}{{end}} +{{define "icon-login-theme-dark"}}{{end}} +{{define "icon-check-lg"}}{{end}} +{{define "icon-circle-x"}}{{end}} +{{define "icon-yt-chevron"}}{{end}} +{{define "icon-yt-row-arrow"}}{{end}} +{{define "icon-yt-subsection-arrow"}}{{end}} +{{define "icon-search-empty"}}{{end}} +{{define "icon-eye"}}{{end}} diff --git a/src/webui/templates/layouts/sidebar.html b/src/webui/templates/layouts/sidebar.html new file mode 100644 index 00000000..62adddf0 --- /dev/null +++ b/src/webui/templates/layouts/sidebar.html @@ -0,0 +1,352 @@ +{{define "sidebar"}} + +{{end}} diff --git a/src/webui/templates/pages/backup.html b/src/webui/templates/pages/backup.html new file mode 100644 index 00000000..71083559 --- /dev/null +++ b/src/webui/templates/pages/backup.html @@ -0,0 +1,71 @@ +{{define "backup.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +
+ +
+
Backup
+
+

Download the current startup configuration as a JSON file. + The file format is startup-config-$hostname-$date-$time.cfg +

+ +
+
+ +
+
Restore
+
+

Restore a previously backed-up configuration. By default + the restore operation is made to running config, this is the safe default.

+

If the file was saved by an older release, it is migrated to + the current configuration syntax before being applied.

+
+
+ +
+ +
+
+ +
+
+
+
+ +
+
Support Bundle
+
+

Collect system state — configuration, logs, routing, + interfaces, hardware inventory, and container and service status — into a + single archive to attach to a support ticket. Collection can take up to + a minute.

+
+ +
+

Leave blank for a plain .tar.gz, or set + a password to encrypt the bundle (GPG) before download.

+
+ +
+

+
+
+ +
+{{end}} diff --git a/src/webui/templates/pages/configure-dns.html b/src/webui/templates/pages/configure-dns.html new file mode 100644 index 00000000..c6d6be2e --- /dev/null +++ b/src/webui/templates/pages/configure-dns.html @@ -0,0 +1,124 @@ +{{define "configure-dns.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +
+{{if .Error}} +
{{.Error}}
+{{end}} + +
+
Search Domains
+
+ + + + {{range .DNS.Search}} + + + + + {{else}} + + {{end}} + +
Domain{{template "field-info" (index .Desc "search")}}
{{.}} + +
No search domains configured.
+
+ + +
+ +
+
DNS Servers
+
+ + + + + + + + + + + {{range $i, $s := .DNS.Servers}} + + + + + + + + + + {{else}} + + {{end}} + +
Name{{template "field-info" (index .Desc "name")}}Address{{template "field-info" (index .Desc "address")}}Port{{template "field-info" (index .Desc "port")}}
+ + {{if $s.UDPAndTCP.Address}}{{$s.UDPAndTCP.Address}}{{else}}{{end}}{{if $s.UDPAndTCP.Port}}{{$s.UDPAndTCP.Port}}{{else}}53{{end}} + +
+
+
+ + + + + + + + + +
Address{{template "field-info" (index $.Desc "address")}}
Port{{template "field-info" (index $.Desc "port")}}
+ +
+
+
No DNS servers configured.
+
+ + +
+{{template "configure-toolbar" .}} +
+{{end}} diff --git a/src/webui/templates/pages/configure-firewall.html b/src/webui/templates/pages/configure-firewall.html new file mode 100644 index 00000000..011798bb --- /dev/null +++ b/src/webui/templates/pages/configure-firewall.html @@ -0,0 +1,734 @@ +{{define "configure-firewall.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +
+{{if .Loading}} +
+ Downloading YANG schema from device… this takes a moment on first login. +
+{{template "configure-toolbar" .}} +
+{{else}} +{{$d := .Desc}} +{{if .Error}} +
{{.Error}}
+{{end}} + +{{if not .Active}} + +
+
Firewall
+

Firewall is not configured on this device.

+ +
+ +{{else}} + +
+ + {{/* ── Global settings ─────────────────────────────────────────────── */}} +
+
Global Settings
+
+ + + + + + + + + + + + + + + + +
Enabled{{template "field-info" (index $d "enabled")}} +
+ + +
+
+ +
Default zone{{template "field-info" (index $d "default")}} + + + +
Logging{{template "field-info" (index $d "logging")}} + {{$log := .Logging}} + + + +
+ +
+
+ + {{/* ── Zones ───────────────────────────────────────────────────────── */}} +
+
Zones
+
+ + + + + + + + + + + + {{range $i, $z := .Zones}} + + + + + + + + + + + {{end}} + + {{if not .Zones}} + + {{end}} + + {{/* Hidden add-zone row */}} + + + + +
Name{{template "field-info" (index $d "zone-name")}}Action{{template "field-info" (index $d "zone-action")}}Description{{template "field-info" (index $d "zone-description")}}Interfaces
+ + {{if .Action}}{{.Action}}{{else}}reject{{end}}{{if .Description}}{{.Description}}{{else}}{{end}}{{.IfaceCount}} + {{if not .Immutable}} + + {{end}} +
+
+ {{if .Immutable}} +

This zone is system-defined and cannot be modified.

+ {{if .Interface}}

Interfaces: {{range $j, $iface := .Interface}}{{if $j}}, {{end}}{{$iface}}{{end}}

{{end}} + {{if .Network}}

Networks: {{.NetworksTxt}}

{{end}} + {{if .Service}}

Services: {{.ServicesTxt}}

{{end}} + {{else}} +
+ + + + + + + + + + + + {{if .Network}} + + + + + {{else}} + + + + + + {{end}} + + + + + +
Action{{template "field-info" (index $d "zone-action")}} + {{$action := .Action}} +
+ {{range $.ActionOptions}} + + {{end}} +
+
+ +
Description{{template "field-info" (index $d "zone-description")}} + +
Networks + {{.NetworksTxt}} + Use Configure → Advanced to modify networks. +
Interfaces{{template "field-info" (index $d "zone-interface")}} + {{if $.AllInterfaces}} +
+
+ {{range $.AllInterfaces}} + + {{end}} +
+
+ {{else}} + + {{end}} +
+ +
Services{{template "field-info" (index $d "zone-service")}} + {{if $.ServiceOptions}} + + {{else}} + + {{end}} + Applied when action is reject or drop. Ctrl/⌘-click to select multiple. + + +
+ +
+ + {{/* Port forwarding (DNAT) — own forms, edits land directly. */}} +
Port Forwarding
+ {{if .PortForward}} + + + + + + + + + + + + {{range .PortForward}} + + + + + + + + {{end}} + +
PortProtoTo addressTo port
{{.Lower}}{{if gt .Upper .Lower}}-{{.Upper}}{{end}}{{.Proto}}{{if and .To .To.Addr}}{{.To.Addr}}{{else}}— local —{{end}}{{if and .To .To.Port}}{{.To.Port}}{{else}}same{{end}} + +
+ {{end}} +
+ + + + + + + +
+ {{end}} +
+
No zones configured
+
+ +
+ + {{/* ── Policies ─────────────────────────────────────────────────────── */}} +
+
Policies
+
+ + + + + + + + + + + + + {{range $i, $p := .Policies}} + + + + + + + + + + + + {{end}} + + {{if not .Policies}} + + {{end}} + + {{/* Hidden add-policy row */}} + + + + +
Name{{template "field-info" (index $d "policy-name")}}Action{{template "field-info" (index $d "policy-action")}}Ingress{{template "field-info" (index $d "policy-ingress")}}Egress{{template "field-info" (index $d "policy-egress")}}Masquerade{{template "field-info" (index $d "policy-masquerade")}}
+ + {{if .Action}}{{.Action}}{{else}}reject{{end}}{{if .IngressDisplay}}{{.IngressDisplay}}{{else}}{{end}}{{if .EgressDisplay}}{{.EgressDisplay}}{{else}}{{end}}{{.MasqDisplay}} + {{if not .Immutable}} + + {{end}} +
+
+ {{if .Immutable}} +

This policy is system-defined and cannot be modified.

+ {{else}} +
+ + + + + + + + + + + + + + + + + + + +
Action{{template "field-info" (index $d "policy-action")}} + {{$action := .Action}} + + + +
Ingress zones{{template "field-info" (index $d "policy-ingress")}} +
+ {{if .IngressDisplay}}{{.IngressDisplay}}{{else}}(None){{end}} +
+ {{range $.ZoneNames}} + + {{end}} +
+
+
Egress zones{{template "field-info" (index $d "policy-egress")}} +
+ {{if .EgressDisplay}}{{.EgressDisplay}}{{else}}(None){{end}} +
+ {{range $.ZoneNames}} + + {{end}} +
+
+
Masquerade{{template "field-info" (index $d "policy-masquerade")}} +
+ + +
+
+ +
+ +
+ {{end}} +
+
No policies configured
+
+ +
+ + {{/* ── Custom Services ──────────────────────────────────────────────── */}} +
+
Services
+

+ Define port/protocol bundles by name, e.g. myapp = tcp 8080. + Custom services appear in the zone Services picker alongside the built-in ones. +

+
+ + + + + + + + + + + + {{range $i, $s := .Services}} + + + + + + + + + + + {{end}} + + {{if not .Services}} + + {{end}} + + {{/* Hidden add-service row */}} + + + + +
Name{{template "field-info" (index $d "service-name")}}Description{{template "field-info" (index $d "service-description")}}PortsDestination{{template "field-info" (index $d "service-destination")}}
+ + {{if .Description}}{{.Description}}{{else}}{{end}}{{if .PortsDisplay}}{{.PortsDisplay}}{{else}}{{end}}{{if .Destination}}{{.Destination}}{{else}}any{{end}} + +
+
+
+ + + + + + + + + + + + + +
Description{{template "field-info" (index $d "service-description")}}
Destination{{template "field-info" (index $d "service-destination")}}
Ports + + + + + + + + + + {{range .Port}} + + + + + + {{end}} + {{/* One blank row so the user can add another port without losing their session. */}} + + + + + + +
Lower{{template "field-info" (index $d "service-port-lower")}}Upper{{template "field-info" (index $d "service-port-upper")}}Proto{{template "field-info" (index $d "service-port-proto")}}
+ +
+ +
+ Leave "Upper" blank for a single port. Blank "Lower" rows are ignored. +
+ +
+
+
No custom services configured
+
+ +
+ +
+{{end}} + +{{template "configure-toolbar" .}} + +{{end}}{{/* end {{else}} — schema ready */}} + +{{end}} + diff --git a/src/webui/templates/pages/configure-hardware.html b/src/webui/templates/pages/configure-hardware.html new file mode 100644 index 00000000..7096185f --- /dev/null +++ b/src/webui/templates/pages/configure-hardware.html @@ -0,0 +1,274 @@ +{{define "configure-hardware.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +
+{{if .Loading}} +
+ Downloading YANG schema from device… this takes a moment on first login. +
+{{template "configure-toolbar" .}} +
+{{else}} +{{if .Error}} +
{{.Error}}
+{{end}} + +
+
+
Hardware + Status → +
+
+ + + + + + + + + + + + {{range $i, $c := .Configured}} + + + + + + + + + + + {{end}} + + {{if not .Configured}} + + {{end}} + + {{if .HasAvailable}} + {{/* Hidden add row */}} + + + + {{end}} + +
NameClassDescriptionState
+ + {{$c.ClassDisplay}}{{if $c.Description}}{{$c.Description}}{{else}}{{end}} + {{if $c.IsUSB}} + {{if $c.Unlocked}}unlocked + {{else}}locked{{end}} + {{else if $c.IsWiFi}} + {{$c.CountryCode}}{{if $c.Band}} · {{$c.Band}}{{end}} + {{else if $c.IsGPS}} + configured + {{else}}{{end}} + + +
+
+ {{if $c.IsUSB}}{{template "hw-usb-form" $c}}{{end}} + {{if $c.IsWiFi}}{{template "wifi-radio-form" $c}}{{end}} + {{if $c.IsGPS}}{{template "hw-gps-form" $c}}{{end}} +
+
No hardware components configured yet{{if .HasAvailable}} — use Add hardware below to configure detected devices{{end}}.
+
+ + {{if .HasAvailable}} + + {{end}} +
+
+ +{{template "configure-toolbar" .}} + +{{end}}{{/* else schema ready */}} + +{{end}} + +{{define "hw-usb-form"}} +{{$p := printf "/ietf-hardware:hardware/component=%s" .Name}} +
+ + + + + + + + + + + +
Description{{template "field-info" .DescDescription}} + + + +
State{{template "field-info" .DescAdminState}} + + + +
+ +
+{{end}} + +{{define "wifi-radio-form"}} +{{$p := printf "/ietf-hardware:hardware/component=%s" .Name}} +{{$rp := printf "%s/infix-hardware:wifi-radio" $p}} +
+ + + + + + + + + + + + + + + + + + + + + +
Description{{template "field-info" .DescDescription}} + + + +
Country code{{template "field-info" .DescCountry}} + +
Band{{template "field-info" .DescBand}} + + + +
Channel{{template "field-info" .DescChannel}} + + + +
+ +
+{{end}} + +{{define "hw-gps-form"}} +{{$p := printf "/ietf-hardware:hardware/component=%s" .Name}} +
+ + + + + + +
Description{{template "field-info" .DescDescription}} + + + +
+ +
+{{end}} + diff --git a/src/webui/templates/pages/configure-interfaces.html b/src/webui/templates/pages/configure-interfaces.html new file mode 100644 index 00000000..6f175a7e --- /dev/null +++ b/src/webui/templates/pages/configure-interfaces.html @@ -0,0 +1,1858 @@ +{{define "configure-interfaces.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +
+{{if .Loading}} +
+ Downloading YANG schema from device… this takes a moment on first login. +
+{{template "configure-toolbar" .}} +
+{{else}} +{{$d := .Desc}} +{{if .Error}} +
{{.Error}}
+{{end}} + +
+
Interfaces
+
+ + + + + + + + + + + + + {{range $i, $r := .Interfaces}} + + + + + + + + + + {{/* ── Fold-out edit form ───────────────────────────────────────── */}} + + + + {{end}}{{/* range Interfaces */}} + +
NameTypeStatusMember ofConfig
+ + {{.TypeDisplay}} + {{if eq .OperStatus "up"}} + up + {{else if eq .OperStatus "down"}} + down + {{else}} + {{.OperStatus}} + {{end}} + {{if .MemberOf}}{{.MemberOf}}{{else}}{{end}} + {{range .ConfigTags}}{{.}}{{end}} + {{if .AddrSummary}}{{.AddrSummary}}{{end}} + {{if and (not .ConfigTags) (not .AddrSummary)}}{{end}} + + {{if ne .TypeSlug "loopback"}} + + {{end}} +
+
+ + {{/* ── General ──────────────────────────────────────────── */}} + {{$ip := printf "/ietf-interfaces:interfaces/interface=%s" .Name}} +
+

General

+ + + + + + + + + + + + + + + + +
Description{{template "field-info" (index $d "description")}} + + + +
Enabled{{template "field-info" (index $d "enabled")}} +
+ + +
+
+ +
MAC address{{template "field-info" (index $d "mac")}} + + + +
+ +
+ + {{/* ── IP Addresses ──────────────────────────────────────── */}} + {{if .HasIP}} +
+

IP Addresses

+ + {{/* IPv4. Toggles, DHCP foldout, address list, and add-address + row visually compose one IPv4 group; the Save button at the + bottom is bound to the toggles form via the HTML5 `form` + attribute, so users see the whole group before clicking Save. */}} + {{template "iface-ipv4-block" .}} + + {{/* IPv6 — mirrors the IPv4 form structure above. */}} + {{/* IPv6 — mirrors the IPv4 layout above. */}} + {{template "iface-ipv6-block" .}} +
+ {{end}}{{/* HasIP */}} + + + {{/* ── Bridge port editor ──────────────────────────────── */}} + {{if .IsBridgePort}} +
+

Bridge Port Settings

+
+ + + + + + {{$bpPath := printf "/ietf-interfaces:interfaces/interface=%s/infix-interfaces:bridge-port" .Name}} + {{if .ParentBridgeIs8021Q}} + + + + + + {{end}} + + + + + + + + + + + + + + + +
Bridge{{template "field-info" (index $d "bp-bridge")}}
PVID{{template "field-info" (index $d "bp-pvid")}} + +
Flood unknown{{template "field-info" (index $d "bp-flood")}} +
+ + + +
+
+ +
Multicast router{{template "field-info" (index $d "bp-mc-router")}} + {{/* YANG default is auto. */}} + {{$mr := "auto"}}{{if and .BridgePort .BridgePort.Multicast .BridgePort.Multicast.Router}}{{$mr = .BridgePort.Multicast.Router}}{{end}} + + + +
Multicast fast-leave{{template "field-info" (index $d "bp-mc-fast-leave")}} + {{/* Hidden companion lets the handler distinguish + "form omitted the field" from "user unchecked". */}} + + + + +
+ +
+
+ {{end}}{{/* IsBridgePort */}} + + + {{/* ── LAG port editor ─────────────────────────────────── */}} + {{if .IsLagPort}} +
+

LAG Port Settings

+

+ LACP state is operational-only; configure the LAG itself on its own row. +

+
+ + + + + +
LAG{{template "field-info" (index $d "lp-lag")}}
+ +
+
+ {{end}}{{/* IsLagPort */}} + + + {{/* ── Ethernet ────────────────────────────────────────── */}} + {{if eq .TypeSlug "ethernet"}} + {{$ep := printf "%s/ieee802-ethernet-interface:ethernet" $ip}} +
+

Ethernet

+
+ + + + + + + + + + + + + + + + + + + + + + + + +
Auto-negotiation{{template "field-info" (index $d "eth-autoneg")}} + +
Advertised speeds{{template "field-info" (index $d "eth-advertised")}} + {{if .EthSupported}} + {{$advertised := .EthAdvertised}} +
+ + {{if $advertised}} + {{range $i, $v := $advertised}}{{if $i}}, {{end}}{{shortPMD $v}}{{end}} + {{else}}(All){{end}} + +
+ + {{range .EthSupported}} + {{$cur := .}} + {{$selected := false}} + {{range $advertised}}{{if eq . $cur}}{{$selected = true}}{{end}}{{end}} + + {{end}} +
+
+ {{else}} + No supported-PMD data from device. + {{end}} +
+ +
Duplex{{template "field-info" (index $d "eth-duplex")}} + + + + + +
MDI-X{{template "field-info" (index $d "eth-mdix")}} + + + + + +
+ +
+
+ {{end}}{{/* TypeSlug ethernet */}} + + + {{/* ── WiFi interface editor ───────────────────────────── */}} + {{/* Radio + PSK pickers mirror the Add-Interface wizard + pattern: each picker has Edit / + New buttons that + toggle an inline form (radio: name/country/band/ + channel; PSK: name/passphrase). The forms POST to + the wizard endpoints and HTMX swaps the picker + + + + + + + {{if and .WiFi .WiFi.AccessPoint}} + {{$ap := .WiFi.AccessPoint}} + + + + + + + + + + + + + {{else if and .WiFi .WiFi.Station}} + {{$sta := .WiFi.Station}} + + + + + + + + + {{end}} + + + + +
Radio{{template "field-info" (index $d "wifi-radio")}} +
+ {{template "wizard-radio-picker.html" (dict "Radios" $.WizardWifiRadios "Selected" $curRadio "ID" $radioPickerID)}} + + +
+ +
SSID{{template "field-info" (index $d "wifi-ssid")}}
Hidden SSID{{template "field-info" (index $d "wifi-hidden")}} + +
Security mode{{template "field-info" (index $d "wifi-sec-mode")}} + {{$sm := "wpa2-wpa3-personal"}}{{if and $ap.Security $ap.Security.Mode}}{{$sm = $ap.Security.Mode}}{{end}} + +
SSID{{template "field-info" (index $d "wifi-ssid")}}
Security mode{{template "field-info" (index $d "wifi-sec-mode")}} + {{$sm := "auto"}}{{if and $sta.Security $sta.Security.Mode}}{{$sm = $sta.Security.Mode}}{{end}} + +
PSK{{template "field-info" (index $d "wifi-secret")}} +
+ {{template "wizard-psk-picker.html" (dict "Keys" $.WizardSymKeys "Selected" $curSecret "ID" $pskPickerID)}} + + +
+ +
+ + +
+ {{end}}{{/* IsWifi */}} + + + {{/* ── Bridge editor ────────────────────────────────────── */}} + {{if .IsBridge}} +
+ + {{/* Bridge settings */}} +

Bridge Settings

+
+ + + + + + + + + +
Type{{template "field-info" (index $d "bridge-type")}} +
+ + +
+
Ports + {{if $r.PortCandidates}} +
+
+ {{range $r.PortCandidates}} + + {{end}} +
+
+ {{else}} + No candidate port interfaces available. + {{end}} +
+ + +
+ + {{$brPath := printf "/ietf-interfaces:interfaces/interface=%s/infix-interfaces:bridge" .Name}} + {{/* STP — own form, save inside the fold-out so the + section is self-contained. YANG defaults are + rendered inline (value="..." not a placeholder) + so the user sees the actual default; the reset + button drops the candidate value and lets YANG + re-apply. */}} +
+
+ + Spanning Tree Protocol + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Protocol{{template "field-info" (index $d "stp-force")}} + {{$stpForce := "rstp"}}{{if and .Bridge .Bridge.STP.ForceProtocol}}{{$stpForce = .Bridge.STP.ForceProtocol}}{{end}} + + + +
Hello time (s){{template "field-info" (index $d "stp-hello")}} + + + +
Forward delay (s){{template "field-info" (index $d "stp-fwd-delay")}} + + + +
Max age (s){{template "field-info" (index $d "stp-max-age")}} + + + +
Transmit hold count{{template "field-info" (index $d "stp-hold-count")}} + + + +
Max hops{{template "field-info" (index $d "stp-max-hops")}} + + + +
+ +
+
+ + {{/* Multicast Snooping — parallel STP layout. YANG + defaults inline: querier=auto, query-interval=125. */}} +
+
+ + Multicast Snooping + + + + + + + + + + + + + + + + + +
Snooping{{template "field-info" (index $d "mc-snoop")}} + + + +
Querier{{template "field-info" (index $d "mc-querier")}} + {{$q := "auto"}}{{if and .Bridge .Bridge.Multicast .Bridge.Multicast.Querier}}{{$q = .Bridge.Multicast.Querier}}{{end}} + + + +
Query interval (s){{template "field-info" (index $d "mc-query-int")}} + + + +
+ +
+
+ + {{/* 802.1Q VLANs — matrix layout: rows = VLAN, columns + = bridge ports + self, cells = U/T/-. Click VID to + expand the existing per-port checkbox editor. */}} + {{if .BridgeIs8021Q}} +

VLANs

+
+ + + + + {{range $r.BridgeMembers}}{{end}} + + + + + + {{range $vi, $vlan := .VLANRows}} + + + {{range $r.BridgeMembers}} + {{if index $vlan.UntaggedSet .}} + {{else if index $vlan.TaggedSet .}} + {{else}}{{end}} + {{end}} + {{if index $vlan.UntaggedSet $r.Name}} + {{else if index $vlan.TaggedSet $r.Name}} + {{else}}{{end}} + + + + + + {{end}} + + {{if not .VLANRows}} + + {{end}} + + {{/* Add VLAN inline row */}} + + + + +
VID{{.}}{{$r.Name}}
(self)
+ + UTUT + +
+
+
+ + + + + + + + + +
Untagged +
+
+ {{range $r.BridgeMembers}} + + {{end}} + +
+
+
Tagged +
+
+ {{range $r.BridgeMembers}} + + {{end}} + +
+
+
+ +
+
+
No VLANs configured
+
+ + {{end}}{{/* BridgeIs8021Q */}} +
+ {{end}}{{/* IsBridge */}} + + {{/* ── LAG editor ───────────────────────────────────────── */}} + {{if .IsLag}} +
+

Members

+ {{if $r.PortCandidates}} +
+
+
+ {{range $r.PortCandidates}} + + {{end}} +
+
+ +
+ {{else}} +

No candidate port interfaces available.

+ {{end}} + +

LAG Settings

+
+ {{$lagMode := ""}}{{if and .Lag .Lag.Mode}}{{$lagMode = .Lag.Mode}}{{end}} + + + + + + {{if $.LagHashOptions}} + + + + + {{end}} + {{/* LACP options — hidden unless mode=lacp via JS or always shown */}} + + + + + + + + + + + + +
Mode{{template "field-info" (index $d "lag-mode")}} +
+ {{range $.LagModeOptions}} + + {{end}} +
+
Hash policy{{template "field-info" (index $d "lag-hash")}} + {{$lagHash := ""}}{{if and .Lag .Lag.Hash}}{{$lagHash = .Lag.Hash}}{{end}} + +
LACP mode{{template "field-info" (index $d "lacp-mode")}} + {{$lacpMode := ""}}{{if and .Lag .Lag.LACP.Mode}}{{$lacpMode = .Lag.LACP.Mode}}{{end}} + +
LACP rate{{template "field-info" (index $d "lacp-rate")}} + {{$lacpRate := ""}}{{if and .Lag .Lag.LACP.Rate}}{{$lacpRate = .Lag.LACP.Rate}}{{end}} + +
System priority{{template "field-info" (index $d "lacp-sysprio")}} + +
+ +
+
+ {{end}}{{/* IsLag */}} + + {{/* key-detail-body */}} +
+
+ +
+ +{{/* ── Add Interface modal dialog ─────────────────────────────────────── + One
, multiple per-type
. The type pulldown + enables exactly one fieldset so only its inputs submit. Bridge type + adds a vlan-filtering radio (default plain). Ethernet uses a + datalist-backed input that doubles as a "Restore deleted interface" + picker for free-form names and known unconfigured interfaces alike. + Unsupported types share one panel pointing to Advanced YANG tree. */}} + + +
+

Add Interface

+ +
+ +
+ + + + + +
Interface Type{{template "field-info" (index $d "type")}} + +
+ + {{/* Bridge */}} + + + {{/* LAG */}} + + + {{/* VLAN — name auto-built from . via JS as the user + changes either input; user can still override by typing. */}} + + + {{/* Ethernet — re-bind a previously deleted physical interface. + Ethernet names are tied to detected hardware, so this is a + plain + {{if .UnconfiguredPhysical}} + {{range .UnconfiguredPhysical}}{{end}} + {{else}} + + {{end}} + + + + +
+ + {{/* VETH */}} + + + {{/* Dummy */}} + + + {{/* Loopback */}} + + + {{/* GRE — point-to-point tunnel. tunnel-common requires local and + remote IPs of the same family. */}} + + + {{/* GRETAP — like GRE but carries Ethernet frames. Same backend + container (infix-interfaces:gre); YANG when-clause discriminates + by if:type. */}} + + + {{/* VXLAN — VNI mandatory (0..16777215); UDP port defaults to 4789. */}} + + + {{/* WireGuard — private-key references a keystore asymmetric-key by + name. The YANG `must` clause additionally enforces X25519 format + at commit; we expose all asym keys here and let the backend + validate. Inline keystore key creation is a Phase 4 follow-up + (same keystore-integration work as the WiFi PSK picker). */}} + + + {{/* WiFi — radio reference is mandatory; mode (Station / AP) drives + which security mode options apply and whether the PSK picker + shows. AP-only fields (hidden checkbox + AP security modes) are + swapped in by JS based on the mode radio. */}} + + + {{/* Unsupported types fall here: "other" only now. */}} + + + +
+ + + +
+
+ + +{{template "configure-toolbar" .}} + +{{end}}{{/* else schema ready */}} + +{{end}} + + + +{{define "iface-ipv4-block"}} +
+ {{$ipv4Form := printf "ipv4-form-%s" $.Name}} + {{$ipv4Fold := printf "ipv4-dhcp-%s" $.Name}} +
IPv4{{template "field-info" (index $.Desc "ipv4-address")}}
+
+
+ + + +
+
+ + {{/* DHCPv4 foldout — always in the DOM, hidden when DHCP is off. + The data-fold-target hook on the DHCP checkbox toggles + this without waiting for a server round-trip so the + foldout is discoverable before clicking Save. */}} +
+ DHCP Settings & Options +
+
+ + + + + + + + + + + + + +
Client ID
ARP check
Route preference
+ +
+
DHCP Options
+ {{if .IPv4.DHCP.Options}} + + + + {{range .IPv4.DHCP.Options}} + + + + + + {{end}} + +
OptionValue
{{.ID}}{{if .Value}}{{.Value}}{{else if .Hex}}hex:{{.Hex}}{{else}}{{end}} + +
+ {{end}} + {{if $.DHCPv4Options}} +
+ + + + +
+ {{end}} +
+
+ + {{if and .IPv4 .IPv4.Address}} + + {{range .IPv4.Address}} + + + + + + {{end}} +
{{.IP}}/{{.PrefixLength}}{{if .Origin}}{{.Origin}}{{end}} + {{if or (eq .Origin "") (eq .Origin "static")}} + + {{end}} +
+ {{else}} +

No IPv4 addresses configured.

+ {{end}} +
+ + / + + + +
+ + +
+{{end}} + +{{define "iface-ipv6-block"}} +
+ {{$ipv6Form := printf "ipv6-form-%s" $.Name}} + {{$ipv6Fold := printf "ipv6-dhcp-%s" $.Name}} +
IPv6{{template "field-info" (index $.Desc "ipv6-address")}}
+
+
+ + + +
+
+ +
+ DHCPv6 Settings & Options +
+
+ + + + + + + + + + + + + +
DUID
Information only
Route preference
+ +
+
DHCPv6 Options
+ {{if .IPv6.DHCPv6.Options}} + + + + {{range .IPv6.DHCPv6.Options}} + + + + + + {{end}} + +
OptionValue
{{.ID}}{{if .Value}}{{.Value}}{{else if .Hex}}hex:{{.Hex}}{{else}}{{end}} + +
+ {{end}} + {{if $.DHCPv6Options}} +
+ + + + +
+ {{end}} +
+
+ + {{if and .IPv6 .IPv6.Address}} + + {{range .IPv6.Address}} + + + + + + {{end}} +
{{.IP}}/{{.PrefixLength}}{{if .Origin}}{{.Origin}}{{end}} + {{if or (eq .Origin "") (eq .Origin "static")}} + + {{end}} +
+ {{else}} +

No IPv6 addresses configured.

+ {{end}} +
+ + / + + + +
+ + +
+{{end}} \ No newline at end of file diff --git a/src/webui/templates/pages/configure-keystore.html b/src/webui/templates/pages/configure-keystore.html new file mode 100644 index 00000000..1e5efc0c --- /dev/null +++ b/src/webui/templates/pages/configure-keystore.html @@ -0,0 +1,337 @@ +{{define "configure-keystore.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +
+{{if .Loading}} +
+ Downloading YANG schema from device… this takes a moment on first login. +
+{{template "configure-toolbar" .}} +
+{{else}} +{{if .Error}} +
{{.Error}}
+{{end}} + +
+ + {{/* ── Symmetric Keys ─────────────────────────────────────────────── */}} +
+
Symmetric Keys
+
+ + + + + + + + + + + {{range $i, $k := .SymmetricKeys}} + + + + + + + + + + {{end}} + + {{/* Hidden add-sym-key row */}} + + + + +
NameFormatValue
+ + {{.Format}} + {{if .Value}} + {{.Value}} + {{else}}{{end}} + + {{if .Value}} + + {{end}} + +
+
+
+ + + + + + + + + +
Format + +
Value +
+ + +
+
+ +
+
+
+
+ +
+ + {{/* ── Asymmetric Keys ─────────────────────────────────────────────── */}} +
+
Asymmetric Keys
+
+ + + + + + + + + + + {{range $i, $k := .AsymmetricKeys}} + + + + + + + + + + {{end}} + + {{/* Hidden add-asym-key row */}} + + + + + +
NameAlgorithmCertificates
+ + {{.Algorithm}}{{len .Certificates}} + +
+
+ + {{/* Editable key material */}} +
+ + + + + + {{if .PublicKeyPEM}} + + + + + {{end}} +
Private Key
Public Key
+ +
+ + {{/* Certificate list */}} +
+
Certificates ({{len .Certificates}})
+ {{if .Certificates}} +
+ + + + + + {{range .Certificates}} + + + + + {{if .PEM}} + + + + {{end}} + {{end}} + +
Name
+ {{if .PEM}} + + {{else}}{{.Name}}{{end}} + + +
+
+
+ + + + + +
PEM
+ +
+
+
+
+ {{end}} + + {{/* Add certificate */}} +
+ + + + + + + + + +
Cert name
PEM data
+ +
+
+ +
+
+
+ +
+ +
+ +{{template "configure-toolbar" .}} + +{{end}}{{/* end {{else}} — schema ready */}} + +{{end}} diff --git a/src/webui/templates/pages/configure-ntp.html b/src/webui/templates/pages/configure-ntp.html new file mode 100644 index 00000000..f8dd9e80 --- /dev/null +++ b/src/webui/templates/pages/configure-ntp.html @@ -0,0 +1,114 @@ +{{define "configure-ntp.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +
+{{if .Error}} +
{{.Error}}
+{{end}} + +
+
NTP Servers
+
+ + + + + + + + + + + + {{range $i, $s := .NTP.Servers}} + + + + + + + + + + + {{else}} + + {{end}} + +
Name{{template "field-info" (index .Desc "name")}}Address{{template "field-info" (index .Desc "address")}}Port{{template "field-info" (index .Desc "port")}}Prefer{{template "field-info" (index .Desc "prefer")}}
+ + {{if $s.UDP.Address}}{{$s.UDP.Address}}{{else}}{{end}}{{if $s.UDP.Port}}{{$s.UDP.Port}}{{else}}123{{end}}{{if $s.Prefer}}★{{end}} + +
+
+
+ + + + + + + + + + + + + + + + + + + + + +
Address{{template "field-info" (index $.Desc "address")}}
Port{{template "field-info" (index $.Desc "port")}}
Association{{template "field-info" (index $.Desc "assoc")}} + +
iburst{{template "field-info" (index $.Desc "iburst")}}
Prefer{{template "field-info" (index $.Desc "prefer")}}
+ +
+
+
No NTP servers configured.
+
+ + {{/* Hidden add-server form, revealed by the + Add server button (data-show) */}} + + +
+{{template "configure-toolbar" .}} +
+{{end}} diff --git a/src/webui/templates/pages/configure-routes.html b/src/webui/templates/pages/configure-routes.html new file mode 100644 index 00000000..927747e2 --- /dev/null +++ b/src/webui/templates/pages/configure-routes.html @@ -0,0 +1,222 @@ +{{define "configure-routes.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +
+{{if .Loading}} +
+ Downloading YANG schema from device… this takes a moment on first login. +
+{{template "configure-toolbar" .}} +
+{{else}} +{{$d := .Desc}} +{{if .Error}} +
{{.Error}}
+{{end}} + +
+ +
+
IPv4 Static Routes
+
+ + + + + + + + + + + {{range $i, $rt := .IPv4Routes}} + + + + + + + + + + {{end}} + + {{if not .IPv4Routes}} + + {{end}} + + {{/* Hidden add row */}} + + + + +
Destination{{template "field-info" (index $d "prefix")}}Next Hop{{template "field-info" (index $d "nexthop")}}Interface{{template "field-info" (index $d "interface")}}
+ + {{.NextHop}}{{.Interface}} + +
+
+
+ + + + + + + + + + + + + + + +
Destination{{.Prefix}}
Next hop{{template "field-info" (index $d "nexthop")}}
Interface{{template "field-info" (index $d "interface")}}
+ +
+
+
No IPv4 static routes
+
+ +
+ +
+
IPv6 Static Routes
+
+ + + + + + + + + + + {{range $i, $rt := .IPv6Routes}} + + + + + + + + + + {{end}} + + {{if not .IPv6Routes}} + + {{end}} + + {{/* Hidden add row */}} + + + + +
Destination{{template "field-info" (index $d "prefix")}}Next Hop{{template "field-info" (index $d "nexthop")}}Interface{{template "field-info" (index $d "interface")}}
+ + {{.NextHop}}{{.Interface}} + +
+
+
+ + + + + + + + + + + + + + + +
Destination{{.Prefix}}
Next hop{{template "field-info" (index $d "nexthop")}}
Interface{{template "field-info" (index $d "interface")}}
+ +
+
+
No IPv6 static routes
+
+ +
+ +
+ +{{template "configure-toolbar" .}} + +{{end}}{{/* end {{else}} — schema ready */}} + +{{end}} + diff --git a/src/webui/templates/pages/configure-system.html b/src/webui/templates/pages/configure-system.html new file mode 100644 index 00000000..3c7e11ad --- /dev/null +++ b/src/webui/templates/pages/configure-system.html @@ -0,0 +1,184 @@ +{{define "configure-system.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +
+{{if .Loading}} +
+ Downloading YANG schema from device… this takes a moment on first login. +
+{{template "configure-toolbar" .}} +
+{{else}} +{{$d := .Desc}} +{{if .Error}} +
{{.Error}}
+{{end}} + +
+ + {{/* ── Identity ─────────────────────────────────────────────────────── */}} +
+
Identity
+ + + + + + + + + + + + + + + + +
Hostname{{template "field-info" (index $d "hostname")}}
Contact{{template "field-info" (index $d "contact")}} + +
Location{{template "field-info" (index $d "location")}} + +
+ +
+ + {{/* ── Date & Time ────────────────────────────────────────────────── + One table for all three rows so the value and action columns + line up. The two write paths have different semantics (Set time + = immediate RPC, Timezone = candidate save), so they're declared + as sibling
s and bound to the table's inputs / buttons via + the HTML5 form="…" attribute. The System time row's + data-server-time seeds the JS-driven live tick (initDatetimePicker + in app.js). */}} +
+
Date & Time
+ + + + + + + + + + + + + + + + + + + + + + + +
System time + {{/* Span pads itself to match the Set time input's border+left + padding (1px + 0.6rem), so plain text and the input text + start at the same x-position. */}} + {{if .CurrentDatetime}}{{else}}unavailable{{end}} +
Set time{{template "field-info" "Manually set the device system clock. The time you enter is interpreted in your browser's local timezone and converted to UTC on submit."}} + + + +
Timezone{{template "field-info" (index $d "timezone")}} + {{$tz := .Timezone}} + + + +
+ +
+ +
+
+ + {{/* ── Preferences ─────────────────────────────────────────────────── */}} +
+
Preferences
+ + + + + + + + + + + +
Text editor{{template "field-info" (index $d "text-editor")}} + + + +
Login banner{{template "field-info" (index $d "motd-banner")}} + + + +
+ +
+ +
+{{template "configure-toolbar" .}} + +{{end}}{{/* end {{else}} — schema ready */}} + +{{end}} + diff --git a/src/webui/templates/pages/configure-users.html b/src/webui/templates/pages/configure-users.html new file mode 100644 index 00000000..447beddb --- /dev/null +++ b/src/webui/templates/pages/configure-users.html @@ -0,0 +1,255 @@ +{{define "configure-users.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +
+{{if .Loading}} +
+ Downloading YANG schema from device… this takes a moment on first login. +
+{{template "configure-toolbar" .}} +
+{{else}} +{{if .Error}} +
{{.Error}}
+{{end}} + +
+ + {{/* ── Users ───────────────────────────────────────────────────────── */}} +
+
Users
+
+ + + + + + + + + + + {{range $i, $u := .Users}} + + + + + + + + + + {{end}} + + {{/* Hidden add-user row — revealed by the + Add user button */}} + + + + + +
UsernameShellSSH Keys
+ + +
+ + + +
+
{{.KeyCount}} + +
+
+ + {{/* Change password */}} +
+
Change Password
+ + + + + +
New password
+ +
+ + {{/* Authorized SSH keys */}} +
+
Authorized SSH Keys ({{.KeyCount}})
+ {{if .AuthorizedKeys}} +
+ + + + + + {{range .AuthorizedKeys}} + + + + + + {{end}} + +
NameAlgorithm
{{.Name}}{{.Algorithm}} + +
+
+ {{end}} +
+ + + + + + + + + +
Key name
Public key
+ +
+
+ +
+
+
+ +
+ + + {{/* ── Groups ─────────────────────────────────────────────────────── */}} +
+
Groups
+
+ + + + + + + + + {{range $i, $g := .Groups}} + + + + + + + + {{end}} + +
GroupMembers
+ + {{.MembersSummary}}
+
+ +
Members
+ {{if .UserNames}} +
+ + + + {{range .UserNames}} + + + + + {{end}} + +
Username
{{.}} + +
+
+ {{else}} +

No members.

+ {{end}} + + {{if .Available}} +
+
Add Members
+ + +
+ {{end}} + +
+
+
+
+ +
+ +{{template "configure-toolbar" .}} + +{{end}}{{/* end {{else}} — schema ready */}} + +{{end}} diff --git a/src/webui/templates/pages/containers.html b/src/webui/templates/pages/containers.html new file mode 100644 index 00000000..df6abe27 --- /dev/null +++ b/src/webui/templates/pages/containers.html @@ -0,0 +1,64 @@ +{{define "containers.html"}}{{template "base" .}}{{end}} + +{{define "content"}} +{{if .Error}}
{{.Error}}
{{end}} + +{{if .Containers}} +
+
Containers + Configure → +
+
+ + + + + + {{range .Containers}} + + + + + + + + + {{end}} + +
NameImageStatusCPUMemoryUptime
{{.Name}}{{.Image}} + + {{.Status}} + + {{if gt .CPUPct 0}} +
+
+
+ {{.CPUPct}}% + {{else}}{{end}} +
+ {{if .MemUsed}} +
+
+
+ {{.MemUsed}} / {{.MemLimit}} + {{else}}{{end}} +
{{if .Uptime}}{{.Uptime}}{{else}}—{{end}}
+
+
+{{else if not .Error}} +
+
Containers + Configure → +
+

No containers configured.

+
+{{end}} +{{end}} diff --git a/src/webui/templates/pages/dashboard.html b/src/webui/templates/pages/dashboard.html new file mode 100644 index 00000000..7f70c8b7 --- /dev/null +++ b/src/webui/templates/pages/dashboard.html @@ -0,0 +1,157 @@ +{{define "dashboard.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +{{if .Error}} +
{{.Error}}
+{{end}} + +
+
+
System Information
+
+ + {{if .Hostname}}{{end}} + {{if .Contact}}{{end}} + {{if .Location}}{{end}} + {{if .OSName}}{{end}} + {{if .Machine}}{{end}} + {{if .Software}}{{end}} +
Hostname{{.Hostname}}
Contact{{.Contact}}
Location{{.Location}}
OS{{.OSName}} {{.OSVersion}}
Architecture{{.Machine}}
Boot Partition{{.Software}}
+
+
+ +
+
Runtime
+
+ + {{if .Uptime}}{{end}} + {{if .CurrentTime}}{{end}} + {{if .MemTotal}} + + + + + {{end}} + {{if .Load1}} + + + + + {{end}} +
Uptime{{.Uptime}}
Current Time{{.CurrentTime}}
Memory +
+
+
+ {{.MemUsed}} / {{.MemTotal}} MiB ({{.MemPercent}}%) +
Load Average{{.Load1}} · {{.Load5}} · {{.Load15}}
+
+
+ + {{if .Board.Model}} +
+
Board
+
+ + + {{if .Board.Manufacturer}}{{end}} + {{if .Board.SerialNum}}{{end}} + {{if .Board.HardwareRev}}{{end}} + {{if .Board.BaseMAC}}{{end}} +
Model{{.Board.Model}}
Manufacturer{{.Board.Manufacturer}}
Serial Number{{.Board.SerialNum}}
Hardware Rev{{.Board.HardwareRev}}
Base MAC{{.Board.BaseMAC}}
+
+
+ {{end}} + + + {{if .KeyVitals}} +
+
Key Vitals + All sensors → +
+
+ + {{range .KeyVitals}} + + {{end}} +
{{.Name}}{{.Value}}
+
+
+ {{end}} + + {{if .Disks}} +
+
Disk Usage
+ {{range .Disks}} +
+
+ {{.Mount}}{{if .ReadOnly}} read-only{{end}} + {{.Percent}}% +
+
+
+
+
{{.Available}} free of {{.Size}}
+
+ {{end}} +
+ {{end}} + +
+
Connectivity
+
+ + + + + + + + + + + + + + {{if .DNSSearch}} + + {{end}} + {{if .NTPSync}} + + {{end}} +
Gateway + {{range .Gateways}}
{{.Addr}}{{if .Iface}} via {{.Iface}}{{end}}
{{else}}none{{end}} +
Internet{{.InternetProbe}}
DNS + {{range .DNSServers}}
{{.Address}} {{if eq .Origin "dhcp"}}DHCP{{if .Interface}} · {{.Interface}}{{end}}{{else}}{{.Origin}}{{end}}
{{else}}none{{end}} +
Search{{range $i, $s := .DNSSearch}}{{if $i}}, {{end}}{{$s}}{{end}}
Timesynced · {{.NTPSync}}
+
+
+ + {{if .Addresses}} +
+
Addresses + All interfaces → +
+
+ + {{range .Addresses}} + + + + + {{end}} +
{{.Name}}{{range $i, $a := .Addrs}}{{if $i}}
{{end}}{{$a}}{{end}}
+
+
+ {{end}} +
+{{end}} diff --git a/src/webui/templates/pages/dhcp.html b/src/webui/templates/pages/dhcp.html new file mode 100644 index 00000000..cbd7037c --- /dev/null +++ b/src/webui/templates/pages/dhcp.html @@ -0,0 +1,76 @@ +{{define "dhcp.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +{{if .Error}} +
{{.Error}}
+{{end}} + +{{if .DHCP}} +
+
DHCP Server + Configure → +
+ {{if .DHCP.Enabled}} + + + + + +
Status + + Enabled +
+ {{end}} + +
+ Discovers{{.DHCP.Stats.InDiscoveries}} + Requests{{.DHCP.Stats.InRequests}} + Releases{{.DHCP.Stats.InReleases}} + Offers{{.DHCP.Stats.OutOffers}} + Acks{{.DHCP.Stats.OutAcks}} + Naks{{.DHCP.Stats.OutNaks}} +
+ + {{if .DHCP.Leases}} +

Active Leases

+
+ + + + + + + + + + + + {{range .DHCP.Leases}} + + + + + + + + {{end}} + +
IP AddressMAC AddressHostnameExpiresClient ID
{{.Address}}{{.MAC}}{{if .Hostname}}{{.Hostname}}{{else}}{{end}}{{.Expires}}{{if .ClientID}}{{.ClientID}}{{else}}{{end}}
+
+ {{else}} +

No active leases.

+ {{end}} +
+{{else if not .Error}} +
+
DHCP Server
+

DHCP server data not available.

+
+{{end}} +{{end}} diff --git a/src/webui/templates/pages/diagnostics.html b/src/webui/templates/pages/diagnostics.html new file mode 100644 index 00000000..23806861 --- /dev/null +++ b/src/webui/templates/pages/diagnostics.html @@ -0,0 +1,149 @@ +{{define "diagnostics.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +{{template "diag-card" .}} +{{end}} + +{{/* Tabs are client-side here (unlike Logs): switching tool only swaps + which option fields show, there's no server data to fetch. The Run + button opens an EventSource to /maintenance/diagnostics/run; Stop + closes it, which cancels the request context and kills the tool. */}} +{{define "diag-card"}} +
+
Diagnostics
+ +
+ + + + + +
+ +
+ {{/* One wrapping row: the target grows to fill the left; the per-tool + option fields flow to its right, and Source interface + Address + family always come last on the line. Per-tool fields show/hide + via the [hidden] attr driven by JS keyed on data-tool. */}} +
+ + + + + + + + {{/* mtr's cycle/size only appear when continuous mode is off; the + toggle itself lives on its own row below so the revealed + fields don't intermingle with the checkbox. */}} + + + + + + + + +
+ + {{/* MTR run mode lives on its own row, below the options, so toggling + it off reveals Cycles/Size cleanly above without reflowing + around the checkbox. */}} +
+ +
+ +
+ + + + +
+
+ + {{/* Shared host history for the target field, persisted in + localStorage and rebuilt by JS on each run. */}} + + + {{/* Two output surfaces share the card: a text pane for ping / + traceroute / dns, and a live table for mtr. JS toggles which is + visible per tool. */}} +
+ + + + + + + + + + + + + + + + + + + + +

Enter a target and press Run.

+
+
+{{end}} + +{{/* DNS lookup result fragment, swapped into .diag-dns. */}} +{{define "diag-dns-result"}} +{{if .Error}} +

Lookup failed for {{.Name}}: {{.Error}}

+{{else}} +

{{.Name}} resolves to:

+
    + {{range .Addrs}}
  • {{.}}
  • {{end}} +
+{{end}} +{{end}} diff --git a/src/webui/templates/pages/firewall.html b/src/webui/templates/pages/firewall.html new file mode 100644 index 00000000..41701d20 --- /dev/null +++ b/src/webui/templates/pages/firewall.html @@ -0,0 +1,145 @@ +{{define "firewall.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +{{if .Error}} +
{{.Error}}
+{{end}} + +
+ +
+
Firewall Status + Configure → +
+ + + + + + {{if .DefaultZone}} + + {{end}} + + + + + +
Firewall + + {{.EnabledText}} +
Default Zone{{.DefaultZone}}
Lockdown Mode{{if .Lockdown}}Active{{else}}Inactive{{end}}
Log Denied Traffic{{.Logging}}
+
+ + {{if .Matrix}} +
+
Zone Matrix
+
+ + + + + {{range .ZoneNames}}{{end}} + + + + {{range .Matrix}} + + + {{range .Cells}} + + {{end}} + + {{end}} + +
{{.}}
{{.Zone}}{{.Symbol}}
+ +
+ Allow + Conditional + Deny +
+
+
+ {{end}} + + {{if .Zones}} +
+
Zones
+
+ + + + + + + + + + + + {{range .Zones}} + + + + + + + + {{end}} + +
NameActionInterfacesNetworksHost Services
{{.Name}}{{.Action}}{{.Interfaces}}{{if .Networks}}{{.Networks}}{{else}}{{end}}{{if .Services}}{{.Services}}{{else}}{{end}}
+
+
+ {{end}} + + {{if .Policies}} +
+
Policies
+
+ + + + + + + + + + + + + {{range .Policies}} + + + + + + + + + {{end}} + +
NameActionIngressEgressServices
{{if .Immutable}}{{end}}{{.Name}}{{.Action}}{{.Ingress}}{{.Egress}}{{if .Services}}{{.Services}} {{end}}{{if .Masquerade}}masq{{end}}
+
+
+ {{end}} + +
+{{end}} diff --git a/src/webui/templates/pages/hardware.html b/src/webui/templates/pages/hardware.html new file mode 100644 index 00000000..9c014a2c --- /dev/null +++ b/src/webui/templates/pages/hardware.html @@ -0,0 +1,206 @@ +{{define "hardware.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +{{if .Error}} +
{{.Error}}
+{{end}} + +
+ + {{/* ── Board ─────────────────────────────────────────────────────────── */}} + {{if .Board.Model}} +
+
Board
+
+ + + {{if .Board.Manufacturer}}{{end}} + {{if .Board.SerialNum}}{{end}} + {{if .Board.HardwareRev}}{{end}} + {{if .Board.BaseMAC}}{{end}} +
Model{{.Board.Model}}
Manufacturer{{.Board.Manufacturer}}
Serial Number{{.Board.SerialNum}}
Hardware Rev{{.Board.HardwareRev}}
Base MAC{{.Board.BaseMAC}}
+
+
+ {{end}} + + {{/* ── USB Ports ────────────────────────────────────────────────────── */}} + {{if .USBPorts}} +
+
USB Ports + Configure → +
+
+ + + + + + + + + + + {{range .USBPorts}} + + + + + + + {{end}} + +
NameStateOperationalDescription
{{.Name}} + {{if eq .AdminState "unlocked"}} + unlocked + {{else if eq .AdminState "locked"}} + locked + {{else}} + {{.AdminState}} + {{end}} + {{if .OperState}}{{.OperState}}{{else}}{{end}}{{if .Description}}{{.Description}}{{else}}{{end}}
+
+
+ {{end}} + + {{/* ── WiFi Radios ──────────────────────────────────────────────────── */}} + {{if .WiFiRadios}} +
+
WiFi Radios + Configure → +
+
+ + + + + + + + + + + + {{range .WiFiRadios}} + + + + + + + + {{end}} + +
NameManufacturerBandsStandardsMax AP
{{.Name}}{{if .Manufacturer}}{{.Manufacturer}}{{else}}{{end}}{{if .Bands}}{{.Bands}}{{else}}{{end}}{{if .Standards}}{{.Standards}}{{else}}{{end}}{{if .MaxAP}}{{.MaxAP}}{{else}}{{end}}
+
+
+ {{end}} + + {{/* ── GPS Receivers ────────────────────────────────────────────────── */}} + {{if .GPSReceivers}} +
+
GPS Receivers
+
+ + + + + + + + + + + {{range .GPSReceivers}} + + + + + + + {{end}} + +
NameManufacturerModelHW Rev
{{.Name}}{{if .Manufacturer}}{{.Manufacturer}}{{else}}{{end}}{{if .ModelName}}{{.ModelName}}{{else}}{{end}}{{if .HardwareRev}}{{.HardwareRev}}{{else}}{{end}}
+
+
+ {{end}} + + {{/* ── Sensors ──────────────────────────────────────────────────────── */}} + {{if .SensorGroups}} +
+
Sensors
+
+ + {{range $g := .SensorGroups}} + {{if $g.Parent}}{{end}} + {{range $g.Sensors}} + + + + + + {{end}} + {{end}} +
{{$g.Parent}}
{{.Name}}{{.Value}} + {{if eq .OperStatus "ok"}} + + {{else if eq .OperStatus ""}} + + {{else}} + {{.OperStatus}} + {{end}} +
+
+
+ {{end}} + + {{/* ── Other Components ─────────────────────────────────────────────── */}} + {{if .OtherComps}} +
+
Other Components
+
+ + + + + + + + + + + + + {{range .OtherComps}} + + + + + + + + + {{end}} + +
NameClassParentManufacturerModelSerial
{{.Name}}{{.Class}}{{if .Parent}}{{.Parent}}{{else}}{{end}}{{if .Manufacturer}}{{.Manufacturer}}{{else}}{{end}}{{if .ModelName}}{{.ModelName}}{{else}}{{end}}{{if .SerialNum}}{{.SerialNum}}{{else}}{{end}}
+
+
+ {{end}} + + {{if and (not .Board.Model) (not .USBPorts) (not .WiFiRadios) (not .GPSReceivers) (not .SensorGroups) (not .OtherComps) (not .Error)}} +
+

No hardware information available.

+
+ {{end}} + +
+{{end}} diff --git a/src/webui/templates/pages/iface-detail.html b/src/webui/templates/pages/iface-detail.html new file mode 100644 index 00000000..40b7afe8 --- /dev/null +++ b/src/webui/templates/pages/iface-detail.html @@ -0,0 +1,178 @@ +{{define "iface-detail.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +
+ ← Interfaces +

{{.Name}}

+
+ +
+
+
Interface Info + {{$cfgPath := printf "/ietf-interfaces:interfaces/interface=%s" .Name}} + Configure → +
+ + + + + + + + + {{if .PhysAddr}}{{end}} + {{if .MTU}}{{end}} + {{if .Speed}}{{end}} + {{if .Duplex}}{{end}} + {{if .AutoNeg}}{{end}} + {{if .PhyType}}{{end}} + {{if .PMDType}}{{end}} + {{if or .SupportedPMDs .AdvertisedPMDs}} + + {{end}} + {{if .WiFiMode}}{{end}} + {{if .WiFiSSID}}{{end}} + {{if .WiFiSignal}}{{end}} + {{if .WiFiRxSpeed}}{{end}} + {{if .WiFiTxSpeed}}{{end}} + {{if .WiFiStationCount}}{{end}} + {{if .WGPeerSummary}}{{end}} +
Name{{.Name}}
Type{{.Type}}
Index{{.IfIndex}}
Status{{.Status}}
MAC Address{{.PhysAddr}}
MTU{{template "field-info" (index .Desc "mtu")}}{{.MTU}}
Speed{{template "field-info" (index .Desc "speed")}}{{.Speed}}
Duplex{{template "field-info" (index .Desc "duplex")}}{{.Duplex}}
Auto-negotiation{{template "field-info" (index .Desc "autoneg")}}{{.AutoNeg}}
PHY type{{template "field-info" (index .Desc "phy-type")}}{{.PhyType}}
PMD type{{template "field-info" (index .Desc "pmd-type")}}{{.PMDType}}
Link modes +
+
+
Supported{{template "field-info" (index .Desc "supported")}}
+
    {{range .SupportedPMDs}}
  • {{.}}
  • {{end}}
+
+
+
Advertised{{template "field-info" (index .Desc "advertised")}}
+
    {{range .AdvertisedPMDs}}
  • {{.}}
  • {{end}}
+
+
+
Mode{{.WiFiMode}}
SSID{{.WiFiSSID}}
Signal{{.WiFiSignal}}
RX Speed{{.WiFiRxSpeed}}
TX Speed{{.WiFiTxSpeed}}
Connected Stations{{.WiFiStationCount}}
Peers{{.WGPeerSummary}}
+ {{if .Addresses}} +

Addresses

+ + {{range .Addresses}} + + + + {{end}} +
{{.Address}}{{if .Origin}} ({{.Origin}}){{end}}
+ {{end}} +
+ +
+
Counters
+ {{template "iface-counters" .}} +
+
+ +{{if .WiFiStations}} +
+
Connected Stations
+
+ + + + + + + + + + + + + + + + {{range .WiFiStations}} + + + + + + + + + + + + {{end}} + +
MACSignalTimeRX PktsTX PktsRX BytesTX BytesRX SpeedTX Speed
{{.MAC}}{{if .Signal}}{{.Signal}}{{end}}{{.Time}}{{.RxPkts}}{{.TxPkts}}{{.RxBytes}}{{.TxBytes}}{{.RxSpeed}}{{.TxSpeed}}
+
+
+{{end}} + +{{if .ScanResults}} +
+
Scan Results
+
+ + + + + + + + + + + + {{range .ScanResults}} + + + + + + + + {{end}} + +
SSIDBSSIDSignalChannelSecurity
{{if .SSID}}{{.SSID}}{{else}}Hidden{{end}}{{.BSSID}}{{if .Signal}}{{.Signal}}{{end}}{{.Channel}}{{.Encryption}}
+
+
+{{end}} + +{{if .WGPeers}} +
+
WireGuard Peers
+ {{range .WGPeers}} +
+ + + + + + + {{if .Endpoint}}{{end}} + {{if .Handshake}}{{end}} + {{if .TxBytes}}{{end}} + {{if .RxBytes}}{{end}} +
Public Key{{.PublicKey}}
Status{{.Status}}
Endpoint{{.Endpoint}}
Latest Handshake{{.Handshake}}
Transfer TX{{.TxBytes}}
Transfer RX{{.RxBytes}}
+
+ {{end}} +
+{{end}} + +{{if .EthFrameStats}} +
+
Ethernet Frame Statistics
+
+ {{range .EthFrameStats}} +
+
{{.Key}}
+
{{.Value}}
+
+ {{end}} +
+
+{{end}} +{{end}} diff --git a/src/webui/templates/pages/interfaces.html b/src/webui/templates/pages/interfaces.html new file mode 100644 index 00000000..a64789a5 --- /dev/null +++ b/src/webui/templates/pages/interfaces.html @@ -0,0 +1,62 @@ +{{define "interfaces.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +{{if .Error}} +
{{.Error}}
+{{end}} + +{{if .Interfaces}} +
+
Interfaces + Configure → +
+
+ + + + + + + + + + + + + + + {{range .Interfaces}} + + + + + + + + + + + {{end}} + +
NameTypeStatusSpeedMACData
{{if .Forwarding}}{{end}}{{if .HasMembers}}{{end}}{{.Name}}{{.Type}}{{.Status}}{{.Link}}{{.PhysAddr}}{{range $i, $a := .Addresses}}{{if $i}}
{{end}}{{$a.Address}}{{if $a.Origin}} ({{$a.Origin}}){{end}}{{end}}{{if and .Addresses .Detail}}
{{end}}{{if .Detail}}{{.Detail}}{{end}}
+
+
+{{else if not .Error}} +
+
Interfaces + Configure → +
+

No interfaces found.

+
+{{end}} +{{end}} diff --git a/src/webui/templates/pages/lldp.html b/src/webui/templates/pages/lldp.html new file mode 100644 index 00000000..d7c80ee4 --- /dev/null +++ b/src/webui/templates/pages/lldp.html @@ -0,0 +1,58 @@ +{{define "lldp.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +{{if .Error}} +
{{.Error}}
+{{end}} + +{{if .Neighbors}} +
+
LLDP Neighbors + Configure → +
+
+ + + + + + + + + + + + + {{range .Neighbors}} + + + + + + + + + {{end}} + +
Local PortChassis IDSystem NamePort IDPort DescMgmt Address
{{.LocalPort}}{{.ChassisID}}{{if .SystemName}}{{.SystemName}}{{else}}{{end}}{{if .PortID}}{{.PortID}}{{else}}{{end}}{{if .PortDesc}}{{.PortDesc}}{{else}}{{end}}{{if .MgmtAddress}}{{.MgmtAddress}}{{else}}{{end}}
+
+
+{{else if not .Error}} +
+
LLDP Neighbors + Configure → +
+

No LLDP neighbors discovered.

+
+{{end}} +{{end}} diff --git a/src/webui/templates/pages/login.html b/src/webui/templates/pages/login.html new file mode 100644 index 00000000..7c5d7ac3 --- /dev/null +++ b/src/webui/templates/pages/login.html @@ -0,0 +1,54 @@ +{{define "login.html"}} + + + + + + + + Login + + + + + + + + + + +{{end}} diff --git a/src/webui/templates/pages/logs.html b/src/webui/templates/pages/logs.html new file mode 100644 index 00000000..18810bfe --- /dev/null +++ b/src/webui/templates/pages/logs.html @@ -0,0 +1,90 @@ +{{define "logs.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +{{template "logs-card" .}} +{{end}} + +{{/* The whole card is the htmx swap target for tab changes — server + re-renders the strip with the right tab marked active each time, + no JS state to sync. Side effect: filter clears on tab change, + which matches "new log, fresh look" intent. + + Load earlier walks back through the current file; once exhausted, + the button re-labels to "Load previous from " and + starts paging into rotated archives. Live tail is JS-driven via + EventSource and survives only within a tab; switching tabs closes + the stream. */}} +{{define "logs-card"}} +
+
Logs
+ +
+ {{range .Sources}} + + {{end}} +
+ + {{template "logs-body" .}} +
+{{end}} + +{{define "logs-body"}} +
+
+ + + + + Download + +
+ + {{if .Source.Lines}} +
+ {{/* sourceContent's field names mirror earlierData so the same + logs-earlier template renders both the initial buffer (.Source) + and the Earlier-handler response. */}} + {{template "logs-earlier" .Source}} +
+
+ Source: {{.Source.Path}} +
+ {{else}} +

No entries in {{.Source.Path}} yet.

+ {{end}} +
+{{end}} + +{{/* Combined load-more button + lines fragment. Used both as the + initial buffer and as the response to /earlier requests — the old + button is replaced via outerHTML by [new button, new lines], + keeping chronology in the DOM correct. + + The button's label depends on whether the next click stays in the + current file ("Load earlier") or crosses into a rotation + ("Load previous from messages.0"). */}} +{{define "logs-earlier"}} +{{with .Next}} +{{end}}{{range .Lines}}
{{.Text}}
+{{end}}{{end}} diff --git a/src/webui/templates/pages/mdns.html b/src/webui/templates/pages/mdns.html new file mode 100644 index 00000000..64770fff --- /dev/null +++ b/src/webui/templates/pages/mdns.html @@ -0,0 +1,74 @@ +{{define "mdns.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +{{if .Error}} +
{{.Error}}
+{{end}} + +
+
mDNS + Configure → +
+ + + + + + + {{if .Allow}}{{end}} + {{if .Deny}}{{end}} + {{if .Reflector}} + + + + + {{end}} +
Status + + {{.EnabledText}} +
Domain{{.Domain}}
Allow{{.Allow}}
Deny{{.Deny}}
Reflector Active{{if .SvcFilter}} — filter: {{.SvcFilter}}{{end}}
+ +

Neighbors

+ {{if .Neighbors}} +
+ + + + + + + + + + + {{range .Neighbors}} + {{$host := .Hostname}} + + + + + + + {{range .ExtraAddrs}} + + + + + + + {{end}} + {{end}} + +
HostnameAddressLast SeenServices
{{if .ExtraAddrs}} {{end}}{{.Hostname}}{{.PrimaryAddr}}{{.LastSeen}}{{range $i, $svc := .Services}}{{if $i}} {{end}}{{if $svc.URL}}{{$svc.Label}}{{else}}{{$svc.Label}}({{$svc.Port}}){{end}}{{end}}
{{.}}
+
+ {{else}} +

No mDNS neighbors discovered.

+ {{end}} +
+{{end}} diff --git a/src/webui/templates/pages/nacm.html b/src/webui/templates/pages/nacm.html new file mode 100644 index 00000000..7c5e386d --- /dev/null +++ b/src/webui/templates/pages/nacm.html @@ -0,0 +1,123 @@ +{{define "nacm.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +{{if .Error}} +
{{.Error}}
+{{end}} + +
+ + {{if not .Error}} +
+
NACM Settings + Configure → +
+ + + + + + + + +
Enabled{{.Enabled}}
Default read{{.ReadDefault}}
Default write{{.WriteDefault}}
Default exec{{.ExecDefault}}
Denied operations{{.DeniedOperations}}
Denied writes{{.DeniedDataWrites}}
Denied notifications{{.DeniedNotifications}}
+
+ {{end}} + + {{if .Matrix}} +
+
Group Permissions
+
+ + + + + + + + + + + {{range .Matrix}} + + + + + + + {{if .Restrictions}} + + + + + {{end}} + {{end}} + +
GroupReadWriteExec
{{.Name}}{{.Read.Symbol}}{{.Write.Symbol}}{{.Exec.Symbol}}
Restricted: {{range $i, $r := .Restrictions}}{{if $i}}, {{end}}{{$r}}{{end}}
+
+
+ Full   + Restricted   + Denied +
+
+ {{end}} + + {{if .Users}} +
+
Users
+
+ + + + + + + + + + {{range .Users}} + + + + + + {{end}} + +
UserShellLogin
{{.Name}}{{.Shell}}{{.Login}}
+
+
+ {{end}} + + {{if .Groups}} +
+
Groups
+
+ + + + + + + + + {{range .Groups}} + + + + + {{end}} + +
GroupMembers
{{.Name}}{{.Members}}
+
+
+ {{end}} + +
+{{end}} diff --git a/src/webui/templates/pages/ntp.html b/src/webui/templates/pages/ntp.html new file mode 100644 index 00000000..4d24f7b6 --- /dev/null +++ b/src/webui/templates/pages/ntp.html @@ -0,0 +1,87 @@ +{{define "ntp.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +{{if .Error}} +
{{.Error}}
+{{end}} + +{{if .NTP}} +
+
NTP + Configure → +
+ + + + + + {{if .NTP.Stratum}} + + {{end}} + {{if .NTP.RefID}} + + {{end}} + {{if .NTP.Offset}} + + {{end}} + {{if .NTP.RootDelay}} + + {{end}} +
Sync Status + + {{if .NTP.Synchronized}}Synchronized{{else}}Not Synchronized{{end}} +
Stratum{{.NTP.Stratum}}
Reference ID{{.NTP.RefID}}
Offset{{.NTP.Offset}}
Root Delay{{.NTP.RootDelay}}
+ +

Associations

+ {{if .NTP.Associations}} +
+ + + + + + + + + + + + + + {{range .NTP.Associations}} + + + + + + + + + + {{end}} + +
ServerStratumRef IDReachPoll (s)OffsetDelay
{{.Address}}{{.Stratum}}{{.RefID}}{{.Reach}}{{.Poll}}{{.Offset}}{{.Delay}}
+
+ {{else}} +

No upstream associations to display.

+ {{end}} +
+{{else if not .Error}} +
+
NTP + Configure → +
+

NTP is not configured.

+
+{{end}} +{{end}} diff --git a/src/webui/templates/pages/routing.html b/src/webui/templates/pages/routing.html new file mode 100644 index 00000000..0af4da05 --- /dev/null +++ b/src/webui/templates/pages/routing.html @@ -0,0 +1,131 @@ +{{define "routing.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +{{if .Error}}
{{.Error}}
{{end}} + +
+
Routing Table + Configure → +
+ {{if .Routes}} +
+ + + + + + + + + + + {{range .Routes}} + + + + + + + + + {{end}} + +
DestinationNext HopProtocolPreferenceUptimeActive
{{.DestPrefix}}{{if .NextHopAddr}}{{.NextHopAddr}}{{else}}{{.NextHopIface}}{{end}} + {{if eq .Protocol "ospfv2"}}ospfv2 + {{else if eq .Protocol "ospfv3"}}ospfv3 + {{else if eq .Protocol "static"}}static + {{else if eq .Protocol "kernel"}}kernel + {{else if eq .Protocol "direct"}}direct + {{else}}{{.Protocol}}{{end}} + {{.Preference}}{{.Uptime}}{{if .Active}}{{else}}{{end}}
+
+ {{else}} +

No routes found.

+ {{end}} +
+ +{{if .HasOSPF}} +
+
OSPF Neighbors
+ {{if .OSPFNeighbors}} +
+ + + + + + + + + + + + {{range .OSPFNeighbors}} + + + + + + + + + + {{end}} + +
Router IDPriStateUptimeAddressInterfaceArea
{{.RouterID}}{{.Priority}}{{.State}}{{.Uptime}}{{.Address}}{{.Interface}}{{.Area}}
+
+ {{else}} +

No OSPF neighbors.

+ {{end}} +
+ +
+
OSPF Interfaces
+ {{if .OSPFIfaces}} +
+ + + + + + + + + + + + + + {{range .OSPFIfaces}} + + + + + + + + + + + + {{end}} + +
InterfaceAreaTypeStateCostPriDRBDRNbrs
{{.Name}}{{.Area}}{{if .Type}}{{.Type}}{{else}}{{end}}{{.State}}{{.Cost}}{{.Priority}}{{if .DR}}{{.DR}}{{else}}{{end}}{{if .BDR}}{{.BDR}}{{else}}{{end}}{{.NbrCount}}
+
+ {{else}} +

No OSPF interfaces.

+ {{end}} +
+{{else}} +
+
OSPF
+

OSPF is not configured on this device.

+
+{{end}} +{{end}} diff --git a/src/webui/templates/pages/services.html b/src/webui/templates/pages/services.html new file mode 100644 index 00000000..64a33b03 --- /dev/null +++ b/src/webui/templates/pages/services.html @@ -0,0 +1,48 @@ +{{define "services.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +{{if .Error}} +
{{.Error}}
+{{end}} + +{{if .Services}} +
+
Services
+
+ + + + + + + + + + + + + + {{range .Services}} + + + + + + + + + + {{end}} + +
NameStatusPIDMemUpRstDescription
{{.Name}}{{.Status}}{{.PID}}{{.Memory}}{{.Uptime}}{{if .RestartCount}}{{.RestartCount}}{{end}}{{.Description}}
+
+
+{{else if not .Error}} +
+
Services
+

No services found.

+
+{{end}} +{{end}} diff --git a/src/webui/templates/pages/software.html b/src/webui/templates/pages/software.html new file mode 100644 index 00000000..94500601 --- /dev/null +++ b/src/webui/templates/pages/software.html @@ -0,0 +1,147 @@ +{{define "software.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +{{if .Error}} +
{{.Error}}
+{{end}} + +{{if .Message}} +
{{.Message}}
+{{end}} + +{{$sseURL := "/software/progress"}}{{if .AutoReboot}}{{$sseURL = "/software/progress?auto-reboot=1"}}{{end}} + +{{if .Installing}} +
+ {{template "sw-progress-body" .}} +
+{{end}} + +
+ +
+
Software
+ {{if .BootOrder}} +
+ Boot order + + {{range .BootOrder}}{{.}}{{end}} + + + +
+ {{end}} + {{if .Slots}} +
+ {{range .Slots}} +
+
+ {{.Name}} + {{if .Booted}}booted + {{else}}{{.State}}{{end}} +
+
{{if .Version}}{{.Version}}{{else}}—{{end}}
+ {{if .InstallDate}}
{{.InstallDate}}
{{end}} +
+ {{end}} +
+ {{else}} +

No software information available.

+ {{end}} +
+ +
+
Install from URL
+
+

+ Fetch a .pkg software bundle from a URL and install directly. +

+
+ +
+ + + +
+ + +
+
+
+ +
+
Upload & Install
+
+

+ Select a .pkg software bundle from your computer to upload and install directly. +

+
+ + +
+ +
+ +
+
+
+ + +
+{{end}} + +{{define "sw-progress-body"}} +{{if and .Installer .Installer.Done}} + {{if .Installer.Success}} +
Install Complete
+
+
+ {{template "icon-check-lg"}} + Software installed successfully. +
+ {{if not .AutoReboot}} +
+ +
+ {{end}} +
+ {{else}} +
Install Failed
+
+
+ {{template "icon-circle-x"}} + {{.Installer.LastError}} +
+
+ {{end}} +{{else}} +
Install in Progress
+
+
+
+
+

{{if .Installer}}{{.Installer.Percentage}}% — {{.Installer.Message}}{{else}}Installing…{{end}}

+
+{{end}} +{{end}} diff --git a/src/webui/templates/pages/system-control.html b/src/webui/templates/pages/system-control.html new file mode 100644 index 00000000..aa74be04 --- /dev/null +++ b/src/webui/templates/pages/system-control.html @@ -0,0 +1,72 @@ +{{define "system-control.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +
+ +
+
Power
+
+ +
+
+ Reboot{{template "field-info" "Restart the device. The running configuration is preserved. Any unsaved changes (running config not yet copied to startup) will be lost on reboot."}} +
+ +
+ +
+ +
+
+ Shutdown{{template "field-info" "Power off the device. Physical access is required to bring it back online."}} +
+ +
+ +
+
+ +
+
Danger Zone
+
+ +
+
+ Reset running config{{template "field-info" "Replaces the running configuration with factory defaults. No reboot — takes effect immediately. Use the persistent notification to save to startup."}} +
+
+ +
+ +
+ +
+
+ Factory reset{{template "field-info" "Wipes all configuration and non-volatile storage, then reboots. The device may become unreachable on the network. This cannot be undone."}} +
+ +
+ +
+
+ +
+{{end}} diff --git a/src/webui/templates/pages/vpn.html b/src/webui/templates/pages/vpn.html new file mode 100644 index 00000000..e1f0fde6 --- /dev/null +++ b/src/webui/templates/pages/vpn.html @@ -0,0 +1,69 @@ +{{define "vpn.html"}}{{template "base" .}}{{end}} + +{{define "content"}} +{{if .Error}} +
{{.Error}}
+{{end}} + +{{if .Tunnels}} +{{range .Tunnels}} +
+
+ + {{.Name}}{{if .ListenPort}} :{{.ListenPort}}{{end}} + Configure → +
+ {{if .Addresses}} +
+ {{range .Addresses}}{{.}} {{end}} +
+ {{end}} + {{if .Peers}} +
+ + + + + + + + + + + + + {{range .Peers}} + + + + + + + + + {{end}} + +
Public KeyEndpointStatusLast HandshakeRXTX
{{.PublicKeyShort}}{{if .Endpoint}}{{.Endpoint}}{{else}}{{end}} {{.Status}}{{.LastHandshake}}{{.RxBytes}}{{.TxBytes}}
+
+ {{else}} +

No peers configured.

+ {{end}} +
+{{end}} +{{else if not .Error}} +
+
WireGuard + Configure → +
+

No WireGuard tunnels configured.

+
+{{end}} +{{end}} diff --git a/src/webui/templates/pages/wifi.html b/src/webui/templates/pages/wifi.html new file mode 100644 index 00000000..b6d8a3bd --- /dev/null +++ b/src/webui/templates/pages/wifi.html @@ -0,0 +1,153 @@ +{{define "wifi.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +{{if .Error}} +
{{.Error}}
+{{end}} + +{{if .Radios}} +{{range .Radios}} +
+
+
+
+ {{.Name}} +
+ Configure → +
+
+ + {{if .Manufacturer}}{{end}} + {{if .Driver}}{{end}} + {{if .Standards}}{{end}} + {{if .Band}}{{end}} + {{if .Channel}}{{end}} + {{if .Frequency}}{{end}} + {{if .Noise}}{{end}} + {{if .MaxAP}}{{end}} + {{if .Bands}} + + + + + {{end}} +
Manufacturer{{.Manufacturer}}
Driver{{.Driver}}
Standards{{.Standards}}
Active Band{{.Band}}
Channel{{.Channel}}
Frequency{{.Frequency}} MHz
Noise Floor{{.Noise}} dBm
Max APs{{.MaxAP}}
Supported Bands +
+ {{range .Bands}} +
+ {{.Name}} +
+ {{end}} +
+
+
+
+ + {{if .SurveySVG}} +
+
Channel Survey
+
+ {{.SurveySVG}} +
+
+ {{end}} +
+ +{{range .Interfaces}} +
+
+
+ + + {{.Name}} + {{if .SSID}}{{.SSID}}{{end}} + + {{if .Mode}}{{.Mode}}{{end}} +
+
+ + {{if eq .Mode "station"}} +
+ + {{if .Signal}}{{end}} + {{if .RxSpeed}}{{end}} + {{if .TxSpeed}}{{end}} +
Signal{{.Signal}}
RX Speed{{.RxSpeed}}
TX Speed{{.TxSpeed}}
+
+ {{end}} + + {{if and (eq .Mode "ap") .APClients}} +
+ + + + + + + + + + + + + + {{range .APClients}} + + + + + + + + + + {{end}} + +
MAC AddressSignalConnectedRX BytesTX BytesRX SpeedTX Speed
{{.MAC}}{{if .Signal}}{{.Signal}}{{else}}—{{end}}{{.ConnTime}}{{.RxBytes}}{{.TxBytes}}{{.RxSpeed}}{{.TxSpeed}}
+
+ {{else if eq .Mode "ap"}} +

No stations connected.

+ {{end}} + + {{if and (eq .Mode "station") .ScanResults}} +
+ + + + + + + + + + + + {{range .ScanResults}} + + + + + + + + {{end}} + +
SSIDBSSIDSignalChannelSecurity
{{if .SSID}}{{.SSID}}{{else}}Hidden{{end}}{{.BSSID}}{{if .Signal}}{{.Signal}}{{else}}—{{end}}{{.Channel}}{{.Encryption}}
+
+ {{end}} +
+{{end}} +{{end}} +{{else if not .Error}} +
+
WiFi
+

No WiFi radios detected.

+
+{{end}} +{{end}} diff --git a/src/webui/templates/pages/yang-tree.html b/src/webui/templates/pages/yang-tree.html new file mode 100644 index 00000000..3da28bdd --- /dev/null +++ b/src/webui/templates/pages/yang-tree.html @@ -0,0 +1,56 @@ +{{define "yang-tree.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +
+ {{if .Loading}} +
+ Downloading YANG schema from device… this takes a moment on first login. +
+ {{else}} +
+ + {{/* ── Left pane: navigation tree ───────────────────────── */}} +
+
{{if .ReadOnly}}View all{{else}}Edit all{{end}}
+
    +
  • +
    + + {{template "icon-yt-chevron"}} + / + +
      + {{template "yang-tree-nodes" .Nodes}} +
    +
    +
  • +
+
+ + {{/* ── Right pane: node detail ──────────────────────────── */}} +
+ {{if not .InitialPath}} +
+ {{template "icon-search-empty"}} +

Select a node to inspect.

+
+ {{end}} +
+ +
+ {{end}} + + {{if not .ReadOnly}}{{template "configure-toolbar" .}}{{end}} +
+{{end}} diff --git a/src/webui/vendor/github.com/google/go-cmp/LICENSE b/src/webui/vendor/github.com/google/go-cmp/LICENSE new file mode 100644 index 00000000..32017f8f --- /dev/null +++ b/src/webui/vendor/github.com/google/go-cmp/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2017 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/src/webui/vendor/github.com/google/go-cmp/cmp/compare.go b/src/webui/vendor/github.com/google/go-cmp/cmp/compare.go new file mode 100644 index 00000000..0f5b8a48 --- /dev/null +++ b/src/webui/vendor/github.com/google/go-cmp/cmp/compare.go @@ -0,0 +1,671 @@ +// Copyright 2017, The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package cmp determines equality of values. +// +// This package is intended to be a more powerful and safer alternative to +// [reflect.DeepEqual] for comparing whether two values are semantically equal. +// It is intended to only be used in tests, as performance is not a goal and +// it may panic if it cannot compare the values. Its propensity towards +// panicking means that its unsuitable for production environments where a +// spurious panic may be fatal. +// +// The primary features of cmp are: +// +// - When the default behavior of equality does not suit the test's needs, +// custom equality functions can override the equality operation. +// For example, an equality function may report floats as equal so long as +// they are within some tolerance of each other. +// +// - Types with an Equal method (e.g., [time.Time.Equal]) may use that method +// to determine equality. This allows package authors to determine +// the equality operation for the types that they define. +// +// - If no custom equality functions are used and no Equal method is defined, +// equality is determined by recursively comparing the primitive kinds on +// both values, much like [reflect.DeepEqual]. Unlike [reflect.DeepEqual], +// unexported fields are not compared by default; they result in panics +// unless suppressed by using an [Ignore] option +// (see [github.com/google/go-cmp/cmp/cmpopts.IgnoreUnexported]) +// or explicitly compared using the [Exporter] option. +package cmp + +import ( + "fmt" + "reflect" + "strings" + + "github.com/google/go-cmp/cmp/internal/diff" + "github.com/google/go-cmp/cmp/internal/function" + "github.com/google/go-cmp/cmp/internal/value" +) + +// TODO(≥go1.18): Use any instead of interface{}. + +// Equal reports whether x and y are equal by recursively applying the +// following rules in the given order to x and y and all of their sub-values: +// +// - Let S be the set of all [Ignore], [Transformer], and [Comparer] options that +// remain after applying all path filters, value filters, and type filters. +// If at least one [Ignore] exists in S, then the comparison is ignored. +// If the number of [Transformer] and [Comparer] options in S is non-zero, +// then Equal panics because it is ambiguous which option to use. +// If S contains a single [Transformer], then use that to transform +// the current values and recursively call Equal on the output values. +// If S contains a single [Comparer], then use that to compare the current values. +// Otherwise, evaluation proceeds to the next rule. +// +// - If the values have an Equal method of the form "(T) Equal(T) bool" or +// "(T) Equal(I) bool" where T is assignable to I, then use the result of +// x.Equal(y) even if x or y is nil. Otherwise, no such method exists and +// evaluation proceeds to the next rule. +// +// - Lastly, try to compare x and y based on their basic kinds. +// Simple kinds like booleans, integers, floats, complex numbers, strings, +// and channels are compared using the equivalent of the == operator in Go. +// Functions are only equal if they are both nil, otherwise they are unequal. +// +// Structs are equal if recursively calling Equal on all fields report equal. +// If a struct contains unexported fields, Equal panics unless an [Ignore] option +// (e.g., [github.com/google/go-cmp/cmp/cmpopts.IgnoreUnexported]) ignores that field +// or the [Exporter] option explicitly permits comparing the unexported field. +// +// Slices are equal if they are both nil or both non-nil, where recursively +// calling Equal on all non-ignored slice or array elements report equal. +// Empty non-nil slices and nil slices are not equal; to equate empty slices, +// consider using [github.com/google/go-cmp/cmp/cmpopts.EquateEmpty]. +// +// Maps are equal if they are both nil or both non-nil, where recursively +// calling Equal on all non-ignored map entries report equal. +// Map keys are equal according to the == operator. +// To use custom comparisons for map keys, consider using +// [github.com/google/go-cmp/cmp/cmpopts.SortMaps]. +// Empty non-nil maps and nil maps are not equal; to equate empty maps, +// consider using [github.com/google/go-cmp/cmp/cmpopts.EquateEmpty]. +// +// Pointers and interfaces are equal if they are both nil or both non-nil, +// where they have the same underlying concrete type and recursively +// calling Equal on the underlying values reports equal. +// +// Before recursing into a pointer, slice element, or map, the current path +// is checked to detect whether the address has already been visited. +// If there is a cycle, then the pointed at values are considered equal +// only if both addresses were previously visited in the same path step. +func Equal(x, y interface{}, opts ...Option) bool { + s := newState(opts) + s.compareAny(rootStep(x, y)) + return s.result.Equal() +} + +// Diff returns a human-readable report of the differences between two values: +// y - x. It returns an empty string if and only if Equal returns true for the +// same input values and options. +// +// The output is displayed as a literal in pseudo-Go syntax. +// At the start of each line, a "-" prefix indicates an element removed from x, +// a "+" prefix to indicates an element added from y, and the lack of a prefix +// indicates an element common to both x and y. If possible, the output +// uses fmt.Stringer.String or error.Error methods to produce more humanly +// readable outputs. In such cases, the string is prefixed with either an +// 's' or 'e' character, respectively, to indicate that the method was called. +// +// Do not depend on this output being stable. If you need the ability to +// programmatically interpret the difference, consider using a custom Reporter. +func Diff(x, y interface{}, opts ...Option) string { + s := newState(opts) + + // Optimization: If there are no other reporters, we can optimize for the + // common case where the result is equal (and thus no reported difference). + // This avoids the expensive construction of a difference tree. + if len(s.reporters) == 0 { + s.compareAny(rootStep(x, y)) + if s.result.Equal() { + return "" + } + s.result = diff.Result{} // Reset results + } + + r := new(defaultReporter) + s.reporters = append(s.reporters, reporter{r}) + s.compareAny(rootStep(x, y)) + d := r.String() + if (d == "") != s.result.Equal() { + panic("inconsistent difference and equality results") + } + return d +} + +// rootStep constructs the first path step. If x and y have differing types, +// then they are stored within an empty interface type. +func rootStep(x, y interface{}) PathStep { + vx := reflect.ValueOf(x) + vy := reflect.ValueOf(y) + + // If the inputs are different types, auto-wrap them in an empty interface + // so that they have the same parent type. + var t reflect.Type + if !vx.IsValid() || !vy.IsValid() || vx.Type() != vy.Type() { + t = anyType + if vx.IsValid() { + vvx := reflect.New(t).Elem() + vvx.Set(vx) + vx = vvx + } + if vy.IsValid() { + vvy := reflect.New(t).Elem() + vvy.Set(vy) + vy = vvy + } + } else { + t = vx.Type() + } + + return &pathStep{t, vx, vy} +} + +type state struct { + // These fields represent the "comparison state". + // Calling statelessCompare must not result in observable changes to these. + result diff.Result // The current result of comparison + curPath Path // The current path in the value tree + curPtrs pointerPath // The current set of visited pointers + reporters []reporter // Optional reporters + + // recChecker checks for infinite cycles applying the same set of + // transformers upon the output of itself. + recChecker recChecker + + // dynChecker triggers pseudo-random checks for option correctness. + // It is safe for statelessCompare to mutate this value. + dynChecker dynChecker + + // These fields, once set by processOption, will not change. + exporters []exporter // List of exporters for structs with unexported fields + opts Options // List of all fundamental and filter options +} + +func newState(opts []Option) *state { + // Always ensure a validator option exists to validate the inputs. + s := &state{opts: Options{validator{}}} + s.curPtrs.Init() + s.processOption(Options(opts)) + return s +} + +func (s *state) processOption(opt Option) { + switch opt := opt.(type) { + case nil: + case Options: + for _, o := range opt { + s.processOption(o) + } + case coreOption: + type filtered interface { + isFiltered() bool + } + if fopt, ok := opt.(filtered); ok && !fopt.isFiltered() { + panic(fmt.Sprintf("cannot use an unfiltered option: %v", opt)) + } + s.opts = append(s.opts, opt) + case exporter: + s.exporters = append(s.exporters, opt) + case reporter: + s.reporters = append(s.reporters, opt) + default: + panic(fmt.Sprintf("unknown option %T", opt)) + } +} + +// statelessCompare compares two values and returns the result. +// This function is stateless in that it does not alter the current result, +// or output to any registered reporters. +func (s *state) statelessCompare(step PathStep) diff.Result { + // We do not save and restore curPath and curPtrs because all of the + // compareX methods should properly push and pop from them. + // It is an implementation bug if the contents of the paths differ from + // when calling this function to when returning from it. + + oldResult, oldReporters := s.result, s.reporters + s.result = diff.Result{} // Reset result + s.reporters = nil // Remove reporters to avoid spurious printouts + s.compareAny(step) + res := s.result + s.result, s.reporters = oldResult, oldReporters + return res +} + +func (s *state) compareAny(step PathStep) { + // Update the path stack. + s.curPath.push(step) + defer s.curPath.pop() + for _, r := range s.reporters { + r.PushStep(step) + defer r.PopStep() + } + s.recChecker.Check(s.curPath) + + // Cycle-detection for slice elements (see NOTE in compareSlice). + t := step.Type() + vx, vy := step.Values() + if si, ok := step.(SliceIndex); ok && si.isSlice && vx.IsValid() && vy.IsValid() { + px, py := vx.Addr(), vy.Addr() + if eq, visited := s.curPtrs.Push(px, py); visited { + s.report(eq, reportByCycle) + return + } + defer s.curPtrs.Pop(px, py) + } + + // Rule 1: Check whether an option applies on this node in the value tree. + if s.tryOptions(t, vx, vy) { + return + } + + // Rule 2: Check whether the type has a valid Equal method. + if s.tryMethod(t, vx, vy) { + return + } + + // Rule 3: Compare based on the underlying kind. + switch t.Kind() { + case reflect.Bool: + s.report(vx.Bool() == vy.Bool(), 0) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + s.report(vx.Int() == vy.Int(), 0) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + s.report(vx.Uint() == vy.Uint(), 0) + case reflect.Float32, reflect.Float64: + s.report(vx.Float() == vy.Float(), 0) + case reflect.Complex64, reflect.Complex128: + s.report(vx.Complex() == vy.Complex(), 0) + case reflect.String: + s.report(vx.String() == vy.String(), 0) + case reflect.Chan, reflect.UnsafePointer: + s.report(vx.Pointer() == vy.Pointer(), 0) + case reflect.Func: + s.report(vx.IsNil() && vy.IsNil(), 0) + case reflect.Struct: + s.compareStruct(t, vx, vy) + case reflect.Slice, reflect.Array: + s.compareSlice(t, vx, vy) + case reflect.Map: + s.compareMap(t, vx, vy) + case reflect.Ptr: + s.comparePtr(t, vx, vy) + case reflect.Interface: + s.compareInterface(t, vx, vy) + default: + panic(fmt.Sprintf("%v kind not handled", t.Kind())) + } +} + +func (s *state) tryOptions(t reflect.Type, vx, vy reflect.Value) bool { + // Evaluate all filters and apply the remaining options. + if opt := s.opts.filter(s, t, vx, vy); opt != nil { + opt.apply(s, vx, vy) + return true + } + return false +} + +func (s *state) tryMethod(t reflect.Type, vx, vy reflect.Value) bool { + // Check if this type even has an Equal method. + m, ok := t.MethodByName("Equal") + if !ok || !function.IsType(m.Type, function.EqualAssignable) { + return false + } + + eq := s.callTTBFunc(m.Func, vx, vy) + s.report(eq, reportByMethod) + return true +} + +func (s *state) callTRFunc(f, v reflect.Value, step Transform) reflect.Value { + if !s.dynChecker.Next() { + return f.Call([]reflect.Value{v})[0] + } + + // Run the function twice and ensure that we get the same results back. + // We run in goroutines so that the race detector (if enabled) can detect + // unsafe mutations to the input. + c := make(chan reflect.Value) + go detectRaces(c, f, v) + got := <-c + want := f.Call([]reflect.Value{v})[0] + if step.vx, step.vy = got, want; !s.statelessCompare(step).Equal() { + // To avoid false-positives with non-reflexive equality operations, + // we sanity check whether a value is equal to itself. + if step.vx, step.vy = want, want; !s.statelessCompare(step).Equal() { + return want + } + panic(fmt.Sprintf("non-deterministic function detected: %s", function.NameOf(f))) + } + return want +} + +func (s *state) callTTBFunc(f, x, y reflect.Value) bool { + if !s.dynChecker.Next() { + return f.Call([]reflect.Value{x, y})[0].Bool() + } + + // Swapping the input arguments is sufficient to check that + // f is symmetric and deterministic. + // We run in goroutines so that the race detector (if enabled) can detect + // unsafe mutations to the input. + c := make(chan reflect.Value) + go detectRaces(c, f, y, x) + got := <-c + want := f.Call([]reflect.Value{x, y})[0].Bool() + if !got.IsValid() || got.Bool() != want { + panic(fmt.Sprintf("non-deterministic or non-symmetric function detected: %s", function.NameOf(f))) + } + return want +} + +func detectRaces(c chan<- reflect.Value, f reflect.Value, vs ...reflect.Value) { + var ret reflect.Value + defer func() { + recover() // Ignore panics, let the other call to f panic instead + c <- ret + }() + ret = f.Call(vs)[0] +} + +func (s *state) compareStruct(t reflect.Type, vx, vy reflect.Value) { + var addr bool + var vax, vay reflect.Value // Addressable versions of vx and vy + + var mayForce, mayForceInit bool + step := StructField{&structField{}} + for i := 0; i < t.NumField(); i++ { + step.typ = t.Field(i).Type + step.vx = vx.Field(i) + step.vy = vy.Field(i) + step.name = t.Field(i).Name + step.idx = i + step.unexported = !isExported(step.name) + if step.unexported { + if step.name == "_" { + continue + } + // Defer checking of unexported fields until later to give an + // Ignore a chance to ignore the field. + if !vax.IsValid() || !vay.IsValid() { + // For retrieveUnexportedField to work, the parent struct must + // be addressable. Create a new copy of the values if + // necessary to make them addressable. + addr = vx.CanAddr() || vy.CanAddr() + vax = makeAddressable(vx) + vay = makeAddressable(vy) + } + if !mayForceInit { + for _, xf := range s.exporters { + mayForce = mayForce || xf(t) + } + mayForceInit = true + } + step.mayForce = mayForce + step.paddr = addr + step.pvx = vax + step.pvy = vay + step.field = t.Field(i) + } + s.compareAny(step) + } +} + +func (s *state) compareSlice(t reflect.Type, vx, vy reflect.Value) { + isSlice := t.Kind() == reflect.Slice + if isSlice && (vx.IsNil() || vy.IsNil()) { + s.report(vx.IsNil() && vy.IsNil(), 0) + return + } + + // NOTE: It is incorrect to call curPtrs.Push on the slice header pointer + // since slices represents a list of pointers, rather than a single pointer. + // The pointer checking logic must be handled on a per-element basis + // in compareAny. + // + // A slice header (see reflect.SliceHeader) in Go is a tuple of a starting + // pointer P, a length N, and a capacity C. Supposing each slice element has + // a memory size of M, then the slice is equivalent to the list of pointers: + // [P+i*M for i in range(N)] + // + // For example, v[:0] and v[:1] are slices with the same starting pointer, + // but they are clearly different values. Using the slice pointer alone + // violates the assumption that equal pointers implies equal values. + + step := SliceIndex{&sliceIndex{pathStep: pathStep{typ: t.Elem()}, isSlice: isSlice}} + withIndexes := func(ix, iy int) SliceIndex { + if ix >= 0 { + step.vx, step.xkey = vx.Index(ix), ix + } else { + step.vx, step.xkey = reflect.Value{}, -1 + } + if iy >= 0 { + step.vy, step.ykey = vy.Index(iy), iy + } else { + step.vy, step.ykey = reflect.Value{}, -1 + } + return step + } + + // Ignore options are able to ignore missing elements in a slice. + // However, detecting these reliably requires an optimal differencing + // algorithm, for which diff.Difference is not. + // + // Instead, we first iterate through both slices to detect which elements + // would be ignored if standing alone. The index of non-discarded elements + // are stored in a separate slice, which diffing is then performed on. + var indexesX, indexesY []int + var ignoredX, ignoredY []bool + for ix := 0; ix < vx.Len(); ix++ { + ignored := s.statelessCompare(withIndexes(ix, -1)).NumDiff == 0 + if !ignored { + indexesX = append(indexesX, ix) + } + ignoredX = append(ignoredX, ignored) + } + for iy := 0; iy < vy.Len(); iy++ { + ignored := s.statelessCompare(withIndexes(-1, iy)).NumDiff == 0 + if !ignored { + indexesY = append(indexesY, iy) + } + ignoredY = append(ignoredY, ignored) + } + + // Compute an edit-script for slices vx and vy (excluding ignored elements). + edits := diff.Difference(len(indexesX), len(indexesY), func(ix, iy int) diff.Result { + return s.statelessCompare(withIndexes(indexesX[ix], indexesY[iy])) + }) + + // Replay the ignore-scripts and the edit-script. + var ix, iy int + for ix < vx.Len() || iy < vy.Len() { + var e diff.EditType + switch { + case ix < len(ignoredX) && ignoredX[ix]: + e = diff.UniqueX + case iy < len(ignoredY) && ignoredY[iy]: + e = diff.UniqueY + default: + e, edits = edits[0], edits[1:] + } + switch e { + case diff.UniqueX: + s.compareAny(withIndexes(ix, -1)) + ix++ + case diff.UniqueY: + s.compareAny(withIndexes(-1, iy)) + iy++ + default: + s.compareAny(withIndexes(ix, iy)) + ix++ + iy++ + } + } +} + +func (s *state) compareMap(t reflect.Type, vx, vy reflect.Value) { + if vx.IsNil() || vy.IsNil() { + s.report(vx.IsNil() && vy.IsNil(), 0) + return + } + + // Cycle-detection for maps. + if eq, visited := s.curPtrs.Push(vx, vy); visited { + s.report(eq, reportByCycle) + return + } + defer s.curPtrs.Pop(vx, vy) + + // We combine and sort the two map keys so that we can perform the + // comparisons in a deterministic order. + step := MapIndex{&mapIndex{pathStep: pathStep{typ: t.Elem()}}} + for _, k := range value.SortKeys(append(vx.MapKeys(), vy.MapKeys()...)) { + step.vx = vx.MapIndex(k) + step.vy = vy.MapIndex(k) + step.key = k + if !step.vx.IsValid() && !step.vy.IsValid() { + // It is possible for both vx and vy to be invalid if the + // key contained a NaN value in it. + // + // Even with the ability to retrieve NaN keys in Go 1.12, + // there still isn't a sensible way to compare the values since + // a NaN key may map to multiple unordered values. + // The most reasonable way to compare NaNs would be to compare the + // set of values. However, this is impossible to do efficiently + // since set equality is provably an O(n^2) operation given only + // an Equal function. If we had a Less function or Hash function, + // this could be done in O(n*log(n)) or O(n), respectively. + // + // Rather than adding complex logic to deal with NaNs, make it + // the user's responsibility to compare such obscure maps. + const help = "consider providing a Comparer to compare the map" + panic(fmt.Sprintf("%#v has map key with NaNs\n%s", s.curPath, help)) + } + s.compareAny(step) + } +} + +func (s *state) comparePtr(t reflect.Type, vx, vy reflect.Value) { + if vx.IsNil() || vy.IsNil() { + s.report(vx.IsNil() && vy.IsNil(), 0) + return + } + + // Cycle-detection for pointers. + if eq, visited := s.curPtrs.Push(vx, vy); visited { + s.report(eq, reportByCycle) + return + } + defer s.curPtrs.Pop(vx, vy) + + vx, vy = vx.Elem(), vy.Elem() + s.compareAny(Indirect{&indirect{pathStep{t.Elem(), vx, vy}}}) +} + +func (s *state) compareInterface(t reflect.Type, vx, vy reflect.Value) { + if vx.IsNil() || vy.IsNil() { + s.report(vx.IsNil() && vy.IsNil(), 0) + return + } + vx, vy = vx.Elem(), vy.Elem() + if vx.Type() != vy.Type() { + s.report(false, 0) + return + } + s.compareAny(TypeAssertion{&typeAssertion{pathStep{vx.Type(), vx, vy}}}) +} + +func (s *state) report(eq bool, rf resultFlags) { + if rf&reportByIgnore == 0 { + if eq { + s.result.NumSame++ + rf |= reportEqual + } else { + s.result.NumDiff++ + rf |= reportUnequal + } + } + for _, r := range s.reporters { + r.Report(Result{flags: rf}) + } +} + +// recChecker tracks the state needed to periodically perform checks that +// user provided transformers are not stuck in an infinitely recursive cycle. +type recChecker struct{ next int } + +// Check scans the Path for any recursive transformers and panics when any +// recursive transformers are detected. Note that the presence of a +// recursive Transformer does not necessarily imply an infinite cycle. +// As such, this check only activates after some minimal number of path steps. +func (rc *recChecker) Check(p Path) { + const minLen = 1 << 16 + if rc.next == 0 { + rc.next = minLen + } + if len(p) < rc.next { + return + } + rc.next <<= 1 + + // Check whether the same transformer has appeared at least twice. + var ss []string + m := map[Option]int{} + for _, ps := range p { + if t, ok := ps.(Transform); ok { + t := t.Option() + if m[t] == 1 { // Transformer was used exactly once before + tf := t.(*transformer).fnc.Type() + ss = append(ss, fmt.Sprintf("%v: %v => %v", t, tf.In(0), tf.Out(0))) + } + m[t]++ + } + } + if len(ss) > 0 { + const warning = "recursive set of Transformers detected" + const help = "consider using cmpopts.AcyclicTransformer" + set := strings.Join(ss, "\n\t") + panic(fmt.Sprintf("%s:\n\t%s\n%s", warning, set, help)) + } +} + +// dynChecker tracks the state needed to periodically perform checks that +// user provided functions are symmetric and deterministic. +// The zero value is safe for immediate use. +type dynChecker struct{ curr, next int } + +// Next increments the state and reports whether a check should be performed. +// +// Checks occur every Nth function call, where N is a triangular number: +// +// 0 1 3 6 10 15 21 28 36 45 55 66 78 91 105 120 136 153 171 190 ... +// +// See https://en.wikipedia.org/wiki/Triangular_number +// +// This sequence ensures that the cost of checks drops significantly as +// the number of functions calls grows larger. +func (dc *dynChecker) Next() bool { + ok := dc.curr == dc.next + if ok { + dc.curr = 0 + dc.next++ + } + dc.curr++ + return ok +} + +// makeAddressable returns a value that is always addressable. +// It returns the input verbatim if it is already addressable, +// otherwise it creates a new value and returns an addressable copy. +func makeAddressable(v reflect.Value) reflect.Value { + if v.CanAddr() { + return v + } + vc := reflect.New(v.Type()).Elem() + vc.Set(v) + return vc +} diff --git a/src/webui/vendor/github.com/google/go-cmp/cmp/export.go b/src/webui/vendor/github.com/google/go-cmp/cmp/export.go new file mode 100644 index 00000000..29f82fe6 --- /dev/null +++ b/src/webui/vendor/github.com/google/go-cmp/cmp/export.go @@ -0,0 +1,31 @@ +// Copyright 2017, The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package cmp + +import ( + "reflect" + "unsafe" +) + +// retrieveUnexportedField uses unsafe to forcibly retrieve any field from +// a struct such that the value has read-write permissions. +// +// The parent struct, v, must be addressable, while f must be a StructField +// describing the field to retrieve. If addr is false, +// then the returned value will be shallowed copied to be non-addressable. +func retrieveUnexportedField(v reflect.Value, f reflect.StructField, addr bool) reflect.Value { + ve := reflect.NewAt(f.Type, unsafe.Pointer(uintptr(unsafe.Pointer(v.UnsafeAddr()))+f.Offset)).Elem() + if !addr { + // A field is addressable if and only if the struct is addressable. + // If the original parent value was not addressable, shallow copy the + // value to make it non-addressable to avoid leaking an implementation + // detail of how forcibly exporting a field works. + if ve.Kind() == reflect.Interface && ve.IsNil() { + return reflect.Zero(f.Type) + } + return reflect.ValueOf(ve.Interface()).Convert(f.Type) + } + return ve +} diff --git a/src/webui/vendor/github.com/google/go-cmp/cmp/internal/diff/debug_disable.go b/src/webui/vendor/github.com/google/go-cmp/cmp/internal/diff/debug_disable.go new file mode 100644 index 00000000..36062a60 --- /dev/null +++ b/src/webui/vendor/github.com/google/go-cmp/cmp/internal/diff/debug_disable.go @@ -0,0 +1,18 @@ +// Copyright 2017, The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build !cmp_debug +// +build !cmp_debug + +package diff + +var debug debugger + +type debugger struct{} + +func (debugger) Begin(_, _ int, f EqualFunc, _, _ *EditScript) EqualFunc { + return f +} +func (debugger) Update() {} +func (debugger) Finish() {} diff --git a/src/webui/vendor/github.com/google/go-cmp/cmp/internal/diff/debug_enable.go b/src/webui/vendor/github.com/google/go-cmp/cmp/internal/diff/debug_enable.go new file mode 100644 index 00000000..a3b97a1a --- /dev/null +++ b/src/webui/vendor/github.com/google/go-cmp/cmp/internal/diff/debug_enable.go @@ -0,0 +1,123 @@ +// Copyright 2017, The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build cmp_debug +// +build cmp_debug + +package diff + +import ( + "fmt" + "strings" + "sync" + "time" +) + +// The algorithm can be seen running in real-time by enabling debugging: +// go test -tags=cmp_debug -v +// +// Example output: +// === RUN TestDifference/#34 +// ┌───────────────────────────────┐ +// │ \ · · · · · · · · · · · · · · │ +// │ · # · · · · · · · · · · · · · │ +// │ · \ · · · · · · · · · · · · · │ +// │ · · \ · · · · · · · · · · · · │ +// │ · · · X # · · · · · · · · · · │ +// │ · · · # \ · · · · · · · · · · │ +// │ · · · · · # # · · · · · · · · │ +// │ · · · · · # \ · · · · · · · · │ +// │ · · · · · · · \ · · · · · · · │ +// │ · · · · · · · · \ · · · · · · │ +// │ · · · · · · · · · \ · · · · · │ +// │ · · · · · · · · · · \ · · # · │ +// │ · · · · · · · · · · · \ # # · │ +// │ · · · · · · · · · · · # # # · │ +// │ · · · · · · · · · · # # # # · │ +// │ · · · · · · · · · # # # # # · │ +// │ · · · · · · · · · · · · · · \ │ +// └───────────────────────────────┘ +// [.Y..M.XY......YXYXY.|] +// +// The grid represents the edit-graph where the horizontal axis represents +// list X and the vertical axis represents list Y. The start of the two lists +// is the top-left, while the ends are the bottom-right. The '·' represents +// an unexplored node in the graph. The '\' indicates that the two symbols +// from list X and Y are equal. The 'X' indicates that two symbols are similar +// (but not exactly equal) to each other. The '#' indicates that the two symbols +// are different (and not similar). The algorithm traverses this graph trying to +// make the paths starting in the top-left and the bottom-right connect. +// +// The series of '.', 'X', 'Y', and 'M' characters at the bottom represents +// the currently established path from the forward and reverse searches, +// separated by a '|' character. + +const ( + updateDelay = 100 * time.Millisecond + finishDelay = 500 * time.Millisecond + ansiTerminal = true // ANSI escape codes used to move terminal cursor +) + +var debug debugger + +type debugger struct { + sync.Mutex + p1, p2 EditScript + fwdPath, revPath *EditScript + grid []byte + lines int +} + +func (dbg *debugger) Begin(nx, ny int, f EqualFunc, p1, p2 *EditScript) EqualFunc { + dbg.Lock() + dbg.fwdPath, dbg.revPath = p1, p2 + top := "┌─" + strings.Repeat("──", nx) + "┐\n" + row := "│ " + strings.Repeat("· ", nx) + "│\n" + btm := "└─" + strings.Repeat("──", nx) + "┘\n" + dbg.grid = []byte(top + strings.Repeat(row, ny) + btm) + dbg.lines = strings.Count(dbg.String(), "\n") + fmt.Print(dbg) + + // Wrap the EqualFunc so that we can intercept each result. + return func(ix, iy int) (r Result) { + cell := dbg.grid[len(top)+iy*len(row):][len("│ ")+len("· ")*ix:][:len("·")] + for i := range cell { + cell[i] = 0 // Zero out the multiple bytes of UTF-8 middle-dot + } + switch r = f(ix, iy); { + case r.Equal(): + cell[0] = '\\' + case r.Similar(): + cell[0] = 'X' + default: + cell[0] = '#' + } + return + } +} + +func (dbg *debugger) Update() { + dbg.print(updateDelay) +} + +func (dbg *debugger) Finish() { + dbg.print(finishDelay) + dbg.Unlock() +} + +func (dbg *debugger) String() string { + dbg.p1, dbg.p2 = *dbg.fwdPath, dbg.p2[:0] + for i := len(*dbg.revPath) - 1; i >= 0; i-- { + dbg.p2 = append(dbg.p2, (*dbg.revPath)[i]) + } + return fmt.Sprintf("%s[%v|%v]\n\n", dbg.grid, dbg.p1, dbg.p2) +} + +func (dbg *debugger) print(d time.Duration) { + if ansiTerminal { + fmt.Printf("\x1b[%dA", dbg.lines) // Reset terminal cursor + } + fmt.Print(dbg) + time.Sleep(d) +} diff --git a/src/webui/vendor/github.com/google/go-cmp/cmp/internal/diff/diff.go b/src/webui/vendor/github.com/google/go-cmp/cmp/internal/diff/diff.go new file mode 100644 index 00000000..a248e543 --- /dev/null +++ b/src/webui/vendor/github.com/google/go-cmp/cmp/internal/diff/diff.go @@ -0,0 +1,402 @@ +// Copyright 2017, The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package diff implements an algorithm for producing edit-scripts. +// The edit-script is a sequence of operations needed to transform one list +// of symbols into another (or vice-versa). The edits allowed are insertions, +// deletions, and modifications. The summation of all edits is called the +// Levenshtein distance as this problem is well-known in computer science. +// +// This package prioritizes performance over accuracy. That is, the run time +// is more important than obtaining a minimal Levenshtein distance. +package diff + +import ( + "math/rand" + "time" + + "github.com/google/go-cmp/cmp/internal/flags" +) + +// EditType represents a single operation within an edit-script. +type EditType uint8 + +const ( + // Identity indicates that a symbol pair is identical in both list X and Y. + Identity EditType = iota + // UniqueX indicates that a symbol only exists in X and not Y. + UniqueX + // UniqueY indicates that a symbol only exists in Y and not X. + UniqueY + // Modified indicates that a symbol pair is a modification of each other. + Modified +) + +// EditScript represents the series of differences between two lists. +type EditScript []EditType + +// String returns a human-readable string representing the edit-script where +// Identity, UniqueX, UniqueY, and Modified are represented by the +// '.', 'X', 'Y', and 'M' characters, respectively. +func (es EditScript) String() string { + b := make([]byte, len(es)) + for i, e := range es { + switch e { + case Identity: + b[i] = '.' + case UniqueX: + b[i] = 'X' + case UniqueY: + b[i] = 'Y' + case Modified: + b[i] = 'M' + default: + panic("invalid edit-type") + } + } + return string(b) +} + +// stats returns a histogram of the number of each type of edit operation. +func (es EditScript) stats() (s struct{ NI, NX, NY, NM int }) { + for _, e := range es { + switch e { + case Identity: + s.NI++ + case UniqueX: + s.NX++ + case UniqueY: + s.NY++ + case Modified: + s.NM++ + default: + panic("invalid edit-type") + } + } + return +} + +// Dist is the Levenshtein distance and is guaranteed to be 0 if and only if +// lists X and Y are equal. +func (es EditScript) Dist() int { return len(es) - es.stats().NI } + +// LenX is the length of the X list. +func (es EditScript) LenX() int { return len(es) - es.stats().NY } + +// LenY is the length of the Y list. +func (es EditScript) LenY() int { return len(es) - es.stats().NX } + +// EqualFunc reports whether the symbols at indexes ix and iy are equal. +// When called by Difference, the index is guaranteed to be within nx and ny. +type EqualFunc func(ix int, iy int) Result + +// Result is the result of comparison. +// NumSame is the number of sub-elements that are equal. +// NumDiff is the number of sub-elements that are not equal. +type Result struct{ NumSame, NumDiff int } + +// BoolResult returns a Result that is either Equal or not Equal. +func BoolResult(b bool) Result { + if b { + return Result{NumSame: 1} // Equal, Similar + } else { + return Result{NumDiff: 2} // Not Equal, not Similar + } +} + +// Equal indicates whether the symbols are equal. Two symbols are equal +// if and only if NumDiff == 0. If Equal, then they are also Similar. +func (r Result) Equal() bool { return r.NumDiff == 0 } + +// Similar indicates whether two symbols are similar and may be represented +// by using the Modified type. As a special case, we consider binary comparisons +// (i.e., those that return Result{1, 0} or Result{0, 1}) to be similar. +// +// The exact ratio of NumSame to NumDiff to determine similarity may change. +func (r Result) Similar() bool { + // Use NumSame+1 to offset NumSame so that binary comparisons are similar. + return r.NumSame+1 >= r.NumDiff +} + +var randBool = rand.New(rand.NewSource(time.Now().Unix())).Intn(2) == 0 + +// Difference reports whether two lists of lengths nx and ny are equal +// given the definition of equality provided as f. +// +// This function returns an edit-script, which is a sequence of operations +// needed to convert one list into the other. The following invariants for +// the edit-script are maintained: +// - eq == (es.Dist()==0) +// - nx == es.LenX() +// - ny == es.LenY() +// +// This algorithm is not guaranteed to be an optimal solution (i.e., one that +// produces an edit-script with a minimal Levenshtein distance). This algorithm +// favors performance over optimality. The exact output is not guaranteed to +// be stable and may change over time. +func Difference(nx, ny int, f EqualFunc) (es EditScript) { + // This algorithm is based on traversing what is known as an "edit-graph". + // See Figure 1 from "An O(ND) Difference Algorithm and Its Variations" + // by Eugene W. Myers. Since D can be as large as N itself, this is + // effectively O(N^2). Unlike the algorithm from that paper, we are not + // interested in the optimal path, but at least some "decent" path. + // + // For example, let X and Y be lists of symbols: + // X = [A B C A B B A] + // Y = [C B A B A C] + // + // The edit-graph can be drawn as the following: + // A B C A B B A + // ┌─────────────┐ + // C │_|_|\|_|_|_|_│ 0 + // B │_|\|_|_|\|\|_│ 1 + // A │\|_|_|\|_|_|\│ 2 + // B │_|\|_|_|\|\|_│ 3 + // A │\|_|_|\|_|_|\│ 4 + // C │ | |\| | | | │ 5 + // └─────────────┘ 6 + // 0 1 2 3 4 5 6 7 + // + // List X is written along the horizontal axis, while list Y is written + // along the vertical axis. At any point on this grid, if the symbol in + // list X matches the corresponding symbol in list Y, then a '\' is drawn. + // The goal of any minimal edit-script algorithm is to find a path from the + // top-left corner to the bottom-right corner, while traveling through the + // fewest horizontal or vertical edges. + // A horizontal edge is equivalent to inserting a symbol from list X. + // A vertical edge is equivalent to inserting a symbol from list Y. + // A diagonal edge is equivalent to a matching symbol between both X and Y. + + // Invariants: + // - 0 ≤ fwdPath.X ≤ (fwdFrontier.X, revFrontier.X) ≤ revPath.X ≤ nx + // - 0 ≤ fwdPath.Y ≤ (fwdFrontier.Y, revFrontier.Y) ≤ revPath.Y ≤ ny + // + // In general: + // - fwdFrontier.X < revFrontier.X + // - fwdFrontier.Y < revFrontier.Y + // + // Unless, it is time for the algorithm to terminate. + fwdPath := path{+1, point{0, 0}, make(EditScript, 0, (nx+ny)/2)} + revPath := path{-1, point{nx, ny}, make(EditScript, 0)} + fwdFrontier := fwdPath.point // Forward search frontier + revFrontier := revPath.point // Reverse search frontier + + // Search budget bounds the cost of searching for better paths. + // The longest sequence of non-matching symbols that can be tolerated is + // approximately the square-root of the search budget. + searchBudget := 4 * (nx + ny) // O(n) + + // Running the tests with the "cmp_debug" build tag prints a visualization + // of the algorithm running in real-time. This is educational for + // understanding how the algorithm works. See debug_enable.go. + f = debug.Begin(nx, ny, f, &fwdPath.es, &revPath.es) + + // The algorithm below is a greedy, meet-in-the-middle algorithm for + // computing sub-optimal edit-scripts between two lists. + // + // The algorithm is approximately as follows: + // - Searching for differences switches back-and-forth between + // a search that starts at the beginning (the top-left corner), and + // a search that starts at the end (the bottom-right corner). + // The goal of the search is connect with the search + // from the opposite corner. + // - As we search, we build a path in a greedy manner, + // where the first match seen is added to the path (this is sub-optimal, + // but provides a decent result in practice). When matches are found, + // we try the next pair of symbols in the lists and follow all matches + // as far as possible. + // - When searching for matches, we search along a diagonal going through + // through the "frontier" point. If no matches are found, + // we advance the frontier towards the opposite corner. + // - This algorithm terminates when either the X coordinates or the + // Y coordinates of the forward and reverse frontier points ever intersect. + + // This algorithm is correct even if searching only in the forward direction + // or in the reverse direction. We do both because it is commonly observed + // that two lists commonly differ because elements were added to the front + // or end of the other list. + // + // Non-deterministically start with either the forward or reverse direction + // to introduce some deliberate instability so that we have the flexibility + // to change this algorithm in the future. + if flags.Deterministic || randBool { + goto forwardSearch + } else { + goto reverseSearch + } + +forwardSearch: + { + // Forward search from the beginning. + if fwdFrontier.X >= revFrontier.X || fwdFrontier.Y >= revFrontier.Y || searchBudget == 0 { + goto finishSearch + } + for stop1, stop2, i := false, false, 0; !(stop1 && stop2) && searchBudget > 0; i++ { + // Search in a diagonal pattern for a match. + z := zigzag(i) + p := point{fwdFrontier.X + z, fwdFrontier.Y - z} + switch { + case p.X >= revPath.X || p.Y < fwdPath.Y: + stop1 = true // Hit top-right corner + case p.Y >= revPath.Y || p.X < fwdPath.X: + stop2 = true // Hit bottom-left corner + case f(p.X, p.Y).Equal(): + // Match found, so connect the path to this point. + fwdPath.connect(p, f) + fwdPath.append(Identity) + // Follow sequence of matches as far as possible. + for fwdPath.X < revPath.X && fwdPath.Y < revPath.Y { + if !f(fwdPath.X, fwdPath.Y).Equal() { + break + } + fwdPath.append(Identity) + } + fwdFrontier = fwdPath.point + stop1, stop2 = true, true + default: + searchBudget-- // Match not found + } + debug.Update() + } + // Advance the frontier towards reverse point. + if revPath.X-fwdFrontier.X >= revPath.Y-fwdFrontier.Y { + fwdFrontier.X++ + } else { + fwdFrontier.Y++ + } + goto reverseSearch + } + +reverseSearch: + { + // Reverse search from the end. + if fwdFrontier.X >= revFrontier.X || fwdFrontier.Y >= revFrontier.Y || searchBudget == 0 { + goto finishSearch + } + for stop1, stop2, i := false, false, 0; !(stop1 && stop2) && searchBudget > 0; i++ { + // Search in a diagonal pattern for a match. + z := zigzag(i) + p := point{revFrontier.X - z, revFrontier.Y + z} + switch { + case fwdPath.X >= p.X || revPath.Y < p.Y: + stop1 = true // Hit bottom-left corner + case fwdPath.Y >= p.Y || revPath.X < p.X: + stop2 = true // Hit top-right corner + case f(p.X-1, p.Y-1).Equal(): + // Match found, so connect the path to this point. + revPath.connect(p, f) + revPath.append(Identity) + // Follow sequence of matches as far as possible. + for fwdPath.X < revPath.X && fwdPath.Y < revPath.Y { + if !f(revPath.X-1, revPath.Y-1).Equal() { + break + } + revPath.append(Identity) + } + revFrontier = revPath.point + stop1, stop2 = true, true + default: + searchBudget-- // Match not found + } + debug.Update() + } + // Advance the frontier towards forward point. + if revFrontier.X-fwdPath.X >= revFrontier.Y-fwdPath.Y { + revFrontier.X-- + } else { + revFrontier.Y-- + } + goto forwardSearch + } + +finishSearch: + // Join the forward and reverse paths and then append the reverse path. + fwdPath.connect(revPath.point, f) + for i := len(revPath.es) - 1; i >= 0; i-- { + t := revPath.es[i] + revPath.es = revPath.es[:i] + fwdPath.append(t) + } + debug.Finish() + return fwdPath.es +} + +type path struct { + dir int // +1 if forward, -1 if reverse + point // Leading point of the EditScript path + es EditScript +} + +// connect appends any necessary Identity, Modified, UniqueX, or UniqueY types +// to the edit-script to connect p.point to dst. +func (p *path) connect(dst point, f EqualFunc) { + if p.dir > 0 { + // Connect in forward direction. + for dst.X > p.X && dst.Y > p.Y { + switch r := f(p.X, p.Y); { + case r.Equal(): + p.append(Identity) + case r.Similar(): + p.append(Modified) + case dst.X-p.X >= dst.Y-p.Y: + p.append(UniqueX) + default: + p.append(UniqueY) + } + } + for dst.X > p.X { + p.append(UniqueX) + } + for dst.Y > p.Y { + p.append(UniqueY) + } + } else { + // Connect in reverse direction. + for p.X > dst.X && p.Y > dst.Y { + switch r := f(p.X-1, p.Y-1); { + case r.Equal(): + p.append(Identity) + case r.Similar(): + p.append(Modified) + case p.Y-dst.Y >= p.X-dst.X: + p.append(UniqueY) + default: + p.append(UniqueX) + } + } + for p.X > dst.X { + p.append(UniqueX) + } + for p.Y > dst.Y { + p.append(UniqueY) + } + } +} + +func (p *path) append(t EditType) { + p.es = append(p.es, t) + switch t { + case Identity, Modified: + p.add(p.dir, p.dir) + case UniqueX: + p.add(p.dir, 0) + case UniqueY: + p.add(0, p.dir) + } + debug.Update() +} + +type point struct{ X, Y int } + +func (p *point) add(dx, dy int) { p.X += dx; p.Y += dy } + +// zigzag maps a consecutive sequence of integers to a zig-zag sequence. +// +// [0 1 2 3 4 5 ...] => [0 -1 +1 -2 +2 ...] +func zigzag(x int) int { + if x&1 != 0 { + x = ^x + } + return x >> 1 +} diff --git a/src/webui/vendor/github.com/google/go-cmp/cmp/internal/flags/flags.go b/src/webui/vendor/github.com/google/go-cmp/cmp/internal/flags/flags.go new file mode 100644 index 00000000..d8e459c9 --- /dev/null +++ b/src/webui/vendor/github.com/google/go-cmp/cmp/internal/flags/flags.go @@ -0,0 +1,9 @@ +// Copyright 2019, The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package flags + +// Deterministic controls whether the output of Diff should be deterministic. +// This is only used for testing. +var Deterministic bool diff --git a/src/webui/vendor/github.com/google/go-cmp/cmp/internal/function/func.go b/src/webui/vendor/github.com/google/go-cmp/cmp/internal/function/func.go new file mode 100644 index 00000000..def01a6b --- /dev/null +++ b/src/webui/vendor/github.com/google/go-cmp/cmp/internal/function/func.go @@ -0,0 +1,106 @@ +// Copyright 2017, The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package function provides functionality for identifying function types. +package function + +import ( + "reflect" + "regexp" + "runtime" + "strings" +) + +type funcType int + +const ( + _ funcType = iota + + tbFunc // func(T) bool + ttbFunc // func(T, T) bool + ttiFunc // func(T, T) int + trbFunc // func(T, R) bool + tibFunc // func(T, I) bool + trFunc // func(T) R + + Equal = ttbFunc // func(T, T) bool + EqualAssignable = tibFunc // func(T, I) bool; encapsulates func(T, T) bool + Transformer = trFunc // func(T) R + ValueFilter = ttbFunc // func(T, T) bool + Less = ttbFunc // func(T, T) bool + Compare = ttiFunc // func(T, T) int + ValuePredicate = tbFunc // func(T) bool + KeyValuePredicate = trbFunc // func(T, R) bool +) + +var boolType = reflect.TypeOf(true) +var intType = reflect.TypeOf(0) + +// IsType reports whether the reflect.Type is of the specified function type. +func IsType(t reflect.Type, ft funcType) bool { + if t == nil || t.Kind() != reflect.Func || t.IsVariadic() { + return false + } + ni, no := t.NumIn(), t.NumOut() + switch ft { + case tbFunc: // func(T) bool + if ni == 1 && no == 1 && t.Out(0) == boolType { + return true + } + case ttbFunc: // func(T, T) bool + if ni == 2 && no == 1 && t.In(0) == t.In(1) && t.Out(0) == boolType { + return true + } + case ttiFunc: // func(T, T) int + if ni == 2 && no == 1 && t.In(0) == t.In(1) && t.Out(0) == intType { + return true + } + case trbFunc: // func(T, R) bool + if ni == 2 && no == 1 && t.Out(0) == boolType { + return true + } + case tibFunc: // func(T, I) bool + if ni == 2 && no == 1 && t.In(0).AssignableTo(t.In(1)) && t.Out(0) == boolType { + return true + } + case trFunc: // func(T) R + if ni == 1 && no == 1 { + return true + } + } + return false +} + +var lastIdentRx = regexp.MustCompile(`[_\p{L}][_\p{L}\p{N}]*$`) + +// NameOf returns the name of the function value. +func NameOf(v reflect.Value) string { + fnc := runtime.FuncForPC(v.Pointer()) + if fnc == nil { + return "" + } + fullName := fnc.Name() // e.g., "long/path/name/mypkg.(*MyType).(long/path/name/mypkg.myMethod)-fm" + + // Method closures have a "-fm" suffix. + fullName = strings.TrimSuffix(fullName, "-fm") + + var name string + for len(fullName) > 0 { + inParen := strings.HasSuffix(fullName, ")") + fullName = strings.TrimSuffix(fullName, ")") + + s := lastIdentRx.FindString(fullName) + if s == "" { + break + } + name = s + "." + name + fullName = strings.TrimSuffix(fullName, s) + + if i := strings.LastIndexByte(fullName, '('); inParen && i >= 0 { + fullName = fullName[:i] + } + fullName = strings.TrimSuffix(fullName, ".") + } + return strings.TrimSuffix(name, ".") +} diff --git a/src/webui/vendor/github.com/google/go-cmp/cmp/internal/value/name.go b/src/webui/vendor/github.com/google/go-cmp/cmp/internal/value/name.go new file mode 100644 index 00000000..7b498bb2 --- /dev/null +++ b/src/webui/vendor/github.com/google/go-cmp/cmp/internal/value/name.go @@ -0,0 +1,164 @@ +// Copyright 2020, The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package value + +import ( + "reflect" + "strconv" +) + +var anyType = reflect.TypeOf((*interface{})(nil)).Elem() + +// TypeString is nearly identical to reflect.Type.String, +// but has an additional option to specify that full type names be used. +func TypeString(t reflect.Type, qualified bool) string { + return string(appendTypeName(nil, t, qualified, false)) +} + +func appendTypeName(b []byte, t reflect.Type, qualified, elideFunc bool) []byte { + // BUG: Go reflection provides no way to disambiguate two named types + // of the same name and within the same package, + // but declared within the namespace of different functions. + + // Use the "any" alias instead of "interface{}" for better readability. + if t == anyType { + return append(b, "any"...) + } + + // Named type. + if t.Name() != "" { + if qualified && t.PkgPath() != "" { + b = append(b, '"') + b = append(b, t.PkgPath()...) + b = append(b, '"') + b = append(b, '.') + b = append(b, t.Name()...) + } else { + b = append(b, t.String()...) + } + return b + } + + // Unnamed type. + switch k := t.Kind(); k { + case reflect.Bool, reflect.String, reflect.UnsafePointer, + reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr, + reflect.Float32, reflect.Float64, reflect.Complex64, reflect.Complex128: + b = append(b, k.String()...) + case reflect.Chan: + if t.ChanDir() == reflect.RecvDir { + b = append(b, "<-"...) + } + b = append(b, "chan"...) + if t.ChanDir() == reflect.SendDir { + b = append(b, "<-"...) + } + b = append(b, ' ') + b = appendTypeName(b, t.Elem(), qualified, false) + case reflect.Func: + if !elideFunc { + b = append(b, "func"...) + } + b = append(b, '(') + for i := 0; i < t.NumIn(); i++ { + if i > 0 { + b = append(b, ", "...) + } + if i == t.NumIn()-1 && t.IsVariadic() { + b = append(b, "..."...) + b = appendTypeName(b, t.In(i).Elem(), qualified, false) + } else { + b = appendTypeName(b, t.In(i), qualified, false) + } + } + b = append(b, ')') + switch t.NumOut() { + case 0: + // Do nothing + case 1: + b = append(b, ' ') + b = appendTypeName(b, t.Out(0), qualified, false) + default: + b = append(b, " ("...) + for i := 0; i < t.NumOut(); i++ { + if i > 0 { + b = append(b, ", "...) + } + b = appendTypeName(b, t.Out(i), qualified, false) + } + b = append(b, ')') + } + case reflect.Struct: + b = append(b, "struct{ "...) + for i := 0; i < t.NumField(); i++ { + if i > 0 { + b = append(b, "; "...) + } + sf := t.Field(i) + if !sf.Anonymous { + if qualified && sf.PkgPath != "" { + b = append(b, '"') + b = append(b, sf.PkgPath...) + b = append(b, '"') + b = append(b, '.') + } + b = append(b, sf.Name...) + b = append(b, ' ') + } + b = appendTypeName(b, sf.Type, qualified, false) + if sf.Tag != "" { + b = append(b, ' ') + b = strconv.AppendQuote(b, string(sf.Tag)) + } + } + if b[len(b)-1] == ' ' { + b = b[:len(b)-1] + } else { + b = append(b, ' ') + } + b = append(b, '}') + case reflect.Slice, reflect.Array: + b = append(b, '[') + if k == reflect.Array { + b = strconv.AppendUint(b, uint64(t.Len()), 10) + } + b = append(b, ']') + b = appendTypeName(b, t.Elem(), qualified, false) + case reflect.Map: + b = append(b, "map["...) + b = appendTypeName(b, t.Key(), qualified, false) + b = append(b, ']') + b = appendTypeName(b, t.Elem(), qualified, false) + case reflect.Ptr: + b = append(b, '*') + b = appendTypeName(b, t.Elem(), qualified, false) + case reflect.Interface: + b = append(b, "interface{ "...) + for i := 0; i < t.NumMethod(); i++ { + if i > 0 { + b = append(b, "; "...) + } + m := t.Method(i) + if qualified && m.PkgPath != "" { + b = append(b, '"') + b = append(b, m.PkgPath...) + b = append(b, '"') + b = append(b, '.') + } + b = append(b, m.Name...) + b = appendTypeName(b, m.Type, qualified, true) + } + if b[len(b)-1] == ' ' { + b = b[:len(b)-1] + } else { + b = append(b, ' ') + } + b = append(b, '}') + default: + panic("invalid kind: " + k.String()) + } + return b +} diff --git a/src/webui/vendor/github.com/google/go-cmp/cmp/internal/value/pointer.go b/src/webui/vendor/github.com/google/go-cmp/cmp/internal/value/pointer.go new file mode 100644 index 00000000..e5dfff69 --- /dev/null +++ b/src/webui/vendor/github.com/google/go-cmp/cmp/internal/value/pointer.go @@ -0,0 +1,34 @@ +// Copyright 2018, The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package value + +import ( + "reflect" + "unsafe" +) + +// Pointer is an opaque typed pointer and is guaranteed to be comparable. +type Pointer struct { + p unsafe.Pointer + t reflect.Type +} + +// PointerOf returns a Pointer from v, which must be a +// reflect.Ptr, reflect.Slice, or reflect.Map. +func PointerOf(v reflect.Value) Pointer { + // The proper representation of a pointer is unsafe.Pointer, + // which is necessary if the GC ever uses a moving collector. + return Pointer{unsafe.Pointer(v.Pointer()), v.Type()} +} + +// IsNil reports whether the pointer is nil. +func (p Pointer) IsNil() bool { + return p.p == nil +} + +// Uintptr returns the pointer as a uintptr. +func (p Pointer) Uintptr() uintptr { + return uintptr(p.p) +} diff --git a/src/webui/vendor/github.com/google/go-cmp/cmp/internal/value/sort.go b/src/webui/vendor/github.com/google/go-cmp/cmp/internal/value/sort.go new file mode 100644 index 00000000..98533b03 --- /dev/null +++ b/src/webui/vendor/github.com/google/go-cmp/cmp/internal/value/sort.go @@ -0,0 +1,106 @@ +// Copyright 2017, The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package value + +import ( + "fmt" + "math" + "reflect" + "sort" +) + +// SortKeys sorts a list of map keys, deduplicating keys if necessary. +// The type of each value must be comparable. +func SortKeys(vs []reflect.Value) []reflect.Value { + if len(vs) == 0 { + return vs + } + + // Sort the map keys. + sort.SliceStable(vs, func(i, j int) bool { return isLess(vs[i], vs[j]) }) + + // Deduplicate keys (fails for NaNs). + vs2 := vs[:1] + for _, v := range vs[1:] { + if isLess(vs2[len(vs2)-1], v) { + vs2 = append(vs2, v) + } + } + return vs2 +} + +// isLess is a generic function for sorting arbitrary map keys. +// The inputs must be of the same type and must be comparable. +func isLess(x, y reflect.Value) bool { + switch x.Type().Kind() { + case reflect.Bool: + return !x.Bool() && y.Bool() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return x.Int() < y.Int() + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + return x.Uint() < y.Uint() + case reflect.Float32, reflect.Float64: + // NOTE: This does not sort -0 as less than +0 + // since Go maps treat -0 and +0 as equal keys. + fx, fy := x.Float(), y.Float() + return fx < fy || math.IsNaN(fx) && !math.IsNaN(fy) + case reflect.Complex64, reflect.Complex128: + cx, cy := x.Complex(), y.Complex() + rx, ix, ry, iy := real(cx), imag(cx), real(cy), imag(cy) + if rx == ry || (math.IsNaN(rx) && math.IsNaN(ry)) { + return ix < iy || math.IsNaN(ix) && !math.IsNaN(iy) + } + return rx < ry || math.IsNaN(rx) && !math.IsNaN(ry) + case reflect.Ptr, reflect.UnsafePointer, reflect.Chan: + return x.Pointer() < y.Pointer() + case reflect.String: + return x.String() < y.String() + case reflect.Array: + for i := 0; i < x.Len(); i++ { + if isLess(x.Index(i), y.Index(i)) { + return true + } + if isLess(y.Index(i), x.Index(i)) { + return false + } + } + return false + case reflect.Struct: + for i := 0; i < x.NumField(); i++ { + if isLess(x.Field(i), y.Field(i)) { + return true + } + if isLess(y.Field(i), x.Field(i)) { + return false + } + } + return false + case reflect.Interface: + vx, vy := x.Elem(), y.Elem() + if !vx.IsValid() || !vy.IsValid() { + return !vx.IsValid() && vy.IsValid() + } + tx, ty := vx.Type(), vy.Type() + if tx == ty { + return isLess(x.Elem(), y.Elem()) + } + if tx.Kind() != ty.Kind() { + return vx.Kind() < vy.Kind() + } + if tx.String() != ty.String() { + return tx.String() < ty.String() + } + if tx.PkgPath() != ty.PkgPath() { + return tx.PkgPath() < ty.PkgPath() + } + // This can happen in rare situations, so we fallback to just comparing + // the unique pointer for a reflect.Type. This guarantees deterministic + // ordering within a program, but it is obviously not stable. + return reflect.ValueOf(vx.Type()).Pointer() < reflect.ValueOf(vy.Type()).Pointer() + default: + // Must be Func, Map, or Slice; which are not comparable. + panic(fmt.Sprintf("%T is not comparable", x.Type())) + } +} diff --git a/src/webui/vendor/github.com/google/go-cmp/cmp/options.go b/src/webui/vendor/github.com/google/go-cmp/cmp/options.go new file mode 100644 index 00000000..ba3fce81 --- /dev/null +++ b/src/webui/vendor/github.com/google/go-cmp/cmp/options.go @@ -0,0 +1,562 @@ +// Copyright 2017, The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package cmp + +import ( + "fmt" + "reflect" + "regexp" + "strings" + + "github.com/google/go-cmp/cmp/internal/function" +) + +// Option configures for specific behavior of [Equal] and [Diff]. In particular, +// the fundamental Option functions ([Ignore], [Transformer], and [Comparer]), +// configure how equality is determined. +// +// The fundamental options may be composed with filters ([FilterPath] and +// [FilterValues]) to control the scope over which they are applied. +// +// The [github.com/google/go-cmp/cmp/cmpopts] package provides helper functions +// for creating options that may be used with [Equal] and [Diff]. +type Option interface { + // filter applies all filters and returns the option that remains. + // Each option may only read s.curPath and call s.callTTBFunc. + // + // An Options is returned only if multiple comparers or transformers + // can apply simultaneously and will only contain values of those types + // or sub-Options containing values of those types. + filter(s *state, t reflect.Type, vx, vy reflect.Value) applicableOption +} + +// applicableOption represents the following types: +// +// Fundamental: ignore | validator | *comparer | *transformer +// Grouping: Options +type applicableOption interface { + Option + + // apply executes the option, which may mutate s or panic. + apply(s *state, vx, vy reflect.Value) +} + +// coreOption represents the following types: +// +// Fundamental: ignore | validator | *comparer | *transformer +// Filters: *pathFilter | *valuesFilter +type coreOption interface { + Option + isCore() +} + +type core struct{} + +func (core) isCore() {} + +// Options is a list of [Option] values that also satisfies the [Option] interface. +// Helper comparison packages may return an Options value when packing multiple +// [Option] values into a single [Option]. When this package processes an Options, +// it will be implicitly expanded into a flat list. +// +// Applying a filter on an Options is equivalent to applying that same filter +// on all individual options held within. +type Options []Option + +func (opts Options) filter(s *state, t reflect.Type, vx, vy reflect.Value) (out applicableOption) { + for _, opt := range opts { + switch opt := opt.filter(s, t, vx, vy); opt.(type) { + case ignore: + return ignore{} // Only ignore can short-circuit evaluation + case validator: + out = validator{} // Takes precedence over comparer or transformer + case *comparer, *transformer, Options: + switch out.(type) { + case nil: + out = opt + case validator: + // Keep validator + case *comparer, *transformer, Options: + out = Options{out, opt} // Conflicting comparers or transformers + } + } + } + return out +} + +func (opts Options) apply(s *state, _, _ reflect.Value) { + const warning = "ambiguous set of applicable options" + const help = "consider using filters to ensure at most one Comparer or Transformer may apply" + var ss []string + for _, opt := range flattenOptions(nil, opts) { + ss = append(ss, fmt.Sprint(opt)) + } + set := strings.Join(ss, "\n\t") + panic(fmt.Sprintf("%s at %#v:\n\t%s\n%s", warning, s.curPath, set, help)) +} + +func (opts Options) String() string { + var ss []string + for _, opt := range opts { + ss = append(ss, fmt.Sprint(opt)) + } + return fmt.Sprintf("Options{%s}", strings.Join(ss, ", ")) +} + +// FilterPath returns a new [Option] where opt is only evaluated if filter f +// returns true for the current [Path] in the value tree. +// +// This filter is called even if a slice element or map entry is missing and +// provides an opportunity to ignore such cases. The filter function must be +// symmetric such that the filter result is identical regardless of whether the +// missing value is from x or y. +// +// The option passed in may be an [Ignore], [Transformer], [Comparer], [Options], or +// a previously filtered [Option]. +func FilterPath(f func(Path) bool, opt Option) Option { + if f == nil { + panic("invalid path filter function") + } + if opt := normalizeOption(opt); opt != nil { + return &pathFilter{fnc: f, opt: opt} + } + return nil +} + +type pathFilter struct { + core + fnc func(Path) bool + opt Option +} + +func (f pathFilter) filter(s *state, t reflect.Type, vx, vy reflect.Value) applicableOption { + if f.fnc(s.curPath) { + return f.opt.filter(s, t, vx, vy) + } + return nil +} + +func (f pathFilter) String() string { + return fmt.Sprintf("FilterPath(%s, %v)", function.NameOf(reflect.ValueOf(f.fnc)), f.opt) +} + +// FilterValues returns a new [Option] where opt is only evaluated if filter f, +// which is a function of the form "func(T, T) bool", returns true for the +// current pair of values being compared. If either value is invalid or +// the type of the values is not assignable to T, then this filter implicitly +// returns false. +// +// The filter function must be +// symmetric (i.e., agnostic to the order of the inputs) and +// deterministic (i.e., produces the same result when given the same inputs). +// If T is an interface, it is possible that f is called with two values with +// different concrete types that both implement T. +// +// The option passed in may be an [Ignore], [Transformer], [Comparer], [Options], or +// a previously filtered [Option]. +func FilterValues(f interface{}, opt Option) Option { + v := reflect.ValueOf(f) + if !function.IsType(v.Type(), function.ValueFilter) || v.IsNil() { + panic(fmt.Sprintf("invalid values filter function: %T", f)) + } + if opt := normalizeOption(opt); opt != nil { + vf := &valuesFilter{fnc: v, opt: opt} + if ti := v.Type().In(0); ti.Kind() != reflect.Interface || ti.NumMethod() > 0 { + vf.typ = ti + } + return vf + } + return nil +} + +type valuesFilter struct { + core + typ reflect.Type // T + fnc reflect.Value // func(T, T) bool + opt Option +} + +func (f valuesFilter) filter(s *state, t reflect.Type, vx, vy reflect.Value) applicableOption { + if !vx.IsValid() || !vx.CanInterface() || !vy.IsValid() || !vy.CanInterface() { + return nil + } + if (f.typ == nil || t.AssignableTo(f.typ)) && s.callTTBFunc(f.fnc, vx, vy) { + return f.opt.filter(s, t, vx, vy) + } + return nil +} + +func (f valuesFilter) String() string { + return fmt.Sprintf("FilterValues(%s, %v)", function.NameOf(f.fnc), f.opt) +} + +// Ignore is an [Option] that causes all comparisons to be ignored. +// This value is intended to be combined with [FilterPath] or [FilterValues]. +// It is an error to pass an unfiltered Ignore option to [Equal]. +func Ignore() Option { return ignore{} } + +type ignore struct{ core } + +func (ignore) isFiltered() bool { return false } +func (ignore) filter(_ *state, _ reflect.Type, _, _ reflect.Value) applicableOption { return ignore{} } +func (ignore) apply(s *state, _, _ reflect.Value) { s.report(true, reportByIgnore) } +func (ignore) String() string { return "Ignore()" } + +// validator is a sentinel Option type to indicate that some options could not +// be evaluated due to unexported fields, missing slice elements, or +// missing map entries. Both values are validator only for unexported fields. +type validator struct{ core } + +func (validator) filter(_ *state, _ reflect.Type, vx, vy reflect.Value) applicableOption { + if !vx.IsValid() || !vy.IsValid() { + return validator{} + } + if !vx.CanInterface() || !vy.CanInterface() { + return validator{} + } + return nil +} +func (validator) apply(s *state, vx, vy reflect.Value) { + // Implies missing slice element or map entry. + if !vx.IsValid() || !vy.IsValid() { + s.report(vx.IsValid() == vy.IsValid(), 0) + return + } + + // Unable to Interface implies unexported field without visibility access. + if !vx.CanInterface() || !vy.CanInterface() { + help := "consider using a custom Comparer; if you control the implementation of type, you can also consider using an Exporter, AllowUnexported, or cmpopts.IgnoreUnexported" + var name string + if t := s.curPath.Index(-2).Type(); t.Name() != "" { + // Named type with unexported fields. + name = fmt.Sprintf("%q.%v", t.PkgPath(), t.Name()) // e.g., "path/to/package".MyType + isProtoMessage := func(t reflect.Type) bool { + m, ok := reflect.PointerTo(t).MethodByName("ProtoReflect") + return ok && m.Type.NumIn() == 1 && m.Type.NumOut() == 1 && + m.Type.Out(0).PkgPath() == "google.golang.org/protobuf/reflect/protoreflect" && + m.Type.Out(0).Name() == "Message" + } + if isProtoMessage(t) { + help = `consider using "google.golang.org/protobuf/testing/protocmp".Transform to compare proto.Message types` + } else if _, ok := reflect.New(t).Interface().(error); ok { + help = "consider using cmpopts.EquateErrors to compare error values" + } else if t.Comparable() { + help = "consider using cmpopts.EquateComparable to compare comparable Go types" + } + } else { + // Unnamed type with unexported fields. Derive PkgPath from field. + var pkgPath string + for i := 0; i < t.NumField() && pkgPath == ""; i++ { + pkgPath = t.Field(i).PkgPath + } + name = fmt.Sprintf("%q.(%v)", pkgPath, t.String()) // e.g., "path/to/package".(struct { a int }) + } + panic(fmt.Sprintf("cannot handle unexported field at %#v:\n\t%v\n%s", s.curPath, name, help)) + } + + panic("not reachable") +} + +// identRx represents a valid identifier according to the Go specification. +const identRx = `[_\p{L}][_\p{L}\p{N}]*` + +var identsRx = regexp.MustCompile(`^` + identRx + `(\.` + identRx + `)*$`) + +// Transformer returns an [Option] that applies a transformation function that +// converts values of a certain type into that of another. +// +// The transformer f must be a function "func(T) R" that converts values of +// type T to those of type R and is implicitly filtered to input values +// assignable to T. The transformer must not mutate T in any way. +// +// To help prevent some cases of infinite recursive cycles applying the +// same transform to the output of itself (e.g., in the case where the +// input and output types are the same), an implicit filter is added such that +// a transformer is applicable only if that exact transformer is not already +// in the tail of the [Path] since the last non-[Transform] step. +// For situations where the implicit filter is still insufficient, +// consider using [github.com/google/go-cmp/cmp/cmpopts.AcyclicTransformer], +// which adds a filter to prevent the transformer from +// being recursively applied upon itself. +// +// The name is a user provided label that is used as the [Transform.Name] in the +// transformation [PathStep] (and eventually shown in the [Diff] output). +// The name must be a valid identifier or qualified identifier in Go syntax. +// If empty, an arbitrary name is used. +func Transformer(name string, f interface{}) Option { + v := reflect.ValueOf(f) + if !function.IsType(v.Type(), function.Transformer) || v.IsNil() { + panic(fmt.Sprintf("invalid transformer function: %T", f)) + } + if name == "" { + name = function.NameOf(v) + if !identsRx.MatchString(name) { + name = "λ" // Lambda-symbol as placeholder name + } + } else if !identsRx.MatchString(name) { + panic(fmt.Sprintf("invalid name: %q", name)) + } + tr := &transformer{name: name, fnc: reflect.ValueOf(f)} + if ti := v.Type().In(0); ti.Kind() != reflect.Interface || ti.NumMethod() > 0 { + tr.typ = ti + } + return tr +} + +type transformer struct { + core + name string + typ reflect.Type // T + fnc reflect.Value // func(T) R +} + +func (tr *transformer) isFiltered() bool { return tr.typ != nil } + +func (tr *transformer) filter(s *state, t reflect.Type, _, _ reflect.Value) applicableOption { + for i := len(s.curPath) - 1; i >= 0; i-- { + if t, ok := s.curPath[i].(Transform); !ok { + break // Hit most recent non-Transform step + } else if tr == t.trans { + return nil // Cannot directly use same Transform + } + } + if tr.typ == nil || t.AssignableTo(tr.typ) { + return tr + } + return nil +} + +func (tr *transformer) apply(s *state, vx, vy reflect.Value) { + step := Transform{&transform{pathStep{typ: tr.fnc.Type().Out(0)}, tr}} + vvx := s.callTRFunc(tr.fnc, vx, step) + vvy := s.callTRFunc(tr.fnc, vy, step) + step.vx, step.vy = vvx, vvy + s.compareAny(step) +} + +func (tr transformer) String() string { + return fmt.Sprintf("Transformer(%s, %s)", tr.name, function.NameOf(tr.fnc)) +} + +// Comparer returns an [Option] that determines whether two values are equal +// to each other. +// +// The comparer f must be a function "func(T, T) bool" and is implicitly +// filtered to input values assignable to T. If T is an interface, it is +// possible that f is called with two values of different concrete types that +// both implement T. +// +// The equality function must be: +// - Symmetric: equal(x, y) == equal(y, x) +// - Deterministic: equal(x, y) == equal(x, y) +// - Pure: equal(x, y) does not modify x or y +func Comparer(f interface{}) Option { + v := reflect.ValueOf(f) + if !function.IsType(v.Type(), function.Equal) || v.IsNil() { + panic(fmt.Sprintf("invalid comparer function: %T", f)) + } + cm := &comparer{fnc: v} + if ti := v.Type().In(0); ti.Kind() != reflect.Interface || ti.NumMethod() > 0 { + cm.typ = ti + } + return cm +} + +type comparer struct { + core + typ reflect.Type // T + fnc reflect.Value // func(T, T) bool +} + +func (cm *comparer) isFiltered() bool { return cm.typ != nil } + +func (cm *comparer) filter(_ *state, t reflect.Type, _, _ reflect.Value) applicableOption { + if cm.typ == nil || t.AssignableTo(cm.typ) { + return cm + } + return nil +} + +func (cm *comparer) apply(s *state, vx, vy reflect.Value) { + eq := s.callTTBFunc(cm.fnc, vx, vy) + s.report(eq, reportByFunc) +} + +func (cm comparer) String() string { + return fmt.Sprintf("Comparer(%s)", function.NameOf(cm.fnc)) +} + +// Exporter returns an [Option] that specifies whether [Equal] is allowed to +// introspect into the unexported fields of certain struct types. +// +// Users of this option must understand that comparing on unexported fields +// from external packages is not safe since changes in the internal +// implementation of some external package may cause the result of [Equal] +// to unexpectedly change. However, it may be valid to use this option on types +// defined in an internal package where the semantic meaning of an unexported +// field is in the control of the user. +// +// In many cases, a custom [Comparer] should be used instead that defines +// equality as a function of the public API of a type rather than the underlying +// unexported implementation. +// +// For example, the [reflect.Type] documentation defines equality to be determined +// by the == operator on the interface (essentially performing a shallow pointer +// comparison) and most attempts to compare *[regexp.Regexp] types are interested +// in only checking that the regular expression strings are equal. +// Both of these are accomplished using [Comparer] options: +// +// Comparer(func(x, y reflect.Type) bool { return x == y }) +// Comparer(func(x, y *regexp.Regexp) bool { return x.String() == y.String() }) +// +// In other cases, the [github.com/google/go-cmp/cmp/cmpopts.IgnoreUnexported] +// option can be used to ignore all unexported fields on specified struct types. +func Exporter(f func(reflect.Type) bool) Option { + return exporter(f) +} + +type exporter func(reflect.Type) bool + +func (exporter) filter(_ *state, _ reflect.Type, _, _ reflect.Value) applicableOption { + panic("not implemented") +} + +// AllowUnexported returns an [Option] that allows [Equal] to forcibly introspect +// unexported fields of the specified struct types. +// +// See [Exporter] for the proper use of this option. +func AllowUnexported(types ...interface{}) Option { + m := make(map[reflect.Type]bool) + for _, typ := range types { + t := reflect.TypeOf(typ) + if t.Kind() != reflect.Struct { + panic(fmt.Sprintf("invalid struct type: %T", typ)) + } + m[t] = true + } + return exporter(func(t reflect.Type) bool { return m[t] }) +} + +// Result represents the comparison result for a single node and +// is provided by cmp when calling Report (see [Reporter]). +type Result struct { + _ [0]func() // Make Result incomparable + flags resultFlags +} + +// Equal reports whether the node was determined to be equal or not. +// As a special case, ignored nodes are considered equal. +func (r Result) Equal() bool { + return r.flags&(reportEqual|reportByIgnore) != 0 +} + +// ByIgnore reports whether the node is equal because it was ignored. +// This never reports true if [Result.Equal] reports false. +func (r Result) ByIgnore() bool { + return r.flags&reportByIgnore != 0 +} + +// ByMethod reports whether the Equal method determined equality. +func (r Result) ByMethod() bool { + return r.flags&reportByMethod != 0 +} + +// ByFunc reports whether a [Comparer] function determined equality. +func (r Result) ByFunc() bool { + return r.flags&reportByFunc != 0 +} + +// ByCycle reports whether a reference cycle was detected. +func (r Result) ByCycle() bool { + return r.flags&reportByCycle != 0 +} + +type resultFlags uint + +const ( + _ resultFlags = (1 << iota) / 2 + + reportEqual + reportUnequal + reportByIgnore + reportByMethod + reportByFunc + reportByCycle +) + +// Reporter is an [Option] that can be passed to [Equal]. When [Equal] traverses +// the value trees, it calls PushStep as it descends into each node in the +// tree and PopStep as it ascend out of the node. The leaves of the tree are +// either compared (determined to be equal or not equal) or ignored and reported +// as such by calling the Report method. +func Reporter(r interface { + // PushStep is called when a tree-traversal operation is performed. + // The PathStep itself is only valid until the step is popped. + // The PathStep.Values are valid for the duration of the entire traversal + // and must not be mutated. + // + // Equal always calls PushStep at the start to provide an operation-less + // PathStep used to report the root values. + // + // Within a slice, the exact set of inserted, removed, or modified elements + // is unspecified and may change in future implementations. + // The entries of a map are iterated through in an unspecified order. + PushStep(PathStep) + + // Report is called exactly once on leaf nodes to report whether the + // comparison identified the node as equal, unequal, or ignored. + // A leaf node is one that is immediately preceded by and followed by + // a pair of PushStep and PopStep calls. + Report(Result) + + // PopStep ascends back up the value tree. + // There is always a matching pop call for every push call. + PopStep() +}) Option { + return reporter{r} +} + +type reporter struct{ reporterIface } +type reporterIface interface { + PushStep(PathStep) + Report(Result) + PopStep() +} + +func (reporter) filter(_ *state, _ reflect.Type, _, _ reflect.Value) applicableOption { + panic("not implemented") +} + +// normalizeOption normalizes the input options such that all Options groups +// are flattened and groups with a single element are reduced to that element. +// Only coreOptions and Options containing coreOptions are allowed. +func normalizeOption(src Option) Option { + switch opts := flattenOptions(nil, Options{src}); len(opts) { + case 0: + return nil + case 1: + return opts[0] + default: + return opts + } +} + +// flattenOptions copies all options in src to dst as a flat list. +// Only coreOptions and Options containing coreOptions are allowed. +func flattenOptions(dst, src Options) Options { + for _, opt := range src { + switch opt := opt.(type) { + case nil: + continue + case Options: + dst = flattenOptions(dst, opt) + case coreOption: + dst = append(dst, opt) + default: + panic(fmt.Sprintf("invalid option type: %T", opt)) + } + } + return dst +} diff --git a/src/webui/vendor/github.com/google/go-cmp/cmp/path.go b/src/webui/vendor/github.com/google/go-cmp/cmp/path.go new file mode 100644 index 00000000..c3c14564 --- /dev/null +++ b/src/webui/vendor/github.com/google/go-cmp/cmp/path.go @@ -0,0 +1,390 @@ +// Copyright 2017, The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package cmp + +import ( + "fmt" + "reflect" + "strings" + "unicode" + "unicode/utf8" + + "github.com/google/go-cmp/cmp/internal/value" +) + +// Path is a list of [PathStep] describing the sequence of operations to get +// from some root type to the current position in the value tree. +// The first Path element is always an operation-less [PathStep] that exists +// simply to identify the initial type. +// +// When traversing structs with embedded structs, the embedded struct will +// always be accessed as a field before traversing the fields of the +// embedded struct themselves. That is, an exported field from the +// embedded struct will never be accessed directly from the parent struct. +type Path []PathStep + +// PathStep is a union-type for specific operations to traverse +// a value's tree structure. Users of this package never need to implement +// these types as values of this type will be returned by this package. +// +// Implementations of this interface: +// - [StructField] +// - [SliceIndex] +// - [MapIndex] +// - [Indirect] +// - [TypeAssertion] +// - [Transform] +type PathStep interface { + String() string + + // Type is the resulting type after performing the path step. + Type() reflect.Type + + // Values is the resulting values after performing the path step. + // The type of each valid value is guaranteed to be identical to Type. + // + // In some cases, one or both may be invalid or have restrictions: + // - For StructField, both are not interface-able if the current field + // is unexported and the struct type is not explicitly permitted by + // an Exporter to traverse unexported fields. + // - For SliceIndex, one may be invalid if an element is missing from + // either the x or y slice. + // - For MapIndex, one may be invalid if an entry is missing from + // either the x or y map. + // + // The provided values must not be mutated. + Values() (vx, vy reflect.Value) +} + +var ( + _ PathStep = StructField{} + _ PathStep = SliceIndex{} + _ PathStep = MapIndex{} + _ PathStep = Indirect{} + _ PathStep = TypeAssertion{} + _ PathStep = Transform{} +) + +func (pa *Path) push(s PathStep) { + *pa = append(*pa, s) +} + +func (pa *Path) pop() { + *pa = (*pa)[:len(*pa)-1] +} + +// Last returns the last [PathStep] in the Path. +// If the path is empty, this returns a non-nil [PathStep] +// that reports a nil [PathStep.Type]. +func (pa Path) Last() PathStep { + return pa.Index(-1) +} + +// Index returns the ith step in the Path and supports negative indexing. +// A negative index starts counting from the tail of the Path such that -1 +// refers to the last step, -2 refers to the second-to-last step, and so on. +// If index is invalid, this returns a non-nil [PathStep] +// that reports a nil [PathStep.Type]. +func (pa Path) Index(i int) PathStep { + if i < 0 { + i = len(pa) + i + } + if i < 0 || i >= len(pa) { + return pathStep{} + } + return pa[i] +} + +// String returns the simplified path to a node. +// The simplified path only contains struct field accesses. +// +// For example: +// +// MyMap.MySlices.MyField +func (pa Path) String() string { + var ss []string + for _, s := range pa { + if _, ok := s.(StructField); ok { + ss = append(ss, s.String()) + } + } + return strings.TrimPrefix(strings.Join(ss, ""), ".") +} + +// GoString returns the path to a specific node using Go syntax. +// +// For example: +// +// (*root.MyMap["key"].(*mypkg.MyStruct).MySlices)[2][3].MyField +func (pa Path) GoString() string { + var ssPre, ssPost []string + var numIndirect int + for i, s := range pa { + var nextStep PathStep + if i+1 < len(pa) { + nextStep = pa[i+1] + } + switch s := s.(type) { + case Indirect: + numIndirect++ + pPre, pPost := "(", ")" + switch nextStep.(type) { + case Indirect: + continue // Next step is indirection, so let them batch up + case StructField: + numIndirect-- // Automatic indirection on struct fields + case nil: + pPre, pPost = "", "" // Last step; no need for parenthesis + } + if numIndirect > 0 { + ssPre = append(ssPre, pPre+strings.Repeat("*", numIndirect)) + ssPost = append(ssPost, pPost) + } + numIndirect = 0 + continue + case Transform: + ssPre = append(ssPre, s.trans.name+"(") + ssPost = append(ssPost, ")") + continue + } + ssPost = append(ssPost, s.String()) + } + for i, j := 0, len(ssPre)-1; i < j; i, j = i+1, j-1 { + ssPre[i], ssPre[j] = ssPre[j], ssPre[i] + } + return strings.Join(ssPre, "") + strings.Join(ssPost, "") +} + +type pathStep struct { + typ reflect.Type + vx, vy reflect.Value +} + +func (ps pathStep) Type() reflect.Type { return ps.typ } +func (ps pathStep) Values() (vx, vy reflect.Value) { return ps.vx, ps.vy } +func (ps pathStep) String() string { + if ps.typ == nil { + return "" + } + s := value.TypeString(ps.typ, false) + if s == "" || strings.ContainsAny(s, "{}\n") { + return "root" // Type too simple or complex to print + } + return fmt.Sprintf("{%s}", s) +} + +// StructField is a [PathStep] that represents a struct field access +// on a field called [StructField.Name]. +type StructField struct{ *structField } +type structField struct { + pathStep + name string + idx int + + // These fields are used for forcibly accessing an unexported field. + // pvx, pvy, and field are only valid if unexported is true. + unexported bool + mayForce bool // Forcibly allow visibility + paddr bool // Was parent addressable? + pvx, pvy reflect.Value // Parent values (always addressable) + field reflect.StructField // Field information +} + +func (sf StructField) Type() reflect.Type { return sf.typ } +func (sf StructField) Values() (vx, vy reflect.Value) { + if !sf.unexported { + return sf.vx, sf.vy // CanInterface reports true + } + + // Forcibly obtain read-write access to an unexported struct field. + if sf.mayForce { + vx = retrieveUnexportedField(sf.pvx, sf.field, sf.paddr) + vy = retrieveUnexportedField(sf.pvy, sf.field, sf.paddr) + return vx, vy // CanInterface reports true + } + return sf.vx, sf.vy // CanInterface reports false +} +func (sf StructField) String() string { return fmt.Sprintf(".%s", sf.name) } + +// Name is the field name. +func (sf StructField) Name() string { return sf.name } + +// Index is the index of the field in the parent struct type. +// See [reflect.Type.Field]. +func (sf StructField) Index() int { return sf.idx } + +// SliceIndex is a [PathStep] that represents an index operation on +// a slice or array at some index [SliceIndex.Key]. +type SliceIndex struct{ *sliceIndex } +type sliceIndex struct { + pathStep + xkey, ykey int + isSlice bool // False for reflect.Array +} + +func (si SliceIndex) Type() reflect.Type { return si.typ } +func (si SliceIndex) Values() (vx, vy reflect.Value) { return si.vx, si.vy } +func (si SliceIndex) String() string { + switch { + case si.xkey == si.ykey: + return fmt.Sprintf("[%d]", si.xkey) + case si.ykey == -1: + // [5->?] means "I don't know where X[5] went" + return fmt.Sprintf("[%d->?]", si.xkey) + case si.xkey == -1: + // [?->3] means "I don't know where Y[3] came from" + return fmt.Sprintf("[?->%d]", si.ykey) + default: + // [5->3] means "X[5] moved to Y[3]" + return fmt.Sprintf("[%d->%d]", si.xkey, si.ykey) + } +} + +// Key is the index key; it may return -1 if in a split state +func (si SliceIndex) Key() int { + if si.xkey != si.ykey { + return -1 + } + return si.xkey +} + +// SplitKeys are the indexes for indexing into slices in the +// x and y values, respectively. These indexes may differ due to the +// insertion or removal of an element in one of the slices, causing +// all of the indexes to be shifted. If an index is -1, then that +// indicates that the element does not exist in the associated slice. +// +// [SliceIndex.Key] is guaranteed to return -1 if and only if the indexes +// returned by SplitKeys are not the same. SplitKeys will never return -1 for +// both indexes. +func (si SliceIndex) SplitKeys() (ix, iy int) { return si.xkey, si.ykey } + +// MapIndex is a [PathStep] that represents an index operation on a map at some index Key. +type MapIndex struct{ *mapIndex } +type mapIndex struct { + pathStep + key reflect.Value +} + +func (mi MapIndex) Type() reflect.Type { return mi.typ } +func (mi MapIndex) Values() (vx, vy reflect.Value) { return mi.vx, mi.vy } +func (mi MapIndex) String() string { return fmt.Sprintf("[%#v]", mi.key) } + +// Key is the value of the map key. +func (mi MapIndex) Key() reflect.Value { return mi.key } + +// Indirect is a [PathStep] that represents pointer indirection on the parent type. +type Indirect struct{ *indirect } +type indirect struct { + pathStep +} + +func (in Indirect) Type() reflect.Type { return in.typ } +func (in Indirect) Values() (vx, vy reflect.Value) { return in.vx, in.vy } +func (in Indirect) String() string { return "*" } + +// TypeAssertion is a [PathStep] that represents a type assertion on an interface. +type TypeAssertion struct{ *typeAssertion } +type typeAssertion struct { + pathStep +} + +func (ta TypeAssertion) Type() reflect.Type { return ta.typ } +func (ta TypeAssertion) Values() (vx, vy reflect.Value) { return ta.vx, ta.vy } +func (ta TypeAssertion) String() string { return fmt.Sprintf(".(%v)", value.TypeString(ta.typ, false)) } + +// Transform is a [PathStep] that represents a transformation +// from the parent type to the current type. +type Transform struct{ *transform } +type transform struct { + pathStep + trans *transformer +} + +func (tf Transform) Type() reflect.Type { return tf.typ } +func (tf Transform) Values() (vx, vy reflect.Value) { return tf.vx, tf.vy } +func (tf Transform) String() string { return fmt.Sprintf("%s()", tf.trans.name) } + +// Name is the name of the [Transformer]. +func (tf Transform) Name() string { return tf.trans.name } + +// Func is the function pointer to the transformer function. +func (tf Transform) Func() reflect.Value { return tf.trans.fnc } + +// Option returns the originally constructed [Transformer] option. +// The == operator can be used to detect the exact option used. +func (tf Transform) Option() Option { return tf.trans } + +// pointerPath represents a dual-stack of pointers encountered when +// recursively traversing the x and y values. This data structure supports +// detection of cycles and determining whether the cycles are equal. +// In Go, cycles can occur via pointers, slices, and maps. +// +// The pointerPath uses a map to represent a stack; where descension into a +// pointer pushes the address onto the stack, and ascension from a pointer +// pops the address from the stack. Thus, when traversing into a pointer from +// reflect.Ptr, reflect.Slice element, or reflect.Map, we can detect cycles +// by checking whether the pointer has already been visited. The cycle detection +// uses a separate stack for the x and y values. +// +// If a cycle is detected we need to determine whether the two pointers +// should be considered equal. The definition of equality chosen by Equal +// requires two graphs to have the same structure. To determine this, both the +// x and y values must have a cycle where the previous pointers were also +// encountered together as a pair. +// +// Semantically, this is equivalent to augmenting Indirect, SliceIndex, and +// MapIndex with pointer information for the x and y values. +// Suppose px and py are two pointers to compare, we then search the +// Path for whether px was ever encountered in the Path history of x, and +// similarly so with py. If either side has a cycle, the comparison is only +// equal if both px and py have a cycle resulting from the same PathStep. +// +// Using a map as a stack is more performant as we can perform cycle detection +// in O(1) instead of O(N) where N is len(Path). +type pointerPath struct { + // mx is keyed by x pointers, where the value is the associated y pointer. + mx map[value.Pointer]value.Pointer + // my is keyed by y pointers, where the value is the associated x pointer. + my map[value.Pointer]value.Pointer +} + +func (p *pointerPath) Init() { + p.mx = make(map[value.Pointer]value.Pointer) + p.my = make(map[value.Pointer]value.Pointer) +} + +// Push indicates intent to descend into pointers vx and vy where +// visited reports whether either has been seen before. If visited before, +// equal reports whether both pointers were encountered together. +// Pop must be called if and only if the pointers were never visited. +// +// The pointers vx and vy must be a reflect.Ptr, reflect.Slice, or reflect.Map +// and be non-nil. +func (p pointerPath) Push(vx, vy reflect.Value) (equal, visited bool) { + px := value.PointerOf(vx) + py := value.PointerOf(vy) + _, ok1 := p.mx[px] + _, ok2 := p.my[py] + if ok1 || ok2 { + equal = p.mx[px] == py && p.my[py] == px // Pointers paired together + return equal, true + } + p.mx[px] = py + p.my[py] = px + return false, false +} + +// Pop ascends from pointers vx and vy. +func (p pointerPath) Pop(vx, vy reflect.Value) { + delete(p.mx, value.PointerOf(vx)) + delete(p.my, value.PointerOf(vy)) +} + +// isExported reports whether the identifier is exported. +func isExported(id string) bool { + r, _ := utf8.DecodeRuneInString(id) + return unicode.IsUpper(r) +} diff --git a/src/webui/vendor/github.com/google/go-cmp/cmp/report.go b/src/webui/vendor/github.com/google/go-cmp/cmp/report.go new file mode 100644 index 00000000..f43cd12e --- /dev/null +++ b/src/webui/vendor/github.com/google/go-cmp/cmp/report.go @@ -0,0 +1,54 @@ +// Copyright 2017, The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package cmp + +// defaultReporter implements the reporter interface. +// +// As Equal serially calls the PushStep, Report, and PopStep methods, the +// defaultReporter constructs a tree-based representation of the compared value +// and the result of each comparison (see valueNode). +// +// When the String method is called, the FormatDiff method transforms the +// valueNode tree into a textNode tree, which is a tree-based representation +// of the textual output (see textNode). +// +// Lastly, the textNode.String method produces the final report as a string. +type defaultReporter struct { + root *valueNode + curr *valueNode +} + +func (r *defaultReporter) PushStep(ps PathStep) { + r.curr = r.curr.PushStep(ps) + if r.root == nil { + r.root = r.curr + } +} +func (r *defaultReporter) Report(rs Result) { + r.curr.Report(rs) +} +func (r *defaultReporter) PopStep() { + r.curr = r.curr.PopStep() +} + +// String provides a full report of the differences detected as a structured +// literal in pseudo-Go syntax. String may only be called after the entire tree +// has been traversed. +func (r *defaultReporter) String() string { + assert(r.root != nil && r.curr == nil) + if r.root.NumDiff == 0 { + return "" + } + ptrs := new(pointerReferences) + text := formatOptions{}.FormatDiff(r.root, ptrs) + resolveReferences(text) + return text.String() +} + +func assert(ok bool) { + if !ok { + panic("assertion failure") + } +} diff --git a/src/webui/vendor/github.com/google/go-cmp/cmp/report_compare.go b/src/webui/vendor/github.com/google/go-cmp/cmp/report_compare.go new file mode 100644 index 00000000..2050bf6b --- /dev/null +++ b/src/webui/vendor/github.com/google/go-cmp/cmp/report_compare.go @@ -0,0 +1,433 @@ +// Copyright 2019, The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package cmp + +import ( + "fmt" + "reflect" +) + +// numContextRecords is the number of surrounding equal records to print. +const numContextRecords = 2 + +type diffMode byte + +const ( + diffUnknown diffMode = 0 + diffIdentical diffMode = ' ' + diffRemoved diffMode = '-' + diffInserted diffMode = '+' +) + +type typeMode int + +const ( + // emitType always prints the type. + emitType typeMode = iota + // elideType never prints the type. + elideType + // autoType prints the type only for composite kinds + // (i.e., structs, slices, arrays, and maps). + autoType +) + +type formatOptions struct { + // DiffMode controls the output mode of FormatDiff. + // + // If diffUnknown, then produce a diff of the x and y values. + // If diffIdentical, then emit values as if they were equal. + // If diffRemoved, then only emit x values (ignoring y values). + // If diffInserted, then only emit y values (ignoring x values). + DiffMode diffMode + + // TypeMode controls whether to print the type for the current node. + // + // As a general rule of thumb, we always print the type of the next node + // after an interface, and always elide the type of the next node after + // a slice or map node. + TypeMode typeMode + + // formatValueOptions are options specific to printing reflect.Values. + formatValueOptions +} + +func (opts formatOptions) WithDiffMode(d diffMode) formatOptions { + opts.DiffMode = d + return opts +} +func (opts formatOptions) WithTypeMode(t typeMode) formatOptions { + opts.TypeMode = t + return opts +} +func (opts formatOptions) WithVerbosity(level int) formatOptions { + opts.VerbosityLevel = level + opts.LimitVerbosity = true + return opts +} +func (opts formatOptions) verbosity() uint { + switch { + case opts.VerbosityLevel < 0: + return 0 + case opts.VerbosityLevel > 16: + return 16 // some reasonable maximum to avoid shift overflow + default: + return uint(opts.VerbosityLevel) + } +} + +const maxVerbosityPreset = 6 + +// verbosityPreset modifies the verbosity settings given an index +// between 0 and maxVerbosityPreset, inclusive. +func verbosityPreset(opts formatOptions, i int) formatOptions { + opts.VerbosityLevel = int(opts.verbosity()) + 2*i + if i > 0 { + opts.AvoidStringer = true + } + if i >= maxVerbosityPreset { + opts.PrintAddresses = true + opts.QualifiedNames = true + } + return opts +} + +// FormatDiff converts a valueNode tree into a textNode tree, where the later +// is a textual representation of the differences detected in the former. +func (opts formatOptions) FormatDiff(v *valueNode, ptrs *pointerReferences) (out textNode) { + if opts.DiffMode == diffIdentical { + opts = opts.WithVerbosity(1) + } else if opts.verbosity() < 3 { + opts = opts.WithVerbosity(3) + } + + // Check whether we have specialized formatting for this node. + // This is not necessary, but helpful for producing more readable outputs. + if opts.CanFormatDiffSlice(v) { + return opts.FormatDiffSlice(v) + } + + var parentKind reflect.Kind + if v.parent != nil && v.parent.TransformerName == "" { + parentKind = v.parent.Type.Kind() + } + + // For leaf nodes, format the value based on the reflect.Values alone. + // As a special case, treat equal []byte as a leaf nodes. + isBytes := v.Type.Kind() == reflect.Slice && v.Type.Elem() == byteType + isEqualBytes := isBytes && v.NumDiff+v.NumIgnored+v.NumTransformed == 0 + if v.MaxDepth == 0 || isEqualBytes { + switch opts.DiffMode { + case diffUnknown, diffIdentical: + // Format Equal. + if v.NumDiff == 0 { + outx := opts.FormatValue(v.ValueX, parentKind, ptrs) + outy := opts.FormatValue(v.ValueY, parentKind, ptrs) + if v.NumIgnored > 0 && v.NumSame == 0 { + return textEllipsis + } else if outx.Len() < outy.Len() { + return outx + } else { + return outy + } + } + + // Format unequal. + assert(opts.DiffMode == diffUnknown) + var list textList + outx := opts.WithTypeMode(elideType).FormatValue(v.ValueX, parentKind, ptrs) + outy := opts.WithTypeMode(elideType).FormatValue(v.ValueY, parentKind, ptrs) + for i := 0; i <= maxVerbosityPreset && outx != nil && outy != nil && outx.Equal(outy); i++ { + opts2 := verbosityPreset(opts, i).WithTypeMode(elideType) + outx = opts2.FormatValue(v.ValueX, parentKind, ptrs) + outy = opts2.FormatValue(v.ValueY, parentKind, ptrs) + } + if outx != nil { + list = append(list, textRecord{Diff: '-', Value: outx}) + } + if outy != nil { + list = append(list, textRecord{Diff: '+', Value: outy}) + } + return opts.WithTypeMode(emitType).FormatType(v.Type, list) + case diffRemoved: + return opts.FormatValue(v.ValueX, parentKind, ptrs) + case diffInserted: + return opts.FormatValue(v.ValueY, parentKind, ptrs) + default: + panic("invalid diff mode") + } + } + + // Register slice element to support cycle detection. + if parentKind == reflect.Slice { + ptrRefs := ptrs.PushPair(v.ValueX, v.ValueY, opts.DiffMode, true) + defer ptrs.Pop() + defer func() { out = wrapTrunkReferences(ptrRefs, out) }() + } + + // Descend into the child value node. + if v.TransformerName != "" { + out := opts.WithTypeMode(emitType).FormatDiff(v.Value, ptrs) + out = &textWrap{Prefix: "Inverse(" + v.TransformerName + ", ", Value: out, Suffix: ")"} + return opts.FormatType(v.Type, out) + } else { + switch k := v.Type.Kind(); k { + case reflect.Struct, reflect.Array, reflect.Slice: + out = opts.formatDiffList(v.Records, k, ptrs) + out = opts.FormatType(v.Type, out) + case reflect.Map: + // Register map to support cycle detection. + ptrRefs := ptrs.PushPair(v.ValueX, v.ValueY, opts.DiffMode, false) + defer ptrs.Pop() + + out = opts.formatDiffList(v.Records, k, ptrs) + out = wrapTrunkReferences(ptrRefs, out) + out = opts.FormatType(v.Type, out) + case reflect.Ptr: + // Register pointer to support cycle detection. + ptrRefs := ptrs.PushPair(v.ValueX, v.ValueY, opts.DiffMode, false) + defer ptrs.Pop() + + out = opts.FormatDiff(v.Value, ptrs) + out = wrapTrunkReferences(ptrRefs, out) + out = &textWrap{Prefix: "&", Value: out} + case reflect.Interface: + out = opts.WithTypeMode(emitType).FormatDiff(v.Value, ptrs) + default: + panic(fmt.Sprintf("%v cannot have children", k)) + } + return out + } +} + +func (opts formatOptions) formatDiffList(recs []reportRecord, k reflect.Kind, ptrs *pointerReferences) textNode { + // Derive record name based on the data structure kind. + var name string + var formatKey func(reflect.Value) string + switch k { + case reflect.Struct: + name = "field" + opts = opts.WithTypeMode(autoType) + formatKey = func(v reflect.Value) string { return v.String() } + case reflect.Slice, reflect.Array: + name = "element" + opts = opts.WithTypeMode(elideType) + formatKey = func(reflect.Value) string { return "" } + case reflect.Map: + name = "entry" + opts = opts.WithTypeMode(elideType) + formatKey = func(v reflect.Value) string { return formatMapKey(v, false, ptrs) } + } + + maxLen := -1 + if opts.LimitVerbosity { + if opts.DiffMode == diffIdentical { + maxLen = ((1 << opts.verbosity()) >> 1) << 2 // 0, 4, 8, 16, 32, etc... + } else { + maxLen = (1 << opts.verbosity()) << 1 // 2, 4, 8, 16, 32, 64, etc... + } + opts.VerbosityLevel-- + } + + // Handle unification. + switch opts.DiffMode { + case diffIdentical, diffRemoved, diffInserted: + var list textList + var deferredEllipsis bool // Add final "..." to indicate records were dropped + for _, r := range recs { + if len(list) == maxLen { + deferredEllipsis = true + break + } + + // Elide struct fields that are zero value. + if k == reflect.Struct { + var isZero bool + switch opts.DiffMode { + case diffIdentical: + isZero = r.Value.ValueX.IsZero() || r.Value.ValueY.IsZero() + case diffRemoved: + isZero = r.Value.ValueX.IsZero() + case diffInserted: + isZero = r.Value.ValueY.IsZero() + } + if isZero { + continue + } + } + // Elide ignored nodes. + if r.Value.NumIgnored > 0 && r.Value.NumSame+r.Value.NumDiff == 0 { + deferredEllipsis = !(k == reflect.Slice || k == reflect.Array) + if !deferredEllipsis { + list.AppendEllipsis(diffStats{}) + } + continue + } + if out := opts.FormatDiff(r.Value, ptrs); out != nil { + list = append(list, textRecord{Key: formatKey(r.Key), Value: out}) + } + } + if deferredEllipsis { + list.AppendEllipsis(diffStats{}) + } + return &textWrap{Prefix: "{", Value: list, Suffix: "}"} + case diffUnknown: + default: + panic("invalid diff mode") + } + + // Handle differencing. + var numDiffs int + var list textList + var keys []reflect.Value // invariant: len(list) == len(keys) + groups := coalesceAdjacentRecords(name, recs) + maxGroup := diffStats{Name: name} + for i, ds := range groups { + if maxLen >= 0 && numDiffs >= maxLen { + maxGroup = maxGroup.Append(ds) + continue + } + + // Handle equal records. + if ds.NumDiff() == 0 { + // Compute the number of leading and trailing records to print. + var numLo, numHi int + numEqual := ds.NumIgnored + ds.NumIdentical + for numLo < numContextRecords && numLo+numHi < numEqual && i != 0 { + if r := recs[numLo].Value; r.NumIgnored > 0 && r.NumSame+r.NumDiff == 0 { + break + } + numLo++ + } + for numHi < numContextRecords && numLo+numHi < numEqual && i != len(groups)-1 { + if r := recs[numEqual-numHi-1].Value; r.NumIgnored > 0 && r.NumSame+r.NumDiff == 0 { + break + } + numHi++ + } + if numEqual-(numLo+numHi) == 1 && ds.NumIgnored == 0 { + numHi++ // Avoid pointless coalescing of a single equal record + } + + // Format the equal values. + for _, r := range recs[:numLo] { + out := opts.WithDiffMode(diffIdentical).FormatDiff(r.Value, ptrs) + list = append(list, textRecord{Key: formatKey(r.Key), Value: out}) + keys = append(keys, r.Key) + } + if numEqual > numLo+numHi { + ds.NumIdentical -= numLo + numHi + list.AppendEllipsis(ds) + for len(keys) < len(list) { + keys = append(keys, reflect.Value{}) + } + } + for _, r := range recs[numEqual-numHi : numEqual] { + out := opts.WithDiffMode(diffIdentical).FormatDiff(r.Value, ptrs) + list = append(list, textRecord{Key: formatKey(r.Key), Value: out}) + keys = append(keys, r.Key) + } + recs = recs[numEqual:] + continue + } + + // Handle unequal records. + for _, r := range recs[:ds.NumDiff()] { + switch { + case opts.CanFormatDiffSlice(r.Value): + out := opts.FormatDiffSlice(r.Value) + list = append(list, textRecord{Key: formatKey(r.Key), Value: out}) + keys = append(keys, r.Key) + case r.Value.NumChildren == r.Value.MaxDepth: + outx := opts.WithDiffMode(diffRemoved).FormatDiff(r.Value, ptrs) + outy := opts.WithDiffMode(diffInserted).FormatDiff(r.Value, ptrs) + for i := 0; i <= maxVerbosityPreset && outx != nil && outy != nil && outx.Equal(outy); i++ { + opts2 := verbosityPreset(opts, i) + outx = opts2.WithDiffMode(diffRemoved).FormatDiff(r.Value, ptrs) + outy = opts2.WithDiffMode(diffInserted).FormatDiff(r.Value, ptrs) + } + if outx != nil { + list = append(list, textRecord{Diff: diffRemoved, Key: formatKey(r.Key), Value: outx}) + keys = append(keys, r.Key) + } + if outy != nil { + list = append(list, textRecord{Diff: diffInserted, Key: formatKey(r.Key), Value: outy}) + keys = append(keys, r.Key) + } + default: + out := opts.FormatDiff(r.Value, ptrs) + list = append(list, textRecord{Key: formatKey(r.Key), Value: out}) + keys = append(keys, r.Key) + } + } + recs = recs[ds.NumDiff():] + numDiffs += ds.NumDiff() + } + if maxGroup.IsZero() { + assert(len(recs) == 0) + } else { + list.AppendEllipsis(maxGroup) + for len(keys) < len(list) { + keys = append(keys, reflect.Value{}) + } + } + assert(len(list) == len(keys)) + + // For maps, the default formatting logic uses fmt.Stringer which may + // produce ambiguous output. Avoid calling String to disambiguate. + if k == reflect.Map { + var ambiguous bool + seenKeys := map[string]reflect.Value{} + for i, currKey := range keys { + if currKey.IsValid() { + strKey := list[i].Key + prevKey, seen := seenKeys[strKey] + if seen && prevKey.CanInterface() && currKey.CanInterface() { + ambiguous = prevKey.Interface() != currKey.Interface() + if ambiguous { + break + } + } + seenKeys[strKey] = currKey + } + } + if ambiguous { + for i, k := range keys { + if k.IsValid() { + list[i].Key = formatMapKey(k, true, ptrs) + } + } + } + } + + return &textWrap{Prefix: "{", Value: list, Suffix: "}"} +} + +// coalesceAdjacentRecords coalesces the list of records into groups of +// adjacent equal, or unequal counts. +func coalesceAdjacentRecords(name string, recs []reportRecord) (groups []diffStats) { + var prevCase int // Arbitrary index into which case last occurred + lastStats := func(i int) *diffStats { + if prevCase != i { + groups = append(groups, diffStats{Name: name}) + prevCase = i + } + return &groups[len(groups)-1] + } + for _, r := range recs { + switch rv := r.Value; { + case rv.NumIgnored > 0 && rv.NumSame+rv.NumDiff == 0: + lastStats(1).NumIgnored++ + case rv.NumDiff == 0: + lastStats(1).NumIdentical++ + case rv.NumDiff > 0 && !rv.ValueY.IsValid(): + lastStats(2).NumRemoved++ + case rv.NumDiff > 0 && !rv.ValueX.IsValid(): + lastStats(2).NumInserted++ + default: + lastStats(2).NumModified++ + } + } + return groups +} diff --git a/src/webui/vendor/github.com/google/go-cmp/cmp/report_references.go b/src/webui/vendor/github.com/google/go-cmp/cmp/report_references.go new file mode 100644 index 00000000..be31b33a --- /dev/null +++ b/src/webui/vendor/github.com/google/go-cmp/cmp/report_references.go @@ -0,0 +1,264 @@ +// Copyright 2020, The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package cmp + +import ( + "fmt" + "reflect" + "strings" + + "github.com/google/go-cmp/cmp/internal/flags" + "github.com/google/go-cmp/cmp/internal/value" +) + +const ( + pointerDelimPrefix = "⟪" + pointerDelimSuffix = "⟫" +) + +// formatPointer prints the address of the pointer. +func formatPointer(p value.Pointer, withDelims bool) string { + v := p.Uintptr() + if flags.Deterministic { + v = 0xdeadf00f // Only used for stable testing purposes + } + if withDelims { + return pointerDelimPrefix + formatHex(uint64(v)) + pointerDelimSuffix + } + return formatHex(uint64(v)) +} + +// pointerReferences is a stack of pointers visited so far. +type pointerReferences [][2]value.Pointer + +func (ps *pointerReferences) PushPair(vx, vy reflect.Value, d diffMode, deref bool) (pp [2]value.Pointer) { + if deref && vx.IsValid() { + vx = vx.Addr() + } + if deref && vy.IsValid() { + vy = vy.Addr() + } + switch d { + case diffUnknown, diffIdentical: + pp = [2]value.Pointer{value.PointerOf(vx), value.PointerOf(vy)} + case diffRemoved: + pp = [2]value.Pointer{value.PointerOf(vx), value.Pointer{}} + case diffInserted: + pp = [2]value.Pointer{value.Pointer{}, value.PointerOf(vy)} + } + *ps = append(*ps, pp) + return pp +} + +func (ps *pointerReferences) Push(v reflect.Value) (p value.Pointer, seen bool) { + p = value.PointerOf(v) + for _, pp := range *ps { + if p == pp[0] || p == pp[1] { + return p, true + } + } + *ps = append(*ps, [2]value.Pointer{p, p}) + return p, false +} + +func (ps *pointerReferences) Pop() { + *ps = (*ps)[:len(*ps)-1] +} + +// trunkReferences is metadata for a textNode indicating that the sub-tree +// represents the value for either pointer in a pair of references. +type trunkReferences struct{ pp [2]value.Pointer } + +// trunkReference is metadata for a textNode indicating that the sub-tree +// represents the value for the given pointer reference. +type trunkReference struct{ p value.Pointer } + +// leafReference is metadata for a textNode indicating that the value is +// truncated as it refers to another part of the tree (i.e., a trunk). +type leafReference struct{ p value.Pointer } + +func wrapTrunkReferences(pp [2]value.Pointer, s textNode) textNode { + switch { + case pp[0].IsNil(): + return &textWrap{Value: s, Metadata: trunkReference{pp[1]}} + case pp[1].IsNil(): + return &textWrap{Value: s, Metadata: trunkReference{pp[0]}} + case pp[0] == pp[1]: + return &textWrap{Value: s, Metadata: trunkReference{pp[0]}} + default: + return &textWrap{Value: s, Metadata: trunkReferences{pp}} + } +} +func wrapTrunkReference(p value.Pointer, printAddress bool, s textNode) textNode { + var prefix string + if printAddress { + prefix = formatPointer(p, true) + } + return &textWrap{Prefix: prefix, Value: s, Metadata: trunkReference{p}} +} +func makeLeafReference(p value.Pointer, printAddress bool) textNode { + out := &textWrap{Prefix: "(", Value: textEllipsis, Suffix: ")"} + var prefix string + if printAddress { + prefix = formatPointer(p, true) + } + return &textWrap{Prefix: prefix, Value: out, Metadata: leafReference{p}} +} + +// resolveReferences walks the textNode tree searching for any leaf reference +// metadata and resolves each against the corresponding trunk references. +// Since pointer addresses in memory are not particularly readable to the user, +// it replaces each pointer value with an arbitrary and unique reference ID. +func resolveReferences(s textNode) { + var walkNodes func(textNode, func(textNode)) + walkNodes = func(s textNode, f func(textNode)) { + f(s) + switch s := s.(type) { + case *textWrap: + walkNodes(s.Value, f) + case textList: + for _, r := range s { + walkNodes(r.Value, f) + } + } + } + + // Collect all trunks and leaves with reference metadata. + var trunks, leaves []*textWrap + walkNodes(s, func(s textNode) { + if s, ok := s.(*textWrap); ok { + switch s.Metadata.(type) { + case leafReference: + leaves = append(leaves, s) + case trunkReference, trunkReferences: + trunks = append(trunks, s) + } + } + }) + + // No leaf references to resolve. + if len(leaves) == 0 { + return + } + + // Collect the set of all leaf references to resolve. + leafPtrs := make(map[value.Pointer]bool) + for _, leaf := range leaves { + leafPtrs[leaf.Metadata.(leafReference).p] = true + } + + // Collect the set of trunk pointers that are always paired together. + // This allows us to assign a single ID to both pointers for brevity. + // If a pointer in a pair ever occurs by itself or as a different pair, + // then the pair is broken. + pairedTrunkPtrs := make(map[value.Pointer]value.Pointer) + unpair := func(p value.Pointer) { + if !pairedTrunkPtrs[p].IsNil() { + pairedTrunkPtrs[pairedTrunkPtrs[p]] = value.Pointer{} // invalidate other half + } + pairedTrunkPtrs[p] = value.Pointer{} // invalidate this half + } + for _, trunk := range trunks { + switch p := trunk.Metadata.(type) { + case trunkReference: + unpair(p.p) // standalone pointer cannot be part of a pair + case trunkReferences: + p0, ok0 := pairedTrunkPtrs[p.pp[0]] + p1, ok1 := pairedTrunkPtrs[p.pp[1]] + switch { + case !ok0 && !ok1: + // Register the newly seen pair. + pairedTrunkPtrs[p.pp[0]] = p.pp[1] + pairedTrunkPtrs[p.pp[1]] = p.pp[0] + case ok0 && ok1 && p0 == p.pp[1] && p1 == p.pp[0]: + // Exact pair already seen; do nothing. + default: + // Pair conflicts with some other pair; break all pairs. + unpair(p.pp[0]) + unpair(p.pp[1]) + } + } + } + + // Correlate each pointer referenced by leaves to a unique identifier, + // and print the IDs for each trunk that matches those pointers. + var nextID uint + ptrIDs := make(map[value.Pointer]uint) + newID := func() uint { + id := nextID + nextID++ + return id + } + for _, trunk := range trunks { + switch p := trunk.Metadata.(type) { + case trunkReference: + if print := leafPtrs[p.p]; print { + id, ok := ptrIDs[p.p] + if !ok { + id = newID() + ptrIDs[p.p] = id + } + trunk.Prefix = updateReferencePrefix(trunk.Prefix, formatReference(id)) + } + case trunkReferences: + print0 := leafPtrs[p.pp[0]] + print1 := leafPtrs[p.pp[1]] + if print0 || print1 { + id0, ok0 := ptrIDs[p.pp[0]] + id1, ok1 := ptrIDs[p.pp[1]] + isPair := pairedTrunkPtrs[p.pp[0]] == p.pp[1] && pairedTrunkPtrs[p.pp[1]] == p.pp[0] + if isPair { + var id uint + assert(ok0 == ok1) // must be seen together or not at all + if ok0 { + assert(id0 == id1) // must have the same ID + id = id0 + } else { + id = newID() + ptrIDs[p.pp[0]] = id + ptrIDs[p.pp[1]] = id + } + trunk.Prefix = updateReferencePrefix(trunk.Prefix, formatReference(id)) + } else { + if print0 && !ok0 { + id0 = newID() + ptrIDs[p.pp[0]] = id0 + } + if print1 && !ok1 { + id1 = newID() + ptrIDs[p.pp[1]] = id1 + } + switch { + case print0 && print1: + trunk.Prefix = updateReferencePrefix(trunk.Prefix, formatReference(id0)+","+formatReference(id1)) + case print0: + trunk.Prefix = updateReferencePrefix(trunk.Prefix, formatReference(id0)) + case print1: + trunk.Prefix = updateReferencePrefix(trunk.Prefix, formatReference(id1)) + } + } + } + } + } + + // Update all leaf references with the unique identifier. + for _, leaf := range leaves { + if id, ok := ptrIDs[leaf.Metadata.(leafReference).p]; ok { + leaf.Prefix = updateReferencePrefix(leaf.Prefix, formatReference(id)) + } + } +} + +func formatReference(id uint) string { + return fmt.Sprintf("ref#%d", id) +} + +func updateReferencePrefix(prefix, ref string) string { + if prefix == "" { + return pointerDelimPrefix + ref + pointerDelimSuffix + } + suffix := strings.TrimPrefix(prefix, pointerDelimPrefix) + return pointerDelimPrefix + ref + ": " + suffix +} diff --git a/src/webui/vendor/github.com/google/go-cmp/cmp/report_reflect.go b/src/webui/vendor/github.com/google/go-cmp/cmp/report_reflect.go new file mode 100644 index 00000000..e39f4228 --- /dev/null +++ b/src/webui/vendor/github.com/google/go-cmp/cmp/report_reflect.go @@ -0,0 +1,414 @@ +// Copyright 2019, The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package cmp + +import ( + "bytes" + "fmt" + "reflect" + "strconv" + "strings" + "unicode" + "unicode/utf8" + + "github.com/google/go-cmp/cmp/internal/value" +) + +var ( + anyType = reflect.TypeOf((*interface{})(nil)).Elem() + stringType = reflect.TypeOf((*string)(nil)).Elem() + bytesType = reflect.TypeOf((*[]byte)(nil)).Elem() + byteType = reflect.TypeOf((*byte)(nil)).Elem() +) + +type formatValueOptions struct { + // AvoidStringer controls whether to avoid calling custom stringer + // methods like error.Error or fmt.Stringer.String. + AvoidStringer bool + + // PrintAddresses controls whether to print the address of all pointers, + // slice elements, and maps. + PrintAddresses bool + + // QualifiedNames controls whether FormatType uses the fully qualified name + // (including the full package path as opposed to just the package name). + QualifiedNames bool + + // VerbosityLevel controls the amount of output to produce. + // A higher value produces more output. A value of zero or lower produces + // no output (represented using an ellipsis). + // If LimitVerbosity is false, then the level is treated as infinite. + VerbosityLevel int + + // LimitVerbosity specifies that formatting should respect VerbosityLevel. + LimitVerbosity bool +} + +// FormatType prints the type as if it were wrapping s. +// This may return s as-is depending on the current type and TypeMode mode. +func (opts formatOptions) FormatType(t reflect.Type, s textNode) textNode { + // Check whether to emit the type or not. + switch opts.TypeMode { + case autoType: + switch t.Kind() { + case reflect.Struct, reflect.Slice, reflect.Array, reflect.Map: + if s.Equal(textNil) { + return s + } + default: + return s + } + if opts.DiffMode == diffIdentical { + return s // elide type for identical nodes + } + case elideType: + return s + } + + // Determine the type label, applying special handling for unnamed types. + typeName := value.TypeString(t, opts.QualifiedNames) + if t.Name() == "" { + // According to Go grammar, certain type literals contain symbols that + // do not strongly bind to the next lexicographical token (e.g., *T). + switch t.Kind() { + case reflect.Chan, reflect.Func, reflect.Ptr: + typeName = "(" + typeName + ")" + } + } + return &textWrap{Prefix: typeName, Value: wrapParens(s)} +} + +// wrapParens wraps s with a set of parenthesis, but avoids it if the +// wrapped node itself is already surrounded by a pair of parenthesis or braces. +// It handles unwrapping one level of pointer-reference nodes. +func wrapParens(s textNode) textNode { + var refNode *textWrap + if s2, ok := s.(*textWrap); ok { + // Unwrap a single pointer reference node. + switch s2.Metadata.(type) { + case leafReference, trunkReference, trunkReferences: + refNode = s2 + if s3, ok := refNode.Value.(*textWrap); ok { + s2 = s3 + } + } + + // Already has delimiters that make parenthesis unnecessary. + hasParens := strings.HasPrefix(s2.Prefix, "(") && strings.HasSuffix(s2.Suffix, ")") + hasBraces := strings.HasPrefix(s2.Prefix, "{") && strings.HasSuffix(s2.Suffix, "}") + if hasParens || hasBraces { + return s + } + } + if refNode != nil { + refNode.Value = &textWrap{Prefix: "(", Value: refNode.Value, Suffix: ")"} + return s + } + return &textWrap{Prefix: "(", Value: s, Suffix: ")"} +} + +// FormatValue prints the reflect.Value, taking extra care to avoid descending +// into pointers already in ptrs. As pointers are visited, ptrs is also updated. +func (opts formatOptions) FormatValue(v reflect.Value, parentKind reflect.Kind, ptrs *pointerReferences) (out textNode) { + if !v.IsValid() { + return nil + } + t := v.Type() + + // Check slice element for cycles. + if parentKind == reflect.Slice { + ptrRef, visited := ptrs.Push(v.Addr()) + if visited { + return makeLeafReference(ptrRef, false) + } + defer ptrs.Pop() + defer func() { out = wrapTrunkReference(ptrRef, false, out) }() + } + + // Check whether there is an Error or String method to call. + if !opts.AvoidStringer && v.CanInterface() { + // Avoid calling Error or String methods on nil receivers since many + // implementations crash when doing so. + if (t.Kind() != reflect.Ptr && t.Kind() != reflect.Interface) || !v.IsNil() { + var prefix, strVal string + func() { + // Swallow and ignore any panics from String or Error. + defer func() { recover() }() + switch v := v.Interface().(type) { + case error: + strVal = v.Error() + prefix = "e" + case fmt.Stringer: + strVal = v.String() + prefix = "s" + } + }() + if prefix != "" { + return opts.formatString(prefix, strVal) + } + } + } + + // Check whether to explicitly wrap the result with the type. + var skipType bool + defer func() { + if !skipType { + out = opts.FormatType(t, out) + } + }() + + switch t.Kind() { + case reflect.Bool: + return textLine(fmt.Sprint(v.Bool())) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return textLine(fmt.Sprint(v.Int())) + case reflect.Uint, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return textLine(fmt.Sprint(v.Uint())) + case reflect.Uint8: + if parentKind == reflect.Slice || parentKind == reflect.Array { + return textLine(formatHex(v.Uint())) + } + return textLine(fmt.Sprint(v.Uint())) + case reflect.Uintptr: + return textLine(formatHex(v.Uint())) + case reflect.Float32, reflect.Float64: + return textLine(fmt.Sprint(v.Float())) + case reflect.Complex64, reflect.Complex128: + return textLine(fmt.Sprint(v.Complex())) + case reflect.String: + return opts.formatString("", v.String()) + case reflect.UnsafePointer, reflect.Chan, reflect.Func: + return textLine(formatPointer(value.PointerOf(v), true)) + case reflect.Struct: + var list textList + v := makeAddressable(v) // needed for retrieveUnexportedField + maxLen := v.NumField() + if opts.LimitVerbosity { + maxLen = ((1 << opts.verbosity()) >> 1) << 2 // 0, 4, 8, 16, 32, etc... + opts.VerbosityLevel-- + } + for i := 0; i < v.NumField(); i++ { + vv := v.Field(i) + if vv.IsZero() { + continue // Elide fields with zero values + } + if len(list) == maxLen { + list.AppendEllipsis(diffStats{}) + break + } + sf := t.Field(i) + if !isExported(sf.Name) { + vv = retrieveUnexportedField(v, sf, true) + } + s := opts.WithTypeMode(autoType).FormatValue(vv, t.Kind(), ptrs) + list = append(list, textRecord{Key: sf.Name, Value: s}) + } + return &textWrap{Prefix: "{", Value: list, Suffix: "}"} + case reflect.Slice: + if v.IsNil() { + return textNil + } + + // Check whether this is a []byte of text data. + if t.Elem() == byteType { + b := v.Bytes() + isPrintSpace := func(r rune) bool { return unicode.IsPrint(r) || unicode.IsSpace(r) } + if len(b) > 0 && utf8.Valid(b) && len(bytes.TrimFunc(b, isPrintSpace)) == 0 { + out = opts.formatString("", string(b)) + skipType = true + return opts.FormatType(t, out) + } + } + + fallthrough + case reflect.Array: + maxLen := v.Len() + if opts.LimitVerbosity { + maxLen = ((1 << opts.verbosity()) >> 1) << 2 // 0, 4, 8, 16, 32, etc... + opts.VerbosityLevel-- + } + var list textList + for i := 0; i < v.Len(); i++ { + if len(list) == maxLen { + list.AppendEllipsis(diffStats{}) + break + } + s := opts.WithTypeMode(elideType).FormatValue(v.Index(i), t.Kind(), ptrs) + list = append(list, textRecord{Value: s}) + } + + out = &textWrap{Prefix: "{", Value: list, Suffix: "}"} + if t.Kind() == reflect.Slice && opts.PrintAddresses { + header := fmt.Sprintf("ptr:%v, len:%d, cap:%d", formatPointer(value.PointerOf(v), false), v.Len(), v.Cap()) + out = &textWrap{Prefix: pointerDelimPrefix + header + pointerDelimSuffix, Value: out} + } + return out + case reflect.Map: + if v.IsNil() { + return textNil + } + + // Check pointer for cycles. + ptrRef, visited := ptrs.Push(v) + if visited { + return makeLeafReference(ptrRef, opts.PrintAddresses) + } + defer ptrs.Pop() + + maxLen := v.Len() + if opts.LimitVerbosity { + maxLen = ((1 << opts.verbosity()) >> 1) << 2 // 0, 4, 8, 16, 32, etc... + opts.VerbosityLevel-- + } + var list textList + for _, k := range value.SortKeys(v.MapKeys()) { + if len(list) == maxLen { + list.AppendEllipsis(diffStats{}) + break + } + sk := formatMapKey(k, false, ptrs) + sv := opts.WithTypeMode(elideType).FormatValue(v.MapIndex(k), t.Kind(), ptrs) + list = append(list, textRecord{Key: sk, Value: sv}) + } + + out = &textWrap{Prefix: "{", Value: list, Suffix: "}"} + out = wrapTrunkReference(ptrRef, opts.PrintAddresses, out) + return out + case reflect.Ptr: + if v.IsNil() { + return textNil + } + + // Check pointer for cycles. + ptrRef, visited := ptrs.Push(v) + if visited { + out = makeLeafReference(ptrRef, opts.PrintAddresses) + return &textWrap{Prefix: "&", Value: out} + } + defer ptrs.Pop() + + // Skip the name only if this is an unnamed pointer type. + // Otherwise taking the address of a value does not reproduce + // the named pointer type. + if v.Type().Name() == "" { + skipType = true // Let the underlying value print the type instead + } + out = opts.FormatValue(v.Elem(), t.Kind(), ptrs) + out = wrapTrunkReference(ptrRef, opts.PrintAddresses, out) + out = &textWrap{Prefix: "&", Value: out} + return out + case reflect.Interface: + if v.IsNil() { + return textNil + } + // Interfaces accept different concrete types, + // so configure the underlying value to explicitly print the type. + return opts.WithTypeMode(emitType).FormatValue(v.Elem(), t.Kind(), ptrs) + default: + panic(fmt.Sprintf("%v kind not handled", v.Kind())) + } +} + +func (opts formatOptions) formatString(prefix, s string) textNode { + maxLen := len(s) + maxLines := strings.Count(s, "\n") + 1 + if opts.LimitVerbosity { + maxLen = (1 << opts.verbosity()) << 5 // 32, 64, 128, 256, etc... + maxLines = (1 << opts.verbosity()) << 2 // 4, 8, 16, 32, 64, etc... + } + + // For multiline strings, use the triple-quote syntax, + // but only use it when printing removed or inserted nodes since + // we only want the extra verbosity for those cases. + lines := strings.Split(strings.TrimSuffix(s, "\n"), "\n") + isTripleQuoted := len(lines) >= 4 && (opts.DiffMode == '-' || opts.DiffMode == '+') + for i := 0; i < len(lines) && isTripleQuoted; i++ { + lines[i] = strings.TrimPrefix(strings.TrimSuffix(lines[i], "\r"), "\r") // trim leading/trailing carriage returns for legacy Windows endline support + isPrintable := func(r rune) bool { + return unicode.IsPrint(r) || r == '\t' // specially treat tab as printable + } + line := lines[i] + isTripleQuoted = !strings.HasPrefix(strings.TrimPrefix(line, prefix), `"""`) && !strings.HasPrefix(line, "...") && strings.TrimFunc(line, isPrintable) == "" && len(line) <= maxLen + } + if isTripleQuoted { + var list textList + list = append(list, textRecord{Diff: opts.DiffMode, Value: textLine(prefix + `"""`), ElideComma: true}) + for i, line := range lines { + if numElided := len(lines) - i; i == maxLines-1 && numElided > 1 { + comment := commentString(fmt.Sprintf("%d elided lines", numElided)) + list = append(list, textRecord{Diff: opts.DiffMode, Value: textEllipsis, ElideComma: true, Comment: comment}) + break + } + list = append(list, textRecord{Diff: opts.DiffMode, Value: textLine(line), ElideComma: true}) + } + list = append(list, textRecord{Diff: opts.DiffMode, Value: textLine(prefix + `"""`), ElideComma: true}) + return &textWrap{Prefix: "(", Value: list, Suffix: ")"} + } + + // Format the string as a single-line quoted string. + if len(s) > maxLen+len(textEllipsis) { + return textLine(prefix + formatString(s[:maxLen]) + string(textEllipsis)) + } + return textLine(prefix + formatString(s)) +} + +// formatMapKey formats v as if it were a map key. +// The result is guaranteed to be a single line. +func formatMapKey(v reflect.Value, disambiguate bool, ptrs *pointerReferences) string { + var opts formatOptions + opts.DiffMode = diffIdentical + opts.TypeMode = elideType + opts.PrintAddresses = disambiguate + opts.AvoidStringer = disambiguate + opts.QualifiedNames = disambiguate + opts.VerbosityLevel = maxVerbosityPreset + opts.LimitVerbosity = true + s := opts.FormatValue(v, reflect.Map, ptrs).String() + return strings.TrimSpace(s) +} + +// formatString prints s as a double-quoted or backtick-quoted string. +func formatString(s string) string { + // Use quoted string if it the same length as a raw string literal. + // Otherwise, attempt to use the raw string form. + qs := strconv.Quote(s) + if len(qs) == 1+len(s)+1 { + return qs + } + + // Disallow newlines to ensure output is a single line. + // Only allow printable runes for readability purposes. + rawInvalid := func(r rune) bool { + return r == '`' || r == '\n' || !(unicode.IsPrint(r) || r == '\t') + } + if utf8.ValidString(s) && strings.IndexFunc(s, rawInvalid) < 0 { + return "`" + s + "`" + } + return qs +} + +// formatHex prints u as a hexadecimal integer in Go notation. +func formatHex(u uint64) string { + var f string + switch { + case u <= 0xff: + f = "0x%02x" + case u <= 0xffff: + f = "0x%04x" + case u <= 0xffffff: + f = "0x%06x" + case u <= 0xffffffff: + f = "0x%08x" + case u <= 0xffffffffff: + f = "0x%010x" + case u <= 0xffffffffffff: + f = "0x%012x" + case u <= 0xffffffffffffff: + f = "0x%014x" + case u <= 0xffffffffffffffff: + f = "0x%016x" + } + return fmt.Sprintf(f, u) +} diff --git a/src/webui/vendor/github.com/google/go-cmp/cmp/report_slices.go b/src/webui/vendor/github.com/google/go-cmp/cmp/report_slices.go new file mode 100644 index 00000000..23e444f6 --- /dev/null +++ b/src/webui/vendor/github.com/google/go-cmp/cmp/report_slices.go @@ -0,0 +1,614 @@ +// Copyright 2019, The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package cmp + +import ( + "bytes" + "fmt" + "math" + "reflect" + "strconv" + "strings" + "unicode" + "unicode/utf8" + + "github.com/google/go-cmp/cmp/internal/diff" +) + +// CanFormatDiffSlice reports whether we support custom formatting for nodes +// that are slices of primitive kinds or strings. +func (opts formatOptions) CanFormatDiffSlice(v *valueNode) bool { + switch { + case opts.DiffMode != diffUnknown: + return false // Must be formatting in diff mode + case v.NumDiff == 0: + return false // No differences detected + case !v.ValueX.IsValid() || !v.ValueY.IsValid(): + return false // Both values must be valid + case v.NumIgnored > 0: + return false // Some ignore option was used + case v.NumTransformed > 0: + return false // Some transform option was used + case v.NumCompared > 1: + return false // More than one comparison was used + case v.NumCompared == 1 && v.Type.Name() != "": + // The need for cmp to check applicability of options on every element + // in a slice is a significant performance detriment for large []byte. + // The workaround is to specify Comparer(bytes.Equal), + // which enables cmp to compare []byte more efficiently. + // If they differ, we still want to provide batched diffing. + // The logic disallows named types since they tend to have their own + // String method, with nicer formatting than what this provides. + return false + } + + // Check whether this is an interface with the same concrete types. + t := v.Type + vx, vy := v.ValueX, v.ValueY + if t.Kind() == reflect.Interface && !vx.IsNil() && !vy.IsNil() && vx.Elem().Type() == vy.Elem().Type() { + vx, vy = vx.Elem(), vy.Elem() + t = vx.Type() + } + + // Check whether we provide specialized diffing for this type. + switch t.Kind() { + case reflect.String: + case reflect.Array, reflect.Slice: + // Only slices of primitive types have specialized handling. + switch t.Elem().Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr, + reflect.Bool, reflect.Float32, reflect.Float64, reflect.Complex64, reflect.Complex128: + default: + return false + } + + // Both slice values have to be non-empty. + if t.Kind() == reflect.Slice && (vx.Len() == 0 || vy.Len() == 0) { + return false + } + + // If a sufficient number of elements already differ, + // use specialized formatting even if length requirement is not met. + if v.NumDiff > v.NumSame { + return true + } + default: + return false + } + + // Use specialized string diffing for longer slices or strings. + const minLength = 32 + return vx.Len() >= minLength && vy.Len() >= minLength +} + +// FormatDiffSlice prints a diff for the slices (or strings) represented by v. +// This provides custom-tailored logic to make printing of differences in +// textual strings and slices of primitive kinds more readable. +func (opts formatOptions) FormatDiffSlice(v *valueNode) textNode { + assert(opts.DiffMode == diffUnknown) + t, vx, vy := v.Type, v.ValueX, v.ValueY + if t.Kind() == reflect.Interface { + vx, vy = vx.Elem(), vy.Elem() + t = vx.Type() + opts = opts.WithTypeMode(emitType) + } + + // Auto-detect the type of the data. + var sx, sy string + var ssx, ssy []string + var isString, isMostlyText, isPureLinedText, isBinary bool + switch { + case t.Kind() == reflect.String: + sx, sy = vx.String(), vy.String() + isString = true + case t.Kind() == reflect.Slice && t.Elem() == byteType: + sx, sy = string(vx.Bytes()), string(vy.Bytes()) + isString = true + case t.Kind() == reflect.Array: + // Arrays need to be addressable for slice operations to work. + vx2, vy2 := reflect.New(t).Elem(), reflect.New(t).Elem() + vx2.Set(vx) + vy2.Set(vy) + vx, vy = vx2, vy2 + } + if isString { + var numTotalRunes, numValidRunes, numLines, lastLineIdx, maxLineLen int + for i, r := range sx + sy { + numTotalRunes++ + if (unicode.IsPrint(r) || unicode.IsSpace(r)) && r != utf8.RuneError { + numValidRunes++ + } + if r == '\n' { + if maxLineLen < i-lastLineIdx { + maxLineLen = i - lastLineIdx + } + lastLineIdx = i + 1 + numLines++ + } + } + isPureText := numValidRunes == numTotalRunes + isMostlyText = float64(numValidRunes) > math.Floor(0.90*float64(numTotalRunes)) + isPureLinedText = isPureText && numLines >= 4 && maxLineLen <= 1024 + isBinary = !isMostlyText + + // Avoid diffing by lines if it produces a significantly more complex + // edit script than diffing by bytes. + if isPureLinedText { + ssx = strings.Split(sx, "\n") + ssy = strings.Split(sy, "\n") + esLines := diff.Difference(len(ssx), len(ssy), func(ix, iy int) diff.Result { + return diff.BoolResult(ssx[ix] == ssy[iy]) + }) + esBytes := diff.Difference(len(sx), len(sy), func(ix, iy int) diff.Result { + return diff.BoolResult(sx[ix] == sy[iy]) + }) + efficiencyLines := float64(esLines.Dist()) / float64(len(esLines)) + efficiencyBytes := float64(esBytes.Dist()) / float64(len(esBytes)) + quotedLength := len(strconv.Quote(sx + sy)) + unquotedLength := len(sx) + len(sy) + escapeExpansionRatio := float64(quotedLength) / float64(unquotedLength) + isPureLinedText = efficiencyLines < 4*efficiencyBytes || escapeExpansionRatio > 1.1 + } + } + + // Format the string into printable records. + var list textList + var delim string + switch { + // If the text appears to be multi-lined text, + // then perform differencing across individual lines. + case isPureLinedText: + list = opts.formatDiffSlice( + reflect.ValueOf(ssx), reflect.ValueOf(ssy), 1, "line", + func(v reflect.Value, d diffMode) textRecord { + s := formatString(v.Index(0).String()) + return textRecord{Diff: d, Value: textLine(s)} + }, + ) + delim = "\n" + + // If possible, use a custom triple-quote (""") syntax for printing + // differences in a string literal. This format is more readable, + // but has edge-cases where differences are visually indistinguishable. + // This format is avoided under the following conditions: + // - A line starts with `"""` + // - A line starts with "..." + // - A line contains non-printable characters + // - Adjacent different lines differ only by whitespace + // + // For example: + // + // """ + // ... // 3 identical lines + // foo + // bar + // - baz + // + BAZ + // """ + isTripleQuoted := true + prevRemoveLines := map[string]bool{} + prevInsertLines := map[string]bool{} + var list2 textList + list2 = append(list2, textRecord{Value: textLine(`"""`), ElideComma: true}) + for _, r := range list { + if !r.Value.Equal(textEllipsis) { + line, _ := strconv.Unquote(string(r.Value.(textLine))) + line = strings.TrimPrefix(strings.TrimSuffix(line, "\r"), "\r") // trim leading/trailing carriage returns for legacy Windows endline support + normLine := strings.Map(func(r rune) rune { + if unicode.IsSpace(r) { + return -1 // drop whitespace to avoid visually indistinguishable output + } + return r + }, line) + isPrintable := func(r rune) bool { + return unicode.IsPrint(r) || r == '\t' // specially treat tab as printable + } + isTripleQuoted = !strings.HasPrefix(line, `"""`) && !strings.HasPrefix(line, "...") && strings.TrimFunc(line, isPrintable) == "" + switch r.Diff { + case diffRemoved: + isTripleQuoted = isTripleQuoted && !prevInsertLines[normLine] + prevRemoveLines[normLine] = true + case diffInserted: + isTripleQuoted = isTripleQuoted && !prevRemoveLines[normLine] + prevInsertLines[normLine] = true + } + if !isTripleQuoted { + break + } + r.Value = textLine(line) + r.ElideComma = true + } + if !(r.Diff == diffRemoved || r.Diff == diffInserted) { // start a new non-adjacent difference group + prevRemoveLines = map[string]bool{} + prevInsertLines = map[string]bool{} + } + list2 = append(list2, r) + } + if r := list2[len(list2)-1]; r.Diff == diffIdentical && len(r.Value.(textLine)) == 0 { + list2 = list2[:len(list2)-1] // elide single empty line at the end + } + list2 = append(list2, textRecord{Value: textLine(`"""`), ElideComma: true}) + if isTripleQuoted { + var out textNode = &textWrap{Prefix: "(", Value: list2, Suffix: ")"} + switch t.Kind() { + case reflect.String: + if t != stringType { + out = opts.FormatType(t, out) + } + case reflect.Slice: + // Always emit type for slices since the triple-quote syntax + // looks like a string (not a slice). + opts = opts.WithTypeMode(emitType) + out = opts.FormatType(t, out) + } + return out + } + + // If the text appears to be single-lined text, + // then perform differencing in approximately fixed-sized chunks. + // The output is printed as quoted strings. + case isMostlyText: + list = opts.formatDiffSlice( + reflect.ValueOf(sx), reflect.ValueOf(sy), 64, "byte", + func(v reflect.Value, d diffMode) textRecord { + s := formatString(v.String()) + return textRecord{Diff: d, Value: textLine(s)} + }, + ) + + // If the text appears to be binary data, + // then perform differencing in approximately fixed-sized chunks. + // The output is inspired by hexdump. + case isBinary: + list = opts.formatDiffSlice( + reflect.ValueOf(sx), reflect.ValueOf(sy), 16, "byte", + func(v reflect.Value, d diffMode) textRecord { + var ss []string + for i := 0; i < v.Len(); i++ { + ss = append(ss, formatHex(v.Index(i).Uint())) + } + s := strings.Join(ss, ", ") + comment := commentString(fmt.Sprintf("%c|%v|", d, formatASCII(v.String()))) + return textRecord{Diff: d, Value: textLine(s), Comment: comment} + }, + ) + + // For all other slices of primitive types, + // then perform differencing in approximately fixed-sized chunks. + // The size of each chunk depends on the width of the element kind. + default: + var chunkSize int + if t.Elem().Kind() == reflect.Bool { + chunkSize = 16 + } else { + switch t.Elem().Bits() { + case 8: + chunkSize = 16 + case 16: + chunkSize = 12 + case 32: + chunkSize = 8 + default: + chunkSize = 8 + } + } + list = opts.formatDiffSlice( + vx, vy, chunkSize, t.Elem().Kind().String(), + func(v reflect.Value, d diffMode) textRecord { + var ss []string + for i := 0; i < v.Len(); i++ { + switch t.Elem().Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + ss = append(ss, fmt.Sprint(v.Index(i).Int())) + case reflect.Uint, reflect.Uint16, reflect.Uint32, reflect.Uint64: + ss = append(ss, fmt.Sprint(v.Index(i).Uint())) + case reflect.Uint8, reflect.Uintptr: + ss = append(ss, formatHex(v.Index(i).Uint())) + case reflect.Bool, reflect.Float32, reflect.Float64, reflect.Complex64, reflect.Complex128: + ss = append(ss, fmt.Sprint(v.Index(i).Interface())) + } + } + s := strings.Join(ss, ", ") + return textRecord{Diff: d, Value: textLine(s)} + }, + ) + } + + // Wrap the output with appropriate type information. + var out textNode = &textWrap{Prefix: "{", Value: list, Suffix: "}"} + if !isMostlyText { + // The "{...}" byte-sequence literal is not valid Go syntax for strings. + // Emit the type for extra clarity (e.g. "string{...}"). + if t.Kind() == reflect.String { + opts = opts.WithTypeMode(emitType) + } + return opts.FormatType(t, out) + } + switch t.Kind() { + case reflect.String: + out = &textWrap{Prefix: "strings.Join(", Value: out, Suffix: fmt.Sprintf(", %q)", delim)} + if t != stringType { + out = opts.FormatType(t, out) + } + case reflect.Slice: + out = &textWrap{Prefix: "bytes.Join(", Value: out, Suffix: fmt.Sprintf(", %q)", delim)} + if t != bytesType { + out = opts.FormatType(t, out) + } + } + return out +} + +// formatASCII formats s as an ASCII string. +// This is useful for printing binary strings in a semi-legible way. +func formatASCII(s string) string { + b := bytes.Repeat([]byte{'.'}, len(s)) + for i := 0; i < len(s); i++ { + if ' ' <= s[i] && s[i] <= '~' { + b[i] = s[i] + } + } + return string(b) +} + +func (opts formatOptions) formatDiffSlice( + vx, vy reflect.Value, chunkSize int, name string, + makeRec func(reflect.Value, diffMode) textRecord, +) (list textList) { + eq := func(ix, iy int) bool { + return vx.Index(ix).Interface() == vy.Index(iy).Interface() + } + es := diff.Difference(vx.Len(), vy.Len(), func(ix, iy int) diff.Result { + return diff.BoolResult(eq(ix, iy)) + }) + + appendChunks := func(v reflect.Value, d diffMode) int { + n0 := v.Len() + for v.Len() > 0 { + n := chunkSize + if n > v.Len() { + n = v.Len() + } + list = append(list, makeRec(v.Slice(0, n), d)) + v = v.Slice(n, v.Len()) + } + return n0 - v.Len() + } + + var numDiffs int + maxLen := -1 + if opts.LimitVerbosity { + maxLen = (1 << opts.verbosity()) << 2 // 4, 8, 16, 32, 64, etc... + opts.VerbosityLevel-- + } + + groups := coalesceAdjacentEdits(name, es) + groups = coalesceInterveningIdentical(groups, chunkSize/4) + groups = cleanupSurroundingIdentical(groups, eq) + maxGroup := diffStats{Name: name} + for i, ds := range groups { + if maxLen >= 0 && numDiffs >= maxLen { + maxGroup = maxGroup.Append(ds) + continue + } + + // Print equal. + if ds.NumDiff() == 0 { + // Compute the number of leading and trailing equal bytes to print. + var numLo, numHi int + numEqual := ds.NumIgnored + ds.NumIdentical + for numLo < chunkSize*numContextRecords && numLo+numHi < numEqual && i != 0 { + numLo++ + } + for numHi < chunkSize*numContextRecords && numLo+numHi < numEqual && i != len(groups)-1 { + numHi++ + } + if numEqual-(numLo+numHi) <= chunkSize && ds.NumIgnored == 0 { + numHi = numEqual - numLo // Avoid pointless coalescing of single equal row + } + + // Print the equal bytes. + appendChunks(vx.Slice(0, numLo), diffIdentical) + if numEqual > numLo+numHi { + ds.NumIdentical -= numLo + numHi + list.AppendEllipsis(ds) + } + appendChunks(vx.Slice(numEqual-numHi, numEqual), diffIdentical) + vx = vx.Slice(numEqual, vx.Len()) + vy = vy.Slice(numEqual, vy.Len()) + continue + } + + // Print unequal. + len0 := len(list) + nx := appendChunks(vx.Slice(0, ds.NumIdentical+ds.NumRemoved+ds.NumModified), diffRemoved) + vx = vx.Slice(nx, vx.Len()) + ny := appendChunks(vy.Slice(0, ds.NumIdentical+ds.NumInserted+ds.NumModified), diffInserted) + vy = vy.Slice(ny, vy.Len()) + numDiffs += len(list) - len0 + } + if maxGroup.IsZero() { + assert(vx.Len() == 0 && vy.Len() == 0) + } else { + list.AppendEllipsis(maxGroup) + } + return list +} + +// coalesceAdjacentEdits coalesces the list of edits into groups of adjacent +// equal or unequal counts. +// +// Example: +// +// Input: "..XXY...Y" +// Output: [ +// {NumIdentical: 2}, +// {NumRemoved: 2, NumInserted 1}, +// {NumIdentical: 3}, +// {NumInserted: 1}, +// ] +func coalesceAdjacentEdits(name string, es diff.EditScript) (groups []diffStats) { + var prevMode byte + lastStats := func(mode byte) *diffStats { + if prevMode != mode { + groups = append(groups, diffStats{Name: name}) + prevMode = mode + } + return &groups[len(groups)-1] + } + for _, e := range es { + switch e { + case diff.Identity: + lastStats('=').NumIdentical++ + case diff.UniqueX: + lastStats('!').NumRemoved++ + case diff.UniqueY: + lastStats('!').NumInserted++ + case diff.Modified: + lastStats('!').NumModified++ + } + } + return groups +} + +// coalesceInterveningIdentical coalesces sufficiently short (<= windowSize) +// equal groups into adjacent unequal groups that currently result in a +// dual inserted/removed printout. This acts as a high-pass filter to smooth +// out high-frequency changes within the windowSize. +// +// Example: +// +// WindowSize: 16, +// Input: [ +// {NumIdentical: 61}, // group 0 +// {NumRemoved: 3, NumInserted: 1}, // group 1 +// {NumIdentical: 6}, // ├── coalesce +// {NumInserted: 2}, // ├── coalesce +// {NumIdentical: 1}, // ├── coalesce +// {NumRemoved: 9}, // └── coalesce +// {NumIdentical: 64}, // group 2 +// {NumRemoved: 3, NumInserted: 1}, // group 3 +// {NumIdentical: 6}, // ├── coalesce +// {NumInserted: 2}, // ├── coalesce +// {NumIdentical: 1}, // ├── coalesce +// {NumRemoved: 7}, // ├── coalesce +// {NumIdentical: 1}, // ├── coalesce +// {NumRemoved: 2}, // └── coalesce +// {NumIdentical: 63}, // group 4 +// ] +// Output: [ +// {NumIdentical: 61}, +// {NumIdentical: 7, NumRemoved: 12, NumInserted: 3}, +// {NumIdentical: 64}, +// {NumIdentical: 8, NumRemoved: 12, NumInserted: 3}, +// {NumIdentical: 63}, +// ] +func coalesceInterveningIdentical(groups []diffStats, windowSize int) []diffStats { + groups, groupsOrig := groups[:0], groups + for i, ds := range groupsOrig { + if len(groups) >= 2 && ds.NumDiff() > 0 { + prev := &groups[len(groups)-2] // Unequal group + curr := &groups[len(groups)-1] // Equal group + next := &groupsOrig[i] // Unequal group + hadX, hadY := prev.NumRemoved > 0, prev.NumInserted > 0 + hasX, hasY := next.NumRemoved > 0, next.NumInserted > 0 + if ((hadX || hasX) && (hadY || hasY)) && curr.NumIdentical <= windowSize { + *prev = prev.Append(*curr).Append(*next) + groups = groups[:len(groups)-1] // Truncate off equal group + continue + } + } + groups = append(groups, ds) + } + return groups +} + +// cleanupSurroundingIdentical scans through all unequal groups, and +// moves any leading sequence of equal elements to the preceding equal group and +// moves and trailing sequence of equal elements to the succeeding equal group. +// +// This is necessary since coalesceInterveningIdentical may coalesce edit groups +// together such that leading/trailing spans of equal elements becomes possible. +// Note that this can occur even with an optimal diffing algorithm. +// +// Example: +// +// Input: [ +// {NumIdentical: 61}, +// {NumIdentical: 1 , NumRemoved: 11, NumInserted: 2}, // assume 3 leading identical elements +// {NumIdentical: 67}, +// {NumIdentical: 7, NumRemoved: 12, NumInserted: 3}, // assume 10 trailing identical elements +// {NumIdentical: 54}, +// ] +// Output: [ +// {NumIdentical: 64}, // incremented by 3 +// {NumRemoved: 9}, +// {NumIdentical: 67}, +// {NumRemoved: 9}, +// {NumIdentical: 64}, // incremented by 10 +// ] +func cleanupSurroundingIdentical(groups []diffStats, eq func(i, j int) bool) []diffStats { + var ix, iy int // indexes into sequence x and y + for i, ds := range groups { + // Handle equal group. + if ds.NumDiff() == 0 { + ix += ds.NumIdentical + iy += ds.NumIdentical + continue + } + + // Handle unequal group. + nx := ds.NumIdentical + ds.NumRemoved + ds.NumModified + ny := ds.NumIdentical + ds.NumInserted + ds.NumModified + var numLeadingIdentical, numTrailingIdentical int + for j := 0; j < nx && j < ny && eq(ix+j, iy+j); j++ { + numLeadingIdentical++ + } + for j := 0; j < nx && j < ny && eq(ix+nx-1-j, iy+ny-1-j); j++ { + numTrailingIdentical++ + } + if numIdentical := numLeadingIdentical + numTrailingIdentical; numIdentical > 0 { + if numLeadingIdentical > 0 { + // Remove leading identical span from this group and + // insert it into the preceding group. + if i-1 >= 0 { + groups[i-1].NumIdentical += numLeadingIdentical + } else { + // No preceding group exists, so prepend a new group, + // but do so after we finish iterating over all groups. + defer func() { + groups = append([]diffStats{{Name: groups[0].Name, NumIdentical: numLeadingIdentical}}, groups...) + }() + } + // Increment indexes since the preceding group would have handled this. + ix += numLeadingIdentical + iy += numLeadingIdentical + } + if numTrailingIdentical > 0 { + // Remove trailing identical span from this group and + // insert it into the succeeding group. + if i+1 < len(groups) { + groups[i+1].NumIdentical += numTrailingIdentical + } else { + // No succeeding group exists, so append a new group, + // but do so after we finish iterating over all groups. + defer func() { + groups = append(groups, diffStats{Name: groups[len(groups)-1].Name, NumIdentical: numTrailingIdentical}) + }() + } + // Do not increment indexes since the succeeding group will handle this. + } + + // Update this group since some identical elements were removed. + nx -= numIdentical + ny -= numIdentical + groups[i] = diffStats{Name: ds.Name, NumRemoved: nx, NumInserted: ny} + } + ix += nx + iy += ny + } + return groups +} diff --git a/src/webui/vendor/github.com/google/go-cmp/cmp/report_text.go b/src/webui/vendor/github.com/google/go-cmp/cmp/report_text.go new file mode 100644 index 00000000..388fcf57 --- /dev/null +++ b/src/webui/vendor/github.com/google/go-cmp/cmp/report_text.go @@ -0,0 +1,432 @@ +// Copyright 2019, The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package cmp + +import ( + "bytes" + "fmt" + "math/rand" + "strings" + "time" + "unicode/utf8" + + "github.com/google/go-cmp/cmp/internal/flags" +) + +var randBool = rand.New(rand.NewSource(time.Now().Unix())).Intn(2) == 0 + +const maxColumnLength = 80 + +type indentMode int + +func (n indentMode) appendIndent(b []byte, d diffMode) []byte { + // The output of Diff is documented as being unstable to provide future + // flexibility in changing the output for more humanly readable reports. + // This logic intentionally introduces instability to the exact output + // so that users can detect accidental reliance on stability early on, + // rather than much later when an actual change to the format occurs. + if flags.Deterministic || randBool { + // Use regular spaces (U+0020). + switch d { + case diffUnknown, diffIdentical: + b = append(b, " "...) + case diffRemoved: + b = append(b, "- "...) + case diffInserted: + b = append(b, "+ "...) + } + } else { + // Use non-breaking spaces (U+00a0). + switch d { + case diffUnknown, diffIdentical: + b = append(b, "  "...) + case diffRemoved: + b = append(b, "- "...) + case diffInserted: + b = append(b, "+ "...) + } + } + return repeatCount(n).appendChar(b, '\t') +} + +type repeatCount int + +func (n repeatCount) appendChar(b []byte, c byte) []byte { + for ; n > 0; n-- { + b = append(b, c) + } + return b +} + +// textNode is a simplified tree-based representation of structured text. +// Possible node types are textWrap, textList, or textLine. +type textNode interface { + // Len reports the length in bytes of a single-line version of the tree. + // Nested textRecord.Diff and textRecord.Comment fields are ignored. + Len() int + // Equal reports whether the two trees are structurally identical. + // Nested textRecord.Diff and textRecord.Comment fields are compared. + Equal(textNode) bool + // String returns the string representation of the text tree. + // It is not guaranteed that len(x.String()) == x.Len(), + // nor that x.String() == y.String() implies that x.Equal(y). + String() string + + // formatCompactTo formats the contents of the tree as a single-line string + // to the provided buffer. Any nested textRecord.Diff and textRecord.Comment + // fields are ignored. + // + // However, not all nodes in the tree should be collapsed as a single-line. + // If a node can be collapsed as a single-line, it is replaced by a textLine + // node. Since the top-level node cannot replace itself, this also returns + // the current node itself. + // + // This does not mutate the receiver. + formatCompactTo([]byte, diffMode) ([]byte, textNode) + // formatExpandedTo formats the contents of the tree as a multi-line string + // to the provided buffer. In order for column alignment to operate well, + // formatCompactTo must be called before calling formatExpandedTo. + formatExpandedTo([]byte, diffMode, indentMode) []byte +} + +// textWrap is a wrapper that concatenates a prefix and/or a suffix +// to the underlying node. +type textWrap struct { + Prefix string // e.g., "bytes.Buffer{" + Value textNode // textWrap | textList | textLine + Suffix string // e.g., "}" + Metadata interface{} // arbitrary metadata; has no effect on formatting +} + +func (s *textWrap) Len() int { + return len(s.Prefix) + s.Value.Len() + len(s.Suffix) +} +func (s1 *textWrap) Equal(s2 textNode) bool { + if s2, ok := s2.(*textWrap); ok { + return s1.Prefix == s2.Prefix && s1.Value.Equal(s2.Value) && s1.Suffix == s2.Suffix + } + return false +} +func (s *textWrap) String() string { + var d diffMode + var n indentMode + _, s2 := s.formatCompactTo(nil, d) + b := n.appendIndent(nil, d) // Leading indent + b = s2.formatExpandedTo(b, d, n) // Main body + b = append(b, '\n') // Trailing newline + return string(b) +} +func (s *textWrap) formatCompactTo(b []byte, d diffMode) ([]byte, textNode) { + n0 := len(b) // Original buffer length + b = append(b, s.Prefix...) + b, s.Value = s.Value.formatCompactTo(b, d) + b = append(b, s.Suffix...) + if _, ok := s.Value.(textLine); ok { + return b, textLine(b[n0:]) + } + return b, s +} +func (s *textWrap) formatExpandedTo(b []byte, d diffMode, n indentMode) []byte { + b = append(b, s.Prefix...) + b = s.Value.formatExpandedTo(b, d, n) + b = append(b, s.Suffix...) + return b +} + +// textList is a comma-separated list of textWrap or textLine nodes. +// The list may be formatted as multi-lines or single-line at the discretion +// of the textList.formatCompactTo method. +type textList []textRecord +type textRecord struct { + Diff diffMode // e.g., 0 or '-' or '+' + Key string // e.g., "MyField" + Value textNode // textWrap | textLine + ElideComma bool // avoid trailing comma + Comment fmt.Stringer // e.g., "6 identical fields" +} + +// AppendEllipsis appends a new ellipsis node to the list if none already +// exists at the end. If cs is non-zero it coalesces the statistics with the +// previous diffStats. +func (s *textList) AppendEllipsis(ds diffStats) { + hasStats := !ds.IsZero() + if len(*s) == 0 || !(*s)[len(*s)-1].Value.Equal(textEllipsis) { + if hasStats { + *s = append(*s, textRecord{Value: textEllipsis, ElideComma: true, Comment: ds}) + } else { + *s = append(*s, textRecord{Value: textEllipsis, ElideComma: true}) + } + return + } + if hasStats { + (*s)[len(*s)-1].Comment = (*s)[len(*s)-1].Comment.(diffStats).Append(ds) + } +} + +func (s textList) Len() (n int) { + for i, r := range s { + n += len(r.Key) + if r.Key != "" { + n += len(": ") + } + n += r.Value.Len() + if i < len(s)-1 { + n += len(", ") + } + } + return n +} + +func (s1 textList) Equal(s2 textNode) bool { + if s2, ok := s2.(textList); ok { + if len(s1) != len(s2) { + return false + } + for i := range s1 { + r1, r2 := s1[i], s2[i] + if !(r1.Diff == r2.Diff && r1.Key == r2.Key && r1.Value.Equal(r2.Value) && r1.Comment == r2.Comment) { + return false + } + } + return true + } + return false +} + +func (s textList) String() string { + return (&textWrap{Prefix: "{", Value: s, Suffix: "}"}).String() +} + +func (s textList) formatCompactTo(b []byte, d diffMode) ([]byte, textNode) { + s = append(textList(nil), s...) // Avoid mutating original + + // Determine whether we can collapse this list as a single line. + n0 := len(b) // Original buffer length + var multiLine bool + for i, r := range s { + if r.Diff == diffInserted || r.Diff == diffRemoved { + multiLine = true + } + b = append(b, r.Key...) + if r.Key != "" { + b = append(b, ": "...) + } + b, s[i].Value = r.Value.formatCompactTo(b, d|r.Diff) + if _, ok := s[i].Value.(textLine); !ok { + multiLine = true + } + if r.Comment != nil { + multiLine = true + } + if i < len(s)-1 { + b = append(b, ", "...) + } + } + // Force multi-lined output when printing a removed/inserted node that + // is sufficiently long. + if (d == diffInserted || d == diffRemoved) && len(b[n0:]) > maxColumnLength { + multiLine = true + } + if !multiLine { + return b, textLine(b[n0:]) + } + return b, s +} + +func (s textList) formatExpandedTo(b []byte, d diffMode, n indentMode) []byte { + alignKeyLens := s.alignLens( + func(r textRecord) bool { + _, isLine := r.Value.(textLine) + return r.Key == "" || !isLine + }, + func(r textRecord) int { return utf8.RuneCountInString(r.Key) }, + ) + alignValueLens := s.alignLens( + func(r textRecord) bool { + _, isLine := r.Value.(textLine) + return !isLine || r.Value.Equal(textEllipsis) || r.Comment == nil + }, + func(r textRecord) int { return utf8.RuneCount(r.Value.(textLine)) }, + ) + + // Format lists of simple lists in a batched form. + // If the list is sequence of only textLine values, + // then batch multiple values on a single line. + var isSimple bool + for _, r := range s { + _, isLine := r.Value.(textLine) + isSimple = r.Diff == 0 && r.Key == "" && isLine && r.Comment == nil + if !isSimple { + break + } + } + if isSimple { + n++ + var batch []byte + emitBatch := func() { + if len(batch) > 0 { + b = n.appendIndent(append(b, '\n'), d) + b = append(b, bytes.TrimRight(batch, " ")...) + batch = batch[:0] + } + } + for _, r := range s { + line := r.Value.(textLine) + if len(batch)+len(line)+len(", ") > maxColumnLength { + emitBatch() + } + batch = append(batch, line...) + batch = append(batch, ", "...) + } + emitBatch() + n-- + return n.appendIndent(append(b, '\n'), d) + } + + // Format the list as a multi-lined output. + n++ + for i, r := range s { + b = n.appendIndent(append(b, '\n'), d|r.Diff) + if r.Key != "" { + b = append(b, r.Key+": "...) + } + b = alignKeyLens[i].appendChar(b, ' ') + + b = r.Value.formatExpandedTo(b, d|r.Diff, n) + if !r.ElideComma { + b = append(b, ',') + } + b = alignValueLens[i].appendChar(b, ' ') + + if r.Comment != nil { + b = append(b, " // "+r.Comment.String()...) + } + } + n-- + + return n.appendIndent(append(b, '\n'), d) +} + +func (s textList) alignLens( + skipFunc func(textRecord) bool, + lenFunc func(textRecord) int, +) []repeatCount { + var startIdx, endIdx, maxLen int + lens := make([]repeatCount, len(s)) + for i, r := range s { + if skipFunc(r) { + for j := startIdx; j < endIdx && j < len(s); j++ { + lens[j] = repeatCount(maxLen - lenFunc(s[j])) + } + startIdx, endIdx, maxLen = i+1, i+1, 0 + } else { + if maxLen < lenFunc(r) { + maxLen = lenFunc(r) + } + endIdx = i + 1 + } + } + for j := startIdx; j < endIdx && j < len(s); j++ { + lens[j] = repeatCount(maxLen - lenFunc(s[j])) + } + return lens +} + +// textLine is a single-line segment of text and is always a leaf node +// in the textNode tree. +type textLine []byte + +var ( + textNil = textLine("nil") + textEllipsis = textLine("...") +) + +func (s textLine) Len() int { + return len(s) +} +func (s1 textLine) Equal(s2 textNode) bool { + if s2, ok := s2.(textLine); ok { + return bytes.Equal([]byte(s1), []byte(s2)) + } + return false +} +func (s textLine) String() string { + return string(s) +} +func (s textLine) formatCompactTo(b []byte, d diffMode) ([]byte, textNode) { + return append(b, s...), s +} +func (s textLine) formatExpandedTo(b []byte, _ diffMode, _ indentMode) []byte { + return append(b, s...) +} + +type diffStats struct { + Name string + NumIgnored int + NumIdentical int + NumRemoved int + NumInserted int + NumModified int +} + +func (s diffStats) IsZero() bool { + s.Name = "" + return s == diffStats{} +} + +func (s diffStats) NumDiff() int { + return s.NumRemoved + s.NumInserted + s.NumModified +} + +func (s diffStats) Append(ds diffStats) diffStats { + assert(s.Name == ds.Name) + s.NumIgnored += ds.NumIgnored + s.NumIdentical += ds.NumIdentical + s.NumRemoved += ds.NumRemoved + s.NumInserted += ds.NumInserted + s.NumModified += ds.NumModified + return s +} + +// String prints a humanly-readable summary of coalesced records. +// +// Example: +// +// diffStats{Name: "Field", NumIgnored: 5}.String() => "5 ignored fields" +func (s diffStats) String() string { + var ss []string + var sum int + labels := [...]string{"ignored", "identical", "removed", "inserted", "modified"} + counts := [...]int{s.NumIgnored, s.NumIdentical, s.NumRemoved, s.NumInserted, s.NumModified} + for i, n := range counts { + if n > 0 { + ss = append(ss, fmt.Sprintf("%d %v", n, labels[i])) + } + sum += n + } + + // Pluralize the name (adjusting for some obscure English grammar rules). + name := s.Name + if sum > 1 { + name += "s" + if strings.HasSuffix(name, "ys") { + name = name[:len(name)-2] + "ies" // e.g., "entrys" => "entries" + } + } + + // Format the list according to English grammar (with Oxford comma). + switch n := len(ss); n { + case 0: + return "" + case 1, 2: + return strings.Join(ss, " and ") + " " + name + default: + return strings.Join(ss[:n-1], ", ") + ", and " + ss[n-1] + " " + name + } +} + +type commentString string + +func (s commentString) String() string { return string(s) } diff --git a/src/webui/vendor/github.com/google/go-cmp/cmp/report_value.go b/src/webui/vendor/github.com/google/go-cmp/cmp/report_value.go new file mode 100644 index 00000000..668d470f --- /dev/null +++ b/src/webui/vendor/github.com/google/go-cmp/cmp/report_value.go @@ -0,0 +1,121 @@ +// Copyright 2019, The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package cmp + +import "reflect" + +// valueNode represents a single node within a report, which is a +// structured representation of the value tree, containing information +// regarding which nodes are equal or not. +type valueNode struct { + parent *valueNode + + Type reflect.Type + ValueX reflect.Value + ValueY reflect.Value + + // NumSame is the number of leaf nodes that are equal. + // All descendants are equal only if NumDiff is 0. + NumSame int + // NumDiff is the number of leaf nodes that are not equal. + NumDiff int + // NumIgnored is the number of leaf nodes that are ignored. + NumIgnored int + // NumCompared is the number of leaf nodes that were compared + // using an Equal method or Comparer function. + NumCompared int + // NumTransformed is the number of non-leaf nodes that were transformed. + NumTransformed int + // NumChildren is the number of transitive descendants of this node. + // This counts from zero; thus, leaf nodes have no descendants. + NumChildren int + // MaxDepth is the maximum depth of the tree. This counts from zero; + // thus, leaf nodes have a depth of zero. + MaxDepth int + + // Records is a list of struct fields, slice elements, or map entries. + Records []reportRecord // If populated, implies Value is not populated + + // Value is the result of a transformation, pointer indirect, of + // type assertion. + Value *valueNode // If populated, implies Records is not populated + + // TransformerName is the name of the transformer. + TransformerName string // If non-empty, implies Value is populated +} +type reportRecord struct { + Key reflect.Value // Invalid for slice element + Value *valueNode +} + +func (parent *valueNode) PushStep(ps PathStep) (child *valueNode) { + vx, vy := ps.Values() + child = &valueNode{parent: parent, Type: ps.Type(), ValueX: vx, ValueY: vy} + switch s := ps.(type) { + case StructField: + assert(parent.Value == nil) + parent.Records = append(parent.Records, reportRecord{Key: reflect.ValueOf(s.Name()), Value: child}) + case SliceIndex: + assert(parent.Value == nil) + parent.Records = append(parent.Records, reportRecord{Value: child}) + case MapIndex: + assert(parent.Value == nil) + parent.Records = append(parent.Records, reportRecord{Key: s.Key(), Value: child}) + case Indirect: + assert(parent.Value == nil && parent.Records == nil) + parent.Value = child + case TypeAssertion: + assert(parent.Value == nil && parent.Records == nil) + parent.Value = child + case Transform: + assert(parent.Value == nil && parent.Records == nil) + parent.Value = child + parent.TransformerName = s.Name() + parent.NumTransformed++ + default: + assert(parent == nil) // Must be the root step + } + return child +} + +func (r *valueNode) Report(rs Result) { + assert(r.MaxDepth == 0) // May only be called on leaf nodes + + if rs.ByIgnore() { + r.NumIgnored++ + } else { + if rs.Equal() { + r.NumSame++ + } else { + r.NumDiff++ + } + } + assert(r.NumSame+r.NumDiff+r.NumIgnored == 1) + + if rs.ByMethod() { + r.NumCompared++ + } + if rs.ByFunc() { + r.NumCompared++ + } + assert(r.NumCompared <= 1) +} + +func (child *valueNode) PopStep() (parent *valueNode) { + if child.parent == nil { + return nil + } + parent = child.parent + parent.NumSame += child.NumSame + parent.NumDiff += child.NumDiff + parent.NumIgnored += child.NumIgnored + parent.NumCompared += child.NumCompared + parent.NumTransformed += child.NumTransformed + parent.NumChildren += child.NumChildren + 1 + if parent.MaxDepth < child.MaxDepth+1 { + parent.MaxDepth = child.MaxDepth + 1 + } + return parent +} diff --git a/src/webui/vendor/github.com/openconfig/goyang/AUTHORS b/src/webui/vendor/github.com/openconfig/goyang/AUTHORS new file mode 100644 index 00000000..121ba4ef --- /dev/null +++ b/src/webui/vendor/github.com/openconfig/goyang/AUTHORS @@ -0,0 +1,9 @@ +# This is the official list of goyang authors for copyright purposes. +# This file is distinct from the CONTRIBUTORS files. +# See the latter for an explanation. + +# Names should be added to this file as: +# Name or Organization +# The email address is not required for organizations. + +Google Inc. \ No newline at end of file diff --git a/src/webui/vendor/github.com/openconfig/goyang/CONTRIBUTORS b/src/webui/vendor/github.com/openconfig/goyang/CONTRIBUTORS new file mode 100644 index 00000000..b2ac0e81 --- /dev/null +++ b/src/webui/vendor/github.com/openconfig/goyang/CONTRIBUTORS @@ -0,0 +1,15 @@ +# People who have agreed to one of the CLAs and can contribute patches. +# The AUTHORS file lists the copyright holders; this file +# lists people. For example, Google employees are listed here +# but not in AUTHORS, because Google holds the copyright. +# +# https://developers.google.com/open-source/cla/individual +# https://developers.google.com/open-source/cla/corporate +# +# Names should be added to this file as: +# Name + +Paul Borman +Andrew Fort +Rob Shakir +Sean Condon diff --git a/src/webui/vendor/github.com/openconfig/goyang/LICENSE b/src/webui/vendor/github.com/openconfig/goyang/LICENSE new file mode 100644 index 00000000..8f71f43f --- /dev/null +++ b/src/webui/vendor/github.com/openconfig/goyang/LICENSE @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/src/webui/vendor/github.com/openconfig/goyang/pkg/indent/indent.go b/src/webui/vendor/github.com/openconfig/goyang/pkg/indent/indent.go new file mode 100644 index 00000000..a67b8885 --- /dev/null +++ b/src/webui/vendor/github.com/openconfig/goyang/pkg/indent/indent.go @@ -0,0 +1,112 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package indent indents lines of text. +package indent + +import ( + "bytes" + "io" + "strings" +) + +// String returns s with each line in s prefixed by indent. +func String(indent, s string) string { + if indent == "" || s == "" { + return s + } + lines := strings.SplitAfter(s, "\n") + if len(lines[len(lines)-1]) == 0 { + lines = lines[:len(lines)-1] + } + return strings.Join(append([]string{""}, lines...), indent) +} + +// Bytes returns b with each line in b prefixed by indent. +func Bytes(indent, b []byte) []byte { + if len(indent) == 0 || len(b) == 0 { + return b + } + lines := bytes.SplitAfter(b, []byte{'\n'}) + if len(lines[len(lines)-1]) == 0 { + lines = lines[:len(lines)-1] + } + return bytes.Join(append([][]byte{{}}, lines...), indent) +} + +// NewWriter returns an io.Writer that prefixes the lines written to it with +// indent and then writes them to w. The writer returns the number of bytes +// written to the underlying Writer. +func NewWriter(w io.Writer, indent string) io.Writer { + if indent == "" { + return w + } + return &iw{ + w: w, + prefix: []byte(indent), + } +} + +type iw struct { + w io.Writer + prefix []byte + partial bool // true if next line's indent already written +} + +// Write implements io.Writer. +func (w *iw) Write(buf []byte) (int, error) { + if len(buf) == 0 { + return 0, nil + } + lines := bytes.SplitAfter(buf, []byte{'\n'}) + if len(lines[len(lines)-1]) == 0 { + lines = lines[:len(lines)-1] + } + if !w.partial { + lines = append([][]byte{{}}, lines...) + } + joined := bytes.Join(lines, w.prefix) + w.partial = joined[len(joined)-1] != '\n' + + n, err := w.w.Write(joined) + if err != nil { + return actualWrittenSize(n, len(w.prefix), lines), err + } + + return len(buf), nil +} + +func actualWrittenSize(underlay, prefix int, lines [][]byte) int { + actual := 0 + remain := underlay + for _, line := range lines { + if len(line) == 0 { + continue + } + + addition := remain - prefix + if addition <= 0 { + return actual + } + + if addition <= len(line) { + return actual + addition + } + + actual += len(line) + remain -= prefix + len(line) + } + + return actual +} diff --git a/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/ast.go b/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/ast.go new file mode 100644 index 00000000..3c7edb6a --- /dev/null +++ b/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/ast.go @@ -0,0 +1,461 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +// This file implements BuildAST() and its associated helper structs and +// functions for constructing an AST of Nodes from a Statement tree. This +// function also populates all typedefs into a type cache. +// +// The initTypes function generates the helper struct and functions that +// recursively fill in the various Node structures defined in yang.go. +// BuildAST() then uses those functions to convert raw parsed Statements into +// an AST. + +import ( + "errors" + "fmt" + "reflect" + "strings" +) + +func init() { + // Initialize the global variables `typeMap` and `nameMap`. + // By doing this, we are making the assumption that all modules will be + // parsed according to the type hierarchy rooted at `meta`, and thus + // all input YANG modules will be parsed in this manner. + initTypes(reflect.TypeOf(&meta{})) +} + +// A yangStatement contains all information needed to build a particular +// type of statement into an AST node. +type yangStatement struct { + // funcs is the map of YANG field names to the function that populates + // the statement into the AST node. + funcs map[string]func(*Statement, reflect.Value, reflect.Value, *typeDictionary) error + // required is a list of fields that must be present in the statement. + required []string + // sRequired maps a statement name to a list of required sub-field + // names. The statement name can be an alias of the primary field type. + // e.g. If a field is required by statement type foo, then only foo + // should have the field. If bar is an alias of foo, it must not + // have this field. + sRequired map[string][]string + // addext is the function to handle possible extensions. + addext func(*Statement, reflect.Value, reflect.Value) error +} + +// newYangStatement creates a new yangStatement. +func newYangStatement() *yangStatement { + return &yangStatement{ + funcs: make(map[string]func(*Statement, reflect.Value, reflect.Value, *typeDictionary) error), + sRequired: make(map[string][]string), + } +} + +var ( + // The following maps are built up at init time. + // typeMap provides a lookup from a Node type to the corresponding + // yangStatement. + typeMap = map[reflect.Type]*yangStatement{} + // nameMap provides a lookup from a keyword string to the corresponding + // concrete type implementing the Node interface (see yang.go). + nameMap = map[string]reflect.Type{} + + // The following are helper types used by the implementation. + statementType = reflect.TypeOf(&Statement{}) + nilValue = reflect.ValueOf(nil) + // nodeType is the reflect.Type of the Node interface. + nodeType = reflect.TypeOf((*Node)(nil)).Elem() +) + +// meta is a collection of top-level statements. There is no actual +// statement named "meta". All other statements are a sub-statement of one +// of the meta statements. +type meta struct { + Module []*Module `yang:"module"` +} + +// aliases is a map of "aliased" names, that is, two types of statements +// that parse (nearly) the same. +// NOTE: This only works for root-level aliasing for now, which is good enough +// for module/submodule. This is because yangStatement.funcs doesn't store the +// handler function for aliased fields, and sRequired also may only store the +// correct values when processing a root-level statement due to aliasing. These +// issues would need to be fixed in order to support aliasing for non-top-level +// statements. +var aliases = map[string]string{ + "submodule": "module", +} + +// buildASTWithTypeDict creates an AST for the input statement, and returns its +// root node. It also takes as input a type dictionary into which any +// encountered typedefs within the statement are cached. +func buildASTWithTypeDict(stmt *Statement, types *typeDictionary) (Node, error) { + v, err := build(stmt, nilValue, types) + if err != nil { + return nil, err + } + return v.Interface().(Node), nil +} + +// build builds and returns an AST from the statement stmt and with parent node +// parent. It also takes as input a type dictionary types into which any +// encountered typedefs within the statement are cached. The type of value +// returned depends on the keyword in stmt (see yang.go). It returns an error +// if it cannot build the statement into its corresponding Node type. +func build(stmt *Statement, parent reflect.Value, types *typeDictionary) (v reflect.Value, err error) { + defer func() { + // If we are returning a real Node then call addTypedefs + // if the node possibly contains typedefs. + // Cache these in the typedef cache for look-ups. + if err != nil || v == nilValue { + return + } + if t, ok := v.Interface().(Typedefer); ok { + types.addTypedefs(t) + } + }() + keyword := stmt.Keyword + if k, ok := aliases[stmt.Keyword]; ok { + keyword = k + } + t := nameMap[keyword] + y := typeMap[t] + // Keep track of which substatements are present in the statement. + found := map[string]bool{} + + // Get the struct type we are pointing to. + t = t.Elem() + // v is a pointer to the instantiated structure we are building. + v = reflect.New(t) + + // Handle special cases that are not actually substatements: + + if fn := y.funcs["Name"]; fn != nil { + // Name uses stmt directly. + if err := fn(stmt, v, parent, types); err != nil { + return nilValue, err + } + } + if fn := y.funcs["Statement"]; fn != nil { + // Statement uses stmt directly. + if err := fn(stmt, v, parent, types); err != nil { + return nilValue, err + } + } + if fn := y.funcs["Parent"]; fn != nil { + // parent is the parent node, which is nilValue (reflect.ValueOf(nil)) if there is none. + // parent.IsValid will return false when parent is a nil interface + // parent.IsValid will true if parent references a concrete type + // (even if it is nil). + if parent.IsValid() { + if err := fn(stmt, v, parent, types); err != nil { + return nilValue, err + } + } + } + + // Now handle the substatements + + for _, ss := range stmt.statements { + found[ss.Keyword] = true + fn := y.funcs[ss.Keyword] + switch { + case fn != nil: + // Normal case, the keyword is known. + if err := fn(ss, v, parent, types); err != nil { + return nilValue, err + } + case len(strings.Split(ss.Keyword, ":")) == 2: + // Keyword is not known but it has a prefix so it might + // be an extension. + if y.addext == nil { + return nilValue, fmt.Errorf("%s: no extension function", ss.Location()) + } + y.addext(ss, v, parent) + default: + return nilValue, fmt.Errorf("%s: unknown %s field: %s", ss.Location(), stmt.Keyword, ss.Keyword) + } + } + + // Make sure all of our required field are there. + for _, r := range y.required { + if !found[r] { + return nilValue, fmt.Errorf("%s: missing required %s field: %s", stmt.Location(), stmt.Keyword, r) + } + } + + // Make sure required fields based on our keyword are there (module vs submodule) + for _, r := range y.sRequired[stmt.Keyword] { + if !found[r] { + return nilValue, fmt.Errorf("%s: missing required %s field: %s", stmt.Location(), stmt.Keyword, r) + } + } + + // Make sure we don't have any field set that is required by a different keyword. + for n, or := range y.sRequired { + if n == stmt.Keyword { + continue + } + for _, r := range or { + if found[r] { + return nilValue, fmt.Errorf("%s: unknown %s field: %s", stmt.Location(), stmt.Keyword, r) + } + } + } + return v, nil +} + +// initTypes creates the functions necessary to build a Statement into the +// given the type "at" based on its possible substatements. at must implement +// Node, with its concrete type being a pointer to a struct defined in yang.go. +// +// This function also builds up the functions to populate the input type +// dictionary types with any encountered typedefs within the statement. +// +// For each field of the struct with a yang tag (e.g., `yang:"command"`), a +// function is created with "command" as its unique ID. The complete map of +// builder functions for at is then added to the typeMap map with at as the +// key. The idea is to call these builder functions for each substatement +// encountered. +// +// The functions have the form: +// +// func fn(ss *Statement, v, p reflect.Value, types *typeDictionary) error +// +// Given stmt as a Statement of type at, ss is a substatement of stmt (in a few +// exceptional cases, ss is the Statement itself). v must have the same type +// as at and is the structure being filled in. p is the parent Node, or nil. +// types is the type dictionary cache of the current set of modules being parsed, +// which is used for looking up typedefs. p is only used to set the Parent +// field of a Node. For example, given the following structure and variables: +// +// type Include struct { +// Name string `yang:"Name"` +// Source *Statement `yang:"Statement"` +// Parent Node `yang:"Parent"` +// Extensions []*Statement `yang:"Ext"` +// RevisionDate *Value `yang:"revision-date"` +// } +// +// var inc = &Include{} +// var vInc = reflect.ValueOf(inc) +// var tInc = reflect.TypeOf(inc) +// +// Functions are created for each fields and named Name, Statement, Parent, Ext, +// and revision-date. +// +// The function built for RevisionDate will be called for any substatement, +// ds, of stmt that has the keyword "revision-date" along with the value of +// vInc and its parent: +// +// typeMap[tInc]["revision-date"](ss, vInc, parent, types) +// +// Normal fields are all processed this same way. +// +// The other 4 fields are special. In the case of Name, Statement, and Parent, +// the function is passed stmt, rather than ss, as these fields are not filled in +// by substatements. +// +// The Name command must set its field to the Statement's argument. The +// Statement command must set its field to the Statement itself. The +// Parent command must set its field with the Node of its parent (the +// parent parameter). +// +// The Ext command is unique and must decode into a []*Statement. This is a +// slice of all statements that use unknown keywords with a prefix (in a valid +// .yang file these should be the extensions). +// +// The Field can have attributes delimited by a ','. The only +// supported attributes are: +// +// nomerge: Do not merge this field +// required: This field must be populated +// required=KIND: This field must be populated if the keyword is KIND +// otherwise this field must not be present. +// (This is to support merging Module and SubModule). +// +// If at contains substructures, initTypes recurses on the substructures. +func initTypes(at reflect.Type) { + if at.Kind() != reflect.Ptr || at.Elem().Kind() != reflect.Struct { + panic(fmt.Sprintf("interface not a struct pointer, is %v", at)) + } + if typeMap[at] != nil { + return // we already defined this type + } + + y := newYangStatement() + typeMap[at] = y + t := at.Elem() + for i := 0; i != t.NumField(); i++ { + i := i + f := t.Field(i) + yang := f.Tag.Get("yang") + if yang == "" { + continue + } + parts := strings.Split(yang, ",") + name := parts[0] + if a, ok := aliases[name]; ok { + name = a + } + + const reqe = "required=" + for _, p := range parts[1:] { + switch { + case p == "nomerge": + case p == "required": + y.required = append(y.required, name) + case strings.HasPrefix(p, reqe): + p = p[len(reqe):] + y.sRequired[p] = append(y.sRequired[p], name) + default: + panic(f.Name + ": unknown tag: " + p) + } + } + + // Ext means this is where we squirrel away extensions + if name == "Ext" { + // stmt is the extension to put into v at for field f. + y.addext = func(stmt *Statement, v, _ reflect.Value) error { + if v.Type() != at { + panic(fmt.Sprintf("given type %s, need type %s", v.Type(), at)) + } + fv := v.Elem().Field(i) + fv.Set(reflect.Append(fv, reflect.ValueOf(stmt))) + return nil + } + continue + } + + // descend runs initType on dt if it has not already done so. + descend := func(name string, dt reflect.Type) { + switch nameMap[name] { + case nil: + nameMap[name] = dt + initTypes(dt) // Make sure that structure type is included + case dt: + default: + panic("redeclared type " + name) + } + } + + // Create a function, fn, that will build the field from a + // Statement. These functions are used when actually making + // an AST from a Statement Tree. + var fn func(*Statement, reflect.Value, reflect.Value, *typeDictionary) error + + // The field can be a pointer, a slice or a string + switch f.Type.Kind() { + default: + panic(fmt.Sprintf("invalid type: %v", f.Type.Kind())) + + case reflect.Interface: + // The only case of this should be the "Parent" field. + if name != "Parent" { + panic(fmt.Sprintf("interface field is %s, not Parent", name)) + } + fn = func(stmt *Statement, v, p reflect.Value, types *typeDictionary) error { + if !p.Type().Implements(nodeType) { + panic(fmt.Sprintf("invalid interface: %v", f.Type.Kind())) + } + v.Elem().Field(i).Set(p) + return nil + } + case reflect.String: + // The only case of this should be the "Name" field + if name != "Name" { + panic(fmt.Sprintf("string field is %s, not Name", name)) + } + fn = func(stmt *Statement, v, _ reflect.Value, types *typeDictionary) error { + if v.Type() != at { + panic(fmt.Sprintf("got type %v, want %v", v.Type(), at)) + } + fv := v.Elem().Field(i) + if fv.String() != "" { + return errors.New(stmt.Keyword + ": already set") + } + + v.Elem().Field(i).SetString(stmt.Argument) + return nil + } + + case reflect.Ptr: + if f.Type == statementType { + // The only case of this should be the + // "Statement" field + if name != "Statement" { + panic(fmt.Sprintf("string field is %s, not Statement", name)) + } + fn = func(stmt *Statement, v, _ reflect.Value, types *typeDictionary) error { + if v.Type() != at { + panic(fmt.Sprintf("got type %v, want %v", v.Type(), at)) + } + v.Elem().Field(i).Set(reflect.ValueOf(stmt)) + return nil + } + break + } + + // Make sure our field type is also setup. + descend(name, f.Type) + + fn = func(stmt *Statement, v, p reflect.Value, types *typeDictionary) error { + if v.Type() != at { + panic(fmt.Sprintf("given type %s, need type %s", v.Type(), at)) + } + fv := v.Elem().Field(i) + if !fv.IsNil() { + return errors.New(stmt.Keyword + ": already set") + } + + // Use build to build the value for this field. + sv, err := build(stmt, v, types) + if err != nil { + return err + } + v.Elem().Field(i).Set(sv) + return nil + } + + case reflect.Slice: + // A slice at this point is always a slice of + // substructures. We may see the same keyword multiple + // times, each time we see it we just append to the + // slice. + st := f.Type.Elem() + switch st.Kind() { + default: + panic(fmt.Sprintf("invalid type: %v", st.Kind())) + case reflect.Ptr: + descend(name, st) + fn = func(stmt *Statement, v, p reflect.Value, types *typeDictionary) error { + if v.Type() != at { + panic(fmt.Sprintf("given type %s, need type %s", v.Type(), at)) + } + sv, err := build(stmt, v, types) + if err != nil { + return err + } + + fv := v.Elem().Field(i) + fv.Set(reflect.Append(fv, sv)) + return nil + } + } + } + y.funcs[name] = fn + } +} diff --git a/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/camelcase.go b/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/camelcase.go new file mode 100644 index 00000000..2704ddcd --- /dev/null +++ b/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/camelcase.go @@ -0,0 +1,94 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +var knownWords = map[string]string{ + "Ietf": "IETF", +} + +// Is c an ASCII lower-case letter? +func isASCIILower(c byte) bool { + return 'a' <= c && c <= 'z' +} + +// Is c an ASCII digit? +func isASCIIDigit(c byte) bool { + return '0' <= c && c <= '9' +} + +// CamelCase returns a CamelCased name for a YANG identifier. +// Currently this supports the output being used for a Go or proto identifier. +// Dash and dot are first converted to underscore, and then any underscores +// before a lower-case letter are removed, and the letter converted to +// upper-case. Any input characters not part of the YANG identifier +// specification (https://tools.ietf.org/html/rfc7950#section-6.2) are treated +// as lower-case characters. +// The first letter is always upper-case in order to be an exported name in Go. +// There is a remote possibility of this rewrite causing a name collision, but +// it's so remote we're prepared to pretend it's nonexistent - since the C++ +// generator lowercases names, it's extremely unlikely to have two fields with +// different capitalizations. In short, _my_field-name_2 becomes XMyFieldName_2. +func CamelCase(s string) string { + if s == "" { + return "" + } + + fix := func(c byte) byte { + if c == '-' || c == '.' { + return '_' + } + return c + } + + t := make([]byte, 0, 32) + i := 0 + if fix(s[0]) == '_' { + // Need a capital letter; drop the '_'. + t = append(t, 'X') + i++ + } + + // Invariant: if the next letter is lower case, it must be converted + // to upper case. + // That is, we process a word at a time, where words are marked by _ or + // upper case letter. Digits are treated as words. + for ; i < len(s); i++ { + c := fix(s[i]) + if c == '_' && i+1 < len(s) && isASCIILower(s[i+1]) { + continue // Skip the underscore in s. + } + if isASCIIDigit(c) { + t = append(t, c) + continue + } + // Assume we have a letter now - if not, it's a bogus identifier. + // The next word is a sequence of characters that must start upper case. + if isASCIILower(c) { + c ^= ' ' // Make it a capital letter. + } + start := len(t) + t = append(t, c) // Guaranteed not lower case. + // Accept lower case sequence that follows. + for i+1 < len(s) && isASCIILower(s[i+1]) { + i++ + t = append(t, s[i]) + } + // If the word turns out to be a special word, then use that instead. + if kn := knownWords[string(t[start:])]; kn != "" { + t = append(t[:start], []byte(kn)...) + } + } + return string(t) +} diff --git a/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/doc.go b/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/doc.go new file mode 100644 index 00000000..3dab829c --- /dev/null +++ b/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/doc.go @@ -0,0 +1,48 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package yang is used to parse .yang files (see RFC 6020). +// +// A generic yang statements takes one of the forms: +// +// keyword [argument] ; +// keyword [argument] { [statement [...]] } +// +// At the lowest level, package yang returns a simple tree of statements via the +// Parse function. The Parse function makes no attempt to determine the +// validity of the source, other than checking for generic syntax errors. +// +// At it's simplest, the GetModule function is used. The GetModule function +// searches the current directory, and any directory added to the Path variable, +// for a matching .yang source file by appending .yang to the name of the +// module: +// +// // Get the tree for the module module-name by looking for the source +// // file named module-name.yang. +// e, errs := yang.GetModule("module-name" [, optional sources...]) +// if len(errs) > 0 { +// for _, err := range errs { +// fmt.Fprintln(os.Stderr, err) +// } +// os.Exit(1) +// } +// +// // e is the Entry tree for "module-name" +// +// More complicated uses cases should use NewModules and then some combination +// of Modules.GetModule, Modules.Read, Modules.Parse, and Modules.GetErrors. +// +// The GetErrors method is mandatory, however, both yang.GetModule and +// Modules.GetModule automatically call Modules.GetErrors. +package yang diff --git a/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/entry.go b/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/entry.go new file mode 100644 index 00000000..cbba30b7 --- /dev/null +++ b/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/entry.go @@ -0,0 +1,1664 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +// The file contains the code to convert an AST (Node) tree into an Entry tree +// via the ToEntry function. The entry tree, once fully resolved, is the +// product of this package. The tree should have all types and references +// resolved. +// +// TODO(borman): handle types, leafrefs, and extensions + +import ( + "errors" + "fmt" + "io" + "math" + "reflect" + "sort" + "strconv" + "strings" + + "github.com/openconfig/goyang/pkg/indent" +) + +// A TriState may be true, false, or unset +type TriState int + +// The possible states of a TriState. +const ( + TSUnset = TriState(iota) + TSTrue + TSFalse +) + +// Value returns the value of t as a boolean. Unset is returned as false. +func (t TriState) Value() bool { + return t == TSTrue +} + +// String displays t as a string. +func (t TriState) String() string { + switch t { + case TSUnset: + return "unset" + case TSTrue: + return "true" + case TSFalse: + return "false" + default: + return fmt.Sprintf("ts-%d", t) + } +} + +// deviationPresence stores whether certain attributes for a DeviateEntry-type +// Entry have been given deviation values. This is useful when the attribute +// doesn't have a presence indicator (e.g. non-pointers). +type deviationPresence struct { + hasMinElements bool + hasMaxElements bool +} + +// Entry represents a single schema tree node, which can be a directory +// (containing a subtree) or a leaf node (which contains YANG types that have +// no children, e.g., leaf, leaf-list). They can be distinguished by whether +// their "Dir" field is nil. This object is created from a corresponding AST +// node after applying modifications (i.e. uses, augments, deviations). If +// Errors is not nil then it means semantic errors existed while converting the +// AST, in which case the only other valid field other than Errors is Node. +type Entry struct { + Parent *Entry `json:"-"` + Node Node `json:"-"` // the base node this Entry was derived from. + Name string // our name, same as the key in our parent Dirs + Description string `json:",omitempty"` // description from node, if any + // Default value for the node, if any. Note that only leaf-lists may + // have more than one value. For all other types, use the + // SingleDefaultValue() method to access the default value. + Default []string `json:",omitempty"` + Units string `json:",omitempty"` // units associated with the type, if any + Errors []error `json:"-"` // list of errors encountered on this node + Kind EntryKind // kind of Entry + Config TriState // config state of this entry, if known + Prefix *Value `json:",omitempty"` // prefix to use from this point down + Mandatory TriState `json:",omitempty"` // whether this entry is mandatory in the tree + + // Fields associated with directory nodes + Dir map[string]*Entry `json:",omitempty"` + Key string `json:",omitempty"` // Optional key name for lists (i.e., maps) + + // Fields associated with leaf nodes + Type *YangType `json:",omitempty"` + + // Extensions found + Exts []*Statement `json:",omitempty"` + + // Fields associated with list nodes (both lists and leaf-lists) + ListAttr *ListAttr `json:",omitempty"` + + RPC *RPCEntry `json:",omitempty"` // set if we are an RPC + + // Identities that are defined in this context, this is set if the Entry + // is a module only. + Identities []*Identity `json:",omitempty"` + + Augments []*Entry `json:",omitempty"` // Augments defined in this entry. + Augmented []*Entry `json:",omitempty"` // Augments merged into this entry. + Deviations []*DeviatedEntry `json:"-"` // Deviations associated with this entry. + Deviate map[deviationType][]*Entry `json:"-"` + // deviationPresence tracks whether certain attributes for a DeviateEntry-type + // Entry have been given deviation values. + deviatePresence deviationPresence + Uses []*UsesStmt `json:",omitempty"` // Uses merged into this entry. + + // Extra maps all the unsupported fields to their values + Extra map[string][]interface{} `json:"extra-unstable,omitempty"` + + // Annotation stores annotated values, and is not populated by this + // library but rather can be used by calling code where additional + // information should be stored alongside the Entry. + Annotation map[string]interface{} `json:",omitempty"` + + // namespace stores the namespace of the Entry if it overrides the + // root namespace within the schema tree. This is the case where an + // entry is augmented into the tree, and it retains the namespace of + // the augmenting entity per RFC6020 Section 7.15.2. The namespace + // of the Entry should be accessed using the Namespace function. + namespace *Value +} + +// An RPCEntry contains information related to an RPC Node. +type RPCEntry struct { + Input *Entry + Output *Entry +} + +// A ListAttr is associated with an Entry that represents a List node +type ListAttr struct { + MinElements uint64 // leaf-list or list MUST have at least min-elements + MaxElements uint64 // leaf-list or list has at most max-elements + // OrderedBy is deprecated. Use OrderedByUser instead. + OrderedBy *Value + // OrderedByUser indicates whether the entries are "ordered-by user". + // Otherwise the order is determined by the system. + OrderedByUser bool +} + +// parseOrderedBy parses the ordered-by value and classifies the list/leaf-list +// by whether the `ordered-by user` modifier is active. +// +// For more information see +// https://datatracker.ietf.org/doc/html/rfc7950#section-7.7.7 +func (l *ListAttr) parseOrderedBy(s *Value) error { + if s == nil { + return nil + } + l.OrderedBy = s + switch s.Name { + case "user": + l.OrderedByUser = true + case "system": + default: + return fmt.Errorf("%s: ordered-by has invalid argument: %q", Source(s), s.Name) + } + return nil +} + +// NewDefaultListAttr returns a new ListAttr object with min/max elements being +// set to 0/math.MaxUint64 respectively. +func NewDefaultListAttr() *ListAttr { + return &ListAttr{ + MinElements: 0, + MaxElements: math.MaxUint64, + } +} + +// A UsesStmt associates a *Uses with its referenced grouping *Entry +type UsesStmt struct { + Uses *Uses + Grouping *Entry +} + +// Modules returns the Modules structure that e is part of. This is needed +// when looking for rooted nodes not part of this Entry tree. +func (e *Entry) Modules() *Modules { + for e.Parent != nil { + e = e.Parent + } + return e.Node.(*Module).Modules +} + +// IsDir returns true if e is a directory. +func (e *Entry) IsDir() bool { + return e.Dir != nil +} + +// IsLeaf returns true if e is a leaf i.e. is not a container, list, leaf-list, +// choice or case statement. +func (e *Entry) IsLeaf() bool { + return !e.IsDir() && e.Kind == LeafEntry && e.ListAttr == nil +} + +// IsLeafList returns true if e is a leaf-list. +func (e *Entry) IsLeafList() bool { + return !e.IsDir() && e.Kind == LeafEntry && e.ListAttr != nil +} + +// IsList returns true if e is a list. +func (e *Entry) IsList() bool { + return e.IsDir() && e.ListAttr != nil +} + +// IsContainer returns true if e is a container. +func (e *Entry) IsContainer() bool { + return e.Kind == DirectoryEntry && e.ListAttr == nil +} + +// IsChoice returns true if the entry is a choice node within the schema. +func (e *Entry) IsChoice() bool { + return e.Kind == ChoiceEntry +} + +// IsCase returns true if the entry is a case node within the schema. +func (e *Entry) IsCase() bool { + return e.Kind == CaseEntry +} + +// Print prints e to w in human readable form. +func (e *Entry) Print(w io.Writer) { + if e.Description != "" { + fmt.Fprintln(w) + fmt.Fprintln(indent.NewWriter(w, "// "), e.Description) + } + if e.ReadOnly() { + fmt.Fprintf(w, "RO: ") + } else { + fmt.Fprintf(w, "rw: ") + } + if e.Type != nil { + fmt.Fprintf(w, "%s ", e.Type.Name) + } + switch { + case e.Dir == nil && e.ListAttr != nil: + fmt.Fprintf(w, "[]%s\n", e.Name) + return + case e.Dir == nil: + fmt.Fprintf(w, "%s\n", e.Name) + return + case e.ListAttr != nil: + fmt.Fprintf(w, "[%s]%s {\n", e.Key, e.Name) //} + default: + fmt.Fprintf(w, "%s {\n", e.Name) //} + } + var names []string + for k := range e.Dir { + names = append(names, k) + } + sort.Strings(names) + for _, k := range names { + e.Dir[k].Print(indent.NewWriter(w, " ")) + } + // { to match the brace below to keep brace matching working + fmt.Fprintln(w, "}") +} + +// An EntryKind is the kind of node an Entry is. All leaf nodes are of kind +// LeafEntry. A LeafList is also considered a leaf node. All other kinds are +// directory nodes. +type EntryKind int + +// Enumeration of the types of entries. +const ( + LeafEntry = EntryKind(iota) + DirectoryEntry + AnyDataEntry + AnyXMLEntry + CaseEntry + ChoiceEntry + InputEntry + NotificationEntry + OutputEntry + DeviateEntry +) + +// EntryKindToName maps EntryKind to their names +var EntryKindToName = map[EntryKind]string{ + LeafEntry: "Leaf", + DirectoryEntry: "Directory", + AnyDataEntry: "AnyData", + AnyXMLEntry: "AnyXML", + CaseEntry: "Case", + ChoiceEntry: "Choice", + InputEntry: "Input", + NotificationEntry: "Notification", + OutputEntry: "Output", + DeviateEntry: "Deviate", +} + +func (k EntryKind) String() string { + if s := EntryKindToName[k]; s != "" { + return s + } + return fmt.Sprintf("unknown-entry-%d", k) +} + +// newDirectory returns an empty directory Entry. +func newDirectory(n Node) *Entry { + return &Entry{ + Kind: DirectoryEntry, + Dir: make(map[string]*Entry), + Node: n, + Name: n.NName(), + Extra: map[string][]interface{}{}, + } +} + +// newLeaf returns an empty leaf Entry. +func newLeaf(n Node) *Entry { + return &Entry{ + Kind: LeafEntry, + Node: n, + Name: n.NName(), + Extra: map[string][]interface{}{}, + } +} + +// newError returns an error Entry using format and v to create the error +// contained in the node. The location of the error is prepended. +func newError(n Node, format string, v ...interface{}) *Entry { + e := &Entry{Node: n} + e.errorf("%s: "+format, append([]interface{}{Source(n)}, v...)...) + return e +} + +// errorf appends the error constructed from string and v to the list of errors +// on e. +func (e *Entry) errorf(format string, v ...interface{}) { + e.Errors = append(e.Errors, fmt.Errorf(format, v...)) +} + +// addError appends err to the list of errors on e if err is not nil. +func (e *Entry) addError(err error) { + if err != nil { + e.Errors = append(e.Errors, err) + } +} + +// importErrors imports all the errors from c and its children into e. +func (e *Entry) importErrors(c *Entry) { + if c == nil { + return + } + for _, err := range c.Errors { + e.addError(err) + } + // TODO(borman): need to determine if the extensions have errors + // for _, ce := range e.Exts { + // e.importErrors(ce) + // } + for _, ce := range c.Dir { + e.importErrors(ce) + } +} + +// checkErrors calls f on every error found in the tree e and its children. +func (e *Entry) checkErrors(f func(error)) { + if e == nil { + return + } + for _, e := range e.Dir { + e.checkErrors(f) + } + for _, err := range e.Errors { + f(err) + } + // TODO(borman): need to determine if the extensions have errors + // for _, e := range e.Exts { + // e.checkErrors(f) + // } +} + +// GetErrors returns a sorted list of errors found in e. +func (e *Entry) GetErrors() []error { + // the seen map is used to eliminate duplicate errors. + // Some entries will be processed more than once + // (groupings in particular) and as such may cause + // duplication of errors. + seen := map[error]bool{} + var errs []error + e.checkErrors(func(err error) { + if !seen[err] { + errs = append(errs, err) + seen[err] = true + } + }) + return errorSort(errs) +} + +// add adds the directory entry key assigned to the provided value. +func (e *Entry) add(key string, value *Entry) *Entry { + value.Parent = e + if e.Dir[key] != nil { + e.errorf("%s: duplicate key from %s: %s", Source(e.Node), Source(value.Node), key) + return e + } + e.Dir[key] = value + return e +} + +// delete removes the directory entry key from the entry. +func (e *Entry) delete(key string) { + if _, ok := e.Dir[key]; !ok { + e.errorf("%s: unknown child key %s", Source(e.Node), key) + } + delete(e.Dir, key) +} + +// GetWhenXPath returns the when XPath statement of e if able. +func (e *Entry) GetWhenXPath() (string, bool) { + switch n := e.Node.(type) { + case *Container: + if n.When != nil && n.When.Statement() != nil { + return n.When.Statement().Arg() + } + case *Leaf: + if n.When != nil && n.When.Statement() != nil { + return n.When.Statement().Arg() + } + case *LeafList: + if n.When != nil && n.When.Statement() != nil { + return n.When.Statement().Arg() + } + case *List: + if n.When != nil && n.When.Statement() != nil { + return n.When.Statement().Arg() + } + case *Choice: + if n.When != nil && n.When.Statement() != nil { + return n.When.Statement().Arg() + } + case *Case: + if n.When != nil && n.When.Statement() != nil { + return n.When.Statement().Arg() + } + case *AnyXML: + if n.When != nil && n.When.Statement() != nil { + return n.When.Statement().Arg() + } + case *AnyData: + if n.When != nil && n.When.Statement() != nil { + return n.When.Statement().Arg() + } + case *Augment: + if n.When != nil && n.When.Statement() != nil { + return n.When.Statement().Arg() + } + } + return "", false +} + +// deviationType specifies an enumerated value covering the different substatements +// to the deviate statement. +type deviationType int64 + +const ( + // DeviationUnset specifies that the argument was unset, which is invalid. + DeviationUnset deviationType = iota + // DeviationNotSupported corresponds to the not-supported deviate argument. + DeviationNotSupported + // DeviationAdd corresponds to the add deviate argument to the deviate stmt. + DeviationAdd + // DeviationReplace corresponds to the replace argument to the deviate stmt. + DeviationReplace + // DeviationDelete corresponds to the delete argument to the deviate stmt. + DeviationDelete +) + +var ( + // fromDeviation maps from an enumerated deviation type to the YANG keyword. + fromDeviation = map[deviationType]string{ + DeviationNotSupported: "not-supported", + DeviationAdd: "add", + DeviationReplace: "replace", + DeviationDelete: "delete", + DeviationUnset: "unknown", + } + + // toDeviation maps from the YANG keyword to an enumerated deviation type. + toDeviation = map[string]deviationType{ + "not-supported": DeviationNotSupported, + "add": DeviationAdd, + "replace": DeviationReplace, + "delete": DeviationDelete, + } +) + +func (d deviationType) String() string { + return fromDeviation[d] +} + +// DeviatedEntry stores a wrapped Entry that corresponds to a deviation. +type DeviatedEntry struct { + Type deviationType // Type specifies the deviation type. + DeviatedPath string // DeviatedPath corresponds to the path that is being deviated. + // Entry is the embedded Entry storing the deviations that are made. Fields + // are set to the value in the schema after the deviation has been applied. + *Entry +} + +// semCheckMaxElements checks whether the max-element argument is valid, and returns the specified value. +func semCheckMaxElements(v *Value) (uint64, error) { + if v == nil || v.Name == "unbounded" { + return math.MaxUint64, nil + } + val, err := strconv.ParseUint(v.Name, 10, 64) + if err != nil { + return val, fmt.Errorf(`%s: invalid max-elements value %q (expect "unbounded" or a positive integer): %v`, Source(v), v.Name, err) + } + if val == 0 { + return val, fmt.Errorf(`%s: invalid max-elements value 0 (expect "unbounded" or a positive integer)`, Source(v)) + } + return val, nil +} + +// semCheckMinElements checks whether the min-element argument is valid, and returns the specified value. +func semCheckMinElements(v *Value) (uint64, error) { + if v == nil { + return 0, nil + } + val, err := strconv.ParseUint(v.Name, 10, 64) + if err != nil { + return val, fmt.Errorf(`%s: invalid min-elements value %q (expect a non-negative integer): %v`, Source(v), v.Name, err) + } + return val, nil +} + +// ToEntry expands node n into a directory Entry. Expansion is based on the +// YANG tags in the structure behind n. ToEntry must only be used +// with nodes that are directories, such as top level modules and sub-modules. +// ToEntry never returns nil. Any errors encountered are found in the Errors +// fields of the returned Entry and its children. Use GetErrors to determine +// if there were any errors. +func ToEntry(n Node) (e *Entry) { + if n == nil { + err := errors.New("ToEntry called on nil AST node") + return &Entry{ + Node: &ErrorNode{Error: err}, + Errors: []error{err}, + } + } + ms := RootNode(n).Modules + if e := ms.getEntryCache(n); e != nil { + return e + } + defer func() { + ms.setEntryCache(n, e) + }() + + // Copy in the extensions from our Node, if any. + defer func(n Node) { + if e != nil { + e.Exts = append(e.Exts, n.Exts()...) + } + }(n) + + // tristateValue returns TSTrue if i contains the value of true, TSFalse + // if it contains the value of false, and TSUnset if i does not have + // a set value (for instance, i is nil). An error is returned if i + // contains a value other than true or false. + tristateValue := func(i interface{}) (TriState, error) { + if v, ok := i.(*Value); ok && v != nil { + switch v.Name { + case "true": + return TSTrue, nil + case "false": + return TSFalse, nil + default: + return TSUnset, fmt.Errorf("%s: invalid config value: %s", Source(n), v.Name) + } + } + return TSUnset, nil + } + + var err error + // Handle non-directory nodes (leaf, leafref, and oddly enough, uses). + switch s := n.(type) { + case *Leaf: + e := newLeaf(n) + if errs := s.Type.resolve(ms.typeDict); errs != nil { + e.Errors = errs + } + if s.Description != nil { + e.Description = s.Description.Name + } + if s.Default != nil { + e.Default = []string{s.Default.Name} + } + e.Type = s.Type.YangType + e.Config, err = tristateValue(s.Config) + e.addError(err) + e.Prefix = getRootPrefix(e) + addExtraKeywordsToLeafEntry(n, e) + e.Mandatory, err = tristateValue(s.Mandatory) + e.addError(err) + return e + case *LeafList: + // Create the equivalent leaf element that we are a list of. + // We can then just annotate it as a list rather than a leaf. + leaf := &Leaf{ + Name: s.Name, + Source: s.Source, + Parent: s.Parent, + Extensions: s.Extensions, + Config: s.Config, + Description: s.Description, + IfFeature: s.IfFeature, + Must: s.Must, + Reference: s.Reference, + Status: s.Status, + Type: s.Type, + Units: s.Units, + When: s.When, + } + + e = ToEntry(leaf) + e.ListAttr = NewDefaultListAttr() + if err := e.ListAttr.parseOrderedBy(s.OrderedBy); err != nil { + e.addError(err) + } + var err error + if e.ListAttr.MaxElements, err = semCheckMaxElements(s.MaxElements); err != nil { + e.addError(err) + } + if e.ListAttr.MinElements, err = semCheckMinElements(s.MinElements); err != nil { + e.addError(err) + } + if len(s.Default) != 0 { + for _, def := range s.Default { + e.Default = append(e.Default, def.Name) + } + } + e.Prefix = getRootPrefix(e) + return e + case *Uses: + g := FindGrouping(s, s.Name, map[string]bool{}) + if g == nil { + return newError(n, "unknown group: %s", s.Name) + } + // We need to return a duplicate so we resolve properly + // when the group is used in multiple locations and the + // grouping has a leafref that references outside the group. + e = ToEntry(g).dup() + addExtraKeywordsToLeafEntry(n, e) + return e + } + + e = newDirectory(n) + + // Special handling for individual Node types. Lists are like any other + // node except a List has a ListAttr. + // + // Nodes of identified special kinds have their Kind set here. + switch s := n.(type) { + case *List: + e.ListAttr = NewDefaultListAttr() + if err := e.ListAttr.parseOrderedBy(s.OrderedBy); err != nil { + e.addError(err) + } + var err error + if e.ListAttr.MaxElements, err = semCheckMaxElements(s.MaxElements); err != nil { + e.addError(err) + } + if e.ListAttr.MinElements, err = semCheckMinElements(s.MinElements); err != nil { + e.addError(err) + } + case *Choice: + e.Kind = ChoiceEntry + if s.Default != nil { + e.Default = []string{s.Default.Name} + } + case *Case: + e.Kind = CaseEntry + case *AnyData: + e.Kind = AnyDataEntry + case *AnyXML: + e.Kind = AnyXMLEntry + case *Input: + e.Kind = InputEntry + case *Output: + e.Kind = OutputEntry + case *Notification: + e.Kind = NotificationEntry + case *Deviate: + e.Kind = DeviateEntry + } + + // Use Elem to get the Value of structure that n is pointing to. + v := reflect.ValueOf(n).Elem() + t := v.Type() + found := false + + for i := t.NumField() - 1; i > 0; i-- { + f := t.Field(i) + yang := f.Tag.Get("yang") + if yang == "" { + continue + } + fv := v.Field(i) + name := strings.Split(yang, ",")[0] + switch name { + case "": + e.addError(fmt.Errorf("%s: nil statement", Source(n))) + case "config": + e.Config, err = tristateValue(fv.Interface()) + e.addError(err) + case "description": + if v := fv.Interface().(*Value); v != nil { + e.Description = v.Name + } + case "prefix": + if v := fv.Interface().(*Value); v != nil { + e.Prefix = v + } + case "action": + for _, r := range fv.Interface().([]*Action) { + e.add(r.Name, ToEntry(r)) + } + case "augment": + for _, a := range fv.Interface().([]*Augment) { + ne := ToEntry(a) + ne.Parent = e + e.Augments = append(e.Augments, ne) + } + case "anydata": + for _, a := range fv.Interface().([]*AnyData) { + e.add(a.Name, ToEntry(a)) + } + case "anyxml": + for _, a := range fv.Interface().([]*AnyXML) { + e.add(a.Name, ToEntry(a)) + } + case "case": + for _, a := range fv.Interface().([]*Case) { + e.add(a.Name, ToEntry(a)) + } + case "choice": + for _, a := range fv.Interface().([]*Choice) { + e.add(a.Name, ToEntry(a)) + } + case "container": + for _, a := range fv.Interface().([]*Container) { + e.add(a.Name, ToEntry(a)) + } + case "grouping": + for _, a := range fv.Interface().([]*Grouping) { + // We just want to parse the grouping to + // collect errors. + e.importErrors(ToEntry(a)) + } + case "import": + // Import only makes types and such available. + // There is nothing else for us to do. + case "include": + for _, a := range fv.Interface().([]*Include) { + // Handle circular dependencies between submodules. This can occur in + // two ways: + // - Where submodule A imports submodule B, and vice versa then the + // whilst processing A we will also try and process A (learnt via + // B). The default case of the switch handles this case. + // - Where submodule A imports submodule B that imports C, which also + // imports A, then we need to check whether we already have merged + // the specified module during this parse attempt. We check this + // against a map of merged submodules. + // The key of the map used is a synthesised value which is formed by + // concatenating the name of this node and the included submodule, + // separated by a ":". + srcToIncluded := a.Module.Name + ":" + n.NName() + includedToSrc := n.NName() + ":" + a.Module.Name + + switch { + case ms.mergedSubmodule[srcToIncluded]: + // We have already merged this module, so don't try and do it + // again. + continue + case !ms.mergedSubmodule[includedToSrc] && a.Module.NName() != n.NName(): + // We have not merged A->B, and B != B hence go ahead and merge. + includedToParent := a.Module.Name + ":" + a.Module.BelongsTo.Name + if ms.mergedSubmodule[includedToParent] { + // Don't try and re-import submodules that have already been imported + // into the top-level module. Note that this ensures that we get to the + // top the tree (whichever the actual module for the chain of + // submodules is). The tracking of the immediate parent is achieved + // through 'key', which ensures that we do not end up in loops + // walking through a sub-cycle of the include graph. + continue + } + ms.mergedSubmodule[srcToIncluded] = true + ms.mergedSubmodule[includedToParent] = true + e.merge(a.Module.Prefix, nil, ToEntry(a.Module)) + case ms.ParseOptions.IgnoreSubmoduleCircularDependencies: + continue + default: + e.addError(fmt.Errorf("%s: has a circular dependency, importing %s", n.NName(), a.Module.NName())) + } + } + case "leaf": + for _, a := range fv.Interface().([]*Leaf) { + e.add(a.Name, ToEntry(a)) + } + case "leaf-list": + for _, a := range fv.Interface().([]*LeafList) { + e.add(a.Name, ToEntry(a)) + } + case "list": + for _, a := range fv.Interface().([]*List) { + e.add(a.Name, ToEntry(a)) + } + case "key": + if v := fv.Interface().(*Value); v != nil { + e.Key = v.Name + } + case "notification": + for _, a := range fv.Interface().([]*Notification) { + e.add(a.Name, ToEntry(a)) + } + case "rpc": + // TODO(borman): what do we do with these? + // seems fine to ignore them for now, we are + // just interested in the tree structure. + for _, r := range fv.Interface().([]*RPC) { + switch rpc := ToEntry(r); { + case rpc.RPC == nil: + // When "rpc" has no "input" or "output" children + rpc.RPC = &RPCEntry{} + fallthrough + default: + e.add(r.Name, rpc) + } + } + + case "input": + if i := fv.Interface().(*Input); i != nil { + if e.RPC == nil { + e.RPC = &RPCEntry{} + } + in := ToEntry(i) + in.Parent = e + e.RPC.Input = in + e.RPC.Input.Name = "input" + e.RPC.Input.Kind = InputEntry + } + case "output": + if o := fv.Interface().(*Output); o != nil { + if e.RPC == nil { + e.RPC = &RPCEntry{} + } + out := ToEntry(o) + out.Parent = e + e.RPC.Output = out + e.RPC.Output.Name = "output" + e.RPC.Output.Kind = OutputEntry + } + case "identity": + if i := fv.Interface().([]*Identity); i != nil { + e.Identities = i + } + case "uses": + for _, a := range fv.Interface().([]*Uses) { + grouping := ToEntry(a) + e.merge(nil, nil, grouping) + // Apply inline augments from the uses statement. Their paths + // are relative to e (the node where the uses appears), so we + // resolve them directly rather than deferring to e.Augment(). + for _, aug := range a.Augment { + target := e.Find(aug.Name) + if target != nil { + augEntry := ToEntry(aug) + target.merge(nil, augEntry.Namespace(), augEntry) + } + } + if ms.ParseOptions.StoreUses { + e.Uses = append(e.Uses, &UsesStmt{a, grouping.shallowDup()}) + } + } + case "type": + // The type keyword is specific to deviate to change a type. Other type handling + // (e.g., leaf type resolution) is done outside of this case. + n, ok := n.(*Deviate) + if !ok { + e.addError(fmt.Errorf("unexpected type found, only valid under Deviate, is %T", n)) + continue + } + + if n.Type != nil { + if errs := n.Type.resolve(ms.typeDict); errs != nil { + e.addError(fmt.Errorf("deviation has unresolvable type, %v", errs)) + continue + } + e.Type = n.Type.YangType + } + continue + // Keywords that do not need to be handled as an Entry as they are added + // to other dictionaries. + case "default": + switch e.Kind { + case LeafEntry, ChoiceEntry: + // default is handled separately for leaf, leaf-list and choice + case DeviateEntry: + // handle deviate statements. + // TODO(wenovus): support refine statement's default substatement. + d, ok := fv.Interface().(*Value) + if !ok { + e.addError(fmt.Errorf("%s: unexpected default type in %s:%s", Source(n), n.Kind(), n.NName())) + } + // TODO(wenovus): deviate statement and refine statement should + // allow multiple default substatements for leaf-list types (YANG1.1). + if d != nil { + e.Default = []string{d.asString()} + } + } + case "typedef": + continue + case "deviation": + if a := fv.Interface().([]*Deviation); a != nil { + for _, d := range a { + deviatedEntry := ToEntry(d) + e.importErrors(deviatedEntry) + e.Deviations = append(e.Deviations, &DeviatedEntry{ + Entry: deviatedEntry, + DeviatedPath: d.Statement().Argument, + }) + + for _, sd := range d.Deviate { + if sd.Type != nil { + sd.Type.resolve(ms.typeDict) + } + } + } + } + case "deviate": + if a := fv.Interface().([]*Deviate); a != nil { + for _, d := range a { + de := ToEntry(d) + + dt, ok := toDeviation[d.Statement().Argument] + if !ok { + e.addError(fmt.Errorf("%s: unknown deviation type in %s:%s", Source(n), n.Kind(), n.NName())) + continue + } + + if e.Deviate == nil { + e.Deviate = map[deviationType][]*Entry{} + } + + e.Deviate[dt] = append(e.Deviate[dt], de) + } + } + case "mandatory": + v, ok := fv.Interface().(*Value) + if !ok { + e.addError(fmt.Errorf("%s: did not get expected value type", Source(n))) + } + e.Mandatory, err = tristateValue(v) + e.addError(err) + case "max-elements", "min-elements": + if e.Kind != DeviateEntry { + continue + } + // we can get max-elements or min-elements in a deviate statement, so create the + // corresponding logic. + v, ok := fv.Interface().(*Value) + if !ok { + e.addError(fmt.Errorf("%s: max or min elements had wrong type, %s:%s", Source(n), n.Kind(), n.NName())) + continue + } + + if e.ListAttr == nil { + e.ListAttr = NewDefaultListAttr() + } + + // Only record the deviation if the statement exists. + if v != nil { + var err error + if name == "max-elements" { + e.deviatePresence.hasMaxElements = true + if e.ListAttr.MaxElements, err = semCheckMaxElements(v); err != nil { + e.addError(err) + } + } else { + e.deviatePresence.hasMinElements = true + if e.ListAttr.MinElements, err = semCheckMinElements(v); err != nil { + e.addError(err) + } + } + } + case "units": + v, ok := fv.Interface().(*Value) + if !ok { + e.addError(fmt.Errorf("%s: units had wrong type, %s:%s", Source(n), n.Kind(), n.NName())) + } + if v != nil { + e.Units = v.asString() + } + // TODO(borman): unimplemented keywords + case "belongs-to", + "contact", + "extension", + "feature", + "if-feature", + "must", + "namespace", + "ordered-by", + "organization", + "presence", + "reference", + "revision", + "status", + "unique", + "when", + "yang-version": + if !fv.IsNil() { + addToExtrasSlice(fv, name, e) + } + continue + + case "Ext", "Name", "Parent", "Statement": + // These are meta-keywords used internally + continue + default: + e.addError(fmt.Errorf("%s: unexpected statement: %s", Source(n), name)) + continue + + } + // We found at least one field. + found = true + } + if !found { + return newError(n, "%T: cannot be converted to a *Entry", n) + } + // If prefix isn't set, provide it based on our root node (module) + if e.Prefix == nil { + e.Prefix = getRootPrefix(e) + } + + return e +} + +// addExtraKeywordsToLeafEntry stores the values for unimplemented keywords in leaf entries. +func addExtraKeywordsToLeafEntry(n Node, e *Entry) { + v := reflect.ValueOf(n).Elem() + t := v.Type() + + for i := t.NumField() - 1; i > 0; i-- { + f := t.Field(i) + yang := f.Tag.Get("yang") + if yang == "" { + continue + } + fv := v.Field(i) + name := strings.Split(yang, ",")[0] + switch name { + case "if-feature", + "must", + "reference", + "status", + "when": + if !fv.IsNil() { + addToExtrasSlice(fv, name, e) + } + } + } +} + +func addToExtrasSlice(fv reflect.Value, name string, e *Entry) { + if fv.Kind() == reflect.Slice { + for j := 0; j < fv.Len(); j++ { + e.Extra[name] = append(e.Extra[name], fv.Index(j).Interface()) + } + } else { + e.Extra[name] = append(e.Extra[name], fv.Interface()) + } +} + +// getRootPrefix returns the prefix of e's root node (module) +func getRootPrefix(e *Entry) *Value { + if m := RootNode(e.Node); m != nil { + return m.getPrefix() + } + return nil +} + +// Augment processes augments in e, return the number of augments processed +// and the augments skipped. If addErrors is true then missing augments will +// generate errors. +func (e *Entry) Augment(addErrors bool) (processed, skipped int) { + // Now process the augments we found + // NOTE(borman): is it possible this will fail if the augment refers + // to some removed sibling that has not been processed? Perhaps this + // should be done after the entire tree is built. Is it correct to + // assume augment paths are data tree paths and not schema tree paths? + // Augments can depend upon augments. We need to figure out how to + // order the augments (or just keep trying until we can make no further + // progress) + var unapplied []*Entry + for _, a := range e.Augments { + target := a.Find(a.Name) + if target == nil { + if addErrors { + e.errorf("%s: augment %s not found", Source(a.Node), a.Name) + } + skipped++ + unapplied = append(unapplied, a) + continue + } + // Augments do not have a prefix we merge in, just a node. + // We retain the namespace from the original context of the + // augment since the nodes have this namespace even though they + // are merged into another entry. + processed++ + target.merge(nil, a.Namespace(), a) + target.Augmented = append(target.Augmented, a.shallowDup()) + } + e.Augments = unapplied + return processed, skipped +} + +// ApplyDeviate walks the deviations within the supplied entry, and applies them to the +// schema. +func (e *Entry) ApplyDeviate(deviateOpts ...DeviateOpt) []error { + var errs []error + appendErr := func(err error) { errs = append(errs, err) } + for _, d := range e.Deviations { + deviatedNode := e.Find(d.DeviatedPath) + if deviatedNode == nil { + appendErr(fmt.Errorf("cannot find target node to deviate, %s", d.DeviatedPath)) + continue + } + + for dt, dv := range d.Deviate { + for _, devSpec := range dv { + switch dt { + case DeviationAdd, DeviationReplace: + if devSpec.Config != TSUnset { + deviatedNode.Config = devSpec.Config + } + + if len(devSpec.Default) > 0 { + switch dt { + case DeviationAdd: + switch { + case deviatedNode.IsLeafList(): + deviatedNode.Default = append(deviatedNode.Default, devSpec.Default...) + case len(devSpec.Default) > 1: + appendErr(fmt.Errorf("%s: tried to add more than one default to a non-leaflist entry at deviation", Source(e.Node))) + case len(deviatedNode.Default) != 0: + appendErr(fmt.Errorf("%s: tried to add a default value to an entry that already has a default value", Source(e.Node))) + case len(devSpec.Default) == 1 && len(deviatedNode.Default) == 0: + deviatedNode.Default = append([]string{}, devSpec.Default[0]) + } + case DeviationReplace: + deviatedNode.Default = append([]string{}, devSpec.Default...) + } + } + + if devSpec.Mandatory != TSUnset { + deviatedNode.Mandatory = devSpec.Mandatory + } + + if devSpec.deviatePresence.hasMinElements { + if !deviatedNode.IsList() && !deviatedNode.IsLeafList() { + appendErr(fmt.Errorf("tried to deviate min-elements on a non-list type %s", deviatedNode.Kind)) + continue + } + deviatedNode.ListAttr.MinElements = devSpec.ListAttr.MinElements + } + + if devSpec.deviatePresence.hasMaxElements { + if !deviatedNode.IsList() && !deviatedNode.IsLeafList() { + appendErr(fmt.Errorf("tried to deviate max-elements on a non-list type %s", deviatedNode.Kind)) + continue + } + deviatedNode.ListAttr.MaxElements = devSpec.ListAttr.MaxElements + } + + if devSpec.Units != "" { + deviatedNode.Units = devSpec.Units + } + + if devSpec.Type != nil { + deviatedNode.Type = devSpec.Type + } + + case DeviationNotSupported: + dp := deviatedNode.Parent + if dp == nil { + appendErr(fmt.Errorf("%s: node %s does not have a valid parent, but deviate not-supported references one", Source(e.Node), e.Name)) + continue + } + if !hasIgnoreDeviateNotSupported(deviateOpts) { + dp.delete(deviatedNode.Name) + } + case DeviationDelete: + if devSpec.Config != TSUnset { + deviatedNode.Config = TSUnset + } + + if len(devSpec.Default) > 0 { + switch { + case deviatedNode.IsLeafList(): + // It is unclear from RFC7950 on how deviate delete works + // when there are duplicate leaf-list values in config-false leafs. + // TODO(wenbli): Add support for deleting default values when the leaf-list is a config leaf (duplicates are not allowed). + appendErr(fmt.Errorf("%s: deviate delete on default statements unsupported for leaf-lists, please use replace instead", Source(e.Node))) + case len(deviatedNode.Default) == 0: + appendErr(fmt.Errorf("%s: tried to deviate delete a default statement that doesn't exist", Source(e.Node))) + case devSpec.Default[0] != deviatedNode.Default[0]: + appendErr(fmt.Errorf("%s: tried to deviate delete a default statement with a non-matching keyword", Source(e.Node))) + default: + deviatedNode.Default = nil + } + } + + if devSpec.Mandatory != TSUnset { + deviatedNode.Mandatory = TSUnset + } + + if devSpec.deviatePresence.hasMinElements { + if !deviatedNode.IsList() && !deviatedNode.IsLeafList() { + appendErr(fmt.Errorf("tried to deviate min-elements on a non-list type %s", deviatedNode.Kind)) + continue + } + if deviatedNode.ListAttr.MinElements != devSpec.ListAttr.MinElements { + // Argument value must match: + // https://tools.ietf.org/html/rfc7950#section-7.20.3.2 + appendErr(fmt.Errorf("min-element value %d differs from deviation's min-element value %d for entry %v", devSpec.ListAttr.MinElements, deviatedNode.ListAttr.MinElements, d.DeviatedPath)) + } + deviatedNode.ListAttr.MinElements = 0 + } + + if devSpec.deviatePresence.hasMaxElements { + if !deviatedNode.IsList() && !deviatedNode.IsLeafList() { + appendErr(fmt.Errorf("tried to deviate max-elements on a non-list type %s", deviatedNode.Kind)) + continue + } + if deviatedNode.ListAttr.MaxElements != devSpec.ListAttr.MaxElements { + appendErr(fmt.Errorf("max-element value %d differs from deviation's max-element value %d for entry %v", devSpec.ListAttr.MaxElements, deviatedNode.ListAttr.MaxElements, d.DeviatedPath)) + } + deviatedNode.ListAttr.MaxElements = math.MaxUint64 + } + + default: + appendErr(fmt.Errorf("invalid deviation type %s", dt)) + } + } + } + } + + return errs +} + +// FixChoice inserts missing Case entries for non-case entries within a choice +// entry. +func (e *Entry) FixChoice() { + if e.Kind == ChoiceEntry && len(e.Errors) == 0 { + for k, ce := range e.Dir { + if ce.Kind != CaseEntry { + ne := &Entry{ + Parent: e, + Node: &Case{ + Parent: ce.Node.ParentNode(), + Name: ce.Node.NName(), + Source: ce.Node.Statement(), + Extensions: ce.Node.Exts(), + }, + Name: ce.Name, + Kind: CaseEntry, + Config: ce.Config, + Prefix: ce.Prefix, + Dir: map[string]*Entry{ce.Name: ce}, + Extra: map[string][]interface{}{}, + } + ce.Parent = ne + e.Dir[k] = ne + } + } + } + for _, ce := range e.Dir { + ce.FixChoice() + } +} + +// ReadOnly returns true if e is a read-only variable (config == false). +// If Config is unset in e, then false is returned if e has no parent, +// otherwise the value parent's ReadOnly is returned. +func (e *Entry) ReadOnly() bool { + switch { + case e == nil: + // We made it all the way to the root of the tree + return false + case e.Kind == OutputEntry: + return true + case e.Config == TSUnset: + return e.Parent.ReadOnly() + default: + return !e.Config.Value() + } +} + +// Find finds the Entry named by name relative to e. +func (e *Entry) Find(name string) *Entry { + if e == nil || name == "" { + return nil + } + parts := strings.Split(name, "/") + + // If parts[0] is "" then this path started with a / + // and we need to find our parent. + if parts[0] == "" { + parts = parts[1:] + contextNode := e.Node + for e.Parent != nil { + e = e.Parent + } + if prefix, _ := getPrefix(parts[0]); prefix != "" { + mod := FindModuleByPrefix(contextNode, prefix) + if mod == nil { + e.addError(fmt.Errorf("cannot find module giving prefix %q within context entry %q", prefix, e.Path())) + return nil + } + m := module(mod) + if m == nil { + e.addError(fmt.Errorf("cannot find which module %q belongs to within context entry %q", + mod.NName(), e.Path())) + return nil + } + if m != e.Node.(*Module) { + e = ToEntry(m) + } + } + } + + for _, part := range parts { + switch { + case e == nil: + return nil + case part == ".": + case part == "..": + e = e.Parent + case e.RPC != nil: + _, part = getPrefix(part) + switch part { + case "input": + if e.RPC.Input == nil { + e.RPC.Input = &Entry{ + Name: "input", + Kind: InputEntry, + Dir: make(map[string]*Entry), + } + } + e = e.RPC.Input + case "output": + if e.RPC.Output == nil { + e.RPC.Output = &Entry{ + Name: "output", + Kind: OutputEntry, + Dir: make(map[string]*Entry), + } + } + e = e.RPC.Output + } + default: + _, part = getPrefix(part) + switch part { + case ".": + case "", "..": + return nil + default: + e = e.Dir[part] + } + } + } + return e +} + +// Path returns the path to e. A nil Entry returns "". +func (e *Entry) Path() string { + if e == nil { + return "" + } + return e.Parent.Path() + "/" + e.Name +} + +// Namespace returns the YANG/XML namespace Value for e as mounted in the Entry +// tree (e.g., as placed by grouping statements). +// +// Per RFC6020 section 7.12, the namespace on elements in the tree due to a +// "uses" statement is that of the where the uses statement occurs, i.e., the +// user, rather than creator (grouping) of those elements, so we follow the +// usage (Entry) tree up to the parent before obtaining the (then adjacent) root +// node for its namespace Value. +func (e *Entry) Namespace() *Value { + // Make e the root parent entry + for ; e.Parent != nil; e = e.Parent { + if e.namespace != nil { + return e.namespace + } + } + + // Return the namespace of a valid root parent entry + if e != nil && e.Node != nil { + if root := RootNode(e.Node); root != nil { + if root.Kind() == "submodule" { + root = root.Modules.Modules[root.BelongsTo.Name] + if root == nil { + return new(Value) + } + } + return root.Namespace + } + } + + // Otherwise return an empty namespace Value (rather than nil) + return new(Value) +} + +// InstantiatingModule returns the YANG module which instantiated the Entry +// within the schema tree - using the same rules described in the documentation +// of the Namespace function. The namespace is resolved in the module name. This +// approach to namespacing is used when serialising YANG-modelled data to JSON as +// per RFC7951. +func (e *Entry) InstantiatingModule() (string, error) { + n := e.Namespace() + if n == nil { + return "", fmt.Errorf("entry %s had nil namespace", e.Name) + } + + module, err := e.Modules().FindModuleByNamespace(n.Name) + if err != nil { + return "", fmt.Errorf("could not find module %q when retrieving namespace for %s: %v", n.Name, e.Name, err) + } + return module.Name, nil +} + +// shallowDup makes a shallow duplicate of e (only direct children are +// duplicated; grandchildren and deeper descendants are deleted). +func (e *Entry) shallowDup() *Entry { + // Warning: if we add any elements to Entry that should not be + // copied we will have to explicitly uncopy them. + ne := *e + + // Now only copy direct children, clear their Dir, and fix up + // Parent pointers. + if e.Dir != nil { + ne.Dir = make(map[string]*Entry, len(e.Dir)) + for k, v := range e.Dir { + de := *v + de.Dir = nil + de.Parent = &ne + ne.Dir[k] = &de + } + } + return &ne +} + +// dup makes a deep duplicate of e. +func (e *Entry) dup() *Entry { + // Warning: if we add any elements to Entry that should not be + // copied we will have to explicitly uncopy them. + // It is possible we may want to do a deep copy on some other fields, + // such as Exts, Choice and Case, but it is not clear that we need + // to do that. + ne := *e + + // Now recurse down to all of our children, fixing up Parent + // pointers as we go. + if e.Dir != nil { + ne.Dir = make(map[string]*Entry, len(e.Dir)) + for k, v := range e.Dir { + de := v.dup() + de.Parent = &ne + ne.Dir[k] = de + } + } + + ne.Extra = make(map[string][]interface{}) + for k, v := range e.Extra { + ne.Extra[k] = v + } + + return &ne +} + +// merge merges a duplicate of oe.Dir into e.Dir, setting the prefix of each +// element to prefix, if not nil. It is an error if e and oe contain common +// elements. +func (e *Entry) merge(prefix *Value, namespace *Value, oe *Entry) { + e.importErrors(oe) + for k, v := range oe.Dir { + v := v.dup() + if prefix != nil { + v.Prefix = prefix + } + if namespace != nil { + v.namespace = namespace + } + if se := e.Dir[k]; se != nil { + er := newError(oe.Node, `Duplicate node %q in %q from: + %s: %s + %s: %s`, k, e.Name, Source(v.Node), v.Name, Source(se.Node), se.Name) + e.addError(er.Errors[0]) + } else { + v.Parent = e + v.Exts = append(v.Exts, oe.Exts...) + for lk := range oe.Extra { + v.Extra[lk] = append(v.Extra[lk], oe.Extra[lk]...) + } + e.Dir[k] = v + } + } +} + +// nless returns -1 if a is less than b, 0 if a == b, and 1 if a > b. +// If a and b are both numeric, then nless compares them as numbers, +// otherwise they are compared lexicographically. +func nless(a, b string) int { + an, ae := strconv.Atoi(a) + bn, be := strconv.Atoi(b) + switch { + case ae == nil && be == nil: + switch { + case an < bn: + return -1 + case an > bn: + return 1 + default: + return 0 + } + case a < b: + return -1 + case a > b: + return 1 + default: + return 0 + } +} + +type sError struct { + s string + err error +} + +type sortedErrors []sError + +func (s sortedErrors) Len() int { return len(s) } +func (s sortedErrors) Swap(i, j int) { s[i], s[j] = s[j], s[i] } +func (s sortedErrors) Less(i, j int) bool { + // We expect the error strings to be composed of error messages, + // line numbers, etc. delimited by ":". + const errorSplitCount = 4 + fi := strings.SplitN(s[i].s, ":", errorSplitCount) + fj := strings.SplitN(s[j].s, ":", errorSplitCount) + // First, order the errors by the file name. + if fi[0] < fj[0] { + return true + } + if fi[0] > fj[0] { + return false + } + + // compare remaining indices of the error string slices + // in order to create a total ordering. + for i := 1; i < errorSplitCount; i++ { + switch { + // Handle when an expected index doesn't exist. + case len(fj) == i: + return false + case len(fi) == i: + return true + } + + switch nless(fi[i], fj[i]) { + case -1: + return true + case 1: + return false + } + } + return false +} + +// errorSort sorts the strings in the errors slice assuming each line starts +// with file:line:col. Line and column number are sorted numerically. +// Duplicate errors are stripped. +func errorSort(errors []error) []error { + switch len(errors) { + case 0: + return nil + case 1: + return errors + } + elist := make(sortedErrors, len(errors)) + for x, err := range errors { + elist[x] = sError{err.Error(), err} + } + sort.Sort(elist) + errors = make([]error, len(errors)) + i := 0 + for _, err := range elist { + if i > 0 && reflect.DeepEqual(err.err, errors[i-1]) { + continue + } + errors[i] = err.err + i++ + } + return errors[:i] +} + +// SingleDefaultValue returns the single schema default value for e and a bool +// indicating whether the entry contains one and only one default value. The +// empty string is returned when the entry has zero or multiple default values. +// This function is useful for determining the default values of a +// non-leaf-list leaf entry. If the leaf has no explicit default, its type +// default (if any) will be used. +// +// For a leaf-list entry, use DefaultValues() instead. +func (e *Entry) SingleDefaultValue() (string, bool) { + if dvals := e.DefaultValues(); len(dvals) == 1 { + return dvals[0], true + } + return "", false +} + +// DefaultValues returns all default values for the leaf entry. This is useful +// for determining the default values for a leaf-list, which may have more than +// one default value. If the entry has no explicit default, its type default +// (if any) will be used. nil is returned when no default value exists. +// +// For a leaf entry, use SingleDefaultValue() instead. +func (e *Entry) DefaultValues() []string { + if len(e.Default) > 0 { + return append([]string{}, e.Default...) + } + + if typ := e.Type; typ != nil && typ.HasDefault { + switch leaf := e.Node.(type) { + case *Leaf: + switch { + case e.IsLeaf() && (leaf.Mandatory == nil || leaf.Mandatory.Name == "false"), e.IsLeafList() && e.ListAttr.MinElements == 0: + return []string{typ.Default} + } + } + } + return nil +} diff --git a/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/file.go b/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/file.go new file mode 100644 index 00000000..48ff76be --- /dev/null +++ b/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/file.go @@ -0,0 +1,167 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +var ( + // revisionDateSuffixRegex matches on the revision-date portion of a YANG + // file's name. + revisionDateSuffixRegex = regexp.MustCompile(`^@\d{4}-\d{2}-\d{2}\.yang$`) +) + +// PathsWithModules returns all paths under and including the +// root containing files with a ".yang" extension, as well as +// any error encountered +func PathsWithModules(root string) (paths []string, err error) { + pm := map[string]bool{} + filepath.Walk(root, func(p string, info os.FileInfo, e error) error { + err = e + if err == nil { + if info == nil { + return nil + } + if !info.IsDir() && strings.HasSuffix(p, ".yang") { + dir := filepath.Dir(p) + if !pm[dir] { + pm[dir] = true + paths = append(paths, dir) + } + } + return nil + } + return err + }) + return +} + +// AddPath adds the directories specified in p, a colon separated list +// of directory names, to Path, if they are not already in Path. Using +// multiple arguments is also supported. +func (ms *Modules) AddPath(paths ...string) { + for _, path := range paths { + for _, p := range strings.Split(path, ":") { + if !ms.pathMap[p] { + ms.pathMap[p] = true + ms.Path = append(ms.Path, p) + } + } + } +} + +// readFile makes testing of findFile easier. +var readFile = ioutil.ReadFile + +// scanDir makes testing of findFile easier. +var scanDir = findInDir + +// findFile returns the name and contents of the .yang file associated with +// name, or an error. If name is a module name rather than a file name (it does +// not have a .yang extension and there is no / in name), .yang is appended to +// the the name. The directory that the .yang file is found in is added to Path +// if not already in Path. If a file is not found by exact match, directories +// are scanned for "name@revision-date.yang" files, the latest (sorted by +// YYYY-MM-DD revision-date) of these will be selected. +// +// If a path has the form dir/... then dir and all direct or indirect +// subdirectories of dir are searched. +// +// The current directory (.) is always checked first, no matter the value of +// Path. +func (ms *Modules) findFile(name string) (string, string, error) { + slash := strings.Index(name, "/") + if slash < 0 && !strings.HasSuffix(name, ".yang") { + name += ".yang" + if best := scanDir(".", name, false); best != "" { + // we found a matching candidate in the local directory + name = best + } + } + + switch data, err := readFile(name); true { + case err == nil: + ms.AddPath(filepath.Dir(name)) + return name, string(data), nil + case slash >= 0: + // If there are any /'s in the name then don't search Path. + return "", "", fmt.Errorf("no such file: %s", name) + } + + for _, dir := range ms.Path { + var n string + if filepath.Base(dir) == "..." { + n = scanDir(filepath.Dir(dir), name, true) + } else { + n = scanDir(dir, name, false) + } + if n == "" { + continue + } + if data, err := readFile(n); err == nil { + return n, string(data), nil + } + } + return "", "", fmt.Errorf("no such file: %s", name) +} + +// findInDir looks for a file named name in dir or any of its subdirectories if +// recurse is true. if recurse is false, scan only the directory dir. +// If no matching file is found, an empty string is returned. +// +// The file SHOULD have the following name, per +// https://tools.ietf.org/html/rfc7950#section-5.2: +// module-or-submodule-name ['@' revision-date] '.yang' +// where revision-date = 4DIGIT "-" 2DIGIT "-" 2DIGIT +// +// If a perfect name match is found, then that file's path is returned. +// Else if file(s) with otherwise matching names but which contain a +// revision-date pattern exactly matching the above are found, then path of the +// one with the latest date is returned. +func findInDir(dir, name string, recurse bool) string { + fis, err := ioutil.ReadDir(dir) + if err != nil { + return "" + } + + var revisions []string + mname := strings.TrimSuffix(name, ".yang") + for _, fi := range fis { + switch { + case !fi.IsDir(): + if fn := fi.Name(); fn == name { + return filepath.Join(dir, name) + } else if strings.HasPrefix(fn, mname) && revisionDateSuffixRegex.MatchString(strings.TrimPrefix(fn, mname)) { + revisions = append(revisions, fn) + } + case recurse: + if n := findInDir(filepath.Join(dir, fi.Name()), name, recurse); n != "" { + return n + } + } + } + if len(revisions) == 0 { + return "" + } + sort.Strings(revisions) + return filepath.Join(dir, revisions[len(revisions)-1]) +} diff --git a/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/find.go b/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/find.go new file mode 100644 index 00000000..9f0575d4 --- /dev/null +++ b/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/find.go @@ -0,0 +1,96 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +// This file has functions that search the AST for specified nodes. + +import ( + "reflect" + "strings" +) + +// localPrefix returns the local prefix used by the containing (sub)module to +// refer to its own module. +func localPrefix(n Node) string { + return RootNode(n).GetPrefix() +} + +// trimLocalPrefix trims the current module's prefix from the given name. If the +// name is not prefixed with the local module's prefix or is unprefixed +// entirely, then the same string is returned unchanged. +func trimLocalPrefix(n Node, name string) string { + pfx := localPrefix(n) + if pfx != "" { + pfx += ":" + } + return strings.TrimPrefix(name, pfx) +} + +// FindGrouping finds the grouping named name according to YANG namespace rules +// using the input node as the initial context node. The seen parameter +// provides a list of the modules previously seen by FindGrouping during +// traversal. If the named grouping cannot be found, nil is returned. +// +// FindGrouping works by recursively looking through the context node's parent +// nodes for grouping fields, or in included or imported submodules/modules for +// externally-defined groupings. Note that any prefix in the name must match +// the module prefix of its import statement in the context node's module. +func FindGrouping(n Node, name string, seen map[string]bool) *Grouping { + name = trimLocalPrefix(n, name) + for n != nil { + // Grab the Grouping field of the underlying structure. n is + // always a pointer to a structure, + e := reflect.ValueOf(n).Elem() + v := e.FieldByName("Grouping") + if v.IsValid() { + for _, g := range v.Interface().([]*Grouping) { + if g.Name == name { + return g + } + } + } + v = e.FieldByName("Import") + if v.IsValid() { + for _, i := range v.Interface().([]*Import) { + // If the prefix matches the import statement, + // then search for the trimmed name in that module. + pname := strings.TrimPrefix(name, i.Prefix.Name+":") + if pname == name { + continue + } + if g := FindGrouping(i.Module, pname, seen); g != nil { + return g + } + } + } + v = e.FieldByName("Include") + if v.IsValid() { + for _, i := range v.Interface().([]*Include) { + if seen[i.Module.Name] { + // Prevent infinite loops in the case that we have already looked at + // this submodule. This occurs where submodules have include statements + // in them, or there is a circular dependency. + continue + } + seen[i.Module.Name] = true + if g := FindGrouping(i.Module, name, seen); g != nil { + return g + } + } + } + n = n.ParentNode() + } + return nil +} diff --git a/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/identity.go b/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/identity.go new file mode 100644 index 00000000..615bff79 --- /dev/null +++ b/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/identity.go @@ -0,0 +1,192 @@ +// Copyright 2016 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +import ( + "fmt" + "sort" + "sync" +) + +// This file implements data structures and functions that relate to the +// identity type. + +// identityDictionary stores a set of identities across all parsed Modules that +// have been resolved to be identified by their module and name. +type identityDictionary struct { + mu sync.Mutex + // dict is a global cache of identities keyed by + // modulename:identityname, where modulename is the full name of the + // module to which the identity belongs. If the identity were defined + // in a submodule, then the parent module name is used instead. + dict map[string]resolvedIdentity +} + +// resolvedIdentity is an Identity that has been disambiguated. +type resolvedIdentity struct { + Module *Module + Identity *Identity +} + +// isEmpty determines whether the resolvedIdentity struct value is populated. +func (r resolvedIdentity) isEmpty() bool { + return r.Module == nil && r.Identity == nil +} + +// newResolvedIdentity creates a resolved identity from an identity and its +// associated module, and returns the prefixed name (Prefix:IdentityName) +// along with the resolved identity. +func newResolvedIdentity(m *Module, i *Identity) (string, *resolvedIdentity) { + r := &resolvedIdentity{ + Module: m, + Identity: i, + } + return i.modulePrefixedName(), r +} + +func appendIfNotIn(ids []*Identity, chk *Identity) []*Identity { + for _, id := range ids { + if id == chk { + return ids + } + } + return append(ids, chk) +} + +// addChildren adds identity r and all of its children to ids +// deterministically. +func addChildren(r *Identity, ids []*Identity) []*Identity { + ids = appendIfNotIn(ids, r) + + // Iterate through the values of r. + for _, ch := range r.Values { + ids = addChildren(ch, ids) + } + return ids +} + +// findIdentityBase returns the resolved identity that is corresponds to the +// baseStr string in the context of the module/submodule mod. +func (mod *Module) findIdentityBase(baseStr string) (*resolvedIdentity, []error) { + var base resolvedIdentity + var ok bool + var errs []error + + basePrefix, baseName := getPrefix(baseStr) + rootPrefix := mod.GetPrefix() + source := Source(mod) + typeDict := mod.Modules.typeDict + + switch basePrefix { + case "", rootPrefix: + // This is a local identity which is defined within the current + // module + keyName := fmt.Sprintf("%s:%s", module(mod).Name, baseName) + base, ok = typeDict.identities.dict[keyName] + if !ok { + errs = append(errs, fmt.Errorf("%s: can't resolve the local base %s as %s", source, baseStr, keyName)) + } + default: + // This is an identity which is defined within another module + extmod := FindModuleByPrefix(mod, basePrefix) + if extmod == nil { + errs = append(errs, + fmt.Errorf("%s: can't find external module with prefix %s", source, basePrefix)) + break + } + // The identity we are looking for is modulename:basename. + if id, ok := typeDict.identities.dict[fmt.Sprintf("%s:%s", module(extmod).Name, baseName)]; ok { + base = id + break + } + + // Error if we did not find the identity that had the name specified in + // the module it was expected to be in. + if base.isEmpty() { + errs = append(errs, fmt.Errorf("%s: can't resolve remote base %s", source, baseStr)) + } + } + return &base, errs +} + +func (ms *Modules) resolveIdentities() []error { + defer ms.typeDict.identities.mu.Unlock() + ms.typeDict.identities.mu.Lock() + + var errs []error + + // Across all modules, read the identity values that have been extracted + // from them, and compile them into a "fully resolved" map that means that + // we can look them up based on the 'real' prefix of the module and the + // name of the identity. + for _, mod := range ms.Modules { + for _, i := range mod.Identities() { + keyName, r := newResolvedIdentity(mod, i) + ms.typeDict.identities.dict[keyName] = *r + } + + // Hoist up all identities in our included submodules. + // We could just do a range on ms.SubModules, but that + // might process a submodule that no module included. + for _, in := range mod.Include { + if in.Module == nil { + continue + } + for _, i := range in.Module.Identities() { + keyName, r := newResolvedIdentity(in.Module, i) + ms.typeDict.identities.dict[keyName] = *r + } + } + } + + // Now, we want to create for all identities a view of all of their children. + // A child identity here means an inherited identity. + // + // We start by finding the direct children of all identities using the + // 'base' statement. + for _, i := range ms.typeDict.identities.dict { + if i.Identity.Base != nil { + // This identity inherits from one or more other identities. + + root := RootNode(i.Identity) + for _, b := range i.Identity.Base { + base, baseErr := root.findIdentityBase(b.asString()) + + if baseErr != nil { + errs = append(errs, baseErr...) + continue + } + + // Build up a list of direct children of this identity. + base.Identity.Values = append(base.Identity.Values, i.Identity) + } + } + } + + // Now, we can find all transitive identities by recursively populating + // the children of each identity. + for _, i := range ms.typeDict.identities.dict { + newValues := []*Identity{} + for _, j := range i.Identity.Values { + newValues = addChildren(j, newValues) + } + sort.SliceStable(newValues, func(j, k int) bool { + return newValues[j].Name < newValues[k].Name + }) + i.Identity.Values = newValues + } + + return errs +} diff --git a/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/lex.go b/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/lex.go new file mode 100644 index 00000000..49a0515b --- /dev/null +++ b/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/lex.go @@ -0,0 +1,522 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +// This file implements the lexical tokenization of yang. The lexer returns +// a series of tokens with one of the following codes: +// +// tError // an error was encountered +// tEOF // end-of-file +// tString // A de-quoted string (e.g., "\"bob\"" becomes "bob") +// tUnquoted // An un-quoted string +// '{' +// ';' +// '}' + +import ( + "bytes" + "fmt" + "io" + "os" + "reflect" + "runtime" + "strings" + "unicode/utf8" +) + +const ( + eof = 0x7fffffff // end of file, also an invalid rune + maxErrors = 8 + tooMany = "too many errors...\n" +) + +// stateFn represents a state in the lexer as a function, returning the next +// state the lexer should move to. +type stateFn func(*lexer) stateFn + +// A lexer holds the internal state of the lexer. +type lexer struct { + errout io.Writer // destination for errors, defaults to os.Stderr + errcnt int // number of errors encountered + + file string // name of file we are processing + input string // contents of the file + start int // start position in input of unconsumed data. + pos int // current position in the input. + line int // the current line number (1's based) + col int // the current column number (0 based, add 1 before displaying) + + debug bool // set to true to include internal debugging + inPattern bool // set when parsing the argument to a pattern + items chan *token // channel of scanned items. + tcol int // column with tabs expanded (for multi-line strings) + scol int // starting col of current token + sline int // starting line of current token + state stateFn // current state of the lexer + width int // width of last rune read from input. +} + +// A code is a token code. Single character tokens (i.e., punctuation) +// are represented by their unicode code point. +type code int + +const ( + tEOF = code(-1 - iota) // Reached end of file + tError // An error + tString // A dequoted string + tUnquoted // A non-quoted string +) + +// String returns c as a string. +func (c code) String() string { + switch c { + case tError: + return "Error" + case tString: + return "String" + case tUnquoted: + return "Unquoted" + } + if c < 0 || c > '~' { + return fmt.Sprintf("%d", c) + } + return fmt.Sprintf("'%c'", c) +} + +// A token represents one lexical unit read from the input. +// Line and Col are both 1's based. +type token struct { + code code + Text string // the actual text of the token + File string // the source file the token is from + Line int // the source line number the token is from + Col int // the source column number the token is from (8 space tabs) +} + +// Code returns the code of t. If t is nil, tEOF is returned. +func (t *token) Code() code { + if t == nil { + return tEOF + } + return t.code +} + +// String returns the location, code, and text of t as a string. +func (t *token) String() string { + var s []string + if t.File != "" { + s = append(s, t.File+":") + } + if t.Line != 0 { + s = append(s, fmt.Sprintf("%d:%d:", t.Line, t.Col)) + } + if t.Text == "" { + s = append(s, fmt.Sprintf(" %v", t.code)) + } else { + s = append(s, " ", t.Text) + } + return strings.Join(s, "") +} + +// A note on writing to errout. Errors should always be written to errout +// in a single Write call. The test code makes this assumption for testing +// expected errors. + +// newLexer returns a new lexer, importing into it the provided input and path. +// The provided path should indicate where the source originated. +func newLexer(input, path string) *lexer { + // Force input to be newline terminated. + if len(input) > 0 && input[len(input)-1] != '\n' { + input += "\n" + } + return &lexer{ + file: path, + input: input, + line: 1, // humans start with 1 + items: make(chan *token, maxErrors), + state: lexGround, + errout: os.Stderr, + } +} + +// NextToken returns the next token from the input, returning nil on EOF. +func (l *lexer) NextToken() *token { + for { + select { + case item := <-l.items: + return item + default: + if l.state == nil { + return nil + } + if l.debug { + name := runtime.FuncForPC(reflect.ValueOf(l.state).Pointer()).Name() + name = name[strings.LastIndex(name, ".")+1:] + name = strings.TrimPrefix(name, "lex") + input := l.input[l.pos:] + if len(input) > 8 { + input = input[:8] + "..." + } + fmt.Fprintf(os.Stderr, "%d:%d: state %s %q\n", l.line, l.col+1, name, input) + } + l.state = l.state(l) + } + } +} + +// emit emits the currently parsed token marked with code c using emitText. +func (l *lexer) emit(c code) { + l.emitText(c, l.input[l.start:l.pos]) +} + +// emitText emits text as a token marked with c. +// All input up to the current cursor (pos) is consumed. +func (l *lexer) emitText(c code, text string) { + if l.debug { + fmt.Fprintf(os.Stderr, "%v: %q\n", c, text) + } + select { + case l.items <- &token{ + code: c, + Text: text, + File: l.file, + Line: l.sline, + Col: l.scol + 1, + }: + default: + } + l.consume() +} + +// consume consumes all input to the current cursor. +func (l *lexer) consume() { + l.start = l.pos +} + +// backup steps back one rune. It can be called only immediately after a call +// of next. Backing up over a tab will set tcol to the last position of the +// tab, not where the tab started. This is okay as when we call next again it +// will move tcol back to where it was before backup was called. +func (l *lexer) backup() { + l.pos -= l.width + if l.width > 0 { + l.col-- + l.tcol-- + if l.col < 0 { + // We must have backuped up over a newline. + // Don't bother to figure out the column number + // as the next call to next will reset it to 0. + l.line-- + l.col = 0 + l.tcol = 0 + } + } +} + +// peek returns but does not move past the next rune in the input. backup +// is not supported over peeked characters. +func (l *lexer) peek() rune { + rune := l.next() + l.backup() + return rune +} + +// next returns the next rune in the input. If next encounters the end of input +// then it will return eof. +func (l *lexer) next() (rune rune) { + if l.pos >= len(l.input) { + l.width = 0 + return eof + } + // l.width is what limits more than a single backup. + rune, l.width = utf8.DecodeRuneInString(l.input[l.pos:]) + l.pos += l.width + switch rune { + case '\n': + l.line++ + l.col = 0 + l.tcol = 0 + case '\t': + l.tcol = (l.tcol + 8) & ^7 + l.col++ // should this be l.width? + default: + l.tcol++ + l.col++ // should this be l.width? + } + return rune +} + +// acceptRun moves the cursor forward up to, but not including, the first rune +// not found in the valid set. It returns true if any runes were accepted. +func (l *lexer) acceptRun(valid string) bool { + ret := false + for strings.ContainsRune(valid, l.next()) { + ret = true + } + l.backup() + return ret +} + +// skipTo moves the cursor up to, but not including, s. +// Returns whether s was found in the remaining input. +func (l *lexer) skipTo(s string) bool { + if x := strings.Index(l.input[l.pos:], s); x >= 0 { + l.updateCursor(x) + return true + } + return false +} + +// updateCursor moves the cursor forward n bytes. updateCursor does not +// correctly handle tabs. This is okay as it is only used by skipTo, and skipTo +// is never used to skip to an initial " (which is the only time that tcol is +// necessary, as per YANG's multi-line quoted string requirement). +func (l *lexer) updateCursor(n int) { + s := l.input[l.pos : l.pos+n] + l.pos += n + // we could get away without updating width at all because backup is + // only promised to work after a call to next. + l.width = n + + if c := strings.Count(s, "\n"); c > 0 { + l.line += c + l.col = 0 + } + l.col += utf8.RuneCountInString(s[strings.LastIndex(s, "\n")+1:]) +} + +// Errorf writes an error on l.errout and increments the error count. +// If too many errors (8) are encountered then lexing will stop and +// eof is returned as the next token. +func (l *lexer) Errorf(f string, v ...interface{}) { + buf := &bytes.Buffer{} + + if l.debug { + // For internal debugging, print the file and line number + // of the call to Errorf + _, name, line, _ := runtime.Caller(1) + + fmt.Fprintf(buf, "%s:%d: ", name, line) + } + fmt.Fprintf(buf, "%s:%d:%d: ", l.file, l.line, l.col+1) + fmt.Fprintf(buf, f, v...) + b := buf.Bytes() + if b[len(b)-1] != '\n' { + buf.Write([]byte{'\n'}) + } + l.emit(tError) + l.adderror(buf.Bytes()) +} + +func (l *lexer) ErrorfAt(line, col int, f string, v ...interface{}) { + oline, ocol := l.line, l.col + defer func() { + l.line, l.col = oline, ocol + }() + l.line, l.col = line, col + l.Errorf(f, v...) +} + +// adderror writes out the error string err and increases the error count. +// If more than maxErrors are encountered, a "too many errors" message is +// displayed and processing stops (by clearing the input). +func (l *lexer) adderror(err []byte) { + if l.errcnt == maxErrors { + l.pos = 0 + l.start = 0 + l.input = "" + l.errout.Write([]byte(tooMany)) + l.errcnt++ + return + } else if l.errcnt == maxErrors+1 { + return + } + l.errout.Write(err) + l.errcnt++ +} + +// Below are all the states + +// lexGround is the state when the lexer is not in the middle of a token. The +// ground state is left once the start of a token is found. Pure comment lines +// leave the lexer in the ground state. +func lexGround(l *lexer) stateFn { + l.acceptRun(" \t\r\n") // Skip leading spaces + l.consume() + l.sline = l.line + l.scol = l.col + + switch c := l.peek(); c { + case eof: + return nil + case ';', '{', '}': + l.next() + l.emit(code(c)) + return lexGround + case '\'': + l.next() + l.consume() // Toss the leading ' + if !l.skipTo("'") { + l.ErrorfAt(l.line, l.col-1, `missing closing '`) + return nil + } + l.emit(tString) + l.next() // Either EOF or the matching ' + return lexGround + case '"': + l.next() + return lexQString + case '/': + l.next() + switch l.peek() { + case '/': + // Start of a // comment + if !l.skipTo("\n") { + // Here "\n" should always be found, since we force all + // input to be "\n" terminated. + l.ErrorfAt(l.line, l.col-1, `lexer internal error: all lines should be newline-terminated.`) + return nil + } + return lexGround + case '*': + // Start of a /* comment + if !l.skipTo("*/") { + l.ErrorfAt(l.line, l.col-1, `missing closing */`) + return nil + } + // Now actually skip the */ + l.next() + l.next() + return lexGround + default: + return lexUnquoted + } + case '+': + l.next() + switch l.peek() { + case '"', '\'': + l.emit(tUnquoted) + return lexGround + default: + return lexUnquoted + } + default: + return lexUnquoted + } +} + +// From the YANG standard: +// +// If the double-quoted string contains a line break followed by space +// or tab characters that are used to indent the text according to the +// layout in the YANG file, this leading whitespace is stripped from the +// string, up to and including the column of the double quote character, +// or to the first non-whitespace character, whichever occurs first. In +// this process, a tab character is treated as 8 space characters. +// +// If the double-quoted string contains space or tab characters before a +// line break, this trailing whitespace is stripped from the string. + +// lexQString handles double quoted strings, see the above text on how they +// work. The leading " has already been parsed. +func lexQString(l *lexer) stateFn { + indent := l.tcol // the column our text starts on + over := true // set to false when we are not past the indent + + // Keep track of where the starting quote was + line, col := l.line, l.col-1 + + var text []byte + for { + // l.next can return non-8bit unicode code points. + // c cannot be treated as only a single byte. + switch c := l.next(); c { + case eof: + l.ErrorfAt(line, col, `missing closing "`) + return nil + case '"': + l.emitText(tString, string(text)) + + return lexGround + case '\n': + Loop: + // Trim trailing white space from the line. + for i := len(text); i > 0; { + i-- + switch text[i] { + case ' ', '\t': + text = text[:i] + default: + break Loop + } + } + text = append(text, []byte(string(c))...) + over = false + case ' ', '\t': + // Ignore leading white space up to our indent. + if !over && l.tcol <= indent { + break + } + over = true + text = append(text, []byte(string(c))...) + case '\\': + switch c = l.next(); c { + case 'n': + c = '\n' + case 't': + c = '\t' + case '"': + case '\\': + default: + // Strings are use both in descriptions and + // in patterns. In strings only \n, \t, \" + // and \\ are defined. In patterns the \ + // can either mean to escape the character + // (e..g., \{) or to be part of of a special + // sequence such as \S. + if !l.inPattern { + l.ErrorfAt(l.line, l.col-2, `invalid escape sequence: \`+string(c)) + } + text = append(text, '\\') + } + fallthrough + default: + over = true + text = append(text, []byte(string(c))...) + } + } +} + +// lexUnquoted reads one identifier/number/un-quoted-string/... +// +// From https://tools.ietf.org/html/rfc7950#section-6.1.3: +// An unquoted string is any sequence of characters that does not +// contain any space, tab, carriage return, or line feed characters, a +// single or double quote character, a semicolon (";"), braces ("{" or +// "}"), or comment sequences ("//", "/*", or "*/"). +func lexUnquoted(l *lexer) stateFn { + for { + switch c := l.peek(); c { + // TODO: Support detection of comment immediately following an + // unquoted string, likely through supporting two peeks instead + // of just one. + case ' ', '\r', '\n', '\t', ';', '"', '\'', '{', '}', eof: + l.emit(tUnquoted) + return lexGround + default: + l.next() + } + } +} diff --git a/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/modules.go b/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/modules.go new file mode 100644 index 00000000..ab543d25 --- /dev/null +++ b/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/modules.go @@ -0,0 +1,466 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +// This file implements the Modules type. This includes the processing of +// include and import statements, which must be done prior to turning the +// module into an Entry tree. + +import ( + "fmt" + "sync" +) + +// Modules contains information about all the top level modules and +// submodules that are read into it via its Read method. +type Modules struct { + Modules map[string]*Module // All "module" nodes + SubModules map[string]*Module // All "submodule" nodes + includes map[*Module]bool // Modules we have already done include on + nsMu sync.Mutex // nsMu protects the byNS map. + byNS map[string]*Module // Cache of namespace lookup + typeDict *typeDictionary // Cache for type definitions. + entryCacheMu sync.RWMutex // entryCacheMu protects the entryCache map. + // entryCache is used to prevent unnecessary recursion into previously + // converted nodes. To access the map, use the get/set/ClearEntryCache() + // thread-safe functions. + entryCache map[Node]*Entry + // mergedSubmodule is used to prevent re-parsing a submodule that has already + // been merged into a particular entity when circular dependencies are being + // ignored. The keys of the map are a string that is formed by concatenating + // the name of the including (sub)module and the included submodule. + mergedSubmodule map[string]bool + // ParseOptions sets the options for the current YANG module parsing. It can be + // directly set by the caller to influence how goyang will behave in the presence + // of certain exceptional cases. + ParseOptions Options + // Path is the list of directories to look for .yang files in. + Path []string + // pathMap is used to prevent adding dups in Path. + pathMap map[string]bool +} + +// NewModules returns a newly created and initialized Modules. +func NewModules() *Modules { + ms := &Modules{ + Modules: map[string]*Module{}, + SubModules: map[string]*Module{}, + includes: map[*Module]bool{}, + byNS: map[string]*Module{}, + typeDict: newTypeDictionary(), + mergedSubmodule: map[string]bool{}, + entryCache: map[Node]*Entry{}, + pathMap: map[string]bool{}, + } + return ms +} + +// Read reads the named yang module into ms. The name can be the name of an +// actual .yang file or a module/submodule name (the base name of a .yang file, +// e.g., foo.yang is named foo). An error is returned if the file is not +// found or there was an error parsing the file. +func (ms *Modules) Read(name string) error { + name, data, err := ms.findFile(name) + if err != nil { + return err + } + return ms.Parse(data, name) +} + +// Parse parses data as YANG source and adds it to ms. The name should reflect +// the source of data. +// Note: If an error is returned, valid modules might still have been added to +// the Modules cache. +func (ms *Modules) Parse(data, name string) error { + ss, err := Parse(data, name) + if err != nil { + return err + } + for _, s := range ss { + n, err := buildASTWithTypeDict(s, ms.typeDict) + if err != nil { + return err + } + if err := ms.add(n); err != nil { + return err + } + } + return nil +} + +// GetModule returns the Entry of the module named by name. GetModule will +// search for and read the file named name + ".yang" if it cannot satisfy the +// request from what it has currently read. +// +// GetModule is a convenience function for calling Read and Process, and +// then looking up the module name. It is safe to call Read and Process prior +// to calling GetModule. +func (ms *Modules) GetModule(name string) (*Entry, []error) { + if ms.Modules[name] == nil { + if err := ms.Read(name); err != nil { + return nil, []error{err} + } + if ms.Modules[name] == nil { + return nil, []error{fmt.Errorf("module not found: %s", name)} + } + } + // Make sure that the modules have all been processed and have no + // errors. + if errs := ms.Process(); len(errs) != 0 { + return nil, errs + } + return ToEntry(ms.Modules[name]), nil +} + +// GetModule optionally reads in a set of YANG source files, named by sources, +// and then returns the Entry for the module named module. If sources is +// missing, or the named module is not yet known, GetModule searches for name +// with the suffix ".yang". GetModule either returns an Entry or returns +// one or more errors. +// +// GetModule is a convenience function for calling NewModules, Read, and Process, +// and then looking up the module name. +func GetModule(name string, sources ...string) (*Entry, []error) { + var errs []error + ms := NewModules() + for _, source := range sources { + if err := ms.Read(source); err != nil { + errs = append(errs, err) + } + } + if len(errs) > 0 { + return nil, errs + } + return ms.GetModule(name) +} + +// add adds Node n to ms. n must be assignable to *Module (i.e., it is a +// "module" or "submodule"). An error is returned if n is a duplicate of +// a name already added, or n is not assignable to *Module. +func (ms *Modules) add(n Node) error { + var m map[string]*Module + + name := n.NName() + kind := n.Kind() + switch kind { + case "module": + m = ms.Modules + case "submodule": + m = ms.SubModules + default: + return fmt.Errorf("not a module or submodule: %s is of type %s", name, kind) + } + + mod := n.(*Module) + fullName := mod.FullName() + mod.Modules = ms + + if o := m[fullName]; o != nil { + return fmt.Errorf("duplicate %s %s at %s and %s", kind, fullName, Source(o), Source(n)) + } + m[fullName] = mod + if fullName == name { + return nil + } + + // Add us to the map if: + // name has not been added before + // fullname is a more recent version of the entry. + if o := m[name]; o == nil || o.FullName() < fullName { + m[name] = mod + } + return nil +} + +// FindModule returns the Module/Submodule specified by n, which must be a +// *Include or *Import. If n is a *Include then a submodule is returned. If n +// is a *Import then a module is returned. +func (ms *Modules) FindModule(n Node) *Module { + name := n.NName() + rev := name + var m map[string]*Module + + switch i := n.(type) { + case *Include: + m = ms.SubModules + if i.RevisionDate != nil { + rev = name + "@" + i.RevisionDate.Name + } + // TODO(borman): we should check the BelongsTo field below? + case *Import: + m = ms.Modules + if i.RevisionDate != nil { + rev = name + "@" + i.RevisionDate.Name + } + default: + return nil + } + if n := m[rev]; n != nil { + return n + } + if n := m[name]; n != nil { + return n + } + + // Try to read first a module by revision + if err := ms.Read(rev); err != nil { + // if failed, try to read a module by its bare name + if err := ms.Read(name); err != nil { + return nil + } + } + if n := m[rev]; n != nil { + return n + } + return m[name] +} + +// FindModuleByNamespace either returns the Module specified by the namespace +// or returns an error. +func (ms *Modules) FindModuleByNamespace(ns string) (*Module, error) { + // Protect the byNS map from concurrent accesses + ms.nsMu.Lock() + defer ms.nsMu.Unlock() + + if m, ok := ms.byNS[ns]; ok { + return m, nil + } + var found *Module + for _, m := range ms.Modules { + if m.Namespace.Name == ns { + switch { + case m == found: + case found != nil: + return nil, fmt.Errorf("namespace %s matches two or more modules (%s, %s)", + ns, found.Name, m.Name) + default: + found = m + } + } + } + if found == nil { + return nil, fmt.Errorf("%q: no such namespace", ns) + } + // Don't cache negative results because new modules could be added. + ms.byNS[ns] = found + return found, nil +} + +// process satisfies all include and import statements and verifies that all +// link ref paths reference a known node. If an import or include references +// a [sub]module that is not already known, Process will search for a .yang +// file that contains it, returning an error if not found. An error is also +// returned if there is an unknown link ref path or other parsing errors. +// +// Process must be called once all the source modules have been read in and +// prior to converting Node tree into an Entry tree. +func (ms *Modules) process() []error { + var mods []*Module + var errs []error + + // Collect the list of modules we know about now so when we range + // below we don't pick up new modules. We assume the user tells + // us explicitly which modules they are interested in. + for _, m := range ms.Modules { + mods = append(mods, m) + } + for _, m := range mods { + if err := ms.include(m); err != nil { + errs = append(errs, err) + } + } + + // Resolve identities before resolving typedefs, otherwise when we resolve a + // typedef that has an identityref within it, then the identity dictionary + // has not yet been built. + errs = append(errs, ms.resolveIdentities()...) + // Append any errors found trying to resolve typedefs + errs = append(errs, ms.typeDict.resolveTypedefs()...) + + return errs +} + +// Process processes all the modules and submodules that have been read into +// ms. While processing, if an include or import is found for which there +// is no matching module, Process attempts to locate the source file (using +// Path) and automatically load them. If a file cannot be found then an +// error is returned. When looking for a source file, Process searches for a +// file using the module's or submodule's name with ".yang" appended. After +// searching the current directory, the directories in Path are searched. +// +// Process builds Entry trees for each modules and submodules in ms. These +// trees are accessed using the ToEntry function. Process does augmentation +// on Entry trees once all the modules and submodules in ms have been built. +// Following augmentation, Process inserts implied case statements. I.e., +// +// choice interface-type { +// container ethernet { ... } +// } +// +// has a case statement inserted to become: +// +// choice interface-type { +// case ethernet { +// container ethernet { ... } +// } +// } +// +// Process may return multiple errors if multiple errors were encountered +// while processing. Even though multiple errors may be returned, this does +// not mean these are all the errors. Process will terminate processing early +// based on the type and location of the error. +func (ms *Modules) Process() []error { + // Reset globals that may remain stale if multiple Process() calls are + // made by the same caller. + ms.mergedSubmodule = map[string]bool{} + ms.ClearEntryCache() + + errs := ms.process() + if len(errs) > 0 { + return errorSort(errs) + } + + for _, m := range ms.Modules { + errs = append(errs, ToEntry(m).GetErrors()...) + } + for _, m := range ms.SubModules { + errs = append(errs, ToEntry(m).GetErrors()...) + } + + if len(errs) > 0 { + return errorSort(errs) + } + + // Now handle all the augments. We don't have a good way to know + // what order to process them in, so repeat until no progress is made + + mods := make([]*Module, 0, len(ms.Modules)+len(ms.SubModules)) + for _, m := range ms.Modules { + mods = append(mods, m) + } + for _, m := range ms.SubModules { + mods = append(mods, m) + } + for len(mods) > 0 { + var processed int + for i := 0; i < len(mods); { + m := mods[i] + p, s := ToEntry(m).Augment(false) + processed += p + if s == 0 { + mods[i] = mods[len(mods)-1] + mods = mods[:len(mods)-1] + continue + } + i++ + } + if processed == 0 { + break + } + } + + // Now fix up all the choice statements to add in the missing case + // statements. + for _, m := range ms.Modules { + ToEntry(m).FixChoice() + } + for _, m := range ms.SubModules { + ToEntry(m).FixChoice() + } + + // Go through any modules that have remaining augments and collect + // the errors. + for _, m := range mods { + ToEntry(m).Augment(true) + errs = append(errs, ToEntry(m).GetErrors()...) + } + + // The deviation statement is only valid under a module or submodule, + // which allows us to avoid having to process it within ToEntry, and + // rather we can just walk all modules and submodules *after* entries + // are resolved. This means we do not need to concern ourselves that + // an entry does not exist. + dvP := map[string]bool{} // cache the modules we've handled since we have both modname and modname@revision-date + for _, devmods := range []map[string]*Module{ms.Modules, ms.SubModules} { + for _, m := range devmods { + e := ToEntry(m) + if !dvP[e.Name] { + errs = append(errs, e.ApplyDeviate(ms.ParseOptions.DeviateOptions)...) + dvP[e.Name] = true + } + } + } + + return errorSort(errs) +} + +// include resolves all the include and import statements for m. It returns +// an error if m, or recursively, any of the modules it includes or imports, +// reference a module that cannot be found. +func (ms *Modules) include(m *Module) error { + if ms.includes[m] { + return nil + } + ms.includes[m] = true + + // First process any includes in this module. + for _, i := range m.Include { + im := ms.FindModule(i) + if im == nil { + return fmt.Errorf("no such submodule: %s", i.Name) + } + // Process the include statements in our included module. + if err := ms.include(im); err != nil { + return err + } + i.Module = im + } + + // Next process any imports in this module. Imports are used + // when searching. + for _, i := range m.Import { + im := ms.FindModule(i) + if im == nil { + return fmt.Errorf("no such module: %s", i.Name) + } + // Process the include statements in our included module. + if err := ms.include(im); err != nil { + return err + } + + i.Module = im + } + return nil +} + +func (ms *Modules) getEntryCache(n Node) *Entry { + ms.entryCacheMu.RLock() + defer ms.entryCacheMu.RUnlock() + return ms.entryCache[n] +} + +func (ms *Modules) setEntryCache(n Node, e *Entry) { + ms.entryCacheMu.Lock() + defer ms.entryCacheMu.Unlock() + ms.entryCache[n] = e +} + +// ClearEntryCache clears the entryCache containing previously converted nodes +// used by the ToEntry function. +func (ms *Modules) ClearEntryCache() { + ms.entryCacheMu.Lock() + defer ms.entryCacheMu.Unlock() + ms.entryCache = map[Node]*Entry{} +} diff --git a/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/node.go b/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/node.go new file mode 100644 index 00000000..ee52efeb --- /dev/null +++ b/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/node.go @@ -0,0 +1,388 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +import ( + "errors" + "fmt" + "io" + "reflect" + "strings" + + "github.com/openconfig/goyang/pkg/indent" +) + +// A Node contains a yang statement and all attributes and sub-statements. +// Only pointers to structures should implement Node. +type Node interface { + // Kind returns the kind of yang statement (the keyword). + Kind() string + // NName returns the node's name (the argument) + NName() string + // Statement returns the original Statement of this Node. + Statement() *Statement + // ParentNode returns the parent of this Node, or nil if the + // Node has no parent. + ParentNode() Node + // Exts returns the list of extension statements found. + Exts() []*Statement +} + +// A Typedefer is a Node that defines typedefs. +type Typedefer interface { + Node + Typedefs() []*Typedef +} + +// An ErrorNode is a node that only contains an error. +type ErrorNode struct { + Parent Node `yang:"Parent,nomerge"` + + Error error +} + +func (ErrorNode) Kind() string { return "error" } +func (s *ErrorNode) ParentNode() Node { return s.Parent } +func (s *ErrorNode) NName() string { return "error" } +func (s *ErrorNode) Statement() *Statement { return &Statement{} } +func (s *ErrorNode) Exts() []*Statement { return nil } + +// isRPCNode is a terrible hack to return back that a path points into +// an RPC and we should ignore it. +var isRPCNode = &ErrorNode{Error: errors.New("rpc is unsupported")} + +// Source returns the location of the source where n was defined. +func Source(n Node) string { + if n != nil && n.Statement() != nil { + return n.Statement().Location() + } + return "unknown" +} + +// getPrefix returns the prefix and base name of s. If s has no prefix +// then the returned prefix is "". +func getPrefix(s string) (string, string) { + f := strings.SplitN(s, ":", 2) + if len(f) == 1 { + return "", s + } + return f[0], f[1] +} + +// Prefix notes for types: +// +// If there is prefix, look in nodes ancestors. +// +// If prefix matches the module's prefix statement, look in nodes ancestors. +// +// If prefix matches the submodule's belongs-t statement, look in nodes +// ancestors. +// +// Finally, look in the module imported with prefix. + +// FindModuleByPrefix finds the module or submodule with the provided prefix +// relative to where n was defined. If the prefix cannot be resolved then nil +// is returned. +func FindModuleByPrefix(n Node, prefix string) *Module { + if n == nil { + return nil + } + mod := RootNode(n) + + if prefix == "" || prefix == mod.GetPrefix() { + return mod + } + + for _, i := range mod.Import { + if prefix == i.Prefix.Name { + return mod.Modules.FindModule(i) + } + } + return nil +} + +// MatchingExtensions returns the subset of the given node's extensions +// that match the given module and identifier. +func MatchingExtensions(n Node, module, identifier string) ([]*Statement, error) { + return matchingExtensions(n, n.Exts(), module, identifier) +} + +// MatchingEntryExtensions returns the subset of the given entry's extensions +// that match the given module and identifier. +func MatchingEntryExtensions(e *Entry, module, identifier string) ([]*Statement, error) { + return matchingExtensions(e.Node, e.Exts, module, identifier) +} + +// matchingEntryExtensions returns the subset of the given node's extensions +// that match the given module and identifier. +func matchingExtensions(n Node, exts []*Statement, module, identifier string) ([]*Statement, error) { + var matchingExtensions []*Statement + for _, ext := range exts { + names := strings.SplitN(ext.Keyword, ":", 2) + mod := FindModuleByPrefix(n, names[0]) + if mod == nil { + return nil, fmt.Errorf("matchingExtensions: module prefix %q not found", names[0]) + } + if len(names) == 2 && names[1] == identifier && mod.Name == module { + matchingExtensions = append(matchingExtensions, ext) + } + } + return matchingExtensions, nil +} + +// RootNode returns the submodule or module that n was defined in. +func RootNode(n Node) *Module { + for ; n.ParentNode() != nil; n = n.ParentNode() { + } + if mod, ok := n.(*Module); ok { + return mod + } + return nil +} + +// module returns the Module to which n belongs. If n resides in a submodule, +// the belonging module will be returned. +// If n is nil or a module could not be find, nil is returned. +func module(n Node) *Module { + m := RootNode(n) + if m.Kind() == "submodule" { + m = m.Modules.Modules[m.BelongsTo.Name] + } + return m +} + +// NodePath returns the full path of the node from the module name. +func NodePath(n Node) string { + var path string + for n != nil { + path = "/" + n.NName() + path + n = n.ParentNode() + } + return path +} + +// FindNode finds the node referenced by path relative to n. If path does not +// reference a node then nil is returned (i.e. path not found). The path looks +// similar to an XPath but currently has no wildcarding. For example: +// "/if:interfaces/if:interface" and "../config". +func FindNode(n Node, path string) (Node, error) { + if path == "" { + return n, nil + } + // / is not a valid path, it needs a module name + if path == "/" { + return nil, fmt.Errorf("invalid path %q", path) + } + // Paths do not end in /'s + if path[len(path)-1] == '/' { + return nil, fmt.Errorf("invalid path %q", path) + } + + parts := strings.Split(path, "/") + + // An absolute path has a leading component of "". + // We need to discover which module they are part of + // based on our imports. + if parts[0] == "" { + parts = parts[1:] + + // TODO(borman): merge this with FindModuleByPrefix? + // The base is always a module + mod := RootNode(n) + n = mod + prefix, _ := getPrefix(parts[0]) + if mod.Kind() == "submodule" { + m := mod.Modules.Modules[mod.BelongsTo.Name] + if m == nil { + return nil, fmt.Errorf("%s: unknown module %s", m.Name, mod.BelongsTo.Name) + } + if prefix == "" || prefix == mod.BelongsTo.Prefix.Name { + goto processing + } + mod = m + } + + if prefix == "" || prefix == mod.Prefix.Name { + goto processing + } + + for _, i := range mod.Import { + if prefix == i.Prefix.Name { + n = i.Module + goto processing + } + } + // We didn't find a matching prefix. + return nil, fmt.Errorf("unknown prefix: %q", prefix) + processing: + // At this point, n should be pointing to the Module node + // of module we are rooted in + } + + for _, part := range parts { + // If we encounter an RPC node in our search then we + // return the magic isRPCNode Node which just contains + // an error that it is an RPC node. isRPCNode is a singleton + // and can be checked against. + if n.Kind() == "rpc" { + return isRPCNode, nil + } + if part == ".." { + Loop: + for { + n = n.ParentNode() + if n == nil { + return nil, fmt.Errorf(".. with no parent") + } + // choice, leaf, and case nodes + // are "invisible" when doing ".." + // up the tree. + switch n.Kind() { + case "choice", "leaf", "case": + default: + break Loop + } + } + continue + } + // For now just strip off any prefix + // TODO(borman): fix this + _, spart := getPrefix(part) + n = ChildNode(n, spart) + if n == nil { + return nil, fmt.Errorf("%s: no such element", part) + } + } + return n, nil +} + +// ChildNode finds n's child node named name. It returns nil if the node +// could not be found. ChildNode looks at every direct Node pointer in +// n as well as every node in all slices of Node pointers. Names must +// be non-ambiguous, otherwise ChildNode has a non-deterministic result. +func ChildNode(n Node, name string) Node { + v := reflect.ValueOf(n).Elem() + t := v.Type() + nf := t.NumField() + +Loop: + for i := 0; i < nf; i++ { + ft := t.Field(i) + yang := ft.Tag.Get("yang") + if yang == "" { + continue + } + parts := strings.Split(yang, ",") + for _, p := range parts[1:] { + if p == "nomerge" { + continue Loop + } + } + + f := v.Field(i) + if !f.IsValid() || f.IsNil() { + continue + } + + check := func(n Node) Node { + if n.NName() == name { + return n + } + return nil + } + if parts[0] == "uses" { + check = func(n Node) Node { + uname := n.NName() + // unrooted uses are rooted at root + if !strings.HasPrefix(uname, "/") { + uname = "/" + uname + } + if n, _ = FindNode(n, uname); n != nil { + return ChildNode(n, name) + } + return nil + } + } + + switch ft.Type.Kind() { + case reflect.Ptr: + if n = check(f.Interface().(Node)); n != nil { + return n + } + case reflect.Slice: + sl := f.Len() + for i := 0; i < sl; i++ { + n = f.Index(i).Interface().(Node) + if n = check(n); n != nil { + return n + } + } + } + } + return nil +} + +// PrintNode prints node n to w, recursively. +// TODO(borman): display more information +func PrintNode(w io.Writer, n Node) { + v := reflect.ValueOf(n).Elem() + t := v.Type() + nf := t.NumField() + fmt.Fprintf(w, "%s [%s]\n", n.NName(), n.Kind()) +Loop: + for i := 0; i < nf; i++ { + ft := t.Field(i) + yang := ft.Tag.Get("yang") + if yang == "" { + continue + } + parts := strings.Split(yang, ",") + for _, p := range parts[1:] { + if p == "nomerge" { + continue Loop + } + } + + // Skip uppercase elements. + if parts[0][0] >= 'A' && parts[0][0] <= 'Z' { + continue + } + + f := v.Field(i) + if !f.IsValid() || f.IsNil() { + continue + } + + switch ft.Type.Kind() { + case reflect.Ptr: + n = f.Interface().(Node) + if v, ok := n.(*Value); ok { + fmt.Fprintf(w, "%s = %s\n", ft.Name, v.Name) + } else { + PrintNode(indent.NewWriter(w, " "), n) + } + case reflect.Slice: + sl := f.Len() + for i := 0; i < sl; i++ { + n = f.Index(i).Interface().(Node) + if v, ok := n.(*Value); ok { + fmt.Fprintf(w, "%s[%d] = %s\n", ft.Name, i, v.Name) + } else { + PrintNode(indent.NewWriter(w, " "), n) + } + } + } + } +} diff --git a/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/options.go b/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/options.go new file mode 100644 index 00000000..2de2ebd5 --- /dev/null +++ b/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/options.go @@ -0,0 +1,59 @@ +// Copyright 2017 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +// Options defines the options that should be used when parsing YANG modules, +// including specific overrides for potentially problematic YANG constructs. +type Options struct { + // IgnoreSubmoduleCircularDependencies specifies whether circular dependencies + // between submodules. Setting this value to true will ensure that this + // package will explicitly ignore the case where a submodule will include + // itself through a circular reference. + IgnoreSubmoduleCircularDependencies bool + // StoreUses controls whether the Uses field of each YANG entry should be + // populated. Setting this value to true will cause each Entry which is + // generated within the schema to store the logical grouping from which it + // is derived. + StoreUses bool + // DeviateOptions contains options for how deviations are handled. + DeviateOptions DeviateOptions +} + +// DeviateOptions contains options for how deviations are handled. +type DeviateOptions struct { + // IgnoreDeviateNotSupported indicates to the parser to retain nodes + // that are marked with "deviate not-supported". An example use case is + // where the user wants to interact with different targets that have + // different support for a leaf without having to use a second instance + // of an AST. + IgnoreDeviateNotSupported bool +} + +// IsDeviateOpt ensures that DeviateOptions satisfies the DeviateOpt interface. +func (DeviateOptions) IsDeviateOpt() {} + +// DeviateOpt is an interface that can be used in function arguments. +type DeviateOpt interface { + IsDeviateOpt() +} + +func hasIgnoreDeviateNotSupported(opts []DeviateOpt) bool { + for _, o := range opts { + if opt, ok := o.(DeviateOptions); ok { + return opt.IgnoreDeviateNotSupported + } + } + return false +} diff --git a/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/parse.go b/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/parse.go new file mode 100644 index 00000000..60c388b6 --- /dev/null +++ b/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/parse.go @@ -0,0 +1,338 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +// This file implements Parse, which parses the input as generic YANG and +// returns a slice of base Statements (which in turn may contain more +// Statements, i.e., a slice of Statement trees.) + +import ( + "bytes" + "errors" + "fmt" + "io" + "strings" +) + +// a parser is used to parse the contents of a single .yang file. +type parser struct { + lex *lexer + errout *bytes.Buffer + tokens []*token // stack of pushed tokens (for backing up) + + // Depth of statements in nested braces + statementDepth int + + // hitBrace is returned when we encounter a '}'. The statement location + // is updated with the location of the '}'. The brace may be legitimate + // but only the caller will know if it is. That is, the brace may be + // closing our parent or may be an error (we didn't expect it). + // hitBrace is updated with the file, line, and column of the brace's + // location. + hitBrace *Statement +} + +// Statement is a generic YANG statement that may have sub-statements. +// It implements the Node interface. +// +// Within the parser, it represents a non-terminal token. +// From https://tools.ietf.org/html/rfc7950#section-6.3: +// statement = keyword [argument] (";" / "{" *statement "}") +// The argument is a string. +type Statement struct { + Keyword string + HasArgument bool + Argument string + statements []*Statement + + file string + line int // 1's based line number + col int // 1's based column number +} + +func (s *Statement) NName() string { return s.Argument } +func (s *Statement) Kind() string { return s.Keyword } +func (s *Statement) Statement() *Statement { return s } +func (s *Statement) ParentNode() Node { return nil } +func (s *Statement) Exts() []*Statement { return nil } + +// Arg returns the optional argument to s. It returns false if s has no +// argument. +func (s *Statement) Arg() (string, bool) { return s.Argument, s.HasArgument } + +// SubStatements returns a slice of Statements found in s. +func (s *Statement) SubStatements() []*Statement { return s.statements } + +// Location returns the location in the source where s was defined. +func (s *Statement) Location() string { + switch { + case s.file == "" && s.line == 0: + return "unknown" + case s.file == "": + return fmt.Sprintf("line %d:%d", s.line, s.col) + case s.line == 0: + return s.file + default: + return fmt.Sprintf("%s:%d:%d", s.file, s.line, s.col) + } +} + +// Write writes the tree in s to w, each line indented by ident. Children +// nodes are indented further by a tab. Typically indent is "" at the top +// level. Write is intended to display the contents of Statement, but +// not necessarily reproduce the input of Statement. +func (s *Statement) Write(w io.Writer, indent string) error { + if s.Keyword == "" { + // We are just a collection of statements at the top level. + for _, s := range s.statements { + if err := s.Write(w, indent); err != nil { + return err + } + } + return nil + } + + parts := []string{fmt.Sprintf("%s%s", indent, s.Keyword)} + if s.HasArgument { + args := strings.Split(s.Argument, "\n") + if len(args) == 1 { + parts = append(parts, fmt.Sprintf(" %q", s.Argument)) + } else { + parts = append(parts, ` "`, args[0], "\n") + i := fmt.Sprintf("%*s", len(s.Keyword)+1, "") + for x, p := range args[1:] { + s := fmt.Sprintf("%q", p) + s = s[1 : len(s)-1] + parts = append(parts, indent, " ", i, s) + if x == len(args[1:])-1 { + // last part just needs the closing " + parts = append(parts, `"`) + } else { + parts = append(parts, "\n") + } + } + } + } + + if len(s.statements) == 0 { + _, err := fmt.Fprintf(w, "%s;\n", strings.Join(parts, "")) + return err + } + if _, err := fmt.Fprintf(w, "%s {\n", strings.Join(parts, "")); err != nil { + return err + } + for _, s := range s.statements { + if err := s.Write(w, indent+"\t"); err != nil { + return err + } + } + if _, err := fmt.Fprintf(w, "%s}\n", indent); err != nil { + return err + } + return nil +} + +// ignoreMe is an error recovery token used by the parser in order +// to continue processing for other errors in the file. +var ignoreMe = &Statement{} + +// Parse parses the input as generic YANG and returns the statements parsed. +// The path parameter should be the source name where input was read from (e.g., +// the file name the input was read from). If one more more errors are +// encountered, nil and an error are returned. The error's text includes all +// errors encountered. +func Parse(input, path string) ([]*Statement, error) { + var statements []*Statement + p := &parser{ + lex: newLexer(input, path), + errout: &bytes.Buffer{}, + hitBrace: &Statement{}, + } + p.lex.errout = p.errout +Loop: + for { + switch ns := p.nextStatement(); ns { + case nil: + break Loop + case p.hitBrace: + fmt.Fprintf(p.errout, "%s:%d:%d: unexpected %c\n", ns.file, ns.line, ns.col, '}') + default: + statements = append(statements, ns) + } + } + + p.checkStatementDepthIsZero() + + if p.errout.Len() == 0 { + return statements, nil + } + return nil, errors.New(strings.TrimSpace(p.errout.String())) +} + +// push pushes tokens t back on the input stream so they will be the next +// tokens returned by next. The tokens list is a LIFO so the final token +// listed to push will be the next token returned. +func (p *parser) push(t ...*token) { + p.tokens = append(p.tokens, t...) +} + +// pop returns the last token pushed, or nil if the token stack is empty. +func (p *parser) pop() *token { + if n := len(p.tokens); n > 0 { + n-- + defer func() { p.tokens = p.tokens[:n] }() + return p.tokens[n] + } + return nil +} + +// next returns the next token from the lexer. If the next token is a +// concatenated string, it returns the concatenated string as the token. +func (p *parser) next() *token { + if t := p.pop(); t != nil { + return t + } + // next returns the next unprocessed lexer token. + next := func() *token { + for { + if t := p.lex.NextToken(); t.Code() != tError { + return t + } + } + } + t := next() + if t.Code() != tString { + return t + } + // Process string concatenation (both single and double quote). + // See https://tools.ietf.org/html/rfc7950#section-6.1.3.1 + // The lexer trimmed the quotes already. + for { + nt := next() + switch nt.Code() { + case tEOF: + return t + case tUnquoted: + if nt.Text != "+" { + p.push(nt) + return t + } + default: + p.push(nt) + return t + } + // Invariant: nt is a + sign. + nnt := next() + switch nnt.Code() { + case tEOF: + p.push(nt) + return t + case tString: + // Accumulate the concatenation. + t.Text += nnt.Text + default: + p.push(nnt, nt) + return t + } + } +} + +// nextStatement returns the next statement in the input, which may in turn +// recurse to read sub statements. +// nil is returned when EOF has been reached, or is reached halfway through +// parsing the next statement (with associated syntax errors printed to +// errout). +func (p *parser) nextStatement() *Statement { + t := p.next() + switch t.Code() { + case tEOF: + return nil + case '}': + p.statementDepth -= 1 + p.hitBrace.file = t.File + p.hitBrace.line = t.Line + p.hitBrace.col = t.Col + return p.hitBrace + case tUnquoted: + default: + fmt.Fprintf(p.errout, "%v: keyword token not an unquoted string\n", t) + return ignoreMe + } + // Invariant: t represents a keyword token. + + s := &Statement{ + Keyword: t.Text, + file: t.File, + line: t.Line, + col: t.Col, + } + + // The keyword "pattern" must be treated specially. When + // parsing the argument for "pattern", escape sequences + // must be expanded differently. + p.lex.inPattern = t.Text == "pattern" + t = p.next() + p.lex.inPattern = false + switch t.Code() { + case tString, tUnquoted: + s.HasArgument = true + s.Argument = t.Text + t = p.next() + } + + switch t.Code() { + case tEOF: + fmt.Fprintf(p.errout, "%s: unexpected EOF\n", s.file) + return nil + case ';': + return s + case '{': + p.statementDepth += 1 + for { + switch ns := p.nextStatement(); ns { + case nil: + // Signal EOF reached. + return nil + case p.hitBrace: + return s + default: + s.statements = append(s.statements, ns) + } + } + default: + fmt.Fprintf(p.errout, "%v: syntax error, expected ';' or '{'\n", t) + return ignoreMe + } +} + +// checkStatementDepthIsZero checks that we aren't missing closing +// braces. Note: the parser will error out for the case where we +// start with an unmatched close brace, i.e. depth < 0 +// +// This test should only be done if there are no other errors as +// we may exit early due to those errors -- and therefore there *might* +// not really be a mismatched brace issue. +func (p *parser) checkStatementDepthIsZero() { + if p.errout.Len() > 0 || p.statementDepth == 0 { + return + } + + plural := "" + if p.statementDepth > 1 { + plural = "s" + } + fmt.Fprintf(p.errout, "%s:%d:%d: missing %d closing brace%s\n", + p.lex.file, p.lex.line, p.lex.col, p.statementDepth, plural) +} diff --git a/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/types.go b/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/types.go new file mode 100644 index 00000000..2475fa4a --- /dev/null +++ b/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/types.go @@ -0,0 +1,425 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +// This file implements the functions relating to types and typedefs. + +import ( + "errors" + "fmt" + "regexp/syntax" + "sync" +) + +// A typeDictionary is a dictionary of all Typedefs defined in all Typedefers. +// A map of Nodes is used rather than a map of Typedefers to simplify usage +// when traversing up a Node tree. +type typeDictionary struct { + mu sync.Mutex + dict map[Node]map[string]*Typedef + // identities contains a dictionary of resolved identities. + identities identityDictionary +} + +func newTypeDictionary() *typeDictionary { + return &typeDictionary{ + dict: map[Node]map[string]*Typedef{}, + identities: identityDictionary{dict: map[string]resolvedIdentity{}}, + } +} + +// add adds an entry to the typeDictionary d. +func (d *typeDictionary) add(n Node, name string, td *Typedef) { + defer d.mu.Unlock() + d.mu.Lock() + if d.dict[n] == nil { + d.dict[n] = map[string]*Typedef{} + } + d.dict[n][name] = td +} + +// find returns the Typedef name define in node n, or nil. +func (d *typeDictionary) find(n Node, name string) *Typedef { + defer d.mu.Unlock() + d.mu.Lock() + if d.dict[n] == nil { + return nil + } + return d.dict[n][name] +} + +// findExternal finds the externally-defined typedef name in a module imported +// by n's root with the specified prefix. +func (d *typeDictionary) findExternal(n Node, prefix, name string) (*Typedef, error) { + root := FindModuleByPrefix(n, prefix) + if root == nil { + return nil, fmt.Errorf("%s: unknown prefix: %s for type %s", Source(n), prefix, name) + } + if td := d.find(root, name); td != nil { + return td, nil + } + if prefix != "" { + name = prefix + ":" + name + } + return nil, fmt.Errorf("%s: unknown type %s", Source(n), name) +} + +// typedefs returns a slice of all typedefs in d. +func (d *typeDictionary) typedefs() []*Typedef { + var tds []*Typedef + defer d.mu.Unlock() + d.mu.Lock() + for _, dict := range d.dict { + for _, td := range dict { + tds = append(tds, td) + } + } + return tds +} + +// addTypedefs is called from BuildAST after each Typedefer is defined. There +// are no error conditions in this process as it is simply used to build up the +// typedef dictionary. +func (d *typeDictionary) addTypedefs(t Typedefer) { + for _, td := range t.Typedefs() { + d.add(t, td.Name, td) + } +} + +// resolveTypedefs is called after all of modules and submodules have been read, +// as well as their imports and includes. It resolves all typedefs found in all +// modules and submodules read in. +func (d *typeDictionary) resolveTypedefs() []error { + var errs []error + + // When resolve typedefs, we may need to look up other typedefs. + // We gather all typedefs into a slice so we don't deadlock on + // typeDict. + for _, td := range d.typedefs() { + errs = append(errs, td.resolve(d)...) + } + return errs +} + +// resolve creates a YangType for t, if not already done. Resolving t +// requires resolving the Type that t is based on. +func (t *Typedef) resolve(d *typeDictionary) []error { + // If we have no parent we are a base type and + // are already resolved. + if t.Parent == nil || t.YangType != nil { + return nil + } + + if errs := t.Type.resolve(d); len(errs) != 0 { + return errs + } + + // Make a copy of the YangType we are based on and then + // update it with local information. + y := *t.Type.YangType + y.Name = t.Name + y.Base = t.Type + + if t.Units != nil { + y.Units = t.Units.Name + } + if t.Default != nil { + y.HasDefault = true + y.Default = t.Default.Name + } + + if t.Type.IdentityBase != nil { + // We need to copy over the IdentityBase statement if the type has one + if idBase, err := RootNode(t).findIdentityBase(t.Type.IdentityBase.Name); err == nil { + y.IdentityBase = idBase.Identity + } else { + return []error{fmt.Errorf("could not resolve identity base for typedef: %s", t.Type.IdentityBase.Name)} + } + } + + // If we changed something, we are the new root. + if y.Root == t.Type.YangType || !y.Equal(y.Root) { + y.Root = &y + } + t.YangType = &y + return nil +} + +// resolve resolves Type t, as well as the underlying typedef for t. If t +// cannot be resolved then one or more errors are returned. +func (t *Type) resolve(d *typeDictionary) (errs []error) { + if t.YangType != nil { + return nil + } + + // If t.Name is a base type then td will not be nil, otherwise + // td will be nil and of type *Typedef. + td := BaseTypedefs[t.Name] + + prefix, name := getPrefix(t.Name) + root := RootNode(t) + rootPrefix := root.GetPrefix() + + source := "unknown" +check: + switch { + case td != nil: + source = "builtin" + // This was a base type + case prefix == "" || rootPrefix == prefix: + source = "local" + // If we have no prefix, or the prefix is what we call our own + // root, then we look in our ancestors for a typedef of name. + for n := Node(t); n != nil; n = n.ParentNode() { + if td = d.find(n, name); td != nil { + break check + } + } + // We need to check our sub-modules as well + for _, in := range root.Include { + if td = d.find(in.Module, name); td != nil { + break check + } + } + var pname string + switch { + case prefix == "", prefix == root.Prefix.Name: + pname = root.Prefix.Name + ":" + t.Name + default: + pname = fmt.Sprintf("%s[%s]:%s", prefix, root.Prefix.Name, t.Name) + } + + return []error{fmt.Errorf("%s: unknown type: %s", Source(t), pname)} + + default: + source = "imported" + // prefix is not local to our module, so we have to go find + // what module it is part of and if it is defined at the top + // level of that module. + var err error + td, err = d.findExternal(t, prefix, name) + if err != nil { + return []error{err} + } + } + if errs := td.resolve(d); len(errs) > 0 { + return errs + } + + // Make a copy of the typedef we are based on so we can + // augment it. + if td.YangType == nil { + return []error{fmt.Errorf("%s: no YangType defined for %s %s", Source(td), source, td.Name)} + } + y := *td.YangType + + y.Base = td.Type + t.YangType = &y + + if v := t.RequireInstance; v != nil { + b, err := v.asBool() + if err != nil { + errs = append(errs, err) + } + y.OptionalInstance = !b + } + if v := t.Path; v != nil { + y.Path = v.asString() + } + isDecimal64 := y.Kind == Ydecimal64 && (t.Name == "decimal64" || y.FractionDigits != 0) + switch { + case isDecimal64 && y.FractionDigits != 0: + if t.FractionDigits != nil { + return append(errs, fmt.Errorf("%s: overriding of fraction-digits not allowed", Source(t))) + } + // FractionDigits already set via type inheritance. + case isDecimal64: + // If we are directly of type decimal64 then we must specify + // fraction-digits in the range from 1-18. + i, err := t.FractionDigits.asRangeInt(1, 18) + if err != nil { + errs = append(errs, fmt.Errorf("%s: %v", Source(t), err)) + } + y.FractionDigits = int(i) + // We only know to how to populate Range after knowing the + // fractional digit value. + y.Range = YangRange{{ + Number{Value: AbsMinInt64, Negative: true, FractionDigits: uint8(i)}, + Number{Value: MaxInt64, FractionDigits: uint8(i)}, + }} + case t.FractionDigits != nil: + errs = append(errs, fmt.Errorf("%s: fraction-digits only allowed for decimal64 values", Source(t))) + case y.Kind == Yidentityref: + if source != "builtin" { + // This is a typedef that refers to an identityref, so we want to simply + // maintain the base that the typedef resolution provided + break + } + + if t.IdentityBase == nil { + errs = append(errs, fmt.Errorf("%s: an identityref must specify a base", Source(t))) + break + } + + root := RootNode(t.Parent) + resolvedBase, baseErr := root.findIdentityBase(t.IdentityBase.Name) + if baseErr != nil { + errs = append(errs, baseErr...) + break + } + + if resolvedBase.Identity == nil { + errs = append(errs, fmt.Errorf("%s: identity has a null base", t.IdentityBase.Name)) + break + } + y.IdentityBase = resolvedBase.Identity + } + + if t.Range != nil { + yr, err := y.Range.parseChildRanges(t.Range.Name, isDecimal64, uint8(y.FractionDigits)) + switch { + case err != nil: + errs = append(errs, fmt.Errorf("%s: bad range: %v", Source(t.Range), err)) + case yr.Equal(y.Range): + default: + y.Range = yr + } + } + + if t.Length != nil { + parentRange := Uint64Range + if y.Length != nil { + parentRange = y.Length + } + yr, err := parentRange.parseChildRanges(t.Length.Name, false, 0) + switch { + case err != nil: + errs = append(errs, fmt.Errorf("%s: bad length: %v", Source(t.Length), err)) + case yr.Equal(y.Length): + default: + for _, r := range yr { + if r.Min.Negative { + errs = append(errs, fmt.Errorf("%s: negative length: %v", Source(t.Length), yr)) + break + } + } + y.Length = yr + } + } + + set := func(e *EnumType, name string, value *Value) error { + if value == nil { + return e.SetNext(name) + } + n, err := ParseInt(value.Name) + if err != nil { + return err + } + i, err := n.Int() + if err != nil { + return err + } + return e.Set(name, i) + } + + if len(t.Enum) > 0 { + enum := NewEnumType() + for _, e := range t.Enum { + if err := set(enum, e.Name, e.Value); err != nil { + errs = append(errs, fmt.Errorf("%s: %v", Source(e), err)) + } + } + y.Enum = enum + } + + if len(t.Bit) > 0 { + bit := NewBitfield() + for _, e := range t.Bit { + if err := set(bit, e.Name, e.Position); err != nil { + errs = append(errs, fmt.Errorf("%s: %v", Source(e), err)) + } + } + y.Bit = bit + } + + // Append any newly found patterns to the end of the list of patterns. + // Patterns are ANDed according to section 9.4.6. If all the patterns + // declared by t were also declared by the type t is based on, then + // no patterns are added. + seenPatterns := map[string]bool{} + for _, p := range y.Pattern { + seenPatterns[p] = true + } + seenPOSIXPatterns := map[string]bool{} + for _, p := range y.POSIXPattern { + seenPOSIXPatterns[p] = true + } + + // First parse out the pattern statements. + // These patterns are not checked because there is no support for W3C regexes by Go. + for _, pv := range t.Pattern { + if !seenPatterns[pv.Name] { + seenPatterns[pv.Name] = true + y.Pattern = append(y.Pattern, pv.Name) + } + } + + // Then, parse out the posix-pattern statements, if they exist. + // A YANG module could make use of either or both, so we deal with each separately. + posixPatterns, err := MatchingExtensions(t, "openconfig-extensions", "posix-pattern") + if err != nil { + return []error{err} + } + + checkPattern := func(n Node, p string, flags syntax.Flags) { + if _, err := syntax.Parse(p, flags); err != nil { + if re, ok := err.(*syntax.Error); ok { + // Error adds "error parsing regexp" to + // the error, re.Code is the real error. + err = errors.New(re.Code.String()) + } + errs = append(errs, fmt.Errorf("%s: bad pattern: %v: %s", Source(n), err, p)) + } + } + for _, ext := range posixPatterns { + checkPattern(ext, ext.Argument, syntax.POSIX) + if !seenPOSIXPatterns[ext.Argument] { + seenPOSIXPatterns[ext.Argument] = true + y.POSIXPattern = append(y.POSIXPattern, ext.Argument) + } + } + + // I don't know of an easy way to use a type as a key to a map, + // so we have to check equality the hard way. +looking: + for _, ut := range t.Type { + errs = append(errs, ut.resolve(d)...) + if ut.YangType != nil { + for _, yt := range y.Type { + if ut.YangType.Equal(yt) { + continue looking + } + } + y.Type = append(y.Type, ut.YangType) + } + } + + // If we changed something, we are the new root. + if !y.Equal(y.Root) { + y.Root = &y + } + + return errs +} diff --git a/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/types_builtin.go b/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/types_builtin.go new file mode 100644 index 00000000..f87e1b93 --- /dev/null +++ b/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/types_builtin.go @@ -0,0 +1,700 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +// This module contains all the builtin types as well as types related +// to types (such as ranges, enums, etc). + +import ( + "errors" + "fmt" + "math" + "sort" + "strconv" + "strings" +) + +// This file handles interpretation of types + +// These are the default ranges defined by the YANG standard. +var ( + Int8Range = mustParseRangesInt("-128..127") + Int16Range = mustParseRangesInt("-32768..32767") + Int32Range = mustParseRangesInt("-2147483648..2147483647") + Int64Range = mustParseRangesInt("-9223372036854775808..9223372036854775807") + + Uint8Range = mustParseRangesInt("0..255") + Uint16Range = mustParseRangesInt("0..65535") + Uint32Range = mustParseRangesInt("0..4294967295") + Uint64Range = mustParseRangesInt("0..18446744073709551615") +) + +const ( + // MaxInt64 corresponds to the maximum value of a signed int64. + MaxInt64 = 1<<63 - 1 + // MinInt64 corresponds to the maximum value of a signed int64. + MinInt64 = -1 << 63 + // Min/MaxDecimal64 are the max/min decimal64 values. + MinDecimal64 float64 = -922337203685477580.8 + MaxDecimal64 float64 = 922337203685477580.7 + // AbsMinInt64 is the absolute value of MinInt64. + AbsMinInt64 = 1 << 63 + // MaxEnum is the maximum value of an enumeration. + MaxEnum = 1<<31 - 1 + // MinEnum is the minimum value of an enumeration. + MinEnum = -1 << 31 + // MaxBitfieldSize is the maximum number of bits in a bitfield. + MaxBitfieldSize = 1 << 32 + // MaxFractionDigits is the maximum number of fractional digits as per RFC6020 Section 9.3.4. + MaxFractionDigits uint8 = 18 + + space18 = "000000000000000000" // used for prepending 0's +) + +// A Number is either an integer the range of [-(1<<64) - 1, (1<<64)-1], or a +// YANG decimal conforming to https://tools.ietf.org/html/rfc6020#section-9.3.4. +type Number struct { + // Absolute value of the number. + Value uint64 + // Number of fractional digits. + // 0 means it's an integer. For decimal64 it falls within [1, 18]. + FractionDigits uint8 + // Negative indicates whether the number is negative. + Negative bool +} + +// IsDecimal reports whether n is a decimal number. +func (n Number) IsDecimal() bool { + return n.FractionDigits != 0 +} + +// String returns n as a string in decimal. +func (n Number) String() string { + out := strconv.FormatUint(n.Value, 10) + + if n.IsDecimal() { + if fd := int(n.FractionDigits); fd > 0 { + ofd := len(out) - fd + if ofd <= 0 { + // We want 0.1 not .1 + out = space18[:-ofd+1] + out + ofd = 1 + } + out = out[:ofd] + "." + out[ofd:] + } + } + if n.Negative { + out = "-" + out + } + + return out +} + +// Int returns n as an int64. It returns an error if n overflows an int64 or +// the number is decimal. +func (n Number) Int() (int64, error) { + if n.IsDecimal() { + return 0, errors.New("called Int() on decimal64 value") + } + if n.Negative { + return -int64(n.Value), nil + } + if n.Value <= MaxInt64 { + return int64(n.Value), nil + } + return 0, errors.New("signed integer overflow") +} + +// addQuantum adds the smallest quantum to n without checking overflow. +func (n Number) addQuantum(i uint64) Number { + switch n.Negative { + case true: + if n.Value <= i { + n.Value = i - n.Value + n.Negative = false + } else { + n.Value -= i + } + case false: + n.Value += i + } + return n +} + +// Less returns true if n is less than m. Panics if n and m are a mix of integer +// and decimal. +func (n Number) Less(m Number) bool { + switch { + case n.Negative && !m.Negative: + return true + case !n.Negative && m.Negative: + return false + } + + nt, mt := n.Trunc(), m.Trunc() + lt := nt < mt + if nt == mt { + nf, mf := n.frac(), m.frac() + if nf == mf { + return false + } + lt = nf < mf + } + + if n.Negative { + return !lt + } + return lt +} + +// Equal returns true if n is equal to m. +func (n Number) Equal(m Number) bool { + return !n.Less(m) && !m.Less(n) +} + +// Trunc returns the whole part of abs(n) as a signed integer. +func (n Number) Trunc() uint64 { + nv := n.Value + e := pow10(n.FractionDigits) + return nv / e +} + +// frac returns the fraction part with a precision of 18 fractional digits. +// E.g. if n is 3.1 then n.frac() returns 100,000,000,000,000,000 +func (n Number) frac() uint64 { + frac := n.FractionDigits + i := n.Trunc() * pow10(frac) + return (n.Value - i) * pow10(uint8(18-frac)) +} + +// YRange is a single range of consecutive numbers, inclusive. +type YRange struct { + Min Number + Max Number +} + +// Valid returns false if r is not a valid range (min > max). +func (r YRange) Valid() bool { + return !r.Max.Less(r.Min) +} + +// String returns r as a string using YANG notation, either a simple +// value if min == max or min..max. +func (r YRange) String() string { + if r.Min.Equal(r.Max) { + return r.Min.String() + } + return r.Min.String() + ".." + r.Max.String() +} + +// Equal compares whether two YRanges are equal. +func (r YRange) Equal(s YRange) bool { + return r.Min.Equal(s.Min) && r.Max.Equal(s.Max) +} + +// A YangRange is a set of non-overlapping ranges. +type YangRange []YRange + +// String returns the ranges r using YANG notation. Individual ranges +// are separated by pipes (|). +func (r YangRange) String() string { + s := make([]string, len(r)) + for i, r := range r { + s[i] = r.String() + } + return strings.Join(s, "|") +} + +func (r YangRange) Len() int { return len(r) } +func (r YangRange) Swap(i, j int) { r[i], r[j] = r[j], r[i] } +func (r YangRange) Less(i, j int) bool { + switch { + case r[i].Min.Less(r[j].Min): + return true + case r[j].Min.Less(r[i].Min): + return false + default: + return r[i].Max.Less(r[j].Max) + } +} + +// Validate returns an error if r has either an invalid range or has +// overlapping ranges. +// r is expected to be sorted use YangRange.Sort() +func (r YangRange) Validate() error { + if !sort.IsSorted(r) { + return errors.New("range not sorted") + } + switch { + case len(r) == 0: + return nil + case !r[0].Valid(): + return errors.New("invalid number") + } + p := r[0] + + for _, n := range r[1:] { + if n.Min.Less(p.Max) { + return errors.New("overlapping ranges") + } + } + return nil +} + +// Sort r. Must be called before Validate and coalesce if unsorted +func (r YangRange) Sort() { + sort.Sort(r) +} + +// Equal returns true if ranges r and q are identically equivalent. +// TODO(borman): should we coalesce ranges in the comparison? +func (r YangRange) Equal(q YangRange) bool { + if len(r) != len(q) { + return false + } + for i, r := range r { + if !r.Equal(q[i]) { + return false + } + } + return true +} + +// Contains returns true if all possible values in s are also possible values +// in r. An empty range is assumed to be min..max when it is the receiver +// argument. +func (r YangRange) Contains(s YangRange) bool { + if len(r) == 0 || len(s) == 0 { + return true + } + + // Check if every range in s is subsumed under r. + // Both range lists should be in order and non-adjacent (coalesced). + ri := 0 + for _, ss := range s { + for r[ri].Max.Less(ss.Min) { + ri++ + if ri == len(r) { + return false + } + } + if ss.Min.Less(r[ri].Min) || r[ri].Max.Less(ss.Max) { + return false + } + } + return true +} + +// FromInt creates a Number from an int64. +func FromInt(i int64) Number { + if i < 0 { + return Number{Negative: true, Value: uint64(-i)} + } + return Number{Value: uint64(i)} +} + +// FromUint creates a Number from a uint64. +func FromUint(i uint64) Number { + return Number{Value: i} +} + +// FromFloat creates a Number from a float64. Input values with absolute value +// outside the boundaries specified for the decimal64 value specified in +// RFC6020/RFC7950 are clamped down to the closest boundary value. +func FromFloat(f float64) Number { + if f > MaxDecimal64 { + return Number{ + Value: FromInt(MaxInt64).Value, + FractionDigits: 1, + } + } + if f < MinDecimal64 { + return Number{ + Negative: true, + Value: FromInt(MaxInt64).Value, + FractionDigits: 1, + } + } + + // Per RFC7950/6020, fraction-digits must be at least 1. + fracDig := uint8(1) + f *= 10.0 + for ; Frac(f) != 0.0 && fracDig <= MaxFractionDigits; fracDig++ { + f *= 10.0 + } + negative := false + if f < 0 { + negative = true + f = -f + } + v := uint64(f) + + return Number{Negative: negative, Value: v, FractionDigits: fracDig} +} + +// ParseInt returns s as a Number with FractionDigits=0. +// octal, or hexadecimal using the standard prefix notations (e.g., 0 and 0x) +func ParseInt(s string) (Number, error) { + s = strings.TrimSpace(s) + var n Number + switch s { + case "": + return n, errors.New("converting empty string to number") + case "+", "-": + return n, errors.New("sign with no value") + } + + ns := s + switch s[0] { + case '+': + ns = s[1:] + case '-': + n.Negative = true + ns = s[1:] + } + + var err error + n.Value, err = strconv.ParseUint(ns, 0, 64) + return n, err +} + +// ParseDecimal returns s as a Number with a non-zero FractionDigits. +// octal, or hexadecimal using the standard prefix notations (e.g., 0 and 0x) +func ParseDecimal(s string, fracDigRequired uint8) (n Number, err error) { + s = strings.TrimSpace(s) + switch s { + case "": + return n, errors.New("converting empty string to number") + case "+", "-": + return n, errors.New("sign with no value") + } + + return decimalValueFromString(s, fracDigRequired) +} + +// decimalValueFromString returns a decimal Number representation of numStr. +// fracDigRequired is used to set the number of fractional digits, which must +// be at least the greatest precision seen in numStr. +// which must be between 1 and 18. +// numStr must conform to Section 9.3.4. +func decimalValueFromString(numStr string, fracDigRequired uint8) (n Number, err error) { + if fracDigRequired > MaxFractionDigits || fracDigRequired < 1 { + return n, fmt.Errorf("invalid number of fraction digits %d > max of %d, minimum 1", fracDigRequired, MaxFractionDigits) + } + + s := numStr + dx := strings.Index(s, ".") + var fracDig uint8 + if dx >= 0 { + fracDig = uint8(len(s) - 1 - dx) + // remove first decimal, if dx > 1, will fail ParseInt below + s = s[:dx] + s[dx+1:] + } + + if fracDig > fracDigRequired { + return n, fmt.Errorf("%s has too much precision, expect <= %d fractional digits", s, fracDigRequired) + } + + s += space18[:fracDigRequired-fracDig] + + v, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return n, fmt.Errorf("%s is not a valid decimal number: %s", numStr, err) + } + + negative := false + if v < 0 { + negative = true + v = -v + } + + return Number{Value: uint64(v), FractionDigits: fracDigRequired, Negative: negative}, nil +} + +// ParseRangesInt parses s into a series of ranges. Each individual range is in s +// is separated by the pipe character (|). The min and max value of a range +// are separated by "..". An error is returned if the range is invalid. The +// output range is sorted and coalesced. +func ParseRangesInt(s string) (YangRange, error) { + return YangRange{}.parseChildRanges(s, false, 0) +} + +// ParseRangesDecimal parses s into a series of ranges. Each individual range is in s +// is separated by the pipe character (|). The min and max value of a range +// are separated by "..". An error is returned if the range is invalid. The +// output range is sorted and coalesced. +func ParseRangesDecimal(s string, fracDigRequired uint8) (YangRange, error) { + return YangRange{}.parseChildRanges(s, true, fracDigRequired) +} + +// parseChildRanges parses a child ranges statement 's' into a series of ranges +// based on an already-parsed parent YangRange. Each individual range is in s +// is separated by the pipe character (|). The min and max value of a range are +// separated by "..". An error is returned if the child ranges are not +// equally-limiting or more limiting than the parent range +// (rfc7950#section-9.2.5). The output range is sorted and coalesced. +// fracDigRequired is ignored when decimal=false. +func (y YangRange) parseChildRanges(s string, decimal bool, fracDigRequired uint8) (YangRange, error) { + parseNumber := func(s string) (Number, error) { + switch { + case s == "max": + if len(y) == 0 { + return Number{}, errors.New("cannot resolve 'max' keyword using an empty YangRange parent object") + } + max := y[len(y)-1].Max + max.FractionDigits = fracDigRequired + return max, nil + case s == "min": + if len(y) == 0 { + return Number{}, errors.New("cannot resolve 'min' keyword using an empty YangRange parent object") + } + min := y[0].Min + min.FractionDigits = fracDigRequired + return min, nil + case decimal: + return ParseDecimal(s, fracDigRequired) + default: + return ParseInt(s) + } + } + + parts := strings.Split(s, "|") + r := make(YangRange, len(parts)) + for i, s := range parts { + parts := strings.Split(s, "..") + min, err := parseNumber(strings.TrimSpace(parts[0])) + if err != nil { + return nil, err + } + var max Number + switch len(parts) { + case 1: + max = min + case 2: + if max, err = parseNumber(strings.TrimSpace(parts[1])); err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("too many '..' in %s", s) + } + if max.Less(min) { + return nil, fmt.Errorf("range boundaries out of order (%s less than %s): %s", max, min, s) + } + r[i] = YRange{min, max} + } + r.Sort() + r = coalesce(r) + + if !y.Contains(r) { + return nil, fmt.Errorf("%v not within %v", s, y) + } + + if err := r.Validate(); err != nil { + return nil, err + } + return r, nil +} + +// coalesce coalesces r into as few ranges as possible. For example, +// 1..5|6..10 would become 1..10. r is assumed to be sorted. +func coalesce(r YangRange) YangRange { + // coalesce the ranges if we have more than 1. + if len(r) < 2 { + return r + } + cr := make(YangRange, len(r)) + i := 0 + cr[i] = r[0] + for _, r1 := range r[1:] { + // r1.Min is always at least as large as cr[i].Min + // Cases are: + // r1 is contained in cr[i] + // r1 starts inside of cr[i] + // r1.Min cr[i].Max+1 + // r1 is beyond cr[i] + if cr[i].Max.addQuantum(1).Less(r1.Min) { + // r1 starts after cr[i], this is a new range + i++ + cr[i] = r1 + } else if cr[i].Max.Less(r1.Max) { + cr[i].Max = r1.Max + } + } + return cr[:i+1] +} + +func mustParseRangesInt(s string) YangRange { + r, err := ParseRangesInt(s) + if err != nil { + panic(err) + } + return r +} + +func mustParseRangesDecimal(s string, fracDigRequired uint8) YangRange { + r, err := ParseRangesDecimal(s, fracDigRequired) + if err != nil { + panic(err) + } + return r +} + +// Frac returns the fractional part of f. +func Frac(f float64) float64 { + return f - math.Trunc(f) +} + +// pow10 returns 10^e without checking for overflow. +func pow10(e uint8) uint64 { + var out uint64 = 1 + for i := uint8(0); i < e; i++ { + out *= 10 + } + return out +} + +// A EnumType represents a mapping of strings to integers. It is used both +// for enumerations as well as bitfields. +type EnumType struct { + last int64 // maximum value assigned thus far + min int64 // minimum value allowed + max int64 // maximum value allowed + unique bool // numeric values must be unique (enums) + ToString map[int64]string `json:",omitempty"` // map of enum entries by value (integer) + ToInt map[string]int64 `json:",omitempty"` // map of enum entries by name (string) +} + +// NewEnumType returns an initialized EnumType. +func NewEnumType() *EnumType { + return &EnumType{ + last: -1, // +1 will start at 0 + min: MinEnum, + max: MaxEnum, + unique: true, + ToString: map[int64]string{}, + ToInt: map[string]int64{}, + } +} + +// NewBitfield returns an EnumType initialized as a bitfield. Multiple string +// values may map to the same numeric values. Numeric values must be small +// non-negative integers. +func NewBitfield() *EnumType { + return &EnumType{ + last: -1, // +1 will start at 0 + min: 0, + max: MaxBitfieldSize - 1, + ToString: map[int64]string{}, + ToInt: map[string]int64{}, + } +} + +// Set sets name in e to the provided value. Set returns an error if the value +// is invalid, name is already signed, or when used as an enum rather than a +// bitfield, the value has previousl been used. When two different names are +// assigned to the same value, the conversion from value to name will result in +// the most recently assigned name. +func (e *EnumType) Set(name string, value int64) error { + if _, ok := e.ToInt[name]; ok { + return fmt.Errorf("field %s already assigned", name) + } + if oname, ok := e.ToString[value]; e.unique && ok { + return fmt.Errorf("fields %s and %s conflict on value %d", name, oname, value) + } + if value < e.min { + return fmt.Errorf("value %d for %s too small (minimum is %d)", value, name, e.min) + } + if value > e.max { + return fmt.Errorf("value %d for %s too large (maximum is %d)", value, name, e.max) + } + e.ToString[value] = name + e.ToInt[name] = value + if value >= e.last { + e.last = value + } + return nil +} + +// SetNext sets the name in e using the next possible value that is greater than +// all previous values. +func (e *EnumType) SetNext(name string) error { + if e.last == MaxEnum { + return fmt.Errorf("enum %q must specify a value since previous enum is the maximum value allowed", name) + } + return e.Set(name, e.last+1) +} + +// Name returns the name in e associated with value. The empty string is +// returned if no name has been assigned to value. +func (e *EnumType) Name(value int64) string { return e.ToString[value] } + +// Value returns the value associated with name in e associated. 0 is returned +// if name is not in e, or if it is the first value in an unnumbered enum. Use +// IsDefined to definitively confirm name is in e. +func (e *EnumType) Value(name string) int64 { return e.ToInt[name] } + +// IsDefined returns true if name is defined in e, else false. +func (e *EnumType) IsDefined(name string) bool { + _, defined := e.ToInt[name] + return defined +} + +// Names returns the sorted list of enum string names. +func (e *EnumType) Names() []string { + names := make([]string, len(e.ToInt)) + i := 0 + for name := range e.ToInt { + names[i] = name + i++ + } + sort.Strings(names) + return names +} + +type int64Slice []int64 + +func (p int64Slice) Len() int { return len(p) } +func (p int64Slice) Less(i, j int) bool { return p[i] < p[j] } +func (p int64Slice) Swap(i, j int) { p[i], p[j] = p[j], p[i] } + +// Values returns the sorted list of enum values. +func (e *EnumType) Values() []int64 { + values := make([]int64, len(e.ToInt)) + i := 0 + for _, value := range e.ToInt { + values[i] = value + i++ + } + sort.Sort(int64Slice(values)) + return values +} + +// NameMap returns a map of names to values. +func (e *EnumType) NameMap() map[string]int64 { + m := make(map[string]int64, len(e.ToInt)) + for name, value := range e.ToInt { + m[name] = value + } + return m +} + +// ValueMap returns a map of values to names. +func (e *EnumType) ValueMap() map[int64]string { + m := make(map[int64]string, len(e.ToString)) + for name, value := range e.ToString { + m[name] = value + } + return m +} diff --git a/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/yang.go b/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/yang.go new file mode 100644 index 00000000..fad667e2 --- /dev/null +++ b/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/yang.go @@ -0,0 +1,1101 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +import "fmt" + +// This file contains the definitions for all nodes of the yang AST. +// The actual building of the AST is in ast.go + +// Some field names have specific meanings: +// +// Grouping - This field must always be of type []*Grouping +// Typedef - This field must always be of type []*Typedef + +// A Value is just a string that can have extensions. +type Value struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge" json:",omitempty"` + Parent Node `yang:"Parent,nomerge" json:"-"` + Extensions []*Statement `yang:"Ext" json:",omitempty"` + + Description *Value `yang:"description" json:",omitempty"` + Reference *Value `yang:"reference" json:",omitempty"` +} + +func (Value) Kind() string { return "string" } +func (s *Value) ParentNode() Node { return s.Parent } +func (s *Value) NName() string { return s.Name } +func (s *Value) Statement() *Statement { return s.Source } +func (s *Value) Exts() []*Statement { return s.Extensions } + +// asRangeInt returns the value v as an int64 if it is between the values of +// min and max inclusive. An error is returned if v is out of range or does +// not parse into a number. If v is nil then an error is returned. +func (s *Value) asRangeInt(min, max int64) (int64, error) { + if s == nil { + return 0, fmt.Errorf("value is required in the range of [%d..%d]", min, max) + } + n, err := ParseInt(s.Name) + if err != nil { + return 0, err + } + i, err := n.Int() + if err != nil { + return 0, err + } + if i < min || i > max { + return 0, fmt.Errorf("value %s out of range [%d..%d]", s.Name, min, max) + } + return i, nil +} + +// asBool returns v as a boolean (true or flase) or returns an error if v +// is neither true nor false. If v is nil then false is returned. +func (s *Value) asBool() (bool, error) { + // A missing value is considered false + if s == nil { + return false, nil + } + switch s.Name { + case "true": + return true, nil + case "false": + return false, nil + default: + return false, fmt.Errorf("invalid boolean: %s", s.Name) + } +} + +// asString simply returns the string value of v. If v is nil then an empty +// string is returned. +func (s *Value) asString() string { + if s == nil { + return "" + } + return s.Name +} + +// See http://tools.ietf.org/html/rfc6020#section-7 for a description of the +// following structures. The structures are derived from that document. + +// A Module is defined in: http://tools.ietf.org/html/rfc6020#section-7.1 +// +// A SubModule is defined in: http://tools.ietf.org/html/rfc6020#section-7.2 +type Module struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge" json:"-"` + Parent Node `yang:"Parent,nomerge" json:"-"` + Extensions []*Statement `yang:"Ext"` + + Anydata []*AnyData `yang:"anydata"` + Anyxml []*AnyXML `yang:"anyxml"` + Augment []*Augment `yang:"augment"` + BelongsTo *BelongsTo `yang:"belongs-to,required=submodule,nomerge"` + Choice []*Choice `yang:"choice"` + Contact *Value `yang:"contact,nomerge"` + Container []*Container `yang:"container"` + Description *Value `yang:"description,nomerge"` + Deviation []*Deviation `yang:"deviation"` + Extension []*Extension `yang:"extension"` + Feature []*Feature `yang:"feature"` + Grouping []*Grouping `yang:"grouping"` + Identity []*Identity `yang:"identity"` + Import []*Import `yang:"import"` + Include []*Include `yang:"include"` + Leaf []*Leaf `yang:"leaf"` + LeafList []*LeafList `yang:"leaf-list"` + List []*List `yang:"list"` + Namespace *Value `yang:"namespace,required=module,nomerge"` + Notification []*Notification `yang:"notification"` + Organization *Value `yang:"organization,nomerge"` + Prefix *Value `yang:"prefix,required=module,nomerge"` + Reference *Value `yang:"reference,nomerge"` + Revision []*Revision `yang:"revision,nomerge"` + RPC []*RPC `yang:"rpc"` + Typedef []*Typedef `yang:"typedef"` + Uses []*Uses `yang:"uses"` + YangVersion *Value `yang:"yang-version,nomerge"` + + // Modules references the Modules object from which this Module node + // was parsed. + Modules *Modules +} + +func (s *Module) Kind() string { + if s.BelongsTo != nil { + return "submodule" + } + return "module" +} +func (s *Module) ParentNode() Node { return s.Parent } +func (s *Module) NName() string { return s.Name } +func (s *Module) Statement() *Statement { return s.Source } +func (s *Module) Exts() []*Statement { return s.Extensions } +func (s *Module) Groupings() []*Grouping { return s.Grouping } +func (s *Module) Typedefs() []*Typedef { return s.Typedef } +func (s *Module) Identities() []*Identity { return s.Identity } + +// Current returns the most recent revision of this module, or "" if the module +// has no revisions. +func (s *Module) Current() string { + var rev string + for _, r := range s.Revision { + if r.Name > rev { + rev = r.Name + } + } + return rev +} + +// FullName returns the full name of the module including the most recent +// revision, if any. +func (s *Module) FullName() string { + if rev := s.Current(); rev != "" { + return s.Name + "@" + rev + } + return s.Name +} + +// GetPrefix returns the proper prefix of m. Useful when looking up types +// in modules found by FindModuleByPrefix. +func (s *Module) GetPrefix() string { + pfx := s.getPrefix() + if pfx == nil { + // This case can be true during testing. + return "" + } + return pfx.Name +} + +// getPrefix returns the local prefix of the module used to refer to itself. +func (s *Module) getPrefix() *Value { + switch { + case s == nil: + return nil + case s.Kind() == "module" && s.Prefix != nil: + return s.Prefix + case s.Kind() == "submodule" && s.BelongsTo != nil: + return s.BelongsTo.Prefix + default: + return nil + } +} + +// An Import is defined in: http://tools.ietf.org/html/rfc6020#section-7.1.5 +type Import struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge" json:"-"` + Parent Node `yang:"Parent,nomerge" json:"-"` + Extensions []*Statement `yang:"Ext"` + + Prefix *Value `yang:"prefix,required"` + RevisionDate *Value `yang:"revision-date"` + Reference *Value `yang:"reference,nomerge"` + Description *Value `yang:"description,nomerge"` + + // Module is the imported module. The types and groupings are + // available to the importer with the defined prefix. + Module *Module +} + +func (Import) Kind() string { return "import" } +func (s *Import) ParentNode() Node { return s.Parent } +func (s *Import) NName() string { return s.Name } +func (s *Import) Statement() *Statement { return s.Source } +func (s *Import) Exts() []*Statement { return s.Extensions } + +// An Include is defined in: http://tools.ietf.org/html/rfc6020#section-7.1.6 +type Include struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge" json:"-"` + Parent Node `yang:"Parent,nomerge" json:"-"` + Extensions []*Statement `yang:"Ext" json:",omitempty"` + + RevisionDate *Value `yang:"revision-date"` + + // Module is the included module. The types and groupings are + // available to the importer with the defined prefix. + Module *Module +} + +func (Include) Kind() string { return "include" } +func (s *Include) ParentNode() Node { return s.Parent } +func (s *Include) NName() string { return s.Name } +func (s *Include) Statement() *Statement { return s.Source } +func (s *Include) Exts() []*Statement { return s.Extensions } + +// A Revision is defined in: http://tools.ietf.org/html/rfc6020#section-7.1.9 +type Revision struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge" json:"-"` + Parent Node `yang:"Parent,nomerge" json:"-"` + Extensions []*Statement `yang:"Ext" json:",omitempty"` + + Description *Value `yang:"description"` + Reference *Value `yang:"reference"` +} + +func (Revision) Kind() string { return "revision" } +func (s *Revision) ParentNode() Node { return s.Parent } +func (s *Revision) NName() string { return s.Name } +func (s *Revision) Statement() *Statement { return s.Source } +func (s *Revision) Exts() []*Statement { return s.Extensions } + +// A BelongsTo is defined in: http://tools.ietf.org/html/rfc6020#section-7.2.2 +type BelongsTo struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge" json:"-"` + Parent Node `yang:"Parent,nomerge" json:"-"` + Extensions []*Statement `yang:"Ext" json:",omitempty"` + + Prefix *Value `yang:"prefix,required"` +} + +func (BelongsTo) Kind() string { return "belongs-to" } +func (s *BelongsTo) ParentNode() Node { return s.Parent } +func (s *BelongsTo) NName() string { return s.Name } +func (s *BelongsTo) Statement() *Statement { return s.Source } +func (s *BelongsTo) Exts() []*Statement { return s.Extensions } + +// A Typedef is defined in: http://tools.ietf.org/html/rfc6020#section-7.3 +type Typedef struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Default *Value `yang:"default"` + Description *Value `yang:"description"` + Reference *Value `yang:"reference"` + Status *Value `yang:"status"` + Type *Type `yang:"type,required"` + Units *Value `yang:"units"` + + YangType *YangType `json:"-"` +} + +func (Typedef) Kind() string { return "typedef" } +func (s *Typedef) ParentNode() Node { return s.Parent } +func (s *Typedef) NName() string { return s.Name } +func (s *Typedef) Statement() *Statement { return s.Source } +func (s *Typedef) Exts() []*Statement { return s.Extensions } + +// A Type is defined in: http://tools.ietf.org/html/rfc6020#section-7.4 +// Note that Name is the name of the type we want, it is what must +// be looked up and resolved. +type Type struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + IdentityBase *Value `yang:"base"` // Name == identityref + Bit []*Bit `yang:"bit"` + Enum []*Enum `yang:"enum"` + FractionDigits *Value `yang:"fraction-digits"` // Name == decimal64 + Length *Length `yang:"length"` + Path *Value `yang:"path"` + Pattern []*Pattern `yang:"pattern"` + Range *Range `yang:"range"` + RequireInstance *Value `yang:"require-instance"` + Type []*Type `yang:"type"` // len > 1 only when Name is "union" + + YangType *YangType +} + +func (Type) Kind() string { return "type" } +func (s *Type) ParentNode() Node { return s.Parent } +func (s *Type) NName() string { return s.Name } +func (s *Type) Statement() *Statement { return s.Source } +func (s *Type) Exts() []*Statement { return s.Extensions } + +// A Container is defined in: http://tools.ietf.org/html/rfc6020#section-7.5 +// and http://tools.ietf.org/html/rfc7950#section-7.5 ("container" sub-statement) +type Container struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Anydata []*AnyData `yang:"anydata"` + Action []*Action `yang:"action"` + Anyxml []*AnyXML `yang:"anyxml"` + Choice []*Choice `yang:"choice"` + Config *Value `yang:"config"` + Container []*Container `yang:"container"` + Description *Value `yang:"description"` + Grouping []*Grouping `yang:"grouping"` + IfFeature []*Value `yang:"if-feature"` + Leaf []*Leaf `yang:"leaf"` + LeafList []*LeafList `yang:"leaf-list"` + List []*List `yang:"list"` + Must []*Must `yang:"must"` + Notification []*Notification `yang:"notification"` + Presence *Value `yang:"presence"` + Reference *Value `yang:"reference"` + Status *Value `yang:"status"` + Typedef []*Typedef `yang:"typedef"` + Uses []*Uses `yang:"uses"` + When *Value `yang:"when"` +} + +func (Container) Kind() string { return "container" } +func (s *Container) ParentNode() Node { return s.Parent } +func (s *Container) NName() string { return s.Name } +func (s *Container) Statement() *Statement { return s.Source } +func (s *Container) Exts() []*Statement { return s.Extensions } +func (s *Container) Groupings() []*Grouping { return s.Grouping } +func (s *Container) Typedefs() []*Typedef { return s.Typedef } + +// A Must is defined in: http://tools.ietf.org/html/rfc6020#section-7.5.3 +type Must struct { + Name string `yang:"Name,nomerge" json:",omitempty"` + Source *Statement `yang:"Statement,nomerge" json:"-"` + Parent Node `yang:"Parent,nomerge" json:"-"` + Extensions []*Statement `yang:"Ext" json:",omitempty"` + + Description *Value `yang:"description" json:",omitempty"` + ErrorAppTag *Value `yang:"error-app-tag" json:",omitempty"` + ErrorMessage *Value `yang:"error-message" json:",omitempty"` + Reference *Value `yang:"reference" json:",omitempty"` +} + +func (Must) Kind() string { return "must" } +func (s *Must) ParentNode() Node { return s.Parent } +func (s *Must) NName() string { return s.Name } +func (s *Must) Statement() *Statement { return s.Source } +func (s *Must) Exts() []*Statement { return s.Extensions } + +// A Leaf is defined in: http://tools.ietf.org/html/rfc6020#section-7.6 +type Leaf struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Config *Value `yang:"config"` + Default *Value `yang:"default"` + Description *Value `yang:"description"` + IfFeature []*Value `yang:"if-feature"` + Mandatory *Value `yang:"mandatory"` + Must []*Must `yang:"must"` + Reference *Value `yang:"reference"` + Status *Value `yang:"status"` + Type *Type `yang:"type,required"` + Units *Value `yang:"units"` + When *Value `yang:"when"` +} + +func (Leaf) Kind() string { return "leaf" } +func (s *Leaf) ParentNode() Node { return s.Parent } +func (s *Leaf) NName() string { return s.Name } +func (s *Leaf) Statement() *Statement { return s.Source } +func (s *Leaf) Exts() []*Statement { return s.Extensions } + +// A LeafList is defined in: +// YANG 1: http://tools.ietf.org/html/rfc6020#section-7.7 +// YANG 1.1: https://tools.ietf.org/html/rfc7950#section-7.7 +// It this is supposed to be an array of nodes.. +type LeafList struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Config *Value `yang:"config"` + Default []*Value `yang:"default"` + Description *Value `yang:"description"` + IfFeature []*Value `yang:"if-feature"` + MaxElements *Value `yang:"max-elements"` + MinElements *Value `yang:"min-elements"` + Must []*Must `yang:"must"` + OrderedBy *Value `yang:"ordered-by"` + Reference *Value `yang:"reference"` + Status *Value `yang:"status"` + Type *Type `yang:"type,required"` + Units *Value `yang:"units"` + When *Value `yang:"when"` +} + +func (LeafList) Kind() string { return "leaf-list" } +func (s *LeafList) ParentNode() Node { return s.Parent } +func (s *LeafList) NName() string { return s.Name } +func (s *LeafList) Statement() *Statement { return s.Source } +func (s *LeafList) Exts() []*Statement { return s.Extensions } + +// A List is defined in: http://tools.ietf.org/html/rfc6020#section-7.8 +// and http://tools.ietf.org/html/rfc7950#section-7.8 ("list" sub-statement) +type List struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Anydata []*AnyData `yang:"anydata"` + Action []*Action `yang:"action"` + Anyxml []*AnyXML `yang:"anyxml"` + Choice []*Choice `yang:"choice"` + Config *Value `yang:"config"` + Container []*Container `yang:"container"` + Description *Value `yang:"description"` + Grouping []*Grouping `yang:"grouping"` + IfFeature []*Value `yang:"if-feature"` + Key *Value `yang:"key"` + Leaf []*Leaf `yang:"leaf"` + LeafList []*LeafList `yang:"leaf-list"` + List []*List `yang:"list"` + MaxElements *Value `yang:"max-elements"` + MinElements *Value `yang:"min-elements"` + Must []*Must `yang:"must"` + Notification []*Notification `yang:"notification"` + OrderedBy *Value `yang:"ordered-by"` + Reference *Value `yang:"reference"` + Status *Value `yang:"status"` + Typedef []*Typedef `yang:"typedef"` + Unique []*Value `yang:"unique"` + Uses []*Uses `yang:"uses"` + When *Value `yang:"when"` +} + +func (List) Kind() string { return "list" } +func (s *List) ParentNode() Node { return s.Parent } +func (s *List) NName() string { return s.Name } +func (s *List) Statement() *Statement { return s.Source } +func (s *List) Exts() []*Statement { return s.Extensions } +func (s *List) Groupings() []*Grouping { return s.Grouping } +func (s *List) Typedefs() []*Typedef { return s.Typedef } + +// A Choice is defined in: http://tools.ietf.org/html/rfc6020#section-7.9 +type Choice struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Anydata []*AnyData `yang:"anydata"` + Anyxml []*AnyXML `yang:"anyxml"` + Case []*Case `yang:"case"` + Config *Value `yang:"config"` + Container []*Container `yang:"container"` + Default *Value `yang:"default"` + Description *Value `yang:"description"` + IfFeature []*Value `yang:"if-feature"` + Leaf []*Leaf `yang:"leaf"` + LeafList []*LeafList `yang:"leaf-list"` + List []*List `yang:"list"` + Mandatory *Value `yang:"mandatory"` + Reference *Value `yang:"reference"` + Status *Value `yang:"status"` + When *Value `yang:"when"` +} + +func (Choice) Kind() string { return "choice" } +func (s *Choice) ParentNode() Node { return s.Parent } +func (s *Choice) NName() string { return s.Name } +func (s *Choice) Statement() *Statement { return s.Source } +func (s *Choice) Exts() []*Statement { return s.Extensions } + +// A Case is defined in: http://tools.ietf.org/html/rfc6020#section-7.9.2 +type Case struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Anydata []*AnyData `yang:"anydata"` + Anyxml []*AnyXML `yang:"anyxml"` + Choice []*Choice `yang:"choice"` + Container []*Container `yang:"container"` + Description *Value `yang:"description"` + IfFeature []*Value `yang:"if-feature"` + Leaf []*Leaf `yang:"leaf"` + LeafList []*LeafList `yang:"leaf-list"` + List []*List `yang:"list"` + Reference *Value `yang:"reference"` + Status *Value `yang:"status"` + Uses []*Uses `yang:"uses"` + When *Value `yang:"when"` +} + +func (Case) Kind() string { return "case" } +func (s *Case) ParentNode() Node { return s.Parent } +func (s *Case) NName() string { return s.Name } +func (s *Case) Statement() *Statement { return s.Source } +func (s *Case) Exts() []*Statement { return s.Extensions } + +// An AnyXML is defined in: http://tools.ietf.org/html/rfc6020#section-7.10 +type AnyXML struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Config *Value `yang:"config"` + Description *Value `yang:"description"` + IfFeature []*Value `yang:"if-feature"` + Mandatory *Value `yang:"mandatory"` + Must []*Must `yang:"must"` + Reference *Value `yang:"reference"` + Status *Value `yang:"status"` + When *Value `yang:"when"` +} + +func (AnyXML) Kind() string { return "anyxml" } +func (s *AnyXML) ParentNode() Node { return s.Parent } +func (s *AnyXML) NName() string { return s.Name } +func (s *AnyXML) Statement() *Statement { return s.Source } +func (s *AnyXML) Exts() []*Statement { return s.Extensions } + +// An AnyData is defined in: http://tools.ietf.org/html/rfc7950#section-7.10 +// +// AnyData are only expected in YANG 1.1 modules (those with a +// "yang-version 1.1;" statement in the module). +type AnyData struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Config *Value `yang:"config"` + Description *Value `yang:"description"` + IfFeature []*Value `yang:"if-feature"` + Mandatory *Value `yang:"mandatory"` + Must []*Must `yang:"must"` + Reference *Value `yang:"reference"` + Status *Value `yang:"status"` + When *Value `yang:"when"` +} + +func (AnyData) Kind() string { return "anydata" } +func (s *AnyData) ParentNode() Node { return s.Parent } +func (s *AnyData) NName() string { return s.Name } +func (s *AnyData) Statement() *Statement { return s.Source } +func (s *AnyData) Exts() []*Statement { return s.Extensions } + +// A Grouping is defined in: http://tools.ietf.org/html/rfc6020#section-7.11 +// and http://tools.ietf.org/html/rfc7950#section-7.12 ("grouping" sub-statement) +type Grouping struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Anydata []*AnyData `yang:"anydata"` + Action []*Action `yang:"action"` + Anyxml []*AnyXML `yang:"anyxml"` + Choice []*Choice `yang:"choice"` + Container []*Container `yang:"container"` + Description *Value `yang:"description"` + Grouping []*Grouping `yang:"grouping"` + Leaf []*Leaf `yang:"leaf"` + LeafList []*LeafList `yang:"leaf-list"` + List []*List `yang:"list"` + Notification []*Notification `yang:"notification"` + Reference *Value `yang:"reference"` + Status *Value `yang:"status"` + Typedef []*Typedef `yang:"typedef"` + Uses []*Uses `yang:"uses"` +} + +func (Grouping) Kind() string { return "grouping" } +func (s *Grouping) ParentNode() Node { return s.Parent } +func (s *Grouping) NName() string { return s.Name } +func (s *Grouping) Statement() *Statement { return s.Source } +func (s *Grouping) Exts() []*Statement { return s.Extensions } +func (s *Grouping) Groupings() []*Grouping { return s.Grouping } +func (s *Grouping) Typedefs() []*Typedef { return s.Typedef } + +// A Uses is defined in: http://tools.ietf.org/html/rfc6020#section-7.12 +type Uses struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge" json:"-"` + Parent Node `yang:"Parent,nomerge" json:"-"` + Extensions []*Statement `yang:"Ext" json:"-"` + + Augment []*Augment `yang:"augment" json:",omitempty"` + Description *Value `yang:"description" json:",omitempty"` + IfFeature []*Value `yang:"if-feature" json:"-"` + Refine []*Refine `yang:"refine" json:"-"` + Reference *Value `yang:"reference" json:"-"` + Status *Value `yang:"status" json:"-"` + When *Value `yang:"when" json:",omitempty"` +} + +func (Uses) Kind() string { return "uses" } +func (s *Uses) ParentNode() Node { return s.Parent } +func (s *Uses) NName() string { return s.Name } +func (s *Uses) Statement() *Statement { return s.Source } +func (s *Uses) Exts() []*Statement { return s.Extensions } + +// A Refine is defined in: http://tools.ietf.org/html/rfc6020#section-7.12.2 +type Refine struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Default *Value `yang:"default"` + Description *Value `yang:"description"` + IfFeature []*Value `yang:"if-feature"` + Reference *Value `yang:"reference"` + Config *Value `yang:"config"` + Mandatory *Value `yang:"mandatory"` + Presence *Value `yang:"presence"` + Must []*Must `yang:"must"` + MaxElements *Value `yang:"max-elements"` + MinElements *Value `yang:"min-elements"` +} + +func (Refine) Kind() string { return "refine" } +func (s *Refine) ParentNode() Node { return s.Parent } +func (s *Refine) NName() string { return s.Name } +func (s *Refine) Statement() *Statement { return s.Source } +func (s *Refine) Exts() []*Statement { return s.Extensions } + +// An RPC is defined in: http://tools.ietf.org/html/rfc6020#section-7.13 +type RPC struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Description *Value `yang:"description"` + Grouping []*Grouping `yang:"grouping"` + IfFeature []*Value `yang:"if-feature"` + Input *Input `yang:"input"` + Output *Output `yang:"output"` + Reference *Value `yang:"reference"` + Status *Value `yang:"status"` + Typedef []*Typedef `yang:"typedef"` +} + +func (RPC) Kind() string { return "rpc" } +func (s *RPC) ParentNode() Node { return s.Parent } +func (s *RPC) NName() string { return s.Name } +func (s *RPC) Statement() *Statement { return s.Source } +func (s *RPC) Exts() []*Statement { return s.Extensions } +func (s *RPC) Groupings() []*Grouping { return s.Grouping } +func (s *RPC) Typedefs() []*Typedef { return s.Typedef } + +// An Input is defined in: http://tools.ietf.org/html/rfc6020#section-7.13.2 +type Input struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Anydata []*AnyData `yang:"anydata"` + Anyxml []*AnyXML `yang:"anyxml"` + Choice []*Choice `yang:"choice"` + Container []*Container `yang:"container"` + Grouping []*Grouping `yang:"grouping"` + Leaf []*Leaf `yang:"leaf"` + LeafList []*LeafList `yang:"leaf-list"` + List []*List `yang:"list"` + Must []*Must `yang:"must"` + Typedef []*Typedef `yang:"typedef"` + Uses []*Uses `yang:"uses"` +} + +func (Input) Kind() string { return "input" } +func (s *Input) ParentNode() Node { return s.Parent } +func (s *Input) NName() string { return s.Name } +func (s *Input) Statement() *Statement { return s.Source } +func (s *Input) Exts() []*Statement { return s.Extensions } +func (s *Input) Groupings() []*Grouping { return s.Grouping } +func (s *Input) Typedefs() []*Typedef { return s.Typedef } + +// An Output is defined in: http://tools.ietf.org/html/rfc6020#section-7.13.3 +type Output struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Anydata []*AnyData `yang:"anydata"` + Anyxml []*AnyXML `yang:"anyxml"` + Choice []*Choice `yang:"choice"` + Container []*Container `yang:"container"` + Grouping []*Grouping `yang:"grouping"` + Leaf []*Leaf `yang:"leaf"` + LeafList []*LeafList `yang:"leaf-list"` + List []*List `yang:"list"` + Must []*Must `yang:"must"` + Typedef []*Typedef `yang:"typedef"` + Uses []*Uses `yang:"uses"` +} + +func (Output) Kind() string { return "output" } +func (s *Output) ParentNode() Node { return s.Parent } +func (s *Output) NName() string { return s.Name } +func (s *Output) Statement() *Statement { return s.Source } +func (s *Output) Exts() []*Statement { return s.Extensions } +func (s *Output) Groupings() []*Grouping { return s.Grouping } +func (s *Output) Typedefs() []*Typedef { return s.Typedef } + +// A Notification is defined in: http://tools.ietf.org/html/rfc6020#section-7.14 +type Notification struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Anydata []*AnyData `yang:"anydata"` + Anyxml []*AnyXML `yang:"anyxml"` + Choice []*Choice `yang:"choice"` + Container []*Container `yang:"container"` + Description *Value `yang:"description"` + Grouping []*Grouping `yang:"grouping"` + IfFeature []*Value `yang:"if-feature"` + Leaf []*Leaf `yang:"leaf"` + LeafList []*LeafList `yang:"leaf-list"` + List []*List `yang:"list"` + Reference *Value `yang:"reference"` + Status *Value `yang:"status"` + Typedef []*Typedef `yang:"typedef"` + Uses []*Uses `yang:"uses"` +} + +func (Notification) Kind() string { return "notification" } +func (s *Notification) ParentNode() Node { return s.Parent } +func (s *Notification) NName() string { return s.Name } +func (s *Notification) Statement() *Statement { return s.Source } +func (s *Notification) Exts() []*Statement { return s.Extensions } +func (s *Notification) Groupings() []*Grouping { return s.Grouping } +func (s *Notification) Typedefs() []*Typedef { return s.Typedef } + +// An Augment is defined in: http://tools.ietf.org/html/rfc6020#section-7.15 +// and http://tools.ietf.org/html/rfc7950#section-7.17 ("augment" sub-statement) +type Augment struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Anydata []*AnyData `yang:"anydata"` + Action []*Action `yang:"action"` + Anyxml []*AnyXML `yang:"anyxml"` + Case []*Case `yang:"case"` + Choice []*Choice `yang:"choice"` + Container []*Container `yang:"container"` + Description *Value `yang:"description"` + IfFeature []*Value `yang:"if-feature"` + Leaf []*Leaf `yang:"leaf"` + LeafList []*LeafList `yang:"leaf-list"` + List []*List `yang:"list"` + Notification []*Notification `yang:"notification"` + Reference *Value `yang:"reference"` + Status *Value `yang:"status"` + Uses []*Uses `yang:"uses"` + When *Value `yang:"when"` +} + +func (Augment) Kind() string { return "augment" } +func (s *Augment) ParentNode() Node { return s.Parent } +func (s *Augment) NName() string { return s.Name } +func (s *Augment) Statement() *Statement { return s.Source } +func (s *Augment) Exts() []*Statement { return s.Extensions } + +// An Identity is defined in: http://tools.ietf.org/html/rfc6020#section-7.16 +type Identity struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge" json:"-"` + Parent Node `yang:"Parent,nomerge" json:"-"` + Extensions []*Statement `yang:"Ext" json:"-"` + + Base []*Value `yang:"base" json:"-"` + Description *Value `yang:"description" json:"-"` + IfFeature []*Value `yang:"if-feature" json:"-"` + Reference *Value `yang:"reference" json:"-"` + Status *Value `yang:"status" json:"-"` + Values []*Identity `json:",omitempty"` +} + +func (Identity) Kind() string { return "identity" } +func (s *Identity) ParentNode() Node { return s.Parent } +func (s *Identity) NName() string { return s.Name } +func (s *Identity) Statement() *Statement { return s.Source } +func (s *Identity) Exts() []*Statement { return s.Extensions } + +// PrefixedName returns the prefix-qualified name for the identity +func (s *Identity) PrefixedName() string { + return fmt.Sprintf("%s:%s", RootNode(s).GetPrefix(), s.Name) +} + +// modulePrefixedName returns the module-qualified name for the identity. +func (s *Identity) modulePrefixedName() string { + return fmt.Sprintf("%s:%s", module(s).Name, s.Name) +} + +// IsDefined behaves the same as the implementation for Enum - it returns +// true if an identity with the name is defined within the Values of the +// identity +func (s *Identity) IsDefined(name string) bool { + return s.GetValue(name) != nil +} + +// GetValue returns a pointer to the identity with name "name" that is within +// the values of the identity +func (s *Identity) GetValue(name string) *Identity { + for _, v := range s.Values { + if v.Name == name { + return v + } + } + return nil +} + +// An Extension is defined in: http://tools.ietf.org/html/rfc6020#section-7.17 +type Extension struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge" json:"-"` + Parent Node `yang:"Parent,nomerge" json:"-"` + Extensions []*Statement `yang:"Ext" json:",omitempty"` + + Argument *Argument `yang:"argument" json:",omitempty"` + Description *Value `yang:"description" json:",omitempty"` + Reference *Value `yang:"reference" json:",omitempty"` + Status *Value `yang:"status" json:",omitempty"` +} + +func (Extension) Kind() string { return "extension" } +func (s *Extension) ParentNode() Node { return s.Parent } +func (s *Extension) NName() string { return s.Name } +func (s *Extension) Statement() *Statement { return s.Source } +func (s *Extension) Exts() []*Statement { return s.Extensions } + +// An Argument is defined in: http://tools.ietf.org/html/rfc6020#section-7.17.2 +type Argument struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge" json:"-"` + Parent Node `yang:"Parent,nomerge" json:"-"` + Extensions []*Statement `yang:"Ext" json:",omitempty"` + + YinElement *Value `yang:"yin-element" json:",omitempty"` +} + +func (Argument) Kind() string { return "argument" } +func (s *Argument) ParentNode() Node { return s.Parent } +func (s *Argument) NName() string { return s.Name } +func (s *Argument) Statement() *Statement { return s.Source } +func (s *Argument) Exts() []*Statement { return s.Extensions } + +// An Element is defined in: http://tools.ietf.org/html/rfc6020#section-7.17.2.2 +type Element struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + YinElement *Value `yang:"yin-element"` +} + +func (Element) Kind() string { return "element" } +func (s *Element) ParentNode() Node { return s.Parent } +func (s *Element) NName() string { return s.Name } +func (s *Element) Statement() *Statement { return s.Source } +func (s *Element) Exts() []*Statement { return s.Extensions } + +// A Feature is defined in: http://tools.ietf.org/html/rfc6020#section-7.18.1 +type Feature struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge" json:"-"` + Parent Node `yang:"Parent,nomerge" json:"-"` + Extensions []*Statement `yang:"Ext" json:",omitempty"` + + Description *Value `yang:"description" json:",omitempty"` + IfFeature []*Value `yang:"if-feature" json:",omitempty"` + Status *Value `yang:"status" json:",omitempty"` + Reference *Value `yang:"reference" json:",omitempty"` +} + +func (Feature) Kind() string { return "feature" } +func (s *Feature) ParentNode() Node { return s.Parent } +func (s *Feature) NName() string { return s.Name } +func (s *Feature) Statement() *Statement { return s.Source } +func (s *Feature) Exts() []*Statement { return s.Extensions } + +// A Deviation is defined in: http://tools.ietf.org/html/rfc6020#section-7.18.3 +type Deviation struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Description *Value `yang:"description"` + Deviate []*Deviate `yang:"deviate,required"` + Reference *Value `yang:"reference"` +} + +func (Deviation) Kind() string { return "deviation" } +func (s *Deviation) ParentNode() Node { return s.Parent } +func (s *Deviation) NName() string { return s.Name } +func (s *Deviation) Statement() *Statement { return s.Source } +func (s *Deviation) Exts() []*Statement { return s.Extensions } + +// A Deviate is defined in: http://tools.ietf.org/html/rfc6020#section-7.18.3.2 +type Deviate struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Config *Value `yang:"config"` + Default *Value `yang:"default"` + Mandatory *Value `yang:"mandatory"` + MaxElements *Value `yang:"max-elements"` + MinElements *Value `yang:"min-elements"` + Must []*Must `yang:"must"` + Type *Type `yang:"type"` + Unique []*Value `yang:"unique"` + Units *Value `yang:"units"` +} + +func (Deviate) Kind() string { return "deviate" } +func (s *Deviate) ParentNode() Node { return s.Parent } +func (s *Deviate) NName() string { return s.Name } +func (s *Deviate) Statement() *Statement { return s.Source } +func (s *Deviate) Exts() []*Statement { return s.Extensions } + +// An Enum is defined in: http://tools.ietf.org/html/rfc6020#section-9.6.4 +type Enum struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Description *Value `yang:"description"` + IfFeature []*Value `yang:"if-feature"` + Reference *Value `yang:"reference"` + Status *Value `yang:"status"` + Value *Value `yang:"value"` +} + +func (Enum) Kind() string { return "enum" } +func (s *Enum) ParentNode() Node { return s.Parent } +func (s *Enum) NName() string { return s.Name } +func (s *Enum) Statement() *Statement { return s.Source } +func (s *Enum) Exts() []*Statement { return s.Extensions } + +// A Bit is defined in: http://tools.ietf.org/html/rfc6020#section-9.7.4 +type Bit struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Description *Value `yang:"description"` + IfFeature []*Value `yang:"if-feature"` + Reference *Value `yang:"reference"` + Status *Value `yang:"status"` + Position *Value `yang:"position"` +} + +func (Bit) Kind() string { return "bit" } +func (s *Bit) ParentNode() Node { return s.Parent } +func (s *Bit) NName() string { return s.Name } +func (s *Bit) Statement() *Statement { return s.Source } +func (s *Bit) Exts() []*Statement { return s.Extensions } + +// A Range is defined in: http://tools.ietf.org/html/rfc6020#section-9.2.4 +type Range struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Description *Value `yang:"description"` + ErrorAppTag *Value `yang:"error-app-tag"` + ErrorMessage *Value `yang:"error-message"` + Reference *Value `yang:"reference"` +} + +func (Range) Kind() string { return "range" } +func (s *Range) ParentNode() Node { return s.Parent } +func (s *Range) NName() string { return s.Name } +func (s *Range) Statement() *Statement { return s.Source } +func (s *Range) Exts() []*Statement { return s.Extensions } + +// A Length is defined in: http://tools.ietf.org/html/rfc6020#section-9.4.4 +type Length struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Description *Value `yang:"description"` + ErrorAppTag *Value `yang:"error-app-tag"` + ErrorMessage *Value `yang:"error-message"` + Reference *Value `yang:"reference"` +} + +func (Length) Kind() string { return "length" } +func (s *Length) ParentNode() Node { return s.Parent } +func (s *Length) NName() string { return s.Name } +func (s *Length) Statement() *Statement { return s.Source } +func (s *Length) Exts() []*Statement { return s.Extensions } + +// A Pattern is defined in: http://tools.ietf.org/html/rfc6020#section-9.4.6 +// and http://tools.ietf.org/html/rfc7950#section-9.4.5.1 ("modifier" sub-statement) +type Pattern struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Description *Value `yang:"description"` + ErrorAppTag *Value `yang:"error-app-tag"` + ErrorMessage *Value `yang:"error-message"` + Reference *Value `yang:"reference"` + Modifier *Value `yang:"modifier"` +} + +func (Pattern) Kind() string { return "pattern" } +func (s *Pattern) ParentNode() Node { return s.Parent } +func (s *Pattern) NName() string { return s.Name } +func (s *Pattern) Statement() *Statement { return s.Source } +func (s *Pattern) Exts() []*Statement { return s.Extensions } + +// An Action is defined in http://tools.ietf.org/html/rfc7950#section-7.15 +// +// Action define an RPC operation connected to a specific container or list data +// node in the schema. In the schema tree, Action differ from RPC only in where +// in the tree they are found. RPC nodes are only found as sub-statements of a +// Module, while Action are found only as sub-statements of Container, List, +// Grouping and Augment nodes. +type Action struct { + Name string `yang:"Name,nomerge"` + Source *Statement `yang:"Statement,nomerge"` + Parent Node `yang:"Parent,nomerge"` + Extensions []*Statement `yang:"Ext"` + + Description *Value `yang:"description"` + Grouping []*Grouping `yang:"grouping"` + IfFeature []*Value `yang:"if-feature"` + Input *Input `yang:"input"` + Output *Output `yang:"output"` + Reference *Value `yang:"reference"` + Status *Value `yang:"status"` + Typedef []*Typedef `yang:"typedef"` +} + +func (Action) Kind() string { return "action" } +func (s *Action) ParentNode() Node { return s.Parent } +func (s *Action) NName() string { return s.Name } +func (s *Action) Statement() *Statement { return s.Source } +func (s *Action) Exts() []*Statement { return s.Extensions } +func (s *Action) Groupings() []*Grouping { return s.Grouping } +func (s *Action) Typedefs() []*Typedef { return s.Typedef } diff --git a/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/yangtype.go b/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/yangtype.go new file mode 100644 index 00000000..cc1e33fd --- /dev/null +++ b/src/webui/vendor/github.com/openconfig/goyang/pkg/yang/yangtype.go @@ -0,0 +1,330 @@ +// Copyright 2021 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yang + +import ( + "fmt" + + "github.com/google/go-cmp/cmp" +) + +var ( + // TypeKindFromName maps the string name used in a YANG file to the enumerated + // TypeKind used in this library. + TypeKindFromName = map[string]TypeKind{ + "none": Ynone, + "int8": Yint8, + "int16": Yint16, + "int32": Yint32, + "int64": Yint64, + "uint8": Yuint8, + "uint16": Yuint16, + "uint32": Yuint32, + "uint64": Yuint64, + "binary": Ybinary, + "bits": Ybits, + "boolean": Ybool, + "decimal64": Ydecimal64, + "empty": Yempty, + "enumeration": Yenum, + "identityref": Yidentityref, + "instance-identifier": YinstanceIdentifier, + "leafref": Yleafref, + "string": Ystring, + "union": Yunion, + } + + // TypeKindToName maps the enumerated type used in this library to the string + // used in a YANG file. + TypeKindToName = map[TypeKind]string{ + Ynone: "none", + Yint8: "int8", + Yint16: "int16", + Yint32: "int32", + Yint64: "int64", + Yuint8: "uint8", + Yuint16: "uint16", + Yuint32: "uint32", + Yuint64: "uint64", + Ybinary: "binary", + Ybits: "bits", + Ybool: "boolean", + Ydecimal64: "decimal64", + Yempty: "empty", + Yenum: "enumeration", + Yidentityref: "identityref", + YinstanceIdentifier: "instance-identifier", + Yleafref: "leafref", + Ystring: "string", + Yunion: "union", + } + + // BaseTypedefs is a map of all base types to the Typedef structure manufactured + // for the type. + BaseTypedefs = map[string]*Typedef{} + + baseTypes = map[string]*YangType{ + "int8": { + Name: "int8", + Kind: Yint8, + Range: Int8Range, + }, + "int16": { + Name: "int16", + Kind: Yint16, + Range: Int16Range, + }, + "int32": { + Name: "int32", + Kind: Yint32, + Range: Int32Range, + }, + "int64": { + Name: "int64", + Kind: Yint64, + Range: Int64Range, + }, + "uint8": { + Name: "uint8", + Kind: Yuint8, + Range: Uint8Range, + }, + "uint16": { + Name: "uint16", + Kind: Yuint16, + Range: Uint16Range, + }, + "uint32": { + Name: "uint32", + Kind: Yuint32, + Range: Uint32Range, + }, + "uint64": { + Name: "uint64", + Kind: Yuint64, + Range: Uint64Range, + }, + + "decimal64": { + Name: "decimal64", + Kind: Ydecimal64, + }, + "string": { + Name: "string", + Kind: Ystring, + }, + "boolean": { + Name: "boolean", + Kind: Ybool, + }, + "enumeration": { + Name: "enumeration", + Kind: Yenum, + }, + "bits": { + Name: "bits", + Kind: Ybits, + }, + "binary": { + Name: "binary", + Kind: Ybinary, + }, + "leafref": { + Name: "leafref", + Kind: Yleafref, + }, + "identityref": { + Name: "identityref", + Kind: Yidentityref, + }, + "empty": { + Name: "empty", + Kind: Yempty, + }, + "union": { + Name: "union", + Kind: Yunion, + }, + "instance-identifier": { + Name: "instance-identifier", + Kind: YinstanceIdentifier, + }, + } +) + +// Install builtin types as know types +func init() { + for k, v := range baseTypes { + // Base types are always their own root + v.Root = v + BaseTypedefs[k] = v.typedef() + } +} + +// TypeKind is the enumeration of the base types available in YANG. It +// is analogous to reflect.Kind. +type TypeKind uint + +func (k TypeKind) String() string { + if s := TypeKindToName[k]; s != "" { + return s + } + return fmt.Sprintf("unknown-type-%d", k) +} + +const ( + // Ynone represents the invalid (unset) type. + Ynone = TypeKind(iota) + // Yint8 is an int in the range [-128, 127]. + Yint8 + // Yint16 is an int in the range [-32768, 32767]. + Yint16 + // Yint32 is an int in the range [-2147483648, 2147483647]. + Yint32 + // Yint64 is an int in the range [-9223372036854775808, 9223372036854775807] + Yint64 + // Yuint8 is an int in the range [0, 255] + Yuint8 + // Yuint16 is an int in the range [0, 65535] + Yuint16 + // Yuint32 is an int in the range [0, 4294967295] + Yuint32 + // Yuint64 is an int in the range [0, 18446744073709551615] + Yuint64 + + // Ybinary stores arbitrary data. + Ybinary + // Ybits is a named set of bits or flags. + Ybits + // Ybool is true or false. + Ybool + // Ydecimal64 is a signed decimal number. + Ydecimal64 + // Yempty has no associated value. + Yempty + // Yenum stores enumerated strings. + Yenum + // Yidentityref stores an extensible enumeration. + Yidentityref + // YinstanceIdentifier stores a reference to a data tree node. + YinstanceIdentifier + // Yleafref stores a reference to a leaf instance. + Yleafref + // Ystring is a human readable string. + Ystring + // Yunion is a choice of types. + Yunion +) + +// A YangType is the internal representation of a type in YANG. It may +// refer to either a builtin type or type specified with typedef. Not +// all fields in YangType are used for all types. +type YangType struct { + Name string + Kind TypeKind // Ynone if not a base type + Base *Type `json:"-"` // Base type for non-builtin types + IdentityBase *Identity `json:",omitempty"` // Base statement for a type using identityref + Root *YangType `json:"-"` // root of this type that is the same + Bit *EnumType `json:",omitempty"` // bit position, "status" is lost + Enum *EnumType `json:",omitempty"` // enum name to value, "status" is lost + Units string `json:",omitempty"` // units to be used for this type + Default string `json:",omitempty"` // default value, if any + HasDefault bool `json:",omitempty"` // whether the type has a default. + FractionDigits int `json:",omitempty"` // decimal64 fixed point precision + Length YangRange `json:",omitempty"` // this should be processed by section 12 + OptionalInstance bool `json:",omitempty"` // !require-instances which defaults to true + Path string `json:",omitempty"` // the path in a leafref + Pattern []string `json:",omitempty"` // limiting XSD-TYPES expressions on strings + POSIXPattern []string `json:",omitempty"` // limiting POSIX ERE on strings (specified by openconfig-extensions:posix-pattern) + Range YangRange `json:",omitempty"` // range for integers + Type []*YangType `json:",omitempty"` // for unions +} + +// Equal returns true if y and t describe the same type. +func (y *YangType) Equal(t *YangType) bool { + switch { + case y == t: + return true + case y == nil || t == nil: + return false + case + // Don't check the Name, it contains no information + y.Kind != t.Kind, + y.Units != t.Units, + y.Default != t.Default, + y.HasDefault != t.HasDefault, + y.FractionDigits != t.FractionDigits, + y.IdentityBase != t.IdentityBase, + len(y.Length) != len(t.Length), + !y.Length.Equal(t.Length), + y.OptionalInstance != t.OptionalInstance, + y.Path != t.Path, + !ssEqual(y.Pattern, t.Pattern), + !ssEqual(y.POSIXPattern, t.POSIXPattern), + len(y.Range) != len(t.Range), + !y.Range.Equal(t.Range), + !tsEqual(y.Type, t.Type), + !cmp.Equal(y.Enum, t.Enum, cmp.Comparer(func(t, u EnumType) bool { + return cmp.Equal(t.unique, u.unique) && cmp.Equal(t.ToInt, u.ToInt) && cmp.Equal(t.ToString, u.ToString) + })): + + return false + } + // TODO(borman): Base, Bit + return true +} + +// typedef returns a Typedef created from y for insertion into the BaseTypedefs +// map. +func (y *YangType) typedef() *Typedef { + return &Typedef{ + Name: y.Name, + Source: &Statement{}, + Type: &Type{ + Name: y.Name, + Source: &Statement{}, + YangType: y, + }, + YangType: y, + } +} + +// ssEqual returns true if the two slices are equivalent. +func ssEqual(s1, s2 []string) bool { + if len(s1) != len(s2) { + return false + } + for x, s := range s1 { + if s != s2[x] { + return false + } + } + return true +} + +// tsEqual returns true if the two Type slices are identical. +func tsEqual(t1, t2 []*YangType) bool { + if len(t1) != len(t2) { + return false + } + // For now we compare absolute pointers. + // This may be wrong. + for x, t := range t1 { + if !t.Equal(t2[x]) { + return false + } + } + return true +} diff --git a/src/webui/vendor/modules.txt b/src/webui/vendor/modules.txt new file mode 100644 index 00000000..5e37e86d --- /dev/null +++ b/src/webui/vendor/modules.txt @@ -0,0 +1,14 @@ +# github.com/google/go-cmp v0.7.0 +## explicit; go 1.21 +github.com/google/go-cmp/cmp +github.com/google/go-cmp/cmp/internal/diff +github.com/google/go-cmp/cmp/internal/flags +github.com/google/go-cmp/cmp/internal/function +github.com/google/go-cmp/cmp/internal/value +# github.com/openconfig/goyang v1.6.3 => ./internal/goyang +## explicit; go 1.22.0 +github.com/openconfig/goyang/pkg/indent +github.com/openconfig/goyang/pkg/yang +# github.com/pborman/getopt v1.1.0 +## explicit; go 1.13 +# github.com/openconfig/goyang => ./internal/goyang diff --git a/test/case/services/all.yaml b/test/case/services/all.yaml index eef4de21..155de1b2 100644 --- a/test/case/services/all.yaml +++ b/test/case/services/all.yaml @@ -7,3 +7,6 @@ - name: SSH suite: ssh/all.yaml + +- name: Web UI + suite: webui/all.yaml diff --git a/test/case/services/webui/all.yaml b/test/case/services/webui/all.yaml new file mode 100644 index 00000000..951e6b02 --- /dev/null +++ b/test/case/services/webui/all.yaml @@ -0,0 +1,3 @@ +--- +- name: Web UI Login and CSRF Protection + case: login_csrf/test.py diff --git a/test/case/services/webui/login_csrf/Readme.adoc b/test/case/services/webui/login_csrf/Readme.adoc new file mode 120000 index 00000000..ae32c841 --- /dev/null +++ b/test/case/services/webui/login_csrf/Readme.adoc @@ -0,0 +1 @@ +test.adoc \ No newline at end of file diff --git a/test/case/services/webui/login_csrf/test.adoc b/test/case/services/webui/login_csrf/test.adoc new file mode 100644 index 00000000..b47943a2 --- /dev/null +++ b/test/case/services/webui/login_csrf/test.adoc @@ -0,0 +1,45 @@ +=== Web UI Login and CSRF Protection + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/services/webui/login_csrf] + +==== Description + +Black-box test of the web management interface (nginx :443 -> Go webui on +127.0.0.1:10000). Drives the UI exactly like a browser would, using a +cookie-preserving HTTP session, and verifies the security-critical parts of +the login flow: + +1. A protected page is not served to an unauthenticated client; the request + is redirected to /login. +2. Login with a bad password is rejected (no session is created). +3. Login with the correct credentials establishes a session, and the session + cookie is flagged HttpOnly. +4. An authenticated page load succeeds. +5. A state-changing POST without the CSRF token is rejected with 403, even + with a valid session cookie. +6. The same POST with the CSRF token (X-CSRF-Token header) succeeds. +7. After logout the session cookie no longer grants access. + +The device is reached over its IPv6 link-local mgmt address, which carries a +zone id (fe80::.../%ifname). requests/urllib3 percent-encode the '%' to +'%25' while preparing the request, so we substitute it back before sending — +the same workaround infamy.restconf uses. + +==== Topology + +image::topology.svg[Web UI Login and CSRF Protection topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to target DUT +. Wait for the web UI to come up +. Unauthenticated access to a protected page redirects to /login +. Login with a bad password is rejected +. Login with correct credentials establishes a session +. Session cookie is flagged HttpOnly +. Authenticated page load succeeds +. State-changing POST without CSRF token is rejected +. State-changing POST with CSRF token succeeds (logout) +. After logout the session no longer grants access + + diff --git a/test/case/services/webui/login_csrf/test.py b/test/case/services/webui/login_csrf/test.py new file mode 100755 index 00000000..27c843b3 --- /dev/null +++ b/test/case/services/webui/login_csrf/test.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Web UI login and CSRF protection + +Black-box test of the web management interface (nginx :443 -> Go webui on +127.0.0.1:10000). Drives the UI exactly like a browser would, using a +cookie-preserving HTTP session, and verifies the security-critical parts of +the login flow: + +1. A protected page is not served to an unauthenticated client; the request + is redirected to /login. +2. Login with a bad password is rejected (no session is created). +3. Login with the correct credentials establishes a session, and the session + cookie is flagged HttpOnly. +4. An authenticated page load succeeds. +5. A state-changing POST without the CSRF token is rejected with 403, even + with a valid session cookie. +6. The same POST with the CSRF token (X-CSRF-Token header) succeeds. +7. After logout the session cookie no longer grants access. + +The device is reached over its IPv6 link-local mgmt address, which carries a +zone id (fe80::.../%ifname). requests/urllib3 percent-encode the '%' to +'%25' while preparing the request, so we substitute it back before sending — +the same workaround infamy.restconf uses. +""" +import re +import warnings + +import requests +from urllib3.exceptions import InsecureRequestWarning + +import infamy +from infamy import until + +# The device uses a self-signed certificate; silence the verify=False noise. +warnings.simplefilter("ignore", InsecureRequestWarning) + + +class WebUI: + """Cookie-preserving HTTP client for the web UI, link-local aware.""" + + def __init__(self, host, username, password): + self.base = f"https://[{host}]" + self.username = username + self.password = password + self.session = requests.Session() + + def _send(self, method, path, **kwargs): + req = requests.Request(method, f"{self.base}{path}", **kwargs) + prepared = self.session.prepare_request(req) + # Undo urllib3's percent-encoding of the IPv6 zone id separator. + prepared.url = re.sub(r"%25", "%", prepared.url) + # http.cookiejar's host normalisation for IPv6 link-local URLs is + # inconsistent across Python versions — the csrf cookie set on + # GET /login can fail to match the same URL on the following POST, + # producing a 403 from csrfMiddleware that's awfully hard to + # diagnose. Rebuild the Cookie header from the jar directly so + # whatever we received gets sent back verbatim. + jar = "; ".join(f"{c.name}={c.value}" for c in self.session.cookies) + if jar: + prepared.headers["Cookie"] = jar + # Never auto-follow redirects: requests would rebuild the target URL + # internally and re-mangle the link-local zone id, breaking the + # connection. We assert on the 3xx directly instead. + return self.session.send(prepared, verify=False, allow_redirects=False) + + def get(self, path, **kwargs): + return self._send("GET", path, **kwargs) + + def post(self, path, **kwargs): + return self._send("POST", path, **kwargs) + + def csrf_token(self): + """Return the current CSRF token from the session cookie jar.""" + return self.session.cookies.get("csrf") + + def login(self): + """Fetch /login, scrape the CSRF token, and POST credentials. + + Returns the POST response. On success the server answers 303 to / + and sets the session cookie; on failure it re-renders /login (200) + with no session cookie. + """ + page = self.get("/login") + assert page.status_code == 200, f"GET /login: {page.status_code}" + m = re.search(r'name="csrf-token"\s+content="([^"]+)"', page.text) + assert m, "no csrf-token meta tag on /login" + token = m.group(1) + + return self.post("/login", data={ + "username": self.username, + "password": self.password, + "csrf": token, + }) + + +with infamy.Test() as test: + with test.step("Set up topology and attach to target DUT"): + env = infamy.Env() + # Attach over NETCONF only to learn the mgmt address and admin + # password; the web UI itself is exercised over plain HTTPS below. + target = env.attach("target", "mgmt", protocol="netconf") + host = target.location.host + password = target.location.password + + with test.step("Wait for the web UI to come up"): + def webui_ready(): + try: + r = WebUI(host, "admin", password).get("/login") + return r.status_code == 200 and "csrf-token" in r.text + except Exception: + return False + + until(webui_ready, attempts=60, interval=2) + + with test.step("Unauthenticated access to a protected page redirects to /login"): + anon = WebUI(host, "admin", password) + r = anon.get("/") + assert r.status_code == 303, f"want 303, got {r.status_code}" + assert r.headers.get("Location") == "/login", \ + f"want redirect to /login, got {r.headers.get('Location')}" + + with test.step("Login with a bad password is rejected"): + bad = WebUI(host, "admin", "definitely-not-the-password") + r = bad.login() + assert r.status_code == 200, \ + f"bad login should re-render /login (200), got {r.status_code}" + assert "session" not in bad.session.cookies, \ + "session cookie set despite wrong password" + # And a protected page is still denied. + r = bad.get("/") + assert r.status_code == 303, \ + f"bad login still authenticated? got {r.status_code}" + + with test.step("Login with correct credentials establishes a session"): + ui = WebUI(host, "admin", password) + r = ui.login() + assert r.status_code == 303, f"login should redirect (303), got {r.status_code}" + assert r.headers.get("Location") == "/", \ + f"login should redirect to /, got {r.headers.get('Location')}" + assert "session" in ui.session.cookies, "no session cookie after login" + + with test.step("Session cookie is flagged HttpOnly"): + sess = next(c for c in ui.session.cookies if c.name == "session") + assert sess.has_nonstandard_attr("HttpOnly"), \ + "session cookie missing HttpOnly flag" + + with test.step("Authenticated page load succeeds"): + r = ui.get("/interfaces") + assert r.status_code == 200, f"GET /interfaces: {r.status_code}" + # base.html ships an htmx-config meta tag on every authenticated page; + # its absence means the layout chrome didn't render even though we got 200. + assert 'name="htmx-config"' in r.text, "htmx-config meta missing from authenticated page" + + with test.step("State-changing POST without CSRF token is rejected"): + # Valid session cookie, but no X-CSRF-Token / csrf form field. + r = ui.post("/logout") + assert r.status_code == 403, \ + f"CSRF-less POST not rejected, got {r.status_code}" + + with test.step("State-changing POST with CSRF token succeeds (logout)"): + r = ui.post("/logout", headers={"X-CSRF-Token": ui.csrf_token()}) + assert r.status_code in (200, 204, 303), \ + f"logout failed, got {r.status_code}" + + with test.step("After logout the session no longer grants access"): + r = ui.get("/") + assert r.status_code == 303, \ + f"still authenticated after logout, got {r.status_code}" + + test.succeed() diff --git a/test/case/services/webui/login_csrf/topology.dot b/test/case/services/webui/login_csrf/topology.dot new file mode 100644 index 00000000..34a6b58b --- /dev/null +++ b/test/case/services/webui/login_csrf/topology.dot @@ -0,0 +1,22 @@ +graph "1x1" { + layout="neato"; + overlap="false"; + esep="+80"; + + node [shape=record, fontname="DejaVu Sans Mono, Book"]; + edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"]; + + host [ + label="host | { mgmt }", + pos="0,12!", + requires="controller", + ]; + + target [ + label="{ mgmt } | target", + pos="10,12!", + requires="infix", + ]; + + host:mgmt -- target:mgmt [requires="mgmt", color="lightgray"] +} diff --git a/test/case/services/webui/login_csrf/topology.svg b/test/case/services/webui/login_csrf/topology.svg new file mode 100644 index 00000000..6fc6f47a --- /dev/null +++ b/test/case/services/webui/login_csrf/topology.svg @@ -0,0 +1,33 @@ + + + + + + +1x1 + + + +host + +host + +mgmt + + + +target + +mgmt + +target + + + +host:mgmt--target:mgmt + + + + diff --git a/test/case/statd/system/ietf-system.json b/test/case/statd/system/ietf-system.json index a8dde70b..e8575f7f 100644 --- a/test/case/statd/system/ietf-system.json +++ b/test/case/statd/system/ietf-system.json @@ -416,6 +416,18 @@ "size": "14073", "used": "67", "available": "12860" + }, + { + "mount-point": "/run", + "size": "51200", + "used": "128", + "available": "51072" + }, + { + "mount-point": "/tmp", + "size": "102400", + "used": "240", + "available": "102160" } ] } diff --git a/test/case/statd/system/operational.json b/test/case/statd/system/operational.json index 42832152..8a436a9d 100644 --- a/test/case/statd/system/operational.json +++ b/test/case/statd/system/operational.json @@ -111,6 +111,18 @@ "mount-point": "/cfg", "size": "14073", "used": "67" + }, + { + "available": "51072", + "mount-point": "/run", + "size": "51200", + "used": "128" + }, + { + "available": "102160", + "mount-point": "/tmp", + "size": "102400", + "used": "240" } ], "load-average": { diff --git a/test/case/statd/system/system/run/df_-k_+run b/test/case/statd/system/system/run/df_-k_+run new file mode 100644 index 00000000..40e8f48b --- /dev/null +++ b/test/case/statd/system/system/run/df_-k_+run @@ -0,0 +1,2 @@ +Filesystem 1K-blocks Used Available Use% Mounted on +tmpfs 51200 128 51072 1% /run diff --git a/test/case/statd/system/system/run/df_-k_+tmp b/test/case/statd/system/system/run/df_-k_+tmp new file mode 100644 index 00000000..a628d3f5 --- /dev/null +++ b/test/case/statd/system/system/run/df_-k_+tmp @@ -0,0 +1,2 @@ +Filesystem 1K-blocks Used Available Use% Mounted on +tmpfs 102400 240 102160 1% /tmp diff --git a/test/case/webui/basic/test.py b/test/case/webui/basic/test.py new file mode 100644 index 00000000..c8f02687 --- /dev/null +++ b/test/case/webui/basic/test.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +""" +WebUI login and logout + +Smoke test that the WebUI is reachable, accepts admin credentials, +serves the dashboard while authenticated, and clears the session on +logout. Pure HTTP — no browser framework — so we only catch backend +and plumbing regressions (nginx down, Go service crashed, TLS or +RESTCONF auth broken, CSRF or session cookie logic broken, wrong +defaults). JS, htmx, and visual issues are deliberately out of +scope. +""" +import re +import time +import infamy +import requests + + +with infamy.Test() as test: + with test.step("Set up topology and wait for WebUI to come up"): + env = infamy.Env() + target = env.attach("target", "mgmt") + password = env.get_password("target") + + # Bracket the host so IPv6 link-local mgmt addresses + # (e.g. fe80::...%eth0) form a valid URL. + base = f"https://[{target.location.host}]" + + sess = requests.Session() + sess.verify = False # device ships a self-signed cert + + # env.attach() returns once NETCONF/RESTCONF is reachable, but + # nginx + the Go webui process may still be coming up. Poll + # /login briefly to ride out the early-boot window where + # default.conf's error_page would otherwise hand us /50x.html. + for _ in range(15): + try: + r = sess.get(f"{base}/login", timeout=10) + if r.status_code == 200 and 'name="csrf"' in r.text: + break + except requests.RequestException: + pass + time.sleep(1) + else: + test.fail("WebUI did not come up within 15s") + m = re.search(r'name="csrf"\s+value="([^"]+)"', r.text) + if not m: + test.fail("CSRF token field not found on /login") + login_csrf = m.group(1) + + with test.step("Authenticate as admin and load the dashboard"): + r = sess.post( + f"{base}/login", + data={"username": "admin", "password": password, "csrf": login_csrf}, + allow_redirects=False, + timeout=10, + ) + if r.status_code != 303: + test.fail(f"POST /login: expected 303, got {r.status_code}") + if "session" not in sess.cookies: + test.fail("session cookie not set after successful login") + + r = sess.get(f"{base}/", timeout=10) + if r.status_code != 200: + test.fail(f"GET /: expected 200, got {r.status_code}") + # base.html ships an htmx-config meta tag on every authenticated page. + if 'name="htmx-config"' not in r.text: + test.fail("htmx-config meta tag missing from /") + m = re.search(r'name="csrf-token"\s+content="([^"]+)"', r.text) + if not m: + test.fail("csrf-token meta tag not found on dashboard") + meta_csrf = m.group(1) + + with test.step("Logout clears the session cookie"): + r = sess.post( + f"{base}/logout", + data={"csrf": meta_csrf}, + allow_redirects=False, + timeout=10, + ) + if r.status_code != 303: + test.fail(f"POST /logout: expected 303, got {r.status_code}") + if "session" in sess.cookies: + test.fail("session cookie still present after logout") + + test.succeed()