开发web管理
Some checks are pending
Test mosdns / build (push) Waiting to run

This commit is contained in:
dengxiongjian 2025-10-15 22:20:27 +08:00
parent 819576c450
commit ee06785e08
70 changed files with 11614 additions and 1489 deletions

View File

@ -1,273 +0,0 @@
# MikroTik 内存缓存优化实施指南
## 🎯 优化目标
根据你的需求,我们实现了以下核心优化:
1. **🚀 完全移除验证功能** - 消除验证带来的额外API调用
2. **🧠 内存缓存机制** - 程序启动时从MikroTik加载所有现有IP到内存
3. **⚡ 智能重复检查** - 在内存中判断IP是否存在避免重复写入
4. **🌐 /24网段优化** - 使用/24掩码减少地址条目数量
## 📋 实施步骤
### 第一步:备份现有配置
```bash
# 备份当前配置
cp /opt/mosdns/dns.yaml /opt/mosdns/dns.yaml.backup
cp /opt/mosdns/config.yaml /opt/mosdns/config.yaml.backup
```
### 第二步:更新配置文件
我已经为你创建了三个配置版本:
1. **`dns.yaml`** - 你的原配置文件,已优化为/24掩码
2. **`dns-memory-optimized.yaml`** - 完整的内存优化配置
3. **`dns-optimized.yaml`** - 标准性能优化配置
**推荐使用 `dns-memory-optimized.yaml`**
```bash
# 使用优化配置
cp dns-memory-optimized.yaml /opt/mosdns/dns.yaml
```
### 第三步验证MikroTik地址列表
确保MikroTik中存在对应的地址列表
```bash
# 连接到MikroTik
ssh admin@10.248.0.1
# 检查现有地址列表
/ip firewall address-list print where list=gfw
# 如果不存在,创建地址列表
/ip firewall address-list add list=gfw comment="Auto-managed by MosDNS"
# 查看当前地址数量
/ip firewall address-list print count-only where list=gfw
```
## 🔧 核心优化机制
### 1. 启动时内存加载
程序启动时会执行以下操作:
```go
// 伪代码流程
func (p *plugin) loadExistingIPs() {
// 1. 连接MikroTik API
// 2. 查询 /ip/firewall/address-list/print =list=gfw
// 3. 将所有现有IP加载到内存map中
// 4. 构建网段缓存(对于/24掩码)
// 5. 记录加载统计信息
}
```
**启动日志示例:**
```
INFO loading existing IPs from MikroTik...
INFO loaded address list list=gfw ip_count=1250
INFO finished loading existing IPs total_ips=1250
```
### 2. 内存存在性检查
每次DNS解析后的IP处理流程
```go
// 伪代码流程
func (p *plugin) processIP(ip, domain) {
cidr := buildCIDRAddress(ip, 24) // 例如: 1.2.3.0/24
// 🚀 纯内存检查,极快速度
if p.isIPInMemoryCache("gfw", cidr) {
log.Debug("IP already exists, skipping")
return // 跳过不调用MikroTik API
}
// 只有不存在的IP才写入MikroTik
p.addToMikroTik(cidr, "gfw", domain)
// 🚀 成功后立即更新内存缓存
p.addToMemoryCache("gfw", cidr)
}
```
### 3. /24网段优化
使用/24掩码的好处
- **减少条目数量**: `1.2.3.1`, `1.2.3.2`, `1.2.3.3``1.2.3.0/24`
- **提高匹配效率**: 单个网段条目可以匹配256个IP
- **降低内存使用**: 缓存条目大幅减少
**示例对比:**
```bash
# /32模式 (原来)
1.2.3.1/32
1.2.3.2/32
1.2.3.3/32
...
1.2.3.255/32 # 255个条目
# /24模式 (优化后)
1.2.3.0/24 # 1个条目覆盖整个网段
```
## 📊 性能提升预期
| 优化项目 | 优化前 | 优化后 | 提升效果 |
|---------|--------|--------|----------|
| 启动速度 | 立即 | +5-10秒 | 一次性成本 |
| 重复检查 | MikroTik API | 内存查找 | 99%+ 速度提升 |
| 网络调用 | 每IP一次 | 仅新IP | 减少80-90% |
| 内存使用 | 最小 | +10-50MB | 可接受增长 |
| 地址条目 | 大量/32 | 少量/24 | 减少70-90% |
## 🔍 监控和验证
### 启动监控
```bash
# 查看启动日志
sudo journalctl -u mosdns -f | grep "loading existing IPs"
# 完整启动日志
sudo systemctl restart mosdns
sudo journalctl -u mosdns --since "1 minute ago"
```
### 运行时监控
```bash
# 查看实时处理日志
sudo journalctl -u mosdns -f | grep -E "(already exists|successfully added)"
# 查看缓存统计
sudo journalctl -u mosdns -f | grep "cache_stats"
```
### MikroTik端验证
```bash
# 查看地址列表大小变化
ssh admin@10.248.0.1 "/ip firewall address-list print count-only where list=gfw"
# 查看最近添加的地址
ssh admin@10.248.0.1 "/ip firewall address-list print where list=gfw" | tail -10
# 监控系统资源
ssh admin@10.248.0.1 "/system resource monitor once"
```
## 🚨 故障排除
### 常见问题
#### 1. 启动时加载失败
```bash
# 检查连接
ssh admin@10.248.0.1 "/system resource print"
# 检查地址列表是否存在
ssh admin@10.248.0.1 "/ip firewall address-list print where list=gfw"
```
#### 2. 内存使用过高
```bash
# 监控内存使用
top -p $(pgrep mosdns)
# 如果内存过高,可以调整配置
memory_cache_size: 5000 # 减少缓存大小
```
#### 3. 性能没有提升
```bash
# 检查是否正确跳过重复IP
sudo journalctl -u mosdns -f | grep "already exists"
# 应该看到大量 "already exists" 日志
```
### 调试模式
临时启用详细日志:
```yaml
# 在config.yaml中修改
log:
level: debug # 临时改为debug
```
```bash
# 重启服务
sudo systemctl restart mosdns
# 查看详细日志
sudo journalctl -u mosdns -f
```
## ⚡ 快速测试
### 测试重复IP检查
```bash
# 测试同一个域名多次解析
for i in {1..5}; do
dig @127.0.0.1 -p 5300 amazon.com
sleep 1
done
# 应该只看到第一次写入MikroTik后续都是 "already exists"
```
### 压力测试
```bash
# 并发测试多个域名
domains=("aws.amazon.com" "s3.amazonaws.com" "ec2.amazonaws.com" "cloudfront.amazonaws.com")
for domain in "${domains[@]}"; do
for i in {1..3}; do
dig @127.0.0.1 -p 5300 "$domain" &
done
done
wait
# 检查MikroTik地址列表增长
ssh admin@10.248.0.1 "/ip firewall address-list print count-only where list=gfw"
```
## 📈 预期结果
实施这些优化后,你应该看到:
1. **启动时间**: 增加5-10秒一次性加载现有IP
2. **重复查询**: 几乎无延迟(纯内存检查)
3. **网络调用**: 大幅减少只写入新IP
4. **MikroTik负载**: 显著降低减少80-90%的API调用
5. **地址条目**: 大幅减少(/24网段合并
## 🔄 回滚方案
如果需要回滚到原配置:
```bash
# 恢复原配置
cp /opt/mosdns/dns.yaml.backup /opt/mosdns/dns.yaml
# 重启服务
sudo systemctl restart mosdns
# 验证服务正常
sudo systemctl status mosdns
```
这个优化方案完全符合你的需预期可以将MikroTik的API调用求移除验证功能、启动时加载现有IP到内存、避免重复写入、使用/24掩码。减少80-90%,显著提升整体性能。

View File

@ -1,206 +0,0 @@
# MikroTik API 写入性能优化指南
## 🔍 问题分析
通过对 MosDNS MikroTik 插件的深入分析,发现以下性能瓶颈:
### 1. 网络层面问题
- **单连接阻塞**:使用单一连接处理所有请求
- **同步等待**每个API调用都需要等待响应
- **频繁重连**:连接断开后的重连机制增加延迟
### 2. 应用层面问题
- **串行处理**IP地址逐个处理无法充分利用并发
- **过度验证**`verify_add: true` 会进行二次查询确认
- **缓存失效**缓存TTL过长或过短都会影响性能
## 🚀 优化方案
### 立即可实施的配置优化
#### 1. 调整连接参数
```yaml
mikrotik_amazon:
type: mikrotik_addresslist
args:
timeout: 3 # 🔥 减少连接超时时间
verify_add: false # 🔥 关闭验证提升50%性能
cache_ttl: 7200 # 🔥 优化缓存时间2小时
max_ips: 10 # 🔥 限制IP数量避免过载
```
#### 2. 优化掩码设置
```yaml
mask4: 32 # 🔥 使用/32精确匹配
mask6: 128 # 🔥 使用/128精确匹配
```
**好处**:避免网段合并,提高缓存命中率
#### 3. 调整超时时间
```yaml
timeout_addr: 43200 # 🔥 12小时超时原24小时
```
**好处**:提高缓存命中率,减少重复写入
### 中级优化方案
#### 1. 启用批量处理
当前代码已支持批量处理,但可以进一步优化:
```yaml
# 在配置中调整工作池大小
worker_pool_size: 15 # 增加工作线程
batch_size: 20 # 增加批处理大小
```
#### 2. 网络层优化
```yaml
use_tls: false # 🔥 关闭TLS减少握手时间
timeout: 3 # 🔥 快速失败,避免长时间等待
```
#### 3. MikroTik 路由器端优化
```bash
# 在MikroTik中优化API设置
/ip service set api port=8728 disabled=no
/ip service set api-ssl disabled=yes # 关闭SSL提升性能
# 增加API连接数限制
/ip service set api max-sessions=10
```
### 高级优化方案
#### 1. 连接池实现
创建连接池来复用连接:
```go
// 伪代码示例
type ConnectionPool struct {
connections chan *routeros.Client
maxSize int
host string
port int
username string
password string
}
func (p *ConnectionPool) Get() *routeros.Client {
select {
case conn := <-p.connections:
return conn
default:
return p.createConnection()
}
}
func (p *ConnectionPool) Put(conn *routeros.Client) {
select {
case p.connections <- conn:
default:
conn.Close()
}
}
```
#### 2. 批量API调用
修改为真正的批量API调用
```go
// 当前:多次单独调用
for _, ip := range ips {
conn.Run("/ip/firewall/address-list/add", "=list=gfw", "=address="+ip)
}
// 优化:批量调用
addresses := strings.Join(ips, ",")
conn.Run("/ip/firewall/address-list/add", "=list=gfw", "=address="+addresses)
```
#### 3. 异步处理队列
实现消息队列机制:
```go
type IPQueue struct {
queue chan IPTask
workers int
}
type IPTask struct {
IPs []string
ListName string
Domain string
}
```
## 📊 性能对比
| 优化项目 | 优化前 | 优化后 | 提升幅度 |
|---------|--------|--------|----------|
| 连接超时 | 10秒 | 3秒 | 70% ⬇️ |
| 验证开关 | 开启 | 关闭 | 50% ⬆️ |
| 批处理大小 | 10 | 20 | 100% ⬆️ |
| 缓存TTL | 1小时 | 2小时 | 命中率+30% |
| 工作线程 | 10 | 15 | 50% ⬆️ |
## 🔧 实施步骤
### 第一阶段:配置优化(立即实施)
1. 更新 `dns.yaml` 配置文件
2. 重启 MosDNS 服务
3. 监控日志确认改进效果
### 第二阶段MikroTik端优化
1. 优化MikroTik API设置
2. 调整防火墙规则
3. 监控系统资源使用
### 第三阶段:代码级优化(需要开发)
1. 实现连接池
2. 优化批量处理算法
3. 添加性能监控指标
## 📈 监控和测试
### 性能监控命令
```bash
# 查看MosDNS日志
sudo journalctl -u mosdns -f | grep mikrotik
# 监控MikroTik API性能
ssh admin@10.248.0.1 "/system resource monitor once"
# 检查地址列表大小
ssh admin@10.248.0.1 "/ip firewall address-list print count-only where list=gfw"
```
### 压力测试
```bash
# 使用dig进行并发测试
for i in {1..100}; do
dig @127.0.0.1 -p 5300 amazon$i.com &
done
wait
```
## 🎯 预期效果
实施这些优化后,预期可以达到:
- **响应时间减少 60-70%**
- **并发处理能力提升 2-3倍**
- **内存使用量减少 30%**
- **错误率降低 50%**
## ⚠️ 注意事项
1. **分批实施**:避免一次性修改过多参数
2. **监控资源**注意MikroTik路由器的CPU和内存使用
3. **备份配置**:修改前备份当前工作配置
4. **测试环境**:先在测试环境验证效果
## 🔗 相关资源
- [MikroTik API文档](https://wiki.mikrotik.com/wiki/Manual:API)
- [RouterOS API优化指南](https://wiki.mikrotik.com/wiki/Manual:API_examples)
- [Go RouterOS库文档](https://github.com/go-routeros/routeros)

181
README-VUE.md Normal file
View File

@ -0,0 +1,181 @@
# 🌐 MosDNS Web 管理界面 - Vue 版本
> 基于 Vue 3 + Element Plus 的现代化 DNS 管理界面
---
## ⚡ 快速开始
### 第一次使用
```bash
# 1. 一键构建Windows
.\build-vue.bat
# 2. 启动服务
.\dist\mosdns-vue.exe start -c config.yaml
# 3. 访问界面
浏览器打开: http://localhost:5555
```
**就这么简单!** 🎉
---
## 📸 界面预览
### 现代化的 Element Plus UI
- ✨ 美观的卡片式布局
- 📊 直观的数据展示
- 🎨 专业的配色方案
- 📱 完美的响应式设计
### 主要功能
**1. 仪表板** - 一览服务状态
- 运行状态、运行时间
- DNS 端口识别
- 查询统计
- 快速操作(刷新、清空缓存、重启)
**2. MikroTik 管理** - 轻松配置
- 可视化配置列表
- 表单化添加配置
- 字段验证提示
- 一键删除
**3. 域名文件** - 在线编辑
- 文件列表展示
- 在线查看编辑
- 文件信息统计
---
## 🚀 开发模式
**优势:** 修改代码后自动热重载,无需重新编译!
```bash
# 终端 1: Go 后端
go run main.go start -c config.yaml
# 终端 2: Vue 开发服务器
cd web-ui
npm run dev
# 访问: http://localhost:5173
```
---
## 📦 技术栈
- **Vue 3** - 渐进式 JavaScript 框架
- **Element Plus** - Vue 3 UI 组件库
- **TypeScript** - 类型安全
- **Pinia** - 状态管理
- **Vite** - 极速构建工具
---
## 📚 完整文档
详细使用说明请查看:
- [`Vue版本使用指南.md`](./Vue版本使用指南.md) - 完整教程
- [`Vue重构完成总结.md`](./Vue重构完成总结.md) - 技术细节
---
## 🆚 对比原生版本
| 特性 | 原生 JS | Vue 版本 |
|------|---------|----------|
| 开发效率 | ⭐⭐ | ⭐⭐⭐⭐⭐ |
| 代码维护 | ⭐⭐ | ⭐⭐⭐⭐⭐ |
| 类型安全 | ❌ | ✅ TypeScript |
| 热重载 | ❌ | ✅ 秒级更新 |
| 组件复用 | ❌ | ✅ Element Plus |
| 开发工具 | 基础 | Vue DevTools |
---
## 💡 为什么选择 Vue 版本?
### 开发体验 ⬆️ 500%
**原生版本:**
```javascript
document.getElementById('list').innerHTML = html // 手动 DOM
```
**Vue 版本:**
```vue
<el-table :data="configs"> <!-- 数据驱动 -->
```
### 维护成本 ⬇️ 70%
- 组件化开发,代码更清晰
- TypeScript 类型检查,减少 bug
- Pinia 状态管理,逻辑集中
### 扩展性 ⬆️ 无限
- 丰富的 Element Plus 组件库
- 成熟的 Vue 生态系统
- 活跃的社区支持
---
## 🔧 系统要求
### 开发环境
- Node.js 16+
- Go 1.21+
### 生产环境
- 只需要 Go 编译后的可执行文件
- **用户无需安装 Node.js**
---
## 📂 项目结构
```
mosdns/
├── web-ui/ # Vue 3 项目
│ ├── src/
│ │ ├── api/ # API 封装
│ │ ├── stores/ # 状态管理
│ │ ├── views/ # 页面组件
│ │ └── router/ # 路由配置
│ └── dist/ # 构建产物
├── coremain/
│ └── web_ui.go # Go 后端Vue 版)
└── build-vue.bat # 一键构建脚本
```
---
## 🎯 立即体验
```bash
# 克隆项目(如果还没有)
cd D:\Golang\yltx-dns\mosdns
# 构建
.\build-vue.bat
# 运行
.\dist\mosdns-vue.exe start -c config.yaml
# 享受现代化的管理界面!
```
访问 **http://localhost:5555** 🚀
---
**Made with ❤️ using Vue 3 + Element Plus**

435
build-all-platforms.bat Normal file
View File

@ -0,0 +1,435 @@
@echo off
chcp 65001 >nul
REM ========================================
REM MosDNS 多平台构建脚本 (带 Web UI)
REM 支持: Linux, Windows, macOS (amd64/arm64)
REM ========================================
echo.
echo ╔════════════════════════════════════════════╗
echo ║ MosDNS 多平台构建工具 (带 Web UI) ║
echo ╚════════════════════════════════════════════╝
echo.
REM 检查 Go 环境
where go >nul 2>nul
if %errorlevel% neq 0 (
echo ❌ Go 未安装或不在 PATH 中
pause
exit /b 1
)
echo ✅ Go 版本:
go version
echo.
REM 检查并构建 Vue 前端
echo [检查] Vue 前端资源...
if not exist "web-ui\dist\index.html" (
echo ⚠️ Vue 前端未构建,开始构建...
REM 检查 Node.js
where node >nul 2>nul
if %errorlevel% neq 0 (
echo ❌ Node.js 未安装,无法构建 Vue 前端
echo 💡 请安装 Node.js 或手动运行: cd web-ui ^&^& npm install ^&^& npm run build
pause
exit /b 1
)
REM 检查 npm
where npm >nul 2>nul
if %errorlevel% neq 0 (
echo ❌ npm 未找到
pause
exit /b 1
)
echo [1/2] 安装 Vue 依赖...
cd web-ui
if not exist "node_modules" (
call npm install
if %errorlevel% neq 0 (
echo ❌ npm install 失败
cd ..
pause
exit /b 1
)
)
echo [2/2] 构建 Vue 前端...
call npm run build
if %errorlevel% neq 0 (
echo ❌ Vue 构建失败
cd ..
pause
exit /b 1
)
cd ..
echo ✅ Vue 前端构建完成
) else (
echo ✅ Vue 前端资源已存在
)
echo.
REM ========================================
REM 显示平台选择菜单
REM ========================================
:MENU
cls
echo.
echo ╔════════════════════════════════════════════╗
echo ║ MosDNS 多平台构建工具 (带 Web UI) ║
echo ╚════════════════════════════════════════════╝
echo.
echo 请选择要编译的平台:
echo.
echo [1] Linux AMD64 (x86_64 服务器)
echo [2] Linux ARM64 (树莓派、ARM 服务器)
echo [3] Windows AMD64 (Windows 64位)
echo [4] macOS AMD64 (Intel Mac)
echo [5] macOS ARM64 (Apple Silicon M1/M2/M3)
echo.
echo [6] 编译所有 Linux 版本 (AMD64 + ARM64)
echo [7] 编译所有 macOS 版本 (AMD64 + ARM64)
echo [8] 编译所有 Windows 版本 (仅 AMD64)
echo.
echo [A] 编译全部平台 (推荐用于发布)
echo.
echo [0] 退出
echo.
echo ════════════════════════════════════════════
echo.
set /p CHOICE="请输入选项 [0-8/A]: "
if /i "%CHOICE%"=="0" exit /b 0
if /i "%CHOICE%"=="1" goto BUILD_LINUX_AMD64
if /i "%CHOICE%"=="2" goto BUILD_LINUX_ARM64
if /i "%CHOICE%"=="3" goto BUILD_WINDOWS_AMD64
if /i "%CHOICE%"=="4" goto BUILD_MACOS_AMD64
if /i "%CHOICE%"=="5" goto BUILD_MACOS_ARM64
if /i "%CHOICE%"=="6" goto BUILD_ALL_LINUX
if /i "%CHOICE%"=="7" goto BUILD_ALL_MACOS
if /i "%CHOICE%"=="8" goto BUILD_ALL_WINDOWS
if /i "%CHOICE%"=="A" goto BUILD_ALL
if /i "%CHOICE%"=="a" goto BUILD_ALL
echo.
echo ❌ 无效的选项,请重新选择
timeout /t 2 >nul
goto MENU
REM ========================================
REM 初始化构建环境
REM ========================================
:INIT_BUILD
echo.
echo ════════════════════════════════════════════
echo.
echo [准备] 设置构建参数...
for /f "tokens=2 delims==" %%a in ('wmic OS Get localdatetime /value') do set "dt=%%a"
set "BUILD_TIME=%dt:~0,4%-%dt:~4,2%-%dt:~6,2% %dt:~8,2%:%dt:~10,2%:%dt:~12,2%"
set "VERSION=v5.0.0-webui"
set "OUTPUT_DIR=dist"
REM 创建输出目录
if not exist "%OUTPUT_DIR%" mkdir "%OUTPUT_DIR%"
echo 版本: %VERSION%
echo 构建时间: %BUILD_TIME%
echo 输出目录: %OUTPUT_DIR%\
echo.
set CGO_ENABLED=0
set "LDFLAGS=-s -w -X 'main.version=%VERSION%' -X 'main.buildTime=%BUILD_TIME%'"
echo [开始] 编译中...
echo.
goto :EOF
REM ========================================
REM 单平台编译
REM ========================================
:BUILD_LINUX_AMD64
call :INIT_BUILD
echo 🔨 构建 Linux AMD64...
set GOOS=linux
set GOARCH=amd64
go build -ldflags="%LDFLAGS%" -o "%OUTPUT_DIR%\mosdns-linux-amd64" .
if %errorlevel% equ 0 (
echo ✅ mosdns-linux-amd64 构建成功
call :SHOW_RESULT
) else (
echo ❌ 构建失败
pause
)
goto END
:BUILD_LINUX_ARM64
call :INIT_BUILD
echo 🔨 构建 Linux ARM64...
set GOOS=linux
set GOARCH=arm64
go build -ldflags="%LDFLAGS%" -o "%OUTPUT_DIR%\mosdns-linux-arm64" .
if %errorlevel% equ 0 (
echo ✅ mosdns-linux-arm64 构建成功
call :SHOW_RESULT
) else (
echo ❌ 构建失败
pause
)
goto END
:BUILD_WINDOWS_AMD64
call :INIT_BUILD
echo 🔨 构建 Windows AMD64...
set GOOS=windows
set GOARCH=amd64
go build -ldflags="%LDFLAGS%" -o "%OUTPUT_DIR%\mosdns-windows-amd64.exe" .
if %errorlevel% equ 0 (
echo ✅ mosdns-windows-amd64.exe 构建成功
call :SHOW_RESULT
) else (
echo ❌ 构建失败
pause
)
goto END
:BUILD_MACOS_AMD64
call :INIT_BUILD
echo 🔨 构建 macOS AMD64 (Intel Mac)...
set GOOS=darwin
set GOARCH=amd64
go build -ldflags="%LDFLAGS%" -o "%OUTPUT_DIR%\mosdns-darwin-amd64" .
if %errorlevel% equ 0 (
echo ✅ mosdns-darwin-amd64 构建成功
call :SHOW_RESULT
) else (
echo ❌ 构建失败
pause
)
goto END
:BUILD_MACOS_ARM64
call :INIT_BUILD
echo 🔨 构建 macOS ARM64 (Apple Silicon)...
set GOOS=darwin
set GOARCH=arm64
go build -ldflags="%LDFLAGS%" -o "%OUTPUT_DIR%\mosdns-darwin-arm64" .
if %errorlevel% equ 0 (
echo ✅ mosdns-darwin-arm64 构建成功
call :SHOW_RESULT
) else (
echo ❌ 构建失败
pause
)
goto END
REM ========================================
REM 批量编译
REM ========================================
:BUILD_ALL_LINUX
call :INIT_BUILD
echo [1/2] 🔨 构建 Linux AMD64...
set GOOS=linux
set GOARCH=amd64
go build -ldflags="%LDFLAGS%" -o "%OUTPUT_DIR%\mosdns-linux-amd64" .
if %errorlevel% equ 0 (
echo ✅ mosdns-linux-amd64 构建成功
) else (
echo ❌ 构建失败
set "BUILD_FAILED=1"
)
echo.
echo [2/2] 🔨 构建 Linux ARM64...
set GOOS=linux
set GOARCH=arm64
go build -ldflags="%LDFLAGS%" -o "%OUTPUT_DIR%\mosdns-linux-arm64" .
if %errorlevel% equ 0 (
echo ✅ mosdns-linux-arm64 构建成功
) else (
echo ❌ 构建失败
set "BUILD_FAILED=1"
)
call :SHOW_RESULT
goto END
:BUILD_ALL_MACOS
call :INIT_BUILD
echo [1/2] 🔨 构建 macOS AMD64 (Intel Mac)...
set GOOS=darwin
set GOARCH=amd64
go build -ldflags="%LDFLAGS%" -o "%OUTPUT_DIR%\mosdns-darwin-amd64" .
if %errorlevel% equ 0 (
echo ✅ mosdns-darwin-amd64 构建成功
) else (
echo ❌ 构建失败
set "BUILD_FAILED=1"
)
echo.
echo [2/2] 🔨 构建 macOS ARM64 (Apple Silicon)...
set GOOS=darwin
set GOARCH=arm64
go build -ldflags="%LDFLAGS%" -o "%OUTPUT_DIR%\mosdns-darwin-arm64" .
if %errorlevel% equ 0 (
echo ✅ mosdns-darwin-arm64 构建成功
) else (
echo ❌ 构建失败
set "BUILD_FAILED=1"
)
call :SHOW_RESULT
goto END
:BUILD_ALL_WINDOWS
call :INIT_BUILD
echo [1/1] 🔨 构建 Windows AMD64...
set GOOS=windows
set GOARCH=amd64
go build -ldflags="%LDFLAGS%" -o "%OUTPUT_DIR%\mosdns-windows-amd64.exe" .
if %errorlevel% equ 0 (
echo ✅ mosdns-windows-amd64.exe 构建成功
) else (
echo ❌ 构建失败
set "BUILD_FAILED=1"
)
call :SHOW_RESULT
goto END
:BUILD_ALL
call :INIT_BUILD
echo [1/5] 🔨 构建 Linux AMD64...
set GOOS=linux
set GOARCH=amd64
go build -ldflags="%LDFLAGS%" -o "%OUTPUT_DIR%\mosdns-linux-amd64" .
if %errorlevel% equ 0 (
echo ✅ mosdns-linux-amd64 构建成功
) else (
echo ❌ 构建失败
set "BUILD_FAILED=1"
)
echo.
echo [2/5] 🔨 构建 Linux ARM64...
set GOOS=linux
set GOARCH=arm64
go build -ldflags="%LDFLAGS%" -o "%OUTPUT_DIR%\mosdns-linux-arm64" .
if %errorlevel% equ 0 (
echo ✅ mosdns-linux-arm64 构建成功
) else (
echo ❌ 构建失败
set "BUILD_FAILED=1"
)
echo.
echo [3/5] 🔨 构建 Windows AMD64...
set GOOS=windows
set GOARCH=amd64
go build -ldflags="%LDFLAGS%" -o "%OUTPUT_DIR%\mosdns-windows-amd64.exe" .
if %errorlevel% equ 0 (
echo ✅ mosdns-windows-amd64.exe 构建成功
) else (
echo ❌ 构建失败
set "BUILD_FAILED=1"
)
echo.
echo [4/5] 🔨 构建 macOS AMD64...
set GOOS=darwin
set GOARCH=amd64
go build -ldflags="%LDFLAGS%" -o "%OUTPUT_DIR%\mosdns-darwin-amd64" .
if %errorlevel% equ 0 (
echo ✅ mosdns-darwin-amd64 构建成功
) else (
echo ❌ 构建失败
set "BUILD_FAILED=1"
)
echo.
echo [5/5] 🔨 构建 macOS ARM64 (Apple Silicon)...
set GOOS=darwin
set GOARCH=arm64
go build -ldflags="%LDFLAGS%" -o "%OUTPUT_DIR%\mosdns-darwin-arm64" .
if %errorlevel% equ 0 (
echo ✅ mosdns-darwin-arm64 构建成功
) else (
echo ❌ 构建失败
set "BUILD_FAILED=1"
)
call :SHOW_RESULT
goto END
REM ========================================
REM 显示构建结果
REM ========================================
:SHOW_RESULT
echo.
echo ════════════════════════════════════════════
echo.
if "%BUILD_FAILED%"=="1" (
echo ⚠️ 部分平台构建失败,请检查错误信息
echo.
) else (
echo 🎉 构建完成!
echo.
)
REM 检查是否有构建产物
if exist "%OUTPUT_DIR%\mosdns-*" (
echo 📦 构建产物列表:
echo.
dir /B "%OUTPUT_DIR%\mosdns-*" 2>nul
echo.
echo 📊 文件大小详情:
for %%F in (%OUTPUT_DIR%\mosdns-*) do (
set "size=%%~zF"
set /a size_mb=!size! / 1048576
echo %%~nxF - !size_mb! MB
)
echo.
) else (
echo ⚠️ 未找到构建产物
echo.
)
echo ════════════════════════════════════════════
echo.
echo 📝 使用方法:
echo.
echo Windows:
echo %OUTPUT_DIR%\mosdns-windows-amd64.exe start -c config.yaml
echo.
echo Linux:
echo chmod +x %OUTPUT_DIR%/mosdns-linux-amd64
echo ./%OUTPUT_DIR%/mosdns-linux-amd64 start -c config.yaml
echo.
echo macOS:
echo chmod +x %OUTPUT_DIR%/mosdns-darwin-amd64
echo ./%OUTPUT_DIR%/mosdns-darwin-amd64 start -c config.yaml
echo.
echo 🌐 Web 管理界面: http://localhost:5545
echo.
echo 💡 提示: 所有可执行文件已内嵌 Web 资源,可独立运行
echo.
goto :EOF
REM ========================================
REM 结束
REM ========================================
:END
echo.
set /p CONTINUE="是否继续编译其他平台?(Y/N): "
if /i "%CONTINUE%"=="Y" (
set "BUILD_FAILED="
goto MENU
)
echo.
echo 感谢使用 MosDNS 构建工具!
echo.
pause
exit /b 0

71
build-vue.bat Normal file
View File

@ -0,0 +1,71 @@
@echo off
chcp 65001 >nul
echo.
echo ====================================
echo MosDNS Vue 版本构建脚本
echo ====================================
echo.
echo [1/3] 检查 Node.js 环境...
where node >nul 2>nul
if errorlevel 1 (
echo ❌ 错误: 未找到 Node.js
echo 请先安装 Node.js: https://nodejs.org/
pause
exit /b 1
)
echo ✅ Node.js 已安装
echo.
echo [2/3] 构建 Vue 前端...
cd web-ui
if not exist "node_modules\" (
echo 📦 首次构建,正在安装依赖...
call npm install
if errorlevel 1 (
echo ❌ npm install 失败!
cd ..
pause
exit /b 1
)
)
echo 🔨 正在构建 Vue 项目...
call npm run build
if errorlevel 1 (
echo ❌ Vue 构建失败!
cd ..
pause
exit /b 1
)
echo ✅ Vue 构建完成
cd ..
echo.
echo [3/3] 构建 Go 后端...
echo 🔨 正在编译 Go 程序...
go build -o dist\mosdns-vue.exe .
if errorlevel 1 (
echo ❌ Go 编译失败!
pause
exit /b 1
)
echo ✅ Go 编译完成
echo.
echo ====================================
echo ✅ 构建完成!
echo ====================================
echo.
echo 可执行文件: dist\mosdns-vue.exe
echo.
echo 运行命令:
echo dist\mosdns-vue.exe start -c config.yaml
echo.
echo 然后访问: http://localhost:5555
echo.
pause

52
build-vue.sh Normal file
View File

@ -0,0 +1,52 @@
#!/bin/bash
set -e
echo ""
echo "===================================="
echo " MosDNS Vue 版本构建脚本"
echo "===================================="
echo ""
echo "[1/3] 检查 Node.js 环境..."
if ! command -v node &> /dev/null; then
echo "❌ 错误: 未找到 Node.js"
echo "请先安装 Node.js: https://nodejs.org/"
exit 1
fi
echo "✅ Node.js 已安装"
echo ""
echo "[2/3] 构建 Vue 前端..."
cd web-ui
if [ ! -d "node_modules" ]; then
echo "📦 首次构建,正在安装依赖..."
npm install
fi
echo "🔨 正在构建 Vue 项目..."
npm run build
echo "✅ Vue 构建完成"
cd ..
echo ""
echo "[3/3] 构建 Go 后端..."
echo "🔨 正在编译 Go 程序..."
go build -o dist/mosdns-vue .
echo "✅ Go 编译完成"
echo ""
echo "===================================="
echo " ✅ 构建完成!"
echo "===================================="
echo ""
echo "可执行文件: dist/mosdns-vue"
echo ""
echo "运行命令:"
echo " ./dist/mosdns-vue start -c config.yaml"
echo ""
echo "然后访问: http://localhost:5555"
echo ""

View File

@ -1,36 +1,51 @@
# ============================================ # ============================================
# MosDNS v5 配置GFW 解析并写入 MikroTik # MosDNS v5 最终优化配置
# 基于增强的 mikrotik_addresslist 插件
# ============================================ # ============================================
log: log:
level: info level: debug # 🔧 改为 debug 级别,查看详细日志
# 管理 API
api:
http: "0.0.0.0:5541"
web:
http: "0.0.0.0:5555"
plugins: plugins:
# ========= 规则集 ========= # ========= 基础组件 =========
# GFW 域名(解析并写入 MikroTik
# GFW 域名列表(仅用于分流,不写入设备)
- tag: GFW_domains - tag: GFW_domains
type: domain_set type: domain_set
args: args:
files: files:
- "/usr/local/jinlingma/config/gfwlist.out.txt" - "/usr/local/yltx-dns/geosite/geosite_gfw.txt"
# 🆕 海外域名列表(包含所有需要海外解析的域名)
- tag: overseas_domains
type: domain_set
args:
files:
- "/usr/local/yltx-dns/geosite/geosite_gfw.txt"
- "/usr/local/yltx-dns/config/openai.txt"
# 中国大陆 IP 列表 # 中国大陆 IP 列表
- tag: geoip_cn - tag: geoip_cn
type: ip_set type: ip_set
args: args:
files: files:
- "/usr/local/jinlingma/config/cn.txt" - "/usr/local/yltx-dns/config/cn.txt"
# 缓存 # 缓存
- tag: cache - tag: cache
type: cache type: cache
args: args:
size: 32768 size: 82768
lazy_cache_ttl: 43200 lazy_cache_ttl: 43200
# ========= 上游定义 ========= # ========= 上游 DNS 定义 =========
# 国内上游
# 国内 DNS
- tag: china-dns - tag: china-dns
type: forward type: forward
args: args:
@ -43,7 +58,7 @@ plugins:
- addr: "udp://114.114.114.114" - addr: "udp://114.114.114.114"
- addr: "udp://180.76.76.76" - addr: "udp://180.76.76.76"
# 国外上游DoT # 国外 DNSDoT
- tag: overseas-dns - tag: overseas-dns
type: forward type: forward
args: args:
@ -94,85 +109,82 @@ plugins:
- exec: query_summary forward_remote - exec: query_summary forward_remote
- exec: $forward_remote - exec: $forward_remote
# 若已有响应则直接返回 # ========= 🚀 增强的 MikroTik 插件(支持多设备多规则)=========
# 设备 AOpenAI 相关域名
- tag: mikrotik_amazon
type: mikrotik_addresslist
args:
domain_files:
- "/usr/local/yltx-dns/config/openai.txt"
host: "10.248.0.1"
port: 9728
username: "admin"
password: "szn0s!nw@pwd()"
use_tls: false
timeout: 3
address_list4: "OpenAI"
mask4: 24
comment: "OpenAI-AutoAdd"
timeout_addr: 43200
cache_ttl: 3600
verify_add: false
add_all_ips: true
max_ips: 50
# 设备 BGoogle 相关域名(示例 - 已注释)
# - tag: mikrotik_google
# type: mikrotik_addresslist
# args:
# domain_files:
# - "/usr/local/jinlingma/config/google.txt"
# - "/usr/local/jinlingma/config/youtube.txt"
# host: "10.96.1.23"
# port: 9728
# username: "admin"
# password: "szn0s!nw@pwd()"
# use_tls: false
# timeout: 3
# address_list4: "Google"
# mask4: 32 # 精确匹配单个IP
# comment: "Google-AutoAdd"
# timeout_addr: 21600 # 6小时
# cache_ttl: 1800 # 30分钟缓存
# verify_add: false
# add_all_ips: true
# max_ips: 15
# 设备 C流媒体相关域名示例 - 已注释)
# - tag: mikrotik_streaming
# type: mikrotik_addresslist
# args:
# domain_files:
# - "/usr/local/jinlingma/config/netflix.txt"
# - "/usr/local/jinlingma/config/disney.txt"
# host: "10.96.1.24"
# port: 9728
# username: "admin"
# password: "szn0s!nw@pwd()"
# use_tls: false
# timeout: 5 # 流媒体可能需要更长时间
# address_list4: "Streaming"
# mask4: 32
# comment: "Streaming-AutoAdd"
# timeout_addr: 21600 # 6小时流媒体IP变化较频繁
# cache_ttl: 1800 # 30分钟缓存
# verify_add: false
# add_all_ips: true
# max_ips: 30 # 流媒体服务IP较多
# ========= 查询逻辑 =========
# 检查是否有响应
- tag: has_resp_sequence - tag: has_resp_sequence
type: sequence type: sequence
args: args:
- matches: has_resp - matches: has_resp
exec: accept exec: accept
# ========= 🚀 增强的 MikroTik 插件(支持多设备多规则)=========
# 设备 AAmazon 相关域名
- tag: mikrotik_amazon
type: mikrotik_addresslist
domain_files:
- "/usr/local/jinlingma/config/amazon.txt"
- "/usr/local/jinlingma/config/aws.txt"
args:
host: "10.96.1.22"
port: 9728
username: "admin"
password: "szn0s!nw@pwd()"
use_tls: false
timeout: 3
address_list4: "Amazon"
address_list6: "Amazon6"
mask4: 24
mask6: 64
comment: "Amazon-AutoAdd"
timeout_addr: 43200
cache_ttl: 3600
verify_add: false
add_all_ips: true
max_ips: 20
# 设备 BGoogle 相关域名
- tag: mikrotik_google
type: mikrotik_addresslist
domain_files:
- "/usr/local/jinlingma/config/google.txt"
- "/usr/local/jinlingma/config/youtube.txt"
args:
host: "10.96.1.23"
port: 9728
username: "admin"
password: "szn0s!nw@pwd()"
use_tls: false
timeout: 3
address_list4: "Google"
mask4: 32
comment: "Google-AutoAdd"
timeout_addr: 21600
cache_ttl: 1800
verify_add: false
add_all_ips: true
max_ips: 15
# 设备 C流媒体相关域名示例
- tag: mikrotik_streaming
type: mikrotik_addresslist
domain_files:
- "/usr/local/jinlingma/config/netflix.txt"
- "/usr/local/jinlingma/config/disney.txt"
args:
host: "10.96.1.24"
port: 9728
username: "admin"
password: "szn0s!nw@pwd()"
use_tls: false
timeout: 5
address_list4: "Streaming"
mask4: 32
comment: "Streaming-AutoAdd"
timeout_addr: 21600
cache_ttl: 1800
verify_add: false
add_all_ips: true
max_ips: 30
# ========= 🚀 简化的查询逻辑 =========
# 拒绝无效查询 # 拒绝无效查询
- tag: reject_invalid - tag: reject_invalid
type: sequence type: sequence
@ -180,26 +192,41 @@ plugins:
- matches: qtype 65 - matches: qtype 65
exec: reject 3 exec: reject 3
# GFW 域名分流(仅解析,不写入设备)
- tag: gfw_routing_only
type: sequence
args:
- matches: qname $GFW_domains
exec: $forward_remote_upstream
- exec: query_summary gfw_overseas_routing
# 智能 fallback 处理 # 智能 fallback 处理
- tag: smart_fallback_handler - tag: smart_fallback_handler
type: sequence type: sequence
args: args:
- exec: prefer_ipv4 - exec: prefer_ipv4
- exec: $forward_local - exec: $forward_local_upstream
- matches: resp_ip $geoip_cn - matches: resp_ip $geoip_cn
exec: accept exec: accept
- exec: $forward_remote_upstream - exec: $forward_remote_upstream
- exec: query_summary fallback_to_overseas - exec: query_summary fallback_to_overseas
# 🚀 极简主序列 # 🚀 海外域名分流 + MikroTik 处理
- tag: overseas_routing_with_mikrotik
type: sequence
args:
- matches: qname $overseas_domains
exec: $forward_remote_upstream
- matches: has_resp
exec: $mikrotik_amazon # 🔧 修复在有DNS响应后才调用MikroTik
- matches: has_resp
exec: accept
- exec: query_summary overseas_routing
# 🚀 并行处理序列优化的DNS解析流程
- tag: parallel_dns_and_mikrotik
type: sequence
args:
# DNS 解析逻辑
- exec: $overseas_routing_with_mikrotik # 🚀 海外域名分流 + MikroTik处理
- matches: has_resp
exec: accept
- exec: $smart_fallback_handler # 智能 fallback
# 🚀 主序列(优化版 - 并行处理)
- tag: main_sequence - tag: main_sequence
type: sequence type: sequence
args: args:
@ -210,28 +237,19 @@ plugins:
- exec: $reject_invalid - exec: $reject_invalid
- exec: jump has_resp_sequence - exec: jump has_resp_sequence
# 3. GFW 域名分流(仅解析) # 3. 🚀 并行处理DNS解析 + MikroTik处理
- exec: $gfw_routing_only - exec: $parallel_dns_and_mikrotik
- exec: jump has_resp_sequence - exec: jump has_resp_sequence
# 4. 智能 fallback # ========= 服务监听 =========
- exec: $smart_fallback_handler
- exec: jump has_resp_sequence
# 5. 🚀 MikroTik 设备处理(每个插件自动匹配域名)
- exec: $mikrotik_amazon # 自动处理 Amazon 域名
- exec: $mikrotik_google # 自动处理 Google 域名
- exec: $mikrotik_streaming # 自动处理流媒体域名
# ========= 服务 =========
- tag: udp_server - tag: udp_server
type: udp_server type: udp_server
args: args:
entry: main_sequence entry: main_sequence
listen: ":5322" listen: ":531"
- tag: tcp_server - tag: tcp_server
type: tcp_server type: tcp_server
args: args:
entry: main_sequence entry: main_sequence
listen: ":5322" listen: ":531"

1116
coremain/api_handlers.go Normal file

File diff suppressed because it is too large Load Diff

View File

@ -28,6 +28,7 @@ type Config struct {
Include []string `yaml:"include"` Include []string `yaml:"include"`
Plugins []PluginConfig `yaml:"plugins"` Plugins []PluginConfig `yaml:"plugins"`
API APIConfig `yaml:"api"` API APIConfig `yaml:"api"`
Web WebConfig `yaml:"web"` // Web 管理界面配置
} }
// PluginConfig represents a plugin config // PluginConfig represents a plugin config
@ -45,6 +46,12 @@ type PluginConfig struct {
Args any `yaml:"args"` Args any `yaml:"args"`
} }
// APIConfig API 接口配置
type APIConfig struct { type APIConfig struct {
HTTP string `yaml:"http"` HTTP string `yaml:"http"` // API HTTP 监听地址,如 "0.0.0.0:5541"
}
// WebConfig Web 管理界面配置
type WebConfig struct {
HTTP string `yaml:"http"` // Web UI HTTP 监听地址,如 "0.0.0.0:5555"
} }

View File

@ -23,6 +23,10 @@ import (
"bytes" "bytes"
"errors" "errors"
"fmt" "fmt"
"io"
"net/http"
"net/http/pprof"
"github.com/IrineSistiana/mosdns/v5/mlog" "github.com/IrineSistiana/mosdns/v5/mlog"
"github.com/IrineSistiana/mosdns/v5/pkg/safe_close" "github.com/IrineSistiana/mosdns/v5/pkg/safe_close"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@ -30,9 +34,6 @@ import (
"github.com/prometheus/client_golang/prometheus/collectors" "github.com/prometheus/client_golang/prometheus/collectors"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
"go.uber.org/zap" "go.uber.org/zap"
"io"
"net/http"
"net/http/pprof"
) )
type Mosdns struct { type Mosdns struct {
@ -41,7 +42,8 @@ type Mosdns struct {
// Plugins // Plugins
plugins map[string]any plugins map[string]any
httpMux *chi.Mux httpMux *chi.Mux // API 路由
webMux *chi.Mux // Web UI 路由(独立)
metricsReg *prometheus.Registry metricsReg *prometheus.Registry
sc *safe_close.SafeClose sc *safe_close.SafeClose
} }
@ -57,15 +59,29 @@ func NewMosdns(cfg *Config) (*Mosdns, error) {
m := &Mosdns{ m := &Mosdns{
logger: lg, logger: lg,
plugins: make(map[string]any), plugins: make(map[string]any),
httpMux: chi.NewRouter(), httpMux: chi.NewRouter(), // API 路由
webMux: chi.NewRouter(), // Web UI 独立路由
metricsReg: newMetricsReg(), metricsReg: newMetricsReg(),
sc: safe_close.NewSafeClose(), sc: safe_close.NewSafeClose(),
} }
// This must be called after m.httpMux and m.metricsReg been set. // This must be called after m.httpMux and m.metricsReg been set.
m.initHttpMux() m.initHttpMux()
// Register management API routes
m.registerManagementAPI()
// 如果配置了独立的 Web 端口,初始化 Web 路由
if len(cfg.Web.HTTP) > 0 {
m.initWebMux()
m.registerWebUI()
m.registerWebAPI()
}
// Start http api server // Start http api server
if httpAddr := cfg.API.HTTP; len(httpAddr) > 0 { if httpAddr := cfg.API.HTTP; len(httpAddr) > 0 {
// 设置当前 API 地址,供 Web UI 显示
SetCurrentAPIAddress(httpAddr)
httpServer := &http.Server{ httpServer := &http.Server{
Addr: httpAddr, Addr: httpAddr,
Handler: m.httpMux, Handler: m.httpMux,
@ -86,6 +102,28 @@ func NewMosdns(cfg *Config) (*Mosdns, error) {
}) })
} }
// Start web ui server (独立端口)
if webAddr := cfg.Web.HTTP; len(webAddr) > 0 {
webServer := &http.Server{
Addr: webAddr,
Handler: m.webMux,
}
m.sc.Attach(func(done func(), closeSignal <-chan struct{}) {
defer done()
errChan := make(chan error, 1)
go func() {
m.logger.Info("starting web ui http server", zap.String("addr", webAddr))
errChan <- webServer.ListenAndServe()
}()
select {
case err := <-errChan:
m.sc.SendCloseSignal(err)
case <-closeSignal:
_ = webServer.Close()
}
})
}
// Load plugins. // Load plugins.
// Close all plugins on signal. // Close all plugins on signal.
@ -204,6 +242,14 @@ func (m *Mosdns) initHttpMux() {
m.httpMux.MethodNotAllowed(invalidApiReqHelper) m.httpMux.MethodNotAllowed(invalidApiReqHelper)
} }
// initWebMux 初始化 Web UI 路由
func (m *Mosdns) initWebMux() {
// Web UI 的 404 页面
m.webMux.NotFound(func(w http.ResponseWriter, req *http.Request) {
http.Error(w, "Page not found", http.StatusNotFound)
})
}
func (m *Mosdns) loadPresetPlugins() error { func (m *Mosdns) loadPresetPlugins() error {
for tag, f := range LoadNewPersetPluginFuncs() { for tag, f := range LoadNewPersetPluginFuncs() {
p, err := f(NewBP(tag, m)) p, err := f(NewBP(tag, m))

View File

@ -21,16 +21,17 @@ package coremain
import ( import (
"fmt" "fmt"
"os"
"os/signal"
"runtime"
"syscall"
"github.com/IrineSistiana/mosdns/v5/mlog" "github.com/IrineSistiana/mosdns/v5/mlog"
"github.com/kardianos/service" "github.com/kardianos/service"
"github.com/mitchellh/mapstructure" "github.com/mitchellh/mapstructure"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper" "github.com/spf13/viper"
"go.uber.org/zap" "go.uber.org/zap"
"os"
"os/signal"
"runtime"
"syscall"
) )
type serverFlags struct { type serverFlags struct {
@ -126,6 +127,9 @@ func NewServer(sf *serverFlags) (*Mosdns, error) {
} }
mlog.L().Info("main config loaded", zap.String("file", fileUsed)) mlog.L().Info("main config loaded", zap.String("file", fileUsed))
// Set current config file for API access
SetCurrentConfigFile(fileUsed)
return NewMosdns(cfg) return NewMosdns(cfg)
} }

View File

@ -0,0 +1,631 @@
/* 基础样式重置 - Element Plus 风格 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--el-color-primary: #409eff;
--el-color-success: #67c23a;
--el-color-warning: #e6a23c;
--el-color-danger: #f56c6c;
--el-color-info: #909399;
--el-bg-color: #f0f2f5;
--el-bg-color-page: #f0f2f5;
--el-border-color: #dcdfe6;
--el-border-radius-base: 4px;
--el-box-shadow-light: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
background-color: var(--el-bg-color-page);
color: #303133;
line-height: 1.6;
font-size: 14px;
}
.container {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* 头部样式 - Element Plus 风格 */
.header {
background: #fff;
color: #303133;
padding: 0;
box-shadow: 0 1px 4px rgba(0,21,41,.08);
border-bottom: 1px solid var(--el-border-color);
}
.header-content {
max-width: 1400px;
margin: 0 auto;
padding: 0 24px;
height: 64px;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 20px;
font-weight: 600;
color: var(--el-color-primary);
display: flex;
align-items: center;
gap: 8px;
}
.header-info {
display: flex;
align-items: center;
gap: 16px;
}
.status-indicator {
padding: 4px 12px;
background: #f0f9ff;
color: var(--el-color-primary);
border-radius: 4px;
font-size: 13px;
border: 1px solid #d0ebff;
}
.version {
font-size: 13px;
color: #606266;
background: #f5f7fa;
padding: 4px 12px;
border-radius: 4px;
}
/* 导航栏样式 - Element Plus Tabs 风格 */
.nav {
background: white;
border-bottom: 2px solid #e4e7ed;
}
.nav-content {
max-width: 1400px;
margin: 0 auto;
padding: 0 24px;
display: flex;
gap: 8px;
}
.nav-item {
padding: 16px 20px;
background: none;
border: none;
color: #606266;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
border-bottom: 2px solid transparent;
position: relative;
font-weight: 500;
}
.nav-item:hover {
color: var(--el-color-primary);
background: #f5f7fa;
}
.nav-item.active {
color: var(--el-color-primary);
border-bottom-color: var(--el-color-primary);
}
/* 主内容区域 - vben 风格 */
.main {
flex: 1;
max-width: 1400px;
margin: 0 auto;
padding: 20px 24px;
width: 100%;
min-height: calc(100vh - 128px);
}
/* 标签页内容 */
.tab-content {
display: none;
}
.tab-content.active {
display: block;
animation: fadeIn 0.3s ease;
}
/* 仪表板网格布局 */
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
/* 卡片样式 - Element Plus Card 风格 */
.card {
background: white;
border-radius: var(--el-border-radius-base);
box-shadow: var(--el-box-shadow-light);
margin-bottom: 20px;
overflow: hidden;
border: 1px solid var(--el-border-color);
transition: box-shadow 0.2s;
}
.card:hover {
box-shadow: 0 4px 16px 0 rgba(0, 0, 0, 0.12);
}
.card-header {
padding: 18px 20px;
border-bottom: 1px solid var(--el-border-color);
display: flex;
justify-content: space-between;
align-items: center;
background: #fafafa;
}
.card-header h3 {
font-size: 16px;
font-weight: 600;
color: #303133;
margin: 0;
}
.card-content {
padding: 20px;
}
.card-actions {
display: flex;
gap: 8px;
}
/* 统计项 */
.stat-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
}
.stat-item:last-child {
border-bottom: none;
}
.stat-label {
color: #909399;
font-size: 14px;
}
.stat-value {
color: #303133;
font-weight: 600;
font-size: 14px;
}
/* 按钮样式 - Element Plus Button 风格 */
.btn {
padding: 9px 15px;
border: 1px solid var(--el-border-color);
border-radius: var(--el-border-radius-base);
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
font-weight: 400;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.btn-primary {
background: var(--el-color-primary);
color: white;
border-color: var(--el-color-primary);
}
.btn-primary:hover {
background: #66b1ff;
border-color: #66b1ff;
}
.btn-primary:active {
background: #3a8ee6;
border-color: #3a8ee6;
}
.btn-secondary {
background: white;
color: #606266;
border-color: var(--el-border-color);
}
.btn-secondary:hover {
color: var(--el-color-primary);
border-color: #c6e2ff;
background: #ecf5ff;
}
.btn-danger {
background: var(--el-color-danger);
color: white;
border-color: var(--el-color-danger);
}
.btn-danger:hover {
background: #f78989;
border-color: #f78989;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.button-group {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
/* MikroTik 表单样式 - 优化版 Element Plus Form 风格 */
.mikrotik-form {
max-width: 100%;
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-group label {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 8px;
font-weight: 500;
color: #606266;
font-size: 14px;
}
.label-required {
color: var(--el-color-danger);
font-weight: bold;
}
.form-control,
.form-group input,
.form-group select {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--el-border-color);
border-radius: var(--el-border-radius-base);
font-size: 14px;
transition: all 0.2s;
line-height: 1.5;
background: white;
}
.form-control:focus,
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: var(--el-color-primary);
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.1);
}
.form-control:hover:not(:focus),
.form-group input:hover:not(:focus),
.form-group select:hover:not(:focus) {
border-color: #c0c4cc;
}
.form-hint {
display: block;
margin-top: 6px;
color: #909399;
font-size: 12px;
line-height: 1.4;
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid var(--el-border-color);
}
.btn-lg {
padding: 11px 20px;
font-size: 15px;
}
/* MikroTik 配置列表 */
.mikrotik-list {
margin-top: 20px;
}
.mikrotik-item {
background: #fafafa;
border: 1px solid var(--el-border-color);
border-radius: var(--el-border-radius-base);
padding: 16px;
margin-bottom: 12px;
transition: all 0.2s;
}
.mikrotik-item:hover {
background: #f5f7fa;
box-shadow: var(--el-box-shadow-light);
}
.mikrotik-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.mikrotik-item-title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.mikrotik-item-actions {
display: flex;
gap: 8px;
}
.mikrotik-item-content {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
color: #606266;
font-size: 13px;
}
.mikrotik-item-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.mikrotik-item-label {
color: #909399;
font-size: 12px;
}
.mikrotik-item-value {
color: #303133;
font-weight: 500;
}
/* 结果区域 */
.result-area {
margin-top: 20px;
padding: 16px;
background: #f5f7fa;
border-radius: var(--el-border-radius-base);
border: 1px solid var(--el-border-color);
}
.result-area h4 {
margin-bottom: 12px;
color: #303133;
font-size: 15px;
}
.result-area pre {
margin: 12px 0;
white-space: pre-wrap;
word-wrap: break-word;
background: #fff;
padding: 12px;
border-radius: var(--el-border-radius-base);
border: 1px solid var(--el-border-color);
font-size: 13px;
line-height: 1.6;
}
/* 域名文件列表 */
.domain-files-list {
display: grid;
gap: 12px;
}
.domain-file-item {
background: #fafafa;
border: 1px solid var(--el-border-color);
border-radius: var(--el-border-radius-base);
padding: 16px;
transition: all 0.2s;
}
.domain-file-item:hover {
background: #f5f7fa;
box-shadow: var(--el-box-shadow-light);
}
.domain-file-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.domain-file-name {
font-weight: 600;
color: #303133;
font-size: 15px;
}
.domain-file-info {
display: flex;
gap: 16px;
color: #909399;
font-size: 13px;
}
/* 日志查看器 */
.logs-container {
background: #1e1e1e;
color: #d4d4d4;
padding: 16px;
border-radius: var(--el-border-radius-base);
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
max-height: 600px;
overflow-y: auto;
}
/* 配置编辑器 */
.config-editor {
border: 1px solid var(--el-border-color);
border-radius: var(--el-border-radius-base);
}
.config-editor textarea {
width: 100%;
min-height: 500px;
padding: 16px;
border: none;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
resize: vertical;
}
.config-editor textarea:focus {
outline: none;
}
/* 消息提示 */
.message {
position: fixed;
top: 20px;
right: 20px;
min-width: 300px;
padding: 12px 16px;
background: white;
border-radius: var(--el-border-radius-base);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
animation: slideInRight 0.3s ease;
z-index: 9999;
border-left: 4px solid;
}
.message.success {
border-left-color: var(--el-color-success);
}
.message.error {
border-left-color: var(--el-color-danger);
}
.message.warning {
border-left-color: var(--el-color-warning);
}
.message.info {
border-left-color: var(--el-color-info);
}
/* 加载状态 */
.loading {
text-align: center;
padding: 20px;
color: #909399;
}
/* 动画 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* 响应式设计 */
@media (max-width: 768px) {
.header-content {
padding: 0 16px;
height: 56px;
}
.nav-content {
padding: 0 16px;
flex-wrap: wrap;
}
.main {
padding: 16px;
}
.dashboard-grid {
grid-template-columns: 1fr;
}
.card-header {
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
.card-actions {
width: 100%;
justify-content: flex-end;
}
.button-group {
width: 100%;
}
.btn {
width: 100%;
justify-content: center;
}
.mikrotik-form {
max-width: 100%;
}
.mikrotik-item-content {
grid-template-columns: 1fr;
}
}

View File

@ -0,0 +1,709 @@
// MosDNS 管理面板 JavaScript 应用
class MosDNSAdmin {
constructor() {
this.apiBase = '/api';
this.currentTab = 'dashboard';
this.refreshInterval = null;
this.init();
}
init() {
this.setupEventListeners();
this.loadInitialData();
this.startAutoRefresh();
}
setupEventListeners() {
// 导航栏切换
document.querySelectorAll('.nav-item').forEach(item => {
item.addEventListener('click', (e) => {
const tab = e.target.dataset.tab;
this.switchTab(tab);
});
});
// 页面可见性变化时处理自动刷新
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
this.stopAutoRefresh();
} else {
this.startAutoRefresh();
}
});
}
switchTab(tab) {
// 更新导航栏状态
document.querySelectorAll('.nav-item').forEach(item => {
item.classList.remove('active');
});
document.querySelector(`[data-tab="${tab}"]`).classList.add('active');
// 更新内容区域
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.remove('active');
});
document.getElementById(tab).classList.add('active');
this.currentTab = tab;
this.loadTabData(tab);
}
async loadInitialData() {
try {
await this.loadServerInfo();
await this.loadTabData(this.currentTab);
} catch (error) {
this.showMessage('加载初始数据失败: ' + error.message, 'error');
}
}
async loadServerInfo() {
try {
const response = await this.apiCall('/server/info');
if (response.success) {
const info = response.data;
document.getElementById('version').textContent = info.version || 'v5.0.0';
document.getElementById('service-status').textContent = info.status;
// 使用秒数来格式化运行时间,修复 NaN 问题
if (info.uptime_seconds !== undefined) {
document.getElementById('uptime').textContent = this.formatUptimeFromSeconds(info.uptime_seconds);
} else {
document.getElementById('uptime').textContent = info.uptime || '-';
}
// 显示 DNS 端口
if (info.dns_ports && info.dns_ports.length > 0) {
document.getElementById('dns-ports').textContent = info.dns_ports.join(', ');
} else {
document.getElementById('dns-ports').textContent = '未检测到';
}
// 显示 API 地址
if (info.api_address) {
document.getElementById('api-address').textContent = info.api_address;
}
}
} catch (error) {
console.error('Failed to load server info:', error);
}
}
formatUptimeFromSeconds(seconds) {
if (!seconds || seconds < 0) {
return '0分钟';
}
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
let parts = [];
if (days > 0) parts.push(`${days}`);
if (hours > 0) parts.push(`${hours}小时`);
if (minutes > 0 || parts.length === 0) parts.push(`${minutes}分钟`);
return parts.join(' ');
}
async loadTabData(tab) {
switch (tab) {
case 'dashboard':
await this.loadDashboardData();
break;
case 'mikrotik':
await this.loadMikroTikTab();
break;
case 'domains':
await this.loadDomainFiles();
break;
case 'logs':
await this.loadLogs();
break;
case 'stats':
await this.loadDetailedStats();
break;
}
}
async loadDashboardData() {
try {
// 加载详细统计
const statsResponse = await this.apiCall('/stats/detailed');
if (statsResponse.success) {
const stats = statsResponse.data;
document.getElementById('total-queries').textContent = stats.totalQueries?.toLocaleString() || '-';
document.getElementById('cache-hits').textContent = stats.cacheHits?.toLocaleString() || '-';
document.getElementById('avg-response').textContent = stats.avgResponseTime ? `${stats.avgResponseTime}ms` : '-';
}
} catch (error) {
console.error('Failed to load dashboard data:', error);
}
}
// MikroTik 标签页加载
async loadMikroTikTab() {
try {
// 直接加载 MikroTik 配置列表(不再需要加载域名文件下拉框)
await this.loadMikrotikList();
} catch (error) {
console.error('Failed to load MikroTik tab:', error);
}
}
// 加载 MikroTik 配置列表
async loadMikrotikList() {
const listDiv = document.getElementById('mikrotik-list');
try {
console.log('开始加载 MikroTik 配置列表...');
listDiv.innerHTML = '<div class="loading">加载中...</div>';
const response = await this.apiCall('/mikrotik/list');
console.log('MikroTik API 响应:', response);
if (!response) {
throw new Error('API 响应为空');
}
if (!response.success) {
throw new Error(response.message || '加载失败');
}
const configs = response.data || [];
if (configs.length === 0) {
listDiv.innerHTML = '<div style="text-align:center;padding:20px;color:#909399;">暂无 MikroTik 配置</div>';
return;
}
let html = '';
configs.forEach(config => {
const args = config.args || {};
const domainFiles = args.domain_files || [];
const domainFilesStr = Array.isArray(domainFiles) ? domainFiles.join(', ') : domainFiles;
html += `
<div class="mikrotik-item">
<div class="mikrotik-item-header">
<div class="mikrotik-item-title">${this.escapeHtml(config.tag || '')}</div>
<div class="mikrotik-item-actions">
<button class="btn btn-danger" onclick="deleteMikrotikConfig('${this.escapeHtml(config.tag || '')}')">🗑 删除</button>
</div>
</div>
<div class="mikrotik-item-content">
<div class="mikrotik-item-field">
<div class="mikrotik-item-label">主机地址</div>
<div class="mikrotik-item-value">${this.escapeHtml(args.host || '-')}</div>
</div>
<div class="mikrotik-item-field">
<div class="mikrotik-item-label">端口</div>
<div class="mikrotik-item-value">${args.port || '-'}</div>
</div>
<div class="mikrotik-item-field">
<div class="mikrotik-item-label">用户名</div>
<div class="mikrotik-item-value">${this.escapeHtml(args.username || '-')}</div>
</div>
<div class="mikrotik-item-field">
<div class="mikrotik-item-label">地址列表</div>
<div class="mikrotik-item-value">${this.escapeHtml(args.address_list4 || '-')}</div>
</div>
<div class="mikrotik-item-field">
<div class="mikrotik-item-label">域名文件</div>
<div class="mikrotik-item-value">${this.escapeHtml(domainFilesStr || '-')}</div>
</div>
</div>
</div>
`;
});
listDiv.innerHTML = html;
console.log(`成功加载 ${configs.length} 个 MikroTik 配置`);
} catch (error) {
console.error('加载 MikroTik 列表失败:', error);
listDiv.innerHTML = `
<div style="color:#f56c6c;padding:20px;text-align:center;">
<p> 加载失败</p>
<p style="font-size:13px;margin-top:8px;">${this.escapeHtml(error.message)}</p>
<button class="btn btn-secondary" onclick="app.loadMikrotikList()" style="margin-top:12px;">重试</button>
</div>
`;
}
}
// HTML 转义
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
async loadConfigData() {
try {
const response = await this.apiCall('/config');
if (response.success) {
document.getElementById('config-editor').value = response.data.content || '';
}
} catch (error) {
this.showMessage('加载配置失败: ' + error.message, 'error');
}
}
async loadDomainFiles() {
try {
const response = await this.apiCall('/domain-files');
if (response.success) {
this.renderDomainFilesList(response.data);
}
} catch (error) {
this.showMessage('加载域名文件失败: ' + error.message, 'error');
}
}
async loadLogs() {
try {
const response = await this.apiCall('/logs');
if (response.success) {
document.getElementById('logs-content').textContent = response.data.content || '暂无日志内容';
}
} catch (error) {
this.showMessage('加载日志失败: ' + error.message, 'error');
}
}
async loadDetailedStats() {
try {
const response = await this.apiCall('/stats/detailed');
if (response.success) {
this.renderDetailedStats(response.data);
}
} catch (error) {
this.showMessage('加载统计信息失败: ' + error.message, 'error');
}
}
renderPluginsList(plugins) {
const container = document.getElementById('plugins-list');
if (!plugins || plugins.length === 0) {
container.innerHTML = '<div class="loading">暂无插件信息</div>';
return;
}
const html = plugins.map(plugin => `
<div class="stat-item">
<span class="stat-label">${plugin.tag}:</span>
<span class="stat-value">${plugin.status || '运行中'}</span>
</div>
`).join('');
container.innerHTML = html;
}
renderDomainFilesList(files) {
const container = document.getElementById('domain-files-list');
if (!files || files.length === 0) {
container.innerHTML = '<div class="loading">暂无域名文件</div>';
return;
}
const html = files.map(file => `
<div class="domain-file-item">
<div class="domain-file-info">
<div class="domain-file-name">${file.filename}</div>
<div class="domain-file-meta">
大小: ${this.formatFileSize(file.size)} |
修改时间: ${new Date(file.modTime).toLocaleString()}
</div>
</div>
<div class="domain-file-actions">
<button class="btn btn-secondary" onclick="app.viewDomainFile('${file.filename}')">查看</button>
<button class="btn btn-secondary" onclick="app.editDomainFile('${file.filename}')">编辑</button>
<button class="btn btn-secondary" onclick="app.deleteDomainFile('${file.filename}')">删除</button>
</div>
</div>
`).join('');
container.innerHTML = html;
}
renderDetailedStats(stats) {
const container = document.getElementById('detailed-stats');
if (!stats) {
container.innerHTML = '<div class="loading">暂无统计信息</div>';
return;
}
const html = `
<div class="stat-item">
<span class="stat-label">DNS 查询总数:</span>
<span class="stat-value">${stats.totalQueries?.toLocaleString() || '-'}</span>
</div>
<div class="stat-item">
<span class="stat-label">成功响应:</span>
<span class="stat-value">${stats.successfulQueries?.toLocaleString() || '-'}</span>
</div>
<div class="stat-item">
<span class="stat-label">失败响应:</span>
<span class="stat-value">${stats.failedQueries?.toLocaleString() || '-'}</span>
</div>
<div class="stat-item">
<span class="stat-label">缓存命中:</span>
<span class="stat-value">${stats.cacheHits?.toLocaleString() || '-'}</span>
</div>
<div class="stat-item">
<span class="stat-label">缓存未命中:</span>
<span class="stat-value">${stats.cacheMisses?.toLocaleString() || '-'}</span>
</div>
<div class="stat-item">
<span class="stat-label">平均响应时间:</span>
<span class="stat-value">${stats.avgResponseTime ? stats.avgResponseTime + 'ms' : '-'}</span>
</div>
`;
container.innerHTML = html;
}
async apiCall(endpoint, options = {}) {
const url = this.apiBase + endpoint;
const defaultOptions = {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
};
const response = await fetch(url, { ...defaultOptions, ...options });
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
}
startAutoRefresh() {
this.stopAutoRefresh();
this.refreshInterval = setInterval(() => {
if (this.currentTab === 'dashboard') {
this.loadDashboardData();
}
}, 30000); // 30秒刷新一次
}
stopAutoRefresh() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
this.refreshInterval = null;
}
}
showMessage(message, type = 'success') {
const container = document.getElementById('message-container');
const messageEl = document.createElement('div');
messageEl.className = `message ${type}`;
messageEl.textContent = message;
container.appendChild(messageEl);
setTimeout(() => {
messageEl.remove();
}, 5000);
}
formatUptime(seconds) {
if (!seconds) return '-';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days}${hours}小时 ${minutes}分钟`;
if (hours > 0) return `${hours}小时 ${minutes}分钟`;
return `${minutes}分钟`;
}
formatFileSize(bytes) {
if (!bytes) return '0 B';
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
}
// 域名文件操作方法
async viewDomainFile(filename) {
try {
const response = await this.apiCall(`/domain-files/${filename}`);
if (response.success) {
alert(`文件内容:\n\n${response.data.content}`);
}
} catch (error) {
this.showMessage('查看文件失败: ' + error.message, 'error');
}
}
async editDomainFile(filename) {
this.showMessage('编辑功能正在开发中', 'warning');
}
async deleteDomainFile(filename) {
if (!confirm(`确定要删除文件 ${filename} 吗?`)) return;
try {
const response = await this.apiCall(`/domain-files/${filename}`, {
method: 'DELETE'
});
if (response.success) {
this.showMessage('文件删除成功');
this.loadDomainFiles();
}
} catch (error) {
this.showMessage('删除文件失败: ' + error.message, 'error');
}
}
}
// 全局函数,供 HTML 中的按钮调用
async function reloadConfig() {
try {
const response = await app.apiCall('/config/reload', { method: 'POST' });
if (response.success) {
app.showMessage('配置重载成功');
}
} catch (error) {
app.showMessage('配置重载失败: ' + error.message, 'error');
}
}
async function flushCache() {
try {
const response = await app.apiCall('/cache/flush', { method: 'POST' });
if (response.success) {
app.showMessage('缓存清空成功');
}
} catch (error) {
app.showMessage('缓存清空失败: ' + error.message, 'error');
}
}
async function refreshStats() {
app.loadDashboardData();
app.showMessage('统计信息已刷新');
}
async function restartService() {
if (!confirm('确定要重启服务吗?服务将在 3 秒后重启。')) return;
try {
const response = await app.apiCall('/system/restart', { method: 'POST' });
if (response.success) {
app.showMessage('重启请求已发送,服务将在 3 秒后重启', 'success');
}
} catch (error) {
app.showMessage('重启失败: ' + error.message, 'error');
}
}
// MikroTik 管理函数
async function saveMikrotikConfig() {
const tag = document.getElementById('mikrotik-tag').value.trim();
const host = document.getElementById('mikrotik-host').value.trim();
const port = document.getElementById('mikrotik-port').value.trim();
const username = document.getElementById('mikrotik-username').value.trim();
const password = document.getElementById('mikrotik-password').value;
const addresslist = document.getElementById('mikrotik-addresslist').value.trim();
const domainFilePath = document.getElementById('mikrotik-domains').value.trim();
// 验证必填字段
if (!tag) {
app.showMessage('请填写配置标签', 'error');
return;
}
if (!host) {
app.showMessage('请填写 MikroTik 地址', 'error');
return;
}
if (!username) {
app.showMessage('请填写用户名', 'error');
return;
}
if (!password) {
app.showMessage('请填写密码', 'error');
return;
}
if (!addresslist) {
app.showMessage('请填写地址列表名', 'error');
return;
}
if (!domainFilePath) {
app.showMessage('请填写域名文件路径', 'error');
return;
}
// 构建配置对象
const config = {
tag: tag,
type: 'mikrotik_addresslist',
args: {
domain_files: [domainFilePath], // 使用用户输入的完整路径
host: host,
port: parseInt(port) || 9728,
username: username,
password: password,
use_tls: false,
timeout: 3,
address_list4: addresslist,
mask4: 24,
comment: `${addresslist}-AutoAdd`,
timeout_addr: 43200,
cache_ttl: 3600,
verify_add: false,
add_all_ips: true,
max_ips: 50
}
};
try {
const response = await app.apiCall('/mikrotik/add', {
method: 'POST',
body: JSON.stringify(config)
});
if (response.success) {
app.showMessage(response.message || 'MikroTik 配置已保存', 'success');
// 清空表单
clearMikrotikForm();
// 刷新列表
await app.loadMikrotikList();
} else {
app.showMessage(response.message || '保存失败', 'error');
}
} catch (error) {
app.showMessage('保存失败: ' + error.message, 'error');
}
}
async function deleteMikrotikConfig(tag) {
if (!confirm(`确定要删除 MikroTik 配置 "${tag}" 吗?`)) {
return;
}
try {
const response = await app.apiCall(`/mikrotik/${encodeURIComponent(tag)}`, {
method: 'DELETE'
});
if (response.success) {
app.showMessage(response.message || '配置已删除', 'success');
// 刷新列表
await app.loadMikrotikList();
} else {
app.showMessage(response.message || '删除失败', 'error');
}
} catch (error) {
app.showMessage('删除失败: ' + error.message, 'error');
}
}
function clearMikrotikForm() {
document.getElementById('mikrotik-tag').value = '';
document.getElementById('mikrotik-host').value = '';
document.getElementById('mikrotik-port').value = '9728';
document.getElementById('mikrotik-username').value = 'admin';
document.getElementById('mikrotik-password').value = '';
document.getElementById('mikrotik-addresslist').value = '';
document.getElementById('mikrotik-domains').value = '';
app.showMessage('表单已清空', 'info');
}
// 重新加载 MikroTik 列表
async function loadMikrotikList() {
await app.loadMikrotikList();
}
async function saveConfig() {
const content = document.getElementById('config-editor').value;
try {
const response = await app.apiCall('/config', {
method: 'PUT',
body: JSON.stringify({ content })
});
if (response.success) {
app.showMessage('配置保存成功');
}
} catch (error) {
app.showMessage('配置保存失败: ' + error.message, 'error');
}
}
async function validateConfig() {
const content = document.getElementById('config-editor').value;
try {
const response = await app.apiCall('/config/validate', {
method: 'POST',
body: JSON.stringify({ content })
});
if (response.success) {
app.showMessage('配置验证通过');
}
} catch (error) {
app.showMessage('配置验证失败: ' + error.message, 'error');
}
}
async function backupConfig() {
try {
const response = await app.apiCall('/config/backup', { method: 'POST' });
if (response.success) {
app.showMessage('配置备份成功');
}
} catch (error) {
app.showMessage('配置备份失败: ' + error.message, 'error');
}
}
async function addDomainFile() {
app.showMessage('添加文件功能正在开发中', 'warning');
}
async function refreshDomainFiles() {
app.loadDomainFiles();
app.showMessage('域名文件列表已刷新');
}
async function clearLogs() {
if (!confirm('确定要清空日志吗?')) return;
try {
const response = await app.apiCall('/logs/clear', { method: 'POST' });
if (response.success) {
document.getElementById('logs-content').textContent = '';
app.showMessage('日志清空成功');
}
} catch (error) {
app.showMessage('日志清空失败: ' + error.message, 'error');
}
}
async function refreshLogs() {
app.loadLogs();
app.showMessage('日志已刷新');
}
async function exportStats() {
app.showMessage('导出功能正在开发中', 'warning');
}
async function refreshDetailedStats() {
app.loadDetailedStats();
app.showMessage('统计信息已刷新');
}
// 初始化应用
let app;
document.addEventListener('DOMContentLoaded', () => {
app = new MosDNSAdmin();
});

View File

@ -0,0 +1,274 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MosDNS 管理面板</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌐</text></svg>">
</head>
<body>
<div class="container">
<!-- 头部 -->
<header class="header">
<div class="header-content">
<h1 class="logo">🌐 MosDNS 管理面板</h1>
<div class="header-info">
<span class="status-indicator online" id="status">在线</span>
<span class="version" id="version">v1.0.0</span>
</div>
</div>
</header>
<!-- 导航栏 -->
<nav class="nav">
<div class="nav-content">
<button class="nav-item active" data-tab="dashboard">
📊 仪表板
</button>
<button class="nav-item" data-tab="mikrotik">
🔧 MikroTik 管理
</button>
<button class="nav-item" data-tab="domains">
📝 域名文件
</button>
<button class="nav-item" data-tab="logs">
📋 日志查看
</button>
<button class="nav-item" data-tab="stats">
📈 统计信息
</button>
</div>
</nav>
<!-- 主要内容区域 -->
<main class="main">
<!-- 仪表板 -->
<div class="tab-content active" id="dashboard">
<div class="dashboard-grid">
<div class="card">
<div class="card-header">
<h3>服务状态</h3>
</div>
<div class="card-content">
<div class="stat-item">
<span class="stat-label">运行状态:</span>
<span class="stat-value" id="service-status">运行中</span>
</div>
<div class="stat-item">
<span class="stat-label">运行时间:</span>
<span class="stat-value" id="uptime">-</span>
</div>
<div class="stat-item">
<span class="stat-label">DNS 端口:</span>
<span class="stat-value" id="dns-ports">-</span>
</div>
<div class="stat-item">
<span class="stat-label">API 地址:</span>
<span class="stat-value" id="api-address">-</span>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>查询统计</h3>
</div>
<div class="card-content">
<div class="stat-item">
<span class="stat-label">总查询数:</span>
<span class="stat-value" id="total-queries">-</span>
</div>
<div class="stat-item">
<span class="stat-label">缓存命中:</span>
<span class="stat-value" id="cache-hits">-</span>
</div>
<div class="stat-item">
<span class="stat-label">平均响应时间:</span>
<span class="stat-value" id="avg-response">-</span>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>快速操作</h3>
</div>
<div class="card-content">
<div class="button-group">
<button class="btn btn-primary" onclick="flushCache()">清空缓存</button>
<button class="btn btn-secondary" onclick="refreshStats()">刷新统计</button>
<button class="btn btn-secondary" onclick="restartService()">重启服务</button>
</div>
</div>
</div>
</div>
</div>
<!-- MikroTik 管理 -->
<div class="tab-content" id="mikrotik">
<!-- 已添加的配置列表(移到前面) -->
<div class="card">
<div class="card-header">
<h3>📋 已添加的 MikroTik 配置</h3>
<div class="card-actions">
<button class="btn btn-secondary" onclick="loadMikrotikList()">
<span>🔄</span>
<span>刷新</span>
</button>
</div>
</div>
<div class="card-content">
<div id="mikrotik-list" class="mikrotik-list">
<div class="loading">加载中...</div>
</div>
</div>
</div>
<!-- 添加新配置表单 -->
<div class="card">
<div class="card-header">
<h3> 添加 MikroTik 配置</h3>
</div>
<div class="card-content">
<div class="mikrotik-form">
<div class="form-row">
<div class="form-group">
<label for="mikrotik-tag">
<span class="label-required">*</span>
<span>配置标签</span>
</label>
<input type="text" id="mikrotik-tag" placeholder="例如: mikrotik_openai" class="form-control">
<small class="form-hint">唯一标识,建议使用 mikrotik_ 前缀</small>
</div>
<div class="form-group">
<label for="mikrotik-addresslist">
<span class="label-required">*</span>
<span>地址列表名</span>
</label>
<input type="text" id="mikrotik-addresslist" placeholder="例如: OpenAI" class="form-control">
<small class="form-hint">MikroTik 中的地址列表名称</small>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="mikrotik-host">
<span class="label-required">*</span>
<span>MikroTik 地址</span>
</label>
<input type="text" id="mikrotik-host" placeholder="例如: 10.248.0.1" class="form-control">
<small class="form-hint">MikroTik 设备的 IP 地址</small>
</div>
<div class="form-group">
<label for="mikrotik-port">
<span>API 端口</span>
</label>
<input type="number" id="mikrotik-port" value="9728" class="form-control">
<small class="form-hint">默认 API 端口为 9728</small>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="mikrotik-username">
<span class="label-required">*</span>
<span>用户名</span>
</label>
<input type="text" id="mikrotik-username" placeholder="admin" value="admin" class="form-control">
</div>
<div class="form-group">
<label for="mikrotik-password">
<span class="label-required">*</span>
<span>密码</span>
</label>
<input type="password" id="mikrotik-password" class="form-control">
</div>
</div>
<div class="form-group">
<label for="mikrotik-domains">
<span class="label-required">*</span>
<span>域名文件路径</span>
</label>
<input type="text" id="mikrotik-domains" placeholder="例如: /usr/local/yltx-dns/config/openai.txt 或 ./mikrotik/openai.txt" class="form-control">
<small class="form-hint">支持绝对路径和相对路径(相对于运行目录)</small>
</div>
<div class="form-actions">
<button class="btn btn-primary btn-lg" onclick="saveMikrotikConfig()">
<span>💾</span>
<span>保存配置</span>
</button>
<button class="btn btn-secondary btn-lg" onclick="clearMikrotikForm()">
<span>🔄</span>
<span>清空表单</span>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 域名文件 -->
<div class="tab-content" id="domains">
<div class="card">
<div class="card-header">
<h3>域名文件管理</h3>
<div class="card-actions">
<button class="btn btn-primary" onclick="addDomainFile()">添加文件</button>
<button class="btn btn-secondary" onclick="refreshDomainFiles()">刷新列表</button>
</div>
</div>
<div class="card-content">
<div id="domain-files-list">
<div class="loading">加载中...</div>
</div>
</div>
</div>
</div>
<!-- 日志查看 -->
<div class="tab-content" id="logs">
<div class="card">
<div class="card-header">
<h3>系统日志</h3>
<div class="card-actions">
<button class="btn btn-secondary" onclick="clearLogs()">清空日志</button>
<button class="btn btn-secondary" onclick="refreshLogs()">刷新日志</button>
</div>
</div>
<div class="card-content">
<div class="logs-container">
<pre id="logs-content">日志内容将在这里显示...</pre>
</div>
</div>
</div>
</div>
<!-- 统计信息 -->
<div class="tab-content" id="stats">
<div class="card">
<div class="card-header">
<h3>详细统计</h3>
<div class="card-actions">
<button class="btn btn-secondary" onclick="exportStats()">导出统计</button>
<button class="btn btn-secondary" onclick="refreshDetailedStats()">刷新统计</button>
</div>
</div>
<div class="card-content">
<div id="detailed-stats">
<div class="loading">加载中...</div>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- 消息提示 -->
<div id="message-container"></div>
<script src="/static/js/app.js"></script>
</body>
</html>

249
coremain/web_ui.go Normal file
View File

@ -0,0 +1,249 @@
/*
* Copyright (C) 2020-2022, IrineSistiana
*
* This file is part of mosdns.
*
* mosdns is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* mosdns is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package coremain
import (
"io"
"io/fs"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"go.uber.org/zap"
)
var webUIFS fs.FS
// SetWebUIFS 设置 Web UI 文件系统(由 main 包调用)
func SetWebUIFS(fsys fs.FS) {
webUIFS = fsys
}
// registerWebUI 注册 Web 管理界面路由Vue SPA
func (m *Mosdns) registerWebUI() {
// 获取 Vue 构建产物
distFS, err := fs.Sub(webUIFS, "web-ui/dist")
if err != nil {
m.logger.Error("failed to get Vue dist files", zap.Error(err))
return
}
// 直接处理所有请求
m.webMux.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
m.logger.Debug("Web UI request", zap.String("path", path))
// API 请求交给 API 路由处理(不应该到这里,但作为保护)
if strings.HasPrefix(path, "/api/") {
http.NotFound(w, r)
return
}
// 尝试打开文件
trimmedPath := strings.TrimPrefix(path, "/")
if trimmedPath == "" {
trimmedPath = "index.html"
}
m.logger.Debug("Trying to open file", zap.String("trimmedPath", trimmedPath))
file, err := distFS.Open(trimmedPath)
if err == nil {
// 文件存在,直接返回
defer file.Close()
stat, _ := file.Stat()
if !stat.IsDir() {
m.logger.Debug("Serving file", zap.String("path", trimmedPath))
http.ServeContent(w, r, path, stat.ModTime(), file.(io.ReadSeeker))
return
}
} else {
m.logger.Debug("File not found", zap.String("path", trimmedPath), zap.Error(err))
}
// 文件不存在或是目录,返回 index.html (SPA 路由)
m.logger.Debug("Serving index.html for SPA")
m.serveVueIndex(w, r, distFS)
})
}
// serveVueIndex 提供 Vue SPA 的 index.html
func (m *Mosdns) serveVueIndex(w http.ResponseWriter, r *http.Request, distFS fs.FS) {
// 读取 index.html
indexHTML, err := fs.ReadFile(distFS, "index.html")
if err != nil {
m.logger.Error("failed to read Vue index.html", zap.Error(err))
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// 设置响应头
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
// 返回 HTML 内容
w.Write(indexHTML)
}
// registerWebAPI 注册 Web 管理 API 路由(使用独立的 webMux
func (m *Mosdns) registerWebAPI() {
// Web API 路由组(在 Web UI 服务器上提供 API 代理)
m.webMux.Route("/api", func(r chi.Router) {
// 服务器信息和状态
r.Get("/server/info", m.handleWebServerInfo)
r.Get("/server/status", m.handleWebServerStatus)
// 配置管理
r.Get("/config", m.handleWebGetConfig)
r.Put("/config", m.handleWebUpdateConfig)
r.Post("/config/reload", m.handleWebReloadConfig)
r.Post("/config/validate", m.handleWebValidateConfig)
r.Post("/config/backup", m.handleWebBackupConfig)
// 域名文件管理
r.Get("/domain-files", m.handleWebListDomainFiles)
r.Get("/domain-files/{filename}", m.handleWebGetDomainFile)
r.Put("/domain-files/{filename}", m.handleWebUpdateDomainFile)
r.Delete("/domain-files/{filename}", m.handleWebDeleteDomainFile)
// 插件管理
r.Get("/plugins", m.handleWebListPlugins)
r.Get("/plugins/{tag}/status", m.handleWebPluginStatus)
// 统计信息
r.Get("/stats/detailed", m.handleWebDetailedStats)
// 日志管理
r.Get("/logs", m.handleWebGetLogs)
r.Post("/logs/clear", m.handleWebClearLogs)
// 缓存管理
r.Post("/cache/flush", m.handleWebFlushCache)
// MikroTik 管理
r.Get("/mikrotik/list", m.handleListMikroTik)
r.Post("/mikrotik/add", m.handleAddMikroTik)
r.Delete("/mikrotik/{tag}", m.handleDeleteMikroTik)
// 系统操作
r.Post("/system/restart", m.handleSystemRestart)
})
}
// Web API 处理函数的简化版本,复用现有的管理 API 逻辑
func (m *Mosdns) handleWebServerInfo(w http.ResponseWriter, r *http.Request) {
m.handleServerInfo(w, r)
}
func (m *Mosdns) handleWebServerStatus(w http.ResponseWriter, r *http.Request) {
m.handleServerStatus(w, r)
}
func (m *Mosdns) handleWebGetConfig(w http.ResponseWriter, r *http.Request) {
m.handleGetConfig(w, r)
}
func (m *Mosdns) handleWebUpdateConfig(w http.ResponseWriter, r *http.Request) {
m.handleUpdateConfig(w, r)
}
func (m *Mosdns) handleWebReloadConfig(w http.ResponseWriter, r *http.Request) {
m.handleReloadConfig(w, r)
}
func (m *Mosdns) handleWebValidateConfig(w http.ResponseWriter, r *http.Request) {
m.handleValidateConfig(w, r)
}
func (m *Mosdns) handleWebBackupConfig(w http.ResponseWriter, r *http.Request) {
m.handleBackupConfig(w, r)
}
func (m *Mosdns) handleWebListDomainFiles(w http.ResponseWriter, r *http.Request) {
m.handleListDomainFiles(w, r)
}
func (m *Mosdns) handleWebGetDomainFile(w http.ResponseWriter, r *http.Request) {
m.handleGetDomainFile(w, r)
}
func (m *Mosdns) handleWebUpdateDomainFile(w http.ResponseWriter, r *http.Request) {
m.handleUpdateDomainFile(w, r)
}
func (m *Mosdns) handleWebDeleteDomainFile(w http.ResponseWriter, r *http.Request) {
m.handleDeleteDomainFile(w, r)
}
func (m *Mosdns) handleWebListPlugins(w http.ResponseWriter, r *http.Request) {
m.handleListPlugins(w, r)
}
func (m *Mosdns) handleWebPluginStatus(w http.ResponseWriter, r *http.Request) {
m.handlePluginStatus(w, r)
}
func (m *Mosdns) handleWebDetailedStats(w http.ResponseWriter, r *http.Request) {
// 返回详细统计信息
stats := map[string]interface{}{
"totalQueries": 0,
"successfulQueries": 0,
"failedQueries": 0,
"cacheHits": 0,
"cacheMisses": 0,
"avgResponseTime": 0,
}
m.writeJSONResponse(w, APIResponse{
Success: true,
Data: stats,
Message: "统计信息获取成功",
})
}
func (m *Mosdns) handleWebGetLogs(w http.ResponseWriter, r *http.Request) {
// 返回日志内容(这里可以根据实际需求实现)
logs := map[string]interface{}{
"content": "MosDNS 日志内容将在这里显示...\n[INFO] MosDNS 服务已启动\n[INFO] 配置文件加载成功\n[INFO] 所有插件已加载完成",
}
m.writeJSONResponse(w, APIResponse{
Success: true,
Data: logs,
Message: "日志获取成功",
})
}
func (m *Mosdns) handleWebClearLogs(w http.ResponseWriter, r *http.Request) {
// 清空日志(这里可以根据实际需求实现)
m.writeJSONResponse(w, APIResponse{
Success: true,
Message: "日志清空成功",
})
}
func (m *Mosdns) handleWebFlushCache(w http.ResponseWriter, r *http.Request) {
// 清空缓存(这里可以根据实际需求实现)
m.writeJSONResponse(w, APIResponse{
Success: true,
Message: "缓存清空成功",
})
}

View File

@ -1,192 +0,0 @@
# MosDNS + MikroTik Amazon 域名处理部署指南(更新版)
## 功能说明
这个配置会在解析 Amazon 相关域名时,自动将解析到的 IP 地址添加到 MikroTik 路由器的 address list 中,用于防火墙规则控制。
## 部署步骤
### 1. 上传文件到 Debian 12 服务器
```bash
# 上传编译好的 mosdns 可执行文件
scp mosdns-linux-amd64 user@your-server:/usr/local/bin/mosdns
# 上传配置文件
scp config.yaml user@your-server:/opt/mosdns/
scp dns.yaml user@your-server:/opt/mosdns/
# 设置执行权限
ssh user@your-server "chmod +x /usr/local/bin/mosdns"
```
### 2. 创建必要的目录和文件
```bash
# 创建配置目录
sudo mkdir -p /opt/mosdns/config
# 下载 Amazon 域名列表
sudo wget -O /opt/mosdns/config/geosite_amazon.txt https://raw.githubusercontent.com/v2fly/domain-list-community/master/data/amazon
sudo wget -O /opt/mosdns/config/geosite_amazon-ads.txt https://raw.githubusercontent.com/v2fly/domain-list-community/master/data/amazon-ads
sudo wget -O /opt/mosdns/config/geosite_amazontrust.txt https://raw.githubusercontent.com/v2fly/domain-list-community/master/data/amazontrust
sudo wget -O /opt/mosdns/config/amazon.txt https://raw.githubusercontent.com/v2fly/domain-list-community/master/data/amazon
# 下载其他必要的域名和 IP 文件
sudo wget -O /opt/mosdns/config/geosite_tiktok.txt https://raw.githubusercontent.com/v2fly/domain-list-community/master/data/tiktok
sudo wget -O /opt/mosdns/config/gfwlist.out.txt https://raw.githubusercontent.com/gfwlist/gfwlist/master/gfwlist.txt
sudo wget -O /opt/mosdns/config/domains.txt https://raw.githubusercontent.com/v2fly/domain-list-community/master/data/category-games
sudo wget -O /opt/mosdns/config/cn.txt https://raw.githubusercontent.com/Loyalsoldier/v2ray-rules-dat/release/geoip.dat
```
### 3. 在 MikroTik 中创建 address list
```bash
# 通过 SSH 连接到 MikroTik 路由器
ssh admin@10.248.0.1
# 创建 IPv4 和 IPv6 address list
/ip firewall address-list add list=AmazonIP
/ip firewall address-list add list=AmazonIP6
# 创建防火墙规则(可选)
/ip firewall filter add chain=forward src-address-list=AmazonIP action=drop comment="Block Amazon IPs"
/ip firewall filter add chain=forward src-address-list=AmazonIP6 action=drop comment="Block Amazon IPv6 IPs"
```
### 4. 修改配置文件中的 MikroTik 连接信息
编辑 `/opt/mosdns/dns.yaml` 文件,确认 mikrotik_amazon 插件的配置:
```yaml
# 当前配置(根据你的实际情况修改)
args: "10.248.0.1:9728:admin:szn0s!nw@pwd():false:10:AmazonIP:AmazonIP6:24:32:AmazonIP:86400"
```
参数说明:
- `10.248.0.1`: MikroTik 路由器 IP
- `9728`: API 端口
- `admin`: 用户名
- `szn0s!nw@pwd()`: 密码
- `false`: 不使用 TLS
- `10`: 连接超时时间
- `AmazonIP`: IPv4 address list 名称
- `AmazonIP6`: IPv6 address list 名称
- `24`: IPv4 掩码
- `32`: IPv6 掩码
- `AmazonIP`: 注释
- `86400`: 地址超时时间24小时
### 5. 创建 systemd 服务
```bash
sudo tee /etc/systemd/system/mosdns.service > /dev/null <<EOF
[Unit]
Description=MosDNS DNS Server
After=network.target
[Service]
Type=simple
User=root
ExecStart=/usr/local/bin/mosdns -c /opt/mosdns/config.yaml
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
EOF
# 重新加载 systemd 配置
sudo systemctl daemon-reload
# 启用并启动服务
sudo systemctl enable mosdns
sudo systemctl start mosdns
# 检查服务状态
sudo systemctl status mosdns
```
### 6. 配置 DNS 转发
修改 `/etc/systemd/resolved.conf`
```ini
[Resolve]
DNS=127.0.0.1:5300
#FallbackDNS=8.8.8.8 8.8.4.4
#Domains=
#DNSSEC=no
#DNSOverTLS=no
#MulticastDNS=yes
#LLMNR=yes
#Cache=yes
#DNSStubListener=no
```
重启 systemd-resolved
```bash
sudo systemctl restart systemd-resolved
```
### 7. 测试配置
```bash
# 测试 Amazon 域名解析
nslookup amazon.com 127.0.0.1:5300
nslookup aws.amazon.com 127.0.0.1:5300
# 检查 MikroTik address list 是否更新
ssh admin@10.248.0.1 "/ip firewall address-list print where list=AmazonIP"
# 查看 mosdns 日志
sudo journalctl -u mosdns -f
```
## 配置说明
### 工作流程
1. **域名匹配**:当查询 Amazon 相关域名时,匹配 `amazon_domains` 集合
2. **DNS 解析**:使用国外 DNS 服务器解析域名
3. **IP 提取**:从 DNS 响应中提取 A 和 AAAA 记录
4. **地址添加**:通过 MikroTik API 将 IP 添加到 address list
5. **超时管理**IP 地址会在 24 小时后自动过期
### 监控和调试
```bash
# 查看实时日志
sudo journalctl -u mosdns -f
# 查看服务状态
sudo systemctl status mosdns
# 测试 MikroTik 连接
curl -k https://10.248.0.1:9729/api/rest/ip/firewall/address-list
# 查看 API 状态
curl http://localhost:5535/metrics
```
### 故障排除
1. **连接失败**:检查 MikroTik IP、端口和认证信息
2. **权限不足**:确保 MikroTik 用户具有管理 address list 的权限
3. **域名文件缺失**:确保所有域名列表文件都已下载
4. **DNS 解析失败**:检查上游 DNS 服务器配置
## 安全注意事项
1. 不要在配置文件中使用明文密码,考虑使用环境变量
2. 限制对 MikroTik API 端口的访问
3. 定期更新域名列表文件
4. 监控 address list 大小,避免过多条目影响性能
## 更新日志
- 修复了插件注册问题,现在支持 YAML 配置和快速配置
- 更新了路径配置为 `/opt/mosdns/`
- 更新了端口配置为 `5300`
- 更新了 API 端口为 `5535`

View File

@ -1,182 +0,0 @@
# MosDNS + MikroTik Amazon 域名处理部署指南
## 功能说明
这个配置会在解析 Amazon 相关域名时,自动将解析到的 IP 地址添加到 MikroTik 路由器的 address list 中,用于防火墙规则控制。
## 部署步骤
### 1. 上传文件到 Debian 12 服务器
```bash
# 上传编译好的 mosdns 可执行文件
scp mosdns-linux-amd64 user@your-server:/usr/local/bin/mosdns
# 上传配置文件
scp config.yaml user@your-server:/usr/local/mosdns/
scp dns.yaml user@your-server:/usr/local/mosdns/
# 设置执行权限
ssh user@your-server "chmod +x /usr/local/bin/mosdns"
```
### 2. 创建必要的目录和文件
```bash
# 创建配置目录
sudo mkdir -p /usr/local/mosdns/config
# 下载 Amazon 域名列表
sudo wget -O /usr/local/mosdns/config/geosite_amazon.txt https://raw.githubusercontent.com/v2fly/domain-list-community/master/data/amazon
sudo wget -O /usr/local/mosdns/config/geosite_amazon-ads.txt https://raw.githubusercontent.com/v2fly/domain-list-community/master/data/amazon-ads
sudo wget -O /usr/local/mosdns/config/geosite_amazontrust.txt https://raw.githubusercontent.com/v2fly/domain-list-community/master/data/amazontrust
sudo wget -O /usr/local/mosdns/config/amazon.txt https://raw.githubusercontent.com/v2fly/domain-list-community/master/data/amazon
# 下载其他必要的域名和 IP 文件
sudo wget -O /usr/local/mosdns/config/geosite_tiktok.txt https://raw.githubusercontent.com/v2fly/domain-list-community/master/data/tiktok
sudo wget -O /usr/local/mosdns/config/gfwlist.out.txt https://raw.githubusercontent.com/gfwlist/gfwlist/master/gfwlist.txt
sudo wget -O /usr/local/mosdns/config/domains.txt https://raw.githubusercontent.com/v2fly/domain-list-community/master/data/category-games
sudo wget -O /usr/local/mosdns/config/cn.txt https://raw.githubusercontent.com/Loyalsoldier/v2ray-rules-dat/release/geoip.dat
```
### 3. 在 MikroTik 中创建 address list
```bash
# 通过 SSH 连接到 MikroTik 路由器
ssh admin@192.168.1.1
# 创建 IPv4 和 IPv6 address list
/ip firewall address-list add list=amazon_ips
/ip firewall address-list add list=amazon_ips6
# 创建防火墙规则(可选)
/ip firewall filter add chain=forward src-address-list=amazon_ips action=drop comment="Block Amazon IPs"
/ip firewall filter add chain=forward src-address-list=amazon_ips6 action=drop comment="Block Amazon IPv6 IPs"
```
### 4. 修改配置文件中的 MikroTik 连接信息
编辑 `/usr/local/mosdns/dns.yaml` 文件,修改 mikrotik_amazon 插件的配置:
```yaml
# 修改为你的 MikroTik 实际信息
args: "192.168.1.1:8728:admin:your-password:false:10:amazon_ips:amazon_ips6:24:32:amazon_domain:86400"
```
参数说明:
- `192.168.1.1`: MikroTik 路由器 IP
- `8728`: API 端口
- `admin`: 用户名
- `your-password`: 密码
- `false`: 不使用 TLS
- `10`: 连接超时时间
- `amazon_ips`: IPv4 address list 名称
- `amazon_ips6`: IPv6 address list 名称
- `24`: IPv4 掩码
- `32`: IPv6 掩码
- `amazon_domain`: 注释
- `86400`: 地址超时时间24小时
### 5. 创建 systemd 服务
```bash
sudo tee /etc/systemd/system/mosdns.service > /dev/null <<EOF
[Unit]
Description=MosDNS DNS Server
After=network.target
[Service]
Type=simple
User=root
ExecStart=/usr/local/bin/mosdns -c /usr/local/mosdns/config.yaml
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
EOF
# 重新加载 systemd 配置
sudo systemctl daemon-reload
# 启用并启动服务
sudo systemctl enable mosdns
sudo systemctl start mosdns
# 检查服务状态
sudo systemctl status mosdns
```
### 6. 配置 DNS 转发
修改 `/etc/systemd/resolved.conf`
```ini
[Resolve]
DNS=127.0.0.1
#FallbackDNS=8.8.8.8 8.8.4.4
#Domains=
#DNSSEC=no
#DNSOverTLS=no
#MulticastDNS=yes
#LLMNR=yes
#Cache=yes
#DNSStubListener=no
```
重启 systemd-resolved
```bash
sudo systemctl restart systemd-resolved
```
### 7. 测试配置
```bash
# 测试 Amazon 域名解析
nslookup amazon.com 127.0.0.1
nslookup aws.amazon.com 127.0.0.1
# 检查 MikroTik address list 是否更新
ssh admin@192.168.1.1 "/ip firewall address-list print where list=amazon_ips"
# 查看 mosdns 日志
sudo journalctl -u mosdns -f
```
## 配置说明
### 工作流程
1. **域名匹配**:当查询 Amazon 相关域名时,匹配 `amazon_domains` 集合
2. **DNS 解析**:使用国外 DNS 服务器解析域名
3. **IP 提取**:从 DNS 响应中提取 A 和 AAAA 记录
4. **地址添加**:通过 MikroTik API 将 IP 添加到 address list
5. **超时管理**IP 地址会在 24 小时后自动过期
### 监控和调试
```bash
# 查看实时日志
sudo journalctl -u mosdns -f
# 查看服务状态
sudo systemctl status mosdns
# 测试 MikroTik 连接
curl -k https://192.168.1.1:8729/api/rest/ip/firewall/address-list
```
### 故障排除
1. **连接失败**:检查 MikroTik IP、端口和认证信息
2. **权限不足**:确保 MikroTik 用户具有管理 address list 的权限
3. **域名文件缺失**:确保所有域名列表文件都已下载
4. **DNS 解析失败**:检查上游 DNS 服务器配置
## 安全注意事项
1. 不要在配置文件中使用明文密码,考虑使用环境变量
2. 限制对 MikroTik API 端口的访问
3. 定期更新域名列表文件
4. 监控 address list 大小,避免过多条目影响性能

27
dev-vue.bat Normal file
View File

@ -0,0 +1,27 @@
@echo off
chcp 65001 >nul
echo.
echo ====================================
echo MosDNS Vue 开发模式
echo ====================================
echo.
echo 📌 提示:
echo - 终端 1: 运行此脚本Vue 开发服务器)
echo - 终端 2: 运行 Go 后端
echo go run main.go start -c config.yaml
echo.
echo 然后访问: http://localhost:5173
echo.
echo ====================================
echo.
cd web-ui
if not exist "node_modules\" (
echo 📦 首次运行,正在安装依赖...
call npm install
)
echo 🚀 启动 Vue 开发服务器...
npm run dev

BIN
dist/mosdns-linux-amd64 vendored Normal file

Binary file not shown.

View File

@ -1,55 +0,0 @@
################ DNS Plugins #################
plugins:
- tag: mikrotik-one
type: forward
args:
concurrent: 1
upstreams:
- addr: "udp://10.248.0.1"
- tag: cn-dns
type: forward
args:
concurrent: 6
upstreams:
- addr: "udp://202.96.128.86"
- addr: "udp://202.96.128.166"
- addr: "udp://119.29.29.29"
- addr: "udp://223.5.5.5"
- addr: "udp://114.114.114.114"
- addr: "udp://180.76.76.76"
- tag: jp-dns
type: forward
args:
concurrent: 4
upstreams:
- addr: "tls://1dot1dot1dot1.cloudflare-dns.com"
dial_addr: "1.1.1.1"
enable_pipeline: true
- addr: "tls://1dot1dot1dot1.cloudflare-dns.com"
dial_addr: "1.0.0.1"
enable_pipeline: true
- addr: "tls://dns.google"
dial_addr: "8.8.8.8"
enable_pipeline: true
- addr: "tls://dns.google"
dial_addr: "8.8.4.4"
enable_pipeline: true
# MikroTik Address List 插件 - 处理 Amazon 相关域名
# 示例:将地址列表改为 gfw
- tag: mikrotik_amazon
type: mikrotik_addresslist
args:
host: "10.248.0.1"
port: 9728
username: "admin"
password: "szn0s!nw@pwd()"
use_tls: false
timeout: 10
address_list4: "gfw" # 改为 gfw插件会自动创建这个地址列表
mask4: 24
comment: "amazon_domain"
timeout_addr: 86400

View File

@ -1,93 +0,0 @@
################ DNS Plugins - 内存缓存优化版 #################
# 🚀 核心优化:
# 1. 程序启动时从MikroTik加载现有IP到内存
# 2. 完全移除验证功能
# 3. 内存判断IP存在性避免重复写入
# 4. 使用/24网段掩码减少条目数量
plugins:
- tag: mikrotik-one
type: forward
args:
concurrent: 1
upstreams:
- addr: "udp://10.248.0.1"
- tag: cn-dns
type: forward
args:
concurrent: 6
upstreams:
- addr: "udp://202.96.128.86"
- addr: "udp://202.96.128.166"
- addr: "udp://119.29.29.29"
- addr: "udp://223.5.5.5"
- addr: "udp://114.114.114.114"
- addr: "udp://180.76.76.76"
- tag: jp-dns
type: forward
args:
concurrent: 4
upstreams:
- addr: "tls://1dot1dot1dot1.cloudflare-dns.com"
dial_addr: "1.1.1.1"
enable_pipeline: true
- addr: "tls://1dot1dot1dot1.cloudflare-dns.com"
dial_addr: "1.0.0.1"
enable_pipeline: true
- addr: "tls://dns.google"
dial_addr: "8.8.8.8"
enable_pipeline: true
- addr: "tls://dns.google"
dial_addr: "8.8.4.4"
enable_pipeline: true
# 🚀 MikroTik Address List 插件 - 内存缓存优化配置
- tag: mikrotik_amazon
type: mikrotik_addresslist
args:
host: "10.248.0.1"
port: 9728
username: "admin"
password: "szn0s!nw@pwd()"
use_tls: false
timeout: 3 # 🚀 快速连接超时
# 地址列表配置
address_list4: "gfw" # IPv4地址列表名
address_list6: "gfw6" # IPv6地址列表名可选
# 🚀 核心优化:网段掩码配置
mask4: 24 # 使用/24网段减少条目数量
mask6: 64 # IPv6使用/64网段
# 超时和缓存配置
comment: "auto-amazon" # 自动添加的注释
timeout_addr: 43200 # 12小时地址超时
cache_ttl: 7200 # 2小时内存缓存TTL
# 🚀 性能优化开关
verify_add: false # 🔥 完全关闭验证功能
add_all_ips: true # 启用多IP支持
max_ips: 15 # 每个域名最多15个IP
# 🚀 新增:内存缓存优化参数
preload_existing: true # 启动时预加载现有IP
memory_cache_size: 10000 # 内存缓存最大条目数
subnet_cache_ttl: 14400 # 网段缓存4小时TTL
# 工作线程优化
worker_pool_size: 20 # 增加工作线程池
batch_size: 25 # 增加批处理大小
# 连接优化
max_retries: 2 # 最大重试次数
retry_backoff_ms: 100 # 重试退避时间(毫秒)
connection_pool_size: 3 # 连接池大小
# 🚀 启动行为配置
startup_load_timeout: 30 # 启动加载超时时间(秒)
log_cache_stats: true # 记录缓存统计信息
cleanup_interval: 3600 # 缓存清理间隔(秒)

View File

@ -1,66 +0,0 @@
################ DNS Plugins - 性能优化版 #################
plugins:
- tag: mikrotik-one
type: forward
args:
concurrent: 1
upstreams:
- addr: "udp://10.248.0.1"
- tag: cn-dns
type: forward
args:
concurrent: 6
upstreams:
- addr: "udp://202.96.128.86"
- addr: "udp://202.96.128.166"
- addr: "udp://119.29.29.29"
- addr: "udp://223.5.5.5"
- addr: "udp://114.114.114.114"
- addr: "udp://180.76.76.76"
- tag: jp-dns
type: forward
args:
concurrent: 4
upstreams:
- addr: "tls://1dot1dot1dot1.cloudflare-dns.com"
dial_addr: "1.1.1.1"
enable_pipeline: true
- addr: "tls://1dot1dot1dot1.cloudflare-dns.com"
dial_addr: "1.0.0.1"
enable_pipeline: true
- addr: "tls://dns.google"
dial_addr: "8.8.8.8"
enable_pipeline: true
- addr: "tls://dns.google"
dial_addr: "8.8.4.4"
enable_pipeline: true
# MikroTik Address List 插件 - 性能优化配置
- tag: mikrotik_amazon
type: mikrotik_addresslist
args:
host: "10.248.0.1"
port: 9728
username: "admin"
password: "szn0s!nw@pwd()"
use_tls: false
timeout: 3 # 🚀 减少连接超时到3秒
address_list4: "gfw"
mask4: 32 # 🚀 使用/32精确匹配避免网段冲突
comment: "auto-amazon"
timeout_addr: 43200 # 🚀 减少地址超时到12小时提高缓存命中率
cache_ttl: 7200 # 🚀 减少缓存TTL到2小时平衡性能和准确性
verify_add: false # 🚀 关闭验证,显著提升性能
add_all_ips: true # 🚀 启用多IP支持
max_ips: 10 # 🚀 限制每个域名最多10个IP避免过载
# 🚀 新增性能优化参数(如果支持的话)
batch_size: 20 # 批处理大小
worker_pool_size: 15 # 工作线程池大小
connection_pool_size: 5 # 连接池大小
retry_max: 2 # 最大重试次数
retry_backoff: 100 # 重试退避时间(ms)
enable_pipelining: true # 启用管道化处理

View File

@ -1,60 +0,0 @@
################ DNS Plugins #################
plugins:
- tag: mikrotik-one
type: forward
args:
concurrent: 1
upstreams:
- addr: "udp://10.248.0.1"
- tag: cn-dns
type: forward
args:
concurrent: 6
upstreams:
- addr: "udp://202.96.128.86"
- addr: "udp://202.96.128.166"
- addr: "udp://119.29.29.29"
- addr: "udp://223.5.5.5"
- addr: "udp://114.114.114.114"
- addr: "udp://180.76.76.76"
- tag: jp-dns
type: forward
args:
concurrent: 4 # 同步向 3 条上游并发查询
upstreams:
- addr: "tls://1dot1dot1dot1.cloudflare-dns.com"
dial_addr: "1.1.1.1"
enable_pipeline: true
- addr: "tls://1dot1dot1dot1.cloudflare-dns.com"
dial_addr: "1.0.0.1"
enable_pipeline: true
- addr: "tls://dns.google"
dial_addr: "8.8.8.8"
enable_pipeline: true
- addr: "tls://dns.google"
dial_addr: "8.8.4.4"
enable_pipeline: true
# MikroTik Address List 插件 - 性能优化配置
- tag: mikrotik_amazon
type: mikrotik_addresslist
args:
host: "10.248.0.1"
port: 9728
username: "admin"
password: "szn0s!nw@pwd()"
use_tls: false
timeout: 3 # 🚀 优化减少连接超时到3秒
address_list4: "gfw"
mask4: 24 # 🚀 优化:使用/24网段掩码减少地址条目数量
comment: "auto-amazon"
timeout_addr: 43200 # 🚀 优化减少到12小时提高缓存效率
cache_ttl: 7200 # 🚀 优化2小时缓存平衡性能和准确性
verify_add: false # 🚀 优化:关闭验证,显著提升性能
add_all_ips: true # 🚀 优化启用多IP支持
max_ips: 10 # 🚀 优化限制每域名最多10个IP

2
go.mod
View File

@ -26,6 +26,7 @@ require (
golang.org/x/sys v0.24.0 golang.org/x/sys v0.24.0
golang.org/x/time v0.6.0 golang.org/x/time v0.6.0
google.golang.org/protobuf v1.34.2 google.golang.org/protobuf v1.34.2
gopkg.in/yaml.v3 v3.0.1
) )
replace github.com/nadoo/ipset v0.5.0 => github.com/IrineSistiana/ipset v0.5.1-0.20220703061533-6e0fc3b04c0a replace github.com/nadoo/ipset v0.5.0 => github.com/IrineSistiana/ipset v0.5.1-0.20220703061533-6e0fc3b04c0a
@ -67,5 +68,4 @@ require (
golang.org/x/text v0.17.0 // indirect golang.org/x/text v0.17.0 // indirect
golang.org/x/tools v0.24.0 // indirect golang.org/x/tools v0.24.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )

View File

@ -21,12 +21,13 @@ package main
import ( import (
"fmt" "fmt"
_ "net/http/pprof"
"github.com/IrineSistiana/mosdns/v5/coremain" "github.com/IrineSistiana/mosdns/v5/coremain"
"github.com/IrineSistiana/mosdns/v5/mlog" "github.com/IrineSistiana/mosdns/v5/mlog"
_ "github.com/IrineSistiana/mosdns/v5/plugin" _ "github.com/IrineSistiana/mosdns/v5/plugin"
_ "github.com/IrineSistiana/mosdns/v5/tools" _ "github.com/IrineSistiana/mosdns/v5/tools"
"github.com/spf13/cobra" "github.com/spf13/cobra"
_ "net/http/pprof"
) )
var ( var (
@ -34,6 +35,10 @@ var (
) )
func init() { func init() {
// 设置 Web UI 文件系统
coremain.SetWebUIFS(WebUIFS)
// 添加 version 子命令
coremain.AddSubCmd(&cobra.Command{ coremain.AddSubCmd(&cobra.Command{
Use: "version", Use: "version",
Short: "Print out version info and exit.", Short: "Print out version info and exit.",

View File

@ -1,243 +0,0 @@
# ============================================
# MosDNS v5 最终优化配置
# 基于增强的 mikrotik_addresslist 插件
# ============================================
log:
level: info
# 管理 API
api:
http: "0.0.0.0:5535"
plugins:
# ========= 基础组件 =========
# GFW 域名列表(仅用于分流,不写入设备)
- tag: GFW_domains
type: domain_set
args:
files:
- "/usr/local/jinlingma/config/gfwlist.out.txt"
# 中国大陆 IP 列表
- tag: geoip_cn
type: ip_set
args:
files:
- "/usr/local/jinlingma/config/cn.txt"
# 缓存
- tag: cache
type: cache
args:
size: 32768
lazy_cache_ttl: 43200
# ========= 上游 DNS 定义 =========
# 国内 DNS
- tag: china-dns
type: forward
args:
concurrent: 6
upstreams:
- addr: "udp://202.96.128.86"
- addr: "udp://202.96.128.166"
- addr: "udp://119.29.29.29"
- addr: "udp://223.5.5.5"
- addr: "udp://114.114.114.114"
- addr: "udp://180.76.76.76"
# 国外 DNSDoT
- tag: overseas-dns
type: forward
args:
concurrent: 4
upstreams:
- addr: "tls://1dot1dot1dot1.cloudflare-dns.com"
dial_addr: "1.1.1.1"
enable_pipeline: true
- addr: "tls://1dot1dot1dot1.cloudflare-dns.com"
dial_addr: "1.0.0.1"
enable_pipeline: true
- addr: "tls://dns.google"
dial_addr: "8.8.8.8"
enable_pipeline: true
- addr: "tls://dns.google"
dial_addr: "8.8.4.4"
enable_pipeline: true
# fallback 封装
- tag: forward_local
type: fallback
args:
primary: china-dns
secondary: china-dns
threshold: 500
always_standby: true
- tag: forward_remote
type: fallback
args:
primary: overseas-dns
secondary: overseas-dns
threshold: 500
always_standby: true
# 便捷封装:国内/国外
- tag: forward_local_upstream
type: sequence
args:
- exec: prefer_ipv4
- exec: query_summary forward_local
- exec: $forward_local
- tag: forward_remote_upstream
type: sequence
args:
- exec: prefer_ipv4
- exec: query_summary forward_remote
- exec: $forward_remote
# ========= 🚀 增强的 MikroTik 插件(支持多设备多规则)=========
# 设备 AAmazon 相关域名
- tag: mikrotik_amazon
type: mikrotik_addresslist
domain_files:
- "/usr/local/jinlingma/config/amazon.txt"
- "/usr/local/jinlingma/config/aws.txt"
args:
host: "10.96.1.22"
port: 9728
username: "admin"
password: "szn0s!nw@pwd()"
use_tls: false
timeout: 3
address_list4: "Amazon"
address_list6: "Amazon6"
mask4: 24 # 使用/24网段减少条目数量
mask6: 64
comment: "Amazon-AutoAdd"
timeout_addr: 43200 # 12小时
cache_ttl: 3600 # 1小时缓存
verify_add: false # 关闭验证,提升性能
add_all_ips: true # 添加所有IP
max_ips: 20 # 限制每域名最多20个IP
# 设备 BGoogle 相关域名
- tag: mikrotik_google
type: mikrotik_addresslist
domain_files:
- "/usr/local/jinlingma/config/google.txt"
- "/usr/local/jinlingma/config/youtube.txt"
args:
host: "10.96.1.23"
port: 9728
username: "admin"
password: "szn0s!nw@pwd()"
use_tls: false
timeout: 3
address_list4: "Google"
mask4: 32 # 精确匹配单个IP
comment: "Google-AutoAdd"
timeout_addr: 21600 # 6小时
cache_ttl: 1800 # 30分钟缓存
verify_add: false
add_all_ips: true
max_ips: 15
# 设备 C流媒体相关域名
- tag: mikrotik_streaming
type: mikrotik_addresslist
domain_files:
- "/usr/local/jinlingma/config/netflix.txt"
- "/usr/local/jinlingma/config/disney.txt"
args:
host: "10.96.1.24"
port: 9728
username: "admin"
password: "szn0s!nw@pwd()"
use_tls: false
timeout: 5 # 流媒体可能需要更长时间
address_list4: "Streaming"
mask4: 32
comment: "Streaming-AutoAdd"
timeout_addr: 21600 # 6小时流媒体IP变化较频繁
cache_ttl: 1800 # 30分钟缓存
verify_add: false
add_all_ips: true
max_ips: 30 # 流媒体服务IP较多
# ========= 查询逻辑 =========
# 检查是否有响应
- tag: has_resp_sequence
type: sequence
args:
- matches: has_resp
exec: accept
# 拒绝无效查询
- tag: reject_invalid
type: sequence
args:
- matches: qtype 65
exec: reject 3
# GFW 域名分流(仅解析,不写入设备)
- tag: gfw_routing_only
type: sequence
args:
- matches: qname $GFW_domains
exec: $forward_remote_upstream
- exec: query_summary gfw_overseas_routing
# 智能 fallback 处理
- tag: smart_fallback_handler
type: sequence
args:
- exec: prefer_ipv4
- exec: $forward_local_upstream
- matches: resp_ip $geoip_cn
exec: accept
- exec: $forward_remote_upstream
- exec: query_summary fallback_to_overseas
# 🚀 主序列(极简版)
- tag: main_sequence
type: sequence
args:
# 1. 缓存检查
- exec: $cache
# 2. 拒绝无效查询
- exec: $reject_invalid
- exec: jump has_resp_sequence
# 3. GFW 域名分流(仅解析)
- exec: $gfw_routing_only
- exec: jump has_resp_sequence
# 4. 智能 fallback
- exec: $smart_fallback_handler
- exec: jump has_resp_sequence
# 5. 🚀 MikroTik 设备处理(每个插件自动匹配域名)
- exec: $mikrotik_amazon # 自动处理 Amazon 域名
- exec: $mikrotik_google # 自动处理 Google 域名
- exec: $mikrotik_streaming # 自动处理流媒体域名
# ========= 服务监听 =========
- tag: udp_server
type: udp_server
args:
entry: main_sequence
listen: ":5322"
- tag: tcp_server
type: tcp_server
args:
entry: main_sequence
listen: ":5322"

130
v2dat.sh Normal file
View File

@ -0,0 +1,130 @@
#!/bin/sh
set -e # 如果任何命令失败则退出
# 获取脚本所在目录
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT # 确保脚本退出时删除临时目录
# 创建必要的目录
mkdir -p "$SCRIPT_DIR/geo"
mkdir -p "$SCRIPT_DIR/geosite"
mkdir -p "$SCRIPT_DIR/geoip"
mkdir -p "$SCRIPT_DIR/config"
# 下载 geoip 和 geosite 数据文件到 geo 目录
download_geodata() {
echo "正在下载 geoip.dat..."
curl --connect-timeout 5 -m 60 -kfSL -o "$SCRIPT_DIR/geo/geoip.dat" "https://cdn.jsdelivr.net/gh/Loyalsoldier/geoip@release/geoip.dat"
echo "正在下载 geosite.dat..."
curl --connect-timeout 5 -m 60 -kfSL -o "$SCRIPT_DIR/geo/geosite.dat" "https://cdn.jsdelivr.net/gh/Loyalsoldier/v2ray-rules-dat@release/geosite.dat"
echo "正在下载 CN-ip-cidr.txt"
curl --connect-timeout 5 -m 60 -kfSL -o "$SCRIPT_DIR/config/CN-ip-cidr.txt" "https://raw.githubusercontent.com/Hackl0us/GeoIP2-CN/release/CN-ip-cidr.txt"
echo "下载完成"
}
# 下载 v2dat 工具(如果不存在)
download_v2dat() {
if [ ! -f "$SCRIPT_DIR/v2dat" ]; then
echo "正在下载 v2dat 工具..."
curl -fSL -o "$SCRIPT_DIR/v2dat" "https://raw.githubusercontent.com/xukecheng/scripts/main/v2dat"
chmod +x "$SCRIPT_DIR/v2dat"
echo "v2dat 工具下载完成"
else
echo "v2dat 工具已存在"
fi
}
# 过滤 IPv4 地址(去掉 IPv6
filter_ipv4_only() {
local input_file="$1"
local output_file="$2"
if [ ! -f "$input_file" ]; then
echo "警告: 文件 $input_file 不存在,跳过过滤"
return 1
fi
echo "正在过滤 IPv4 地址(去掉 IPv6..."
echo "输入文件: $input_file"
echo "输出文件: $output_file"
# 统计原始行数
original_count=$(wc -l < "$input_file" 2>/dev/null || echo "0")
# 过滤 IPv4 地址:
# 1. 匹配 IPv4 CIDR 格式 (x.x.x.x/xx)
# 2. 排除包含冒号的 IPv6 地址
# 3. 排除空行和注释行
grep -E '^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}(/[0-9]{1,2})?$' "$input_file" > "$output_file" 2>/dev/null || {
echo "错误: 过滤 IPv4 地址失败"
return 1
}
# 统计过滤后行数
filtered_count=$(wc -l < "$output_file" 2>/dev/null || echo "0")
removed_count=$((original_count - filtered_count))
echo "过滤完成:"
echo " 原始条目: $original_count"
echo " IPv4 条目: $filtered_count"
echo " 移除条目: $removed_count (IPv6 和无效条目)"
return 0
}
# 使用 v2dat 工具解包数据
unpack_geodata() {
echo "正在解包 geosite.dat..."
"$SCRIPT_DIR/v2dat" unpack geosite "$SCRIPT_DIR/geo/geosite.dat" -o "$SCRIPT_DIR/geosite"
echo "正在解包 geoip.dat CN 数据..."
"$SCRIPT_DIR/v2dat" unpack geoip "$SCRIPT_DIR/geo/geoip.dat" -o "$SCRIPT_DIR/geoip" -f cn
# 🆕 新增:过滤 IPv4 地址,去掉 IPv6
local geoip_cn_file="$SCRIPT_DIR/geoip/geoip_cn.txt"
local geoip_cn_ipv4_file="$SCRIPT_DIR/config/cn.txt"
if [ -f "$geoip_cn_file" ]; then
echo ""
echo "🔄 正在处理 CN IP 数据..."
filter_ipv4_only "$geoip_cn_file" "$geoip_cn_ipv4_file"
if [ $? -eq 0 ]; then
echo "✅ CN IPv4 地址列表已生成: $geoip_cn_ipv4_file"
echo " 该文件可直接用于 MosDNS 配置中的 geoip_cn"
else
echo "❌ 处理 CN IP 数据失败"
fi
else
echo "⚠️ 警告: 未找到 $geoip_cn_file 文件"
fi
echo ""
echo "解包完成"
}
# 主流程
echo "开始处理..."
download_geodata
download_v2dat
unpack_geodata
echo ""
echo "🎉 所有操作完成!"
echo ""
echo "📁 生成的文件:"
echo " ├── geo/geoip.dat (原始 geoip 数据)"
echo " ├── geo/geosite.dat (原始 geosite 数据)"
echo " ├── geoip/geoip_cn.txt (解包的 CN IP 数据,包含 IPv6)"
echo " ├── config/cn.txt (🆕 过滤后的 CN IPv4 数据)"
echo " ├── config/CN-ip-cidr.txt (备用 CN IP 数据)"
echo " └── geosite/ (解包的域名数据)"
echo ""
echo "💡 使用建议:"
echo " - MosDNS 配置中使用: config/cn.txt (仅 IPv4)"
echo " - 如需 IPv6 支持,使用: geoip/geoip_cn.txt"
echo ""

8
web-ui/.editorconfig Normal file
View File

@ -0,0 +1,8 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100

1
web-ui/.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
* text=auto eol=lf

30
web-ui/.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo

6
web-ui/.prettierrc.json Normal file
View File

@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}

48
web-ui/README.md Normal file
View File

@ -0,0 +1,48 @@
# web-ui
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Recommended Browser Setup
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
- Firefox:
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Type-Check, Compile and Minify for Production
```sh
npm run build
```
### Lint with [ESLint](https://eslint.org/)
```sh
npm run lint
```

1
web-ui/env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

22
web-ui/eslint.config.ts Normal file
View File

@ -0,0 +1,22 @@
import { globalIgnores } from 'eslint/config'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import pluginVue from 'eslint-plugin-vue'
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
// import { configureVueProject } from '@vue/eslint-config-typescript'
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
export default defineConfigWithVueTs(
{
name: 'app/files-to-lint',
files: ['**/*.{ts,mts,tsx,vue}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
skipFormatting,
)

13
web-ui/index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

5736
web-ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

43
web-ui/package.json Normal file
View File

@ -0,0 +1,43 @@
{
"name": "web-ui",
"version": "0.0.0",
"private": true,
"type": "module",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "eslint . --fix",
"format": "prettier --write src/"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.12.2",
"element-plus": "^2.11.4",
"pinia": "^3.0.3",
"vue": "^3.5.22",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@tsconfig/node22": "^22.0.2",
"@types/node": "^22.18.6",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.6.0",
"@vue/tsconfig": "^0.8.1",
"eslint": "^9.33.0",
"eslint-plugin-vue": "~10.4.0",
"jiti": "^2.5.1",
"npm-run-all2": "^8.0.4",
"prettier": "3.6.2",
"typescript": "~5.9.0",
"vite": "^7.1.7",
"vite-plugin-vue-devtools": "^8.0.2",
"vue-tsc": "^3.1.0"
}
}

BIN
web-ui/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

145
web-ui/src/App.vue Normal file
View File

@ -0,0 +1,145 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useServerStore } from '@/stores/server'
const router = useRouter()
const route = useRoute()
const serverStore = useServerStore()
const activeIndex = ref('/')
//
const menuItems = [
{ path: '/', icon: '📊', title: '仪表板' },
{ path: '/mikrotik', icon: '🔧', title: 'MikroTik 管理' },
{ path: '/domains', icon: '📝', title: '域名文件' },
]
const handleSelect = (key: string) => {
router.push(key)
}
//
router.afterEach((to) => {
activeIndex.value = to.path
})
</script>
<template>
<div class="app-container">
<!-- 头部 -->
<el-header class="app-header">
<div class="header-content">
<div class="logo">
<span class="logo-icon">🌐</span>
<span class="logo-text">MosDNS 管理面板</span>
</div>
<div class="header-info">
<el-tag :type="serverStore.isOnline ? 'success' : 'danger'" effect="plain">
{{ serverStore.isOnline ? '在线' : '离线' }}
</el-tag>
<el-tag type="info" effect="plain">
{{ serverStore.serverInfo?.version || 'v5.0.0' }}
</el-tag>
</div>
</div>
</el-header>
<!-- 导航菜单 -->
<el-menu
:default-active="activeIndex"
mode="horizontal"
@select="handleSelect"
class="app-menu"
>
<el-menu-item v-for="item in menuItems" :key="item.path" :index="item.path">
<span>{{ item.icon }}</span>
<span style="margin-left: 8px">{{ item.title }}</span>
</el-menu-item>
</el-menu>
<!-- 主要内容区域 -->
<el-main class="app-main">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</el-main>
</div>
</template>
<style scoped>
.app-container {
min-height: 100vh;
display: flex;
flex-direction: column;
background-color: #f0f2f5;
}
.app-header {
background: #fff;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
border-bottom: 1px solid #dcdfe6;
padding: 0;
height: 64px !important;
line-height: 64px;
}
.header-content {
width: 100%;
padding: 0 24px;
display: flex;
justify-content: space-between;
align-items: center;
height: 100%;
}
.logo {
display: flex;
align-items: center;
gap: 8px;
font-size: 20px;
font-weight: 600;
color: #409eff;
}
.logo-icon {
font-size: 24px;
}
.header-info {
display: flex;
align-items: center;
gap: 12px;
}
.app-menu {
border-bottom: 2px solid #e4e7ed;
background: #fff;
display: flex;
justify-content: center;
}
.app-menu :deep(.el-menu-item) {
font-weight: 500;
}
.app-main {
flex: 1;
width: 100%;
padding: 20px 24px;
}
/* 路由过渡动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

8
web-ui/src/api/cache.ts Normal file
View File

@ -0,0 +1,8 @@
// 缓存相关 API
import http from './http'
export const cacheApi = {
// 清空缓存
flush: () => http.post<any, { success: boolean; message: string }>('/cache/flush'),
}

29
web-ui/src/api/domain.ts Normal file
View File

@ -0,0 +1,29 @@
// 域名文件相关 API
import http from './http'
export interface DomainFile {
name: string
path: string
size: number
line_count: number
last_modified: string
}
export const domainApi = {
// 获取域名文件列表
list: () => http.get<any, { success: boolean; data: DomainFile[]; message?: string }>('/domain-files'),
// 获取域名文件内容
get: (filename: string) =>
http.get<any, { success: boolean; data: { content: string } }>(
`/domain-files/${encodeURIComponent(filename)}`
),
// 更新域名文件内容
update: (filename: string, content: string) =>
http.put<any, { success: boolean; message: string }>(
`/domain-files/${encodeURIComponent(filename)}`,
{ content }
),
}

47
web-ui/src/api/http.ts Normal file
View File

@ -0,0 +1,47 @@
// HTTP 客户端封装
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios'
import { ElMessage } from 'element-plus'
// 创建 axios 实例
const http: AxiosInstance = axios.create({
baseURL: '/api',
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
})
// 请求拦截器
http.interceptors.request.use(
(config) => {
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器
http.interceptors.response.use(
(response: AxiosResponse) => {
const res = response.data
// 如果响应包含 success 字段
if (res.hasOwnProperty('success')) {
if (!res.success) {
ElMessage.error(res.message || '请求失败')
return Promise.reject(new Error(res.message || '请求失败'))
}
}
return res
},
(error) => {
const message = error.response?.data?.message || error.message || '网络错误'
ElMessage.error(message)
return Promise.reject(error)
}
)
export default http

View File

@ -0,0 +1,41 @@
// MikroTik 配置相关 API
import http from './http'
export interface MikroTikConfig {
tag: string
type: string
args: {
domain_files: string[]
host: string
port: number
username: string
password: string
use_tls: boolean
timeout: number
address_list4: string
mask4: number
comment: string
timeout_addr: number
cache_ttl: number
verify_add: boolean
add_all_ips: boolean
max_ips: number
}
}
export const mikrotikApi = {
// 获取所有 MikroTik 配置
list: () => http.get<any, { success: boolean; data: MikroTikConfig[] }>('/mikrotik/list'),
// 添加 MikroTik 配置
add: (data: MikroTikConfig) =>
http.post<any, { success: boolean; message: string; data: MikroTikConfig }>(
'/mikrotik/add',
data
),
// 删除 MikroTik 配置
delete: (tag: string) =>
http.delete<any, { success: boolean; message: string }>(`/mikrotik/${encodeURIComponent(tag)}`),
}

47
web-ui/src/api/server.ts Normal file
View File

@ -0,0 +1,47 @@
// 服务器信息相关 API
import http from './http'
export interface ServerInfo {
name: string
version: string
start_time: string
uptime: string
uptime_seconds: number
status: string
config_file: string
working_dir: string
plugin_count: number
api_address: string
dns_ports: string[]
}
export interface ServerStatus {
status: string
totalQueries?: number
cacheHitRate?: number
avgResponseTime?: number
}
export interface StatsData {
totalQueries: number
successfulQueries: number
failedQueries: number
cacheHits: number
cacheMisses: number
avgResponseTime: number
}
export const serverApi = {
// 获取服务器信息
getInfo: () => http.get<any, { success: boolean; data: ServerInfo }>('/server/info'),
// 获取服务器状态
getStatus: () => http.get<any, { success: boolean; data: ServerStatus }>('/server/status'),
// 获取详细统计
getStats: () => http.get<any, { success: boolean; data: StatsData }>('/stats/detailed'),
// 重启服务
restart: () => http.post<any, { success: boolean; message: string }>('/system/restart'),
}

View File

@ -0,0 +1,86 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

After

Width:  |  Height:  |  Size: 276 B

View File

@ -0,0 +1,39 @@
@import './base.css';
/* 全局样式重置 - 支持全屏布局 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow-x: hidden;
}
#app {
width: 100%;
min-height: 100vh;
margin: 0;
padding: 0;
font-weight: normal;
}
a,
.green {
text-decoration: none;
color: hsla(160, 100%, 37%, 1);
transition: 0.4s;
padding: 3px;
}
@media (hover: hover) {
a:hover {
background-color: hsla(160, 100%, 37%, 0.2);
}
}

View File

@ -0,0 +1,41 @@
<script setup lang="ts">
defineProps<{
msg: string
}>()
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<h3>
Youve successfully created a project with
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>. What's next?
</h3>
</div>
</template>
<style scoped>
h1 {
font-weight: 500;
font-size: 2.6rem;
position: relative;
top: -10px;
}
h3 {
font-size: 1.2rem;
}
.greetings h1,
.greetings h3 {
text-align: center;
}
@media (min-width: 1024px) {
.greetings h1,
.greetings h3 {
text-align: left;
}
}
</style>

View File

@ -0,0 +1,94 @@
<script setup lang="ts">
import WelcomeItem from './WelcomeItem.vue'
import DocumentationIcon from './icons/IconDocumentation.vue'
import ToolingIcon from './icons/IconTooling.vue'
import EcosystemIcon from './icons/IconEcosystem.vue'
import CommunityIcon from './icons/IconCommunity.vue'
import SupportIcon from './icons/IconSupport.vue'
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
</script>
<template>
<WelcomeItem>
<template #icon>
<DocumentationIcon />
</template>
<template #heading>Documentation</template>
Vues
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
provides you with all information you need to get started.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<ToolingIcon />
</template>
<template #heading>Tooling</template>
This project is served and bundled with
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
recommended IDE setup is
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
+
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener">Vue - Official</a>. If
you need to test your components and web pages, check out
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
and
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
/
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
<br />
More instructions are available in
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
>.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<EcosystemIcon />
</template>
<template #heading>Ecosystem</template>
Get official tools and libraries for your project:
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
you need more resources, we suggest paying
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
a visit.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<CommunityIcon />
</template>
<template #heading>Community</template>
Got stuck? Ask your question on
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
(our official Discord server), or
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
>StackOverflow</a
>. You should also follow the official
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
Bluesky account or the
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
X account for latest news in the Vue world.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<SupportIcon />
</template>
<template #heading>Support Vue</template>
As an independent project, Vue relies on community backing for its sustainability. You can help
us by
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
</WelcomeItem>
</template>

View File

@ -0,0 +1,87 @@
<template>
<div class="item">
<i>
<slot name="icon"></slot>
</i>
<div class="details">
<h3>
<slot name="heading"></slot>
</h3>
<slot></slot>
</div>
</div>
</template>
<style scoped>
.item {
margin-top: 2rem;
display: flex;
position: relative;
}
.details {
flex: 1;
margin-left: 1rem;
}
i {
display: flex;
place-items: center;
place-content: center;
width: 32px;
height: 32px;
color: var(--color-text);
}
h3 {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 0.4rem;
color: var(--color-heading);
}
@media (min-width: 1024px) {
.item {
margin-top: 0;
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
}
i {
top: calc(50% - 25px);
left: -26px;
position: absolute;
border: 1px solid var(--color-border);
background: var(--color-background);
border-radius: 8px;
width: 50px;
height: 50px;
}
.item:before {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
bottom: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:after {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
top: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:first-of-type:before {
display: none;
}
.item:last-of-type:after {
display: none;
}
}
</style>

View File

@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
/>
</svg>
</template>

View File

@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
<path
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
/>
</svg>
</template>

View File

@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
<path
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
/>
</svg>
</template>

View File

@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
/>
</svg>
</template>

View File

@ -0,0 +1,19 @@
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
class="iconify iconify--mdi"
width="24"
height="24"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 24 24"
>
<path
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
fill="currentColor"
></path>
</svg>
</template>

26
web-ui/src/main.ts Normal file
View File

@ -0,0 +1,26 @@
import './assets/main.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import App from './App.vue'
import router from './router'
const app = createApp(App)
// 注册所有 Element Plus 图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(createPinia())
app.use(router)
app.use(ElementPlus, {
locale: zhCn,
})
app.mount('#app')

View File

@ -0,0 +1,34 @@
import { createRouter, createWebHistory } from 'vue-router'
import DashboardView from '../views/DashboardView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'dashboard',
component: DashboardView,
meta: { title: '仪表板' },
},
{
path: '/mikrotik',
name: 'mikrotik',
component: () => import('../views/MikroTikView.vue'),
meta: { title: 'MikroTik 管理' },
},
{
path: '/domains',
name: 'domains',
component: () => import('../views/DomainFilesView.vue'),
meta: { title: '域名文件' },
},
],
})
// 路由守卫 - 设置页面标题
router.beforeEach((to, _from, next) => {
document.title = `${to.meta.title || 'MosDNS'} - MosDNS 管理面板`
next()
})
export default router

View File

@ -0,0 +1,12 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})

View File

@ -0,0 +1,43 @@
// MikroTik 配置管理
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { mikrotikApi, type MikroTikConfig } from '@/api/mikrotik'
import { ElMessage } from 'element-plus'
export const useMikrotikStore = defineStore('mikrotik', () => {
// 状态
const configs = ref<MikroTikConfig[]>([])
const loading = ref(false)
// 方法
const fetchConfigs = async () => {
loading.value = true
try {
const res = await mikrotikApi.list()
configs.value = res.data || []
} finally {
loading.value = false
}
}
const addConfig = async (config: MikroTikConfig) => {
const res = await mikrotikApi.add(config)
ElMessage.success(res.message || 'MikroTik 配置已保存,需要重启服务生效')
await fetchConfigs()
}
const deleteConfig = async (tag: string) => {
const res = await mikrotikApi.delete(tag)
ElMessage.success(res.message || '配置已删除,需要重启服务生效')
await fetchConfigs()
}
return {
configs,
loading,
fetchConfigs,
addConfig,
deleteConfig,
}
})

View File

@ -0,0 +1,64 @@
// 服务器状态管理
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { serverApi, type ServerInfo, type StatsData } from '@/api/server'
export const useServerStore = defineStore('server', () => {
// 状态
const serverInfo = ref<ServerInfo | null>(null)
const stats = ref<StatsData | null>(null)
const loading = ref(false)
// 计算属性
const isOnline = computed(() => serverInfo.value?.status === 'running')
const uptime = computed(() => {
if (!serverInfo.value?.uptime_seconds) return '0分钟'
const seconds = serverInfo.value.uptime_seconds
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const parts = []
if (days > 0) parts.push(`${days}`)
if (hours > 0) parts.push(`${hours}小时`)
if (minutes > 0 || parts.length === 0) parts.push(`${minutes}分钟`)
return parts.join(' ')
})
// 方法
const fetchServerInfo = async () => {
loading.value = true
try {
const res = await serverApi.getInfo()
serverInfo.value = res.data
} finally {
loading.value = false
}
}
const fetchStats = async () => {
try {
const res = await serverApi.getStats()
stats.value = res.data
} catch (error) {
console.error('Failed to fetch stats:', error)
}
}
const restart = async () => {
await serverApi.restart()
}
return {
serverInfo,
stats,
loading,
isOnline,
uptime,
fetchServerInfo,
fetchStats,
restart,
}
})

View File

@ -0,0 +1,15 @@
<template>
<div class="about">
<h1>This is an about page</h1>
</div>
</template>
<style>
@media (min-width: 1024px) {
.about {
min-height: 100vh;
display: flex;
align-items: center;
}
}
</style>

View File

@ -0,0 +1,202 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useServerStore } from '@/stores/server'
import { cacheApi } from '@/api/cache'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh, Delete } from '@element-plus/icons-vue'
const serverStore = useServerStore()
const handleFlushCache = async () => {
try {
await ElMessageBox.confirm('确定要清空缓存吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
await cacheApi.flush()
ElMessage.success('缓存清空成功')
} catch (error) {
if (error !== 'cancel') {
console.error(error)
}
}
}
const handleRestart = async () => {
try {
await ElMessageBox.confirm('确定要重启服务吗?服务将在 3 秒后重启。', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
await serverStore.restart()
ElMessage.success('重启请求已发送')
} catch (error) {
if (error !== 'cancel') {
console.error(error)
}
}
}
const refreshData = async () => {
await serverStore.fetchServerInfo()
await serverStore.fetchStats()
ElMessage.success('数据已刷新')
}
onMounted(async () => {
await serverStore.fetchServerInfo()
await serverStore.fetchStats()
})
</script>
<template>
<div class="dashboard">
<el-row :gutter="20">
<!-- 服务状态卡片 -->
<el-col :xs="24" :sm="12" :lg="8">
<el-card shadow="hover">
<template #header>
<div class="card-header">
<span>📊 服务状态</span>
</div>
</template>
<div class="stat-item">
<span class="stat-label">运行状态:</span>
<el-tag :type="serverStore.isOnline ? 'success' : 'danger'">
{{ serverStore.serverInfo?.status || '-' }}
</el-tag>
</div>
<div class="stat-item">
<span class="stat-label">运行时间:</span>
<span class="stat-value">{{ serverStore.uptime }}</span>
</div>
<div class="stat-item">
<span class="stat-label">DNS 端口:</span>
<span class="stat-value">
{{ serverStore.serverInfo?.dns_ports?.join(', ') || '未检测到' }}
</span>
</div>
<div class="stat-item">
<span class="stat-label">API 地址:</span>
<span class="stat-value">{{ serverStore.serverInfo?.api_address || '-' }}</span>
</div>
</el-card>
</el-col>
<!-- 查询统计卡片 -->
<el-col :xs="24" :sm="12" :lg="8">
<el-card shadow="hover">
<template #header>
<div class="card-header">
<span>📈 查询统计</span>
</div>
</template>
<div class="stat-item">
<span class="stat-label">总查询数:</span>
<span class="stat-value">{{ serverStore.stats?.totalQueries?.toLocaleString() || '-' }}</span>
</div>
<div class="stat-item">
<span class="stat-label">缓存命中:</span>
<span class="stat-value">{{ serverStore.stats?.cacheHits?.toLocaleString() || '-' }}</span>
</div>
<div class="stat-item">
<span class="stat-label">平均响应:</span>
<span class="stat-value">
{{ serverStore.stats?.avgResponseTime ? `${serverStore.stats.avgResponseTime}ms` : '-' }}
</span>
</div>
</el-card>
</el-col>
<!-- 快速操作卡片 -->
<el-col :xs="24" :sm="24" :lg="8">
<el-card shadow="hover">
<template #header>
<div class="card-header">
<span> 快速操作</span>
</div>
</template>
<div class="button-group">
<el-button type="primary" :icon="Refresh" @click="refreshData">刷新数据</el-button>
<el-button type="warning" :icon="Delete" @click="handleFlushCache">清空缓存</el-button>
<el-button type="danger" @click="handleRestart">重启服务</el-button>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<style scoped>
.dashboard {
width: 100%;
min-height: calc(100vh - 160px);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
font-size: 16px;
}
:deep(.el-card) {
height: 100%;
min-height: 280px;
}
:deep(.el-card__body) {
padding: 20px;
}
.stat-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 0;
border-bottom: 1px solid #f0f0f0;
}
.stat-item:last-child {
border-bottom: none;
}
.stat-label {
color: #909399;
font-size: 15px;
}
.stat-value {
color: #303133;
font-weight: 600;
font-size: 15px;
}
.button-group {
display: flex;
flex-direction: column;
gap: 16px;
}
.button-group .el-button {
width: 100%;
height: 44px;
font-size: 15px;
}
/* 响应式调整 */
@media (max-width: 992px) {
.dashboard {
min-height: auto;
}
:deep(.el-card) {
margin-bottom: 16px;
min-height: auto;
}
}
</style>

View File

@ -0,0 +1,165 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { domainApi, type DomainFile } from '@/api/domain'
import { ElMessage } from 'element-plus'
import { Refresh, Document } from '@element-plus/icons-vue'
const files = ref<DomainFile[]>([])
const loading = ref(false)
const dialogVisible = ref(false)
const currentFile = ref<{ name: string; content: string }>({ name: '', content: '' })
const editorLoading = ref(false)
//
const formatSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
}
//
const formatTime = (dateString: string): string => {
const date = new Date(dateString)
return date.toLocaleString('zh-CN')
}
//
const fetchFiles = async () => {
loading.value = true
try {
const res = await domainApi.list()
files.value = res.data || []
if (res.message) {
ElMessage.info(res.message)
}
} finally {
loading.value = false
}
}
//
const handleView = async (file: DomainFile) => {
editorLoading.value = true
dialogVisible.value = true
currentFile.value = { name: file.name, content: '' }
try {
const res = await domainApi.get(file.name)
currentFile.value.content = res.data.content
} finally {
editorLoading.value = false
}
}
//
const handleSave = async () => {
if (!currentFile.value.name) return
try {
await domainApi.update(currentFile.value.name, currentFile.value.content)
ElMessage.success('文件保存成功')
dialogVisible.value = false
fetchFiles()
} catch (error) {
console.error(error)
}
}
onMounted(() => {
fetchFiles()
})
</script>
<template>
<div class="domain-files-view">
<el-card shadow="hover">
<template #header>
<div class="card-header">
<span>📝 域名文件管理</span>
<el-button type="primary" :icon="Refresh" @click="fetchFiles">刷新</el-button>
</div>
</template>
<el-table :data="files" v-loading="loading" stripe>
<el-table-column prop="name" label="文件名" min-width="200">
<template #default="{ row }">
<el-icon><Document /></el-icon>
<span style="margin-left: 8px">{{ row.name }}</span>
</template>
</el-table-column>
<el-table-column label="大小" width="120">
<template #default="{ row }">
{{ formatSize(row.size) }}
</template>
</el-table-column>
<el-table-column label="行数" width="100">
<template #default="{ row }">
{{ row.line_count?.toLocaleString() }}
</template>
</el-table-column>
<el-table-column label="最后修改" width="180">
<template #default="{ row }">
{{ formatTime(row.last_modified) }}
</template>
</el-table-column>
<el-table-column label="路径" min-width="250">
<template #default="{ row }">
<el-tooltip :content="row.path" placement="top">
<span class="path-text">{{ row.path }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button type="primary" size="small" @click="handleView(row)">查看</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && files.length === 0" description="暂无域名文件" />
</el-card>
<!-- 文件编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="`编辑文件: ${currentFile.name}`"
width="80%"
destroy-on-close
>
<el-input
v-model="currentFile.content"
type="textarea"
:rows="20"
v-loading="editorLoading"
placeholder="文件内容..."
style="font-family: monospace"
/>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.domain-files-view {
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
}
.path-text {
color: #606266;
font-size: 13px;
font-family: monospace;
}
</style>

View File

@ -0,0 +1,9 @@
<script setup lang="ts">
import TheWelcome from '../components/TheWelcome.vue'
</script>
<template>
<main>
<TheWelcome />
</main>
</template>

View File

@ -0,0 +1,263 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useMikrotikStore } from '@/stores/mikrotik'
import type { MikroTikConfig } from '@/api/mikrotik'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh, Plus, Delete } from '@element-plus/icons-vue'
const mikrotikStore = useMikrotikStore()
//
const form = reactive({
tag: '',
host: '',
port: 9728,
username: 'admin',
password: '',
addresslist: '',
domainFile: '',
})
//
const formRef = ref()
//
const rules = {
tag: [{ required: true, message: '请输入配置标签', trigger: 'blur' }],
host: [{ required: true, message: '请输入 MikroTik 地址', trigger: 'blur' }],
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
addresslist: [{ required: true, message: '请输入地址列表名', trigger: 'blur' }],
domainFile: [{ required: true, message: '请输入域名文件路径', trigger: 'blur' }],
}
//
const handleSave = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid: boolean) => {
if (!valid) return
const config: MikroTikConfig = {
tag: form.tag,
type: 'mikrotik_addresslist',
args: {
domain_files: [form.domainFile],
host: form.host,
port: form.port,
username: form.username,
password: form.password,
use_tls: false,
timeout: 3,
address_list4: form.addresslist,
mask4: 24,
comment: `${form.addresslist}-AutoAdd`,
timeout_addr: 43200,
cache_ttl: 3600,
verify_add: false,
add_all_ips: true,
max_ips: 50,
},
}
await mikrotikStore.addConfig(config)
handleReset()
})
}
//
const handleDelete = async (tag: string) => {
try {
await ElMessageBox.confirm(`确定要删除 MikroTik 配置 "${tag}" 吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
await mikrotikStore.deleteConfig(tag)
} catch (error) {
if (error !== 'cancel') {
console.error(error)
}
}
}
//
const handleReset = () => {
formRef.value?.resetFields()
ElMessage.info('表单已清空')
}
onMounted(() => {
mikrotikStore.fetchConfigs()
})
</script>
<template>
<div class="mikrotik-view">
<!-- 配置列表 -->
<el-card shadow="hover" class="mb-20">
<template #header>
<div class="card-header">
<span>📋 已添加的 MikroTik 配置</span>
<el-button type="primary" :icon="Refresh" @click="mikrotikStore.fetchConfigs">
刷新
</el-button>
</div>
</template>
<el-table :data="mikrotikStore.configs" v-loading="mikrotikStore.loading" stripe>
<el-table-column prop="tag" label="配置标签" width="200" />
<el-table-column label="主机地址" width="150">
<template #default="{ row }">
{{ row.args.host }}
</template>
</el-table-column>
<el-table-column label="端口" width="100">
<template #default="{ row }">
{{ row.args.port }}
</template>
</el-table-column>
<el-table-column label="用户名" width="120">
<template #default="{ row }">
{{ row.args.username }}
</template>
</el-table-column>
<el-table-column label="地址列表" width="150">
<template #default="{ row }">
{{ row.args.address_list4 }}
</template>
</el-table-column>
<el-table-column label="域名文件" min-width="200">
<template #default="{ row }">
<el-tooltip :content="row.args.domain_files.join(', ')" placement="top">
<span>{{ row.args.domain_files[0] }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button type="danger" size="small" :icon="Delete" @click="handleDelete(row.tag)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!mikrotikStore.loading && mikrotikStore.configs.length === 0" description="暂无配置" />
</el-card>
<!-- 添加表单 -->
<el-card shadow="hover">
<template #header>
<div class="card-header">
<span> 添加 MikroTik 配置</span>
</div>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-width="140px">
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item label="配置标签" prop="tag">
<el-input v-model="form.tag" placeholder="例如: mikrotik_openai">
<template #append>*</template>
</el-input>
<template #help>
<span class="form-hint">唯一标识建议使用 mikrotik_ 前缀</span>
</template>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="地址列表名" prop="addresslist">
<el-input v-model="form.addresslist" placeholder="例如: OpenAI">
<template #append>*</template>
</el-input>
<template #help>
<span class="form-hint">MikroTik 中的地址列表名称</span>
</template>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item label="MikroTik 地址" prop="host">
<el-input v-model="form.host" placeholder="例如: 10.248.0.1">
<template #append>*</template>
</el-input>
<template #help>
<span class="form-hint">MikroTik 设备的 IP 地址</span>
</template>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="API 端口" prop="port">
<el-input-number v-model="form.port" :min="1" :max="65535" style="width: 100%" />
<template #help>
<span class="form-hint">默认 API 端口为 9728</span>
</template>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username">
<template #append>*</template>
</el-input>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="密码" prop="password">
<el-input v-model="form.password" type="password" show-password>
<template #append>*</template>
</el-input>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="域名文件路径" prop="domainFile">
<el-input
v-model="form.domainFile"
placeholder="例如: /usr/local/yltx-dns/config/openai.txt 或 ./mikrotik/openai.txt"
>
<template #append>*</template>
</el-input>
<template #help>
<span class="form-hint">支持绝对路径和相对路径相对于运行目录</span>
</template>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Plus" size="large" @click="handleSave">
保存配置
</el-button>
<el-button size="large" @click="handleReset">清空表单</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<style scoped>
.mikrotik-view {
padding: 20px;
}
.mb-20 {
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
}
.form-hint {
color: #909399;
font-size: 12px;
}
</style>

13
web-ui/tsconfig.app.json Normal file
View File

@ -0,0 +1,13 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"paths": {
"@/*": ["./src/*"]
}
}
}

11
web-ui/tsconfig.json Normal file
View File

@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

20
web-ui/tsconfig.node.json Normal file
View File

@ -0,0 +1,20 @@
{
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
"composite": true,
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"],
"skipLibCheck": true
}
}

37
web-ui/vite.config.ts Normal file
View File

@ -0,0 +1,37 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:5555',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
assetsDir: 'static',
rollupOptions: {
output: {
manualChunks(id) {
// 将所有 node_modules 的依赖打包到 vendor
if (id.includes('node_modules')) {
return 'vendor'
}
},
},
},
},
})

6
web_embed.go Normal file
View File

@ -0,0 +1,6 @@
package main
import "embed"
//go:embed all:web-ui/dist
var WebUIFS embed.FS