feat: add Windows firewall management for openlist port #49

This commit is contained in:
Kuingsmile
2025-07-17 14:03:40 +08:00
parent 0231fa20d7
commit a19e74ce1f
9 changed files with 295 additions and 6 deletions

View File

@@ -41,7 +41,7 @@
"check:all": "yarn check:frontend && yarn check:rust:all",
"fix:rust": "cd src-tauri && cargo fmt --all && cargo clippy --all-targets --all-features --fix --allow-dirty",
"fix:frontend": "yarn lint:fix",
"fix:all": "yarn fix:frontend && yarn fix:rust"
"fix:all": "yarn fix:frontend && yarn fix:rust && yarn i18n:check"
},
"config": {
"commitizen": {

View File

@@ -0,0 +1,120 @@
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
use std::process::{Command, ExitStatus};
#[cfg(target_os = "windows")]
use deelevate::{PrivilegeLevel, Token};
#[cfg(target_os = "windows")]
use runas::Command as RunasCommand;
use tauri::State;
use crate::object::structs::AppState;
#[cfg(target_os = "windows")]
const RULE: &str = "OpenList Core";
#[cfg(target_os = "windows")]
fn netsh_with_elevation(args: &[String]) -> Result<ExitStatus, String> {
let token = Token::with_current_process().map_err(|e| format!("token: {e}"))?;
let elevated = !matches!(
token
.privilege_level()
.map_err(|e| format!("privilege: {e}"))?,
PrivilegeLevel::NotPrivileged
);
if elevated {
Command::new("netsh")
.args(args)
.creation_flags(0x08000000)
.status()
} else {
RunasCommand::new("netsh").args(args).show(false).status()
}
.map_err(|e| format!("netsh: {e}"))
}
#[cfg(target_os = "windows")]
fn firewall_rule(verb: &str, port: Option<u16>) -> Result<bool, String> {
let mut args: Vec<String> = vec![
"advfirewall".into(),
"firewall".into(),
verb.into(),
"rule".into(),
format!("name={RULE}"),
];
if let Some(p) = port {
args.extend([
"dir=in".into(),
"action=allow".into(),
"protocol=TCP".into(),
format!("localport={p}"),
"description=Allow OpenList Core web interface access".into(),
]);
}
Ok(netsh_with_elevation(&args)?.success())
}
#[cfg(target_os = "windows")]
fn rule_stdout() -> Result<Option<String>, String> {
let out = Command::new("netsh")
.args([
"advfirewall",
"firewall",
"show",
"rule",
&format!("name={RULE}"),
])
.output()
.map_err(|e| format!("netsh: {e}"))?;
if out.status.success() {
Ok(Some(String::from_utf8_lossy(&out.stdout).into()))
} else {
Ok(None)
}
}
#[cfg(not(target_os = "windows"))]
fn firewall_rule(_: &str, _: Option<u16>) -> Result<bool, String> {
Ok(true)
}
#[cfg(not(target_os = "windows"))]
fn rule_stdout() -> Result<Option<String>, String> {
Ok(None)
}
#[tauri::command]
pub async fn check_firewall_rule(state: State<'_, AppState>) -> Result<bool, String> {
let port = state
.app_settings
.read()
.clone()
.ok_or("read settings")?
.openlist
.port;
if let Some(out) = rule_stdout()? {
Ok(out.contains(&port.to_string()))
} else {
Ok(false)
}
}
#[tauri::command]
pub async fn add_firewall_rule(state: State<'_, AppState>) -> Result<bool, String> {
let port = state
.app_settings
.read()
.clone()
.ok_or("read settings")?
.openlist
.port;
let _ = firewall_rule("delete", None);
firewall_rule("add", Some(port))
}
#[tauri::command]
pub async fn remove_firewall_rule(_state: State<'_, AppState>) -> Result<bool, String> {
firewall_rule("delete", None)
}

View File

@@ -1,6 +1,7 @@
pub mod binary;
pub mod config;
pub mod custom_updater;
pub mod firewall;
pub mod http_api;
pub mod logs;
pub mod openlist_core;

View File

@@ -13,6 +13,7 @@ use cmd::custom_updater::{
check_for_updates, download_update, get_current_version, install_update_and_restart,
is_auto_check_enabled, set_auto_check_enabled,
};
use cmd::firewall::{add_firewall_rule, check_firewall_rule, remove_firewall_rule};
use cmd::http_api::{
delete_process, get_process_list, restart_process, start_process, stop_process, update_process,
};
@@ -159,6 +160,9 @@ pub fn run() {
check_service_status,
stop_service,
start_service,
check_firewall_rule,
add_firewall_rule,
remove_firewall_rule,
check_for_updates,
download_update,
install_update_and_restart,

View File

@@ -104,6 +104,13 @@ export class TauriAPI {
listen: (cb: (action: string) => void) => listen('tray-core-action', e => cb(e.payload as string))
}
// --- Firewall management ---
static firewall = {
check: (): Promise<boolean> => call('check_firewall_rule'),
add: (): Promise<boolean> => call('add_firewall_rule'),
remove: (): Promise<boolean> => call('remove_firewall_rule')
}
// --- Update management ---
static updater = {
check: (): Promise<UpdateCheck> => call('check_for_updates'),

View File

@@ -75,6 +75,28 @@
<input type="checkbox" v-model="settings.openlist.auto_launch" @change="handleAutoLaunchToggle" />
<span class="toggle-text">{{ t('dashboard.quickActions.autoLaunch') }}</span>
</label>
<!-- Windows Firewall Management-->
<button
v-if="isWindows"
@click="toggleFirewallRule"
:class="['firewall-toggle-btn', { active: firewallEnabled }]"
:disabled="firewallLoading"
:title="
firewallEnabled
? t('dashboard.quickActions.firewall.disable')
: t('dashboard.quickActions.firewall.enable')
"
>
<Shield :size="18" />
<span>
{{
firewallEnabled
? t('dashboard.quickActions.firewall.disable')
: t('dashboard.quickActions.firewall.enable')
}}
</span>
</button>
</div>
</div>
</div>
@@ -82,13 +104,13 @@
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAppStore } from '../../stores/app'
import { useRcloneStore } from '../../stores/rclone'
import { useTranslation } from '../../composables/useI18n'
import Card from '../ui/Card.vue'
import { Play, Square, RotateCcw, ExternalLink, Settings, HardDrive, Key } from 'lucide-vue-next'
import { Play, Square, RotateCcw, ExternalLink, Settings, HardDrive, Key, Shield } from 'lucide-vue-next'
import { TauriAPI } from '@/api/tauri'
const { t } = useTranslation()
@@ -100,6 +122,12 @@ const isCoreRunning = computed(() => appStore.isCoreRunning)
const settings = computed(() => appStore.settings)
let statusCheckInterval: number | null = null
const firewallEnabled = ref(false)
const firewallLoading = ref(false)
const isWindows = computed(() => {
return typeof OS_PLATFORM !== 'undefined' && OS_PLATFORM === 'win32'
})
const serviceButtonIcon = computed(() => {
return isCoreRunning.value ? Square : Play
})
@@ -274,6 +302,80 @@ const stopBackend = async () => {
}
}
const checkFirewallStatus = async () => {
if (!isWindows.value) return
try {
firewallEnabled.value = await TauriAPI.firewall.check()
} catch (error) {
console.error('Failed to check firewall status:', error)
}
}
const toggleFirewallRule = async () => {
if (!isWindows.value) return
try {
firewallLoading.value = true
if (firewallEnabled.value) {
await TauriAPI.firewall.remove()
firewallEnabled.value = false
showNotification('success', t('dashboard.quickActions.firewall.removed'))
} else {
await TauriAPI.firewall.add()
firewallEnabled.value = true
showNotification('success', t('dashboard.quickActions.firewall.added'))
}
} catch (error: any) {
console.error('Failed to toggle firewall rule:', error)
const message = firewallEnabled.value
? t('dashboard.quickActions.firewall.failedToRemove')
: t('dashboard.quickActions.firewall.failedToAdd')
showNotification('error', message + ': ' + (error.message || error))
} finally {
firewallLoading.value = false
}
}
const showNotification = (type: 'success' | 'error', message: string) => {
const notification = document.createElement('div')
const bgColor =
type === 'success'
? 'linear-gradient(135deg, rgb(16, 185, 129), rgb(5, 150, 105))'
: 'linear-gradient(135deg, rgb(239, 68, 68), rgb(220, 38, 38))'
const icon = type === 'success' ? '✓' : '⚠'
notification.innerHTML = `
<div style="
position: fixed;
top: 20px;
right: 20px;
background: ${bgColor};
color: white;
padding: 12px 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 10000;
font-weight: 500;
max-width: 300px;
word-break: break-word;
">
<div style="display: flex; align-items: center; gap: 8px;">
<div style="font-size: 18px;">${icon}</div>
<div style="font-size: 14px;">${message}</div>
</div>
</div>
`
document.body.appendChild(notification)
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification)
}
}, 4000)
}
const openLink = async (url: string) => {
try {
if (appStore.settings.app.open_links_in_browser) {
@@ -289,6 +391,8 @@ const openLink = async (url: string) => {
onMounted(async () => {
await rcloneStore.checkRcloneBackendStatus()
statusCheckInterval = window.setInterval(rcloneStore.checkRcloneBackendStatus, 30 * 1000)
await checkFirewallStatus()
})
onUnmounted(() => {
@@ -519,6 +623,41 @@ onUnmounted(() => {
user-select: none;
}
.firewall-toggle-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border-secondary);
border-radius: 8px;
background: var(--color-surface);
color: var(--color-text-primary);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
}
.firewall-toggle-btn:hover:not(:disabled) {
background: var(--color-surface-elevated);
border-color: rgba(59, 130, 246, 0.3);
}
.firewall-toggle-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.firewall-toggle-btn.active {
background: rgb(16, 185, 129);
color: white;
border-color: rgba(5, 150, 105, 0.3);
}
.firewall-toggle-btn.active:hover:not(:disabled) {
background: rgb(5, 150, 105);
}
@media (max-width: 768px) {
.actions-grid {
gap: 1.5rem;

View File

@@ -32,7 +32,15 @@
"stopRclone": "Stop RClone",
"manageMounts": "Manage Mounts",
"autoLaunch": "Auto Launch Core(not app)",
"showAdminPassword": "Show/Copy admin password from logs"
"showAdminPassword": "Show/Copy admin password from logs",
"firewall": {
"enable": "Allow Port",
"disable": "Remove Port Allow",
"added": "Port allowed successfully",
"removed": "Port removed successfully",
"failedToAdd": "Failed to allow port",
"failedToRemove": "Failed to remove port allow"
}
},
"coreMonitor": {
"title": "Core Monitor",

View File

@@ -32,7 +32,15 @@
"stopRclone": "停止 RClone",
"manageMounts": "管理挂载",
"autoLaunch": "自动启动核心(非桌面app)",
"showAdminPassword": "显示/复制日志中的管理员密码"
"showAdminPassword": "显示/复制日志中的管理员密码",
"firewall": {
"enable": "放行端口",
"disable": "移除端口放行",
"added": "端口放行成功",
"removed": "端口移除成功",
"failedToAdd": "添加端口放行失败",
"failedToRemove": "移除端口放行失败"
}
},
"coreMonitor": {
"title": "核心监控",

View File

@@ -453,7 +453,9 @@ const dismissWebdavTip = () => {
localStorage.setItem('webdav_tip_dismissed', 'true')
}
const isWindows = /win/i.test(navigator.platform) || /win/i.test(navigator.userAgent)
const isWindows = computed(() => {
return typeof OS_PLATFORM !== 'undefined' && OS_PLATFORM === 'win32'
})
const showWinfspTip = ref(isWindows && !localStorage.getItem('winfsp_tip_dismissed'))
const dismissWinfspTip = () => {