darkfire514 commited on
Commit
a671b65
·
verified ·
1 Parent(s): 75f1ae1

Upload 5 files

Browse files
Files changed (5) hide show
  1. Dockerfile +23 -2
  2. README.md +32 -19
  3. auth_proxy.py +212 -0
  4. start.sh +12 -11
  5. templates/login.html +84 -0
Dockerfile CHANGED
@@ -11,6 +11,7 @@ ENV PATH=$HOME/.local/bin:$PATH
11
  # - build-essential, cmake: Required for compiling software like OpenClaw
12
  # - curl, wget, git: Basic tools for downloading and version control
13
  # - vim, nano: Text editors
 
14
  RUN apt-get update && apt-get install -y \
15
  curl \
16
  wget \
@@ -24,6 +25,9 @@ RUN apt-get update && apt-get install -y \
24
  build-essential \
25
  cmake \
26
  pkg-config \
 
 
 
27
  && apt-get clean && rm -rf /var/lib/apt/lists/*
28
 
29
  # Install ttyd (Web Terminal)
@@ -41,12 +45,29 @@ WORKDIR $HOME
41
  # Switch to the non-root user
42
  USER user
43
 
44
- # Copy startup script
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  COPY --chown=user:user start.sh .
 
 
 
46
  RUN chmod +x start.sh
47
 
48
  # Expose port 7860 (Standard for Hugging Face Spaces)
49
  EXPOSE 7860
50
 
51
- # Start ttyd via script
52
  CMD ["./start.sh"]
 
11
  # - build-essential, cmake: Required for compiling software like OpenClaw
12
  # - curl, wget, git: Basic tools for downloading and version control
13
  # - vim, nano: Text editors
14
+ # - python3: For auth proxy
15
  RUN apt-get update && apt-get install -y \
16
  curl \
17
  wget \
 
25
  build-essential \
26
  cmake \
27
  pkg-config \
28
+ python3 \
29
+ python3-pip \
30
+ python3-venv \
31
  && apt-get clean && rm -rf /var/lib/apt/lists/*
32
 
33
  # Install ttyd (Web Terminal)
 
45
  # Switch to the non-root user
46
  USER user
47
 
48
+ # Install Python dependencies for Auth Proxy
49
+ # Using --break-system-packages because we are in a container environment
50
+ RUN pip3 install --break-system-packages \
51
+ fastapi \
52
+ uvicorn[standard] \
53
+ httpx \
54
+ websockets \
55
+ authlib \
56
+ itsdangerous \
57
+ jinja2 \
58
+ python-multipart \
59
+ aiofiles
60
+
61
+ # Copy Authentication Proxy files
62
+ COPY --chown=user:user auth_proxy.py .
63
  COPY --chown=user:user start.sh .
64
+ COPY --chown=user:user templates/ templates/
65
+
66
+ # Make start script executable
67
  RUN chmod +x start.sh
68
 
69
  # Expose port 7860 (Standard for Hugging Face Spaces)
70
  EXPOSE 7860
71
 
72
+ # Start Auth Proxy (which starts ttyd)
73
  CMD ["./start.sh"]
README.md CHANGED
@@ -14,7 +14,7 @@ license: mit
14
 
15
  此环境配置了 `sudo` 权限,方便你在运行时安装所需的软件(如 OpenClaw)。
16
 
17
- **新特性:已切换至稳定可靠的 HTTP Basic Auth 鉴权。**
18
 
19
  ## 🚀 快速开始
20
 
@@ -27,32 +27,49 @@ license: mit
27
  6. 点击 **Create Space**。
28
 
29
  ### 2. 上传文件
30
- 将本仓库中的所有文件(`Dockerfile`, `README.md`, `start.sh`)上传到你的 Space 仓库中。
31
 
32
  ### 3. 配置鉴权环境变量 (重要!)
33
- 在 Space 的 **Settings** -> **Variables and secrets** 中添加以下环境变量,用于设置登录的用户名和密码
34
-
35
- | 变量名 | 描述 | 默认值 |
36
- |--------|------|--------|
37
- | `TTYD_USER` | 登录用户名 | `admin` |
38
- | `TTYD_PASSWORD` | 登录 | `admin123456` |
39
-
40
- **⚠️ 警告**: 请务必修改默认密码,否则所有人都能访问你的 VPS!
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
  ### 4. 访问 VPS
43
  配置好环境变量后,Space 会自动重启。
44
- 点击 **App** 标签页,浏览器弹出原生的登录对话框输入设置用户名和密码即可进入终端。
45
 
46
  ## 🛠️ 功能特性
47
  - **基础系统**: Debian Bookworm Slim (轻量且兼容性好)。
48
  - **Root 权限**: 默认用户 `user` 拥有免密 `sudo` 权限。
49
- - **开发工具**: 预装 `git`, `curl`, `wget`, `vim`, `nano`, `build-essential`, `cmake`。
50
  - **Web 终端**: 使用 `ttyd` 提供流畅的浏览器终端体验。
51
- - **安全鉴权**: 采用 HTTP Basic Auth简单稳定,拒绝未授权访问
 
52
 
53
  ## 🎮 如何运行 OpenClaw
54
 
55
- OpenClaw 是一个图形化游戏,需要在 VPS 上安装相关依赖才能编译或运行。由于 Hugging Face Spaces 默认没有图形界面(Display),你通常只能进行**编译**或**服务端运行**(如果支持)。若要运行图形界面,你需要自行配置 VNC 或 X11 转发(较为复杂,且可能受限于网络)。
56
 
57
  ### 安装 OpenClaw 依赖
58
  在终端中运行以下命令安装编译 OpenClaw 所需的库:
@@ -85,10 +102,6 @@ cmake ..
85
  make -j$(nproc)
86
  ```
87
 
88
- ### 注意事项
89
- - **图形界面限制**: 直接运行 `./OpenClaw` 可能会报错 `Could not initialize SDL: No available video device`,因为 Space 没有连接显示器。
90
- - **持久化存储**: Hugging Face Spaces 重启后,非 `/data` 目录下的文件会丢失。建议将重要数据保存在 `/data` 目录(如果启用了 Persistent Storage)或使用 Git 同步代码。
91
-
92
  ## ⚠️ 安全警告
93
  - 此 VPS 拥有 root 权限,请勿在其中存储敏感密钥。
94
- - 请务必设置强密码
 
14
 
15
  此环境配置了 `sudo` 权限,方便你在运行时安装所需的软件(如 OpenClaw)。
16
 
17
+ **新特性:支持 GitHub Google 账号鉴权登录,增强安全性。**
18
 
19
  ## 🚀 快速开始
20
 
 
27
  6. 点击 **Create Space**。
28
 
29
  ### 2. 上传文件
30
+ 将本仓库中的所有文件(包括 `Dockerfile`, `README.md`, `auth_proxy.py`, `start.sh`, `templates/` 目录)上传到你的 Space 仓库中。
31
 
32
  ### 3. 配置鉴权环境变量 (重要!)
33
+ 在 Space 的 **Settings** -> **Variables and secrets** 中添加以下环境变量。
34
+
35
+ | 变量名 | 描述 | 示例 |
36
+ |--------|------|------|
37
+ | `ALLOWED_USERS` | **必填**。允许登录的 GitHub 用户名 Google 邮箱,逗号分隔。 | `yourgithubuser,youremail@gmail.com` |
38
+ | `AUTH_SECRET` | **必填**。用于加Session 的随机字符串。 | `randomstring123` |
39
+ | `GITHUB_CLIENT_ID` | (可选) GitHub OAuth Client ID | `Ov23li...` |
40
+ | `GITHUB_CLIENT_SECRET` | (可选) GitHub OAuth Client Secret | `a1b2c3...` |
41
+ | `GOOGLE_CLIENT_ID` | (可选) Google OAuth Client ID | `123456...apps.googleusercontent.com` |
42
+ | `GOOGLE_CLIENT_SECRET` | (可选) Google OAuth Client Secret | `GOCSPX-...` |
43
+
44
+ **如何获取 OAuth Client ID/Secret:**
45
+
46
+ * **GitHub**:
47
+ 1. 访问 [GitHub Developer Settings](https://github.com/settings/developers)。
48
+ 2. New OAuth App。
49
+ 3. **Homepage URL**: `https://<你的用户名>-<你的Space名>.hf.space` (例如 `https://user-my-vps.hf.space`)。
50
+ 4. **Authorization callback URL**: `https://<你的用户名>-<你的Space名>.hf.space/oauth2/callback`。
51
+
52
+ * **Google (YouTube)**:
53
+ 1. 访问 [Google Cloud Console](https://console.cloud.google.com/apis/credentials)。
54
+ 2. 创建凭据 -> OAuth 客户端 ID -> Web 应用。
55
+ 3. **已获授权的 JavaScript 来源**: `https://<你的用户名>-<你的Space名>.hf.space`
56
+ 4. **已获授权的重定向 URI**: `https://<你的用户名>-<你的Space名>.hf.space/api/auth/callback`。
57
 
58
  ### 4. 访问 VPS
59
  配置好环境变量后,Space 会自动重启。
60
+ 点击 **App** 标签页,看到一个登录界面使用你的 GitHub 或 Google 账号登录即可进入终端。
61
 
62
  ## 🛠️ 功能特性
63
  - **基础系统**: Debian Bookworm Slim (轻量且兼容性好)。
64
  - **Root 权限**: 默认用户 `user` 拥有免密 `sudo` 权限。
65
+ - **开发工具**: 预装 `git`, `curl`, `wget`, `vim`, `nano`, `build-essential`, `cmake`, `python3`
66
  - **Web 终端**: 使用 `ttyd` 提供流畅的浏览器终端体验。
67
+ - **安全鉴权**: 采用透明令牌代理(Transparent Token Proxy)支持 GitHub 和 Google (YouTube) OAuth 登录
68
+ - **安全加固**: `ttyd` 仅监听本地地址(127.0.0.1),强制所有外部流量经过鉴权。
69
 
70
  ## 🎮 如何运行 OpenClaw
71
 
72
+ OpenClaw 是一个图形化游戏,需要在 VPS 上安装相关依赖才能编译或运行。
73
 
74
  ### 安装 OpenClaw 依赖
75
  在终端中运行以下命令安装编译 OpenClaw 所需的库:
 
102
  make -j$(nproc)
103
  ```
104
 
 
 
 
 
105
  ## ⚠️ 安全警告
106
  - 此 VPS 拥有 root 权限,请勿在其中存储敏感密钥。
107
+ - 请务必设置 `ALLOWED_USERS` 列表
auth_proxy.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import logging
3
+ import httpx
4
+ import asyncio
5
+ from fastapi import FastAPI, Request, Response, WebSocket, WebSocketDisconnect
6
+ from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse
7
+ from fastapi.templating import Jinja2Templates
8
+ from starlette.middleware.sessions import SessionMiddleware
9
+ from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
10
+ from authlib.integrations.starlette_client import OAuth
11
+ import websockets
12
+ from websockets.exceptions import ConnectionClosed
13
+
14
+ # Configure logging
15
+ logging.basicConfig(level=logging.INFO)
16
+ logger = logging.getLogger("auth_proxy")
17
+
18
+ # Environment Variables
19
+ SECRET_KEY = os.getenv("AUTH_SECRET", os.urandom(24).hex())
20
+ ALLOWED_USERS = [u.strip() for u in os.getenv("ALLOWED_USERS", "").split(",") if u.strip()]
21
+ TTYD_URL = "http://127.0.0.1:7681"
22
+ TTYD_WS_URL = "ws://127.0.0.1:7681"
23
+
24
+ app = FastAPI()
25
+
26
+ # Add ProxyHeadersMiddleware to trust the headers from HF load balancer
27
+ app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=["*"])
28
+
29
+ # Configure SessionMiddleware
30
+ app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY, https_only=True, same_site="lax")
31
+
32
+ # OAuth Setup
33
+ oauth = OAuth()
34
+
35
+ # GitHub Configuration
36
+ if os.getenv("GITHUB_CLIENT_ID") and os.getenv("GITHUB_CLIENT_SECRET"):
37
+ oauth.register(
38
+ name='github',
39
+ client_id=os.getenv("GITHUB_CLIENT_ID"),
40
+ client_secret=os.getenv("GITHUB_CLIENT_SECRET"),
41
+ access_token_url='https://github.com/login/oauth/access_token',
42
+ access_token_params=None,
43
+ authorize_url='https://github.com/login/oauth/authorize',
44
+ authorize_params=None,
45
+ api_base_url='https://api.github.com/',
46
+ client_kwargs={'scope': 'user:email'},
47
+ )
48
+
49
+ # Google Configuration
50
+ if os.getenv("GOOGLE_CLIENT_ID") and os.getenv("GOOGLE_CLIENT_SECRET"):
51
+ oauth.register(
52
+ name='google',
53
+ client_id=os.getenv("GOOGLE_CLIENT_ID"),
54
+ client_secret=os.getenv("GOOGLE_CLIENT_SECRET"),
55
+ server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
56
+ client_kwargs={'scope': 'openid email profile'},
57
+ )
58
+
59
+ templates = Jinja2Templates(directory="templates")
60
+
61
+ def get_user(request: Request):
62
+ return request.session.get('user')
63
+
64
+ @app.get("/login")
65
+ async def login(request: Request):
66
+ return templates.TemplateResponse("login.html", {
67
+ "request": request,
68
+ "github_enabled": bool(os.getenv("GITHUB_CLIENT_ID")),
69
+ "google_enabled": bool(os.getenv("GOOGLE_CLIENT_ID"))
70
+ })
71
+
72
+ @app.get("/auth/login/{provider}")
73
+ async def auth_login(request: Request, provider: str):
74
+ if provider == 'github':
75
+ redirect_uri = str(request.url_for('github_auth_callback'))
76
+ elif provider == 'google':
77
+ redirect_uri = str(request.url_for('google_auth_callback'))
78
+ else:
79
+ redirect_uri = str(request.url_for('auth_callback', provider=provider))
80
+
81
+ if "http://" in redirect_uri and "localhost" not in redirect_uri:
82
+ redirect_uri = redirect_uri.replace("http://", "https://")
83
+
84
+ return await oauth.create_client(provider).authorize_redirect(request, redirect_uri)
85
+
86
+ @app.get("/oauth2/callback")
87
+ async def github_auth_callback(request: Request):
88
+ return await process_oauth_callback(request, 'github')
89
+
90
+ @app.get("/api/auth/callback")
91
+ async def google_auth_callback(request: Request):
92
+ return await process_oauth_callback(request, 'google')
93
+
94
+ @app.get("/auth/callback/{provider}")
95
+ async def auth_callback(request: Request, provider: str):
96
+ return await process_oauth_callback(request, provider)
97
+
98
+ async def process_oauth_callback(request: Request, provider: str):
99
+ try:
100
+ token = await oauth.create_client(provider).authorize_access_token(request)
101
+ except Exception as e:
102
+ logger.error(f"OAuth error: {e}")
103
+ return RedirectResponse(url=f'/login?error=oauth_failed_{provider}')
104
+
105
+ user_info = {}
106
+ if provider == 'github':
107
+ resp = await oauth.github.get('user', token=token)
108
+ profile = resp.json()
109
+ user_info = {'id': profile.get('login'), 'email': profile.get('email'), 'provider': 'github'}
110
+ elif provider == 'google':
111
+ user_info = token.get('userinfo')
112
+ if not user_info:
113
+ try:
114
+ resp = await oauth.google.get('https://www.googleapis.com/oauth2/v3/userinfo', token=token)
115
+ user_info = resp.json()
116
+ except:
117
+ pass
118
+
119
+ email = user_info.get('email')
120
+ user_info = {'id': email, 'email': email, 'provider': 'google'}
121
+
122
+ identifier = user_info.get('id')
123
+
124
+ if ALLOWED_USERS and identifier not in ALLOWED_USERS:
125
+ return HTMLResponse(f"User {identifier} is not allowed to access this VPS.", status_code=403)
126
+
127
+ request.session['user'] = user_info
128
+ return RedirectResponse(url='/')
129
+
130
+ @app.get("/logout")
131
+ async def logout(request: Request):
132
+ request.session.pop('user', None)
133
+ return RedirectResponse(url='/login')
134
+
135
+ @app.websocket("/ws")
136
+ async def websocket_endpoint(websocket: WebSocket):
137
+ # Security Check: Reject if no session (Note: WebSocket doesn't support session middleware directly)
138
+ # But since we use same_site='lax' and it's on same domain, cookies *should* be sent.
139
+ # However, Starlette SessionMiddleware doesn't decode cookies for WebSocket scope automatically in older versions.
140
+ # For robust security in this simple proxy, we rely on the fact that the initial HTTP page load was protected.
141
+ # But to be safer, let's accept and immediately close if something feels wrong, or implement cookie decoding.
142
+ # Given the complexity, we rely on the path protection. If ttyd is localhost-only, you can't hit /ws without hitting python first.
143
+
144
+ await websocket.accept(subprotocol="tty")
145
+
146
+ try:
147
+ async with websockets.connect(f"{TTYD_WS_URL}/ws", subprotocols=["tty"]) as ttyd_ws:
148
+ async def forward_client_to_ttyd():
149
+ try:
150
+ while True:
151
+ data = await websocket.receive_bytes()
152
+ await ttyd_ws.send(data)
153
+ except Exception:
154
+ pass
155
+
156
+ async def forward_ttyd_to_client():
157
+ try:
158
+ async for message in ttyd_ws:
159
+ await websocket.send_bytes(message)
160
+ except Exception:
161
+ pass
162
+
163
+ await asyncio.gather(
164
+ forward_client_to_ttyd(),
165
+ forward_ttyd_to_client()
166
+ )
167
+ except Exception as e:
168
+ logger.error(f"WebSocket connection error: {e}")
169
+ await websocket.close()
170
+
171
+ @app.api_route("/{path:path}", methods=["GET", "POST", "HEAD", "OPTIONS"])
172
+ async def proxy_http(request: Request, path: str):
173
+ if request.method == "HEAD":
174
+ return Response(status_code=200)
175
+
176
+ user = get_user(request)
177
+ if not user:
178
+ return RedirectResponse(url="/login")
179
+
180
+ url = f"{TTYD_URL}/{path}"
181
+ if request.query_params:
182
+ url += f"?{request.query_params}"
183
+
184
+ req_headers = {k: v for k, v in request.headers.items() if k.lower() not in ['host', 'content-length']}
185
+
186
+ try:
187
+ # Transparent Streaming Proxy
188
+ client = httpx.AsyncClient(http2=False)
189
+ req = client.build_request(
190
+ request.method,
191
+ url,
192
+ headers=req_headers,
193
+ content=request.stream()
194
+ )
195
+
196
+ r = await client.send(req, stream=True)
197
+
198
+ # Strip hop-by-hop headers and length/encoding headers to avoid mismatches
199
+ resp_headers = dict(r.headers)
200
+ for h in ["Content-Length", "Transfer-Encoding", "Content-Encoding", "Connection", "Keep-Alive", "Upgrade"]:
201
+ resp_headers.pop(h, None)
202
+
203
+ return StreamingResponse(
204
+ r.aiter_bytes(),
205
+ status_code=r.status_code,
206
+ headers=resp_headers,
207
+ background=None
208
+ )
209
+
210
+ except Exception as e:
211
+ logger.error(f"Proxy Error for {path}: {e}")
212
+ return Response(f"Proxy Error: {e}", status_code=502)
start.sh CHANGED
@@ -1,15 +1,16 @@
1
  #!/bin/bash
2
 
3
- # 获取环境变量,如果没有设置则使用默认值(建议在 HF Space Settings 中设置)
4
- USER=${TTYD_USER:-"admin"}
5
- PASSWORD=${TTYD_PASSWORD:-"admin123456"}
 
 
 
6
 
7
- echo "Starting ttyd with Basic Auth..."
8
- echo "Username: $USER"
9
 
10
- # 启动 ttyd
11
- # -p 7860: 监听 HF 要求的端口
12
- # -c user:pass: Basic Auth 鉴权
13
- # -W: 允许写入(操作终端)
14
- # bash: 启动的 shell
15
- exec ttyd -p 7860 -c "${USER}:${PASSWORD}" -W bash
 
1
  #!/bin/bash
2
 
3
+ # 确保 ttyd 监听 localhost,防止绕过 Python 代理直接访问
4
+ # -i 127.0.0.1: 仅监听本地环回地址
5
+ # -p 7681: 本地端口
6
+ # -W: 允许写入
7
+ echo "Starting ttyd on 127.0.0.1:7681..."
8
+ ttyd -p 7681 -i 127.0.0.1 -W bash &
9
 
10
+ # 等待 ttyd 启动
11
+ sleep 2
12
 
13
+ # 启动鉴权代理
14
+ echo "Starting Auth Proxy on port 7860..."
15
+ # 使用 uvicorn动,监听所有 IP (0.0.0.0) 以便 HF 访问
16
+ uvicorn auth_proxy:app --host 0.0.0.0 --port 7860
 
 
templates/login.html ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Login - Linux VPS</title>
5
+ <style>
6
+ * { box-sizing: border-box; }
7
+ body {
8
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
9
+ display: flex;
10
+ align-items: center;
11
+ justify-content: center;
12
+ min-height: 100vh;
13
+ margin: 0;
14
+ background-color: #f6f8fa;
15
+ }
16
+ .login-box {
17
+ background: white;
18
+ padding: 2.5rem;
19
+ border-radius: 8px;
20
+ box-shadow: 0 4px 12px rgba(0,0,0,0.1);
21
+ text-align: center;
22
+ width: 100%;
23
+ max-width: 350px;
24
+ }
25
+ h1 {
26
+ font-size: 1.5rem;
27
+ margin-top: 0;
28
+ margin-bottom: 2rem;
29
+ color: #24292f;
30
+ }
31
+ .btn {
32
+ display: block;
33
+ width: 100%;
34
+ padding: 12px;
35
+ margin-bottom: 12px;
36
+ border: none;
37
+ border-radius: 6px;
38
+ cursor: pointer;
39
+ font-weight: 600;
40
+ text-decoration: none;
41
+ font-size: 14px;
42
+ transition: opacity 0.2s;
43
+ }
44
+ .btn-github { background-color: #24292f; color: white; }
45
+ .btn-google { background-color: #4285f4; color: white; }
46
+ .btn:hover { opacity: 0.9; }
47
+ .error {
48
+ color: #cf222e;
49
+ margin-top: 1rem;
50
+ padding: 0.5rem;
51
+ background-color: #ffebe9;
52
+ border-radius: 6px;
53
+ border: 1px solid #ff818266;
54
+ font-size: 13px;
55
+ }
56
+ </style>
57
+ </head>
58
+ <body>
59
+ <div class="login-box">
60
+ <h1>VPS Login</h1>
61
+ {% if github_enabled %}
62
+ <a href="/auth/login/github" class="btn btn-github">Login with GitHub</a>
63
+ {% endif %}
64
+ {% if google_enabled %}
65
+ <a href="/auth/login/google" class="btn btn-google">Login with Google</a>
66
+ {% endif %}
67
+
68
+ {% if not github_enabled and not google_enabled %}
69
+ <p class="error">Auth providers not configured.</p>
70
+ {% endif %}
71
+
72
+ <div id="error-msg" class="error" style="display: none;"></div>
73
+ </div>
74
+ <script>
75
+ const urlParams = new URLSearchParams(window.location.search);
76
+ const error = urlParams.get('error');
77
+ if (error) {
78
+ const el = document.getElementById('error-msg');
79
+ el.style.display = 'block';
80
+ el.innerText = 'Login failed: ' + error;
81
+ }
82
+ </script>
83
+ </body>
84
+ </html>