refactoring-backend #3

Merged
lukas.pupkalipinski merged 403 commits from refactoring-backend into main 2026-05-20 20:23:46 +02:00
15 changed files with 624 additions and 2 deletions
Showing only changes of commit 8f26776bb3 - Show all commits

View File

@@ -128,6 +128,11 @@ _NamePath = Annotated[
"",
response_model=ActionListResponse,
summary="List all available actions with active/inactive status",
responses={
200: {"description": "Action list returned", "model": ActionListResponse},
401: {"description": "Session missing, expired, or invalid"},
502: {"description": "fail2ban unreachable"},
},
)
async def list_actions(
request: Request,
@@ -165,6 +170,12 @@ async def list_actions(
"/{name}",
response_model=ActionConfig,
summary="Return full parsed detail for a single action",
responses={
200: {"description": "Action config returned", "model": ActionConfig},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Action not found in action.d/"},
502: {"description": "fail2ban unreachable"},
},
)
async def get_action(
request: Request,
@@ -205,6 +216,14 @@ async def get_action(
response_model=ActionConfig,
summary="Update an action's .local override with new lifecycle command values",
dependencies=[Depends(_check_action_update_rate_limit)],
responses={
200: {"description": "Action updated", "model": ActionConfig},
400: {"description": "Invalid action name"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Action not found"},
429: {"description": "Rate limit exceeded for action update operations"},
500: {"description": "Failed to write .local file"},
},
)
async def update_action(
request: Request,
@@ -246,6 +265,14 @@ async def update_action(
status_code=status.HTTP_201_CREATED,
summary="Create a new user-defined action",
dependencies=[Depends(_check_action_create_rate_limit)],
responses={
201: {"description": "Action created", "model": ActionConfig},
400: {"description": "Invalid action name"},
401: {"description": "Session missing, expired, or invalid"},
409: {"description": "Action already exists"},
429: {"description": "Rate limit exceeded for action create operations"},
500: {"description": "Failed to write .local file"},
},
)
async def create_action(
request: Request,
@@ -284,6 +311,15 @@ async def create_action(
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a user-created action's .local file",
dependencies=[Depends(_check_action_delete_rate_limit)],
responses={
204: {"description": "Action deleted successfully"},
400: {"description": "Invalid action name"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Action not found"},
409: {"description": "Action is a shipped default (conf-only)"},
429: {"description": "Rate limit exceeded for action delete operations"},
500: {"description": "Failed to delete .local file"},
},
)
async def delete_action(
request: Request,

View File

@@ -45,6 +45,13 @@ router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
"/login",
response_model=LoginResponse,
summary="Authenticate with the master password",
responses={
200: {"description": "Login successful", "model": LoginResponse},
401: {"description": "Invalid password"},
422: {"description": "Validation error — invalid request body"},
429: {"description": "Too many login attempts, retry after delay"},
503: {"description": "Setup not complete"},
},
)
async def login(
body: LoginRequest,
@@ -116,6 +123,10 @@ async def login(
"/session",
response_model=SessionValidResponse,
summary="Validate the current session",
responses={
200: {"description": "Session valid", "model": SessionValidResponse},
401: {"description": "Session missing, expired, or invalid"},
},
)
async def validate_session(
_: AuthDep,
@@ -142,6 +153,10 @@ async def validate_session(
"/logout",
response_model=LogoutResponse,
summary="Revoke the current session",
responses={
200: {"description": "Logout successful", "model": LogoutResponse},
401: {"description": "Session missing or invalid (silently successful)"},
},
)
async def logout(
request: Request,

View File

@@ -101,6 +101,11 @@ def _check_unban_rate_limit(
"/active",
response_model=ActiveBanListResponse,
summary="List all currently banned IPs across all jails",
responses={
200: {"description": "Active ban list returned", "model": ActiveBanListResponse},
401: {"description": "Session missing, expired, or invalid"},
502: {"description": "fail2ban unreachable"},
},
)
async def get_active_bans(
request: Request,
@@ -144,6 +149,15 @@ async def get_active_bans(
response_model=JailCommandResponse,
summary="Ban an IP address in a specific jail",
dependencies=[Depends(_check_ban_rate_limit)],
responses={
201: {"description": "IP banned successfully", "model": JailCommandResponse},
400: {"description": "Invalid IP address"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Jail not found"},
409: {"description": "Ban command failed in fail2ban"},
429: {"description": "Rate limit exceeded for ban operations"},
502: {"description": "fail2ban unreachable"},
},
)
async def ban_ip(
request: Request,
@@ -182,6 +196,15 @@ async def ban_ip(
response_model=JailCommandResponse,
summary="Unban an IP address from one or all jails",
dependencies=[Depends(_check_unban_rate_limit)],
responses={
200: {"description": "IP unbanned successfully", "model": JailCommandResponse},
400: {"description": "Invalid IP address"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Jail not found"},
409: {"description": "Unban command failed in fail2ban"},
429: {"description": "Rate limit exceeded for unban operations"},
502: {"description": "fail2ban unreachable"},
},
)
async def unban_ip(
request: Request,
@@ -225,6 +248,11 @@ async def unban_ip(
"/all",
response_model=UnbanAllResponse,
summary="Unban every currently banned IP across all jails",
responses={
200: {"description": "All bans cleared", "model": UnbanAllResponse},
401: {"description": "Session missing, expired, or invalid"},
502: {"description": "fail2ban unreachable"},
},
)
async def unban_all(
request: Request,

View File

@@ -97,6 +97,10 @@ def _check_blocklist_import_rate_limit(
"",
response_model=BlocklistListResponse,
summary="List all blocklist sources",
responses={
200: {"description": "Blocklist sources returned", "model": BlocklistListResponse},
401: {"description": "Session missing, expired, or invalid"},
},
)
async def list_blocklists(
blocklist_ctx: BlocklistServiceContextDep,
@@ -120,6 +124,11 @@ async def list_blocklists(
response_model=BlocklistSource,
status_code=status.HTTP_201_CREATED,
summary="Add a new blocklist source",
responses={
201: {"description": "Blocklist source created", "model": BlocklistSource},
400: {"description": "URL validation failed"},
401: {"description": "Session missing, expired, or invalid"},
},
)
async def create_blocklist(
payload: BlocklistSourceCreate,
@@ -157,6 +166,11 @@ async def create_blocklist(
response_model=ImportRunResult,
summary="Trigger a manual blocklist import",
dependencies=[Depends(_check_blocklist_import_rate_limit)],
responses={
200: {"description": "Import completed", "model": ImportRunResult},
401: {"description": "Session missing, expired, or invalid"},
429: {"description": "Rate limit exceeded for blocklist import"},
},
)
async def run_import_now(
http_session: HttpSessionDep,
@@ -193,6 +207,10 @@ async def run_import_now(
"/schedule",
response_model=ScheduleInfo,
summary="Get the current import schedule",
responses={
200: {"description": "Schedule info returned", "model": ScheduleInfo},
401: {"description": "Session missing, expired, or invalid"},
},
)
async def get_schedule(
blocklist_ctx: BlocklistServiceContextDep,
@@ -219,6 +237,10 @@ async def get_schedule(
"/schedule",
response_model=ScheduleInfo,
summary="Update the import schedule",
responses={
200: {"description": "Schedule updated", "model": ScheduleInfo},
401: {"description": "Session missing, expired, or invalid"},
},
)
async def update_schedule(
payload: ScheduleConfig,
@@ -255,6 +277,10 @@ async def update_schedule(
"/log",
response_model=ImportLogListResponse,
summary="Get the paginated import log",
responses={
200: {"description": "Import log returned", "model": ImportLogListResponse},
401: {"description": "Session missing, expired, or invalid"},
},
)
async def get_import_log(
blocklist_ctx: BlocklistServiceContextDep,
@@ -291,6 +317,11 @@ async def get_import_log(
"/{source_id}",
response_model=BlocklistSource,
summary="Get a single blocklist source",
responses={
200: {"description": "Blocklist source returned", "model": BlocklistSource},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Blocklist source not found"},
},
)
async def get_blocklist(
source_id: int,
@@ -317,6 +348,12 @@ async def get_blocklist(
"/{source_id}",
response_model=BlocklistSource,
summary="Update a blocklist source",
responses={
200: {"description": "Blocklist source updated", "model": BlocklistSource},
400: {"description": "URL validation failed"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Blocklist source not found"},
},
)
async def update_blocklist(
source_id: int,
@@ -355,6 +392,11 @@ async def update_blocklist(
"/{source_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a blocklist source",
responses={
204: {"description": "Blocklist source deleted successfully"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Blocklist source not found"},
},
)
async def delete_blocklist(
source_id: int,
@@ -380,6 +422,12 @@ async def delete_blocklist(
"/{source_id}/preview",
response_model=PreviewResponse,
summary="Preview the contents of a blocklist source",
responses={
200: {"description": "Blocklist preview returned", "model": PreviewResponse},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Blocklist source not found"},
502: {"description": "URL could not be reached"},
},
)
async def preview_blocklist(
source_id: int,

View File

@@ -111,6 +111,11 @@ def _validate_log_target(value: str) -> None:
"/global",
response_model=GlobalConfigResponse,
summary="Return global fail2ban settings",
responses={
200: {"description": "Global config returned", "model": GlobalConfigResponse},
401: {"description": "Session missing, expired, or invalid"},
502: {"description": "fail2ban unreachable"},
},
)
async def get_global_config(
_request: Request,
@@ -140,6 +145,13 @@ async def get_global_config(
status_code=status.HTTP_204_NO_CONTENT,
summary="Update global fail2ban settings",
dependencies=[Depends(_check_config_update_rate_limit)],
responses={
204: {"description": "Global config updated successfully"},
400: {"description": "Set command rejected or log_target invalid"},
401: {"description": "Session missing, expired, or invalid"},
429: {"description": "Rate limit exceeded for config update operations"},
502: {"description": "fail2ban unreachable"},
},
)
async def update_global_config(
_request: Request,
@@ -171,6 +183,12 @@ async def update_global_config(
"/reload",
status_code=status.HTTP_204_NO_CONTENT,
summary="Reload fail2ban to apply configuration changes",
responses={
204: {"description": "Fail2ban reloaded successfully"},
401: {"description": "Session missing, expired, or invalid"},
409: {"description": "Reload command failed in fail2ban"},
502: {"description": "fail2ban unreachable"},
},
)
async def reload_fail2ban(
_request: Request,
@@ -199,6 +217,13 @@ async def reload_fail2ban(
"/restart",
status_code=status.HTTP_204_NO_CONTENT,
summary="Restart the fail2ban service",
responses={
204: {"description": "Fail2ban restarted successfully"},
401: {"description": "Session missing, expired, or invalid"},
409: {"description": "Stop command failed in fail2ban"},
502: {"description": "fail2ban unreachable for stop command"},
503: {"description": "fail2ban did not come back online within 10s"},
},
)
async def restart_fail2ban(
_request: Request,
@@ -252,6 +277,11 @@ async def restart_fail2ban(
"/regex-test",
response_model=RegexTestResponse,
summary="Test a fail regex pattern against a sample log line",
responses={
200: {"description": "Regex test result", "model": RegexTestResponse},
401: {"description": "Session missing, expired, or invalid"},
422: {"description": "Invalid regex pattern"},
},
)
async def regex_test(
_auth: AuthDep,
@@ -281,6 +311,11 @@ async def regex_test(
"/preview-log",
response_model=LogPreviewResponse,
summary="Preview log file lines against a regex pattern",
responses={
200: {"description": "Log preview result", "model": LogPreviewResponse},
401: {"description": "Session missing, expired, or invalid"},
422: {"description": "Invalid regex pattern"},
},
)
async def preview_log(
_auth: AuthDep,
@@ -310,6 +345,10 @@ async def preview_log(
"/map-color-thresholds",
response_model=MapColorThresholdsResponse,
summary="Get map color threshold configuration",
responses={
200: {"description": "Color thresholds returned", "model": MapColorThresholdsResponse},
401: {"description": "Session missing, expired, or invalid"},
},
)
async def get_map_color_thresholds(
_request: Request,
@@ -334,6 +373,12 @@ async def get_map_color_thresholds(
response_model=MapColorThresholdsResponse,
summary="Update map color threshold configuration",
dependencies=[Depends(_check_config_update_rate_limit)],
responses={
200: {"description": "Color thresholds updated", "model": MapColorThresholdsResponse},
400: {"description": "Validation error (thresholds not properly ordered)"},
401: {"description": "Session missing, expired, or invalid"},
429: {"description": "Rate limit exceeded for config update operations"},
},
)
async def update_map_color_thresholds(
_request: Request,
@@ -365,6 +410,12 @@ async def update_map_color_thresholds(
"/fail2ban-log",
response_model=Fail2BanLogResponse,
summary="Read the tail of the fail2ban daemon log file",
responses={
200: {"description": "Log file lines returned", "model": Fail2BanLogResponse},
400: {"description": "Log target not a file or path outside allowed directory"},
401: {"description": "Session missing, expired, or invalid"},
502: {"description": "fail2ban unreachable"},
},
)
async def get_fail2ban_log(
_request: Request,
@@ -416,6 +467,11 @@ async def get_fail2ban_log(
"/service-status",
response_model=ServiceStatusResponse,
summary="Return fail2ban service health status with log configuration",
responses={
200: {"description": "Service status returned", "model": ServiceStatusResponse},
401: {"description": "Session missing, expired, or invalid"},
502: {"description": "fail2ban unreachable"},
},
)
async def get_service_status(
_request: Request,

View File

@@ -56,6 +56,11 @@ _DEFAULT_RANGE: TimeRange = "24h"
"/status",
response_model=ServerStatusResponse,
summary="Return the cached fail2ban server status",
responses={
200: {"description": "Server status returned", "model": ServerStatusResponse},
401: {"description": "Session missing, expired, or invalid"},
502: {"description": "fail2ban unreachable"},
},
)
async def get_server_status(
server_status: ServerStatusDep,
@@ -84,6 +89,11 @@ async def get_server_status(
"/bans",
response_model=DashboardBanListResponse,
summary="Return a paginated list of recent bans",
responses={
200: {"description": "Ban list returned", "model": DashboardBanListResponse},
401: {"description": "Session missing, expired, or invalid"},
502: {"description": "fail2ban unreachable"},
},
)
async def get_dashboard_bans(
_auth: AuthDep,
@@ -145,6 +155,11 @@ async def get_dashboard_bans(
"/bans/by-country",
response_model=BansByCountryResponse,
summary="Return ban counts aggregated by country",
responses={
200: {"description": "Ban counts by country returned", "model": BansByCountryResponse},
401: {"description": "Session missing, expired, or invalid"},
502: {"description": "fail2ban unreachable"},
},
)
async def get_bans_by_country(
_auth: AuthDep,
@@ -205,6 +220,11 @@ async def get_bans_by_country(
"/bans/trend",
response_model=BanTrendResponse,
summary="Return ban counts aggregated into time buckets",
responses={
200: {"description": "Ban trend data returned", "model": BanTrendResponse},
401: {"description": "Session missing, expired, or invalid"},
502: {"description": "fail2ban unreachable"},
},
)
async def get_ban_trend(
_auth: AuthDep,
@@ -259,6 +279,11 @@ async def get_ban_trend(
"/bans/by-jail",
response_model=BansByJailResponse,
summary="Return ban counts aggregated by jail",
responses={
200: {"description": "Ban counts by jail returned", "model": BansByJailResponse},
401: {"description": "Session missing, expired, or invalid"},
502: {"description": "fail2ban unreachable"},
},
)
async def get_bans_by_jail(
_auth: AuthDep,

View File

@@ -75,6 +75,11 @@ _NamePath = Annotated[
"/jail-files",
response_model=JailConfigFilesResponse,
summary="List all jail config files",
responses={
200: {"description": "Jail config files returned", "model": JailConfigFilesResponse},
401: {"description": "Session missing, expired, or invalid"},
503: {"description": "Config directory unavailable"},
},
)
async def list_jail_config_files(
config_dir: Fail2BanConfigDirDep,
@@ -97,6 +102,13 @@ async def list_jail_config_files(
"/jail-files/{filename}",
response_model=JailConfigFileContent,
summary="Return a single jail config file with its content",
responses={
200: {"description": "Jail config file returned", "model": JailConfigFileContent},
400: {"description": "Filename unsafe"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "File not found"},
503: {"description": "Config directory unavailable"},
},
)
async def get_jail_config_file(
config_dir: Fail2BanConfigDirDep,
@@ -123,6 +135,13 @@ async def get_jail_config_file(
"/jail-files/{filename}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Overwrite a jail.d config file with new raw content",
responses={
204: {"description": "File overwritten successfully"},
400: {"description": "Filename unsafe or content invalid"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "File not found"},
503: {"description": "Config directory unavailable"},
},
)
async def write_jail_config_file(
config_dir: Fail2BanConfigDirDep,
@@ -151,6 +170,13 @@ async def write_jail_config_file(
"/jail-files/{filename}/enabled",
status_code=status.HTTP_204_NO_CONTENT,
summary="Enable or disable a jail configuration file",
responses={
204: {"description": "Enabled state updated successfully"},
400: {"description": "Filename unsafe or operation failed"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "File not found"},
503: {"description": "Config directory unavailable"},
},
)
async def set_jail_config_file_enabled(
config_dir: Fail2BanConfigDirDep,
@@ -182,6 +208,13 @@ async def set_jail_config_file_enabled(
response_model=ConfFileContent,
status_code=status.HTTP_201_CREATED,
summary="Create a new jail.d config file",
responses={
201: {"description": "File created", "model": ConfFileContent},
400: {"description": "Name unsafe or content exceeds size limit"},
401: {"description": "Session missing, expired, or invalid"},
409: {"description": "File with that name already exists"},
503: {"description": "Config directory unavailable"},
},
)
async def create_jail_config_file(
config_dir: Fail2BanConfigDirDep,
@@ -220,6 +253,13 @@ async def create_jail_config_file(
"/filters/{name}/raw",
response_model=ConfFileContent,
summary="Return a filter definition file's raw content",
responses={
200: {"description": "Filter file returned", "model": ConfFileContent},
400: {"description": "Name unsafe"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "File not found"},
503: {"description": "Config directory unavailable"},
},
)
async def get_filter_file_raw(
config_dir: Fail2BanConfigDirDep,
@@ -250,6 +290,13 @@ async def get_filter_file_raw(
"/filters/{name}/raw",
status_code=status.HTTP_204_NO_CONTENT,
summary="Update a filter definition file (raw content)",
responses={
204: {"description": "Filter file updated successfully"},
400: {"description": "Name unsafe or content exceeds size limit"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "File not found"},
503: {"description": "Config directory unavailable"},
},
)
async def write_filter_file(
config_dir: Fail2BanConfigDirDep,
@@ -276,6 +323,13 @@ async def write_filter_file(
status_code=status.HTTP_201_CREATED,
response_model=ConfFileContent,
summary="Create a new filter definition file (raw content)",
responses={
201: {"description": "Filter file created", "model": ConfFileContent},
400: {"description": "Name invalid or content exceeds limit"},
401: {"description": "Session missing, expired, or invalid"},
409: {"description": "File with that name already exists"},
503: {"description": "Config directory unavailable"},
},
)
async def create_filter_file(
config_dir: Fail2BanConfigDirDep,
@@ -314,6 +368,11 @@ async def create_filter_file(
"/actions",
response_model=ConfFilesResponse,
summary="List all action definition files",
responses={
200: {"description": "Action files returned", "model": ConfFilesResponse},
401: {"description": "Session missing, expired, or invalid"},
503: {"description": "Config directory unavailable"},
},
)
async def list_action_files(
config_dir: Fail2BanConfigDirDep,
@@ -333,6 +392,13 @@ async def list_action_files(
"/actions/{name}/raw",
response_model=ConfFileContent,
summary="Return an action definition file with its content",
responses={
200: {"description": "Action file returned", "model": ConfFileContent},
400: {"description": "Name unsafe"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "File not found"},
503: {"description": "Config directory unavailable"},
},
)
async def get_action_file(
config_dir: Fail2BanConfigDirDep,
@@ -359,6 +425,13 @@ async def get_action_file(
"/actions/{name}/raw",
status_code=status.HTTP_204_NO_CONTENT,
summary="Update an action definition file",
responses={
204: {"description": "Action file updated successfully"},
400: {"description": "Name unsafe or content exceeds size limit"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "File not found"},
503: {"description": "Config directory unavailable"},
},
)
async def write_action_file(
config_dir: Fail2BanConfigDirDep,
@@ -385,6 +458,13 @@ async def write_action_file(
status_code=status.HTTP_201_CREATED,
response_model=ConfFileContent,
summary="Create a new action definition file",
responses={
201: {"description": "Action file created", "model": ConfFileContent},
400: {"description": "Name invalid or content exceeds limit"},
401: {"description": "Session missing, expired, or invalid"},
409: {"description": "File with that name already exists"},
503: {"description": "Config directory unavailable"},
},
)
async def create_action_file(
config_dir: Fail2BanConfigDirDep,
@@ -423,6 +503,13 @@ async def create_action_file(
"/filters/{name}/parsed",
response_model=FilterConfig,
summary="Return a filter file parsed into a structured model",
responses={
200: {"description": "Filter config returned", "model": FilterConfig},
400: {"description": "Name unsafe"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Filter file not found"},
503: {"description": "Config directory unavailable"},
},
)
async def get_parsed_filter(
config_dir: Fail2BanConfigDirDep,
@@ -453,6 +540,13 @@ async def get_parsed_filter(
"/filters/{name}/parsed",
status_code=status.HTTP_204_NO_CONTENT,
summary="Update a filter file from a structured model",
responses={
204: {"description": "Filter file updated successfully"},
400: {"description": "Name unsafe or content exceeds size limit"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Filter file not found"},
503: {"description": "Config directory unavailable"},
},
)
async def update_parsed_filter(
config_dir: Fail2BanConfigDirDep,
@@ -486,6 +580,13 @@ async def update_parsed_filter(
"/actions/{name}/parsed",
response_model=ActionConfig,
summary="Return an action file parsed into a structured model",
responses={
200: {"description": "Action config returned", "model": ActionConfig},
400: {"description": "Name unsafe"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Action file not found"},
503: {"description": "Config directory unavailable"},
},
)
async def get_parsed_action(
config_dir: Fail2BanConfigDirDep,
@@ -516,6 +617,13 @@ async def get_parsed_action(
"/actions/{name}/parsed",
status_code=status.HTTP_204_NO_CONTENT,
summary="Update an action file from a structured model",
responses={
204: {"description": "Action file updated successfully"},
400: {"description": "Name unsafe or content exceeds size limit"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Action file not found"},
503: {"description": "Config directory unavailable"},
},
)
async def update_parsed_action(
config_dir: Fail2BanConfigDirDep,
@@ -549,6 +657,13 @@ async def update_parsed_action(
"/jail-files/{filename}/parsed",
response_model=JailFileConfig,
summary="Return a jail.d file parsed into a structured model",
responses={
200: {"description": "Jail file config returned", "model": JailFileConfig},
400: {"description": "Filename unsafe"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Jail file not found"},
503: {"description": "Config directory unavailable"},
},
)
async def get_parsed_jail_file(
config_dir: Fail2BanConfigDirDep,
@@ -579,6 +694,13 @@ async def get_parsed_jail_file(
"/jail-files/{filename}/parsed",
status_code=status.HTTP_204_NO_CONTENT,
summary="Update a jail.d file from a structured model",
responses={
204: {"description": "Jail file updated successfully"},
400: {"description": "Filename unsafe or content exceeds size limit"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Jail file not found"},
503: {"description": "Config directory unavailable"},
},
)
async def update_parsed_jail_file(
config_dir: Fail2BanConfigDirDep,

View File

@@ -125,6 +125,11 @@ _FilterNamePath = Annotated[
"",
response_model=FilterListResponse,
summary="List all available filters with active/inactive status",
responses={
200: {"description": "Filter list returned", "model": FilterListResponse},
401: {"description": "Session missing, expired, or invalid"},
502: {"description": "fail2ban unreachable"},
},
)
async def list_filters(
request: Request,
@@ -163,6 +168,12 @@ async def list_filters(
"/{name}",
response_model=FilterConfig,
summary="Return full parsed detail for a single filter",
responses={
200: {"description": "Filter config returned", "model": FilterConfig},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Filter not found in filter.d/"},
502: {"description": "fail2ban unreachable"},
},
)
async def get_filter(
request: Request,
@@ -208,6 +219,15 @@ _FilterNamePath = Annotated[
response_model=FilterConfig,
summary="Update a filter's .local override with new regex/pattern values",
dependencies=[Depends(_check_filter_update_rate_limit)],
responses={
200: {"description": "Filter updated", "model": FilterConfig},
400: {"description": "Invalid filter name"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Filter not found"},
422: {"description": "Regex pattern failed to compile"},
429: {"description": "Rate limit exceeded for filter update operations"},
500: {"description": "Failed to write .local file"},
},
)
async def update_filter(
request: Request,
@@ -260,6 +280,15 @@ async def update_filter(
status_code=status.HTTP_201_CREATED,
summary="Create a new user-defined filter",
dependencies=[Depends(_check_filter_create_rate_limit)],
responses={
201: {"description": "Filter created", "model": FilterConfig},
400: {"description": "Invalid filter name or regex too long"},
401: {"description": "Session missing, expired, or invalid"},
409: {"description": "Filter already exists"},
422: {"description": "Regex pattern failed to compile"},
429: {"description": "Rate limit exceeded for filter create operations"},
500: {"description": "Failed to write .local file"},
},
)
async def create_filter(
request: Request,
@@ -309,6 +338,15 @@ async def create_filter(
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a user-created filter's .local file",
dependencies=[Depends(_check_filter_delete_rate_limit)],
responses={
204: {"description": "Filter deleted successfully"},
400: {"description": "Invalid filter name"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Filter not found"},
409: {"description": "Filter is a shipped default (conf-only)"},
429: {"description": "Rate limit exceeded for filter delete operations"},
500: {"description": "Failed to delete .local file"},
},
)
async def delete_filter(
request: Request,

View File

@@ -30,6 +30,12 @@ _IpPath = Annotated[str, Path(description="IPv4 or IPv6 address to look up.")]
"/lookup/{ip}",
response_model=IpLookupResponse,
summary="Look up ban status and geo information for an IP",
responses={
200: {"description": "IP lookup result returned", "model": IpLookupResponse},
400: {"description": "Invalid IP address"},
401: {"description": "Session missing, expired, or invalid"},
502: {"description": "fail2ban unreachable"},
},
)
async def lookup_ip(
_auth: AuthDep,
@@ -75,6 +81,10 @@ async def lookup_ip(
"/stats",
response_model=GeoCacheStatsResponse,
summary="Geo cache diagnostic counters",
responses={
200: {"description": "Geo cache stats returned", "model": GeoCacheStatsResponse},
401: {"description": "Session missing, expired, or invalid"},
},
)
async def geo_stats(
_auth: AuthDep,
@@ -99,6 +109,10 @@ async def geo_stats(
"/re-resolve",
summary="Re-resolve all IPs whose country could not be determined",
response_model=GeoReResolveResponse,
responses={
200: {"description": "Re-resolve result", "model": GeoReResolveResponse},
401: {"description": "Session missing, expired, or invalid"},
},
)
async def re_resolve_geo(
_auth: AuthDep,

View File

@@ -28,7 +28,15 @@ router: APIRouter = APIRouter(prefix="/api/v1/health", tags=["Health"])
log: structlog.stdlib.BoundLogger = structlog.get_logger()
@router.get("", summary="Application health check", response_model=HealthResponse)
@router.get(
"",
summary="Application health check",
response_model=HealthResponse,
responses={
200: {"description": "All components healthy"},
503: {"description": "fail2ban offline or component degraded"},
},
)
async def health_check(
app_state: AppStateDep,
server_status: ServerStatusDep,

View File

@@ -41,6 +41,11 @@ router: APIRouter = APIRouter(prefix="/api/v1/history", tags=["History"])
"",
response_model=HistoryListResponse,
summary="Return a paginated list of historical bans",
responses={
200: {"description": "History list returned", "model": HistoryListResponse},
401: {"description": "Session missing, expired, or invalid"},
502: {"description": "fail2ban unreachable"},
},
)
async def get_history(
request: Request,
@@ -120,6 +125,11 @@ async def get_history(
"/archive",
response_model=HistoryListResponse,
summary="Return a paginated list of archived historical bans",
responses={
200: {"description": "Archived history list returned", "model": HistoryListResponse},
401: {"description": "Session missing, expired, or invalid"},
502: {"description": "fail2ban unreachable"},
},
)
async def get_history_archive(
request: Request,
@@ -157,6 +167,12 @@ async def get_history_archive(
"/{ip}",
response_model=IpDetailResponse,
summary="Return the full ban history for a single IP address",
responses={
200: {"description": "IP history detail returned", "model": IpDetailResponse},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "No history found for this IP"},
502: {"description": "fail2ban unreachable"},
},
)
async def get_ip_history(
request: Request,

View File

@@ -213,6 +213,11 @@ _NamePath = Annotated[str, Path(description='Jail name as configured in fail2ban
"",
response_model=JailConfigListResponse,
summary="List configuration for all active jails",
responses={
200: {"description": "Jail configs returned", "model": JailConfigListResponse},
401: {"description": "Session missing, expired, or invalid"},
502: {"description": "fail2ban unreachable"},
},
)
async def get_jail_configs(
request: Request,
@@ -241,6 +246,11 @@ async def get_jail_configs(
"/inactive",
response_model=InactiveJailListResponse,
summary="List all inactive jails discovered in config files",
responses={
200: {"description": "Inactive jail list returned", "model": InactiveJailListResponse},
401: {"description": "Session missing, expired, or invalid"},
502: {"description": "fail2ban unreachable"},
},
)
async def get_inactive_jails(
request: Request,
@@ -268,6 +278,10 @@ async def get_inactive_jails(
"/pending-recovery",
response_model=PendingRecovery | None,
summary="Return active crash-recovery record if one exists",
responses={
200: {"description": "Recovery record or null", "model": PendingRecovery},
401: {"description": "Session missing, expired, or invalid"},
},
)
async def get_pending_recovery(
_auth: AuthDep,
@@ -293,6 +307,12 @@ async def get_pending_recovery(
"/{name}",
response_model=JailConfigResponse,
summary="Return configuration for a single jail",
responses={
200: {"description": "Jail config returned", "model": JailConfigResponse},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Jail not found"},
502: {"description": "fail2ban unreachable"},
},
)
async def get_jail_config(
request: Request,
@@ -325,6 +345,15 @@ async def get_jail_config(
status_code=status.HTTP_204_NO_CONTENT,
summary="Update jail configuration",
dependencies=[Depends(_check_jail_update_rate_limit)],
responses={
204: {"description": "Jail config updated successfully"},
400: {"description": "Set command rejected or invalid regex"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Jail not found"},
422: {"description": "Regex pattern failed to compile"},
429: {"description": "Rate limit exceeded for jail update operations"},
502: {"description": "fail2ban unreachable"},
},
)
async def update_jail_config(
request: Request,
@@ -365,6 +394,14 @@ async def update_jail_config(
status_code=status.HTTP_204_NO_CONTENT,
summary="Add a log file path to an existing jail",
dependencies=[Depends(_check_jail_create_rate_limit)],
responses={
204: {"description": "Log path added successfully"},
400: {"description": "Command rejected or path invalid"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Jail not found"},
429: {"description": "Rate limit exceeded for jail create operations"},
502: {"description": "fail2ban unreachable"},
},
)
async def add_log_path(
request: Request,
@@ -400,6 +437,15 @@ async def add_log_path(
status_code=status.HTTP_204_NO_CONTENT,
summary="Remove a monitored log path from a jail",
dependencies=[Depends(_check_jail_delete_rate_limit)],
responses={
204: {"description": "Log path removed successfully"},
400: {"description": "Command rejected"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Jail not found"},
422: {"description": "Log path outside allowed directories"},
429: {"description": "Rate limit exceeded for jail delete operations"},
502: {"description": "fail2ban unreachable"},
},
)
async def delete_log_path(
request: Request,
@@ -440,6 +486,15 @@ async def delete_log_path(
response_model=JailActivationResponse,
summary="Activate an inactive jail",
dependencies=[Depends(_check_jail_activate_rate_limit)],
responses={
200: {"description": "Jail activated", "model": JailActivationResponse},
400: {"description": "Invalid jail name"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Jail not found in config files"},
409: {"description": "Jail already active"},
429: {"description": "Rate limit exceeded for jail activate operations"},
502: {"description": "fail2ban unreachable"},
},
)
async def activate_jail(
app: AppDep,
@@ -494,6 +549,15 @@ async def activate_jail(
response_model=JailActivationResponse,
summary="Deactivate an active jail",
dependencies=[Depends(_check_jail_deactivate_rate_limit)],
responses={
200: {"description": "Jail deactivated", "model": JailActivationResponse},
400: {"description": "Invalid jail name"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Jail not found in config files"},
409: {"description": "Jail already inactive"},
429: {"description": "Rate limit exceeded for jail deactivate operations"},
502: {"description": "fail2ban unreachable"},
},
)
async def deactivate_jail(
_auth: AuthDep,
@@ -536,6 +600,15 @@ async def deactivate_jail(
"/{name}/local",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete the jail.d override file for an inactive jail",
responses={
204: {"description": "Override file deleted successfully"},
400: {"description": "Invalid jail name"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Jail not found in config files"},
409: {"description": "Jail currently active"},
500: {"description": "File cannot be deleted"},
502: {"description": "fail2ban unreachable"},
},
)
async def delete_jail_local_override(
request: Request,
@@ -578,6 +651,12 @@ async def delete_jail_local_override(
"/{name}/validate",
response_model=JailValidationResult,
summary="Validate jail configuration before activation",
responses={
200: {"description": "Validation result", "model": JailValidationResult},
400: {"description": "Invalid jail name"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Jail not found in config files"},
},
)
async def validate_jail(
request: Request,
@@ -609,6 +688,12 @@ async def validate_jail(
"/{name}/rollback",
response_model=RollbackResponse,
summary="Disable a bad jail config and restart fail2ban",
responses={
200: {"description": "Rollback completed", "model": RollbackResponse},
400: {"description": "Invalid jail name"},
401: {"description": "Session missing, expired, or invalid"},
500: {"description": "Failed to write .local override file"},
},
)
async def rollback_jail(
_auth: AuthDep,
@@ -654,6 +739,14 @@ async def rollback_jail(
status_code=status.HTTP_204_NO_CONTENT,
summary="Assign a filter to a jail",
dependencies=[Depends(_check_jail_create_rate_limit)],
responses={
204: {"description": "Filter assigned successfully"},
400: {"description": "Invalid jail name or filter name"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Jail or filter not found"},
429: {"description": "Rate limit exceeded for jail create operations"},
500: {"description": "Failed to write .local override file"},
},
)
async def assign_filter_to_jail(
request: Request,
@@ -689,6 +782,14 @@ async def assign_filter_to_jail(
status_code=status.HTTP_204_NO_CONTENT,
summary="Add an action to a jail",
dependencies=[Depends(_check_jail_create_rate_limit)],
responses={
204: {"description": "Action added successfully"},
400: {"description": "Invalid jail name or action name"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Jail or action not found"},
429: {"description": "Rate limit exceeded for jail create operations"},
500: {"description": "Failed to write .local override file"},
},
)
async def assign_action_to_jail(
request: Request,
@@ -725,6 +826,14 @@ async def assign_action_to_jail(
status_code=status.HTTP_204_NO_CONTENT,
summary="Remove an action from a jail",
dependencies=[Depends(_check_jail_delete_rate_limit)],
responses={
204: {"description": "Action removed successfully"},
400: {"description": "Invalid jail name or action name"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Jail not found in config files"},
429: {"description": "Rate limit exceeded for jail delete operations"},
500: {"description": "Failed to write .local override file"},
},
)
async def remove_action_from_jail(
request: Request,

View File

@@ -57,6 +57,11 @@ _NamePath = Annotated[str, Path(description="Jail name as configured in fail2ban
"",
response_model=JailListResponse,
summary="List all active fail2ban jails",
responses={
200: {"description": "Jails list returned", "model": JailListResponse},
401: {"description": "Session missing, expired, or invalid"},
502: {"description": "fail2ban unreachable"},
},
)
async def get_jails(
_auth: AuthDep,
@@ -85,6 +90,12 @@ async def get_jails(
"/{name}",
response_model=JailDetailResponse,
summary="Return full detail for a single jail",
responses={
200: {"description": "Jail detail returned", "model": JailDetailResponse},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Jail not found"},
502: {"description": "fail2ban unreachable"},
},
)
async def get_jail(
_auth: AuthDep,
@@ -129,6 +140,12 @@ async def get_jail(
"/reload-all",
response_model=JailCommandResponse,
summary="Reload all fail2ban jails",
responses={
200: {"description": "All jails reloaded", "model": JailCommandResponse},
401: {"description": "Session missing, expired, or invalid"},
409: {"description": "fail2ban reports operation failed"},
502: {"description": "fail2ban unreachable"},
},
)
async def reload_all_jails(
_auth: AuthDep,
@@ -157,6 +174,13 @@ async def reload_all_jails(
"/{name}/start",
response_model=JailCommandResponse,
summary="Start a stopped jail",
responses={
200: {"description": "Jail started", "model": JailCommandResponse},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Jail not found"},
409: {"description": "fail2ban reports operation failed"},
502: {"description": "fail2ban unreachable"},
},
)
async def start_jail(
_auth: AuthDep,
@@ -185,6 +209,12 @@ async def start_jail(
"/{name}/stop",
response_model=JailCommandResponse,
summary="Stop a running jail",
responses={
200: {"description": "Jail stopped", "model": JailCommandResponse},
401: {"description": "Session missing, expired, or invalid"},
409: {"description": "fail2ban reports operation failed"},
502: {"description": "fail2ban unreachable"},
},
)
async def stop_jail(
_auth: AuthDep,
@@ -216,6 +246,13 @@ async def stop_jail(
"/{name}/idle",
response_model=JailCommandResponse,
summary="Toggle idle mode for a jail",
responses={
200: {"description": "Idle mode toggled", "model": JailCommandResponse},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Jail not found"},
409: {"description": "fail2ban reports operation failed"},
502: {"description": "fail2ban unreachable"},
},
)
async def toggle_idle(
_auth: AuthDep,
@@ -253,6 +290,13 @@ async def toggle_idle(
"/{name}/reload",
response_model=JailCommandResponse,
summary="Reload a single jail",
responses={
200: {"description": "Jail reloaded", "model": JailCommandResponse},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Jail not found"},
409: {"description": "fail2ban reports operation failed"},
502: {"description": "fail2ban unreachable"},
},
)
async def reload_jail(
_auth: AuthDep,
@@ -294,6 +338,12 @@ class _IgnoreSelfRequest(IgnoreIpRequest):
"/{name}/ignoreip",
response_model=IgnoreListResponse,
summary="List the ignore IPs for a jail",
responses={
200: {"description": "Ignore list returned", "model": IgnoreListResponse},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Jail not found"},
502: {"description": "fail2ban unreachable"},
},
)
async def get_ignore_list(
_auth: AuthDep,
@@ -322,6 +372,14 @@ async def get_ignore_list(
status_code=status.HTTP_201_CREATED,
response_model=JailCommandResponse,
summary="Add an IP or network to the ignore list",
responses={
201: {"description": "IP added to ignore list", "model": JailCommandResponse},
400: {"description": "IP or network invalid"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Jail not found"},
409: {"description": "fail2ban reports operation failed"},
502: {"description": "fail2ban unreachable"},
},
)
async def add_ignore_ip(
_auth: AuthDep,
@@ -359,6 +417,13 @@ async def add_ignore_ip(
"/{name}/ignoreip",
response_model=JailCommandResponse,
summary="Remove an IP or network from the ignore list",
responses={
200: {"description": "IP removed from ignore list", "model": JailCommandResponse},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Jail not found"},
409: {"description": "fail2ban reports operation failed"},
502: {"description": "fail2ban unreachable"},
},
)
async def del_ignore_ip(
_auth: AuthDep,
@@ -392,6 +457,13 @@ async def del_ignore_ip(
"/{name}/ignoreself",
response_model=JailCommandResponse,
summary="Toggle the ignoreself option for a jail",
responses={
200: {"description": "ignoreself toggled", "model": JailCommandResponse},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Jail not found"},
409: {"description": "fail2ban reports operation failed"},
502: {"description": "fail2ban unreachable"},
},
)
async def toggle_ignore_self(
_auth: AuthDep,
@@ -434,6 +506,13 @@ async def toggle_ignore_self(
"/{name}/banned",
response_model=JailBannedIpsResponse,
summary="Return paginated currently-banned IPs for a single jail",
responses={
200: {"description": "Banned IPs returned", "model": JailBannedIpsResponse},
400: {"description": "page or page_size out of range"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Jail not found"},
502: {"description": "fail2ban unreachable"},
},
)
async def get_jail_banned_ips(
_auth: AuthDep,

View File

@@ -30,6 +30,11 @@ router: APIRouter = APIRouter(prefix="/api/v1/server", tags=["Server"])
"/settings",
response_model=ServerSettingsResponse,
summary="Return fail2ban server-level settings",
responses={
200: {"description": "Server settings returned", "model": ServerSettingsResponse},
401: {"description": "Session missing, expired, or invalid"},
502: {"description": "fail2ban unreachable"},
},
)
async def get_server_settings(
request: Request,
@@ -59,6 +64,12 @@ async def get_server_settings(
"/settings",
status_code=status.HTTP_204_NO_CONTENT,
summary="Update fail2ban server-level settings",
responses={
204: {"description": "Settings updated successfully"},
400: {"description": "Set command rejected by fail2ban"},
401: {"description": "Session missing, expired, or invalid"},
502: {"description": "fail2ban unreachable"},
},
)
async def update_server_settings(
request: Request,
@@ -88,6 +99,12 @@ async def update_server_settings(
status_code=status.HTTP_200_OK,
summary="Flush and re-open fail2ban log files",
response_model=FlushLogsResponse,
responses={
200: {"description": "Logs flushed successfully", "model": FlushLogsResponse},
400: {"description": "Command rejected by fail2ban"},
401: {"description": "Session missing, expired, or invalid"},
502: {"description": "fail2ban unreachable"},
},
)
async def flush_logs(
request: Request,

View File

@@ -23,9 +23,12 @@ router = APIRouter(prefix="/api/v1/setup", tags=["setup"])
@router.get(
"",
"/",
response_model=SetupStatusResponse,
summary="Check whether setup has been completed",
responses={
200: {"description": "Setup status returned", "model": SetupStatusResponse},
},
)
async def get_setup_status(app: AppDep) -> SetupStatusResponse:
"""Return whether the initial setup wizard has been completed.
@@ -43,6 +46,11 @@ async def get_setup_status(app: AppDep) -> SetupStatusResponse:
response_model=SetupResponse,
status_code=status.HTTP_201_CREATED,
summary="Run the initial setup wizard",
responses={
201: {"description": "Setup completed successfully", "model": SetupResponse},
400: {"description": "Validation error in request body"},
409: {"description": "Setup already completed"},
},
)
async def post_setup(
app: AppDep,
@@ -88,6 +96,9 @@ async def post_setup(
"/timezone",
response_model=SetupTimezoneResponse,
summary="Return the configured IANA timezone",
responses={
200: {"description": "Timezone returned", "model": SetupTimezoneResponse},
},
)
async def get_timezone(settings: SettingsDep) -> SetupTimezoneResponse:
"""Return the IANA timezone configured during the initial setup wizard.