Implement /restore API

This commit is contained in:
Iam54r1n4
2025-02-22 10:01:16 +03:30
parent 4dd9215d1b
commit c399551a50
3 changed files with 110 additions and 57 deletions

View File

@ -180,7 +180,8 @@ def backup_hysteria2():
except Exception as ex: except Exception as ex:
raise raise
def restore_hysteria2(backup_file_path):
def restore_hysteria2(backup_file_path: str):
'''Restores Hysteria configuration from the given backup file.''' '''Restores Hysteria configuration from the given backup file.'''
try: try:
run_cmd(['bash', Command.RESTORE_HYSTERIA2.value, backup_file_path]) run_cmd(['bash', Command.RESTORE_HYSTERIA2.value, backup_file_path])

View File

@ -1,7 +1,8 @@
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException, UploadFile, File
from ..schema.config.hysteria import ConfigFile, GetPortResponse, GetSniResponse from ..schema.config.hysteria import ConfigFile, GetPortResponse, GetSniResponse
from ..schema.response import DetailResponse from ..schema.response import DetailResponse
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
import shutil
# from ..schema.config.hysteria import InstallInputBody # from ..schema.config.hysteria import InstallInputBody
import os import os
import cli_api import cli_api
@ -150,20 +151,20 @@ async def set_sni_api(sni: str):
@router.get('/backup', response_class=FileResponse, summary='Backup Hysteria2 configuration') @router.get('/backup', response_class=FileResponse, summary='Backup Hysteria2 configuration')
async def backup(): async def backup_api():
""" """
Backups the Hysteria2 configuration and sends the backup ZIP file. Backups the Hysteria2 configuration and sends the backup ZIP file.
""" """
try: try:
cli_api.backup_hysteria2() cli_api.backup_hysteria2()
backup_dir = "/opt/hysbackup/" backup_dir = "/opt/hysbackup/" # TODO: get this path from .env
if not os.path.isdir(backup_dir): if not os.path.isdir(backup_dir):
raise HTTPException(status_code=500, detail="Backup directory does not exist.") raise HTTPException(status_code=500, detail="Backup directory does not exist.")
files = [f for f in os.listdir(backup_dir) if f.endswith('.zip')] files = [f for f in os.listdir(backup_dir) if f.endswith('.zip')]
files.sort(key=lambda x: os.path.getctime(os.path.join(backup_dir, x)), reverse=True) files.sort(key=lambda x: os.path.getctime(os.path.join(backup_dir, x)), reverse=True)
latest_backup_file = files[0] if files else None latest_backup_file = files[0] if files else None
if latest_backup_file: if latest_backup_file:
backup_file_path = os.path.join(backup_dir, latest_backup_file) backup_file_path = os.path.join(backup_dir, latest_backup_file)
@ -182,6 +183,24 @@ async def backup():
raise HTTPException(status_code=500, detail=f'Error: {str(e)}') raise HTTPException(status_code=500, detail=f'Error: {str(e)}')
@router.get('/restore', response_model=DetailResponse, summary='Restore Hysteria2 Configuration')
async def restore_api(f: UploadFile = File(...)):
try:
dst_dir_path = '/opt/hysbackup/' # TODO: get this path from .env
if not os.path.isdir(dst_dir_path): # TODO: the dir path should be exist, so no need to check
os.makedirs(dst_dir_path)
dst_path = os.path.join(dst_dir_path, f.filename) # type: ignore
with open(dst_path, 'wb') as buffer:
shutil.copyfileobj(f.file, buffer)
cli_api.restore_hysteria2(dst_path)
return DetailResponse(detail='Hysteria2 restored successfully.')
except Exception as e:
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
@router.get('/enable-obfs', response_model=DetailResponse, summary='Enable Hysteria2 obfs') @router.get('/enable-obfs', response_model=DetailResponse, summary='Enable Hysteria2 obfs')
async def enable_obfs(): async def enable_obfs():
""" """

View File

