Compare commits

...

35 Commits

Author SHA1 Message Date
renovate[bot]
37e6de44f6 Update dependency pyinstaller-hooks-contrib to v2025.11 (#3245)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

[skip ci]
2025-12-29 08:55:33 +01:00
renovate[bot]
f4b3aee48b Update all dependencies (#3239)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

[skip ci]
2025-12-22 10:33:15 +01:00
renovate[bot]
54b55e0937 Update all dependencies (#3232)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

[skip ci]
2025-12-15 10:03:38 +01:00
renovate[bot]
740e4a00c5 Update all dependencies (#3220)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

[skip ci]
2025-12-08 09:49:56 +01:00
renovate[bot]
bd7c628683 Update all dependencies (#3211)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

[skip ci]
2025-12-01 10:03:24 +01:00
renovate[bot]
0fd24741f4 Update all dependencies (#3205)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

[skip ci]
2025-11-24 09:29:31 +01:00
renovate[bot]
7a25b12ed5 Update all dependencies (#3196)
[skip ci]
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-18 13:43:45 +01:00
renovate[bot]
6f9e88d7c6 Update all dependencies (#3187)
[ckip ci]
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-10 11:08:50 +01:00
renovate[bot]
1c8dec7a39 Update all dependencies (#3179)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

[skip ci]
2025-11-03 07:39:14 +01:00
renovate[bot]
2761e4427f Update all dependencies (#3175)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

[skip ci]
2025-10-28 17:45:57 +01:00
renovate[bot]
9f1c6d1364 Update all dependencies (#3168)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

[skip ci]
2025-10-21 11:15:47 +02:00
renovate[bot]
309977303b Update all dependencies (#3166)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

[skip ci]
2025-10-13 07:45:07 +02:00
Safihre
aeabd6d2a8 DRAFT Notification page
[skip ci]
2025-10-08 20:01:14 +02:00
Safihre
5e21abf4ed Config Server to new layout 2025-10-08 15:47:30 +02:00
Safihre
63a48e4e46 Merge all post and get parameters 2025-10-08 15:05:39 +02:00
Safihre
120e89d853 Remove Raiser and rssRaiser 2025-10-08 11:11:59 +02:00
Safihre
e5b7c00a03 Rework handling of GET and POST request parameters 2025-10-08 11:11:58 +02:00
Safihre
586acac41e Security wrappers 2025-10-07 16:40:03 +02:00
Safihre
eca6de4200 Correct api after update 2025-10-07 10:18:53 +02:00
renovate[bot]
3f8af8754d Update all dependencies (#3160)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

[skip ci]
2025-10-06 12:37:09 +02:00
renovate[bot]
8188903783 Update all dependencies (#3157)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

[skip ci]
2025-09-30 15:39:06 +02:00
renovate[bot]
db588a7086 Update all dependencies (#3151)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

[skip ci]
2025-09-30 15:39:05 +02:00
renovate[bot]
91aa0ceebe Update all dependencies (#3145)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-30 15:39:04 +02:00
renovate[bot]
f3eaa434a4 Update all dependencies (#3143)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

[skip ci]
2025-09-30 15:39:04 +02:00
Safihre
36edfe076c Update more pages
TODO: Fix categories
[skip ci]
2025-09-30 15:38:52 +02:00
Safihre
57fa53edde Convert more pages to uvicorn 2025-09-30 15:38:52 +02:00
Safihre
2a00f53b0e Implement more pages 2025-09-30 15:38:51 +02:00
Safihre
a12d5d866b Remove cherrypy imports 2025-09-30 15:38:44 +02:00
Safihre
2840bc9522 Update branch 2025-09-30 15:38:43 +02:00
renovate[bot]
eab725c005 Update all dependencies (#2978)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
[skip ci]
2025-09-30 15:38:43 +02:00
Safihre
f27858a4b4 Add some new pages
[skip ci]
2025-09-30 15:38:21 +02:00
renovate[bot]
a8e75e1011 Update all dependencies (#2959)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-30 15:38:20 +02:00
Safihre
1259cdaa8b Updates to api
[skip ci]
2025-09-30 15:38:20 +02:00
Safihre
efe7327884 Redirect uvicorn logging to our handlers
[skip ci]
2025-09-30 15:37:17 +02:00
Safihre
0539bfd91c Replace cherrypy by uvicorn/starlette 2025-09-30 15:37:16 +02:00
23 changed files with 1492 additions and 1306 deletions

View File

@@ -12,11 +12,11 @@ jobs:
runs-on: windows-2022
timeout-minutes: 30
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Set up Python 3.13
uses: actions/setup-python@v6
with:
python-version: "3.13"
python-version: "3.14"
architecture: "x64"
cache: pip
cache-dependency-path: "**/requirements.txt"
@@ -31,13 +31,13 @@ jobs:
id: windows_binary
run: python builder/package.py binary
- name: Upload Windows standalone binary (unsigned)
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
id: upload-unsigned-binary
with:
path: "*-win64-bin.zip"
name: Windows standalone binary
- name: Sign Windows standalone binary
uses: signpath/github-action-submit-signing-request@v1
uses: signpath/github-action-submit-signing-request@v2
if: contains(github.ref, 'refs/tags/')
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
@@ -49,7 +49,7 @@ jobs:
wait-for-completion: true
output-artifact-directory: "signed"
- name: Upload Windows standalone binary (signed)
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
if: contains(github.ref, 'refs/tags/')
with:
name: Windows standalone binary (signed)
@@ -57,13 +57,13 @@ jobs:
- name: Build Windows installer
run: python builder/package.py installer
- name: Upload Windows installer
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
id: upload-unsigned-installer
with:
path: "*-win-setup.exe"
name: Windows installer
- name: Sign Windows installer
uses: signpath/github-action-submit-signing-request@v1
uses: signpath/github-action-submit-signing-request@v2
if: contains(github.ref, 'refs/tags/')
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
@@ -76,7 +76,7 @@ jobs:
output-artifact-directory: "signed"
- name: Upload Windows installer (signed)
if: contains(github.ref, 'refs/tags/')
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: Windows installer (signed)
path: "signed/*-win-setup.exe"
@@ -95,17 +95,17 @@ jobs:
CFLAGS: -arch x86_64 -arch arm64
ARCHFLAGS: -arch x86_64 -arch arm64
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Set up Python 3.13
# Only use this for the caching of pip packages!
uses: actions/setup-python@v6
with:
python-version: "3.13"
python-version: "3.14"
cache: pip
cache-dependency-path: "**/requirements.txt"
- name: Cache Python download
id: cache-python-download
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/python.pkg
key: cache-macOS-Python-${{ env.PYTHON_VERSION }}
@@ -140,7 +140,7 @@ jobs:
# Run this on macOS so the line endings are correct by default
run: python builder/package.py source
- name: Upload source distribution
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
path: "*-src.tar.gz"
name: Source distribution
@@ -153,7 +153,7 @@ jobs:
python3 builder/package.py app
python3 builder/make_dmg.py
- name: Upload macOS binary
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
path: "*-macos.dmg"
name: macOS binary
@@ -172,9 +172,9 @@ jobs:
linux_arch: arm64
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Cache par2cmdline-turbo tarball
uses: actions/cache@v4
uses: actions/cache@v5
id: cache-par2cmdline
# Clearing the cache in case of new version requires manual clearing in GitHub!
with:
@@ -196,7 +196,7 @@ jobs:
timeout 10s snap run sabnzbd --help || true
sudo snap remove sabnzbd
- name: Upload snap
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: Snap package (${{ matrix.linux_arch }})
path: ${{ steps.snapcraft.outputs.snap }}
@@ -215,7 +215,7 @@ jobs:
runs-on: ubuntu-latest
needs: [build_windows, build_macos]
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
@@ -223,15 +223,15 @@ jobs:
cache: pip
cache-dependency-path: "builder/release-requirements.txt"
- name: Download Source distribution artifact
uses: actions/download-artifact@v5
uses: actions/download-artifact@v7
with:
name: Source distribution
- name: Download macOS artifact
uses: actions/download-artifact@v5
uses: actions/download-artifact@v7
with:
name: macOS binary
- name: Download Windows artifacts
uses: actions/download-artifact@v5
uses: actions/download-artifact@v7
with:
pattern: ${{ (contains(github.ref, 'refs/tags/')) && '*signed*' || '*Windows*' }}
merge-multiple: true

View File

@@ -7,7 +7,7 @@ jobs:
name: Black Code Formatter
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Black Code Formatter
uses: lgeiger/black-action@master
with:
@@ -43,7 +43,7 @@ jobs:
python-version: "3.13"
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:

View File

@@ -26,7 +26,7 @@ jobs:
if: github.repository_owner == 'sabnzbd'
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v5
- uses: dessant/lock-threads@v6
with:
log-output: true
issue-inactive-days: 60

View File

@@ -12,7 +12,7 @@ jobs:
env:
TX_TOKEN: ${{ secrets.TX_TOKEN }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
token: ${{ secrets.AUTOMATION_GITHUB_TOKEN }}
- name: Generate translatable texts
@@ -30,7 +30,7 @@ jobs:
run: |
python3 tools/make_mo.py
- name: Push translatable and translated texts back to repo
uses: stefanzweifel/git-auto-commit-action@v6.0.1
uses: stefanzweifel/git-auto-commit-action@v7.1.0
if: env.TX_TOKEN
with:
commit_message: |

View File

@@ -39,7 +39,7 @@ import time
import re
import gc
import threading
import http.cookies
import uvicorn
from typing import List, Dict, Any
try:
@@ -47,7 +47,6 @@ try:
import Cheetah
import feedparser
import configobj
import cherrypy
import cheroot.errors
import portend
import cryptography
@@ -1262,109 +1261,33 @@ def main():
if inet_exposure:
sabnzbd.cfg.inet_exposure.set(inet_exposure)
mime_gzip = (
"text/*",
"application/javascript",
"application/x-javascript",
"application/json",
"application/xml",
"application/vnd.ms-fontobject",
"application/font*",
"image/svg+xml",
)
cherrypy.config.update(
{
"server.environment": "production",
"server.socket_host": web_host,
"server.socket_port": web_port,
"server.shutdown_timeout": 0,
"engine.autoreload.on": False,
"tools.encode.on": True,
"tools.gzip.on": True,
"tools.gzip.mime_types": mime_gzip,
"request.show_tracebacks": True,
"error_page.401": sabnzbd.panic.error_page_401,
"error_page.404": sabnzbd.panic.error_page_404,
}
)
# Monkey-patch key validation to prevent cherrypy from stumbling over invalid cookies
http.cookies._is_legal_key = lambda _: True
# Catch shutdown errors that can break cherrypy/cheroot
# See https://github.com/cherrypy/cheroot/issues/710
try:
cheroot.errors.acceptable_sock_shutdown_exceptions += (OSError,)
except AttributeError:
pass
# Do we want CherryPy Logging? Cannot be done via the config
cherrypy.log.screen = False
cherrypy.log.access_log.propagate = False
if cherrypylogging:
sabnzbd.WEBLOGFILE = os.path.join(logdir, DEF_LOG_CHERRY)
cherrypy.log.access_file = str(sabnzbd.WEBLOGFILE)
# Force mimetypes (OS might overwrite them)
forced_mime_types = {"css": "text/css", "js": "application/javascript"}
static = {
"tools.staticdir.on": True,
"tools.staticdir.dir": os.path.join(sabnzbd.WEB_DIR, "static"),
"tools.staticdir.content_types": forced_mime_types,
}
staticcfg = {
"tools.staticdir.on": True,
"tools.staticdir.dir": os.path.join(sabnzbd.WEB_DIR_CONFIG, "staticcfg"),
"tools.staticdir.content_types": forced_mime_types,
}
wizard_static = {
"tools.staticdir.on": True,
"tools.staticdir.dir": os.path.join(sabnzbd.WIZARD_DIR, "static"),
"tools.staticdir.content_types": forced_mime_types,
}
appconfig = {
"/api": {
"tools.auth_basic.on": False,
"tools.response_headers.on": True,
"tools.response_headers.headers": [("Access-Control-Allow-Origin", "*")],
},
"/static": static,
"/wizard/static": wizard_static,
"/favicon.ico": {
"tools.staticfile.on": True,
"tools.staticfile.filename": os.path.join(sabnzbd.WEB_DIR_CONFIG, "staticcfg", "ico", "favicon.ico"),
},
"/staticcfg": staticcfg,
}
# Make available from both URLs
main_page = sabnzbd.interface.MainPage()
cherrypy.Application.relative_urls = "server"
cherrypy.tree.mount(main_page, "/", config=appconfig)
cherrypy.tree.mount(main_page, sabnzbd.cfg.url_base(), config=appconfig)
# Set authentication for CherryPy
sabnzbd.interface.set_auth(cherrypy.config)
logging.info("Starting web-interface on %s:%s", web_host, web_port)
sabnzbd.cfg.log_level.callback(guard_loglevel)
try:
cherrypy.engine.start()
except Exception:
# Since the webserver is started by cherrypy in a separate thread, we can't really catch any
# start-up errors. This try/except only catches very few errors, the rest is only shown in the console.
logging.error(T("Failed to start web-interface: "), exc_info=True)
abort_and_show_error(browserhost, web_port)
# Create a record of the active cert/key/chain files, for use with config.create_config_backup()
if enable_https:
for setting in CONFIG_BACKUP_HTTPS.values():
if full_path := getattr(sabnzbd.cfg, setting).get_path():
sabnzbd.CONFIG_BACKUP_HTTPS_OK.append(full_path)
# Catch logging using SABnzbd handlers
# Format: https://github.com/encode/uvicorn/blob/d43afed1cfa018a85c83094da8a2dd29f656d676/uvicorn/config.py#L82-L114
uvicorn_logging_config = {
"version": 1,
"loggers": {
"uvicorn": {"propagate": True},
"uvicorn.error": {"propagate": True},
"uvicorn.access": {"propagate": False},
},
}
server_config = uvicorn.Config(
sabnzbd.interface.app, host=web_host, port=web_port, log_config=uvicorn_logging_config
)
sabnzbd.WEB_SERVER = sabnzbd.interface.ThreadedServer(config=server_config)
sabnzbd.WEB_SERVER.run_in_thread()
# Set URL for browser
if enable_https:
sabnzbd.BROWSER_URL = "https://%s:%s%s" % (browserhost, web_port, sabnzbd.cfg.url_base())
@@ -1484,6 +1407,7 @@ def main():
notifier.send_notification("SABnzbd", T("SABnzbd shutdown finished"), "startup")
logging.info("Leaving SABnzbd")
sabnzbd.pid_file()
sabnzbd.WEB_SERVER.stop()
try:
sys.stderr.flush()

View File

@@ -1,19 +1,27 @@
# Basic build requirements
# Note that not all sub-dependencies are listed, but only ones we know could cause trouble
pyinstaller==6.16.0
pyinstaller==6.17.0
packaging==25.0
pyinstaller-hooks-contrib==2025.9
altgraph==0.17.4
wrapt==1.17.3
pyinstaller-hooks-contrib==2025.11
altgraph==0.17.5
wrapt==2.0.1
setuptools==80.9.0
# Required on 32bit Windows, exclude it based on Python-version
importlib_metadata==8.7.1; python_version < '3.10'
importlib_resources==6.5.2; python_version < '3.10'
zipp==3.23.0; python_version < '3.10'
# orjson does not support 32bit Windows, also exclude based on Python-version
orjson==3.11.5; python_version > '3.8'
# For the Windows build
pefile==2024.8.26; sys_platform == 'win32'
pywin32-ctypes==0.2.3; sys_platform == 'win32'
# For the macOS build
dmgbuild==1.6.5; sys_platform == 'darwin'
mac-alias==2.2.2; sys_platform == 'darwin'
macholib==1.16.3; sys_platform == 'darwin'
ds-store==1.3.1; sys_platform == 'darwin'
PyNaCl==1.6.0; sys_platform == 'darwin'
dmgbuild==1.6.6; sys_platform == 'darwin'
mac-alias==2.2.3; sys_platform == 'darwin'
macholib==1.16.4; sys_platform == 'darwin'
ds-store==1.3.2; sys_platform == 'darwin'
PyNaCl==1.6.1; sys_platform == 'darwin'

View File

@@ -10,7 +10,7 @@
<h5 class="darkred"><strong>$T('explain-relFolder'):</strong> <span class="path">$defdir</span></h5>
<!--#for $cur, $slot in enumerate($slotinfo)#-->
<!--#set $cansort = $slot.name != '*' and $slot.name != ''#-->
<form action="save" method="post" <!--#if $cansort#-->class="sorting-row"<!--#end if#-->>
<form action="categories/save" method="get" class="fullform" <!--#if $cansort#-->class="sorting-row"<!--#end if#-->>
<table class="catTable">
<!--#if $cur == 0#-->
<tr>
@@ -108,7 +108,7 @@
jQuery(document).ready(function() {
jQuery('.delCat').click(function() {
var theForm = jQuery(this).closest("form");
theForm.attr("action", "delete").submit();
theForm.attr("action", "categories/delete").submit();
});
// Add autocomplete and file-browser

View File

@@ -8,7 +8,7 @@
<input type="checkbox" id="advanced-settings-button" name="advanced-settings-button"> $T('button-advanced')
</label>
</div>
<form action="saveDirectories" method="post" name="fullform" class="fullform" autocomplete="off">
<form action="folders/save" method="get" name="fullform" class="fullform" autocomplete="off">
<input type="hidden" id="apikey" name="apikey" value="$apikey" />
<input type="hidden" name="output" value="json" />
<input type="hidden" id="ajax" name="ajax" value="1" />

View File

@@ -8,7 +8,7 @@
<input type="checkbox" id="advanced-settings-button" name="advanced-settings-button"> $T('button-advanced')
</label>
</div>
<form action="saveGeneral" method="post" name="fullform" class="fullform" autocomplete="off">
<form action="general/save" method="get" name="fullform" class="fullform" autocomplete="off">
<input type="hidden" id="apikey" name="apikey" value="$apikey" />
<input type="hidden" id="ajax" name="ajax" value="1" />
<input type="hidden" name="output" value="json" />
@@ -46,7 +46,6 @@
<!--#end if#-->
<!--#end for#-->
</select>
<span class="desc">$T('explain-web_dir')&nbsp;&nbsp;<a href="$caller_url">$caller_url</a></span>
</div>
<div class="field-pair">
<label class="config" for="language">$T('opt-language')</label>

View File

@@ -29,7 +29,7 @@
<!--#end def#-->
<div class="colmask">
<form action="saveNotify" method="post" name="fullform" class="fullform" autocomplete="off">
<form action="notify/save" method="post" name="fullform" class="fullform" autocomplete="off">
<input type="hidden" id="apikey" name="apikey" value="$apikey" />
<input type="hidden" name="output" value="json" />
<input type="hidden" id="ajax" name="ajax" value="1" />

View File

@@ -40,7 +40,7 @@
<h3>$T('addServer') <a href="$help_uri" target="_blank"><span class="glyphicon glyphicon-question-sign"></span></a></h3>
</div>
<div class="col1">
<form action="addServer" method="post" autocomplete="off" onsubmit="removeObfuscation();">
<form action="server/add_server" method="post" autocomplete="off" onsubmit="removeObfuscation();">
<input type="hidden" name="apikey" value="$apikey" />
<input type="hidden" name="output" value="json" />
<fieldset>
@@ -147,7 +147,7 @@
<!--#set $cur_prio_color = -1 #-->
<!--#set $last_prio = -1 #-->
<!--#for $cur, $server in enumerate($servers) #-->
<form action="saveServer" method="post" class="fullform" autocomplete="off">
<form action="server/save_server" method="post" class="fullform" autocomplete="off">
<input type="hidden" name="apikey" value="$apikey" />
<input type="hidden" name="output" value="json" />
<input type="hidden" name="server" value="$server['name']" />
@@ -561,7 +561,7 @@
resultBox.prepend('<span class="glyphicon glyphicon-ok-sign"></span> ')
// Allow adding the new server if we are in the new-server section
if(theButton.parents("form[action='addServer']").length) {
if(theButton.parents("form[action='server/add_server']").length) {
jQuery(".addNewServer").removeAttr("disabled")
}
} else {
@@ -569,7 +569,7 @@
resultBox.prepend('<span class="glyphicon glyphicon-exclamation-sign"></span> ')
// Disable the adding of new server, just to be sure
if(theButton.parents("form[action='addServer']").length) {
if(theButton.parents("form[action='server/add_server']").length) {
jQuery(".addNewServer").attr("disabled", "disabled")
}
}
@@ -578,7 +578,7 @@
jQuery('.delServer').click(function(){
if( confirm("$T('confirm')") ) {
jQuery(this).parents('form:first').attr('action','delServer').submit();
jQuery(this).parents('form:first').attr('action','server/delete_server').submit();
// Let us leave!
formWasSubmitted = true;
formHasChanged = false;
@@ -589,7 +589,7 @@
jQuery('.clrServer').click(function(){
if( confirm("$T('confirm')") ) {
jQuery(this).parents('form:first').attr('action','clrServer').submit();
jQuery(this).parents('form:first').attr('action','server/clear_server').submit();
// Let us leave!
formWasSubmitted = true;
formHasChanged = false;
@@ -602,7 +602,7 @@
var whichServer = jQuery(this).attr("name");
jQuery.ajax({
type: "POST",
url: "toggleServer",
url: "server/toggle_server",
data: {server: whichServer, apikey: "$apikey" }
}).done(function() {
// Let us leave!

View File

@@ -3,7 +3,7 @@
<!--#include $webdir + "/_inc_header_uc.tmpl"#-->
<div class="colmask">
<form action="saveSpecial" method="post" autocomplete="off">
<form action="special/save" method="get" name="fullform" class="fullform" autocomplete="off">
<input type="hidden" id="apikey" name="apikey" value="$apikey" />
<div class="padTable">
<h4 class="darkred nomargin">$T('explain-special')</h4>

View File

@@ -8,7 +8,7 @@
<input type="checkbox" id="advanced-settings-button" name="advanced-settings-button"> $T('button-advanced')
</label>
</div>
<form action="saveSwitches" method="post" name="fullform" class="fullform" autocomplete="off">
<form action="switches/save" method="get" name="fullform" class="fullform" autocomplete="off">
<input type="hidden" id="apikey" name="apikey" value="$apikey" />
<input type="hidden" id="ajax" name="ajax" value="1" />
<input type="hidden" name="output" value="json" />

View File

@@ -32,7 +32,7 @@
<span class="glyphicon glyphicon-question-sign"></span>
</a>
</div>
<form class="form-signin" action="./" method="post">
<form class="form-signin" action="./login" method="post">
<!--#if $error#-->
<div class="alert alert-danger" role="alert">$error</div>
<!--#end if#-->

View File

@@ -1,16 +1,16 @@
# Main requirements
# Note that not all sub-dependencies are listed, but only ones we know could cause trouble
apprise==1.9.4
apprise==1.9.6
sabctools==8.2.6
CT3==3.4.0
CT3==3.4.0.post5
cffi==2.0.0
pycparser==2.23
feedparser==6.0.12
configobj==5.0.9
cheroot==11.0.0
cheroot==11.1.2
six==1.17.0
cherrypy==18.10.0
jaraco.functools==4.3.0
jaraco.functools==4.4.0
jaraco.collections==5.0.0
jaraco.text==3.8.1 # Newer version introduces irrelevant extra dependencies
jaraco.classes==3.4.0
@@ -29,14 +29,24 @@ guessit==3.8.0
babelfish==0.6.1
rebulk==3.2.0
# New for starlette/uvicorn
starlette==0.50.0
anyio==4.12.0
sniffio==1.3.1
uvicorn==0.40.0
click==8.3.1
h11==0.16.0
python-multipart==0.0.21
httptools==0.7.1
# Recent cryptography versions require Rust. If you run into issues compiling this
# SABnzbd will also work with older pre-Rust versions such as cryptography==3.3.2
cryptography==46.0.1
cryptography==46.0.3
# We recommend using "orjson" as it is 2x as fast as "ujson". However, it requires
# Rust so SABnzbd works just as well with "ujson" or the Python built in "json" module
ujson==5.11.0
orjson==3.11.3
orjson==3.11.5
# Windows system integration
pywin32==311; sys_platform == 'win32'
@@ -49,8 +59,8 @@ winrt-Windows.UI.Notifications==3.2.1; sys_platform == 'win32'
typing_extensions==4.15.0; sys_platform == 'win32'
# macOS system calls
pyobjc-core==11.1; sys_platform == 'darwin'
pyobjc-framework-Cocoa==11.1; sys_platform == 'darwin'
pyobjc-core==12.1; sys_platform == 'darwin'
pyobjc-framework-Cocoa==12.1; sys_platform == 'darwin'
# Linux notifications
notify2==0.3.1; sys_platform != 'win32' and sys_platform != 'darwin'
@@ -59,14 +69,14 @@ notify2==0.3.1; sys_platform != 'win32' and sys_platform != 'darwin'
requests==2.32.5
requests-oauthlib==2.0.0
PyYAML==6.0.3
markdown==3.9
markdown==3.10
paho-mqtt==1.6.1 # Pinned, newer versions don't work with AppRise yet
# Requests Requirements
charset_normalizer==3.4.3
idna==3.10
urllib3==2.5.0
certifi==2025.8.3
charset_normalizer==3.4.4
idna==3.11
urllib3==2.6.2
certifi==2025.11.12
oauthlib==3.3.1
PyJWT==2.10.1
blinker==1.9.0

View File

@@ -21,8 +21,8 @@ import datetime
import ctypes.util
import time
import ssl
from typing import Optional
import cherrypy
import platform
import concurrent.futures
import sys
@@ -150,6 +150,7 @@ LOGFILE = None
WEBLOGFILE = None
GUIHANDLER = None
LOG_ALL = False
WEB_SERVER: Optional[sabnzbd.interface.ThreadedServer] = None
WIN_SERVICE = None # Instance of our Win32 Service Class
BROWSER_URL = None
@@ -225,9 +226,6 @@ def initialize(pause_downloader=False, clean_up=False, repair=0):
sys.setswitchinterval(cfg.switchinterval())
# Set global database connection for Web-UI threads
cherrypy.engine.subscribe("start_thread", get_db_connection)
# Paused?
pause_downloader = pause_downloader or cfg.start_paused()
@@ -418,7 +416,7 @@ def shutdown_program():
if not sabnzbd.SABSTOP:
logging.info("[%s] Performing SABnzbd shutdown", misc.caller_name())
sabnzbd.halt()
cherrypy.engine.exit()
sabnzbd.WEB_SERVER.stop()
sabnzbd.SABSTOP = True
notify_shutdown_loop()

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

@@ -29,7 +29,6 @@ import datetime
import zipfile
import tempfile
import cherrypy._cpreqbody
from typing import Optional, Dict, Any, Union, List, Tuple
import sabnzbd
@@ -47,7 +46,7 @@ from sabnzbd.utils import rarfile
def add_nzbfile(
nzbfile: Union[str, cherrypy._cpreqbody.Part],
nzbfile,
pp: Optional[Union[int, str]] = None,
script: Optional[str] = None,
cat: Optional[str] = None,

View File

@@ -22,7 +22,6 @@ sabnzbd.nzbqueue - nzb queue
import os
import logging
import time
import cherrypy._cpreqbody
from typing import List, Dict, Union, Tuple, Optional
import sabnzbd
@@ -158,9 +157,7 @@ class NzbQueue:
logging.info("Skipping repair for job %s", folder)
return result
def repair_job(
self, repair_folder: str, new_nzb: Optional[cherrypy._cpreqbody.Part] = None, password: Optional[str] = None
) -> Optional[str]:
def repair_job(self, repair_folder: str, new_nzb=None, password: Optional[str] = None) -> Optional[str]:
"""Reconstruct admin for a single job folder, optionally with new NZB"""
# Check if folder exists
if not repair_folder or not os.path.exists(repair_folder):

View File

@@ -10,7 +10,7 @@ pytest-httpserver
flaky
xmltodict
tavern
tavern==3.0.0a9; python_version >= '3.11' # Latest version only supported on Python 3.11 and above
tavern==3.0.0; python_version >= '3.11' # Latest version only supported on Python 3.11 and above
flask
tavalidate
importlib_metadata

View File

@@ -18,92 +18,157 @@
"""
tests.test_api - Tests for API functions
"""
import cherrypy
import pytest
from unittest.mock import Mock, AsyncMock, patch
from starlette.requests import Request
from starlette.responses import Response, PlainTextResponse, RedirectResponse
from starlette.datastructures import Headers, Address, QueryParams
from tests.testhelper import *
import sabnzbd.api as api
import sabnzbd.interface as interface
import sabnzbd
class TestApiInternals:
"""Test internal functions of the API"""
def test_empty(self):
with pytest.raises(TypeError):
api.api_handler(None)
with pytest.raises(AttributeError):
api.api_handler("")
api.api_handler(None)
# Empty string should work but result in undefined mode
result = api.api_handler(QueryParams({}))
assert "not implemented" in result.body.decode()
def test_mode_invalid(self):
assert "not implemented" in str(api.api_handler({"mode": "invalid"}))
result = api.api_handler(QueryParams({"mode": "invalid"}))
assert "not implemented" in result.body.decode()
def test_version(self):
assert sabnzbd.__version__ in str(api.api_handler({"mode": "version"}))
result = api.api_handler(QueryParams({"mode": "version"}))
assert sabnzbd.__version__ in result.body.decode()
def test_auth(self):
assert "apikey" in str(api.api_handler({"mode": "auth"}))
result = api.api_handler(QueryParams({"mode": "auth"}))
assert "apikey" in result.body.decode()
def set_remote_host_or_ip(hostname: str = "localhost", remote_ip: str = "127.0.0.1"):
"""Change CherryPy's "Host" and "remote.ip"-values"""
cherrypy.request.headers["Host"] = hostname
cherrypy.request.remote.ip = remote_ip
def create_mock_request(
hostname: str = "localhost", remote_ip: str = "127.0.0.1", headers: dict = None, query_params: dict = None
):
"""Create a mock Starlette Request object for testing"""
mock_request = Mock(spec=Request)
mock_request.client = Address(remote_ip, 12345)
# Set up headers
request_headers = {"Host": hostname}
if headers:
request_headers.update(headers)
mock_request.headers = Headers(request_headers)
# Set up query params
mock_request.query_params = QueryParams(query_params or {})
return mock_request
class TestSecuredExpose:
"""Test the security handling"""
"""Test the security handling for Starlette interface"""
main_page = sabnzbd.interface.MainPage()
def setup_method(self):
"""Set up mocks for SABnzbd components before each test"""
# Instead of mocking every individual component, let's mock the main API functions
# that are used in testing to return simple, predictable responses
def api_wrapper(self, *args, **kwargs):
"""Wrapper to convert bytes to str"""
if api_response := self.main_page.api(*args, **kwargs):
return str(api_response)
# Mock build_queue to return a simple queue response
mock_queue_response = {
"version": "test-version",
"paused": False,
"slots": [],
"noofslots": 0,
"limit": 0,
"start": 0,
"finish": 0,
"cache_art": "0",
"cache_size": "0 B",
"kbpersec": "0.00",
"speed": "0 B/s",
"mbleft": "0.00",
"mb": "0.00",
"sizeleft": "0 B",
"size": "0 B",
"timeleft": "0:00:00",
"eta": "unknown",
}
def check_full_access(self, redirect_match: str = r".*wizard.*"):
# Apply patches for main API functions
self.build_queue_patch = patch("sabnzbd.api.build_queue", return_value=mock_queue_response)
# Start all patches
self.build_queue_patch.start()
def teardown_method(self):
"""Clean up mocks after each test"""
self.build_queue_patch.stop()
async def call_api_endpoint(self, request: Request):
"""Call the API endpoint directly"""
return await interface.api(request)
async def call_main_endpoint(self, request: Request):
"""Call the main endpoint directly"""
return await interface.main_index(request)
def api_wrapper(self, **kwargs):
"""Wrapper to test API calls with query parameters"""
request = create_mock_request(query_params=kwargs)
return api.api_handler(request.query_params)
def check_full_access(self, hostname="localhost", remote_ip="127.0.0.1"):
"""Basic test if we have full access to API and interface"""
assert sabnzbd.__version__ in self.api_wrapper(mode="version")
# Passed authentication
assert api._MSG_NOT_IMPLEMENTED in self.api_wrapper(apikey=sabnzbd.cfg.api_key())
# Raises a redirect to the wizard
with pytest.raises(cherrypy._cperror.HTTPRedirect, match=redirect_match):
self.main_page.index()
# Test API access
result = self.api_wrapper(mode="version")
assert sabnzbd.__version__ in result.body.decode()
# Test API with correct key
result = self.api_wrapper(mode="queue", apikey=sabnzbd.cfg.api_key())
assert "queue" in result.body.decode() # Should return queue data
def test_basic(self):
set_remote_host_or_ip()
"""Test basic API access functionality"""
self.check_full_access()
def test_api_no_or_wrong_api_key(self):
set_remote_host_or_ip()
# Get blocked
assert interface._MSG_APIKEY_REQUIRED in self.api_wrapper()
assert interface._MSG_APIKEY_REQUIRED in self.api_wrapper(mode="queue")
"""Test API key validation through direct API handler calls"""
# Allowed to access "auth" and "version" without key
assert "apikey" in self.api_wrapper(mode="auth")
assert sabnzbd.__version__ in self.api_wrapper(mode="version")
# Blocked when you do something wrong
assert interface._MSG_APIKEY_INCORRECT in self.api_wrapper(mode="queue", apikey="wrong")
result = self.api_wrapper(mode="auth")
assert "apikey" in result.body.decode()
result = self.api_wrapper(mode="version")
assert sabnzbd.__version__ in result.body.decode()
# Other modes should work with correct API key
result = self.api_wrapper(mode="queue", apikey=sabnzbd.cfg.api_key())
assert "queue" in result.body.decode()
def test_api_nzb_key(self):
set_remote_host_or_ip()
# It should only access the nzb-functions, nothing else
assert api._MSG_NO_VALUE in self.api_wrapper(mode="addfile", apikey=sabnzbd.cfg.nzb_key())
assert interface._MSG_APIKEY_INCORRECT in self.api_wrapper(mode="set_config", apikey=sabnzbd.cfg.nzb_key())
assert interface._MSG_APIKEY_INCORRECT in self.main_page.shutdown(apikey=sabnzbd.cfg.nzb_key())
"""Test NZB key functionality"""
# NZB key should work for addfile (level 1 access)
result = self.api_wrapper(mode="addfile", apikey=sabnzbd.cfg.nzb_key())
assert api._MSG_NO_VALUE in result.body.decode() # No file provided, but key was accepted
def test_check_hostname_basic(self):
# Block bad host
set_remote_host_or_ip(hostname="not_me")
assert interface._MSG_ACCESS_DENIED_HOSTNAME in self.api_wrapper()
assert interface._MSG_ACCESS_DENIED_HOSTNAME in self.main_page.index()
# Block empty value
set_remote_host_or_ip(hostname="")
assert interface._MSG_ACCESS_DENIED_HOSTNAME in self.api_wrapper()
assert interface._MSG_ACCESS_DENIED_HOSTNAME in self.main_page.index()
"""Test hostname checking functionality"""
# Test the check_hostname_starlette function directly
# Fine if ip-address
# Block bad host
bad_request = create_mock_request(hostname="not_me")
assert interface.check_hostname(bad_request) is False
# Block empty hostname
empty_request = create_mock_request(hostname="")
assert interface.check_hostname(empty_request) is False
# Allow valid hostnames/IPs
for test_hostname in (
"100.100.100.100",
"100.100.100.100:8080",
@@ -112,121 +177,206 @@ class TestSecuredExpose:
"test.local",
"test.local:8080",
"test.local.",
"localhost",
):
set_remote_host_or_ip(hostname=test_hostname)
self.check_full_access()
good_request = create_mock_request(hostname=test_hostname)
assert interface.check_hostname(good_request) is True
@set_config({"username": "foo", "password": "bar"})
def test_check_hostname_not_user_password(self):
set_remote_host_or_ip(hostname="not_me")
self.check_full_access(redirect_match=r".*login.*")
def test_check_hostname_with_auth(self):
"""Test hostname checking with authentication enabled"""
# With username/password set, hostname check should always pass
bad_request = create_mock_request(hostname="not_me")
assert interface.check_hostname(bad_request) is True
@set_config({"host_whitelist": "test.com, not_evil"})
def test_check_hostname_whitelist(self):
set_remote_host_or_ip(hostname="test.com")
self.check_full_access()
set_remote_host_or_ip(hostname="not_evil")
self.check_full_access()
"""Test hostname whitelist functionality"""
# Whitelisted hostnames should be allowed
request1 = create_mock_request(hostname="test.com")
assert interface.check_hostname(request1) is True
request2 = create_mock_request(hostname="not_evil")
assert interface.check_hostname(request2) is True
# Non-whitelisted hostname should be blocked
request3 = create_mock_request(hostname="evil.com")
assert interface.check_hostname(request3) is False
def test_dual_stack(self):
set_remote_host_or_ip(remote_ip="::ffff:192.168.0.10")
self.check_full_access()
"""Test IPv6 dual stack functionality"""
request = create_mock_request(remote_ip="::ffff:192.168.0.10")
# Dual stack IPs should be treated as local
assert interface.check_access(request, access_type=4) is True
@set_config({"local_ranges": "132.10."})
def test_dual_stack_local_ranges(self):
# Without custom local_ranges this one would be allowed
set_remote_host_or_ip(remote_ip="::ffff:192.168.0.10")
self.check_inet_blocks(inet_exposure=0)
# But now we only allow the custom ones
set_remote_host_or_ip(remote_ip="::ffff:132.10.0.10")
self.check_full_access()
"""Test custom local ranges"""
# IP not in custom local_ranges should be blocked
request1 = create_mock_request(remote_ip="::ffff:192.168.0.10")
assert interface.check_access(request1, access_type=5) is False
def check_inet_allows(self, inet_exposure: int):
"""Each should allow all previous ones and the current one"""
# Level 1: nzb
if inet_exposure >= 1:
assert api._MSG_NO_VALUE in self.api_wrapper(mode="addfile", apikey=sabnzbd.cfg.nzb_key())
assert api._MSG_NO_VALUE in self.api_wrapper(mode="addfile", apikey=sabnzbd.cfg.api_key())
# IP in custom local_ranges should be allowed
request2 = create_mock_request(remote_ip="::ffff:132.10.0.10")
assert interface.check_access(request2, access_type=4) is True
# Level 2: basic API
if inet_exposure >= 2:
assert api._MSG_NO_VALUE in self.api_wrapper(mode="get_files", apikey=sabnzbd.cfg.api_key())
assert api._MSG_NO_VALUE in self.api_wrapper(mode="change_script", apikey=sabnzbd.cfg.api_key())
# Sub-function
assert "status" in self.api_wrapper(mode="queue", name="resume", apikey=sabnzbd.cfg.api_key())
def test_inet_exposure_basic(self):
"""Test basic inet exposure functionality"""
# Test with external IP (should be blocked for high access levels)
external_request = create_mock_request(remote_ip="11.11.11.11")
# Level 3: full API
if inet_exposure >= 3:
assert "misc" in self.api_wrapper(mode="get_config", apikey=sabnzbd.cfg.api_key())
# Sub-function
assert "The hostname is not set" in self.api_wrapper(
mode="config", name="test_server", apikey=sabnzbd.cfg.api_key()
)
# Test different access levels
@set_config({"inet_exposure": 2})
def _test_exposure():
# Level 1-2 should be allowed
assert interface.check_access(external_request, access_type=1) is True
assert interface.check_access(external_request, access_type=2) is True
# Level 3+ should be blocked
assert interface.check_access(external_request, access_type=3) is False
assert interface.check_access(external_request, access_type=4) is False
# Level 4: full interface
if inet_exposure >= 4:
self.check_full_access()
_test_exposure()
def check_inet_blocks(self, inet_exposure: int):
"""We count from the most exposure down"""
# Level 4: full interface, no blocking
# Level 3: full API
if inet_exposure <= 3:
assert interface._MSG_ACCESS_DENIED in self.main_page.index()
def test_local_access_always_allowed(self):
"""Test that local IPs are always allowed regardless of inet_exposure"""
local_request = create_mock_request(remote_ip="127.0.0.1")
# Level 2: basic API
if inet_exposure <= 2:
assert interface._MSG_ACCESS_DENIED in self.api_wrapper(mode="get_config", apikey=sabnzbd.cfg.api_key())
assert interface._MSG_ACCESS_DENIED in self.api_wrapper(
mode="config", name="set_nzbkey", apikey=sabnzbd.cfg.api_key()
)
# Level 1: nzb
if inet_exposure <= 1:
assert interface._MSG_ACCESS_DENIED in self.api_wrapper(mode="get_scripts", apikey=sabnzbd.cfg.api_key())
assert interface._MSG_ACCESS_DENIED in self.api_wrapper(
mode="queue", name="resume", apikey=sabnzbd.cfg.api_key()
)
@set_config({"inet_exposure": 0})
def _test_local():
# Even with minimal exposure, local IPs should be allowed
assert interface.check_access(local_request, access_type=4) is True
assert interface.check_access(local_request, access_type=5) is True
# Level 0: nothing, already checked above, but just to be sure
if inet_exposure <= 0:
assert interface._MSG_ACCESS_DENIED in self.api_wrapper(mode="addfile", apikey=sabnzbd.cfg.api_key())
# Check with or without API-key
assert interface._MSG_ACCESS_DENIED in self.api_wrapper(mode="auth", apikey=sabnzbd.cfg.api_key())
assert interface._MSG_ACCESS_DENIED in self.api_wrapper(mode="auth")
_test_local()
def test_inet_exposure(self):
# Run all tests as external user
set_remote_host_or_ip(hostname="100.100.100.100", remote_ip="11.11.11.11")
@pytest.mark.parametrize("inet_exposure", [0, 1, 2, 3, 4, 5])
@pytest.mark.parametrize("access_type", [1, 2, 3, 4, 5, 6])
@pytest.mark.parametrize(
"remote_ip,expected_local",
[
("192.168.1.10", True), # Local IP
("127.0.0.1", True), # Loopback IP
("8.8.8.8", False), # External IP
],
)
def test_inet_exposure_levels_comprehensive(self, inet_exposure, access_type, remote_ip, expected_local):
"""Test all inet_exposure levels with different access types and IP types"""
request = create_mock_request(remote_ip=remote_ip)
# We don't use the wrapper, it would require creating many extra functions
# Option 5 is special, so it also gets it's own special test
for inet_exposure in range(6):
sabnzbd.cfg.inet_exposure.set(inet_exposure)
self.check_inet_allows(inet_exposure=inet_exposure)
self.check_inet_blocks(inet_exposure=inet_exposure)
@set_config({"inet_exposure": inet_exposure})
def _test_exposure():
if expected_local:
# Local and loopback IPs should always be allowed
assert interface.check_access(request, access_type) is True
else:
# External IPs should follow inet_exposure rules
expected_allowed = access_type <= inet_exposure
assert interface.check_access(request, access_type) is expected_allowed
# Reset it
sabnzbd.cfg.inet_exposure.set(sabnzbd.cfg.inet_exposure.default)
_test_exposure()
@set_config({"inet_exposure": 5, "username": "foo", "password": "bar"})
def test_inet_exposure_login_for_external(self):
# Local user: full access
set_remote_host_or_ip()
self.check_full_access()
def test_inet_exposure_with_xff_headers(self):
"""Test inet_exposure behavior with X-Forwarded-For headers"""
# XFF is only checked when remote IP is local/loopback but XFF contains non-local IPs
# Test with local remote IP but external XFF
local_request_external_xff = create_mock_request(
remote_ip="192.168.1.1", headers={"X-Forwarded-For": "8.8.8.8"}
)
# Remote user: redirect to login
set_remote_host_or_ip(hostname="100.100.100.100", remote_ip="11.11.11.11")
self.check_full_access(redirect_match=r".*login.*")
# Test with local remote IP and local XFF
local_request_local_xff = create_mock_request(
remote_ip="192.168.1.1", headers={"X-Forwarded-For": "192.168.1.10"}
)
@set_config({"api_warnings": False})
def test_no_text_warnings(self):
assert self.main_page.index() is None
assert cherrypy.response.status == 403
assert self.api_wrapper(mode="queue") is None
assert cherrypy.response.status == 403
set_remote_host_or_ip(hostname="not_me")
assert self.api_wrapper() is None
assert cherrypy.response.status == 403
# Test with external remote IP (XFF should be ignored)
external_request = create_mock_request(remote_ip="8.8.8.8", headers={"X-Forwarded-For": "192.168.1.10"})
@set_config({"inet_exposure": 2, "verify_xff_header": True})
def test_xff_with_exposure():
# Local IP with external XFF should be denied (XFF verification fails)
assert interface.check_access(local_request_external_xff, access_type=4) is False
# Local IP with local XFF should be allowed
assert interface.check_access(local_request_local_xff, access_type=4) is True
# External IP should follow inet_exposure rules (XFF ignored for external IPs)
assert interface.check_access(external_request, access_type=1) is True
assert interface.check_access(external_request, access_type=2) is True
assert interface.check_access(external_request, access_type=3) is False
test_xff_with_exposure()
# Note: The comprehensive parametrized test above covers all these scenarios,
# but this test provides explicit documentation of the API access level meanings
def test_inet_exposure_api_levels_documentation(self):
"""Document the different API access levels with inet_exposure"""
external_request = create_mock_request(remote_ip="8.8.8.8")
@set_config({"inet_exposure": 2})
def test_api_access_levels():
# access_type = 1: NZB upload access
assert interface.check_access(external_request, access_type=1) is True
# access_type = 2: Basic API access
assert interface.check_access(external_request, access_type=2) is True
# access_type = 3: Full API access (blocked with inet_exposure=2)
assert interface.check_access(external_request, access_type=3) is False
# access_type = 4: WebUI access (blocked with inet_exposure=2)
assert interface.check_access(external_request, access_type=4) is False
test_api_access_levels()
def test_inet_exposure_edge_cases(self):
"""Test edge cases for inet_exposure"""
# Test IPv6 addresses
ipv6_external_request = create_mock_request(remote_ip="2001:4860:4860::8888")
ipv6_local_request = create_mock_request(remote_ip="::1")
# Test dual-stack (IPv4-mapped IPv6)
dual_stack_request = create_mock_request(remote_ip="::ffff:192.168.1.10")
@set_config({"inet_exposure": 1})
def test_ipv6_exposure():
# IPv6 loopback should always be allowed
assert interface.check_access(ipv6_local_request, access_type=4) is True
# IPv6 external should follow inet_exposure rules
assert interface.check_access(ipv6_external_request, access_type=1) is True
assert interface.check_access(ipv6_external_request, access_type=2) is False
# Dual-stack should be treated as local
assert interface.check_access(dual_stack_request, access_type=4) is True
test_ipv6_exposure()
# Test with custom local ranges
custom_local_request = create_mock_request(remote_ip="4.4.4.10")
@set_config({"inet_exposure": 1, "local_ranges": ["4.4.4.0/24"]})
def test_custom_local_ranges():
# IP in custom local range should be treated as local
assert interface.check_access(custom_local_request, access_type=4) is True
test_custom_local_ranges()
# Note: Boundary conditions are covered by the comprehensive parametrized test
# This test serves as explicit documentation of the most restrictive/permissive settings
def test_inet_exposure_boundary_documentation(self):
"""Document boundary conditions for inet_exposure settings"""
external_request = create_mock_request(remote_ip="1.1.1.1")
@set_config({"inet_exposure": 0})
def test_most_restrictive_doc():
# inet_exposure=0: No external access allowed for any access type
assert interface.check_access(external_request, access_type=1) is False
@set_config({"inet_exposure": 5})
def test_most_permissive_doc():
# inet_exposure=5: External access allowed for access_type 1-5, but not 6
assert interface.check_access(external_request, access_type=5) is True
assert interface.check_access(external_request, access_type=6) is False
test_most_restrictive_doc()
test_most_permissive_doc()
class TestHistory:

View File

@@ -18,7 +18,9 @@
"""
tests.test_interface - Testing functions in interface.py
"""
import cherrypy
from unittest.mock import Mock
from starlette.requests import Request
from starlette.datastructures import Headers, Address
from sabnzbd import interface
from sabnzbd.misc import is_local_addr, is_loopback_addr
@@ -26,6 +28,14 @@ from sabnzbd.misc import is_local_addr, is_loopback_addr
from tests.testhelper import *
def create_mock_request(remote_ip: str = "127.0.0.1", headers: dict = None, remote_port: int = 12345):
"""Create a mock Starlette Request object for testing"""
mock_request = Mock(spec=Request)
mock_request.client = Address(remote_ip, remote_port)
mock_request.headers = Headers(headers or {})
return mock_request
class TestInterfaceFunctions:
@pytest.mark.parametrize(
"remote_ip, local_ranges, xff_header, result_with_xff",
@@ -147,9 +157,11 @@ class TestInterfaceFunctions:
}
)
def _func():
# Insert fake request data
cherrypy.request.remote.ip = remote_ip
cherrypy.request.headers.update({"X-Forwarded-For": xff_header})
# Create mock Starlette request with test data
headers = {}
if xff_header:
headers["X-Forwarded-For"] = xff_header
request = create_mock_request(remote_ip=remote_ip, headers=headers)
if verify_xff_header:
result = result_with_xff
@@ -158,9 +170,9 @@ class TestInterfaceFunctions:
result = is_loopback_addr(remote_ip) or is_local_addr(remote_ip)
if access_type <= inet_exposure:
assert interface.check_access(access_type) is True
assert interface.check_access(request, access_type) is True
else:
assert interface.check_access(access_type) is result
assert interface.check_access(request, access_type) is result
_func()
@@ -218,8 +230,8 @@ class TestInterfaceFunctions:
def test_remote_ip_from_xff(self, local_ranges, xff_ips, expected_result):
@set_config({"local_ranges": local_ranges})
def _func():
# Insert fake request data; should *not* influence the results of the tested function
cherrypy.request.remote.ip = "6.6.6.6"
# The remote_ip_from_xff function doesn't depend on request objects,
# it only takes the xff_ips list as parameter
assert xff_ips
assert interface.remote_ip_from_xff(xff_ips) is expected_result