Skip to main content

HLE Client

PyPI Python License CI

HomeLab Everywhere — Expose homelab services to the internet with built-in SSO authentication, WebSocket support, and webhook forwarding.

One command: hle tunnel create myapp http://localhost:8080

Your local service gets a public URL like myapp-x7k.hle.world with automatic HTTPS and SSO protection.

Install

curl -fsSL https://get.hle.world | sh

Installs via pipx (preferred), uv, or pip-in-venv. Supports --version:

curl -fsSL https://get.hle.world | sh -s -- --version 2610.14

To install the agent unattended (CI, cloud-init, Ansible), pass its token in HLE_AGENT_TOKEN rather than on the command line, so it stays out of ps and shell history:

curl -fsSL https://get.hle.world | HLE_AGENT_TOKEN=hle_xxxxx sh -s -- --agent

pipx

pipx install hle-client

Homebrew

brew install hle-world/tap/hle-client

Quick Start

  1. Sign up at hle.world and create an API key in the dashboard.

  2. Save your API key:

hle auth login

This opens the dashboard in your browser. Copy your key and paste it at the prompt. The key is saved to ~/.config/hle/config.toml.

  1. Expose a service:
hle tunnel create myapp http://localhost:8080

# Or forward webhooks from GitHub/Stripe:
hle tunnel webhook --path /hook/github --forward-to http://localhost:3000 --label github-hook
  1. See what this machine is doing:
hle status

CLI Usage

The CLI has one shape: hle <noun> <verb>. The nouns are tunnel, agent, daemon, firepuncher and auth, and the verbs are the same wherever they apply: list, get, create, set, delete. hle fp is a short alias for hle firepuncher. Older spellings (hle expose, hle config, hle service, hle forward, and verbs such as access add, pin status, share revoke, auth-mode --set, daemon uninstall --label, agent enroll) still work but are not listed in --help.

Every command that reads a resource accepts -o json.

hle tunnel create

Expose a local service to the internet. The label names the tunnel; the URL is the local service.

hle tunnel create ha http://localhost:8123                       # ha-x7k.hle.world
hle tunnel create app http://localhost:3000 --auth none          # Disable SSO
hle tunnel create app http://localhost:8080 --no-websocket       # Disable WS proxying
hle tunnel create app http://localhost:8080 --allow user@gmail.com
hle tunnel create app http://localhost:8080 --allow google:user@gmail.com --allow github:dev@co.com
hle tunnel create --apex --zone zone.example http://localhost:3000    # Serve at a custom zone root

Options:

  • --auth — Auth mode: sso (default) or none
  • --allow — Allow an email to access the tunnel (repeatable). Format: email or provider:email
  • --zone — Custom zone to publish under (e.g. zone.example)
  • --apex — Serve at the bare zone root instead of a subdomain. Requires --zone
  • --websocket/--no-websocket — Enable/disable WebSocket proxying (default: enabled)
  • --verify-ssl — Verify the local service's TLS certificate (default: off, accepts self-signed)
  • --upstream-basic-auth USER:PASS — Inject Basic Auth into requests forwarded to the local service (also reads HLE_UPSTREAM_BASIC_AUTH)
  • --forward-host — Forward the browser's Host header to the local service
  • --option KEY=VALUE — Server-interpreted parameter, passed through verbatim (repeatable)
  • --response-timeout SECONDS — How long the relay waits for the local service to respond (default 30, max 1200)
  • --api-key — API key (also reads HLE_API_KEY env var, then config file)

hle daemon install tunnel takes the same options, so any tunnel you can run you can also install as a service. --upstream-basic-auth is written to an owner-only environment file the service reads at start — never to its command line or to the service manager's visible environment (systemd exposes Environment= over D-Bus) — and the service file is then readable by its owner only.

hle tunnel preflight

Check whether a service will work as a tunnel before creating one. Probes it the way a tunnel would and reports what would break. Changes nothing.

