refactor: replace password-based sudo with pkexec for improved security

This commit is contained in:
miwu04 2025-04-13 00:19:00 +08:00
parent fcb323a17a
commit b15fc6ce3a
6 changed files with 63 additions and 144 deletions

View File

@ -262,18 +262,22 @@ async function checkProfile(): Promise<void> {
} }
} }
export async function manualGrantCorePermition(password?: string): Promise<void> { export async function manualGrantCorePermition(): Promise<void> {
const { core = 'mihomo' } = await getAppConfig() const { core = 'mihomo' } = await getAppConfig()
const corePath = mihomoCorePath(core) const corePath = mihomoCorePath(core)
const execPromise = promisify(exec) const execPromise = promisify(exec)
const execFilePromise = promisify(execFile)
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
const shell = `chown root:admin ${corePath.replace(' ', '\\\\ ')}\nchmod +sx ${corePath.replace(' ', '\\\\ ')}` const shell = `chown root:admin ${corePath.replace(' ', '\\\\ ')}\nchmod +sx ${corePath.replace(' ', '\\\\ ')}`
const command = `do shell script "${shell}" with administrator privileges` const command = `do shell script "${shell}" with administrator privileges`
await execPromise(`osascript -e '${command}'`) await execPromise(`osascript -e '${command}'`)
} }
if (process.platform === 'linux') { if (process.platform === 'linux') {
await execPromise(`echo "${password}" | sudo -S chown root:root "${corePath}"`) await execFilePromise('pkexec', [
await execPromise(`echo "${password}" | sudo -S chmod +sx "${corePath}"`) 'bash',
'-c',
`chown root:root "${corePath}" && chmod +sx "${corePath}"`
])
} }
} }

View File

