security: harden dashboard (SSH keys, auth, uploads, CORS, non-root)
Deploy Dashboard / deploy (push) Successful in 3m37s
Deploy Dashboard / deploy (push) Successful in 3m37s
Remove SSH private keys from git, add SECRET_KEY validation, move WS auth from query string to first message, add session limits/idle timeout, PBKDF2 Fernet key, refresh token rotation, TOTP replay protection, file upload size limit + filename sanitization, symlink safety check, restrict CORS methods, IP-gate OpenClaw token, run container as non-root, rate-limit refresh/passkey endpoints, sanitize Gitea path params. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,3 +4,4 @@ dist/
|
||||
*.tar.gz
|
||||
__pycache__/
|
||||
dashboard/nas-dashboard.tar
|
||||
dashboard/ssh/
|
||||
|
||||
@@ -8,10 +8,13 @@ RUN npm install --registry=https://registry.npmmirror.com && npm run build
|
||||
# Stage 2: Python runtime
|
||||
FROM python:3.12-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends openssh-client && rm -rf /var/lib/apt/lists/*
|
||||
RUN adduser --disabled-password --no-create-home --gecos "" app
|
||||
WORKDIR /app
|
||||
COPY backend/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY backend/ ./
|
||||
COPY --from=frontend /build/dist /app/static
|
||||
RUN chown -R app:app /app
|
||||
USER app
|
||||
EXPOSE 4000
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "4000"]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
import time
|
||||
import jwt
|
||||
from passlib.context import CryptContext
|
||||
import pyotp
|
||||
@@ -19,14 +20,14 @@ def get_password_hash(password):
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=15))
|
||||
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=15))
|
||||
to_encode.update({"exp": expire, "type": "access"})
|
||||
return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM)
|
||||
|
||||
def create_refresh_token(data: dict):
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + timedelta(minutes=config.REFRESH_TOKEN_EXPIRE_MINUTES)
|
||||
to_encode.update({"exp": expire, "type": "refresh"})
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=config.REFRESH_TOKEN_EXPIRE_MINUTES)
|
||||
to_encode.update({"exp": expire, "type": "refresh", "tv": _load_token_version()})
|
||||
return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM)
|
||||
|
||||
async def get_current_user(token: str = Depends(oauth2_scheme)):
|
||||
@@ -70,6 +71,13 @@ import hashlib
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
def _get_fernet():
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=b"nas-dashboard-fernet", iterations=480000)
|
||||
key = base64.urlsafe_b64encode(kdf.derive(config.SECRET_KEY.encode()))
|
||||
return Fernet(key)
|
||||
|
||||
def _get_legacy_fernet():
|
||||
key = base64.urlsafe_b64encode(hashlib.sha256(config.SECRET_KEY.encode()).digest())
|
||||
return Fernet(key)
|
||||
|
||||
@@ -83,6 +91,11 @@ def _decrypt(ciphertext: str) -> str:
|
||||
return ""
|
||||
try:
|
||||
return _get_fernet().decrypt(ciphertext.encode()).decode()
|
||||
except Exception:
|
||||
pass
|
||||
# Fallback: try legacy SHA256-based key
|
||||
try:
|
||||
return _get_legacy_fernet().decrypt(ciphertext.encode()).decode()
|
||||
except Exception:
|
||||
return ciphertext # fallback for unencrypted legacy values
|
||||
|
||||
@@ -125,7 +138,16 @@ def verify_totp(token: str, secret: str = None):
|
||||
if not secret:
|
||||
return True # 2FA not enabled
|
||||
totp = pyotp.TOTP(secret)
|
||||
return totp.verify(token)
|
||||
if not totp.verify(token):
|
||||
return False
|
||||
# Replay protection: reject reuse within same 30s window
|
||||
current_ts = int(time.time()) // 30
|
||||
data = _load_auth_data()
|
||||
if data.get("last_totp_ts") == current_ts:
|
||||
return False
|
||||
data["last_totp_ts"] = current_ts
|
||||
_save_auth_data(data)
|
||||
return True
|
||||
|
||||
# Passkey credentials
|
||||
def load_passkey_credentials():
|
||||
@@ -142,3 +164,17 @@ def clear_passkey_credentials():
|
||||
data = _load_auth_data()
|
||||
data["passkey_credentials"] = []
|
||||
_save_auth_data(data)
|
||||
|
||||
# Token version for refresh token rotation
|
||||
def _load_token_version() -> int:
|
||||
return _load_auth_data().get("token_version", 0)
|
||||
|
||||
def increment_token_version() -> int:
|
||||
data = _load_auth_data()
|
||||
v = data.get("token_version", 0) + 1
|
||||
data["token_version"] = v
|
||||
_save_auth_data(data)
|
||||
return v
|
||||
|
||||
def verify_token_version(tv: int) -> bool:
|
||||
return tv == _load_token_version()
|
||||
|
||||
@@ -8,6 +8,8 @@ VOLUME_ROOT = os.environ.get("VOLUME_ROOT", "/volume1")
|
||||
|
||||
# Auth
|
||||
SECRET_KEY = os.environ.get("SECRET_KEY", "")
|
||||
if not SECRET_KEY or len(SECRET_KEY) < 32:
|
||||
raise RuntimeError("SECRET_KEY must be set and at least 32 characters")
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
||||
REFRESH_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
|
||||
@@ -19,6 +21,7 @@ TOTP_SECRET = os.environ.get("TOTP_SECRET", "")
|
||||
SSH_HOST = os.environ.get("SSH_HOST", "host.docker.internal")
|
||||
SSH_USER = os.environ.get("SSH_USER", "zjgump")
|
||||
SSH_KEY_PATH = os.environ.get("SSH_KEY_PATH", "/app/ssh/id_ed25519")
|
||||
SSH_KNOWN_HOSTS = os.environ.get("SSH_KNOWN_HOSTS", "/app/ssh/known_hosts")
|
||||
|
||||
VPS_SSH_HOST = os.environ.get("VPS_SSH_HOST", "158.101.140.85")
|
||||
VPS_SSH_USER = os.environ.get("VPS_SSH_USER", "jimmyg")
|
||||
|
||||
@@ -26,8 +26,8 @@ app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Authorization", "Content-Type"],
|
||||
)
|
||||
|
||||
@app.exception_handler(RateLimitExceeded)
|
||||
@@ -118,7 +118,7 @@ async def health():
|
||||
async def client_ip(request: Request):
|
||||
ip = request.headers.get("x-forwarded-for", "").split(",")[0].strip() or request.client.host
|
||||
lan = ip.startswith("192.168.31.") or (ip.startswith("100.") and _router_reachable())
|
||||
return {"ip": ip, "lan": lan}
|
||||
return {"lan": lan}
|
||||
|
||||
def _router_reachable():
|
||||
import socket
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -74,7 +74,7 @@ async def login(creds: LoginRequest, request: Request):
|
||||
asyncio.create_task(send_failed_login_alert(client_ip, creds.username, "Incorrect username or password"))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
detail="Invalid credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
@@ -92,7 +92,7 @@ async def login(creds: LoginRequest, request: Request):
|
||||
asyncio.create_task(send_failed_login_alert(client_ip, creds.username, "Invalid 2FA Code"))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid 2FA code",
|
||||
detail="Invalid credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
@@ -115,7 +115,8 @@ async def read_users_me(current_user: str = Depends(auth.get_current_user)):
|
||||
return {"username": current_user, "has_2fa": bool(totp_secret)}
|
||||
|
||||
@router.post("/refresh")
|
||||
async def refresh_token(req: RefreshRequest):
|
||||
@limiter.limit("10/minute")
|
||||
async def refresh_token(req: RefreshRequest, request: Request):
|
||||
try:
|
||||
payload = jwt.decode(req.refresh_token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
|
||||
if payload.get("type") != "refresh":
|
||||
@@ -123,8 +124,11 @@ async def refresh_token(req: RefreshRequest):
|
||||
username = payload.get("sub")
|
||||
if username != config.ADMIN_USER:
|
||||
raise HTTPException(status_code=401, detail="Invalid user")
|
||||
if not auth.verify_token_version(payload.get("tv", -1)):
|
||||
raise HTTPException(status_code=401, detail="Token revoked")
|
||||
except jwt.PyJWTError:
|
||||
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
||||
auth.increment_token_version()
|
||||
access_token = auth.create_access_token(
|
||||
data={"sub": username},
|
||||
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||
@@ -217,7 +221,8 @@ async def passkey_register_verify(request: Request, current_user: str = Depends(
|
||||
return {"ok": True}
|
||||
|
||||
@router.post("/passkey/login/options")
|
||||
async def passkey_login_options():
|
||||
@limiter.limit("10/minute")
|
||||
async def passkey_login_options(request: Request):
|
||||
creds = auth.load_passkey_credentials()
|
||||
if not creds:
|
||||
raise HTTPException(status_code=404, detail="No passkeys registered")
|
||||
@@ -231,6 +236,7 @@ async def passkey_login_options():
|
||||
return JSONResponse(content=json.loads(options_to_json(options)))
|
||||
|
||||
@router.post("/passkey/login/verify")
|
||||
@limiter.limit("10/minute")
|
||||
async def passkey_login_verify(request: Request):
|
||||
body = await request.json()
|
||||
challenge = _get_challenge()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import docker
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Query
|
||||
from config import DOCKER_HOST
|
||||
|
||||
router = APIRouter()
|
||||
@@ -31,6 +31,6 @@ def container_action(container_id: str, action: str):
|
||||
|
||||
|
||||
@router.get("/containers/{container_id}/logs")
|
||||
def container_logs(container_id: str, tail: int = 200):
|
||||
def container_logs(container_id: str, tail: int = Query(200, le=10000)):
|
||||
c = client.containers.get(container_id)
|
||||
return {"logs": c.logs(tail=tail, timestamps=True).decode(errors="replace")}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, UploadFile, File, HTTPException
|
||||
@@ -8,12 +9,16 @@ from config import VOLUME_ROOT
|
||||
router = APIRouter()
|
||||
BASE = Path(VOLUME_ROOT)
|
||||
|
||||
BLOCKED_DIRS = {"@appstore", "@docker", "@eaDir", "@S2S", "@sharesnap", "@tmp", "docker"}
|
||||
MAX_UPLOAD_SIZE = 100 * 1024 * 1024 # 100 MB
|
||||
BLOCKED_DIRS = {"@appstore", "@docker", "@eaDir", "@S2S", "@sharesnap", "@tmp", "docker", "homes", "backups"}
|
||||
|
||||
|
||||
_BASE_RESOLVED = str(BASE.resolve())
|
||||
|
||||
|
||||
def _safe_path(path: str) -> Path:
|
||||
target = (BASE / path).resolve()
|
||||
if not str(target).startswith(str(BASE.resolve())):
|
||||
if not str(target).startswith(_BASE_RESOLVED):
|
||||
raise ValueError("forbidden")
|
||||
# Block access to sensitive directories
|
||||
rel = target.relative_to(BASE.resolve())
|
||||
@@ -22,6 +27,32 @@ def _safe_path(path: str) -> Path:
|
||||
return target
|
||||
|
||||
|
||||
def _is_safe_symlink(item: Path) -> bool:
|
||||
"""Check if a symlink resolves to a path within BASE and not in blocked dirs."""
|
||||
if not item.is_symlink():
|
||||
return True
|
||||
try:
|
||||
resolved = item.resolve()
|
||||
if not str(resolved).startswith(_BASE_RESOLVED):
|
||||
return False
|
||||
rel = resolved.relative_to(BASE.resolve())
|
||||
if rel.parts and rel.parts[0] in BLOCKED_DIRS:
|
||||
return False
|
||||
return True
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _sanitize_filename(name: str) -> str:
|
||||
name = os.path.basename(name)
|
||||
name = name.replace("\x00", "").replace("/", "").replace("\\", "")
|
||||
name = re.sub(r'\.\.+', '.', name)
|
||||
name = name.strip(". ")
|
||||
if not name:
|
||||
raise ValueError("invalid filename")
|
||||
return name
|
||||
|
||||
|
||||
@router.get("/browse")
|
||||
def browse(path: str = ""):
|
||||
try:
|
||||
@@ -31,9 +62,11 @@ def browse(path: str = ""):
|
||||
entries = []
|
||||
for item in sorted(target.iterdir()):
|
||||
try:
|
||||
if not _is_safe_symlink(item):
|
||||
continue
|
||||
st = item.stat()
|
||||
entries.append({"name": item.name, "is_dir": item.is_dir(), "size": st.st_size if item.is_file() else 0})
|
||||
except PermissionError:
|
||||
except (PermissionError, OSError):
|
||||
continue
|
||||
return {"path": str(target.relative_to(BASE)), "entries": entries}
|
||||
|
||||
@@ -49,11 +82,20 @@ def download(path: str):
|
||||
@router.post("/upload")
|
||||
async def upload(path: str, file: UploadFile = File(...)):
|
||||
try:
|
||||
target = _safe_path(path) / file.filename
|
||||
safe_name = _sanitize_filename(file.filename)
|
||||
target = _safe_path(path) / safe_name
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
# Stream with size check
|
||||
total = 0
|
||||
with open(target, "wb") as f:
|
||||
shutil.copyfileobj(file.file, f)
|
||||
while chunk := await file.read(1024 * 1024):
|
||||
total += len(chunk)
|
||||
if total > MAX_UPLOAD_SIZE:
|
||||
f.close()
|
||||
target.unlink(missing_ok=True)
|
||||
raise HTTPException(status_code=413, detail="File too large (max 100MB)")
|
||||
f.write(chunk)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import re
|
||||
import httpx
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from config import GITEA_URL, GITEA_TOKEN
|
||||
|
||||
router = APIRouter()
|
||||
HEADERS = {"Authorization": f"token {GITEA_TOKEN}"} if GITEA_TOKEN else {}
|
||||
_SAFE_NAME = re.compile(r'^[a-zA-Z0-9._-]+$')
|
||||
|
||||
|
||||
@router.get("/repos")
|
||||
@@ -15,6 +17,8 @@ async def list_repos():
|
||||
|
||||
@router.get("/repos/{owner}/{repo}/commits")
|
||||
async def list_commits(owner: str, repo: str, page: int = 1):
|
||||
if not _SAFE_NAME.match(owner) or not _SAFE_NAME.match(repo):
|
||||
raise HTTPException(status_code=400, detail="Invalid owner or repo name")
|
||||
async with httpx.AsyncClient() as c:
|
||||
r = await c.get(f"{GITEA_URL}/api/v1/repos/{owner}/{repo}/commits", headers=HEADERS, params={"page": page})
|
||||
return r.json()
|
||||
|
||||
@@ -4,7 +4,7 @@ import psutil
|
||||
import platform
|
||||
import time
|
||||
import httpx
|
||||
from fastapi import APIRouter, Query
|
||||
from fastapi import APIRouter, Query, Request, HTTPException
|
||||
from config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, VOLUME_ROOT, OPENCLAW_GATEWAY_TOKEN
|
||||
|
||||
router = APIRouter()
|
||||
@@ -115,5 +115,8 @@ def _classify(method, path, status, user):
|
||||
|
||||
|
||||
@router.get("/openclaw-token")
|
||||
def openclaw_token():
|
||||
def openclaw_token(request: Request):
|
||||
ip = request.headers.get("x-forwarded-for", "").split(",")[0].strip() or request.client.host
|
||||
if not (ip.startswith("192.168.31.") or ip.startswith("100.")):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
return {"token": OPENCLAW_GATEWAY_TOKEN}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import asyncio
|
||||
import struct
|
||||
import logging
|
||||
import time
|
||||
from fastapi import WebSocket, WebSocketDisconnect, Query
|
||||
import asyncssh
|
||||
import jwt
|
||||
import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_SESSIONS = 5
|
||||
IDLE_TIMEOUT = 30 * 60 # 30 minutes
|
||||
_active_sessions: set = set()
|
||||
|
||||
HOSTS = {
|
||||
"nas": {"host": config.SSH_HOST, "user": config.SSH_USER, "key": config.SSH_KEY_PATH},
|
||||
"vps": {"host": config.VPS_SSH_HOST, "user": config.VPS_SSH_USER, "key": config.VPS_SSH_KEY_PATH},
|
||||
@@ -12,26 +20,43 @@ HOSTS = {
|
||||
"command": "/volume1/@appstore/ContainerManager/usr/bin/docker exec -it claude-dev bash"},
|
||||
}
|
||||
|
||||
# Load known_hosts once at import time
|
||||
_known_hosts = None
|
||||
try:
|
||||
_known_hosts = asyncssh.read_known_hosts(config.SSH_KNOWN_HOSTS)
|
||||
except Exception:
|
||||
logger.warning("SSH known_hosts file not found at %s — host key verification disabled", config.SSH_KNOWN_HOSTS)
|
||||
|
||||
async def ws_endpoint(websocket: WebSocket, token: str = Query(""), host: str = Query("nas")):
|
||||
|
||||
async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")):
|
||||
await websocket.accept()
|
||||
|
||||
# Auth: expect token as first text message
|
||||
try:
|
||||
payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
|
||||
if payload.get("sub") != config.ADMIN_USER:
|
||||
first_msg = await asyncio.wait_for(websocket.receive_text(), timeout=5)
|
||||
payload = jwt.decode(first_msg, config.SECRET_KEY, algorithms=[config.ALGORITHM])
|
||||
if payload.get("type") != "access" or payload.get("sub") != config.ADMIN_USER:
|
||||
await websocket.close(code=1008, reason="Unauthorized")
|
||||
return
|
||||
except Exception:
|
||||
await websocket.close(code=1008, reason="Unauthorized")
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
if len(_active_sessions) >= MAX_SESSIONS:
|
||||
await websocket.close(code=1013, reason="Too many sessions")
|
||||
return
|
||||
|
||||
session_id = id(websocket)
|
||||
_active_sessions.add(session_id)
|
||||
|
||||
h = HOSTS.get(host, HOSTS["nas"])
|
||||
try:
|
||||
conn = await asyncssh.connect(
|
||||
h["host"], username=h["user"],
|
||||
client_keys=[h["key"]], known_hosts=None,
|
||||
client_keys=[h["key"]], known_hosts=_known_hosts,
|
||||
)
|
||||
except Exception:
|
||||
_active_sessions.discard(session_id)
|
||||
await websocket.close(code=1011, reason="SSH connection failed")
|
||||
return
|
||||
|
||||
@@ -52,10 +77,18 @@ async def ws_endpoint(websocket: WebSocket, token: str = Query(""), host: str =
|
||||
pass
|
||||
|
||||
read_task = asyncio.create_task(read_ssh())
|
||||
last_activity = time.monotonic()
|
||||
|
||||
try:
|
||||
while True:
|
||||
msg = await websocket.receive()
|
||||
try:
|
||||
msg = await asyncio.wait_for(websocket.receive(), timeout=60)
|
||||
except asyncio.TimeoutError:
|
||||
if time.monotonic() - last_activity > IDLE_TIMEOUT:
|
||||
await websocket.close(code=1000, reason="Idle timeout")
|
||||
break
|
||||
continue
|
||||
last_activity = time.monotonic()
|
||||
if "bytes" in msg and msg["bytes"]:
|
||||
data = msg["bytes"]
|
||||
if data[0:1] == b"\x01" and len(data) == 5:
|
||||
@@ -73,3 +106,4 @@ async def ws_endpoint(websocket: WebSocket, token: str = Query(""), host: str =
|
||||
process.close()
|
||||
finally:
|
||||
conn.close()
|
||||
_active_sessions.discard(session_id)
|
||||
|
||||
@@ -44,6 +44,7 @@ services:
|
||||
- /volume1:/volume1
|
||||
- /volume1/docker/nas-dashboard/ssh/dashboard_terminal:/app/ssh/id_ed25519:ro
|
||||
- /volume1/docker/nas-dashboard/ssh/vps_terminal:/app/ssh/vps_id_ed25519:ro
|
||||
- /volume1/docker/nas-dashboard/ssh/known_hosts:/app/ssh/known_hosts:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:4000/api/health')"]
|
||||
interval: 30s
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const token = localStorage.getItem("token") || "";
|
||||
const ws = new WebSocket(
|
||||
`${proto}//${location.host}/ws/terminal?token=${encodeURIComponent(token)}&host=${tab.hostId}`
|
||||
`${proto}//${location.host}/ws/terminal?host=${tab.hostId}`
|
||||
);
|
||||
ws.binaryType = "arraybuffer";
|
||||
function sendSize() {
|
||||
@@ -79,7 +79,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
ws.onopen = () => { updateTab(tabId, { connected: true }); sendSize(); };
|
||||
ws.onopen = () => { ws.send(token); updateTab(tabId, { connected: true }); sendSize(); };
|
||||
ws.onmessage = (e) => term.write(new Uint8Array(e.data));
|
||||
ws.onclose = () => {
|
||||
updateTab(tabId, { connected: false });
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACAA1B82gNubHN9YSbouzZBx5ZcBAfSXrTEf6j2+k9p/MwAAAJi8dYFQvHWB
|
||||
UAAAAAtzc2gtZWQyNTUxOQAAACAA1B82gNubHN9YSbouzZBx5ZcBAfSXrTEf6j2+k9p/Mw
|
||||
AAAEANwPXx/fEISyOhps72XCb5Okusk9wHUTW6TjHuqfKAEQDUHzaA25sc31hJui7NkHHl
|
||||
lwEB9JetMR/qPb6T2n8zAAAAEmRhc2hib2FyZC10ZXJtaW5hbAECAw==
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
@@ -1 +0,0 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIADUHzaA25sc31hJui7NkHHllwEB9JetMR/qPb6T2n8z dashboard-terminal
|
||||
@@ -1,7 +0,0 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACC6rZoMd8T2DBFC2hdfgWHhwnusiHtbZ753dLibmZDyNQAAAJiY03+ImNN/
|
||||
iAAAAAtzc2gtZWQyNTUxOQAAACC6rZoMd8T2DBFC2hdfgWHhwnusiHtbZ753dLibmZDyNQ
|
||||
AAAEBiLX9L0ASvdqTqmD/kOs/cuLpyUBgQl+j8+uHuVFrkN7qtmgx3xPYMEULaF1+BYeHC
|
||||
e6yIe1tnvnd0uJuZkPI1AAAAEW5hcy1kYXNoYm9hcmQtdnBzAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
@@ -1 +0,0 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILqtmgx3xPYMEULaF1+BYeHCe6yIe1tnvnd0uJuZkPI1 nas-dashboard-vps
|
||||
Reference in New Issue
Block a user