hle tunnel preflight http://localhost:8123
hle tunnel preflight https://192.168.1.10:8006 --forward-host

hle tunnel webhook

Forward incoming webhooks to a local service.

hle tunnel webhook --path /hook/github --forward-to http://localhost:3000 --label github-hook
hle tunnel webhook --path /hook/stripe --forward-to http://localhost:4000/stripe --label stripe-hook

Options:

  • --path — Webhook path prefix, e.g. /webhook/github (required). Cannot be /
  • --forward-to — Local URL to forward webhooks to (required)
  • --label — Webhook label, e.g. github-hook (required)
  • --response-timeout SECONDS — How long the relay waits for the local service to respond (default 120 for webhooks, max 1200)
  • --api-key — API key (also reads HLE_API_KEY env var, then config file)

Webhook tunnels bypass SSO so external services (GitHub, Stripe, etc.) can deliver payloads without authentication.

Inspecting and securing tunnels

Tunnel subcommands take a label (resolved to <label>-<user_code>) or a full subdomain. Labels may contain hyphens (home-assistant).

hle tunnel list                       # List your active tunnels
hle tunnel get ha                     # Full status for one tunnel (auth, rules, PIN, …)
hle tunnel delete ha                  # Remove a tunnel's record

hle tunnel set

hle tunnel set ha --auth sso          # SSO gate on
hle tunnel set ha --auth none         # Tunnel becomes public

hle tunnel access — SSO email allow-list

hle tunnel access list ha                                # List rules
hle tunnel access create ha friend@example.com           # Allow an email
hle tunnel access create ha dev@co.com --provider github # Require GitHub SSO
hle tunnel access delete ha 42                           # Remove rule by ID
hle tunnel access replace ha google:alice@x.com github:bob@y.com   # Declarative — adds + prunes
hle tunnel access replace ha --clear                     # Remove all rules

replace is declarative: rules on the server but not in the args are removed. hle tunnel create --allow remains additive (never prunes) for ad-hoc sessions.

hle tunnel pin

hle tunnel pin set ha          # Set a PIN (prompts for 4-8 digits)
hle tunnel pin get ha          # Check PIN status
hle tunnel pin delete ha       # Remove PIN

hle tunnel basic-auth

hle tunnel basic-auth set ha          # Prompts for username + password (min 8 chars)
hle tunnel basic-auth get ha          # Check Basic Auth status
hle tunnel basic-auth delete ha       # Remove Basic Auth
hle tunnel share create ha                        # 24h link (default)
hle tunnel share create ha --duration 1h          # 1-hour link
hle tunnel share create ha --max-uses 5           # Limited uses
hle tunnel share create ha --name "demo"          # Name it for reference
hle tunnel share list ha                          # List share links
hle tunnel share delete ha 42                     # Revoke a link

hle agent

One process, many tunnels, managed from the dashboard. Create an agent at hle.world/dashboard, copy its token, and enrol this machine. Endpoints added or removed in the dashboard take effect without a restart.

hle auth login                      # Paste the agent token at the prompt
hle agent run                       # Run in the foreground
hle agent status                    # Is a token configured here?
hle agent status --ready            # Exit 0 only while this agent is connected
hle agent list                      # Agents on your account, and whether they are online
hle agent services                  # Services this machine can see and could expose
hle agent logout                    # Remove the saved token

For scripts, an enrollment token can be read from stdin instead of an argument, so it never appears in the process list or shell history:

printf %s "$HLE_AGENT_TOKEN" | hle agent enroll --stdin

hle agent status --ready is meant for supervisors: it exits 0 only while the running agent has a live control connection (the recorded process must still be alive), and exits 1 with a one-line reason otherwise. A Kubernetes readinessProbe uses it.

For an agent that survives reboots, install it as a service instead: hle daemon install agent.

hle firepuncher

