Compare commits

...

6 commits

8 changed files with 92 additions and 53 deletions

View file

@ -5,7 +5,7 @@
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "Python: Modul", "name": "Main App",
"type": "python", "type": "python",
"request": "launch", "request": "launch",
"module": "kiwi_vpn_api.main", "module": "kiwi_vpn_api.main",

View file

@ -227,6 +227,7 @@ class CryptoConfig(BaseModel):
schemes: list[str] = ["bcrypt"] schemes: list[str] = ["bcrypt"]
# pki settings # pki settings
ca_password: str | None
cert_algo: CertificateAlgo | None cert_algo: CertificateAlgo | None
expiry_days: int | None expiry_days: int | None

View file

@ -1,3 +1,7 @@
"""
Python interface to EasyRSA CA.
"""
import subprocess import subprocess
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@ -5,20 +9,32 @@ from pathlib import Path
from OpenSSL import crypto from OpenSSL import crypto
from passlib import pwd from passlib import pwd
from .config import Config, Settings
class EasyRSA: class EasyRSA:
__directory: Path | None """
__ca_password: str | None Represents an EasyRSA PKI.
"""
def __init__(self, directory: Path) -> None: @property
self.__directory = directory def pki_directory(self) -> Path:
return Settings._.data_dir.joinpath("pki")
def set_ca_password(self, password: str | None = None) -> None: @property
if password is None: def ca_password(self) -> str:
password = pwd.genword(length=32, charset="ascii_62") config = Config._
self.__ca_password = password if (ca_password := config.crypto.ca_password) is None:
print(self.__ca_password) ca_password = pwd.genword(
length=32,
charset="ascii_62",
)
config.crypto.ca_password = ca_password
config.save()
return config.crypto.ca_password
def __easyrsa( def __easyrsa(
self, self,
@ -27,7 +43,7 @@ class EasyRSA:
return subprocess.run( return subprocess.run(
[ [
"easyrsa", "--batch", "easyrsa", "--batch",
f"--pki-dir={self.__directory}", f"--pki-dir={self.pki_directory}",
*easyrsa_args, *easyrsa_args,
], ],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
@ -42,7 +58,7 @@ class EasyRSA:
self.__easyrsa(*easyrsa_args) self.__easyrsa(*easyrsa_args)
with open( with open(
self.__directory.joinpath(cert_filename), "r" self.pki_directory.joinpath(cert_filename), "r"
) as cert_file: ) as cert_file:
return crypto.load_certificate( return crypto.load_certificate(
crypto.FILETYPE_PEM, cert_file.read() crypto.FILETYPE_PEM, cert_file.read()
@ -53,14 +69,14 @@ class EasyRSA:
def build_ca( def build_ca(
self, self,
days: int = 365 * 50,
cn: str = "kiwi-vpn-ca"
) -> crypto.X509: ) -> crypto.X509:
config = Config._
cert = self.__build_cert( cert = self.__build_cert(
Path("ca.crt"), Path("ca.crt"),
f"--passout=pass:{self.__ca_password}", f"--passout=pass:{self.ca_password}",
f"--passin=pass:{self.__ca_password}", f"--passin=pass:{self.ca_password}",
# "--dn-mode=org", # "--dn-mode=org",
# "--req-c=EX", # "--req-c=EX",
@ -70,8 +86,8 @@ class EasyRSA:
# "--req-ou=EXAMPLE", # "--req-ou=EXAMPLE",
# "--req-email=EXAMPLE", # "--req-email=EXAMPLE",
f"--req-cn={cn}", f"--req-cn={config.server_name}",
f"--days={days}", f"--days={config.crypto.expiry_days}",
# "--use-algo=ed", # "--use-algo=ed",
# "--curve=ed25519", # "--curve=ed25519",
@ -79,20 +95,21 @@ class EasyRSA:
"build-ca", "build-ca",
) )
self.__easyrsa("gen-dh") # self.__easyrsa("gen-dh")
return cert return cert
def issue( def issue(
self, self,
days: int = 365 * 50,
cn: str = "kiwi-vpn-client", cn: str = "kiwi-vpn-client",
cert_type: str = "client" cert_type: str = "client"
) -> crypto.X509: ) -> crypto.X509:
config = Config._
return self.__build_cert( return self.__build_cert(
Path(f"issued/{cn}.crt"), Path(f"issued/{cn}.crt"),
f"--passin=pass:{self.__ca_password}", f"--passin=pass:{self.ca_password}",
f"--days={days}", f"--days={config.crypto.expiry_days}",
f"build-{cert_type}-full", f"build-{cert_type}-full",
cn, cn,
@ -101,11 +118,10 @@ class EasyRSA:
if __name__ == "__main__": if __name__ == "__main__":
easy_rsa = EasyRSA(Path("tmp/easyrsa")) easy_rsa = EasyRSA()
easy_rsa.init_pki() easy_rsa.init_pki()
easy_rsa.set_ca_password()
ca = easy_rsa.build_ca(cn="kiwi-vpn-ca") ca = easy_rsa.build_ca()
server = easy_rsa.issue(cert_type="server", cn="kiwi-vpn-server") server = easy_rsa.issue(cert_type="server", cn="kiwi-vpn-server")
client = easy_rsa.issue(cert_type="client", cn="kiwi-vpn-client") client = easy_rsa.issue(cert_type="client", cn="kiwi-vpn-client")

View file

@ -7,11 +7,12 @@ This file: Main API router definition.
from fastapi import APIRouter from fastapi import APIRouter
from ..config import Settings from ..config import Settings
from . import admin, device, user from . import admin, device, service, user
main_router = APIRouter() main_router = APIRouter(prefix=f"/{Settings._.api_v1_prefix}")
main_router.include_router(admin.router, prefix=f"/{Settings._.api_v1_prefix}") main_router.include_router(admin.router)
main_router.include_router(service.router)
main_router.include_router(device.router) main_router.include_router(device.router)
main_router.include_router(user.router) main_router.include_router(user.router)

View file

@ -2,7 +2,6 @@
Common dependencies for routers. Common dependencies for routers.
""" """
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
@ -50,18 +49,28 @@ class Responses:
} }
async def get_current_config(
current_config: Config | None = Depends(Config.load),
) -> Config:
"""
Get the current configuration if it exists.
"""
# fail if not configured
if current_config is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
return current_config
async def get_current_user( async def get_current_user(
token: str = Depends(oauth2_scheme), token: str = Depends(oauth2_scheme),
current_config: Config | None = Depends(Config.load), current_config: Config = Depends(get_current_config),
) -> User: ) -> User:
""" """
Get the currently logged-in user if it exists. Get the currently logged-in user if it exists.
""" """
# can't connect to an unconfigured database
if current_config is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
username = await current_config.jwt.decode_token(token) username = await current_config.jwt.decode_token(token)
# fail if not requested by a user # fail if not requested by a user
@ -74,22 +83,18 @@ async def get_current_user(
async def get_user_by_name( async def get_user_by_name(
user_name: str, user_name: str,
current_config: Config | None = Depends(Config.load), _: Config = Depends(get_current_config),
) -> User | None: ) -> User | None:
""" """
Get a user by name. Get a user by name.
""" """
# can't connect to an unconfigured database
if current_config is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
return User.get(user_name) return User.get(user_name)
async def get_device_by_id( async def get_device_by_id(
device_id: int, device_id: int,
current_config: Config | None = Depends(Config.load), current_config: Config = Depends(get_current_config),
) -> Device | None: ) -> Device | None:
# can't connect to an unconfigured database # can't connect to an unconfigured database

View file

@ -2,13 +2,12 @@
/admin endpoints. /admin endpoints.
""" """
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import select from sqlmodel import select
from ..config import Config from ..config import Config
from ..db import Connection, TagValue, User, UserCreate from ..db import Connection, TagValue, User, UserCreate
from ._common import Responses, get_current_user from ._common import Responses, get_current_config, get_current_user
router = APIRouter(prefix="/admin", tags=["admin"]) router = APIRouter(prefix="/admin", tags=["admin"])
@ -47,16 +46,12 @@ async def initial_configure(
) )
async def create_initial_admin( async def create_initial_admin(
admin_user: UserCreate, admin_user: UserCreate,
current_config: Config | None = Depends(Config.load), current_config: Config = Depends(get_current_config),
): ):
""" """
PUT ./install/admin: Create the first administrative user. PUT ./install/admin: Create the first administrative user.
""" """
# fail if not configured
if current_config is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
# fail if any user exists # fail if any user exists
with Connection.session as db: with Connection.session as db:
if db.exec(select(User).limit(1)).first() is not None: if db.exec(select(User).limit(1)).first() is not None:

View file

@ -0,0 +1,24 @@
"""
/service endpoints.
"""
from fastapi import APIRouter, Depends, HTTPException, status
from ..config import Config
from ._common import Responses, get_current_config
router = APIRouter(prefix="/service", tags=["service"])
@router.put(
"/pki/init",
responses={
status.HTTP_200_OK: Responses.OK,
status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED,
status.HTTP_403_FORBIDDEN: Responses.NEEDS_PERMISSION,
},
)
async def init_pki(
_: Config = Depends(get_current_config),
) -> None:
pass

View file

@ -8,7 +8,8 @@ from pydantic import BaseModel
from ..config import Config from ..config import Config
from ..db import TagValue, User, UserCreate, UserRead from ..db import TagValue, User, UserCreate, UserRead
from ._common import Responses, get_current_user, get_user_by_name from ._common import (Responses, get_current_config, get_current_user,
get_user_by_name)
router = APIRouter(prefix="/user", tags=["user"]) router = APIRouter(prefix="/user", tags=["user"])
@ -25,16 +26,12 @@ class Token(BaseModel):
@router.post("/authenticate", response_model=Token) @router.post("/authenticate", response_model=Token)
async def login( async def login(
form_data: OAuth2PasswordRequestForm = Depends(), form_data: OAuth2PasswordRequestForm = Depends(),
current_config: Config | None = Depends(Config.load), current_config: Config = Depends(get_current_config),
): ):
""" """
POST ./authenticate: Authenticate a user. Issues a bearer token. POST ./authenticate: Authenticate a user. Issues a bearer token.
""" """
# fail if not installed
if current_config is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
# try logging in # try logging in
if (user := User.authenticate( if (user := User.authenticate(
name=form_data.username, name=form_data.username,