webgate 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.
webgate/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """webgate — self-hosted SSH terminal and SFTP file browser."""
webgate/__main__.py ADDED
@@ -0,0 +1,18 @@
1
+ import uvicorn
2
+
3
+ from webgate.config import settings
4
+
5
+
6
+ def main() -> None:
7
+ uvicorn.run(
8
+ "webgate.app:create_app",
9
+ factory=True,
10
+ host=settings.host,
11
+ port=settings.port,
12
+ log_level=settings.log_level,
13
+ reload=False,
14
+ )
15
+
16
+
17
+ if __name__ == "__main__":
18
+ main()
webgate/app.py ADDED
@@ -0,0 +1,58 @@
1
+ from collections.abc import AsyncGenerator
2
+ from contextlib import asynccontextmanager
3
+
4
+ from fastapi import FastAPI
5
+ from fastapi.middleware.cors import CORSMiddleware
6
+ from fastapi.staticfiles import StaticFiles
7
+
8
+ from slowapi import _rate_limit_exceeded_handler
9
+ from slowapi.errors import RateLimitExceeded
10
+
11
+ from webgate.auth.routes import limiter
12
+ from webgate.auth.routes import router as auth_router
13
+ from webgate.auth.service import seed_admin
14
+ from webgate.config import settings
15
+ from webgate.db.engine import async_session_factory, close_db, init_db
16
+ from webgate.files.pool import sftp_pool
17
+ from webgate.files.routes import router as files_router
18
+ from webgate.servers.routes import router as servers_router
19
+ from webgate.terminal.routes import router as terminal_router
20
+
21
+
22
+ @asynccontextmanager
23
+ async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
24
+ await init_db()
25
+ async with async_session_factory() as session:
26
+ await seed_admin(session)
27
+ await sftp_pool.start()
28
+ yield
29
+ await sftp_pool.stop()
30
+ await close_db()
31
+
32
+
33
+ def create_app() -> FastAPI:
34
+ app = FastAPI(title="webgate", version="0.1.0", lifespan=lifespan)
35
+ app.state.limiter = limiter
36
+ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
37
+
38
+ origins = [o.strip() for o in settings.allowed_origins.split(",")]
39
+ app.add_middleware(
40
+ CORSMiddleware,
41
+ allow_origins=origins,
42
+ allow_credentials=True,
43
+ allow_methods=["*"],
44
+ allow_headers=["*"],
45
+ )
46
+
47
+ @app.get("/api/health")
48
+ async def health() -> dict[str, str]: # pyright: ignore[reportUnusedFunction]
49
+ return {"status": "ok"}
50
+
51
+ app.include_router(auth_router)
52
+ app.include_router(servers_router)
53
+ app.include_router(terminal_router)
54
+ app.include_router(files_router)
55
+
56
+ app.mount("/", StaticFiles(directory=str(settings.static_dir), html=True), name="static")
57
+
58
+ return app
File without changes
@@ -0,0 +1,31 @@
1
+ from datetime import datetime
2
+
3
+ from pydantic import BaseModel
4
+ from sqlalchemy import DateTime, Integer, String, Text, func
5
+ from sqlalchemy.orm import Mapped, mapped_column
6
+
7
+ from webgate.db.engine import Base
8
+
9
+
10
+ class AuditEntry(Base):
11
+ __tablename__ = "audit_log"
12
+
13
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
14
+ user_id: Mapped[int] = mapped_column(Integer)
15
+ username: Mapped[str] = mapped_column(String(150))
16
+ action: Mapped[str] = mapped_column(String(50)) # login, ssh_connect, sftp_ls, server_create, etc.
17
+ detail: Mapped[str] = mapped_column(Text, default="")
18
+ ip_address: Mapped[str] = mapped_column(String(45), default="")
19
+ created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
20
+
21
+
22
+ class AuditOut(BaseModel):
23
+ id: int
24
+ user_id: int
25
+ username: str
26
+ action: str
27
+ detail: str
28
+ ip_address: str
29
+ created_at: datetime
30
+
31
+ model_config = {"from_attributes": True}
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from sqlalchemy import select
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+
6
+ from webgate.audit.models import AuditEntry, AuditOut
7
+ from webgate.db.engine import async_session_factory
8
+
9
+
10
+ async def log_action(
11
+ user_id: int,
12
+ username: str,
13
+ action: str,
14
+ detail: str = "",
15
+ ip_address: str = "",
16
+ ) -> None:
17
+ """Fire-and-forget audit log entry."""
18
+ async with async_session_factory() as session:
19
+ entry = AuditEntry(
20
+ user_id=user_id,
21
+ username=username,
22
+ action=action,
23
+ detail=detail,
24
+ ip_address=ip_address,
25
+ )
26
+ session.add(entry)
27
+ await session.commit()
28
+
29
+
30
+ async def get_audit_log(
31
+ session: AsyncSession,
32
+ limit: int = 100,
33
+ offset: int = 0,
34
+ username: str | None = None,
35
+ action: str | None = None,
36
+ ) -> list[AuditOut]:
37
+ stmt = select(AuditEntry).order_by(AuditEntry.created_at.desc())
38
+ if username:
39
+ stmt = stmt.where(AuditEntry.username == username)
40
+ if action:
41
+ stmt = stmt.where(AuditEntry.action == action)
42
+ stmt = stmt.offset(offset).limit(limit)
43
+ result = await session.execute(stmt)
44
+ return [AuditOut.model_validate(e) for e in result.scalars().all()]
File without changes
webgate/auth/models.py ADDED
@@ -0,0 +1,70 @@
1
+ import json
2
+ from datetime import datetime
3
+
4
+ from pydantic import BaseModel, field_validator
5
+ from sqlalchemy import Boolean, DateTime, String, Text, func
6
+ from sqlalchemy.orm import Mapped, mapped_column
7
+
8
+ from webgate.db.engine import Base
9
+
10
+
11
+ class User(Base):
12
+ __tablename__ = "users"
13
+
14
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
15
+ username: Mapped[str] = mapped_column(String(150), unique=True, index=True)
16
+ hashed_password: Mapped[str] = mapped_column(String(255))
17
+ is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
18
+ must_change_password: Mapped[bool] = mapped_column(Boolean, default=False)
19
+ allowed_groups: Mapped[str] = mapped_column(Text, default="[]") # JSON array of group names
20
+ created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
21
+
22
+
23
+ class UserCreate(BaseModel):
24
+ username: str
25
+ password: str
26
+
27
+
28
+ class UserLogin(BaseModel):
29
+ username: str
30
+ password: str
31
+
32
+
33
+ class UserOut(BaseModel):
34
+ id: int
35
+ username: str
36
+ is_admin: bool
37
+ must_change_password: bool = False
38
+ allowed_groups: list[str] = []
39
+
40
+ model_config = {"from_attributes": True}
41
+
42
+ @field_validator("allowed_groups", mode="before")
43
+ @classmethod
44
+ def parse_groups(cls, v: object) -> list[str]:
45
+ if isinstance(v, str):
46
+ try:
47
+ parsed = json.loads(v)
48
+ return parsed if isinstance(parsed, list) else []
49
+ except (json.JSONDecodeError, TypeError):
50
+ return []
51
+ return v if isinstance(v, list) else []
52
+
53
+
54
+ class UserManage(BaseModel):
55
+ username: str
56
+ password: str = ""
57
+ allowed_groups: list[str] = []
58
+
59
+
60
+ class UserUpdateGroups(BaseModel):
61
+ allowed_groups: list[str]
62
+
63
+
64
+ class ChangePassword(BaseModel):
65
+ new_password: str
66
+
67
+
68
+ class TokenOut(BaseModel):
69
+ access_token: str
70
+ token_type: str = "bearer"
webgate/auth/routes.py ADDED
@@ -0,0 +1,175 @@
1
+ from typing import Annotated
2
+
3
+ from fastapi import APIRouter, Depends, HTTPException, Request, status
4
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
5
+ from slowapi import Limiter
6
+ from slowapi.util import get_remote_address
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+
9
+ limiter = Limiter(key_func=get_remote_address)
10
+
11
+ from webgate.audit.models import AuditOut
12
+ from webgate.audit.service import get_audit_log, log_action
13
+ from webgate.auth.models import (
14
+ ChangePassword,
15
+ TokenOut,
16
+ UserLogin,
17
+ UserManage,
18
+ UserOut,
19
+ UserUpdateGroups,
20
+ )
21
+ from webgate.auth.service import (
22
+ create_access_token,
23
+ create_user,
24
+ decode_access_token,
25
+ delete_user,
26
+ get_user_by_id,
27
+ get_user_by_username,
28
+ list_users,
29
+ update_user_groups,
30
+ update_user_password,
31
+ verify_password,
32
+ )
33
+ from webgate.db.engine import get_session
34
+
35
+ router = APIRouter(prefix="/api/auth", tags=["auth"])
36
+ security = HTTPBearer()
37
+
38
+ SessionDep = Annotated[AsyncSession, Depends(get_session)]
39
+ AuthDep = Annotated[HTTPAuthorizationCredentials, Depends(security)]
40
+
41
+
42
+ async def get_current_user(credentials: AuthDep, session: SessionDep) -> UserOut:
43
+ payload = decode_access_token(credentials.credentials)
44
+ if payload is None:
45
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
46
+ user_id = payload.get("sub")
47
+ if user_id is None:
48
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
49
+ user = await get_user_by_id(session, int(user_id))
50
+ if user is None:
51
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
52
+ return UserOut.model_validate(user)
53
+
54
+
55
+ CurrentUserDep = Annotated[UserOut, Depends(get_current_user)]
56
+
57
+
58
+ def _require_admin(user: UserOut) -> None:
59
+ if not user.is_admin:
60
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin only")
61
+
62
+
63
+ @router.post("/login", response_model=TokenOut)
64
+ @limiter.limit("10/minute")
65
+ async def login(request: Request, body: UserLogin, session: SessionDep) -> TokenOut:
66
+ user = await get_user_by_username(session, body.username)
67
+ if not user or not verify_password(body.password, user.hashed_password):
68
+ raise HTTPException(
69
+ status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials"
70
+ )
71
+ token = create_access_token({"sub": str(user.id), "username": user.username})
72
+ await log_action(user.id, user.username, "login", ip_address=request.client.host if request.client else "")
73
+ return TokenOut(access_token=token)
74
+
75
+
76
+ @router.get("/me", response_model=UserOut)
77
+ async def me(current_user: CurrentUserDep) -> UserOut:
78
+ return current_user
79
+
80
+
81
+ @router.post("/change-password", response_model=UserOut)
82
+ @limiter.limit("5/minute")
83
+ async def change_password(
84
+ request: Request, body: ChangePassword, session: SessionDep, current_user: CurrentUserDep
85
+ ) -> UserOut:
86
+ if len(body.new_password) < 4:
87
+ raise HTTPException(
88
+ status_code=status.HTTP_400_BAD_REQUEST, detail="Password too short (min 4 chars)"
89
+ )
90
+ user = await get_user_by_id(session, current_user.id)
91
+ if not user:
92
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
93
+ updated = await update_user_password(session, user, body.new_password)
94
+ updated.must_change_password = False
95
+ await session.commit()
96
+ await session.refresh(updated)
97
+ return UserOut.model_validate(updated)
98
+
99
+
100
+ # ---- Admin: user management ----
101
+
102
+
103
+ @router.get("/users", response_model=list[UserOut])
104
+ async def get_users(session: SessionDep, current_user: CurrentUserDep) -> list[UserOut]:
105
+ _require_admin(current_user)
106
+ users = await list_users(session)
107
+ return [UserOut.model_validate(u) for u in users]
108
+
109
+
110
+ @router.post("/users", response_model=UserOut, status_code=status.HTTP_201_CREATED)
111
+ async def create_new_user(
112
+ body: UserManage, session: SessionDep, current_user: CurrentUserDep
113
+ ) -> UserOut:
114
+ _require_admin(current_user)
115
+ existing = await get_user_by_username(session, body.username)
116
+ if existing:
117
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Username already taken")
118
+ if not body.password:
119
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Password required")
120
+ user = await create_user(
121
+ session, body.username, body.password, allowed_groups=body.allowed_groups
122
+ )
123
+ return UserOut.model_validate(user)
124
+
125
+
126
+ @router.put("/users/{user_id}/groups", response_model=UserOut)
127
+ async def set_user_groups(
128
+ user_id: int, body: UserUpdateGroups, session: SessionDep, current_user: CurrentUserDep
129
+ ) -> UserOut:
130
+ _require_admin(current_user)
131
+ user = await get_user_by_id(session, user_id)
132
+ if not user:
133
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
134
+ if user.is_admin:
135
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot modify admin")
136
+ updated = await update_user_groups(session, user, body.allowed_groups)
137
+ return UserOut.model_validate(updated)
138
+
139
+
140
+ @router.put("/users/{user_id}/password", response_model=UserOut)
141
+ async def reset_user_password(
142
+ user_id: int, body: UserLogin, session: SessionDep, current_user: CurrentUserDep
143
+ ) -> UserOut:
144
+ _require_admin(current_user)
145
+ user = await get_user_by_id(session, user_id)
146
+ if not user:
147
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
148
+ updated = await update_user_password(session, user, body.password)
149
+ return UserOut.model_validate(updated)
150
+
151
+
152
+ @router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
153
+ async def remove_user(
154
+ user_id: int, session: SessionDep, current_user: CurrentUserDep
155
+ ) -> None:
156
+ _require_admin(current_user)
157
+ user = await get_user_by_id(session, user_id)
158
+ if not user:
159
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
160
+ if user.is_admin:
161
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot delete admin")
162
+ await delete_user(session, user)
163
+
164
+
165
+ @router.get("/audit", response_model=list[AuditOut])
166
+ async def audit_log_endpoint(
167
+ session: SessionDep,
168
+ current_user: CurrentUserDep,
169
+ limit: int = 100,
170
+ offset: int = 0,
171
+ username: str | None = None,
172
+ action: str | None = None,
173
+ ) -> list[AuditOut]:
174
+ _require_admin(current_user)
175
+ return await get_audit_log(session, limit=limit, offset=offset, username=username, action=action)
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ from datetime import UTC, datetime, timedelta
6
+ from typing import Any
7
+
8
+ import bcrypt
9
+ from jose import JWTError, jwt
10
+ from sqlalchemy import func, select
11
+ from sqlalchemy.ext.asyncio import AsyncSession
12
+
13
+ from webgate.auth.models import User
14
+ from webgate.config import settings
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def hash_password(password: str) -> str:
20
+ return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
21
+
22
+
23
+ def verify_password(plain: str, hashed: str) -> bool:
24
+ return bcrypt.checkpw(plain.encode(), hashed.encode())
25
+
26
+
27
+ def create_access_token(data: dict[str, Any]) -> str:
28
+ to_encode = data.copy()
29
+ expire = datetime.now(UTC) + timedelta(minutes=settings.jwt_expire_minutes)
30
+ to_encode["exp"] = expire
31
+ return jwt.encode(to_encode, settings.secret_key, algorithm=settings.jwt_algorithm)
32
+
33
+
34
+ def decode_access_token(token: str) -> dict[str, Any] | None:
35
+ try:
36
+ payload: dict[str, Any] = jwt.decode(
37
+ token, settings.secret_key, algorithms=[settings.jwt_algorithm]
38
+ )
39
+ return payload
40
+ except JWTError:
41
+ return None
42
+
43
+
44
+ async def get_user_by_username(session: AsyncSession, username: str) -> User | None:
45
+ result = await session.execute(select(User).where(User.username == username))
46
+ return result.scalar_one_or_none()
47
+
48
+
49
+ async def get_user_by_id(session: AsyncSession, user_id: int) -> User | None:
50
+ result = await session.execute(select(User).where(User.id == user_id))
51
+ return result.scalar_one_or_none()
52
+
53
+
54
+ async def get_user_count(session: AsyncSession) -> int:
55
+ result = await session.execute(select(func.count()).select_from(User))
56
+ count = result.scalar_one()
57
+ return int(count)
58
+
59
+
60
+ async def create_user(
61
+ session: AsyncSession,
62
+ username: str,
63
+ password: str,
64
+ is_admin: bool = False,
65
+ allowed_groups: list[str] | None = None,
66
+ ) -> User:
67
+ user = User(
68
+ username=username,
69
+ hashed_password=hash_password(password),
70
+ is_admin=is_admin,
71
+ allowed_groups=json.dumps(allowed_groups or []),
72
+ )
73
+ session.add(user)
74
+ await session.commit()
75
+ await session.refresh(user)
76
+ return user
77
+
78
+
79
+ async def seed_admin(session: AsyncSession) -> None:
80
+ """Create default admin user if no users exist."""
81
+ count = await get_user_count(session)
82
+ if count > 0:
83
+ return
84
+ user = await create_user(session, "admin", "admin", is_admin=True)
85
+ user.must_change_password = True
86
+ await session.commit()
87
+ logger.info("Created default admin user (admin/admin) — password change required on first login")
88
+
89
+
90
+ async def list_users(session: AsyncSession) -> list[User]:
91
+ result = await session.execute(select(User).order_by(User.username))
92
+ return list(result.scalars().all())
93
+
94
+
95
+ async def update_user_groups(
96
+ session: AsyncSession, user: User, groups: list[str]
97
+ ) -> User:
98
+ user.allowed_groups = json.dumps(groups)
99
+ await session.commit()
100
+ await session.refresh(user)
101
+ return user
102
+
103
+
104
+ async def update_user_password(
105
+ session: AsyncSession, user: User, new_password: str
106
+ ) -> User:
107
+ user.hashed_password = hash_password(new_password)
108
+ await session.commit()
109
+ await session.refresh(user)
110
+ return user
111
+
112
+
113
+ async def delete_user(session: AsyncSession, user: User) -> None:
114
+ await session.delete(user)
115
+ await session.commit()
webgate/config.py ADDED
@@ -0,0 +1,28 @@
1
+ from pathlib import Path
2
+
3
+ from pydantic_settings import BaseSettings
4
+
5
+
6
+ class Settings(BaseSettings):
7
+ model_config = {"env_prefix": "WEBGATE_"}
8
+
9
+ host: str = "0.0.0.0"
10
+ port: int = 8443
11
+ secret_key: str = "change-me-in-production"
12
+ db_url: str = "sqlite+aiosqlite:///./webgate.db"
13
+ allowed_origins: str = "*"
14
+ log_level: str = "info"
15
+ session_timeout: int = 3600
16
+ max_upload_size: int = 104857600 # 100MB
17
+ first_run: bool = True
18
+
19
+ # JWT settings
20
+ jwt_algorithm: str = "HS256"
21
+ jwt_expire_minutes: int = 1440 # 24 hours
22
+
23
+ @property
24
+ def static_dir(self) -> Path:
25
+ return Path(__file__).parent / "static"
26
+
27
+
28
+ settings = Settings()
webgate/db/__init__.py ADDED
File without changes
webgate/db/engine.py ADDED
@@ -0,0 +1,36 @@
1
+ from collections.abc import AsyncGenerator
2
+
3
+ from sqlalchemy import MetaData
4
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
5
+ from sqlalchemy.orm import DeclarativeBase
6
+
7
+ from webgate.config import settings
8
+
9
+ engine = create_async_engine(settings.db_url, echo=False)
10
+ async_session_factory = async_sessionmaker(engine, expire_on_commit=False)
11
+
12
+ convention = {
13
+ "ix": "ix_%(column_0_label)s",
14
+ "uq": "uq_%(table_name)s_%(column_0_name)s",
15
+ "ck": "ck_%(table_name)s_%(constraint_name)s",
16
+ "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
17
+ "pk": "pk_%(table_name)s",
18
+ }
19
+
20
+
21
+ class Base(DeclarativeBase):
22
+ metadata = MetaData(naming_convention=convention)
23
+
24
+
25
+ async def get_session() -> AsyncGenerator[AsyncSession]:
26
+ async with async_session_factory() as session:
27
+ yield session
28
+
29
+
30
+ async def init_db() -> None:
31
+ async with engine.begin() as conn:
32
+ await conn.run_sync(Base.metadata.create_all)
33
+
34
+
35
+ async def close_db() -> None:
36
+ await engine.dispose()
File without changes
@@ -0,0 +1,36 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class FileEntry(BaseModel):
5
+ name: str
6
+ path: str
7
+ is_dir: bool
8
+ size: int
9
+ permissions: str
10
+ owner: str
11
+ group: str
12
+ modified: str
13
+
14
+
15
+ class DirectoryListing(BaseModel):
16
+ path: str
17
+ entries: list[FileEntry]
18
+
19
+
20
+ class FileWriteRequest(BaseModel):
21
+ path: str
22
+ content: str
23
+
24
+
25
+ class MkdirRequest(BaseModel):
26
+ path: str
27
+
28
+
29
+ class RenameRequest(BaseModel):
30
+ old_path: str
31
+ new_path: str
32
+
33
+
34
+ class ChmodRequest(BaseModel):
35
+ path: str
36
+ mode: str