Forward TCP ports from a remote agent to this machine, e.g. SSH into a box that has no public address. hle fp is the short alias.

hle firepuncher rpi 22 --port 9922        # then: ssh -p 9922 root@localhost
hle firepuncher nas 192.168.1.50:5432     # Postgres on the agent's LAN
hle firepuncher rpi 22 nas:445            # Two forwards, one command
hle firepuncher rpi 22 -- ssh -p '{port}' me@127.0.0.1   # Run a command, tear down on exit

The first argument is the agent name (see hle agent list); each target is a port or host:port as the agent sees it. The agent must allow each target; adjust per agent in the dashboard. By default the agent's own networks are allowed, but the cloud metadata service (169.254.169.254, 169.254.170.2, fd00:ec2::254) is always refused, even when a rule covers it, so a forward cannot hand over the instance's IAM credentials. An operator on the agent host can opt back in with HLE_FIREPUNCHER_ALLOW_METADATA=true.

On iPad/iOS (a-Shell) the shell cannot start a second Python process, so a trailing Python command is refused with exit 126: run the forward in one window and the Python command in another, with HLE_FP_PORT set to the local port.

hle daemon

Install and manage a background service, so a tunnel, agent or forward survives reboots and restarts on failure. Uses systemd on Linux, launchd on macOS and rc.d on FreeBSD/pfSense (Windows is unsupported). The API key is read at runtime from ~/.config/hle/config.toml (or HLE_API_KEY) and is never written into the service file.

# One always-on tunnel
#   Linux → /etc/systemd/system/hle-tv.service (or ~/.config/systemd/user/ with --user)
#   macOS → /Library/LaunchDaemons/world.hle.tv.plist (or ~/Library/LaunchAgents/ with --user)
sudo hle daemon install tunnel tv http://localhost:9998
hle daemon install tunnel tv http://localhost:9998 --user           # per-user, no sudo
sudo hle daemon install tunnel prox https://192.168.100.200:8006 --zone pr.zone.example

# The dashboard-driven agent
sudo hle daemon install agent

# A permanent forward
hle daemon install firepuncher rpi 22 --port 9922 --user

hle daemon list                     # Installed hle services, in both scopes
hle daemon status tv                # One service's status
hle daemon logs agent               # Its log (-f to follow, -n 200 for more)
hle daemon restart tv               # Restart one, or --all
hle daemon refresh --all            # Rebuild service files after an upgrade
hle daemon delete tv                # Stop, disable, remove

hle update

