mirror of
https://github.com/openziti/zrok.git
synced 2025-02-27 23:51:38 +01:00
Merge pull request #847 from openziti/py-sdk-example-proxy
add a py sdk example for a proxy backend
This commit is contained in:
commit
621dfab56a
@ -4,6 +4,11 @@
|
|||||||
|
|
||||||
CHANGE: Add usage hint in `zrok config get --help` to clarify how to list all valid `configName` and their current values by running `zrok status`.
|
CHANGE: Add usage hint in `zrok config get --help` to clarify how to list all valid `configName` and their current values by running `zrok status`.
|
||||||
|
|
||||||
|
CHANGE: The Python SDK's `Overview()` function was refactored as a class method (https://github.com/openziti/zrok/pull/846).
|
||||||
|
|
||||||
|
FEATURE: The Python SDK now includes a `ProxyShare` class providing an HTTP proxy for public and private shares and a
|
||||||
|
Jupyter notebook example (https://github.com/openziti/zrok/pull/847).
|
||||||
|
|
||||||
## v0.4.46
|
## v0.4.46
|
||||||
|
|
||||||
FEATURE: Linux service template for systemd user units (https://github.com/openziti/zrok/pull/818)
|
FEATURE: Linux service template for systemd user units (https://github.com/openziti/zrok/pull/818)
|
||||||
|
48
sdk/python/examples/proxy/README.md
Normal file
48
sdk/python/examples/proxy/README.md
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
|
||||||
|
# zrok Python Proxy Example
|
||||||
|
|
||||||
|
This demonstrates using the ProxyShare class to forward requests from the public frontend to a target URL.
|
||||||
|
|
||||||
|
## Run the Example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LOG_LEVEL=INFO python ./proxy.py http://127.0.0.1:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
2025-01-29 06:37:00,884 - __main__ - INFO - === Starting proxy server ===
|
||||||
|
2025-01-29 06:37:00,884 - __main__ - INFO - Target URL: http://127.0.0.1:3000
|
||||||
|
2025-01-29 06:37:01,252 - __main__ - INFO - Access proxy at: https://24x0pq7s6jr0.zrok.example.com:443
|
||||||
|
2025-01-29 06:37:07,981 - zrok.proxy - INFO - Share 24x0pq7s6jr0 released
|
||||||
|
```
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
from zrok.proxy import ProxyShare
|
||||||
|
import zrok
|
||||||
|
|
||||||
|
# Load the environment
|
||||||
|
root = zrok.environment.root.Load()
|
||||||
|
|
||||||
|
# Create a temporary proxy share (will be cleaned up on exit)
|
||||||
|
proxy = ProxyShare.create(root=root, target="http://my-target-service")
|
||||||
|
|
||||||
|
# Access the proxy's endpoints and token
|
||||||
|
print(f"Access proxy at: {proxy.endpoints}")
|
||||||
|
proxy.run()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating a Reserved Proxy Share
|
||||||
|
|
||||||
|
To create a share token that persists and can be reused, run the example `proxy.py --unique-name my-persistent-proxy`. If the unique name already exists it will be reused. Here's how it works:
|
||||||
|
|
||||||
|
```python
|
||||||
|
proxy = ProxyShare.create(
|
||||||
|
root=root,
|
||||||
|
target="http://127.0.0.1:3000",
|
||||||
|
unique_name="my-persistent-proxy"
|
||||||
|
)
|
||||||
|
```
|
100
sdk/python/examples/proxy/proxy.ipynb
Normal file
100
sdk/python/examples/proxy/proxy.ipynb
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
{
|
||||||
|
"cells": [
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "52d42237",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"! pip install zrok"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "0a33915c",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"\n",
|
||||||
|
"import zrok\n",
|
||||||
|
"from zrok.proxy import ProxyShare\n"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "0db6b615",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"\n",
|
||||||
|
"target_url = \"http://127.0.0.1:8000/\"\n",
|
||||||
|
"unique_name = \"myuniquename\" # a name to reuse each run or 'None' for random\n",
|
||||||
|
"share_mode = \"public\" # \"public\" or \"private\"\n",
|
||||||
|
"frontend = \"public\" # custom domain frontend or \"public\"\n",
|
||||||
|
"\n",
|
||||||
|
"if unique_name.lower() == \"none\":\n",
|
||||||
|
" unique_name = None\n"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "d3efcfa5",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"\n",
|
||||||
|
"zrok_env = zrok.environment.root.Load() # Load the environment from ~/.zrok\n",
|
||||||
|
"\n",
|
||||||
|
"proxy_share = ProxyShare.create(\n",
|
||||||
|
" root=zrok_env,\n",
|
||||||
|
" target=target_url,\n",
|
||||||
|
" frontends=[frontend],\n",
|
||||||
|
" share_mode=share_mode,\n",
|
||||||
|
" unique_name=unique_name,\n",
|
||||||
|
" verify_ssl=True # Set 'False' to skip SSL verification\n",
|
||||||
|
")\n"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "21966557",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"\n",
|
||||||
|
"if share_mode == \"public\":\n",
|
||||||
|
" print(f\"Access proxy at: {', '.join(proxy_share.endpoints)}\")\n",
|
||||||
|
"elif share_mode == \"private\":\n",
|
||||||
|
" print(f\"Run a private access frontend: 'zrok access private {proxy_share.token}'\")\n",
|
||||||
|
"\n",
|
||||||
|
"proxy_share.run()\n"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"metadata": {
|
||||||
|
"kernelspec": {
|
||||||
|
"display_name": "Python 3",
|
||||||
|
"language": "python",
|
||||||
|
"name": "python3"
|
||||||
|
},
|
||||||
|
"language_info": {
|
||||||
|
"codemirror_mode": {
|
||||||
|
"name": "ipython",
|
||||||
|
"version": 3
|
||||||
|
},
|
||||||
|
"file_extension": ".py",
|
||||||
|
"mimetype": "text/x-python",
|
||||||
|
"name": "python",
|
||||||
|
"nbconvert_exporter": "python",
|
||||||
|
"pygments_lexer": "ipython3",
|
||||||
|
"version": "3.11.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nbformat": 4,
|
||||||
|
"nbformat_minor": 5
|
||||||
|
}
|
62
sdk/python/examples/proxy/proxy.py
Normal file
62
sdk/python/examples/proxy/proxy.py
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
"""
|
||||||
|
Example of using zrok's proxy facility to create an HTTP proxy server.
|
||||||
|
|
||||||
|
This example demonstrates how to:
|
||||||
|
1. Create a proxy share (optionally with a unique name for persistence)
|
||||||
|
2. Handle HTTP requests/responses through the proxy
|
||||||
|
3. Automatically clean up non-reserved shares on exit
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import zrok
|
||||||
|
from zrok.proxy import ProxyShare
|
||||||
|
|
||||||
|
# Setup logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point."""
|
||||||
|
parser = argparse.ArgumentParser(description='Start a zrok proxy server')
|
||||||
|
parser.add_argument('target_url', help='Target URL to proxy requests to')
|
||||||
|
parser.add_argument('-n', '--unique-name', help='Unique name for the proxy instance')
|
||||||
|
parser.add_argument('-f', '--frontends', nargs='+', help='One or more space-separated frontends to use')
|
||||||
|
parser.add_argument('-k', '--insecure', action='store_false', dest='verify_ssl', default=True,
|
||||||
|
help='Skip SSL verification')
|
||||||
|
parser.add_argument('-s', '--share-mode', default='public', choices=['public', 'private'],
|
||||||
|
help='Share mode (default: public)')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
logger.info("=== Starting proxy server ===")
|
||||||
|
logger.info(f"Target URL: {args.target_url}")
|
||||||
|
logger.info(f"Share mode: {args.share_mode}")
|
||||||
|
|
||||||
|
# Load environment and create proxy share
|
||||||
|
root = zrok.environment.root.Load()
|
||||||
|
proxy_share = ProxyShare.create(
|
||||||
|
root=root,
|
||||||
|
target=args.target_url,
|
||||||
|
share_mode=args.share_mode,
|
||||||
|
unique_name=args.unique_name,
|
||||||
|
frontends=args.frontends,
|
||||||
|
verify_ssl=args.verify_ssl
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log access information and start the proxy
|
||||||
|
if args.share_mode == "public":
|
||||||
|
logger.info(f"Access proxy at: {', '.join(proxy_share.endpoints)}")
|
||||||
|
elif args.share_mode == "private":
|
||||||
|
logger.info(f"Run a private access frontend: 'zrok access private {proxy_share.token}'")
|
||||||
|
proxy_share.run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
@ -19,6 +19,7 @@ class Metadata:
|
|||||||
@dataclass
|
@dataclass
|
||||||
class Config:
|
class Config:
|
||||||
ApiEndpoint: str = ""
|
ApiEndpoint: str = ""
|
||||||
|
DefaultFrontend: str = ""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -137,7 +138,10 @@ def __loadConfig() -> Config:
|
|||||||
cf = configFile()
|
cf = configFile()
|
||||||
with open(cf) as f:
|
with open(cf) as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
return Config(ApiEndpoint=data["api_endpoint"])
|
return Config(
|
||||||
|
ApiEndpoint=data["api_endpoint"],
|
||||||
|
DefaultFrontend=data["default_frontend"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def isEnabled() -> bool:
|
def isEnabled() -> bool:
|
||||||
|
203
sdk/python/sdk/zrok/zrok/proxy.py
Normal file
203
sdk/python/sdk/zrok/zrok/proxy.py
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
"""
|
||||||
|
Proxy share management functionality for the zrok SDK.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import atexit
|
||||||
|
import logging
|
||||||
|
import urllib.parse
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from flask import Flask, Response, request
|
||||||
|
from waitress import serve
|
||||||
|
from zrok.environment.root import Root
|
||||||
|
from zrok.model import (PROXY_BACKEND_MODE, PUBLIC_SHARE_MODE, PRIVATE_SHARE_MODE, Share,
|
||||||
|
ShareRequest)
|
||||||
|
from zrok.overview import Overview
|
||||||
|
from zrok.share import CreateShare, ReleaseReservedShare
|
||||||
|
|
||||||
|
import zrok
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# List of hop-by-hop headers that should not be returned to the viewer
|
||||||
|
HOP_BY_HOP_HEADERS = {
|
||||||
|
'connection',
|
||||||
|
'keep-alive',
|
||||||
|
'proxy-authenticate',
|
||||||
|
'proxy-authorization',
|
||||||
|
'te',
|
||||||
|
'trailers',
|
||||||
|
'transfer-encoding',
|
||||||
|
'upgrade'
|
||||||
|
}
|
||||||
|
|
||||||
|
# The proxy only listens on the zrok socket, the port used to initialize the Waitress server is not actually bound or
|
||||||
|
# listening
|
||||||
|
DUMMY_PORT = 18081
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProxyShare:
|
||||||
|
"""Represents a proxy share with its configuration and state."""
|
||||||
|
root: Root
|
||||||
|
share: Share
|
||||||
|
target: str
|
||||||
|
unique_name: Optional[str] = None
|
||||||
|
_cleanup_registered: bool = False
|
||||||
|
_app: Optional[Flask] = None
|
||||||
|
verify_ssl: bool = True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(cls, root: Root, target: str, share_mode: str = PUBLIC_SHARE_MODE, unique_name: Optional[str] = None,
|
||||||
|
frontends: Optional[List[str]] = None, verify_ssl: bool = True) -> 'ProxyShare':
|
||||||
|
"""
|
||||||
|
Create a new proxy share, handling reservation and cleanup logic based on unique_name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
root: The zrok root environment
|
||||||
|
target: Target URL or service to proxy to
|
||||||
|
unique_name: Optional unique name for a reserved share
|
||||||
|
frontends: Optional list of frontends to use, takes precedence over root's default_frontend
|
||||||
|
verify_ssl: Whether to verify SSL certificates when forwarding requests.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ProxyShare instance configured with the created share
|
||||||
|
"""
|
||||||
|
# First check if we have an existing reserved share with this name
|
||||||
|
if unique_name:
|
||||||
|
existing_share = cls._find_existing_share(root, unique_name)
|
||||||
|
if existing_share:
|
||||||
|
logger.debug(f"Found existing share with token: {existing_share.Token}")
|
||||||
|
return cls(
|
||||||
|
root=root,
|
||||||
|
share=existing_share,
|
||||||
|
target=target,
|
||||||
|
unique_name=unique_name,
|
||||||
|
verify_ssl=verify_ssl
|
||||||
|
)
|
||||||
|
|
||||||
|
# Compose the share request
|
||||||
|
share_frontends = []
|
||||||
|
if share_mode == PUBLIC_SHARE_MODE:
|
||||||
|
if frontends:
|
||||||
|
share_frontends = frontends
|
||||||
|
elif root.cfg and root.cfg.DefaultFrontend:
|
||||||
|
share_frontends = [root.cfg.DefaultFrontend]
|
||||||
|
else:
|
||||||
|
share_frontends = ['public']
|
||||||
|
|
||||||
|
share_request = ShareRequest(
|
||||||
|
BackendMode=PROXY_BACKEND_MODE,
|
||||||
|
ShareMode=share_mode,
|
||||||
|
Target=target,
|
||||||
|
Frontends=share_frontends,
|
||||||
|
Reserved=True
|
||||||
|
)
|
||||||
|
if unique_name:
|
||||||
|
share_request.UniqueName = unique_name
|
||||||
|
|
||||||
|
# Create the share
|
||||||
|
share = CreateShare(root=root, request=share_request)
|
||||||
|
if share_mode == PUBLIC_SHARE_MODE:
|
||||||
|
logger.debug(f"Created new proxy share with endpoints: {', '.join(share.FrontendEndpoints)}")
|
||||||
|
elif share_mode == PRIVATE_SHARE_MODE:
|
||||||
|
logger.debug(f"Created new private share with token: {share.Token}")
|
||||||
|
|
||||||
|
# Create class instance and setup cleanup-at-exit if we reserved a random share token
|
||||||
|
instance = cls(
|
||||||
|
root=root,
|
||||||
|
share=share,
|
||||||
|
target=target,
|
||||||
|
unique_name=unique_name,
|
||||||
|
verify_ssl=verify_ssl
|
||||||
|
)
|
||||||
|
if not unique_name:
|
||||||
|
instance.register_cleanup()
|
||||||
|
return instance
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _find_existing_share(root: Root, unique_name: str) -> Optional[Share]:
|
||||||
|
"""Find an existing share with the given unique name."""
|
||||||
|
overview = Overview.create(root=root)
|
||||||
|
for env in overview.environments:
|
||||||
|
if env.environment.z_id == root.env.ZitiIdentity:
|
||||||
|
for share in env.shares:
|
||||||
|
if share.token == unique_name:
|
||||||
|
return Share(Token=share.token, FrontendEndpoints=[share.frontend_endpoint])
|
||||||
|
return None
|
||||||
|
|
||||||
|
def register_cleanup(self):
|
||||||
|
"""Register cleanup handler to release randomly generated shares on exit."""
|
||||||
|
if not self._cleanup_registered:
|
||||||
|
def cleanup():
|
||||||
|
try:
|
||||||
|
ReleaseReservedShare(root=self.root, shr=self.share)
|
||||||
|
logger.info(f"Share {self.share.Token} released")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error during cleanup: {e}")
|
||||||
|
|
||||||
|
# Register for normal exit only
|
||||||
|
atexit.register(cleanup)
|
||||||
|
self._cleanup_registered = True
|
||||||
|
return cleanup # Return the cleanup function for reuse
|
||||||
|
|
||||||
|
def _create_app(self) -> Flask:
|
||||||
|
"""Create and configure the Flask app for proxying."""
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
@app.route('/', defaults={'path': ''}, methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'])
|
||||||
|
@app.route('/<path:path>', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'])
|
||||||
|
def proxy(path):
|
||||||
|
# Construct the target URL
|
||||||
|
url = urllib.parse.urljoin(self.target, path)
|
||||||
|
|
||||||
|
# Forward the request
|
||||||
|
resp = requests.request(
|
||||||
|
method=request.method,
|
||||||
|
url=url,
|
||||||
|
headers={key: value for (key, value) in request.headers
|
||||||
|
if key.lower() not in HOP_BY_HOP_HEADERS},
|
||||||
|
data=request.get_data(),
|
||||||
|
cookies=request.cookies,
|
||||||
|
allow_redirects=False,
|
||||||
|
stream=True,
|
||||||
|
verify=self.verify_ssl
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create the response
|
||||||
|
excluded_headers = HOP_BY_HOP_HEADERS.union({'host'})
|
||||||
|
headers = [(name, value) for (name, value) in resp.raw.headers.items()
|
||||||
|
if name.lower() not in excluded_headers]
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
resp.iter_content(chunk_size=10*1024),
|
||||||
|
status=resp.status_code,
|
||||||
|
headers=headers
|
||||||
|
)
|
||||||
|
return app
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""Run the proxy server."""
|
||||||
|
if not self._app:
|
||||||
|
self._app = self._create_app()
|
||||||
|
|
||||||
|
zrok_opts: Dict[str, Any] = {}
|
||||||
|
zrok_opts['cfg'] = zrok.decor.Opts(root=self.root, shrToken=self.token, bindPort=DUMMY_PORT)
|
||||||
|
|
||||||
|
@zrok.decor.zrok(opts=zrok_opts)
|
||||||
|
def run_server():
|
||||||
|
serve(self._app, port=DUMMY_PORT, _quiet=True)
|
||||||
|
|
||||||
|
run_server()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def endpoints(self) -> List[str]:
|
||||||
|
"""Get the frontend endpoints for this share."""
|
||||||
|
return self.share.FrontendEndpoints
|
||||||
|
|
||||||
|
@property
|
||||||
|
def token(self) -> str:
|
||||||
|
"""Get the share token."""
|
||||||
|
return self.share.Token
|
Loading…
Reference in New Issue
Block a user