@ -19,65 +19,84 @@
<div class='col-lg-12'> <div class='col-lg-12'>
<div class='card card-primary card-outline card-tabs'> <div class='card card-primary card-outline card-tabs'>
<div class='card-header p-0 pt-1 border-bottom-0'> <div class='card-header p-0 pt-1 border-bottom-0'>
<ul class='nav nav-pills' id='custom-tabs-three-tab' role='tablist' style="margin-left: 20px; margin-top: 10px;"> <ul class='nav nav-pills' id='custom-tabs-three-tab' role='tablist'
style="margin-left: 20px; margin-top: 10px;">
<li class='nav-item'> <li class='nav-item'>
<a class='nav-link active' id='subs-tab' data-toggle='pill' href='#subs' role='tab' aria-controls='subs' aria-selected='false'><i class="fas fa-link"></i> Subscriptions</a> <a class='nav-link active' id='subs-tab' data-toggle='pill' href='#subs' role='tab'
aria-controls='subs' aria-selected='false'><i class="fas fa-link"></i>
Subscriptions</a>
</li> </li>
<li class='nav-item'> <li class='nav-item'>
<a class='nav-link' id='telegram-tab' data-toggle='pill' href='#telegram' role='tab' aria-controls='telegram' aria-selected='true'><i class="fab fa-telegram"></i> Telegram <a class='nav-link' id='telegram-tab' data-toggle='pill' href='#telegram' role='tab'
aria-controls='telegram' aria-selected='true'><i class="fab fa-telegram"></i>
Telegram
Bot</a> Bot</a>
</li> </li>
<li class='nav-item'> <li class='nav-item'>
<a class='nav-link' id='port-tab' data-toggle='pill' href='#port' role='tab' aria-controls='port' aria-selected='false'><i class="fas fa-server"></i> Change Port</a> <a class='nav-link' id='port-tab' data-toggle='pill' href='#port' role='tab'
aria-controls='port' aria-selected='false'><i class="fas fa-server"></i> Change
Port</a>
</li> </li>
<li class='nav-item'> <li class='nav-item'>
<a class='nav-link' id='sni-tab' data-toggle='pill' href='#sni' role='tab' aria-controls='sni' aria-selected='false'><i class="fas fa-shield-alt"></i> Change SNI</a> <a class='nav-link' id='sni-tab' data-toggle='pill' href='#sni' role='tab'
aria-controls='sni' aria-selected='false'><i class="fas fa-shield-alt"></i> Change
SNI</a>
</li> </li>
<li class='nav-item'> <li class='nav-item'>
<a class='nav-link' id='ip-tab' data-toggle='pill' href='#change_ip' role='tab' aria-controls='change_ip' aria-selected='false'><i class="fas fa-network-wired"></i> Change IP</a> <a class='nav-link' id='ip-tab' data-toggle='pill' href='#change_ip' role='tab'
aria-controls='change_ip' aria-selected='false'><i class="fas fa-network-wired"></i>
Change IP</a>
</li> </li>
<!-- New Backup Tab --> <!-- New Backup Tab -->
<li class='nav-item'> <li class='nav-item'>
<a class='nav-link' id='backup-tab' data-toggle='pill' href='#backup' role='tab' aria-controls='backup' aria-selected='false'><i class="fas fa-download"></i> Backup</a> <a class='nav-link' id='backup-tab' data-toggle='pill' href='#backup' role='tab'
aria-controls='backup' aria-selected='false'><i class="fas fa-download"></i>
Backup</a>
</li> </li>
</ul> </ul>
</div> </div>
<div class='card-body' style="margin-left: 25px;"> <div class='card-body' style="margin-left: 25px;">
<div class='tab-content' id='custom-tabs-three-tabContent'> <div class='tab-content' id='custom-tabs-three-tabContent'>
<!-- Subscriptions Tab --> <!-- Subscriptions Tab -->
<div class='tab-pane fade show active' id='subs' role='tabpanel' aria-labelledby='subs-tab'> <div class='tab-pane fade show active' id='subs' role='tabpanel' aria-labelledby='subs-tab'>
<ul class='nav nav-tabs' id='subs-tabs' role='tablist'> <ul class='nav nav-tabs' id='subs-tabs' role='tablist'>
<li class='nav-item'> <li class='nav-item'>
<a class='nav-link active' id='singbox-tab' data-toggle='tab' href='#singbox' role='tab' aria-controls='singbox' <a class='nav-link active' id='singbox-tab' data-toggle='tab' href='#singbox'
role='tab' aria-controls='singbox'
aria-selected='true'><strong>SingBox</strong></a> aria-selected='true'><strong>SingBox</strong></a>
</li> </li>
<li class='nav-item'> <li class='nav-item'>
<a class='nav-link' id='normal-tab' data-toggle='tab' href='#normal' role='tab' aria-controls='normal' <a class='nav-link' id='normal-tab' data-toggle='tab' href='#normal' role='tab'
aria-selected='false'><strong>Normal</strong></a> aria-controls='normal' aria-selected='false'><strong>Normal</strong></a>
</li> </li>
</ul> </ul>
<div class='tab-content' id='subs-tabs-content'> <div class='tab-content' id='subs-tabs-content'>
<br> <br>
<!-- SingBox Sub Tab --> <!-- SingBox Sub Tab -->
<div class='tab-pane fade show active' id='singbox' role='tabpanel' aria-labelledby='singbox-tab'> <div class='tab-pane fade show active' id='singbox' role='tabpanel'
aria-labelledby='singbox-tab'>
<form> <form>
<div class='form-group'> <div class='form-group'>
<label for='singbox_domain'>Domain:</label> <label for='singbox_domain'>Domain:</label>
<input type='text' class='form-control' id='singbox_domain' placeholder='Enter Domain'> <input type='text' class='form-control' id='singbox_domain'
placeholder='Enter Domain'>
<div class="invalid-feedback"> <div class="invalid-feedback">
Please enter a valid domain (without http:// or https://). Please enter a valid domain (without http:// or https://).
</div> </div>
</div> </div>
<div class='form-group'> <div class='form-group'>
<label for='singbox_port'>Port:</label> <label for='singbox_port'>Port:</label>
<input type='text' class='form-control' id='singbox_port' placeholder='Enter Port'> <input type='text' class='form-control' id='singbox_port'
placeholder='Enter Port'>
<div class="invalid-feedback"> <div class="invalid-feedback">
Please enter a valid port number. Please enter a valid port number.
</div> </div>
</div> </div>
<button id="singbox_start" type='button' class='btn btn-success'>Start</button> <button id="singbox_start" type='button'
<button id="singbox_stop" type='button' class='btn btn-danger' style="display: none;">Stop</button> class='btn btn-success'>Start</button>
<button id="singbox_stop" type='button' class='btn btn-danger'
style="display: none;">Stop</button>
</form> </form>
</div> </div>
@ -87,20 +106,24 @@
<form> <form>
<div class='form-group'> <div class='form-group'>
<label for='normal_domain'>Domain:</label> <label for='normal_domain'>Domain:</label>
<input type='text' class='form-control' id='normal_domain' placeholder='Enter Domain'> <input type='text' class='form-control' id='normal_domain'
placeholder='Enter Domain'>
<div class="invalid-feedback"> <div class="invalid-feedback">
Please enter a valid domain (without http:// or https://). Please enter a valid domain (without http:// or https://).
</div> </div>
</div> </div>
<div class='form-group'> <div class='form-group'>
<label for='normal_port'>Port:</label> <label for='normal_port'>Port:</label>
<input type='text' class='form-control' id='normal_port' placeholder='Enter Port'> <input type='text' class='form-control' id='normal_port'
placeholder='Enter Port'>
<div class="invalid-feedback"> <div class="invalid-feedback">
Please enter a valid port number. Please enter a valid port number.
</div> </div>
</div> </div>
<button id="normal_start" type='button' class='btn btn-success'>Start</button> <button id="normal_start" type='button'
<button id="normal_stop" type='button' class='btn btn-danger' style="display: none;">Stop</button> class='btn btn-success'>Start</button>
<button id="normal_stop" type='button' class='btn btn-danger'
style="display: none;">Stop</button>
</form> </form>
</div> </div>
@ -113,20 +136,23 @@
<form> <form>
<div class='form-group'> <div class='form-group'>
<label for='telegram_api_token'>API Token:</label> <label for='telegram_api_token'>API Token:</label>
<input type='text' class='form-control' id='telegram_api_token' placeholder='Enter API Token'> <input type='text' class='form-control' id='telegram_api_token'
<div class="invalid-feedback"> placeholder='Enter API Token'>
Please enter a valid API Token. <div class="invalid-feedback">
</div> Please enter a valid API Token.
</div>
</div> </div>
<div class='form-group'> <div class='form-group'>
<label for='telegram_admin_id'>Admin ID:</label> <label for='telegram_admin_id'>Admin ID:</label>
<input type='text' class='form-control' id='telegram_admin_id' placeholder='Enter Admin ID'> <input type='text' class='form-control' id='telegram_admin_id'
placeholder='Enter Admin ID'>
<div class="invalid-feedback"> <div class="invalid-feedback">
Please enter a valid Admin ID. Please enter a valid Admin ID.
</div> </div>
</div> </div>
<button id="telegram_start" type='button' class='btn btn-success'>Start</button> <button id="telegram_start" type='button' class='btn btn-success'>Start</button>
<button id="telegram_stop" type='button' class='btn btn-danger' style="display: none;">Stop</button> <button id="telegram_stop" type='button' class='btn btn-danger'
style="display: none;">Stop</button>
</form> </form>
</div> </div>
@ -137,7 +163,8 @@
<form> <form>
<div class='form-group'> <div class='form-group'>
<label for='hysteria_port'>Port:</label> <label for='hysteria_port'>Port:</label>
<input type='text' class='form-control' id='hysteria_port' placeholder='Enter Port'> <input type='text' class='form-control' id='hysteria_port'
placeholder='Enter Port'>
<div class="invalid-feedback"> <div class="invalid-feedback">
Please enter a valid port number. Please enter a valid port number.
</div> </div>
@ -152,7 +179,8 @@
<form> <form>
<div class='form-group'> <div class='form-group'>
<label for='sni_domain'>Domain:</label> <label for='sni_domain'>Domain:</label>
<input type='text' class='form-control' id='sni_domain' placeholder='Enter Domain'> <input type='text' class='form-control' id='sni_domain'
placeholder='Enter Domain'>
<div class="invalid-feedback"> <div class="invalid-feedback">
Please enter a valid domain (without http:// or https://). Please enter a valid domain (without http:// or https://).
</div> </div>
@ -166,14 +194,16 @@
<form> <form>
<div class='form-group'> <div class='form-group'>
<label for='ipv4'>IPv4:</label> <label for='ipv4'>IPv4:</label>
<input type='text' class='form-control' id='ipv4' placeholder='Enter IPv4' value="{{ ipv4 or '' }}"> <input type='text' class='form-control' id='ipv4' placeholder='Enter IPv4'
<div class="invalid-feedback"> value="{{ ipv4 or '' }}">
<div class="invalid-feedback">
Please enter a valid IPv4 address. Please enter a valid IPv4 address.
</div> </div>
</div> </div>
<div class='form-group'> <div class='form-group'>
<label for='ipv6'>IPv6:</label> <label for='ipv6'>IPv6:</label>
<input type='text' class='form-control' id='ipv6' placeholder='Enter IPv6' value="{{ ipv6 or '' }}"> <input type='text' class='form-control' id='ipv6' placeholder='Enter IPv6'
value="{{ ipv6 or '' }}">
<div class="invalid-feedback"> <div class="invalid-feedback">
Please enter a valid IPv6 address. Please enter a valid IPv6 address.
</div> </div>
@ -183,7 +213,8 @@
</div> </div>
<!-- Backup Tab (New) --> <!-- Backup Tab (New) -->
<div class='tab-pane fade' id='backup' role='tabpanel' aria-labelledby='backup-tab'> <div class='tab-pane fade' id='backup' role='tabpanel' aria-labelledby='backup-tab'>
<button id="download_backup" type='button' class='btn btn-primary'>Download Backup</button> <button id="download_backup" type='button' class='btn btn-primary'>Download
Backup</button>
</div> </div>
</div> </div>
</div> </div>
@ -200,7 +231,9 @@
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script> <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script> <script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<!-- Font Awesome --> <!-- Font Awesome -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/js/all.min.js" integrity="sha512-yFjZbTYRCJodnuyGlsKamNE/LlEaEA/3apsIOPr7/l+jCMq9Dn9x5qyuAGqgpr4/NBZ95p8yrl/sLhJvoazg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/js/all.min.js"
integrity="sha512-yFjZbTYRCJodnuyGlsKamNE/LlEaEA/3apsIOPr7/l+jCMq9Dn9x5qyuAGqgpr4/NBZ95p8yrl/sLhJvoazg=="
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script> <script>
@ -300,14 +333,14 @@
} }
function isValidPort(port) { function isValidPort(port) {
if (!port) return false; if (!port) return false;
return /^[0-9]+$/.test(port) && parseInt(port) > 0 && parseInt(port) <= 65535; return /^[0-9]+$/.test(port) && parseInt(port) > 0 && parseInt(port) <= 65535;
} }
function isValidIP(ip, version) { function isValidIP(ip, version) {
if (!ip) return true; // Allow empty input (optional) if (!ip) return true; // Allow empty input (optional)
if (version === 4) { if (version === 4) {
return /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/.test(ip); return /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/.test(ip);
} else if (version === 6) { } else if (version === 6) {
return /^(([0-9a-fA-F]{1,4}:){7,7}([0-9a-fA-F]{1,4}|:)|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:))$/.test(ip); return /^(([0-9a-fA-F]{1,4}:){7,7}([0-9a-fA-F]{1,4}|:)|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:))$/.test(ip);
} }
@ -355,8 +388,8 @@
// Function to validate form fields // Function to validate form fields
function validateForm(formId) { function validateForm(formId) {
let isValid = true; let isValid = true;
$(`#${formId} .form-control`).each(function() { $(`#${formId} .form-control`).each(function () {
const input = $(this); const input = $(this);
const id = input.attr('id'); const id = input.attr('id');
@ -390,7 +423,7 @@
} }
} else { } else {
if (!input.val().trim()) { if (!input.val().trim()) {
input.addClass('is-invalid'); input.addClass('is-invalid');
isValid = false; isValid = false;
} else { } else {
@ -403,7 +436,7 @@
} }
// Telegram Bot Start // Telegram Bot Start
function startTelegram() { function startTelegram() {
if (!validateForm('telegram')) return; if (!validateForm('telegram')) return;
const apiToken = $("#telegram_api_token").val(); const apiToken = $("#telegram_api_token").val();
const adminId = $("#telegram_admin_id").val(); const adminId = $("#telegram_admin_id").val();
confirmAction("start the Telegram bot", function () { confirmAction("start the Telegram bot", function () {
@ -457,7 +490,7 @@
// Normal Subscription Start // Normal Subscription Start
function startNormal() { function startNormal() {
if (!validateForm('normal')) return; if (!validateForm('normal')) return;
const domain = $("#normal_domain").val(); const domain = $("#normal_domain").val();
const port = $("#normal_port").val(); const port = $("#normal_port").val();
confirmAction("start the normal subscription", function () { confirmAction("start the normal subscription", function () {
@ -505,7 +538,7 @@
// Save IP // Save IP
function saveIP() { function saveIP() {
if (!validateForm('change_ip')) return; if (!validateForm('change_ip')) return;
confirmAction("save the new IP", function () { confirmAction("save the new IP", function () {
sendRequest( sendRequest(
"{{ url_for('edit_ip_api') }}", "{{ url_for('edit_ip_api') }}",
@ -521,7 +554,7 @@
// Download Backup (New) // Download Backup (New)
function downloadBackup() { function downloadBackup() {
// No confirmation needed for a download // No confirmation needed for a download
window.location.href = "{{ url_for('backup') }}"; // Initiate the download window.location.href = "{{ url_for('backup_api') }}"; // Initiate the download
} }
@ -537,8 +570,8 @@
$("#ip_change").on("click", saveIP); $("#ip_change").on("click", saveIP);
$("#download_backup").on("click", downloadBackup); // New backup button $("#download_backup").on("click", downloadBackup); // New backup button
// Input event listeners for real-time validation // Input event listeners for real-time validation
$('#singbox_domain, #normal_domain, #sni_domain').on('input', function() { $('#singbox_domain, #normal_domain, #sni_domain').on('input', function () {
if (isValidDomain($(this).val())) { if (isValidDomain($(this).val())) {
$(this).removeClass('is-invalid'); $(this).removeClass('is-invalid');
} else { } else {
@ -546,29 +579,29 @@
} }
}); });
$('#singbox_port, #normal_port, #hysteria_port').on('input', function() { $('#singbox_port, #normal_port, #hysteria_port').on('input', function () {
if (isValidPort($(this).val())) { if (isValidPort($(this).val())) {
$(this).removeClass('is-invalid'); $(this).removeClass('is-invalid');
} else { } else {
$(this).addClass('is-invalid'); $(this).addClass('is-invalid');
} }
}); });
$('#ipv4').on('input', function() { $('#ipv4').on('input', function () {
if (isValidIP($(this).val(),4)) { if (isValidIP($(this).val(), 4)) {
$(this).removeClass('is-invalid'); $(this).removeClass('is-invalid');
} else { } else {
$(this).addClass('is-invalid'); $(this).addClass('is-invalid');
} }
}); });
$('#ipv6').on('input', function() { $('#ipv6').on('input', function () {
if (isValidIP($(this).val(),6)) { if (isValidIP($(this).val(), 6)) {
$(this).removeClass('is-invalid'); $(this).removeClass('is-invalid');
} else { } else {
$(this).addClass('is-invalid'); $(this).addClass('is-invalid');
} }
}); });
$('#telegram_api_token, #telegram_admin_id').on('input', function() { $('#telegram_api_token, #telegram_admin_id').on('input', function () {
if ($(this).val().trim() !== "") { // Basic check for non-empty if ($(this).val().trim() !== "") { // Basic check for non-empty
$(this).removeClass('is-invalid'); $(this).removeClass('is-invalid');
} else { } else {
@ -577,4 +610,4 @@
}); });
}); });
</script> </script>
{% endblock %} {% endblock %}