Update the client to the latest version, regardless of how it was installed (pipx, uv tool, the installer's venv, or pip). It detects the install method and runs the right upgrade, then offers to restart any installed services.

Some installs are owned by something outside the client, and hle update prints how to upgrade those instead of guessing:

  • Homebrew — brew upgrade hle-client
  • Docker — pull the new image: docker compose pull && docker compose up -d
  • Home Assistant add-on — update it from Settings → Add-ons
  • Kubernetes — helm upgrade the hle-operator chart
  • Editable / source checkout — this is a development install; update it with git pull
hle update            # upgrade to the latest release
hle update --check    # just report current vs. latest, don't change anything
hle update --version 2610.14   # pin an exact version

hle auth

Manage the credentials saved on this machine. Account API keys and agent tokens look the same (hle_ plus 32 hex characters), so hle auth login asks the server which one you pasted and saves it in the right place.

hle auth login                              # Paste any key (opens dashboard)
hle auth login hle_...                      # Save it non-interactively
hle auth login --api-key <KEY>              # The same, as a flag
hle auth login --agent-token <TOKEN>        # Force agent enrolment (skips detection)
hle auth status                             # Which credentials exist, and where from
hle auth logout                             # Remove saved key

hle status

Everything this machine is set up to do, on one screen: credentials, installed services, published tunnels, and agents.

hle status
hle -o json status

Server notices

While a tunnel is connected, the relay can push informational messages that the client prints (e.g. ✓ Auto-protect added you@example.com via Google SSO). Wording is server-controlled so new notices do not require a client release.

Structured events for supervisors

A program that runs hle as a child process should not parse that text. Pass --events jsonl to hle tunnel create, hle tunnel webhook or hle agent run. stdout then carries one JSON object per line: connected, registered, disconnected, notice, error and fatal. Everything meant for a person goes to stderr. The schema is in docs/events.md.

Global Options

Declared once at the root and accepted anywhere on the line:

hle --version          # Show version
hle --debug ...        # Enable debug logging
hle -o json ...        # Machine-readable output (also accepted after the command)
hle --quiet ...        # Only print errors
hle --no-input ...     # Never prompt; fail instead (for scripts)

Configuration

The HLE client stores configuration in ~/.config/hle/config.toml, written by hle auth login. It holds a single api_key entry.

API key resolution order:

  1. --api-key CLI flag
  2. HLE_API_KEY environment variable
  3. ~/.config/hle/config.toml

Agent self-update policy

An agent can also be told to update itself from the dashboard (the update_request control frame). That is on by default. An operator can refuse it on a host with HLE_REMOTE_UPDATE=off or remote_update = false in ~/.config/hle/agent.toml; every update request is then refused with refused:remote-update-disabled. An update to an older release is refused with refused:downgrade unless the request itself asks for a rollback explicitly (allow_downgrade) or HLE_ALLOW_DOWNGRADE=true (or allow_downgrade = true in agent.toml) is set. Setting HLE_ALLOW_DOWNGRADE=false (or allow_downgrade = false) refuses downgrades even when the request asks for one. A downgrade never goes below the client's minimum supported release. A request may also carry the wheel's sha256 (wheel_sha256), which is checked the same way; without one, updates work as before. To pin releases, point HLE_UPDATE_MANIFEST (or update_manifest in agent.toml) at a JSON object mapping each version to the sha256 of its wheel; the wheel is downloaded, hashed and only then installed, so a wheel swapped on a mirror or index is not activated.

Kubernetes agents

An agent running inside a cluster only tunnels to Kubernetes Services; a raw URL could otherwise publish the API server, the cloud metadata service or a node. The agent targets <svc>, <svc>.<ns>, <svc>.<ns>.svc and <svc>.<ns>.svc.<cluster-domain> names. A bare or two-label name is rewritten to its absolute in-cluster FQDN (with a trailing dot, so the pod's DNS search domains are never consulted) before it is resolved, so an ordinary public domain is never reached through the search path. The agent refuses the Kubernetes API by name — including a hostname-form KUBERNETES_SERVICE_HOST, compared lower-cased, dot-stripped and IDNA-normalised — and by the addresses that the API Service name and a hostname-form service host resolve to. That resolved address set is seeded from the literals in KUBERNETES_SERVICE_HOST and KUBERNETES_PORT_443_TCP_ADDR when they are IPs, then refreshed in the background; the agent waits briefly (bounded) for the first refresh before it starts any endpoint, and a failed or partial refresh is retried with a short backoff and never clears the by-name refusal (a partial refresh keeps the answers that did resolve). The agent also refuses link-local and cloud metadata addresses (169.254.0.0/16, 100.100.100.200, 192.0.0.192, 168.63.129.16, fd00:ec2::254), the unspecified address and 0.0.0.0/8, loopback and any node address, whatever the settings say. IPv6 addresses that wrap an IPv4 address (::ffff:0:0/96, 6to4, NAT64, Teredo and IPv4-compatible ::a.b.c.d) are unwrapped and the embedded address checked too; an IPv6 literal carrying a zone/scope id (fd00::10%1) is refused outright. A hostname must be an ASCII [a-z0-9.-] name: unicode (there is no legitimate use after IDNA) and SRV-style _… labels are refused. When the guard canonicalises a name, the authority the dashboard asked for is kept as the upstream Host header, so host-allowlisting services are unaffected. TLS connections to a canonicalised name verify the certificate against that original host (without the trailing dot canonicalisation added), since Python's TLS stack does not strip the dot. Environment proxies are not used for in-cluster targets: a cluster Service must never be carried by an HTTP(S)_PROXY, which cannot reach the canonical name. Upstream TLS verification still honours SSL_CERT_FILE and SSL_CERT_DIR even though proxy settings are ignored, so a private in-cluster CA can be trusted. Firepuncher is disabled on Kubernetes agents unless HLE_FIREPUNCHER_ENABLED is truthy; when it is on, every forward target is checked by the same guard and dialled at the exact address that was checked.

These settings are read from the environment, with safe defaults:

  • KUBERNETES_SERVICE_HOST — its presence marks the process as in-cluster.
  • KUBERNETES_PORT_443_TCP_ADDR — the API Service's ClusterIP as the kubelet exports it; when it is an IP it seeds the always-refused set. Some managed clusters set it (and KUBERNETES_SERVICE_HOST) to a hostname instead; then the API is covered by name and by the addresses that hostname resolves to, which the agent waits for briefly before its first reconcile.
  • HLE_INSTALL_METHOD — set to kubernetes to declare a cluster agent where the variable above is absent; it also overrides how the install is classified for the dashboard.
  • HLE_CLUSTER_DOMAIN — the cluster DNS domain (default cluster.local).
  • HLE_ALLOW_RAW_URLS — true to also allow raw URLs (IP literals and non-cluster hostnames). The always-refused targets above stay refused.
  • HLE_NODE_IP — the node's address, from the downward API; honoured when set.
  • HLE_POD_NAMESPACE — the pod's namespace, used to expand a bare <svc>. Falls back to the service-account namespace file; if neither is available a bare name is refused.
  • HLE_FIREPUNCHER_ENABLED — true to allow firepuncher on a cluster agent.
  • HLE_DISCOVERY_EXCLUDE_NAMESPACES — comma-separated namespaces to hide from discovery and refuse as endpoint targets, on top of the built-in skips (kube-system, kube-public, kube-node-lease). The check runs on the canonical Service name, so <svc>, <svc>.<ns>, <svc>.<ns>.svc and the full cluster-domain form are all refused alike.

This guard is defence in depth, not the boundary. It validates at connect time, so:

  • DNS answers can change later. A name that resolved to an allowed address when the endpoint started can rebind to a refused one, and an ExternalName Service follows whatever its target domain says. The transport resolves the in-cluster name again at request time.
  • In-cluster names are only as trustworthy as the cluster's DNS. A malicious or compromised DNS entry can point an allowed Service name at any address.
  • HLE_NODE_IP covers the node only when it is set; the API server is covered by name (including the hostname form of KUBERNETES_SERVICE_HOST), by the literals in KUBERNETES_SERVICE_HOST and KUBERNETES_PORT_443_TCP_ADDR when they are IPs, and by the addresses kubernetes.default.svc.<cluster-domain> and a hostname-form service host resolve to — not by every address it may be reachable on.
  • On managed clusters KUBERNETES_SERVICE_HOST is sometimes a hostname (AKS), not an IP. The guard refuses that hostname by name and resolves it and the API Service name in the background (at most every five minutes, retrying with backoff if DNS is unavailable), adding the answers to the always-refused set. Before the first endpoint starts the agent waits briefly for that first refresh, and the set is seeded from KUBERNETES_PORT_443_TCP_ADDR when it is an IP. If cluster DNS is unavailable the by-name and seeded-IP checks still hold, but an address-only route to the API is not covered.

Make the network the real boundary with an egress NetworkPolicy that denies the agent pod the addresses it must never reach:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: hle-agent-egress
  namespace: hle
spec:
  podSelector:
    matchLabels:
      app.kubernetes.io/name: hle-agent
  policyTypes: [Egress]
  egress:
    # DNS. If NodeLocal DNSCache is installed its link-local VIP answers here
    # too; it is outside the cluster DNS pods, so allow it separately.
    - to:
      - namespaceSelector: {}
      ports:
        - { protocol: UDP, port: 53 }
        - { protocol: TCP, port: 53 }
    - to:
      - ipBlock:
          cidr: 169.254.20.10/32   # NodeLocal DNSCache (when present)
      ports:
        - { protocol: UDP, port: 53 }
        - { protocol: TCP, port: 53 }
    # Service endpoints in this namespace only.
    - to:
      - podSelector: {}
    # The relay over HTTPS. On a managed cluster with a public API endpoint the
    # API address is outside the cluster CIDRs below; list the real endpoint(s)
    # from `kubectl get endpoints kubernetes -o wide` (or pin the relay's own
    # address) so 443 to the API is denied too.
    - to:
      - ipBlock:
          cidr: 0.0.0.0/0
          except:
            - 169.254.0.0/16        # cloud metadata
            - 100.100.100.200/32    # Alibaba metadata
            - 192.0.0.192/32        # Oracle metadata
            - 168.63.129.16/32      # Azure WireServer
            - 203.0.113.10/32       # API server endpoint (kubectl get endpoints kubernetes -o wide)
            - 10.0.0.0/8            # node + API server CIDR (adjust)
            - 172.16.0.0/12
            - 192.168.0.0/16
      ports:
        - { protocol: TCP, port: 443 }

Adjust the cluster CIDRs and the API endpoint address to your own node, service and API ranges. kubectl get endpoints kubernetes -o wide and kubectl get endpointslices -n default -l kubernetes.io/service-name=kubernetes both print the API server's endpoint addresses — the guard does not read Endpoints or EndpointSlices, it resolves the kubernetes.default.svc.<cluster-domain> Service name (and a hostname-form KUBERNETES_SERVICE_HOST) and refuses the answers, so use those endpoint addresses to choose the except entries above; the 203.0.113.10/32 line above is a placeholder. On clusters that expose the API on a public address that is not inside the cluster CIDRs, this explicit except is what keeps 443 to the API closed. Denying the metadata, node and API ranges at the network layer holds even if the guard is bypassed or its background resolution fails.

Development

git clone https://github.com/hle-world/hle-client.git
cd hle-client
uv venv && source .venv/bin/activate
uv pip install -e ".[dev]"

# Run tests
pytest

# Lint
ruff check src/ tests/
ruff format --check src/ tests/

License

MIT — see LICENSE.

Metadata

Release files for hle-client 2610.14

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for hle-client 2610.14
File Size Uploaded Provenance
hle_client-2610.14.tar.gz 558.4 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for hle-client 2610.14
File Interpreter ABI Platform
hle_client-2610.14-py3-none-any.whl Python 3 none any Details

Total release size: 847.3 kB

Release files / hle_client-2610.14.tar.gz

Download URL hle_client-2610.14.tar.gz
Size 558.4 kB
Tags Source
SHA-256 checksum
How to use checksums
95c0b44090c82d75e8a9ee326a70dadc8ae035e7d8b6c5ed97d0a2efbd1bb78f
BLAKE2b-256 checksum
How to use checksums
151ba3b8264af780b4a4e8e239cc11d7f710b606c778d46e04f414a90a83c561
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.16

Release files / hle_client-2610.14-py3-none-any.whl

Download URL hle_client-2610.14-py3-none-any.whl
Size 288.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
0c1137428c924be364399b66f162df41757dd92e50d3212f46f673fea6334367
BLAKE2b-256 checksum
How to use checksums
c2af473c7d63137f62122998c5a1eaa384191806b4692fd9c23961a428c43a5d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.16

Security for hle-client 2610.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Release history Release notifications | RSS feed

This release

2610.14 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page