@ -1,12 +1,6 @@
import { getAppConfig, getControledMihomoConfig } from '../config' import { getAppConfig, getControledMihomoConfig } from '../config'
import { Worker } from 'worker_threads' import { Worker } from 'worker_threads'
import { import { mihomoWorkDir, resourcesFilesDir, subStoreDir, substoreLogPath } from '../utils/dirs'
dataDir,
mihomoWorkDir,
resourcesFilesDir,
subStoreDir,
substoreLogPath
} from '../utils/dirs'
import subStoreIcon from '../../../resources/subStoreIcon.png?asset' import subStoreIcon from '../../../resources/subStoreIcon.png?asset'
import { createWriteStream, existsSync, mkdirSync } from 'fs' import { createWriteStream, existsSync, mkdirSync } from 'fs'
import { writeFile, rm, cp } from 'fs/promises' import { writeFile, rm, cp } from 'fs/promises'
@ -18,8 +12,9 @@ import express from 'express'
import axios from 'axios' import axios from 'axios'
import AdmZip from 'adm-zip' import AdmZip from 'adm-zip'
import { promisify } from 'util' import { promisify } from 'util'
import { exec } from 'child_process' import { execFile } from 'child_process'
import { platform } from 'os' import { platform } from 'os'
import { is } from '@electron-toolkit/utils'
export let pacPort: number export let pacPort: number
export let subStorePort: number export let subStorePort: number
@ -148,12 +143,12 @@ export async function stopSubStoreBackendServer(): Promise<void> {
} }
} }
export async function downloadSubStore(password?: string): Promise<void> { export async function downloadSubStore(): Promise<void> {
const { 'mixed-port': mixedPort = 7890 } = await getControledMihomoConfig() const { 'mixed-port': mixedPort = 7890 } = await getControledMihomoConfig()
const frontendDir = path.join(resourcesFilesDir(), 'sub-store-frontend') const frontendDir = path.join(resourcesFilesDir(), 'sub-store-frontend')
const backendPath = path.join(resourcesFilesDir(), 'sub-store.bundle.js') const backendPath = path.join(resourcesFilesDir(), 'sub-store.bundle.js')
const tempDir = path.join(dataDir(), 'temp') const tempDir = path.join(resourcesFilesDir(), 'temp')
const execPromise = promisify(exec) const execFilePromise = promisify(execFile)
try { try {
// 创建临时目录 // 创建临时目录
@ -195,25 +190,20 @@ export async function downloadSubStore(password?: string): Promise<void> {
const zip = new AdmZip(Buffer.from(frontendRes.data)) const zip = new AdmZip(Buffer.from(frontendRes.data))
zip.extractAllTo(tempDir, true) zip.extractAllTo(tempDir, true)
// 如果是 Linux 平台,使用 sudo cp 移动文件 if (platform() === 'linux' && !is.dev) {
if (platform() === 'linux') {
try { try {
await execPromise(`echo "${password}" | sudo -S cp "${tempBackendPath}" "${backendPath}"`) const bashCmd = [
// 确保目标目录存在并清空 `cp "${tempBackendPath}" "${backendPath}"`,
if (existsSync(frontendDir)) { `rm -rf "${frontendDir}"`,
await execPromise(`echo "${password}" | sudo -S rm -r "${frontendDir}"`) `mkdir -p "${frontendDir}"`,
} `cp -r "${tempFrontendDir}"/* "${frontendDir}/"`
await execPromise(`echo "${password}" | sudo -S mkdir "${frontendDir}"`) ].join(' && ')
// 将 dist 目录中的内容移动到目标目录 await execFilePromise('pkexec', ['bash', '-c', bashCmd])
await execPromise(
`echo "${password}" | sudo -S cp -r "${tempFrontendDir}"/* "${frontendDir}/"`
)
} catch (error) { } catch (error) {
console.error('substore.downloadFailed:', error) console.error('substore.downloadFailed:', error)
throw error throw error
} }
} else { } else {
// 非 Linux 平台
await cp(tempBackendPath, backendPath) await cp(tempBackendPath, backendPath)
if (existsSync(frontendDir)) { if (existsSync(frontendDir)) {
await rm(frontendDir, { recursive: true }) await rm(frontendDir, { recursive: true })
@ -221,8 +211,6 @@ export async function downloadSubStore(password?: string): Promise<void> {
mkdirSync(frontendDir, { recursive: true }) mkdirSync(frontendDir, { recursive: true })
await cp(path.join(tempDir, 'dist'), frontendDir, { recursive: true }) await cp(path.join(tempDir, 'dist'), frontendDir, { recursive: true })
} }
// 清理临时目录
await rm(tempDir, { recursive: true }) await rm(tempDir, { recursive: true })
} catch (error) { } catch (error) {
console.error('substore.downloadFailed:', error) console.error('substore.downloadFailed:', error)

View File

@ -174,9 +174,7 @@ export function registerIpcMainHandlers(): void {
ipcMain.handle('restartCore', ipcErrorWrapper(restartCore)) ipcMain.handle('restartCore', ipcErrorWrapper(restartCore))
ipcMain.handle('startMonitor', (_e, detached) => ipcErrorWrapper(startMonitor)(detached)) ipcMain.handle('startMonitor', (_e, detached) => ipcErrorWrapper(startMonitor)(detached))
ipcMain.handle('triggerSysProxy', (_e, enable) => ipcErrorWrapper(triggerSysProxy)(enable)) ipcMain.handle('triggerSysProxy', (_e, enable) => ipcErrorWrapper(triggerSysProxy)(enable))
ipcMain.handle('manualGrantCorePermition', (_e, password) => ipcMain.handle('manualGrantCorePermition', () => ipcErrorWrapper(manualGrantCorePermition)())
ipcErrorWrapper(manualGrantCorePermition)(password)
)
ipcMain.handle('getFilePath', (_e, ext) => getFilePath(ext)) ipcMain.handle('getFilePath', (_e, ext) => getFilePath(ext))
ipcMain.handle('readTextFile', (_e, filePath) => ipcErrorWrapper(readTextFile)(filePath)) ipcMain.handle('readTextFile', (_e, filePath) => ipcErrorWrapper(readTextFile)(filePath))
ipcMain.handle('getRuntimeConfigStr', ipcErrorWrapper(getRuntimeConfigStr)) ipcMain.handle('getRuntimeConfigStr', ipcErrorWrapper(getRuntimeConfigStr))
@ -203,7 +201,7 @@ export function registerIpcMainHandlers(): void {
ipcMain.handle('stopSubStoreFrontendServer', () => ipcErrorWrapper(stopSubStoreFrontendServer)()) ipcMain.handle('stopSubStoreFrontendServer', () => ipcErrorWrapper(stopSubStoreFrontendServer)())
ipcMain.handle('startSubStoreBackendServer', () => ipcErrorWrapper(startSubStoreBackendServer)()) ipcMain.handle('startSubStoreBackendServer', () => ipcErrorWrapper(startSubStoreBackendServer)())
ipcMain.handle('stopSubStoreBackendServer', () => ipcErrorWrapper(stopSubStoreBackendServer)()) ipcMain.handle('stopSubStoreBackendServer', () => ipcErrorWrapper(stopSubStoreBackendServer)())
ipcMain.handle('downloadSubStore', (_e, password) => ipcErrorWrapper(downloadSubStore)(password)) ipcMain.handle('downloadSubStore', () => ipcErrorWrapper(downloadSubStore)())
ipcMain.handle('subStorePort', () => subStorePort) ipcMain.handle('subStorePort', () => subStorePort)
ipcMain.handle('subStoreFrontendPort', () => subStoreFrontendPort) ipcMain.handle('subStoreFrontendPort', () => subStoreFrontendPort)
ipcMain.handle('subStoreSubs', () => ipcErrorWrapper(subStoreSubs)()) ipcMain.handle('subStoreSubs', () => ipcErrorWrapper(subStoreSubs)())

View File

@ -14,8 +14,6 @@ import React, { useEffect, useState } from 'react'
import { HiExternalLink } from 'react-icons/hi' import { HiExternalLink } from 'react-icons/hi'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { IoMdCloudDownload } from 'react-icons/io' import { IoMdCloudDownload } from 'react-icons/io'
import BasePasswordModal from '@renderer/components/base/base-password-modal'
import { platform } from '@renderer/utils/init'
const SubStore: React.FC = () => { const SubStore: React.FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
@ -24,7 +22,6 @@ const SubStore: React.FC = () => {
const [backendPort, setBackendPort] = useState<number | undefined>() const [backendPort, setBackendPort] = useState<number | undefined>()
const [frontendPort, setFrontendPort] = useState<number | undefined>() const [frontendPort, setFrontendPort] = useState<number | undefined>()
const [isUpdating, setIsUpdating] = useState(false) const [isUpdating, setIsUpdating] = useState(false)
const [openPasswordModal, setOpenPasswordModal] = useState(false)
const getPort = async (): Promise<void> => { const getPort = async (): Promise<void> => {
setBackendPort(await subStorePort()) setBackendPort(await subStorePort())
setFrontendPort(await subStoreFrontendPort()) setFrontendPort(await subStoreFrontendPort())
@ -37,85 +34,40 @@ const SubStore: React.FC = () => {
if (!frontendPort) return null if (!frontendPort) return null
return ( return (
<> <>
{openPasswordModal && (
<BasePasswordModal
onCancel={() => setOpenPasswordModal(false)}
onConfirm={async (password: string) => {
try {
setOpenPasswordModal(false)
new Notification(t('substore.updating'))
await downloadSubStore(password)
await stopSubStoreBackendServer()
await startSubStoreBackendServer()
await new Promise((resolve) => setTimeout(resolve, 1000))
setFrontendPort(0)
await stopSubStoreFrontendServer()
await startSubStoreFrontendServer()
await getPort()
new Notification(t('substore.updateCompleted'))
} catch (e) {
alert(e)
}
}}
/>
)}
<BasePage <BasePage
title={t('substore.title')} title={t('substore.title')}
header={ header={
<div className="flex gap-2"> <div className="flex gap-2">
{platform != 'linux' && ( <Button
<Button title={t('substore.checkUpdate')}
title={t('substore.checkUpdate')} isIconOnly
isIconOnly size="sm"
size="sm" className="app-nodrag"
className="app-nodrag" variant="light"
variant="light" isLoading={isUpdating}
isLoading={isUpdating} onPress={async () => {
onPress={async () => { try {
try { new Notification(t('substore.updating'))
new Notification(t('substore.updating')) setIsUpdating(true)
setIsUpdating(true) await downloadSubStore()
await downloadSubStore() await stopSubStoreBackendServer()
await stopSubStoreBackendServer() await startSubStoreBackendServer()
await startSubStoreBackendServer() await new Promise((resolve) => setTimeout(resolve, 1000))
await new Promise((resolve) => setTimeout(resolve, 1000)) setFrontendPort(0)
setFrontendPort(0) await stopSubStoreFrontendServer()
await stopSubStoreFrontendServer() await startSubStoreFrontendServer()
await startSubStoreFrontendServer() await getPort()
await getPort() new Notification(t('substore.updateCompleted'))
new Notification(t('substore.updateCompleted')) } catch (e) {
} catch (e) { new Notification(`${t('substore.updateFailed')}: ${e}`)
new Notification(`${t('substore.updateFailed')}: ${e}`) } finally {
} finally { setIsUpdating(false)
setIsUpdating(false) }
} }}
}} >
> <IoMdCloudDownload className="text-lg" />
<IoMdCloudDownload className="text-lg" /> </Button>
</Button>
)}
{platform === 'linux' && (
<Button
title={t('substore.checkUpdate')}
isIconOnly
size="sm"
className="app-nodrag"
variant="light"
isLoading={isUpdating}
onPress={async () => {
try {
setIsUpdating(true)
setOpenPasswordModal(true)
} catch (e) {
new Notification(`${t('substore.updateFailed')}: ${e}`)
} finally {
setIsUpdating(false)
}
}}
>
<IoMdCloudDownload className="text-lg" />
</Button>
)}
<Button <Button
title={t('substore.openInBrowser')} title={t('substore.openInBrowser')}
isIconOnly isIconOnly

View File

@ -6,7 +6,6 @@ import { useControledMihomoConfig } from '@renderer/hooks/use-controled-mihomo-c
import { manualGrantCorePermition, restartCore, setupFirewall } from '@renderer/utils/ipc' import { manualGrantCorePermition, restartCore, setupFirewall } from '@renderer/utils/ipc'
import { platform } from '@renderer/utils/init' import { platform } from '@renderer/utils/init'
import React, { Key, useState } from 'react' import React, { Key, useState } from 'react'
import BasePasswordModal from '@renderer/components/base/base-password-modal'
import { useAppConfig } from '@renderer/hooks/use-app-config' import { useAppConfig } from '@renderer/hooks/use-app-config'
import { MdDeleteForever } from 'react-icons/md' import { MdDeleteForever } from 'react-icons/md'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -18,7 +17,6 @@ const Tun: React.FC = () => {
const { autoSetDNS = true } = appConfig || {} const { autoSetDNS = true } = appConfig || {}
const { tun } = controledMihomoConfig || {} const { tun } = controledMihomoConfig || {}
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [openPasswordModal, setOpenPasswordModal] = useState(false)
const { const {
device = 'Mihomo', device = 'Mihomo',
stack = 'mixed', stack = 'mixed',
@ -71,21 +69,6 @@ const Tun: React.FC = () => {
return ( return (
<> <>
{openPasswordModal && (
<BasePasswordModal
onCancel={() => setOpenPasswordModal(false)}
onConfirm={async (password: string) => {
try {
await manualGrantCorePermition(password)
new Notification(t('tun.notifications.coreAuthSuccess'))
await restartCore()
setOpenPasswordModal(false)
} catch (e) {
alert(e)
}
}}
/>
)}
<BasePage <BasePage
title={t('tun.title')} title={t('tun.title')}
header={ header={
@ -145,16 +128,12 @@ const Tun: React.FC = () => {
size="sm" size="sm"
color="primary" color="primary"
onPress={async () => { onPress={async () => {
if (platform === 'darwin') { try {
try { await manualGrantCorePermition()
await manualGrantCorePermition() new Notification(t('tun.notifications.coreAuthSuccess'))
new Notification(t('tun.notifications.coreAuthSuccess')) await restartCore()
await restartCore() } catch (e) {
} catch (e) { alert(e)
alert(e)
}
} else {
setOpenPasswordModal(true)
} }
}} }}
> >

View File

@ -207,10 +207,8 @@ export async function triggerSysProxy(enable: boolean): Promise<void> {
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('triggerSysProxy', enable)) return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('triggerSysProxy', enable))
} }
export async function manualGrantCorePermition(password?: string): Promise<void> { export async function manualGrantCorePermition(): Promise<void> {
return ipcErrorWrapper( return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('manualGrantCorePermition'))
await window.electron.ipcRenderer.invoke('manualGrantCorePermition', password)
)
} }
export async function getFilePath(ext: string[]): Promise<string[] | undefined> { export async function getFilePath(ext: string[]): Promise<string[] | undefined> {
@ -326,8 +324,8 @@ export async function startSubStoreBackendServer(): Promise<void> {
export async function stopSubStoreBackendServer(): Promise<void> { export async function stopSubStoreBackendServer(): Promise<void> {
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('stopSubStoreBackendServer')) return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('stopSubStoreBackendServer'))
} }
export async function downloadSubStore(password?: string): Promise<void> { export async function downloadSubStore(): Promise<void> {
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('downloadSubStore', password)) return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('downloadSubStore'))
} }
export async function subStorePort(): Promise<number> { export async function subStorePort(): Promise<number> {