website-agent-server 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- website_agent_server/__init__.py +3 -0
- website_agent_server/__main__.py +159 -0
- website_agent_server/auth.py +67 -0
- website_agent_server/browser.py +2757 -0
- website_agent_server/config.py +44 -0
- website_agent_server/main.py +522 -0
- website_agent_server/url_policy.py +181 -0
- website_agent_server-0.1.0.dist-info/METADATA +123 -0
- website_agent_server-0.1.0.dist-info/RECORD +12 -0
- website_agent_server-0.1.0.dist-info/WHEEL +4 -0
- website_agent_server-0.1.0.dist-info/entry_points.txt +3 -0
- website_agent_server-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import uvicorn
|
|
8
|
+
|
|
9
|
+
from .config import settings
|
|
10
|
+
from .url_policy import HostAccessPolicy
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
14
|
+
parser = argparse.ArgumentParser(
|
|
15
|
+
prog="website-agent-server",
|
|
16
|
+
description="Run the server-side browser proxy.",
|
|
17
|
+
)
|
|
18
|
+
parser.add_argument("--host", default=settings.host, help="Server bind host.")
|
|
19
|
+
parser.add_argument("--port", type=int, default=settings.port, help="Server port.")
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"--headed",
|
|
22
|
+
action="store_true",
|
|
23
|
+
help="Run Chromium with a visible browser window.",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--ignore-https-errors",
|
|
27
|
+
action="store_true",
|
|
28
|
+
help="Ignore remote TLS certificate errors.",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--allow-private-hosts",
|
|
32
|
+
action="store_true",
|
|
33
|
+
help="Allow private, local, and reserved network targets.",
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--session-ttl-seconds",
|
|
37
|
+
type=int,
|
|
38
|
+
default=settings.session_ttl_seconds,
|
|
39
|
+
help="Idle session lifetime in seconds.",
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"--navigation-timeout-ms",
|
|
43
|
+
type=int,
|
|
44
|
+
default=settings.navigation_timeout_ms,
|
|
45
|
+
help="Navigation timeout in milliseconds.",
|
|
46
|
+
)
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"--frame-interval-seconds",
|
|
49
|
+
type=float,
|
|
50
|
+
default=settings.frame_interval_seconds,
|
|
51
|
+
help="Screenshot streaming interval in seconds.",
|
|
52
|
+
)
|
|
53
|
+
parser.add_argument(
|
|
54
|
+
"--screenshot-quality",
|
|
55
|
+
type=int,
|
|
56
|
+
default=settings.screenshot_quality,
|
|
57
|
+
help="Screenshot quality from 1 to 100. Values below 100 use JPEG; 100 uses PNG.",
|
|
58
|
+
)
|
|
59
|
+
parser.add_argument(
|
|
60
|
+
"--media-frame-interval-seconds",
|
|
61
|
+
type=float,
|
|
62
|
+
default=settings.media_frame_interval_seconds,
|
|
63
|
+
help="Screenshot streaming interval while remote media is playing.",
|
|
64
|
+
)
|
|
65
|
+
parser.add_argument(
|
|
66
|
+
"--media-screenshot-quality",
|
|
67
|
+
type=int,
|
|
68
|
+
default=settings.media_screenshot_quality,
|
|
69
|
+
help="JPEG screenshot quality while remote media is playing. Ignored when screenshot quality is 100.",
|
|
70
|
+
)
|
|
71
|
+
parser.add_argument(
|
|
72
|
+
"--min-viewport-width",
|
|
73
|
+
type=int,
|
|
74
|
+
default=settings.min_viewport_width,
|
|
75
|
+
help="Minimum remote viewport width.",
|
|
76
|
+
)
|
|
77
|
+
parser.add_argument(
|
|
78
|
+
"--min-viewport-height",
|
|
79
|
+
type=int,
|
|
80
|
+
default=settings.min_viewport_height,
|
|
81
|
+
help="Minimum remote viewport height.",
|
|
82
|
+
)
|
|
83
|
+
parser.add_argument(
|
|
84
|
+
"--max-viewport-width",
|
|
85
|
+
type=int,
|
|
86
|
+
default=settings.max_viewport_width,
|
|
87
|
+
help="Maximum remote viewport width.",
|
|
88
|
+
)
|
|
89
|
+
parser.add_argument(
|
|
90
|
+
"--max-viewport-height",
|
|
91
|
+
type=int,
|
|
92
|
+
default=settings.max_viewport_height,
|
|
93
|
+
help="Maximum remote viewport height.",
|
|
94
|
+
)
|
|
95
|
+
parser.add_argument(
|
|
96
|
+
"--data-dir",
|
|
97
|
+
type=Path,
|
|
98
|
+
default=settings.data_dir,
|
|
99
|
+
help="Runtime downloads and temporary uploads directory.",
|
|
100
|
+
)
|
|
101
|
+
parser.add_argument(
|
|
102
|
+
"--pin",
|
|
103
|
+
default=None,
|
|
104
|
+
help="Require this PIN before clients can use the proxy.",
|
|
105
|
+
)
|
|
106
|
+
parser.add_argument(
|
|
107
|
+
"--lock-url",
|
|
108
|
+
default=None,
|
|
109
|
+
help="Lock the UI to this initial URL and disable browser option controls.",
|
|
110
|
+
)
|
|
111
|
+
return parser
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def apply_args(args: argparse.Namespace) -> None:
|
|
115
|
+
settings.host = args.host
|
|
116
|
+
settings.port = args.port
|
|
117
|
+
settings.headless = not args.headed
|
|
118
|
+
settings.ignore_https_errors = args.ignore_https_errors
|
|
119
|
+
settings.allow_private_hosts = args.allow_private_hosts
|
|
120
|
+
settings.session_ttl_seconds = args.session_ttl_seconds
|
|
121
|
+
settings.navigation_timeout_ms = args.navigation_timeout_ms
|
|
122
|
+
settings.frame_interval_seconds = args.frame_interval_seconds
|
|
123
|
+
settings.screenshot_quality = max(1, min(100, args.screenshot_quality))
|
|
124
|
+
settings.media_frame_interval_seconds = max(0.05, args.media_frame_interval_seconds)
|
|
125
|
+
settings.media_screenshot_quality = max(1, min(99, args.media_screenshot_quality))
|
|
126
|
+
settings.min_viewport_width = args.min_viewport_width
|
|
127
|
+
settings.min_viewport_height = args.min_viewport_height
|
|
128
|
+
settings.max_viewport_width = args.max_viewport_width
|
|
129
|
+
settings.max_viewport_height = args.max_viewport_height
|
|
130
|
+
settings.data_dir = args.data_dir.resolve()
|
|
131
|
+
settings.pin = args.pin
|
|
132
|
+
if args.lock_url:
|
|
133
|
+
lock_url_policy = HostAccessPolicy(settings.allow_private_hosts)
|
|
134
|
+
settings.lock_url = await lock_url_policy.ensure_navigation_url_allowed(
|
|
135
|
+
args.lock_url,
|
|
136
|
+
verify_https=not settings.ignore_https_errors,
|
|
137
|
+
)
|
|
138
|
+
else:
|
|
139
|
+
settings.lock_url = None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def main() -> None:
|
|
143
|
+
parser = build_parser()
|
|
144
|
+
args = parser.parse_args()
|
|
145
|
+
try:
|
|
146
|
+
asyncio.run(apply_args(args))
|
|
147
|
+
except ValueError as exc:
|
|
148
|
+
parser.error(str(exc))
|
|
149
|
+
uvicorn.run(
|
|
150
|
+
"website_agent_server.main:app",
|
|
151
|
+
host=settings.host,
|
|
152
|
+
port=settings.port,
|
|
153
|
+
reload=False,
|
|
154
|
+
ws_ping_interval=None,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
if __name__ == "__main__":
|
|
159
|
+
main()
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hmac
|
|
4
|
+
import secrets
|
|
5
|
+
from hashlib import sha256
|
|
6
|
+
|
|
7
|
+
from fastapi import HTTPException, Request, WebSocket, status
|
|
8
|
+
from starlette.responses import Response
|
|
9
|
+
|
|
10
|
+
from .config import Settings
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PinAuth:
|
|
14
|
+
def __init__(self, settings: Settings) -> None:
|
|
15
|
+
self.settings = settings
|
|
16
|
+
self._secret = secrets.token_urlsafe(32)
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def enabled(self) -> bool:
|
|
20
|
+
return bool(self.settings.pin)
|
|
21
|
+
|
|
22
|
+
def verify_pin(self, pin: str) -> bool:
|
|
23
|
+
expected = self.settings.pin or ""
|
|
24
|
+
return hmac.compare_digest(pin, expected)
|
|
25
|
+
|
|
26
|
+
def token(self) -> str:
|
|
27
|
+
if not self.settings.pin:
|
|
28
|
+
return ""
|
|
29
|
+
digest = hmac.new(
|
|
30
|
+
self._secret.encode("utf-8"),
|
|
31
|
+
self.settings.pin.encode("utf-8"),
|
|
32
|
+
sha256,
|
|
33
|
+
).hexdigest()
|
|
34
|
+
return digest
|
|
35
|
+
|
|
36
|
+
def is_request_allowed(self, request: Request) -> bool:
|
|
37
|
+
if not self.enabled:
|
|
38
|
+
return True
|
|
39
|
+
token = request.cookies.get(self.settings.auth_cookie_name, "")
|
|
40
|
+
return hmac.compare_digest(token, self.token())
|
|
41
|
+
|
|
42
|
+
def require_request(self, request: Request) -> None:
|
|
43
|
+
if not self.is_request_allowed(request):
|
|
44
|
+
raise HTTPException(
|
|
45
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
46
|
+
detail="PIN required.",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def is_websocket_allowed(self, websocket: WebSocket) -> bool:
|
|
50
|
+
if not self.enabled:
|
|
51
|
+
return True
|
|
52
|
+
token = websocket.cookies.get(self.settings.auth_cookie_name, "")
|
|
53
|
+
return hmac.compare_digest(token, self.token())
|
|
54
|
+
|
|
55
|
+
def set_cookie(self, response: Response) -> None:
|
|
56
|
+
response.set_cookie(
|
|
57
|
+
self.settings.auth_cookie_name,
|
|
58
|
+
self.token(),
|
|
59
|
+
max_age=self.settings.auth_cookie_max_age,
|
|
60
|
+
httponly=True,
|
|
61
|
+
samesite="lax",
|
|
62
|
+
secure=False,
|
|
63
|
+
path="/",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def clear_cookie(self, response: Response) -> None:
|
|
67
|
+
response.delete_cookie(self.settings.auth_cookie_name, path="/")
|