Compare commits
10 commits
api-rate-l
...
main
Author | SHA1 | Date | |
---|---|---|---|
65137b99ac | |||
7f36eafc11 | |||
fc77407379 | |||
0644d260de | |||
8641f7c2ba | |||
6804360352 | |||
f8a67e5fbd | |||
3fb4d18db8 | |||
e6bbd01ea9 | |||
6463f6f9a2 |
11 changed files with 99 additions and 10 deletions
2
LICENSE
2
LICENSE
|
@ -1,6 +1,6 @@
|
||||||
The 3-Clause BSD License
|
The 3-Clause BSD License
|
||||||
|
|
||||||
Copyright 2022 Ivan Golikov
|
Copyright 2025 Ivan Golikov
|
||||||
|
|
||||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
# Pssecret server
|
# Pssecret server
|
||||||
|
|
||||||
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
|
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
|
||||||
|
[![PyPI - Downloads](https://img.shields.io/pypi/dm/pssecret-server?label=PyPI%20downloads)](https://pypi.org/project/pssecret-server/)
|
||||||
|
|
||||||
Pssecret is self-hosted service to share secrets (like passwords) with somebody
|
Pssecret is self-hosted service to share secrets (like passwords) with somebody
|
||||||
over the network, but don't want them to appear in chats, unencrypted e-mails, etc.
|
over the network, but don't want them to appear in chats, unencrypted e-mails, etc.
|
||||||
|
@ -50,6 +51,7 @@ Available configuration options:
|
||||||
--uds TEXT Bind to a UNIX domain socket.
|
--uds TEXT Bind to a UNIX domain socket.
|
||||||
--workers INTEGER Number of worker processes. Defaults to the
|
--workers INTEGER Number of worker processes. Defaults to the
|
||||||
$WEB_CONCURRENCY environment variable if available, or 1.
|
$WEB_CONCURRENCY environment variable if available, or 1.
|
||||||
|
--version Show the version and exit.
|
||||||
--help Show this message and exit.
|
--help Show this message and exit.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
from importlib.metadata import version
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
|
@ -21,5 +23,6 @@ import uvicorn
|
||||||
),
|
),
|
||||||
type=int,
|
type=int,
|
||||||
)
|
)
|
||||||
|
@click.version_option(version("pssecret_server"))
|
||||||
def cli(**kwargs) -> None:
|
def cli(**kwargs) -> None:
|
||||||
uvicorn.run("pssecret_server.main:app", **kwargs)
|
uvicorn.run("pssecret_server.main:app", **kwargs)
|
||||||
|
|
|
@ -8,7 +8,7 @@ from redis.asyncio import Redis
|
||||||
from pssecret_server.fernet import get_fernet
|
from pssecret_server.fernet import get_fernet
|
||||||
from pssecret_server.models import Secret, SecretSaveResult
|
from pssecret_server.models import Secret, SecretSaveResult
|
||||||
from pssecret_server.redis_db import get_redis
|
from pssecret_server.redis_db import get_redis
|
||||||
from pssecret_server.utils import decrypt_secret, encrypt_secret, save_secret
|
from pssecret_server.utils import decrypt_secret, encrypt_secret, getdel, save_secret
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
|
@ -49,7 +49,7 @@ async def set_secret(
|
||||||
async def get_secret(
|
async def get_secret(
|
||||||
secret_key: str, redis: RedisDep, fernet: FernetDep
|
secret_key: str, redis: RedisDep, fernet: FernetDep
|
||||||
) -> dict[str, bytes]:
|
) -> dict[str, bytes]:
|
||||||
data: bytes | None = await redis.getdel(secret_key)
|
data: bytes | None = await getdel(redis, secret_key)
|
||||||
|
|
||||||
if data is None:
|
if data is None:
|
||||||
raise HTTPException(404)
|
raise HTTPException(404)
|
||||||
|
|
|
@ -2,7 +2,7 @@ from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
class Secret(BaseModel):
|
class Secret(BaseModel):
|
||||||
data: str = Field(title="Secret", description="Some secret data")
|
data: str = Field(title="Secret", description="Some secret data", min_length=1)
|
||||||
|
|
||||||
|
|
||||||
class SecretSaveResult(BaseModel):
|
class SecretSaveResult(BaseModel):
|
||||||
|
|
|
@ -3,7 +3,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(env_file=".env")
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||||
|
|
||||||
redis_url: RedisDsn = RedisDsn("redis://localhost")
|
redis_url: RedisDsn = RedisDsn("redis://localhost")
|
||||||
secrets_encryption_key: bytes
|
secrets_encryption_key: bytes
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
|
from functools import lru_cache
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
from redis.asyncio import Redis
|
from redis.asyncio import Redis
|
||||||
|
from redis.exceptions import ResponseError
|
||||||
|
from redis.typing import ResponseT
|
||||||
|
|
||||||
from pssecret_server.models import Secret
|
from pssecret_server.models import Secret
|
||||||
|
|
||||||
|
@ -30,3 +33,35 @@ async def save_secret(data: Secret, redis: Redis) -> str:
|
||||||
await redis.setex(new_key, 60 * 60 * 24, data.data)
|
await redis.setex(new_key, 60 * 60 * 24, data.data)
|
||||||
|
|
||||||
return new_key
|
return new_key
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
async def _is_getdel_available(redis: Redis) -> bool:
|
||||||
|
"""Checks the availability of GETDEL command on the Redis server instance
|
||||||
|
|
||||||
|
GETDEL is not available in Redis prior to version 6.2
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await redis.getdel("test:getdel:availability")
|
||||||
|
except ResponseError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def getdel(redis: Redis, key: str) -> ResponseT:
|
||||||
|
"""Gets the value of key and deletes the key
|
||||||
|
|
||||||
|
Depending on the capabilities of Redis server this function
|
||||||
|
will either call GETDEL command, either first call GETSET with empty string
|
||||||
|
and DEL right after that.
|
||||||
|
"""
|
||||||
|
result: ResponseT
|
||||||
|
|
||||||
|
if await _is_getdel_available(redis):
|
||||||
|
result = await redis.getdel(key)
|
||||||
|
else:
|
||||||
|
result = await redis.getset(key, "")
|
||||||
|
await redis.delete(key)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "pssecret-server"
|
name = "pssecret-server"
|
||||||
version = "0.0.1"
|
version = "1.1.2"
|
||||||
description = "API service for secrets sharing over network"
|
description = "API service for secrets sharing over network"
|
||||||
authors = ["Ivan Golikov <root@ivnglkv.me>"]
|
authors = ["Ivan Golikov <root@ivnglkv.me>"]
|
||||||
license = "BSD-3-Clause"
|
license = "BSD-3-Clause"
|
||||||
|
@ -9,12 +9,14 @@ homepage = "https://git.ivnglkv.me/root/pssecret-server"
|
||||||
repository = "https://git.ivnglkv.me/root/pssecret-server"
|
repository = "https://git.ivnglkv.me/root/pssecret-server"
|
||||||
documentation = "https://git.ivnglkv.me/root/pssecret-server/wiki"
|
documentation = "https://git.ivnglkv.me/root/pssecret-server/wiki"
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 2 - Pre-Alpha",
|
"Development Status :: 5 - Production/Stable",
|
||||||
"Environment :: Web Environment",
|
"Environment :: Web Environment",
|
||||||
"Framework :: FastAPI",
|
"Framework :: FastAPI",
|
||||||
"Intended Audience :: Information Technology",
|
"Intended Audience :: Information Technology",
|
||||||
"Operating System :: OS Independent",
|
"Operating System :: OS Independent",
|
||||||
"Programming Language :: Python :: 3.11",
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
"Topic :: Internet :: WWW/HTTP :: WSGI :: Application"
|
"Topic :: Internet :: WWW/HTTP :: WSGI :: Application"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -51,3 +53,6 @@ reportUnusedCallResult = "none"
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
|
|
||||||
|
[tool.isort]
|
||||||
|
profile = "black"
|
||||||
|
|
|
@ -3,6 +3,13 @@ from fastapi.testclient import TestClient
|
||||||
from tests.factories import SecretFactory
|
from tests.factories import SecretFactory
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_secret_is_not_accepted(client: TestClient):
|
||||||
|
response = client.post("/secret", json={"data": ""})
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert "data" in response.text
|
||||||
|
|
||||||
|
|
||||||
def test_store_secret_returns_key(client: TestClient):
|
def test_store_secret_returns_key(client: TestClient):
|
||||||
response = client.post("/secret", json=dict(SecretFactory().build()))
|
response = client.post("/secret", json=dict(SecretFactory().build()))
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
from unittest.mock import patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
from redis.asyncio import Redis
|
from redis.asyncio import Redis
|
||||||
|
|
||||||
from pssecret_server.utils import get_new_key, save_secret
|
from pssecret_server.utils import get_new_key, getdel, save_secret
|
||||||
|
|
||||||
from ..factories import SecretFactory
|
from ..factories import SecretFactory
|
||||||
|
|
||||||
|
@ -33,3 +34,22 @@ async def test_save_secret_data(redis_server: Redis) -> None:
|
||||||
|
|
||||||
assert redis_data is not None
|
assert redis_data is not None
|
||||||
assert redis_data.decode() == secret.data
|
assert redis_data.decode() == secret.data
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("getdel_available", [True, False])
|
||||||
|
@patch("pssecret_server.utils._is_getdel_available", new_callable=AsyncMock)
|
||||||
|
async def test_getdel(
|
||||||
|
mock_is_getdel_available: AsyncMock,
|
||||||
|
getdel_available: bool,
|
||||||
|
redis_server: Redis,
|
||||||
|
) -> None:
|
||||||
|
mock_is_getdel_available.return_value = getdel_available
|
||||||
|
|
||||||
|
test_value = "test_data"
|
||||||
|
test_key = "test_key"
|
||||||
|
await redis_server.set(test_key, test_value)
|
||||||
|
|
||||||
|
result = await getdel(redis_server, test_key)
|
||||||
|
|
||||||
|
assert result.decode() == test_value # pyright: ignore[reportAttributeAccessIssue]
|
||||||
|
assert not await redis_server.exists(test_key)
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from cryptography.fernet import Fernet, InvalidToken
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
|
from redis.exceptions import ResponseError
|
||||||
|
|
||||||
from pssecret_server.utils import decrypt_secret, encrypt_secret
|
from pssecret_server.utils import _is_getdel_available, decrypt_secret, encrypt_secret
|
||||||
|
|
||||||
from ..factories import SecretFactory
|
from ..factories import SecretFactory
|
||||||
|
|
||||||
|
@ -29,3 +32,17 @@ def test_secret_is_not_decryptable_by_random_key(fernet: Fernet):
|
||||||
|
|
||||||
with pytest.raises(InvalidToken):
|
with pytest.raises(InvalidToken):
|
||||||
decrypt_secret(encrypted_secret.data.encode(), random_fernet)
|
decrypt_secret(encrypted_secret.data.encode(), random_fernet)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("getdel_effect", "expected_result"), [(None, True), (ResponseError, False)]
|
||||||
|
)
|
||||||
|
async def test_is_getdel_available(
|
||||||
|
getdel_effect: ResponseError | None, expected_result: bool
|
||||||
|
):
|
||||||
|
redis = AsyncMock()
|
||||||
|
redis.getdel.side_effect = getdel_effect # pyright: ignore[reportAny]
|
||||||
|
|
||||||
|
result = await _is_getdel_available(redis)
|
||||||
|
|
||||||
|
assert result is expected_result
|
||||||
|
|
Loading…
Reference in a new issue