mirror of
https://github.com/openziti/zrok.git
synced 2025-02-27 15:42:19 +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
CHANGELOG.md
sdk/python
@ -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: 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
|
||||
|
||||
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
|
||||
class Config:
|
||||
ApiEndpoint: str = ""
|
||||
DefaultFrontend: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -137,7 +138,10 @@ def __loadConfig() -> Config:
|
||||
cf = configFile()
|
||||
with open(cf) as f:
|
||||
data = json.load(f)
|
||||
return Config(ApiEndpoint=data["api_endpoint"])
|
||||
return Config(
|
||||
ApiEndpoint=data["api_endpoint"],
|
||||
DefaultFrontend=data["default_frontend"]
|
||||
)
|
||||
|
||||
|
||||
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