init
This commit is contained in:
parent
68457c9fbd
commit
6378ab4994
120
README.md
120
README.md
@ -1,36 +1,114 @@
|
|||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
# HY2 Panel
|
||||||
|
|
||||||
## Getting Started
|
单机自托管的 Next 16 管理面板,面向 Hysteria 2 HTTP 鉴权与多节点流量聚合场景。
|
||||||
|
|
||||||
First, run the development server:
|
功能包括:
|
||||||
|
|
||||||
|
- 单管理员本地登录,基于 SQLite 持久化会话
|
||||||
|
- hy2 用户管理:启用/禁用、重置密码、到期时间、总流量上限、稳定 `auth_id`
|
||||||
|
- 多节点管理:每节点独立鉴权 URL + token、`trafficStats` URL/secret、轮询间隔
|
||||||
|
- 公开 HTTP 鉴权接口:返回标准 `200 + { ok, id }`
|
||||||
|
- 后台轮询 `trafficStats`、聚合用户流量、记录同步错误与鉴权审计
|
||||||
|
- 对禁用、过期、超额用户调用节点 `kick` 做准实时断开
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
复制 `.env.example` 为 `.env`,至少设置:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
APP_URL=http://127.0.0.1:3000
|
||||||
# or
|
SESSION_SECRET=your-long-random-secret
|
||||||
yarn dev
|
SESSION_COOKIE_SECURE=auto
|
||||||
# or
|
ADMIN_PASSWORD=your-strong-admin-password
|
||||||
pnpm dev
|
|
||||||
# or
|
|
||||||
bun dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
说明:
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
- `ADMIN_USERNAME` / `ADMIN_PASSWORD` 是单管理员账号,启动时会同步到数据库中的唯一管理员记录
|
||||||
|
- `SESSION_COOKIE_SECURE=auto` 时会按 `APP_URL` 协议决定是否开启 `Secure` cookie;如果你是直接用 `http://IP:3000` 访问,不要强制设成 `true`
|
||||||
|
- `DATABASE_PATH` 默认是 `./data/hy2-panel.db`
|
||||||
|
- `POLLER_ENABLED=true` 时,Next `instrumentation.ts` 会在 Node 进程启动后启动轮询器
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
## Development
|
||||||
|
|
||||||
## Learn More
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
打开 `http://127.0.0.1:3000`。
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
## Build
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
```bash
|
||||||
|
npm run lint
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
## Deploy on Vercel
|
这里将生产构建固定为 `next build --webpack`。原因是 Next 16 默认 Turbopack,但在部分受限环境下会触发构建期端口绑定问题;Webpack 构建已验证通过。
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
## HY2 Node Access
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
面板里的每个节点都会生成独立鉴权 URL,例如:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
auth:
|
||||||
|
type: http
|
||||||
|
http:
|
||||||
|
url: http://127.0.0.1:3000/api/hy2/nodes/tokyo-01/auth?token=YOUR_TOKEN
|
||||||
|
insecure: false
|
||||||
|
```
|
||||||
|
|
||||||
|
根据 Hysteria 官方文档,HTTP 鉴权回调请求体是:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"addr": "123.123.123.123:44556",
|
||||||
|
"auth": "username:password",
|
||||||
|
"tx": 123456
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
面板响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"id": "immutable-auth-id"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
多节点识别依赖“不同的 URL 路径”,不是依赖 hy2 自动附带节点 ID。
|
||||||
|
|
||||||
|
## trafficStats
|
||||||
|
|
||||||
|
节点需启用:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
trafficStats:
|
||||||
|
listen: :9999
|
||||||
|
secret: some_secret
|
||||||
|
```
|
||||||
|
|
||||||
|
面板会轮询:
|
||||||
|
|
||||||
|
- `GET /traffic?clear=1`
|
||||||
|
- `GET /online`
|
||||||
|
- `GET /dump/streams`
|
||||||
|
- `POST /kick`
|
||||||
|
|
||||||
|
## systemd
|
||||||
|
|
||||||
|
已提供示例单元文件 [deploy/hy2-panel.service](/root/hy2-panel/deploy/hy2-panel.service)。
|
||||||
|
|
||||||
|
典型流程:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
sudo cp deploy/hy2-panel.service /etc/systemd/system/hy2-panel.service
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now hy2-panel
|
||||||
|
```
|
||||||
|
|
||||||
|
如果你需要对外开放,再在 Nginx/Caddy 前面反代 `127.0.0.1:3000` 即可。
|
||||||
|
|||||||
33
app/admin/admin-nav.tsx
Normal file
33
app/admin/admin-nav.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
type NavItem = {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
meta: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AdminNav({ items }: { items: NavItem[] }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="nav-list">
|
||||||
|
{items.map((item) => {
|
||||||
|
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={`nav-link ${active ? "is-active" : ""}`}
|
||||||
|
>
|
||||||
|
<span className="nav-link__label">{item.label}</span>
|
||||||
|
<span className="nav-link__meta">{item.meta}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
app/admin/audits/page.tsx
Normal file
51
app/admin/audits/page.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { formatBytes, listAudits } from "@/lib/store";
|
||||||
|
|
||||||
|
export default async function AuditsPage() {
|
||||||
|
const audits = await listAudits();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="page-stack">
|
||||||
|
<section className="hero-panel hero-panel--compact">
|
||||||
|
<div className="hero-copy">
|
||||||
|
<span className="eyebrow">AUDIT</span>
|
||||||
|
<h2>鉴权审计</h2>
|
||||||
|
<p className="page-lede">记录最近的 HTTP 鉴权回调结果、失败原因和请求来源。</p>
|
||||||
|
</div>
|
||||||
|
<div className="hero-aside">
|
||||||
|
<div className="stat-tile">
|
||||||
|
<span>最近记录</span>
|
||||||
|
<strong>{audits.length}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel-card">
|
||||||
|
<div className="stack-md">
|
||||||
|
{audits.map((audit) => (
|
||||||
|
<div key={audit.id} className="list-row list-row--card">
|
||||||
|
<div>
|
||||||
|
<div className="badge-row">
|
||||||
|
<span className={`status-pill ${audit.ok ? "status-pill--ok" : "status-pill--danger"}`}>
|
||||||
|
{audit.ok ? "允许" : "拒绝"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="muted">
|
||||||
|
{audit.node_name ?? "未知节点"} / {audit.username ?? audit.auth_id ?? "-"}
|
||||||
|
</p>
|
||||||
|
<p className="muted mono">{audit.addr ?? "-"}</p>
|
||||||
|
</div>
|
||||||
|
<div className="align-right">
|
||||||
|
<p>{audit.reason}</p>
|
||||||
|
<p className="muted">
|
||||||
|
请求速率 {audit.requested_tx != null ? formatBytes(audit.requested_tx) : "-"}
|
||||||
|
/s
|
||||||
|
</p>
|
||||||
|
<p className="muted">{audit.created_at}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
193
app/admin/dashboard/page.tsx
Normal file
193
app/admin/dashboard/page.tsx
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
import { formatBytes, getDashboardMetrics } from "@/lib/store";
|
||||||
|
|
||||||
|
function getNodePill(node: {
|
||||||
|
enabled: number;
|
||||||
|
last_error_message: string | null;
|
||||||
|
}) {
|
||||||
|
if (!node.enabled) {
|
||||||
|
return { label: "已禁用", className: "status-pill status-pill--off" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.last_error_message) {
|
||||||
|
return { label: "同步异常", className: "status-pill status-pill--danger" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { label: "运行健康", className: "status-pill status-pill--ok" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function DashboardPage() {
|
||||||
|
const data = await getDashboardMetrics();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="page-stack">
|
||||||
|
<section className="hero-panel">
|
||||||
|
<div className="hero-copy">
|
||||||
|
<span className="eyebrow">OVERVIEW</span>
|
||||||
|
<h2>仪表盘</h2>
|
||||||
|
<p className="page-lede">
|
||||||
|
汇总多节点在线、鉴权失败、同步错误和用户用量,作为当前运行态的统一入口。
|
||||||
|
</p>
|
||||||
|
<div className="badge-row">
|
||||||
|
<span className="status-pill status-pill--ok">HTTP 鉴权在线</span>
|
||||||
|
<span className="status-pill status-pill--neutral">轮询聚合已启用</span>
|
||||||
|
<span className="status-pill status-pill--warm">节点 {data.totals.enabled_nodes}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="hero-aside">
|
||||||
|
<div className="stat-tile">
|
||||||
|
<span>活动连接</span>
|
||||||
|
<strong>{data.totals.total_online_connections}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stat-tile">
|
||||||
|
<span>累计流量</span>
|
||||||
|
<strong>{formatBytes(data.totals.total_traffic)}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="metrics-grid">
|
||||||
|
<article className="metric-card metric-card--warm">
|
||||||
|
<span>总用户</span>
|
||||||
|
<strong>{data.totals.total_users}</strong>
|
||||||
|
<small>启用 {data.totals.enabled_users} / 管理员 {data.totals.admin_users}</small>
|
||||||
|
</article>
|
||||||
|
<article className="metric-card metric-card--ink">
|
||||||
|
<span>活跃用户</span>
|
||||||
|
<strong>{data.totals.active_presence_rows}</strong>
|
||||||
|
<small>在线连接 {data.totals.total_online_connections}</small>
|
||||||
|
</article>
|
||||||
|
<article className="metric-card metric-card--sage">
|
||||||
|
<span>启用节点</span>
|
||||||
|
<strong>{data.totals.enabled_nodes}</strong>
|
||||||
|
<small>轮询与 kick 生效范围</small>
|
||||||
|
</article>
|
||||||
|
<article className="metric-card metric-card--paper">
|
||||||
|
<span>累计流量</span>
|
||||||
|
<strong>{formatBytes(data.totals.total_traffic)}</strong>
|
||||||
|
<small>已聚合到 SQLite</small>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel-grid">
|
||||||
|
<article className="panel-card">
|
||||||
|
<div className="section-title">
|
||||||
|
<h3>节点健康</h3>
|
||||||
|
</div>
|
||||||
|
<div className="stack-md">
|
||||||
|
{data.nodeHealth.map((node) => (
|
||||||
|
<div key={node.id} className="list-row list-row--card">
|
||||||
|
<div>
|
||||||
|
<strong>{node.name}</strong>
|
||||||
|
<p className="muted mono">/{node.slug}</p>
|
||||||
|
</div>
|
||||||
|
<div className="align-right">
|
||||||
|
<p>
|
||||||
|
<span className={getNodePill(node).className}>
|
||||||
|
{getNodePill(node).label}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="muted">
|
||||||
|
在线 {node.last_online_users} / 流 {node.last_stream_count}
|
||||||
|
</p>
|
||||||
|
<p className="muted">
|
||||||
|
最近成功 {node.last_sync_ok_at ?? "无"}
|
||||||
|
</p>
|
||||||
|
{node.last_error_message ? (
|
||||||
|
<p className="error-text">{node.last_error_message}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="panel-card">
|
||||||
|
<div className="section-title">
|
||||||
|
<h3>用量排行</h3>
|
||||||
|
</div>
|
||||||
|
<div className="stack-md">
|
||||||
|
{data.topUsers.map((user) => (
|
||||||
|
<div key={user.auth_id} className="list-row list-row--card">
|
||||||
|
<div>
|
||||||
|
<strong>{user.username}</strong>
|
||||||
|
<p className="muted mono">{user.auth_id}</p>
|
||||||
|
</div>
|
||||||
|
<div className="align-right">
|
||||||
|
<p>{formatBytes(user.total_bytes)}</p>
|
||||||
|
<p className="muted">
|
||||||
|
TX {formatBytes(user.used_tx_bytes)} / RX {formatBytes(user.used_rx_bytes)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="panel-card">
|
||||||
|
<div className="section-title">
|
||||||
|
<h3>最近鉴权失败</h3>
|
||||||
|
</div>
|
||||||
|
<div className="stack-md">
|
||||||
|
{data.recentFailures.map((item, index) => (
|
||||||
|
<div key={`${item.created_at}-${index}`} className="list-row list-row--card">
|
||||||
|
<div>
|
||||||
|
<strong>{item.reason}</strong>
|
||||||
|
<p className="muted">
|
||||||
|
{item.node_name ?? "未知节点"} / {item.username ?? "未知用户"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="align-right">
|
||||||
|
<p className="muted mono">{item.addr ?? "-"}</p>
|
||||||
|
<p className="muted">{item.created_at}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="panel-card">
|
||||||
|
<div className="section-title">
|
||||||
|
<h3>最近流量入账</h3>
|
||||||
|
</div>
|
||||||
|
<div className="stack-md">
|
||||||
|
{data.recentUsage.map((item, index) => (
|
||||||
|
<div key={`${item.created_at}-${item.auth_id}-${index}`} className="list-row list-row--card">
|
||||||
|
<div>
|
||||||
|
<strong>{item.username ?? item.auth_id}</strong>
|
||||||
|
<p className="muted">{item.node_name ?? "未知节点"}</p>
|
||||||
|
</div>
|
||||||
|
<div className="align-right">
|
||||||
|
<p>
|
||||||
|
+{formatBytes(item.tx_bytes + item.rx_bytes)}
|
||||||
|
</p>
|
||||||
|
<p className="muted">{item.created_at}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="panel-card full-span">
|
||||||
|
<div className="section-title">
|
||||||
|
<h3>节点同步错误</h3>
|
||||||
|
</div>
|
||||||
|
<div className="stack-md">
|
||||||
|
{data.syncErrors.length === 0 ? (
|
||||||
|
<p className="muted">暂无同步错误。</p>
|
||||||
|
) : (
|
||||||
|
data.syncErrors.map((item, index) => (
|
||||||
|
<div key={`${item.created_at}-${index}`} className="list-row list-row--card">
|
||||||
|
<div>
|
||||||
|
<strong>{item.node_name}</strong>
|
||||||
|
<p className="error-text">{item.error_message ?? "unknown error"}</p>
|
||||||
|
</div>
|
||||||
|
<p className="muted">{item.created_at}</p>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
app/admin/layout.tsx
Normal file
42
app/admin/layout.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { requireAdminAccess } from "@/lib/session";
|
||||||
|
|
||||||
|
import { AdminNav } from "./admin-nav";
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ href: "/admin/dashboard", label: "仪表盘", meta: "运行态与健康度" },
|
||||||
|
{ href: "/admin/users", label: "用户管理", meta: "凭证、额度与订阅" },
|
||||||
|
{ href: "/admin/nodes", label: "节点管理", meta: "接入、鉴权与导出" },
|
||||||
|
{ href: "/admin/subscriptions", label: "订阅配置", meta: "模板与代理分组" },
|
||||||
|
{ href: "/admin/audits", label: "审计日志", meta: "鉴权失败与追踪" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default async function AdminLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
const session = await requireAdminAccess();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-shell">
|
||||||
|
<aside className="sidebar">
|
||||||
|
<div className="brand-block">
|
||||||
|
<span className="eyebrow">HY2 PANEL</span>
|
||||||
|
<h1>鹅梯控制台</h1>
|
||||||
|
<p className="muted">
|
||||||
|
管理员 {session.username} 已登录
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<AdminNav items={navItems} />
|
||||||
|
<form action="/logout" method="post">
|
||||||
|
<button className="ghost-button full-width" type="submit">
|
||||||
|
退出登录
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</aside>
|
||||||
|
<div className="content-shell">
|
||||||
|
<div className="content-frame">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
app/admin/nodes/actions.ts
Normal file
75
app/admin/nodes/actions.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
import { requireAdminAccess } from "@/lib/session";
|
||||||
|
import { createNode, deleteNode, updateNode } from "@/lib/store";
|
||||||
|
|
||||||
|
function boolFromForm(value: FormDataEntryValue | null) {
|
||||||
|
return value === "on";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createNodeAction(formData: FormData) {
|
||||||
|
await requireAdminAccess();
|
||||||
|
await createNode({
|
||||||
|
slug: String(formData.get("slug") ?? "").trim(),
|
||||||
|
name: String(formData.get("name") ?? "").trim(),
|
||||||
|
authToken: String(formData.get("auth_token") ?? "").trim() || null,
|
||||||
|
hy2Listen: String(formData.get("hy2_listen") ?? "").trim() || "",
|
||||||
|
serverHost: String(formData.get("server_host") ?? "").trim(),
|
||||||
|
serverPort: Number(formData.get("server_port") ?? 443),
|
||||||
|
clientName:
|
||||||
|
String(formData.get("client_name") ?? "").trim() ||
|
||||||
|
String(formData.get("name") ?? "").trim(),
|
||||||
|
bandwidthUpMbps: Number(formData.get("bandwidth_up_mbps") ?? 30),
|
||||||
|
bandwidthDownMbps: Number(formData.get("bandwidth_down_mbps") ?? 30),
|
||||||
|
skipCertVerify: boolFromForm(formData.get("skip_cert_verify")),
|
||||||
|
sni: String(formData.get("sni") ?? "").trim() || null,
|
||||||
|
trafficStatsUrl: String(formData.get("traffic_stats_url") ?? "").trim(),
|
||||||
|
trafficStatsListen:
|
||||||
|
String(formData.get("traffic_stats_listen") ?? "").trim() || ":9999",
|
||||||
|
trafficStatsSecret:
|
||||||
|
String(formData.get("traffic_stats_secret") ?? "").trim(),
|
||||||
|
enabled: boolFromForm(formData.get("enabled")),
|
||||||
|
pollIntervalSeconds: Number(formData.get("poll_interval_seconds") ?? 15),
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/nodes");
|
||||||
|
revalidatePath("/admin/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateNodeAction(formData: FormData) {
|
||||||
|
await requireAdminAccess();
|
||||||
|
await updateNode({
|
||||||
|
id: Number(formData.get("id")),
|
||||||
|
slug: String(formData.get("slug") ?? "").trim(),
|
||||||
|
name: String(formData.get("name") ?? "").trim(),
|
||||||
|
authToken: String(formData.get("auth_token") ?? "").trim(),
|
||||||
|
hy2Listen: String(formData.get("hy2_listen") ?? "").trim() || "",
|
||||||
|
serverHost: String(formData.get("server_host") ?? "").trim(),
|
||||||
|
serverPort: Number(formData.get("server_port") ?? 443),
|
||||||
|
clientName:
|
||||||
|
String(formData.get("client_name") ?? "").trim() ||
|
||||||
|
String(formData.get("name") ?? "").trim(),
|
||||||
|
bandwidthUpMbps: Number(formData.get("bandwidth_up_mbps") ?? 30),
|
||||||
|
bandwidthDownMbps: Number(formData.get("bandwidth_down_mbps") ?? 30),
|
||||||
|
skipCertVerify: boolFromForm(formData.get("skip_cert_verify")),
|
||||||
|
sni: String(formData.get("sni") ?? "").trim() || null,
|
||||||
|
trafficStatsUrl: String(formData.get("traffic_stats_url") ?? "").trim(),
|
||||||
|
trafficStatsListen:
|
||||||
|
String(formData.get("traffic_stats_listen") ?? "").trim() || ":9999",
|
||||||
|
trafficStatsSecret: String(formData.get("traffic_stats_secret") ?? "").trim(),
|
||||||
|
enabled: boolFromForm(formData.get("enabled")),
|
||||||
|
pollIntervalSeconds: Number(formData.get("poll_interval_seconds") ?? 15),
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/nodes");
|
||||||
|
revalidatePath("/admin/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteNodeAction(formData: FormData) {
|
||||||
|
await requireAdminAccess();
|
||||||
|
await deleteNode(Number(formData.get("id")));
|
||||||
|
revalidatePath("/admin/nodes");
|
||||||
|
revalidatePath("/admin/dashboard");
|
||||||
|
}
|
||||||
347
app/admin/nodes/page.tsx
Normal file
347
app/admin/nodes/page.tsx
Normal file
@ -0,0 +1,347 @@
|
|||||||
|
import { getRequestOrigin } from "@/lib/request-origin";
|
||||||
|
import { buildNodeHy2Config } from "@/lib/subscription";
|
||||||
|
import { buildNodeAuthUrl, buildTrafficStatsUrl, listNodes } from "@/lib/store";
|
||||||
|
|
||||||
|
import { createNodeAction, deleteNodeAction, updateNodeAction } from "./actions";
|
||||||
|
|
||||||
|
function getNodePills(node: {
|
||||||
|
enabled: number;
|
||||||
|
last_error_message: string | null;
|
||||||
|
}) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: node.enabled ? "已启用" : "已禁用",
|
||||||
|
className: `status-pill ${node.enabled ? "status-pill--ok" : "status-pill--off"}`,
|
||||||
|
},
|
||||||
|
node.last_error_message
|
||||||
|
? { label: "同步异常", className: "status-pill status-pill--danger" }
|
||||||
|
: { label: "同步正常", className: "status-pill status-pill--sage" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function NodesPage() {
|
||||||
|
const [nodes, origin] = await Promise.all([listNodes(), getRequestOrigin()]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="page-stack">
|
||||||
|
<section className="hero-panel">
|
||||||
|
<div className="hero-copy">
|
||||||
|
<span className="eyebrow">NODES</span>
|
||||||
|
<h2>节点管理</h2>
|
||||||
|
<p className="page-lede">
|
||||||
|
统一维护 hy2 节点监听参数、鉴权 URL、trafficStats 接入和客户端导出配置。
|
||||||
|
</p>
|
||||||
|
<div className="badge-row">
|
||||||
|
<span className="status-pill status-pill--neutral">URL 自动推导</span>
|
||||||
|
<span className="status-pill status-pill--warm">Secret 自动生成</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="hero-aside">
|
||||||
|
<div className="stat-tile">
|
||||||
|
<span>节点总数</span>
|
||||||
|
<strong>{nodes.length}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stat-tile">
|
||||||
|
<span>启用中</span>
|
||||||
|
<strong>{nodes.filter((node) => node.enabled).length}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel-card">
|
||||||
|
<div className="section-title">
|
||||||
|
<h3>新增节点</h3>
|
||||||
|
</div>
|
||||||
|
<form action={createNodeAction} className="form-grid">
|
||||||
|
<label className="field">
|
||||||
|
<span>Slug</span>
|
||||||
|
<input name="slug" placeholder="tokyo-01" required />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>显示名</span>
|
||||||
|
<input name="name" placeholder="Tokyo 01" required />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>客户端地址</span>
|
||||||
|
<input name="server_host" placeholder="jp1.example.com" required />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>hy2 监听地址</span>
|
||||||
|
<input name="hy2_listen" defaultValue=":443" placeholder=":443 或 0.0.0.0:443" />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>对外端口</span>
|
||||||
|
<input
|
||||||
|
name="server_port"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
defaultValue={443}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Clash 节点名</span>
|
||||||
|
<input name="client_name" placeholder="JP1 - 30M - hy2" />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>鉴权 Token</span>
|
||||||
|
<input name="auth_token" placeholder="留空自动生成" />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>上行带宽 Mbps</span>
|
||||||
|
<input name="bandwidth_up_mbps" type="number" min="1" defaultValue={30} required />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>下行带宽 Mbps</span>
|
||||||
|
<input name="bandwidth_down_mbps" type="number" min="1" defaultValue={30} required />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>SNI</span>
|
||||||
|
<input name="sni" placeholder="留空使用默认" />
|
||||||
|
</label>
|
||||||
|
<label className="checkbox">
|
||||||
|
<input name="skip_cert_verify" type="checkbox" />
|
||||||
|
<span>客户端跳过证书校验</span>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>trafficStats 监听</span>
|
||||||
|
<input name="traffic_stats_listen" defaultValue=":9999" required />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>trafficStats URL</span>
|
||||||
|
<input
|
||||||
|
name="traffic_stats_url"
|
||||||
|
placeholder="留空自动按客户端地址 + 监听端口生成"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>trafficStats Secret</span>
|
||||||
|
<input name="traffic_stats_secret" placeholder="留空自动生成" />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>轮询间隔(秒)</span>
|
||||||
|
<input
|
||||||
|
name="poll_interval_seconds"
|
||||||
|
type="number"
|
||||||
|
min="10"
|
||||||
|
max="30"
|
||||||
|
defaultValue={15}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="checkbox">
|
||||||
|
<input name="enabled" type="checkbox" defaultChecked />
|
||||||
|
<span>启用节点</span>
|
||||||
|
</label>
|
||||||
|
<button className="primary-button" type="submit">
|
||||||
|
创建节点
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="stack-lg">
|
||||||
|
{nodes.map((node) => {
|
||||||
|
const authUrl = buildNodeAuthUrl(node.slug, node.auth_token, origin);
|
||||||
|
const suggestedTrafficStatsUrl = buildTrafficStatsUrl(
|
||||||
|
node.server_host,
|
||||||
|
node.traffic_stats_listen,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article key={node.id} className="panel-card entity-card">
|
||||||
|
<div className="section-title section-title--top">
|
||||||
|
<div>
|
||||||
|
<h3>{node.name}</h3>
|
||||||
|
<p className="muted mono">/{node.slug}</p>
|
||||||
|
</div>
|
||||||
|
<div className="align-right stack-tight">
|
||||||
|
<div className="badge-row badge-row--end">
|
||||||
|
{getNodePills(node).map((pill) => (
|
||||||
|
<span key={pill.label} className={pill.className}>
|
||||||
|
{pill.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="muted">
|
||||||
|
最近同步 {node.last_sync_ok_at ?? "无"} / 错误{" "}
|
||||||
|
{node.last_error_at ?? "无"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="summary-grid">
|
||||||
|
<div className="metric-inline">
|
||||||
|
<span>hy2 监听</span>
|
||||||
|
<strong className="mono">{node.hy2_listen}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="metric-inline">
|
||||||
|
<span>对外地址</span>
|
||||||
|
<strong className="mono">{node.server_host}:{node.server_port}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="metric-inline">
|
||||||
|
<span>trafficStats</span>
|
||||||
|
<strong className="mono break-all">{node.traffic_stats_url}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action={updateNodeAction} className="form-grid compact">
|
||||||
|
<input name="id" type="hidden" value={node.id} />
|
||||||
|
<label className="field">
|
||||||
|
<span>Slug</span>
|
||||||
|
<input name="slug" defaultValue={node.slug} required />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>显示名</span>
|
||||||
|
<input name="name" defaultValue={node.name} required />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>客户端地址</span>
|
||||||
|
<input name="server_host" defaultValue={node.server_host} required />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>hy2 监听地址</span>
|
||||||
|
<input
|
||||||
|
name="hy2_listen"
|
||||||
|
defaultValue={node.hy2_listen}
|
||||||
|
placeholder=":443 或 0.0.0.0:443"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>对外端口</span>
|
||||||
|
<input
|
||||||
|
name="server_port"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
defaultValue={node.server_port}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Clash 节点名</span>
|
||||||
|
<input name="client_name" defaultValue={node.client_name} />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>鉴权 Token</span>
|
||||||
|
<input name="auth_token" defaultValue={node.auth_token} required />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>上行带宽 Mbps</span>
|
||||||
|
<input
|
||||||
|
name="bandwidth_up_mbps"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
defaultValue={node.bandwidth_up_mbps}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>下行带宽 Mbps</span>
|
||||||
|
<input
|
||||||
|
name="bandwidth_down_mbps"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
defaultValue={node.bandwidth_down_mbps}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>SNI</span>
|
||||||
|
<input name="sni" defaultValue={node.sni ?? ""} />
|
||||||
|
</label>
|
||||||
|
<label className="checkbox">
|
||||||
|
<input
|
||||||
|
name="skip_cert_verify"
|
||||||
|
type="checkbox"
|
||||||
|
defaultChecked={Boolean(node.skip_cert_verify)}
|
||||||
|
/>
|
||||||
|
<span>客户端跳过证书校验</span>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>trafficStats 监听</span>
|
||||||
|
<input
|
||||||
|
name="traffic_stats_listen"
|
||||||
|
defaultValue={node.traffic_stats_listen}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>trafficStats URL</span>
|
||||||
|
<input
|
||||||
|
name="traffic_stats_url"
|
||||||
|
defaultValue={node.traffic_stats_url}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>trafficStats Secret</span>
|
||||||
|
<input
|
||||||
|
name="traffic_stats_secret"
|
||||||
|
defaultValue={node.traffic_stats_secret}
|
||||||
|
placeholder="留空保持当前值"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>轮询间隔(秒)</span>
|
||||||
|
<input
|
||||||
|
name="poll_interval_seconds"
|
||||||
|
type="number"
|
||||||
|
min="10"
|
||||||
|
max="30"
|
||||||
|
defaultValue={node.poll_interval_seconds}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="checkbox">
|
||||||
|
<input
|
||||||
|
name="enabled"
|
||||||
|
type="checkbox"
|
||||||
|
defaultChecked={Boolean(node.enabled)}
|
||||||
|
/>
|
||||||
|
<span>启用节点</span>
|
||||||
|
</label>
|
||||||
|
<button className="ghost-button" type="submit">
|
||||||
|
保存节点
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="instruction-block">
|
||||||
|
<strong>节点接入说明</strong>
|
||||||
|
<p className="muted">
|
||||||
|
hy2 HTTP 鉴权 URL 必须独立配置到本节点。面板通过路径识别节点来源,不依赖请求体。
|
||||||
|
</p>
|
||||||
|
<pre>
|
||||||
|
{buildNodeHy2Config(node, origin)}
|
||||||
|
</pre>
|
||||||
|
<p className="muted">
|
||||||
|
面板轮询地址:<span className="mono">{node.traffic_stats_url}</span>
|
||||||
|
</p>
|
||||||
|
<p className="muted">
|
||||||
|
自动推导地址:<span className="mono">{suggestedTrafficStatsUrl}</span>
|
||||||
|
</p>
|
||||||
|
<p className="muted">
|
||||||
|
鉴权 URL:<span className="mono">{authUrl}</span>
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
className="ghost-button inline-button"
|
||||||
|
href={`/api/admin/nodes/${node.slug}/config`}
|
||||||
|
>
|
||||||
|
下载 hy2 config.yaml
|
||||||
|
</a>
|
||||||
|
{node.last_error_message ? (
|
||||||
|
<p className="error-text">{node.last_error_message}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action={deleteNodeAction}>
|
||||||
|
<input name="id" type="hidden" value={node.id} />
|
||||||
|
<button className="danger-button" type="submit">
|
||||||
|
删除节点
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
app/admin/page.tsx
Normal file
5
app/admin/page.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function AdminIndexPage() {
|
||||||
|
redirect("/admin/dashboard");
|
||||||
|
}
|
||||||
59
app/admin/subscriptions/actions.ts
Normal file
59
app/admin/subscriptions/actions.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
import { createStableId } from "@/lib/password";
|
||||||
|
import { requireAdminAccess } from "@/lib/session";
|
||||||
|
import {
|
||||||
|
getSubscriptionSettings,
|
||||||
|
saveProxyGroups,
|
||||||
|
saveSubscriptionSettings,
|
||||||
|
} from "@/lib/store";
|
||||||
|
|
||||||
|
export async function saveSubscriptionSettingsAction(formData: FormData) {
|
||||||
|
await requireAdminAccess();
|
||||||
|
|
||||||
|
await saveSubscriptionSettings({
|
||||||
|
template: String(formData.get("template") ?? ""),
|
||||||
|
extraProxyGroups: String(formData.get("extra_proxy_groups") ?? ""),
|
||||||
|
profileName: String(formData.get("profile_name") ?? "").trim() || "FeieProxy",
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/subscriptions");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveProxyGroupAction(formData: FormData) {
|
||||||
|
await requireAdminAccess();
|
||||||
|
|
||||||
|
const settings = await getSubscriptionSettings();
|
||||||
|
const id = String(formData.get("id") ?? "").trim() || createStableId();
|
||||||
|
const name = String(formData.get("name") ?? "").trim();
|
||||||
|
const memberSlugs = formData
|
||||||
|
.getAll("member_slugs")
|
||||||
|
.map((value) => String(value))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxyGroups = settings.proxyGroups.filter((group) => group.id !== id);
|
||||||
|
proxyGroups.push({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
type: "select",
|
||||||
|
memberSlugs,
|
||||||
|
});
|
||||||
|
|
||||||
|
await saveProxyGroups(proxyGroups);
|
||||||
|
revalidatePath("/admin/subscriptions");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteProxyGroupAction(formData: FormData) {
|
||||||
|
await requireAdminAccess();
|
||||||
|
|
||||||
|
const settings = await getSubscriptionSettings();
|
||||||
|
const id = String(formData.get("id") ?? "").trim();
|
||||||
|
await saveProxyGroups(settings.proxyGroups.filter((group) => group.id !== id));
|
||||||
|
revalidatePath("/admin/subscriptions");
|
||||||
|
}
|
||||||
160
app/admin/subscriptions/page.tsx
Normal file
160
app/admin/subscriptions/page.tsx
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
import { getRequestOrigin } from "@/lib/request-origin";
|
||||||
|
import { buildSubscriptionUrl, getDefaultClashTemplate } from "@/lib/subscription";
|
||||||
|
import {
|
||||||
|
getAdminMirrorUser,
|
||||||
|
getSubscriptionSettings,
|
||||||
|
listSubscriptionNodes,
|
||||||
|
} from "@/lib/store";
|
||||||
|
|
||||||
|
import {
|
||||||
|
deleteProxyGroupAction,
|
||||||
|
saveProxyGroupAction,
|
||||||
|
saveSubscriptionSettingsAction,
|
||||||
|
} from "./actions";
|
||||||
|
|
||||||
|
export default async function AdminSubscriptionsPage() {
|
||||||
|
const [settings, adminUser, nodes, origin] = await Promise.all([
|
||||||
|
getSubscriptionSettings(),
|
||||||
|
getAdminMirrorUser(),
|
||||||
|
listSubscriptionNodes(),
|
||||||
|
getRequestOrigin(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="page-stack">
|
||||||
|
<section className="hero-panel">
|
||||||
|
<div className="hero-copy">
|
||||||
|
<span className="eyebrow">SUBSCRIPTIONS</span>
|
||||||
|
<h2>订阅与模板</h2>
|
||||||
|
<p className="page-lede">
|
||||||
|
管理 Clash 基础模板、订阅文件名、代理分组以及管理员默认不限量订阅链接。
|
||||||
|
</p>
|
||||||
|
<div className="badge-row">
|
||||||
|
<span className="status-pill status-pill--ok">动态 proxies</span>
|
||||||
|
<span className="status-pill status-pill--ink">分组 {settings.proxyGroups.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="hero-aside">
|
||||||
|
<div className="stat-tile">
|
||||||
|
<span>可用节点</span>
|
||||||
|
<strong>{nodes.length}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stat-tile">
|
||||||
|
<span>默认主组</span>
|
||||||
|
<strong>{settings.profileName}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel-card">
|
||||||
|
<div className="section-title">
|
||||||
|
<h3>管理员订阅</h3>
|
||||||
|
</div>
|
||||||
|
{adminUser?.subscription_token ? (
|
||||||
|
<div className="instruction-block no-margin">
|
||||||
|
<p className="muted mono break-all">
|
||||||
|
{buildSubscriptionUrl(adminUser.subscription_token, origin)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="muted">管理员订阅链接暂不可用。</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel-card">
|
||||||
|
<div className="section-title">
|
||||||
|
<h3>Proxy Groups</h3>
|
||||||
|
</div>
|
||||||
|
<div className="stack-lg">
|
||||||
|
{settings.proxyGroups.map((group) => (
|
||||||
|
<article key={group.id} className="instruction-block">
|
||||||
|
<form action={saveProxyGroupAction} className="stack-md">
|
||||||
|
<input type="hidden" name="id" value={group.id} />
|
||||||
|
<label className="field">
|
||||||
|
<span>组名</span>
|
||||||
|
<input name="name" defaultValue={group.name} required />
|
||||||
|
</label>
|
||||||
|
<div className="checkbox-grid">
|
||||||
|
{nodes.map((node) => (
|
||||||
|
<label key={`${group.id}-${node.slug}`} className="checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="member_slugs"
|
||||||
|
value={node.slug}
|
||||||
|
defaultChecked={group.memberSlugs.includes(node.slug)}
|
||||||
|
/>
|
||||||
|
<span>{node.client_name || node.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="action-row">
|
||||||
|
<button className="ghost-button" type="submit">
|
||||||
|
保存分组
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<form action={deleteProxyGroupAction}>
|
||||||
|
<input type="hidden" name="id" value={group.id} />
|
||||||
|
<button className="danger-button" type="submit">
|
||||||
|
删除分组
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<article className="instruction-block">
|
||||||
|
<form action={saveProxyGroupAction} className="stack-md">
|
||||||
|
<label className="field">
|
||||||
|
<span>新建分组名</span>
|
||||||
|
<input name="name" placeholder="ChatGPT" required />
|
||||||
|
</label>
|
||||||
|
<div className="checkbox-grid">
|
||||||
|
{nodes.map((node) => (
|
||||||
|
<label key={`new-${node.slug}`} className="checkbox">
|
||||||
|
<input type="checkbox" name="member_slugs" value={node.slug} />
|
||||||
|
<span>{node.client_name || node.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button className="primary-button" type="submit">
|
||||||
|
新建分组
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel-card">
|
||||||
|
<div className="section-title">
|
||||||
|
<h3>Clash 模板</h3>
|
||||||
|
</div>
|
||||||
|
<form action={saveSubscriptionSettingsAction} className="stack-lg">
|
||||||
|
<label className="field">
|
||||||
|
<span>订阅文件名 / 默认主组名</span>
|
||||||
|
<input name="profile_name" defaultValue={settings.profileName} required />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>基础模板</span>
|
||||||
|
<textarea
|
||||||
|
name="template"
|
||||||
|
rows={18}
|
||||||
|
defaultValue={settings.template || getDefaultClashTemplate()}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>附加 proxy-groups YAML</span>
|
||||||
|
<textarea
|
||||||
|
name="extra_proxy_groups"
|
||||||
|
rows={12}
|
||||||
|
defaultValue={settings.extraProxyGroups}
|
||||||
|
placeholder={` - name: ChatGPT\n type: select\n proxies: ["US 01 - hy2", "JP 01 - hy2"]`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button className="primary-button" type="submit">
|
||||||
|
保存订阅模板
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
app/admin/users/actions.ts
Normal file
71
app/admin/users/actions.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
import { requireAdminAccess } from "@/lib/session";
|
||||||
|
import {
|
||||||
|
createUser,
|
||||||
|
deleteUser,
|
||||||
|
resetUserPassword,
|
||||||
|
toBytesFromGiB,
|
||||||
|
updateUser,
|
||||||
|
} from "@/lib/store";
|
||||||
|
|
||||||
|
function boolFromForm(value: FormDataEntryValue | null) {
|
||||||
|
return value === "on";
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalDateTime(value: FormDataEntryValue | null) {
|
||||||
|
const raw = String(value ?? "").trim();
|
||||||
|
return raw ? new Date(raw).toISOString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUserAction(formData: FormData) {
|
||||||
|
await requireAdminAccess();
|
||||||
|
await createUser({
|
||||||
|
username: String(formData.get("username") ?? "").trim(),
|
||||||
|
password: String(formData.get("password") ?? ""),
|
||||||
|
enabled: boolFromForm(formData.get("enabled")),
|
||||||
|
isAdmin: false,
|
||||||
|
expiresAt: optionalDateTime(formData.get("expires_at")),
|
||||||
|
trafficLimitBytes: toBytesFromGiB(String(formData.get("traffic_limit_gib") ?? "")),
|
||||||
|
notes: String(formData.get("notes") ?? "").trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/users");
|
||||||
|
revalidatePath("/admin/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUserAction(formData: FormData) {
|
||||||
|
await requireAdminAccess();
|
||||||
|
await updateUser({
|
||||||
|
id: Number(formData.get("id")),
|
||||||
|
username: String(formData.get("username") ?? "").trim(),
|
||||||
|
enabled: boolFromForm(formData.get("enabled")),
|
||||||
|
subscriptionLabel:
|
||||||
|
String(formData.get("subscription_label") ?? "").trim() ||
|
||||||
|
String(formData.get("username") ?? "").trim(),
|
||||||
|
expiresAt: optionalDateTime(formData.get("expires_at")),
|
||||||
|
trafficLimitBytes: toBytesFromGiB(String(formData.get("traffic_limit_gib") ?? "")),
|
||||||
|
notes: String(formData.get("notes") ?? "").trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/users");
|
||||||
|
revalidatePath("/admin/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUserAction(formData: FormData) {
|
||||||
|
await requireAdminAccess();
|
||||||
|
await deleteUser(Number(formData.get("id")));
|
||||||
|
revalidatePath("/admin/users");
|
||||||
|
revalidatePath("/admin/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetUserPasswordAction(formData: FormData) {
|
||||||
|
await requireAdminAccess();
|
||||||
|
const password = String(formData.get("password") ?? "");
|
||||||
|
if (!password) return;
|
||||||
|
|
||||||
|
await resetUserPassword(Number(formData.get("id")), password);
|
||||||
|
revalidatePath("/admin/users");
|
||||||
|
}
|
||||||
220
app/admin/users/page.tsx
Normal file
220
app/admin/users/page.tsx
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
import { getRequestOrigin } from "@/lib/request-origin";
|
||||||
|
import {
|
||||||
|
buildSubscriptionUrl,
|
||||||
|
describeRemainingTraffic,
|
||||||
|
} from "@/lib/subscription";
|
||||||
|
import {
|
||||||
|
formatBytes,
|
||||||
|
listUsers,
|
||||||
|
toDatetimeLocalValue,
|
||||||
|
} from "@/lib/store";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createUserAction,
|
||||||
|
deleteUserAction,
|
||||||
|
resetUserPasswordAction,
|
||||||
|
updateUserAction,
|
||||||
|
} from "./actions";
|
||||||
|
|
||||||
|
function getUserPills(user: {
|
||||||
|
enabled: number;
|
||||||
|
is_admin: number;
|
||||||
|
expires_at: string | null;
|
||||||
|
}) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: user.enabled ? "已启用" : "已禁用",
|
||||||
|
className: `status-pill ${user.enabled ? "status-pill--ok" : "status-pill--danger"}`,
|
||||||
|
},
|
||||||
|
user.is_admin
|
||||||
|
? { label: "管理员", className: "status-pill status-pill--ink" }
|
||||||
|
: null,
|
||||||
|
user.expires_at
|
||||||
|
? { label: user.expires_at, className: "status-pill status-pill--neutral" }
|
||||||
|
: { label: "长期有效", className: "status-pill status-pill--warm" },
|
||||||
|
].filter((pill): pill is { label: string; className: string } => Boolean(pill));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function UsersPage() {
|
||||||
|
const [users, origin] = await Promise.all([listUsers(), getRequestOrigin()]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="page-stack">
|
||||||
|
<section className="hero-panel">
|
||||||
|
<div className="hero-copy">
|
||||||
|
<span className="eyebrow">USERS</span>
|
||||||
|
<h2>用户管理</h2>
|
||||||
|
<p className="page-lede">
|
||||||
|
管理用户名:密码、`auth_id`、到期时间、额度、订阅名和用户自助链接。
|
||||||
|
</p>
|
||||||
|
<div className="badge-row">
|
||||||
|
<span className="status-pill status-pill--ok">自助登录</span>
|
||||||
|
<span className="status-pill status-pill--neutral">订阅链接按用户独立生成</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="hero-aside">
|
||||||
|
<div className="stat-tile">
|
||||||
|
<span>用户数</span>
|
||||||
|
<strong>{users.length}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stat-tile">
|
||||||
|
<span>启用中</span>
|
||||||
|
<strong>{users.filter((user) => user.enabled).length}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel-card">
|
||||||
|
<div className="section-title">
|
||||||
|
<h3>新建用户</h3>
|
||||||
|
</div>
|
||||||
|
<form action={createUserAction} className="form-grid">
|
||||||
|
<label className="field">
|
||||||
|
<span>用户名</span>
|
||||||
|
<input name="username" required />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>密码</span>
|
||||||
|
<input name="password" type="password" required />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>到期时间</span>
|
||||||
|
<input name="expires_at" type="datetime-local" />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>总流量上限 GiB</span>
|
||||||
|
<input name="traffic_limit_gib" type="number" min="0" step="0.1" />
|
||||||
|
</label>
|
||||||
|
<label className="field full-span">
|
||||||
|
<span>备注</span>
|
||||||
|
<textarea name="notes" rows={3} />
|
||||||
|
</label>
|
||||||
|
<label className="checkbox">
|
||||||
|
<input name="enabled" type="checkbox" defaultChecked />
|
||||||
|
<span>立即启用</span>
|
||||||
|
</label>
|
||||||
|
<button className="primary-button" type="submit">
|
||||||
|
创建用户
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="stack-lg">
|
||||||
|
{users.map((user) => (
|
||||||
|
<article key={user.id} className="panel-card entity-card">
|
||||||
|
<div className="section-title section-title--top">
|
||||||
|
<div>
|
||||||
|
<h3>{user.username}</h3>
|
||||||
|
<p className="muted mono">auth_id: {user.auth_id}</p>
|
||||||
|
</div>
|
||||||
|
<div className="align-right stack-tight">
|
||||||
|
<div className="badge-row badge-row--end">
|
||||||
|
{getUserPills(user).map((pill) => (
|
||||||
|
<span key={pill.label} className={pill.className}>
|
||||||
|
{pill.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="muted">
|
||||||
|
累计 {formatBytes(user.used_tx_bytes + user.used_rx_bytes)} / 在线{" "}
|
||||||
|
{user.online_connections}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="summary-grid">
|
||||||
|
<div className="metric-inline">
|
||||||
|
<span>总用量</span>
|
||||||
|
<strong>{formatBytes(user.used_tx_bytes + user.used_rx_bytes)}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="metric-inline">
|
||||||
|
<span>剩余额度</span>
|
||||||
|
<strong>{describeRemainingTraffic(user)}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="metric-inline">
|
||||||
|
<span>在线连接</span>
|
||||||
|
<strong>{user.online_connections}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form action={updateUserAction} className="form-grid compact">
|
||||||
|
<input name="id" type="hidden" value={user.id} />
|
||||||
|
<label className="field">
|
||||||
|
<span>用户名</span>
|
||||||
|
<input name="username" defaultValue={user.username} required />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>到期时间</span>
|
||||||
|
<input
|
||||||
|
name="expires_at"
|
||||||
|
type="datetime-local"
|
||||||
|
defaultValue={toDatetimeLocalValue(user.expires_at)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>总流量上限 GiB</span>
|
||||||
|
<input
|
||||||
|
name="traffic_limit_gib"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.1"
|
||||||
|
defaultValue={
|
||||||
|
user.traffic_limit_bytes && !user.is_admin
|
||||||
|
? (user.traffic_limit_bytes / 1024 / 1024 / 1024).toFixed(2)
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
disabled={Boolean(user.is_admin)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>订阅名称</span>
|
||||||
|
<input
|
||||||
|
name="subscription_label"
|
||||||
|
defaultValue={user.subscription_label || user.username}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field full-span">
|
||||||
|
<span>备注</span>
|
||||||
|
<textarea name="notes" rows={3} defaultValue={user.notes} />
|
||||||
|
</label>
|
||||||
|
<label className="checkbox">
|
||||||
|
<input
|
||||||
|
name="enabled"
|
||||||
|
type="checkbox"
|
||||||
|
defaultChecked={Boolean(user.enabled)}
|
||||||
|
/>
|
||||||
|
<span>启用用户</span>
|
||||||
|
</label>
|
||||||
|
<button className="ghost-button" type="submit">
|
||||||
|
保存修改
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div className="instruction-block">
|
||||||
|
<strong>订阅链接</strong>
|
||||||
|
<p className="muted mono break-all">
|
||||||
|
{user.subscription_token
|
||||||
|
? buildSubscriptionUrl(user.subscription_token, origin)
|
||||||
|
: "暂不可用"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="action-row">
|
||||||
|
<form action={resetUserPasswordAction} className="inline-form">
|
||||||
|
<input name="id" type="hidden" value={user.id} />
|
||||||
|
<input name="password" type="password" placeholder="新密码" required />
|
||||||
|
<button className="ghost-button" type="submit">
|
||||||
|
重置密码
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{user.is_admin ? null : (
|
||||||
|
<form action={deleteUserAction}>
|
||||||
|
<input name="id" type="hidden" value={user.id} />
|
||||||
|
<button className="danger-button" type="submit">
|
||||||
|
删除用户
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
app/api/admin/nodes/[slug]/config/route.ts
Normal file
28
app/api/admin/nodes/[slug]/config/route.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { getOriginFromRequest } from "@/lib/request-origin";
|
||||||
|
import { requireAdminAccess } from "@/lib/session";
|
||||||
|
import { buildNodeHy2Config } from "@/lib/subscription";
|
||||||
|
import { getNodeBySlug } from "@/lib/store";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
context: RouteContext<"/api/admin/nodes/[slug]/config">,
|
||||||
|
) {
|
||||||
|
await requireAdminAccess();
|
||||||
|
|
||||||
|
const { slug } = await context.params;
|
||||||
|
const node = await getNodeBySlug(slug);
|
||||||
|
|
||||||
|
if (!node) {
|
||||||
|
return new NextResponse("node not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NextResponse(buildNodeHy2Config(node, getOriginFromRequest(request)), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-yaml; charset=utf-8",
|
||||||
|
"Content-Disposition": `attachment; filename="${slug}.config.yaml"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
134
app/api/hy2/nodes/[slug]/auth/route.ts
Normal file
134
app/api/hy2/nodes/[slug]/auth/route.ts
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
import { getUserByUsername, writeAuthAudit } from "@/lib/store";
|
||||||
|
import { parseHy2Userpass, verifyNodeToken, evaluateUserForAuth } from "@/lib/hy2";
|
||||||
|
import { verifyPassword } from "@/lib/password";
|
||||||
|
|
||||||
|
type AuthRequest = {
|
||||||
|
addr?: string;
|
||||||
|
auth?: string;
|
||||||
|
tx?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function deny(id = "") {
|
||||||
|
return Response.json({ ok: false, id });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
context: RouteContext<"/api/hy2/nodes/[slug]/auth">,
|
||||||
|
) {
|
||||||
|
const { slug } = await context.params;
|
||||||
|
const requestUrl = new URL(request.url);
|
||||||
|
const token =
|
||||||
|
requestUrl.searchParams.get("token") ||
|
||||||
|
request.headers.get("authorization")?.replace(/^Bearer\s+/i, "") ||
|
||||||
|
request.headers.get("x-node-token");
|
||||||
|
|
||||||
|
const nodeResult = await verifyNodeToken(slug, token);
|
||||||
|
if (!nodeResult.ok) {
|
||||||
|
await writeAuthAudit({
|
||||||
|
nodeId: nodeResult.node?.id ?? null,
|
||||||
|
ok: false,
|
||||||
|
reason: nodeResult.reason,
|
||||||
|
});
|
||||||
|
return deny();
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = nodeResult.node;
|
||||||
|
if (!node) {
|
||||||
|
return deny();
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: AuthRequest;
|
||||||
|
|
||||||
|
try {
|
||||||
|
payload = (await request.json()) as AuthRequest;
|
||||||
|
} catch {
|
||||||
|
await writeAuthAudit({
|
||||||
|
nodeId: node.id,
|
||||||
|
addr: null,
|
||||||
|
requestedTx: null,
|
||||||
|
ok: false,
|
||||||
|
reason: "bad_json",
|
||||||
|
});
|
||||||
|
return deny();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload.auth) {
|
||||||
|
await writeAuthAudit({
|
||||||
|
nodeId: node.id,
|
||||||
|
addr: payload.addr ?? null,
|
||||||
|
requestedTx: payload.tx ?? null,
|
||||||
|
ok: false,
|
||||||
|
reason: "missing_auth",
|
||||||
|
});
|
||||||
|
return deny();
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseHy2Userpass(payload.auth);
|
||||||
|
if (!parsed) {
|
||||||
|
await writeAuthAudit({
|
||||||
|
nodeId: node.id,
|
||||||
|
addr: payload.addr ?? null,
|
||||||
|
requestedTx: payload.tx ?? null,
|
||||||
|
ok: false,
|
||||||
|
reason: "bad_auth_format",
|
||||||
|
});
|
||||||
|
return deny();
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserByUsername(parsed.username);
|
||||||
|
if (!user) {
|
||||||
|
await writeAuthAudit({
|
||||||
|
nodeId: node.id,
|
||||||
|
username: parsed.username,
|
||||||
|
addr: payload.addr ?? null,
|
||||||
|
requestedTx: payload.tx ?? null,
|
||||||
|
ok: false,
|
||||||
|
reason: "user_not_found",
|
||||||
|
});
|
||||||
|
return deny();
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordOk = await verifyPassword(parsed.password, user.password_hash);
|
||||||
|
if (!passwordOk) {
|
||||||
|
await writeAuthAudit({
|
||||||
|
nodeId: node.id,
|
||||||
|
userId: user.id,
|
||||||
|
authId: user.auth_id,
|
||||||
|
username: user.username,
|
||||||
|
addr: payload.addr ?? null,
|
||||||
|
requestedTx: payload.tx ?? null,
|
||||||
|
ok: false,
|
||||||
|
reason: "password_invalid",
|
||||||
|
});
|
||||||
|
return deny();
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = evaluateUserForAuth(user);
|
||||||
|
if (!access.ok) {
|
||||||
|
await writeAuthAudit({
|
||||||
|
nodeId: node.id,
|
||||||
|
userId: user.id,
|
||||||
|
authId: user.auth_id,
|
||||||
|
username: user.username,
|
||||||
|
addr: payload.addr ?? null,
|
||||||
|
requestedTx: payload.tx ?? null,
|
||||||
|
ok: false,
|
||||||
|
reason: access.reason,
|
||||||
|
});
|
||||||
|
return deny();
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeAuthAudit({
|
||||||
|
nodeId: node.id,
|
||||||
|
userId: user.id,
|
||||||
|
authId: user.auth_id,
|
||||||
|
username: user.username,
|
||||||
|
addr: payload.addr ?? null,
|
||||||
|
requestedTx: payload.tx ?? null,
|
||||||
|
ok: true,
|
||||||
|
reason: "ok",
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json({ ok: true, id: user.auth_id });
|
||||||
|
}
|
||||||
38
app/api/subscription/[token]/clash/route.ts
Normal file
38
app/api/subscription/[token]/clash/route.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { buildSubscriptionConfig, buildSubscriptionHeaders } from "@/lib/subscription";
|
||||||
|
import {
|
||||||
|
getSubscriptionSettings,
|
||||||
|
getUserBySubscriptionToken,
|
||||||
|
listSubscriptionNodes,
|
||||||
|
} from "@/lib/store";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: Request,
|
||||||
|
context: RouteContext<"/api/subscription/[token]/clash">,
|
||||||
|
) {
|
||||||
|
const { token } = await context.params;
|
||||||
|
const user = await getUserBySubscriptionToken(token);
|
||||||
|
|
||||||
|
if (!user || !user.enabled) {
|
||||||
|
return new Response("subscription not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [nodes, settings] = await Promise.all([
|
||||||
|
listSubscriptionNodes(),
|
||||||
|
getSubscriptionSettings(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let body: string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
body = buildSubscriptionConfig({ user, nodes, settings });
|
||||||
|
} catch {
|
||||||
|
return new Response("subscription secret unavailable", { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileName = user.subscription_label || settings.profileName || user.username;
|
||||||
|
|
||||||
|
return new Response(body, {
|
||||||
|
status: 200,
|
||||||
|
headers: buildSubscriptionHeaders(user, profileName),
|
||||||
|
});
|
||||||
|
}
|
||||||
571
app/config/config.yaml
Normal file
571
app/config/config.yaml
Normal file
@ -0,0 +1,571 @@
|
|||||||
|
mixed-port: 7890
|
||||||
|
allow-lan: true
|
||||||
|
bind-address: '*'
|
||||||
|
mode: rule
|
||||||
|
log-level: info
|
||||||
|
external-controller: '127.0.0.1:9090'
|
||||||
|
dns:
|
||||||
|
enable: true
|
||||||
|
listen: '0.0.0.0:53'
|
||||||
|
ipv6: false
|
||||||
|
respect-rules: true
|
||||||
|
default-nameserver: [223.5.5.5, 223.6.6.6]
|
||||||
|
proxy-server-nameserver: ['https://223.5.5.5/dns-query', 'https://223.6.6.6/dns-query', 'tls://dns.alidns.com', 'tls://223.5.5.5:853', 'tls://223.6.6.6:853']
|
||||||
|
enhanced-mode: fake-ip
|
||||||
|
fake-ip-range: 198.18.0.1/16
|
||||||
|
fake-ip-filter: [+.lan, +.local, +.msftconnecttest.com, +.msftncsi.com, localhost.ptlogin2.qq.com, localhost.sec.qq.com, localhost.work.weixin.qq.com, 'geosite:private']
|
||||||
|
use-hosts: true
|
||||||
|
nameserver: ['https://223.5.5.5/dns-query', 'https://223.6.6.6/dns-query', 'tls://dns.alidns.com', 'tls://223.5.5.5:853', 'tls://223.6.6.6:853']
|
||||||
|
fallback: ['https://8.8.8.8/dns-query', 'https://8.8.4.4/dns-query', 'tls://dns.google', 'tls://8.8.8.8:853', 'tls://8.8.4.4:853']
|
||||||
|
fallback-filter: { geoip: true, geoip-code: CN, geosite: [gfw], ipcidr: [240.0.0.0/4, 0.0.0.0/32], domain: [+.google.com, +.facebook.com, +.youtube.com] }
|
||||||
|
|
||||||
|
rules:
|
||||||
|
- 'DOMAIN-KEYWORD,anthropic,ChatGPT'
|
||||||
|
- 'DOMAIN-KEYWORD,chatgpt,ChatGPT'
|
||||||
|
- 'DOMAIN-KEYWORD,oaiusercontent,ChatGPT'
|
||||||
|
- 'DOMAIN-KEYWORD,oaistatic,ChatGPT'
|
||||||
|
- 'DOMAIN-KEYWORD,claude.ai,ChatGPT'
|
||||||
|
- 'DOMAIN-KEYWORD,openai,ChatGPT'
|
||||||
|
- 'DOMAIN-KEYWORD,gemini,ChatGPT'
|
||||||
|
- 'DOMAIN-KEYWORD,aistudio,ChatGPT'
|
||||||
|
- 'DOMAIN-KEYWORD,alkalimakersuite,ChatGPT'
|
||||||
|
- 'DOMAIN-KEYWORD,x.ai,ChatGPT'
|
||||||
|
- 'DOMAIN-KEYWORD,grok,ChatGPT'
|
||||||
|
|
||||||
|
- 'IP-CIDR,154.36.184.142/8,DIRECT'
|
||||||
|
- 'IP-CIDR,154.40.44.206/8,DIRECT'
|
||||||
|
- 'DOMAIN-KEYWORD,vrchat,FeieProxy'
|
||||||
|
- 'DOMAIN-KEYWORD,zdoc.app,FeieProxy'
|
||||||
|
- 'DOMAIN-KEYWORD,happymh,FeieProxy'
|
||||||
|
- 'DOMAIN-KEYWORD,bbae,FeieProxy'
|
||||||
|
- 'DOMAIN-KEYWORD,sketchfab,FeieProxy'
|
||||||
|
- 'DOMAIN-KEYWORD,tripo3d,FeieProxy'
|
||||||
|
- 'DOMAIN,www.vitejs.net,FeieProxy'
|
||||||
|
|
||||||
|
- 'DOMAIN,laowang.vip,FeieProxy'
|
||||||
|
- 'DOMAIN,hmziyuan.com,FeieProxy'
|
||||||
|
- 'DOMAIN-KEYWORD,maa.plus,FeieProxy'
|
||||||
|
- 'DOMAIN-KEYWORD,placeholder.com,FeieProxy'
|
||||||
|
- 'DOMAIN-KEYWORD,bangumi.moe,FeieProxy'
|
||||||
|
- 'DOMAIN-KEYWORD,linux.do,FeieProxy'
|
||||||
|
- 'DOMAIN-KEYWORD,openai,FeieProxy'
|
||||||
|
- 'DOMAIN-KEYWORD,tepis.me,FeieProxy'
|
||||||
|
- 'DOMAIN-KEYWORD,bangumi.moe,FeieProxy'
|
||||||
|
- 'DOMAIN-KEYWORD,tracker,FeieProxy'
|
||||||
|
- 'DOMAIN-KEYWORD,jiuse.me,FeieProxy'
|
||||||
|
- 'DOMAIN-KEYWORD,btc620,FeieProxy'
|
||||||
|
- 'DOMAIN-KEYWORD,killcovid2021,FeieProxy'
|
||||||
|
- 'DOMAIN-KEYWORD,knit.bid,FeieProxy'
|
||||||
|
- 'DOMAIN-KEYWORD,stripe,FeieProxy'
|
||||||
|
- 'DOMAIN-KEYWORD,intercomassets,FeieProxy'
|
||||||
|
- 'DOMAIN-KEYWORD,galleryepic,FeieProxy'
|
||||||
|
- 'DOMAIN-KEYWORD,adobe,REJECT'
|
||||||
|
- "DOMAIN-KEYWORD,fanbox.cc,FeieProxy"
|
||||||
|
- "DOMAIN-KEYWORD,fantia.jp,FeieProxy"
|
||||||
|
|
||||||
|
- "DOMAIN-SUFFIX,services.googleapis.cn,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,xn--ngstr-lra8j.com,FeieProxy"
|
||||||
|
- "DOMAIN,safebrowsing.urlsec.qq.com,DIRECT"
|
||||||
|
- "DOMAIN,safebrowsing.googleapis.com,DIRECT"
|
||||||
|
- "DOMAIN,developer.apple.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,digicert.com,FeieProxy"
|
||||||
|
- "DOMAIN,ocsp.comodoca.com,FeieProxy"
|
||||||
|
- "DOMAIN,ocsp.usertrust.com,FeieProxy"
|
||||||
|
- "DOMAIN,ocsp.sectigo.com,FeieProxy"
|
||||||
|
- "DOMAIN,ocsp.verisign.net,FeieProxy"
|
||||||
|
|
||||||
|
- "DOMAIN-SUFFIX,mzstatic.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,itunes.apple.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,icloud.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,icloud-content.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,me.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,aaplimg.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,cdn20.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,cdn-apple.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,akadns.net,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,akamaiedge.net,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,edgekey.net,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,mwcloudcdn.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,mwcname.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,apple.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,apple-cloudkit.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,apple-mapkit.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,126.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,126.net,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,127.net,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,163.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,360buyimg.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,36kr.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,acfun.tv,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,air-matters.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,aixifan.com,DIRECT"
|
||||||
|
- "DOMAIN-KEYWORD,alicdn,DIRECT"
|
||||||
|
- "DOMAIN-KEYWORD,alipay,DIRECT"
|
||||||
|
- "DOMAIN-KEYWORD,taobao,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,amap.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,autonavi.com,DIRECT"
|
||||||
|
- "DOMAIN-KEYWORD,baidu,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,bdimg.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,bdstatic.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,bilibili.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,bilivideo.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,caiyunapp.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,clouddn.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,cnbeta.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,cnbetacdn.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,cootekservice.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,csdn.net,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,ctrip.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,dgtle.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,dianping.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,douban.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,doubanio.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,duokan.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,easou.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,ele.me,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,feng.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,fir.im,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,frdic.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,g-cores.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,godic.net,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,gtimg.com,DIRECT"
|
||||||
|
- "DOMAIN,cdn.hockeyapp.net,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,hongxiu.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,hxcdn.net,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,iciba.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,ifeng.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,ifengimg.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,ipip.net,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,iqiyi.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,jd.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,jianshu.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,knewone.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,le.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,lecloud.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,lemicp.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,licdn.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,luoo.net,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,meituan.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,meituan.net,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,mi.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,miaopai.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,microsoft.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,microsoftonline.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,miui.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,miwifi.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,mob.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,netease.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,office.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,office365.com,DIRECT"
|
||||||
|
- "DOMAIN-KEYWORD,officecdn,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,oschina.net,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,ppsimg.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,pstatp.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,qcloud.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,qdaily.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,qdmm.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,qhimg.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,qhres.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,qidian.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,qihucdn.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,qiniu.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,qiniucdn.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,qiyipic.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,qq.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,qqurl.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,rarbg.to,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,ruguoapp.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,segmentfault.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,sinaapp.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,smzdm.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,snapdrop.net,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,sogou.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,sogoucdn.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,sohu.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,soku.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,speedtest.net,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,sspai.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,suning.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,taobao.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,tencent.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,tenpay.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,tianyancha.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,tmall.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,tudou.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,umetrip.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,upaiyun.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,upyun.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,veryzhun.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,weather.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,weibo.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,xiami.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,xiami.net,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,xiaomicp.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,ximalaya.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,xmcdn.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,xunlei.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,yhd.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,yihaodianimg.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,yinxiang.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,ykimg.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,youdao.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,youku.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,zealer.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,zhihu.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,zhimg.com,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,zimuzu.tv,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,zoho.com,DIRECT"
|
||||||
|
- "DOMAIN-KEYWORD,amazon,FeieProxy"
|
||||||
|
- "DOMAIN-KEYWORD,google,FeieProxy"
|
||||||
|
- "DOMAIN-KEYWORD,gmail,FeieProxy"
|
||||||
|
- "DOMAIN-KEYWORD,youtube,FeieProxy"
|
||||||
|
- "DOMAIN-KEYWORD,facebook,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,fb.me,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,fbcdn.net,FeieProxy"
|
||||||
|
- "DOMAIN-KEYWORD,twitter,FeieProxy"
|
||||||
|
- "DOMAIN-KEYWORD,instagram,FeieProxy"
|
||||||
|
- "DOMAIN-KEYWORD,dropbox,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,twimg.com,FeieProxy"
|
||||||
|
- "DOMAIN-KEYWORD,blogspot,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,youtu.be,FeieProxy"
|
||||||
|
- "DOMAIN-KEYWORD,whatsapp,FeieProxy"
|
||||||
|
- "DOMAIN-KEYWORD,admarvel,REJECT"
|
||||||
|
- "DOMAIN-KEYWORD,admaster,REJECT"
|
||||||
|
- "DOMAIN-KEYWORD,adsage,REJECT"
|
||||||
|
- "DOMAIN-KEYWORD,adsmogo,REJECT"
|
||||||
|
- "DOMAIN-KEYWORD,adsrvmedia,REJECT"
|
||||||
|
- "DOMAIN-KEYWORD,adwords,REJECT"
|
||||||
|
- "DOMAIN-KEYWORD,adservice,REJECT"
|
||||||
|
- "DOMAIN-SUFFIX,appsflyer.com,REJECT"
|
||||||
|
- "DOMAIN-KEYWORD,domob,REJECT"
|
||||||
|
- "DOMAIN-SUFFIX,doubleclick.net,REJECT"
|
||||||
|
- "DOMAIN-KEYWORD,duomeng,REJECT"
|
||||||
|
- "DOMAIN-KEYWORD,dwtrack,REJECT"
|
||||||
|
- "DOMAIN-KEYWORD,guanggao,REJECT"
|
||||||
|
- "DOMAIN-KEYWORD,lianmeng,REJECT"
|
||||||
|
- "DOMAIN-SUFFIX,mmstat.com,REJECT"
|
||||||
|
- "DOMAIN-KEYWORD,mopub,REJECT"
|
||||||
|
- "DOMAIN-KEYWORD,omgmta,REJECT"
|
||||||
|
- "DOMAIN-KEYWORD,openx,REJECT"
|
||||||
|
- "DOMAIN-KEYWORD,partnerad,REJECT"
|
||||||
|
- "DOMAIN-KEYWORD,pingfore,REJECT"
|
||||||
|
- "DOMAIN-KEYWORD,supersonicads,REJECT"
|
||||||
|
- "DOMAIN-KEYWORD,uedas,REJECT"
|
||||||
|
- "DOMAIN-KEYWORD,umeng,REJECT"
|
||||||
|
- "DOMAIN-KEYWORD,usage,REJECT"
|
||||||
|
- "DOMAIN-SUFFIX,vungle.com,REJECT"
|
||||||
|
- "DOMAIN-KEYWORD,wlmonitor,REJECT"
|
||||||
|
- "DOMAIN-KEYWORD,zjtoolbar,REJECT"
|
||||||
|
- "DOMAIN-SUFFIX,9to5mac.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,abpchina.org,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,adblockplus.org,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,adobe.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,akamaized.net,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,alfredapp.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,amplitude.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,ampproject.org,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,android.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,angularjs.org,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,aolcdn.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,apkpure.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,appledaily.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,appshopper.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,appspot.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,arcgis.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,archive.org,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,armorgames.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,aspnetcdn.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,att.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,awsstatic.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,azureedge.net,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,azurewebsites.net,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,bing.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,bintray.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,bit.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,bit.ly,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,bitbucket.org,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,bjango.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,bkrtx.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,blog.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,blogcdn.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,blogger.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,blogsmithmedia.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,blogspot.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,blogspot.hk,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,bloomberg.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,box.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,box.net,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,cachefly.net,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,chromium.org,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,cl.ly,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,cloudflare.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,cloudfront.net,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,cloudmagic.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,cmail19.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,cnet.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,cocoapods.org,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,comodoca.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,crashlytics.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,culturedcode.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,d.pr,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,danilo.to,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,dayone.me,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,db.tt,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,deskconnect.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,disq.us,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,disqus.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,disquscdn.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,dnsimple.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,docker.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,dribbble.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,droplr.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,duckduckgo.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,dueapp.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,dytt8.net,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,edgecastcdn.net,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,edgekey.net,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,edgesuite.net,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,engadget.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,entrust.net,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,eurekavpt.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,evernote.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,fabric.io,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,fast.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,fastly.net,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,fc2.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,feedburner.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,feedly.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,feedsportal.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,fiftythree.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,firebaseio.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,flexibits.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,flickr.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,flipboard.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,g.co,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,gabia.net,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,geni.us,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,gfx.ms,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,ggpht.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,ghostnoteapp.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,git.io,FeieProxy"
|
||||||
|
- "DOMAIN-KEYWORD,github,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,globalsign.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,gmodules.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,godaddy.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,golang.org,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,gongm.in,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,goo.gl,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,goodreaders.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,goodreads.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,gravatar.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,gstatic.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,gvt0.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,hockeyapp.net,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,hotmail.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,icons8.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,ifixit.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,ift.tt,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,ifttt.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,iherb.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,imageshack.us,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,img.ly,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,imgur.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,imore.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,instapaper.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,ipn.li,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,is.gd,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,issuu.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,itgonglun.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,itun.es,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,ixquick.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,j.mp,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,js.revsci.net,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,jshint.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,jtvnw.net,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,justgetflux.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,kat.cr,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,klip.me,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,libsyn.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,linkedin.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,line-apps.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,linode.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,lithium.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,littlehj.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,live.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,live.net,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,livefilestore.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,llnwd.net,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,macid.co,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,macromedia.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,macrumors.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,mashable.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,mathjax.org,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,medium.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,mega.co.nz,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,mega.nz,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,megaupload.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,microsofttranslator.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,mindnode.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,mobile01.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,modmyi.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,msedge.net,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,myfontastic.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,name.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,nextmedia.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,nsstatic.net,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,nssurge.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,nyt.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,nytimes.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,omnigroup.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,onedrive.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,onenote.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,ooyala.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,openvpn.net,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,openwrt.org,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,orkut.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,osxdaily.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,outlook.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,ow.ly,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,paddleapi.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,parallels.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,parse.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,pdfexpert.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,periscope.tv,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,pinboard.in,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,pinterest.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,pixelmator.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,pixiv.net,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,playpcesor.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,playstation.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,playstation.com.hk,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,playstation.net,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,playstationnetwork.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,pushwoosh.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,rime.im,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,servebom.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,sfx.ms,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,shadowsocks.org,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,sharethis.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,shazam.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,skype.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,smartdnsFeieProxy.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,smartmailcloud.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,sndcdn.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,sony.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,soundcloud.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,sourceforge.net,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,spotify.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,squarespace.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,sstatic.net,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,st.luluku.pw,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,stackoverflow.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,startpage.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,staticflickr.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,steamcommunity.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,symauth.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,symcb.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,symcd.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,tapbots.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,tapbots.net,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,tdesktop.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,techcrunch.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,techsmith.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,thepiratebay.org,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,theverge.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,time.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,timeinc.net,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,tiny.cc,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,tinypic.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,tmblr.co,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,todoist.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,trello.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,trustasiassl.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,tumblr.co,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,tumblr.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,tweetdeck.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,tweetmarker.net,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,twitch.tv,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,txmblr.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,typekit.net,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,ubertags.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,ublock.org,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,ubnt.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,ulyssesapp.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,urchin.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,usertrust.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,v.gd,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,v2ex.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,vimeo.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,vimeocdn.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,vine.co,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,vivaldi.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,vox-cdn.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,vsco.co,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,vultr.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,w.org,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,w3schools.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,webtype.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,wikiwand.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,wikileaks.org,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,wikimedia.org,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,wikipedia.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,wikipedia.org,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,windows.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,windows.net,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,wire.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,wordpress.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,workflowy.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,wp.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,wsj.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,wsj.net,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,xda-developers.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,xeeno.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,xiti.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,yahoo.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,yimg.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,ying.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,yoyo.org,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,ytimg.com,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,telegra.ph,FeieProxy"
|
||||||
|
- "DOMAIN-SUFFIX,telegram.org,FeieProxy"
|
||||||
|
- "IP-CIDR,91.108.4.0/22,FeieProxy,no-resolve"
|
||||||
|
- "IP-CIDR,91.108.8.0/21,FeieProxy,no-resolve"
|
||||||
|
- "IP-CIDR,91.108.16.0/22,FeieProxy,no-resolve"
|
||||||
|
- "IP-CIDR,91.108.56.0/22,FeieProxy,no-resolve"
|
||||||
|
- "IP-CIDR,149.154.160.0/20,FeieProxy,no-resolve"
|
||||||
|
- "IP-CIDR6,2001:67c:4e8::/48,FeieProxy,no-resolve"
|
||||||
|
- "IP-CIDR6,2001:b28:f23d::/48,FeieProxy,no-resolve"
|
||||||
|
- "IP-CIDR6,2001:b28:f23f::/48,FeieProxy,no-resolve"
|
||||||
|
- "IP-CIDR,120.232.181.162/32,FeieProxy,no-resolve"
|
||||||
|
- "IP-CIDR,120.241.147.226/32,FeieProxy,no-resolve"
|
||||||
|
- "IP-CIDR,120.253.253.226/32,FeieProxy,no-resolve"
|
||||||
|
- "IP-CIDR,120.253.255.162/32,FeieProxy,no-resolve"
|
||||||
|
- "IP-CIDR,120.253.255.34/32,FeieProxy,no-resolve"
|
||||||
|
- "IP-CIDR,120.253.255.98/32,FeieProxy,no-resolve"
|
||||||
|
- "IP-CIDR,180.163.150.162/32,FeieProxy,no-resolve"
|
||||||
|
- "IP-CIDR,180.163.150.34/32,FeieProxy,no-resolve"
|
||||||
|
- "IP-CIDR,180.163.151.162/32,FeieProxy,no-resolve"
|
||||||
|
- "IP-CIDR,180.163.151.34/32,FeieProxy,no-resolve"
|
||||||
|
- "IP-CIDR,203.208.39.0/24,FeieProxy,no-resolve"
|
||||||
|
- "IP-CIDR,203.208.40.0/24,FeieProxy,no-resolve"
|
||||||
|
- "IP-CIDR,203.208.41.0/24,FeieProxy,no-resolve"
|
||||||
|
- "IP-CIDR,203.208.43.0/24,FeieProxy,no-resolve"
|
||||||
|
- "IP-CIDR,203.208.50.0/24,FeieProxy,no-resolve"
|
||||||
|
- "IP-CIDR,220.181.174.162/32,FeieProxy,no-resolve"
|
||||||
|
- "IP-CIDR,220.181.174.226/32,FeieProxy,no-resolve"
|
||||||
|
- "IP-CIDR,220.181.174.34/32,FeieProxy,no-resolve"
|
||||||
|
- "DOMAIN,injections.adguard.org,DIRECT"
|
||||||
|
- "DOMAIN,local.adguard.org,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,local,DIRECT"
|
||||||
|
- "IP-CIDR,127.0.0.0/8,DIRECT"
|
||||||
|
- "IP-CIDR,172.16.0.0/12,DIRECT"
|
||||||
|
- "IP-CIDR,192.168.0.0/16,DIRECT"
|
||||||
|
- "IP-CIDR,10.0.0.0/8,DIRECT"
|
||||||
|
- "IP-CIDR,17.0.0.0/8,DIRECT"
|
||||||
|
- "IP-CIDR,100.64.0.0/10,DIRECT"
|
||||||
|
- "IP-CIDR,224.0.0.0/4,DIRECT"
|
||||||
|
- "IP-CIDR6,fe80::/10,DIRECT"
|
||||||
|
- "DOMAIN-SUFFIX,cn,DIRECT"
|
||||||
|
- "DOMAIN-KEYWORD,-cn,DIRECT"
|
||||||
|
- "GEOIP,CN,DIRECT"
|
||||||
|
- "MATCH,FeieProxy"
|
||||||
881
app/globals.css
881
app/globals.css
@ -1,26 +1,887 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--background: #efe5d4;
|
||||||
--foreground: #171717;
|
--foreground: #18222c;
|
||||||
|
--panel: rgba(255, 251, 245, 0.76);
|
||||||
|
--panel-strong: rgba(255, 251, 245, 0.92);
|
||||||
|
--panel-tint: rgba(255, 244, 230, 0.62);
|
||||||
|
--border: rgba(24, 34, 44, 0.12);
|
||||||
|
--border-strong: rgba(24, 34, 44, 0.22);
|
||||||
|
--accent: #c96233;
|
||||||
|
--accent-deep: #7a3116;
|
||||||
|
--accent-soft: rgba(201, 98, 51, 0.14);
|
||||||
|
--ink-soft: rgba(24, 34, 44, 0.08);
|
||||||
|
--success: #1f7a4f;
|
||||||
|
--danger: #af2d21;
|
||||||
|
--warn: #8e5c10;
|
||||||
|
--muted: #5e6670;
|
||||||
|
--shadow: 0 26px 90px rgba(51, 35, 18, 0.12);
|
||||||
|
--shadow-soft: 0 10px 30px rgba(51, 35, 18, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: "Avenir Next", "PingFang SC", "Segoe UI", sans-serif;
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
* {
|
||||||
:root {
|
box-sizing: border-box;
|
||||||
--background: #0a0a0a;
|
}
|
||||||
--foreground: #ededed;
|
|
||||||
}
|
html,
|
||||||
|
body {
|
||||||
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
margin: 0;
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
font-family: var(--font-sans);
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle at top left, rgba(201, 98, 51, 0.2), transparent 24rem),
|
||||||
|
radial-gradient(circle at 88% 10%, rgba(39, 90, 114, 0.1), transparent 20rem),
|
||||||
|
radial-gradient(circle at bottom right, rgba(24, 34, 44, 0.08), transparent 28rem),
|
||||||
|
linear-gradient(180deg, #f8f0e5 0%, #ede2d3 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(255, 255, 255, 0.18) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(255, 255, 255, 0.18) 1px, transparent 1px);
|
||||||
|
background-size: 44px 44px;
|
||||||
|
mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.65), transparent 80%);
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: rgba(201, 98, 51, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.84);
|
||||||
|
color: var(--foreground);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72);
|
||||||
|
transition: border-color 140ms ease, box-shadow 140ms ease, background-color 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: rgba(201, 98, 51, 0.55);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 4px rgba(201, 98, 51, 0.1),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.72);
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
border: 0;
|
||||||
|
transition:
|
||||||
|
transform 140ms ease,
|
||||||
|
opacity 140ms ease,
|
||||||
|
background-color 140ms ease,
|
||||||
|
box-shadow 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-shell--immersive {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 20% 18%, rgba(233, 128, 64, 0.3), transparent 18rem),
|
||||||
|
radial-gradient(circle at 78% 16%, rgba(38, 89, 111, 0.22), transparent 24rem),
|
||||||
|
radial-gradient(circle at 50% 100%, rgba(24, 34, 44, 0.14), transparent 24rem),
|
||||||
|
linear-gradient(135deg, #f7ecdd 0%, #efe0cb 44%, #e7d7c6 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-shell--immersive::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(255, 255, 255, 0.2) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(255, 255, 255, 0.2) 1px, transparent 1px);
|
||||||
|
background-size: 72px 72px;
|
||||||
|
mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.85), transparent 88%);
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-orb {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 999px;
|
||||||
|
filter: blur(18px);
|
||||||
|
opacity: 0.8;
|
||||||
|
animation: login-float 14s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-orb--one {
|
||||||
|
top: 10%;
|
||||||
|
left: max(2rem, 8vw);
|
||||||
|
width: 18rem;
|
||||||
|
height: 18rem;
|
||||||
|
background: radial-gradient(circle, rgba(201, 98, 51, 0.36), rgba(201, 98, 51, 0.02));
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-orb--two {
|
||||||
|
right: max(2rem, 10vw);
|
||||||
|
bottom: 12%;
|
||||||
|
width: 22rem;
|
||||||
|
height: 22rem;
|
||||||
|
background: radial-gradient(circle, rgba(33, 93, 118, 0.24), rgba(33, 93, 118, 0.02));
|
||||||
|
animation-duration: 18s;
|
||||||
|
animation-delay: -6s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-orb--three {
|
||||||
|
top: 22%;
|
||||||
|
right: 18%;
|
||||||
|
width: 10rem;
|
||||||
|
height: 10rem;
|
||||||
|
background: radial-gradient(circle, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0.04));
|
||||||
|
animation-duration: 12s;
|
||||||
|
animation-delay: -3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-stage {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: min(34rem, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-layout {
|
||||||
|
width: min(76rem, 100%);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.15fr) minmax(22rem, 28rem);
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card,
|
||||||
|
.panel-card,
|
||||||
|
.metric-card,
|
||||||
|
.sidebar {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--panel);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card::before,
|
||||||
|
.panel-card::before,
|
||||||
|
.metric-card::before,
|
||||||
|
.sidebar::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.35), transparent 38%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 30px;
|
||||||
|
padding: 2rem 2.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card--hero {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.3rem;
|
||||||
|
align-content: space-between;
|
||||||
|
min-height: 34rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card--form {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card--brand {
|
||||||
|
gap: 1.1rem;
|
||||||
|
padding: 2.2rem;
|
||||||
|
border-color: rgba(255, 255, 255, 0.34);
|
||||||
|
background:
|
||||||
|
linear-gradient(160deg, rgba(255, 252, 247, 0.84), rgba(255, 248, 241, 0.66)),
|
||||||
|
rgba(255, 251, 245, 0.72);
|
||||||
|
box-shadow:
|
||||||
|
0 34px 90px rgba(60, 36, 18, 0.16),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.66);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card--brand h1 {
|
||||||
|
font-size: clamp(2.1rem, 5vw, 3.2rem);
|
||||||
|
letter-spacing: -0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-points {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-point {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
min-height: 6.6rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 22px;
|
||||||
|
border: 1px solid rgba(24, 34, 44, 0.1);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.56), rgba(255, 255, 255, 0.26)),
|
||||||
|
rgba(255, 255, 255, 0.36);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-point strong {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-note {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wide-card {
|
||||||
|
width: min(56rem, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 19rem minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 1.5rem 1.25rem;
|
||||||
|
border-right: 1px solid rgba(24, 34, 44, 0.08);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-shell {
|
||||||
|
padding: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-frame {
|
||||||
|
min-height: calc(100vh - 2.7rem);
|
||||||
|
border: 1px solid rgba(24, 34, 44, 0.08);
|
||||||
|
border-radius: 32px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.34), transparent 20%),
|
||||||
|
rgba(255, 251, 245, 0.26);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
padding: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-block h1,
|
||||||
|
.page-header h2,
|
||||||
|
.section-title h3,
|
||||||
|
.login-card h1,
|
||||||
|
.login-card h2 {
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.77rem;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: var(--accent-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-lede {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.65;
|
||||||
|
max-width: 46rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-note {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 0.95rem 1rem;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.38), rgba(255, 255, 255, 0.18));
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-note strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-list,
|
||||||
|
.sidebar-stack,
|
||||||
|
.page-stack,
|
||||||
|
.stack-lg,
|
||||||
|
.stack-md,
|
||||||
|
.stack-tight,
|
||||||
|
.hero-copy,
|
||||||
|
.hero-aside {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-list {
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-stack {
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 0.8rem 0.95rem;
|
||||||
|
background: rgba(255, 255, 255, 0.38);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link,
|
||||||
|
.ghost-button,
|
||||||
|
.primary-button,
|
||||||
|
.danger-button {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link,
|
||||||
|
.ghost-button {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: rgba(255, 255, 255, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover,
|
||||||
|
.ghost-button:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.18rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link__label {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link__meta {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.83rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.is-active {
|
||||||
|
background: linear-gradient(135deg, rgba(201, 98, 51, 0.18), rgba(255, 255, 255, 0.62));
|
||||||
|
border-color: rgba(201, 98, 51, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.is-active::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: 0.8rem;
|
||||||
|
top: 50%;
|
||||||
|
width: 0.48rem;
|
||||||
|
height: 0.48rem;
|
||||||
|
margin-top: -0.24rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff8f2;
|
||||||
|
box-shadow: 0 12px 24px rgba(201, 98, 51, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-button {
|
||||||
|
background: rgba(175, 45, 33, 0.14);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-width {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-stack {
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-stack > * {
|
||||||
|
animation: rise-in 360ms ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-lg {
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-md {
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-tight {
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header,
|
||||||
|
.section-title,
|
||||||
|
.list-row,
|
||||||
|
.action-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title--top {
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics-grid,
|
||||||
|
.panel-grid,
|
||||||
|
.form-grid,
|
||||||
|
.summary-grid,
|
||||||
|
.feature-grid,
|
||||||
|
.checkbox-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics-grid {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-grid {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-grid {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card,
|
||||||
|
.panel-card {
|
||||||
|
border-radius: 28px;
|
||||||
|
padding: 1.2rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.52rem;
|
||||||
|
min-height: 9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card strong {
|
||||||
|
font-size: 1.9rem;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card small {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card--warm {
|
||||||
|
background: linear-gradient(180deg, rgba(247, 225, 203, 0.92), rgba(255, 250, 242, 0.72));
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card--ink {
|
||||||
|
background: linear-gradient(180deg, rgba(223, 231, 236, 0.9), rgba(255, 250, 242, 0.72));
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card--sage {
|
||||||
|
background: linear-gradient(180deg, rgba(224, 236, 228, 0.9), rgba(255, 250, 242, 0.72));
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card--paper {
|
||||||
|
background: linear-gradient(180deg, rgba(255, 252, 247, 0.95), rgba(255, 250, 242, 0.72));
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-panel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.3fr) minmax(18rem, 0.9fr);
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.35rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 30px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top right, rgba(201, 98, 51, 0.12), transparent 16rem),
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.46), rgba(255, 255, 255, 0.18));
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-panel--compact {
|
||||||
|
grid-template-columns: minmax(0, 1.4fr) minmax(16rem, 0.75fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy,
|
||||||
|
.hero-aside {
|
||||||
|
align-content: start;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-aside {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-row--end {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill--ok {
|
||||||
|
background: rgba(31, 122, 79, 0.12);
|
||||||
|
color: var(--success);
|
||||||
|
border-color: rgba(31, 122, 79, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill--danger {
|
||||||
|
background: rgba(175, 45, 33, 0.12);
|
||||||
|
color: var(--danger);
|
||||||
|
border-color: rgba(175, 45, 33, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill--off {
|
||||||
|
background: rgba(24, 34, 44, 0.08);
|
||||||
|
color: var(--foreground);
|
||||||
|
border-color: rgba(24, 34, 44, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill--neutral {
|
||||||
|
background: rgba(39, 90, 114, 0.1);
|
||||||
|
color: #26596f;
|
||||||
|
border-color: rgba(39, 90, 114, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill--warm {
|
||||||
|
background: rgba(201, 98, 51, 0.1);
|
||||||
|
color: var(--accent-deep);
|
||||||
|
border-color: rgba(201, 98, 51, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill--ink {
|
||||||
|
background: rgba(24, 34, 44, 0.1);
|
||||||
|
color: var(--foreground);
|
||||||
|
border-color: rgba(24, 34, 44, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill--sage {
|
||||||
|
background: rgba(78, 113, 91, 0.1);
|
||||||
|
color: #456756;
|
||||||
|
border-color: rgba(78, 113, 91, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field span {
|
||||||
|
font-size: 0.92rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid.compact {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-span {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox input {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.align-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-button {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-inline {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.52);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-inline span,
|
||||||
|
.stat-tile span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-inline strong,
|
||||||
|
.stat-tile strong {
|
||||||
|
font-size: 1.02rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-tile {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.3rem;
|
||||||
|
border-radius: 22px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.48);
|
||||||
|
min-height: 7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 22px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: rgba(255, 255, 255, 0.44);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-kicker {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--accent-deep);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.break-all {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instruction-block {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instruction-block.no-margin {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instruction-block pre {
|
||||||
|
overflow: auto;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
padding: 1rem 1.05rem;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #1a1f25;
|
||||||
|
color: #f7eadf;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-row--card {
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.38);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner,
|
||||||
|
.error-text {
|
||||||
|
color: var(--danger);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rise-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes login-float {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translate3d(0, 0, 0) scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translate3d(0, -18px, 0) scale(1.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.admin-shell,
|
||||||
|
.login-layout,
|
||||||
|
.hero-panel,
|
||||||
|
.hero-panel--compact,
|
||||||
|
.hero-aside,
|
||||||
|
.metrics-grid,
|
||||||
|
.panel-grid,
|
||||||
|
.form-grid,
|
||||||
|
.checkbox-grid,
|
||||||
|
.summary-grid,
|
||||||
|
.feature-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: static;
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header,
|
||||||
|
.section-title,
|
||||||
|
.list-row,
|
||||||
|
.action-row {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.align-right {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-row--end {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-frame {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-points {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.login-shell {
|
||||||
|
padding: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card--brand {
|
||||||
|
padding: 1.4rem;
|
||||||
|
border-radius: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-orb--one {
|
||||||
|
width: 13rem;
|
||||||
|
height: 13rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-orb--two {
|
||||||
|
width: 15rem;
|
||||||
|
height: 15rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,20 +1,9 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "HY2 Panel",
|
||||||
description: "Generated by create next app",
|
description: "Self-hosted Hysteria 2 panel with auth, traffic sync, and node management",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@ -23,11 +12,8 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html
|
<html lang="zh-CN" className="h-full">
|
||||||
lang="en"
|
<body>{children}</body>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
|
||||||
>
|
|
||||||
<body className="min-h-full flex flex-col">{children}</body>
|
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
34
app/login/actions.ts
Normal file
34
app/login/actions.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { getAdminByUsername, getUserByUsername } from "@/lib/store";
|
||||||
|
import { verifyPassword } from "@/lib/password";
|
||||||
|
import { createAdminSession, createUserSession } from "@/lib/session";
|
||||||
|
|
||||||
|
export async function loginAction(formData: FormData) {
|
||||||
|
const username = String(formData.get("username") ?? "").trim();
|
||||||
|
const password = String(formData.get("password") ?? "");
|
||||||
|
|
||||||
|
const admin = await getAdminByUsername(username);
|
||||||
|
if (admin) {
|
||||||
|
const ok = await verifyPassword(password, admin.password_hash);
|
||||||
|
if (ok) {
|
||||||
|
await createAdminSession(admin.id);
|
||||||
|
redirect("/admin/dashboard");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserByUsername(username);
|
||||||
|
if (!user) {
|
||||||
|
redirect("/login?error=1");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await verifyPassword(password, user.password_hash);
|
||||||
|
if (!ok || !user.enabled) {
|
||||||
|
redirect("/login?error=1");
|
||||||
|
}
|
||||||
|
|
||||||
|
await createUserSession(user.id);
|
||||||
|
redirect(user.is_admin ? "/admin/dashboard" : "/me");
|
||||||
|
}
|
||||||
70
app/login/page.tsx
Normal file
70
app/login/page.tsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { getAdminSession, getUserSession } from "@/lib/session";
|
||||||
|
|
||||||
|
import { loginAction } from "./actions";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "鹅梯 FeieProxy",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function LoginPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ error?: string }>;
|
||||||
|
}) {
|
||||||
|
const [adminSession, userSession] = await Promise.all([
|
||||||
|
getAdminSession(),
|
||||||
|
getUserSession(),
|
||||||
|
]);
|
||||||
|
if (adminSession) {
|
||||||
|
redirect("/admin/dashboard");
|
||||||
|
}
|
||||||
|
if (userSession) {
|
||||||
|
redirect("/me");
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = await searchParams;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="login-shell login-shell--immersive">
|
||||||
|
<div aria-hidden="true" className="login-backdrop">
|
||||||
|
<span className="login-orb login-orb--one" />
|
||||||
|
<span className="login-orb login-orb--two" />
|
||||||
|
<span className="login-orb login-orb--three" />
|
||||||
|
</div>
|
||||||
|
<section className="login-stage">
|
||||||
|
<article className="login-card login-card--form login-card--brand">
|
||||||
|
<div className="section-title">
|
||||||
|
<div>
|
||||||
|
<span className="eyebrow">FEIEPROXY ACCESS</span>
|
||||||
|
<h1>鹅梯 FeieProxy</h1>
|
||||||
|
</div>
|
||||||
|
<span className="status-pill status-pill--neutral">家宽节点</span>
|
||||||
|
</div>
|
||||||
|
<p className="page-lede">
|
||||||
|
统一管理多节点鉴权、流量同步与 Clash 订阅。管理员进入控制台,普通用户进入自助页查看剩余流量并生成订阅链接。
|
||||||
|
</p>
|
||||||
|
<form action={loginAction} className="stack-lg login-form">
|
||||||
|
<label className="field">
|
||||||
|
<span>用户名</span>
|
||||||
|
<input name="username" type="text" placeholder="admin" required />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>密码</span>
|
||||||
|
<input name="password" type="password" required />
|
||||||
|
</label>
|
||||||
|
{params.error ? (
|
||||||
|
<p className="error-banner">用户名或密码错误。</p>
|
||||||
|
) : null}
|
||||||
|
<button className="primary-button" type="submit">
|
||||||
|
登录控制台
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="login-note">管理员与普通用户共用入口,系统会自动跳转到对应面板。</p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
app/logout/route.ts
Normal file
8
app/logout/route.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { destroyAdminSession, destroyUserSession } from "@/lib/session";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
await Promise.all([destroyAdminSession(), destroyUserSession()]);
|
||||||
|
return NextResponse.redirect(new URL("/login", request.url));
|
||||||
|
}
|
||||||
75
app/me/page.tsx
Normal file
75
app/me/page.tsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { getRequestOrigin } from "@/lib/request-origin";
|
||||||
|
import { buildSubscriptionUrl, describeRemainingTraffic } from "@/lib/subscription";
|
||||||
|
import { requireUserSession } from "@/lib/session";
|
||||||
|
import { formatBytes, getUserById } from "@/lib/store";
|
||||||
|
|
||||||
|
export default async function MePage() {
|
||||||
|
const session = await requireUserSession();
|
||||||
|
const [user, origin] = await Promise.all([
|
||||||
|
getUserById(session.user_id),
|
||||||
|
getRequestOrigin(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="login-shell">
|
||||||
|
<section className="login-card wide-card">
|
||||||
|
<div className="hero-panel hero-panel--compact">
|
||||||
|
<div className="hero-copy">
|
||||||
|
<span className="eyebrow">SELF SERVICE</span>
|
||||||
|
<h1>{user.username}</h1>
|
||||||
|
<p className="page-lede">
|
||||||
|
已用 {formatBytes(user.used_tx_bytes + user.used_rx_bytes)},{describeRemainingTraffic(user)}
|
||||||
|
</p>
|
||||||
|
<div className="badge-row">
|
||||||
|
<span className={`status-pill ${user.enabled ? "status-pill--ok" : "status-pill--danger"}`}>
|
||||||
|
{user.enabled ? "已启用" : "已禁用"}
|
||||||
|
</span>
|
||||||
|
<span className="status-pill status-pill--neutral">
|
||||||
|
{user.expires_at ?? "长期有效"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="hero-aside">
|
||||||
|
<div className="stat-tile">
|
||||||
|
<span>auth_id</span>
|
||||||
|
<strong className="mono break-all">{user.auth_id}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stat-tile">
|
||||||
|
<span>订阅链接</span>
|
||||||
|
<strong className="mono break-all">
|
||||||
|
{user.subscription_token
|
||||||
|
? buildSubscriptionUrl(user.subscription_token, origin)
|
||||||
|
: "暂不可用"}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="summary-grid">
|
||||||
|
<div className="metric-inline">
|
||||||
|
<span>上传</span>
|
||||||
|
<strong>{formatBytes(user.used_tx_bytes)}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="metric-inline">
|
||||||
|
<span>下载</span>
|
||||||
|
<strong>{formatBytes(user.used_rx_bytes)}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="metric-inline">
|
||||||
|
<span>剩余额度</span>
|
||||||
|
<strong>{describeRemainingTraffic(user)}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form action="/logout" method="post">
|
||||||
|
<button className="ghost-button" type="submit">
|
||||||
|
退出登录
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
app/page.tsx
81
app/page.tsx
@ -1,65 +1,20 @@
|
|||||||
import Image from "next/image";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default function Home() {
|
import { getAdminSession, getUserSession } from "@/lib/session";
|
||||||
return (
|
|
||||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
export default async function Home() {
|
||||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
const [adminSession, userSession] = await Promise.all([
|
||||||
<Image
|
getAdminSession(),
|
||||||
className="dark:invert"
|
getUserSession(),
|
||||||
src="/next.svg"
|
]);
|
||||||
alt="Next.js logo"
|
|
||||||
width={100}
|
if (adminSession) {
|
||||||
height={20}
|
redirect("/admin");
|
||||||
priority
|
}
|
||||||
/>
|
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
if (userSession) {
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
redirect("/me");
|
||||||
To get started, edit the page.tsx file.
|
}
|
||||||
</h1>
|
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
redirect("/login");
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
|
||||||
<a
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Templates
|
|
||||||
</a>{" "}
|
|
||||||
or the{" "}
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Learning
|
|
||||||
</a>{" "}
|
|
||||||
center.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Deploy Now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
1
bun.lock
1
bun.lock
@ -26,6 +26,7 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
|
"better-sqlite3",
|
||||||
"sharp",
|
"sharp",
|
||||||
"unrs-resolver",
|
"unrs-resolver",
|
||||||
],
|
],
|
||||||
|
|||||||
17
deploy/hy2-panel.service
Normal file
17
deploy/hy2-panel.service
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=HY2 Panel
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=/root/hy2-panel
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
Environment=PATH=/root/.bun/bin:/root/.nvm/versions/node/v22.22.2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
|
EnvironmentFile=/root/hy2-panel/.env
|
||||||
|
ExecStart=/root/.bun/bin/bun run start
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
User=root
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
11
instrumentation.ts
Normal file
11
instrumentation.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export async function register() {
|
||||||
|
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||||
|
const [{ ensureDb }, { startPoller }] = await Promise.all([
|
||||||
|
import("@/lib/db"),
|
||||||
|
import("@/lib/poller"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await ensureDb();
|
||||||
|
startPoller();
|
||||||
|
}
|
||||||
|
}
|
||||||
38
lib/crypto.ts
Normal file
38
lib/crypto.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
||||||
|
|
||||||
|
import { getEnv } from "@/lib/env";
|
||||||
|
|
||||||
|
function getKey() {
|
||||||
|
return createHash("sha256").update(getEnv().SESSION_SECRET).digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encryptText(value: string) {
|
||||||
|
const iv = randomBytes(12);
|
||||||
|
const cipher = createCipheriv("aes-256-gcm", getKey(), iv);
|
||||||
|
const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
return `${iv.toString("base64url")}.${tag.toString("base64url")}.${encrypted.toString("base64url")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptText(payload: string | null | undefined) {
|
||||||
|
if (!payload) return null;
|
||||||
|
|
||||||
|
const [ivPart, tagPart, encryptedPart] = payload.split(".");
|
||||||
|
if (!ivPart || !tagPart || !encryptedPart) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decipher = createDecipheriv(
|
||||||
|
"aes-256-gcm",
|
||||||
|
getKey(),
|
||||||
|
Buffer.from(ivPart, "base64url"),
|
||||||
|
);
|
||||||
|
decipher.setAuthTag(Buffer.from(tagPart, "base64url"));
|
||||||
|
|
||||||
|
const decrypted = Buffer.concat([
|
||||||
|
decipher.update(Buffer.from(encryptedPart, "base64url")),
|
||||||
|
decipher.final(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return decrypted.toString("utf8");
|
||||||
|
}
|
||||||
340
lib/db.ts
Normal file
340
lib/db.ts
Normal file
@ -0,0 +1,340 @@
|
|||||||
|
import Database from "better-sqlite3";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { encryptText } from "@/lib/crypto";
|
||||||
|
import { getEnv } from "@/lib/env";
|
||||||
|
import { createOpaqueToken, createStableId, hashPassword } from "@/lib/password";
|
||||||
|
|
||||||
|
const MIGRATIONS = [
|
||||||
|
`
|
||||||
|
CREATE TABLE IF NOT EXISTS admins (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
admin_id INTEGER NOT NULL,
|
||||||
|
session_hash TEXT NOT NULL UNIQUE,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
last_seen_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (admin_id) REFERENCES admins(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS hy2_users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
auth_id TEXT NOT NULL UNIQUE,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
expires_at TEXT,
|
||||||
|
traffic_limit_bytes INTEGER,
|
||||||
|
used_tx_bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
used_rx_bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
notes TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS hy2_nodes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
auth_token TEXT NOT NULL,
|
||||||
|
traffic_stats_url TEXT NOT NULL,
|
||||||
|
traffic_stats_secret TEXT NOT NULL,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
poll_interval_seconds INTEGER NOT NULL DEFAULT 15,
|
||||||
|
last_polled_at TEXT,
|
||||||
|
last_sync_ok_at TEXT,
|
||||||
|
last_error_at TEXT,
|
||||||
|
last_error_message TEXT,
|
||||||
|
last_online_users INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_stream_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS traffic_ledgers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
node_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER,
|
||||||
|
auth_id TEXT NOT NULL,
|
||||||
|
tx_bytes INTEGER NOT NULL,
|
||||||
|
rx_bytes INTEGER NOT NULL,
|
||||||
|
window_started_at TEXT NOT NULL,
|
||||||
|
window_ended_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (node_id) REFERENCES hy2_nodes(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES hy2_users(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS node_sync_records (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
node_id INTEGER NOT NULL,
|
||||||
|
started_at TEXT NOT NULL,
|
||||||
|
finished_at TEXT NOT NULL,
|
||||||
|
ok INTEGER NOT NULL,
|
||||||
|
traffic_entries INTEGER NOT NULL DEFAULT 0,
|
||||||
|
online_users INTEGER NOT NULL DEFAULT 0,
|
||||||
|
stream_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
kicked_auth_ids TEXT NOT NULL DEFAULT '',
|
||||||
|
error_message TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (node_id) REFERENCES hy2_nodes(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS auth_audits (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
node_id INTEGER,
|
||||||
|
user_id INTEGER,
|
||||||
|
auth_id TEXT,
|
||||||
|
username TEXT,
|
||||||
|
addr TEXT,
|
||||||
|
requested_tx INTEGER,
|
||||||
|
ok INTEGER NOT NULL,
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (node_id) REFERENCES hy2_nodes(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES hy2_users(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS node_user_presence (
|
||||||
|
node_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER,
|
||||||
|
auth_id TEXT NOT NULL,
|
||||||
|
connections INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (node_id, auth_id),
|
||||||
|
FOREIGN KEY (node_id) REFERENCES hy2_nodes(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES hy2_users(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_hy2_users_auth_id ON hy2_users(auth_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_hy2_users_username ON hy2_users(username);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_hy2_nodes_enabled ON hy2_nodes(enabled);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_traffic_ledgers_auth_id ON traffic_ledgers(auth_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_traffic_ledgers_created_at ON traffic_ledgers(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_node_sync_records_node_id ON node_sync_records(node_id, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_auth_audits_created_at ON auth_audits(created_at DESC);
|
||||||
|
`,
|
||||||
|
`
|
||||||
|
ALTER TABLE hy2_users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE hy2_users ADD COLUMN auth_secret_encrypted TEXT;
|
||||||
|
ALTER TABLE hy2_users ADD COLUMN subscription_token TEXT;
|
||||||
|
ALTER TABLE hy2_users ADD COLUMN subscription_label TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
session_hash TEXT NOT NULL UNIQUE,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
last_seen_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES hy2_users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS app_settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE hy2_nodes ADD COLUMN server_host TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE hy2_nodes ADD COLUMN server_port INTEGER NOT NULL DEFAULT 443;
|
||||||
|
ALTER TABLE hy2_nodes ADD COLUMN client_name TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE hy2_nodes ADD COLUMN bandwidth_up_mbps INTEGER NOT NULL DEFAULT 30;
|
||||||
|
ALTER TABLE hy2_nodes ADD COLUMN bandwidth_down_mbps INTEGER NOT NULL DEFAULT 30;
|
||||||
|
ALTER TABLE hy2_nodes ADD COLUMN skip_cert_verify INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE hy2_nodes ADD COLUMN sni TEXT;
|
||||||
|
ALTER TABLE hy2_nodes ADD COLUMN traffic_stats_listen TEXT NOT NULL DEFAULT ':9999';
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_hy2_users_subscription_token ON hy2_users(subscription_token);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id);
|
||||||
|
`,
|
||||||
|
`
|
||||||
|
ALTER TABLE hy2_nodes ADD COLUMN hy2_listen TEXT NOT NULL DEFAULT ':443';
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
var __hy2PanelDb: Database.Database | undefined;
|
||||||
|
var __hy2PanelDbInit: Promise<void> | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDbInstance() {
|
||||||
|
const env = getEnv();
|
||||||
|
const filePath = path.isAbsolute(env.DATABASE_PATH)
|
||||||
|
? env.DATABASE_PATH
|
||||||
|
: path.join(/* turbopackIgnore: true */ process.cwd(), env.DATABASE_PATH);
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
|
|
||||||
|
const db = new Database(filePath);
|
||||||
|
db.pragma("journal_mode = WAL");
|
||||||
|
db.pragma("foreign_keys = ON");
|
||||||
|
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initializeDb(db: Database.Database) {
|
||||||
|
const currentVersion = db.pragma("user_version", { simple: true }) as number;
|
||||||
|
|
||||||
|
for (let index = currentVersion; index < MIGRATIONS.length; index += 1) {
|
||||||
|
db.exec(MIGRATIONS[index]);
|
||||||
|
db.pragma(`user_version = ${index + 1}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = getEnv();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const passwordHash = await hashPassword(env.ADMIN_PASSWORD);
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
INSERT INTO admins (id, username, password_hash, created_at, updated_at)
|
||||||
|
VALUES (1, @username, @passwordHash, @now, @now)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
username = excluded.username,
|
||||||
|
password_hash = excluded.password_hash,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
`,
|
||||||
|
).run({
|
||||||
|
username: env.ADMIN_USERNAME,
|
||||||
|
passwordHash,
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
|
||||||
|
const templatePath = path.join(process.cwd(), "app", "config", "config.yaml");
|
||||||
|
const defaultTemplate = fs.readFileSync(templatePath, "utf8");
|
||||||
|
const settings = db.prepare(
|
||||||
|
`
|
||||||
|
INSERT INTO app_settings (key, value, updated_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(key) DO NOTHING
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
settings.run("clash_template", defaultTemplate, now);
|
||||||
|
settings.run("extra_proxy_groups", "", now);
|
||||||
|
settings.run("profile_name", "FeieProxy", now);
|
||||||
|
settings.run("proxy_groups_json", "[]", now);
|
||||||
|
|
||||||
|
const existingAdminUser = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT id, auth_id, subscription_token
|
||||||
|
FROM hy2_users
|
||||||
|
WHERE is_admin = 1 OR username = ?
|
||||||
|
ORDER BY is_admin DESC, id ASC
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.get(env.ADMIN_USERNAME) as
|
||||||
|
| { id: number; auth_id: string; subscription_token: string | null }
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
if (existingAdminUser) {
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
UPDATE hy2_users
|
||||||
|
SET
|
||||||
|
username = @username,
|
||||||
|
password_hash = @passwordHash,
|
||||||
|
auth_secret_encrypted = @secret,
|
||||||
|
is_admin = 1,
|
||||||
|
enabled = 1,
|
||||||
|
expires_at = NULL,
|
||||||
|
traffic_limit_bytes = NULL,
|
||||||
|
subscription_token = COALESCE(subscription_token, @subscriptionToken),
|
||||||
|
subscription_label = CASE
|
||||||
|
WHEN subscription_label = '' THEN @username
|
||||||
|
ELSE subscription_label
|
||||||
|
END,
|
||||||
|
updated_at = @now
|
||||||
|
WHERE id = @id
|
||||||
|
`,
|
||||||
|
).run({
|
||||||
|
id: existingAdminUser.id,
|
||||||
|
username: env.ADMIN_USERNAME,
|
||||||
|
passwordHash,
|
||||||
|
secret: encryptText(env.ADMIN_PASSWORD),
|
||||||
|
subscriptionToken: existingAdminUser.subscription_token ?? createOpaqueToken(),
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
INSERT INTO hy2_users (
|
||||||
|
auth_id, username, password_hash, enabled, expires_at, traffic_limit_bytes,
|
||||||
|
used_tx_bytes, used_rx_bytes, notes, is_admin, auth_secret_encrypted,
|
||||||
|
subscription_token, subscription_label, created_at, updated_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
@authId, @username, @passwordHash, 1, NULL, NULL,
|
||||||
|
0, 0, 'System administrator account', 1, @secret,
|
||||||
|
@subscriptionToken, @username, @now, @now
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
).run({
|
||||||
|
authId: `admin-${createStableId()}`,
|
||||||
|
username: env.ADMIN_USERNAME,
|
||||||
|
passwordHash,
|
||||||
|
secret: encryptText(env.ADMIN_PASSWORD),
|
||||||
|
subscriptionToken: createOpaqueToken(),
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const usersMissingTokens = db
|
||||||
|
.prepare("SELECT id, username FROM hy2_users WHERE subscription_token IS NULL")
|
||||||
|
.all() as Array<{ id: number; username: string }>;
|
||||||
|
|
||||||
|
const updateUserToken = db.prepare(
|
||||||
|
`
|
||||||
|
UPDATE hy2_users
|
||||||
|
SET
|
||||||
|
subscription_token = ?,
|
||||||
|
subscription_label = CASE
|
||||||
|
WHEN subscription_label = '' THEN ?
|
||||||
|
ELSE subscription_label
|
||||||
|
END,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const user of usersMissingTokens) {
|
||||||
|
updateUserToken.run(createOpaqueToken(), user.username, now, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
UPDATE hy2_users
|
||||||
|
SET
|
||||||
|
subscription_label = username,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE subscription_label = ''
|
||||||
|
`,
|
||||||
|
).run(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDb() {
|
||||||
|
if (!global.__hy2PanelDb) {
|
||||||
|
global.__hy2PanelDb = createDbInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
return global.__hy2PanelDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureDb() {
|
||||||
|
if (!global.__hy2PanelDbInit) {
|
||||||
|
global.__hy2PanelDbInit = initializeDb(getDb());
|
||||||
|
}
|
||||||
|
|
||||||
|
await global.__hy2PanelDbInit;
|
||||||
|
return getDb();
|
||||||
|
}
|
||||||
37
lib/env.ts
Normal file
37
lib/env.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
NODE_ENV: z
|
||||||
|
.enum(["development", "test", "production"])
|
||||||
|
.default("development"),
|
||||||
|
APP_URL: z.string().url().default("http://127.0.0.1:3000"),
|
||||||
|
DATABASE_PATH: z.string().default("./data/hy2-panel.db"),
|
||||||
|
SESSION_SECRET: z
|
||||||
|
.string()
|
||||||
|
.min(16)
|
||||||
|
.default("change-this-session-secret-before-production"),
|
||||||
|
SESSION_COOKIE_SECURE: z
|
||||||
|
.enum(["auto", "true", "false"])
|
||||||
|
.default("auto"),
|
||||||
|
SESSION_TTL_HOURS: z.coerce.number().int().min(1).max(24 * 365).default(720),
|
||||||
|
ADMIN_USERNAME: z.string().min(1).max(64).default("admin"),
|
||||||
|
ADMIN_PASSWORD: z.string().min(8).max(128).default("change-me-now"),
|
||||||
|
POLLER_ENABLED: z
|
||||||
|
.union([z.literal("true"), z.literal("false")])
|
||||||
|
.default("true")
|
||||||
|
.transform((value) => value === "true"),
|
||||||
|
POLLER_STARTUP_DELAY_MS: z.coerce.number().int().min(0).max(60_000).default(2500),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AppEnv = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
let cachedEnv: AppEnv | null = null;
|
||||||
|
|
||||||
|
export function getEnv(): AppEnv {
|
||||||
|
if (cachedEnv) {
|
||||||
|
return cachedEnv;
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedEnv = schema.parse(process.env);
|
||||||
|
return cachedEnv;
|
||||||
|
}
|
||||||
40
lib/hy2.ts
Normal file
40
lib/hy2.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { getNodeBySlug, getUserLifecycleState } from "@/lib/store";
|
||||||
|
|
||||||
|
export function parseHy2Userpass(auth: string) {
|
||||||
|
const separator = auth.indexOf(":");
|
||||||
|
if (separator <= 0 || separator === auth.length - 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
username: auth.slice(0, separator),
|
||||||
|
password: auth.slice(separator + 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyNodeToken(slug: string, token: string | null) {
|
||||||
|
const node = await getNodeBySlug(slug);
|
||||||
|
if (!node) {
|
||||||
|
return { ok: false, reason: "node_not_found" as const, node: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!node.enabled) {
|
||||||
|
return { ok: false, reason: "node_disabled" as const, node };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token || token !== node.auth_token) {
|
||||||
|
return { ok: false, reason: "node_token_invalid" as const, node };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, reason: "ok" as const, node };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluateUserForAuth(user: {
|
||||||
|
enabled: number;
|
||||||
|
expires_at: string | null;
|
||||||
|
traffic_limit_bytes: number | null;
|
||||||
|
used_tx_bytes: number;
|
||||||
|
used_rx_bytes: number;
|
||||||
|
}) {
|
||||||
|
return getUserLifecycleState(user);
|
||||||
|
}
|
||||||
27
lib/password.ts
Normal file
27
lib/password.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
const BCRYPT_ROUNDS = 12;
|
||||||
|
|
||||||
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
|
return bcrypt.hash(password, BCRYPT_ROUNDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(
|
||||||
|
password: string,
|
||||||
|
passwordHash: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
return bcrypt.compare(password, passwordHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOpaqueToken(size = 24): string {
|
||||||
|
return randomBytes(size).toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStableId(): string {
|
||||||
|
return randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sha256(input: string): string {
|
||||||
|
return createHash("sha256").update(input).digest("hex");
|
||||||
|
}
|
||||||
127
lib/poller.ts
Normal file
127
lib/poller.ts
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import { ensureDb, getDb } from "@/lib/db";
|
||||||
|
import { getEnv } from "@/lib/env";
|
||||||
|
import {
|
||||||
|
applyNodeSync,
|
||||||
|
getKickCandidates,
|
||||||
|
listEnabledNodesForPolling,
|
||||||
|
shouldPollNode,
|
||||||
|
writeNodeSyncError,
|
||||||
|
} from "@/lib/store";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
var __hy2PanelPollerStarted: boolean | undefined;
|
||||||
|
var __hy2PanelPollerRunning: boolean | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrafficMap = Record<string, { tx: number; rx: number }>;
|
||||||
|
|
||||||
|
async function fetchJson<T>(url: string, init: RequestInit): Promise<T> {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...init,
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncNode(node: Awaited<ReturnType<typeof listEnabledNodesForPolling>>[number]) {
|
||||||
|
const startedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const baseUrl = node.traffic_stats_url.replace(/\/$/, "");
|
||||||
|
const headers = {
|
||||||
|
Authorization: node.traffic_stats_secret,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
const [traffic, online, streams] = await Promise.all([
|
||||||
|
fetchJson<TrafficMap>(`${baseUrl}/traffic?clear=1`, { headers }),
|
||||||
|
fetchJson<Record<string, number>>(`${baseUrl}/online`, { headers }),
|
||||||
|
fetchJson<{ streams?: unknown[] }>(`${baseUrl}/dump/streams`, { headers }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const kickAuthIds = await getKickCandidates(Object.keys(online));
|
||||||
|
|
||||||
|
if (kickAuthIds.length > 0) {
|
||||||
|
await fetch(`${baseUrl}/kick`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(kickAuthIds),
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
}).then((response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`kick failed: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureDb();
|
||||||
|
const db = getDb();
|
||||||
|
const finishedAt = new Date().toISOString();
|
||||||
|
const transaction = db.transaction(() => {
|
||||||
|
applyNodeSync(db, {
|
||||||
|
node,
|
||||||
|
startedAt,
|
||||||
|
finishedAt,
|
||||||
|
traffic,
|
||||||
|
online,
|
||||||
|
streamCount: streams.streams?.length ?? 0,
|
||||||
|
kickedAuthIds: kickAuthIds,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
transaction();
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "unknown_sync_error";
|
||||||
|
await writeNodeSyncError(node.id, startedAt, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPollingCycle() {
|
||||||
|
if (global.__hy2PanelPollerRunning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
global.__hy2PanelPollerRunning = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nodes = await listEnabledNodesForPolling();
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (await shouldPollNode(node.id, node.poll_interval_seconds)) {
|
||||||
|
await syncNode(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
global.__hy2PanelPollerRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startPoller() {
|
||||||
|
const env = getEnv();
|
||||||
|
if (!env.POLLER_ENABLED || global.__hy2PanelPollerStarted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
global.__hy2PanelPollerStarted = true;
|
||||||
|
|
||||||
|
const start = () => {
|
||||||
|
runPollingCycle().catch(() => undefined);
|
||||||
|
const handle = setInterval(() => {
|
||||||
|
runPollingCycle().catch(() => undefined);
|
||||||
|
}, 5_000);
|
||||||
|
handle.unref();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (env.POLLER_STARTUP_DELAY_MS > 0) {
|
||||||
|
const timeout = setTimeout(start, env.POLLER_STARTUP_DELAY_MS);
|
||||||
|
timeout.unref();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
start();
|
||||||
|
}
|
||||||
24
lib/request-origin.ts
Normal file
24
lib/request-origin.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
|
import { getEnv } from "@/lib/env";
|
||||||
|
|
||||||
|
export async function getRequestOrigin() {
|
||||||
|
const headerList = await headers();
|
||||||
|
const forwardedProto = headerList.get("x-forwarded-proto");
|
||||||
|
const forwardedHost = headerList.get("x-forwarded-host");
|
||||||
|
const host = forwardedHost || headerList.get("host");
|
||||||
|
|
||||||
|
if (!host) {
|
||||||
|
return getEnv().APP_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
const proto =
|
||||||
|
forwardedProto || (host.startsWith("localhost") || host.startsWith("127.0.0.1") ? "http" : "http");
|
||||||
|
|
||||||
|
return `${proto}://${host}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOriginFromRequest(request: Request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
return `${url.protocol}//${url.host}`;
|
||||||
|
}
|
||||||
188
lib/session.ts
Normal file
188
lib/session.ts
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { getEnv } from "@/lib/env";
|
||||||
|
import { createOpaqueToken, createStableId, sha256 } from "@/lib/password";
|
||||||
|
import {
|
||||||
|
createAdminSessionRecord,
|
||||||
|
createUserSessionRecord,
|
||||||
|
deleteAdminSessionByHash,
|
||||||
|
deleteUserSessionByHash,
|
||||||
|
getAdminSessionByHash,
|
||||||
|
getUserSessionByHash,
|
||||||
|
touchAdminSession,
|
||||||
|
touchUserSession,
|
||||||
|
} from "@/lib/store";
|
||||||
|
|
||||||
|
const ADMIN_COOKIE_NAME = "hy2_panel_admin_session";
|
||||||
|
const USER_COOKIE_NAME = "hy2_panel_user_session";
|
||||||
|
|
||||||
|
function shouldUseSecureCookies() {
|
||||||
|
const env = getEnv();
|
||||||
|
|
||||||
|
if (env.SESSION_COOKIE_SECURE === "true") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (env.SESSION_COOKIE_SECURE === "false") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new URL(env.APP_URL).protocol === "https:";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAdminSession(adminId: number) {
|
||||||
|
const env = getEnv();
|
||||||
|
const token = createOpaqueToken(32);
|
||||||
|
const expiresAt = new Date(
|
||||||
|
Date.now() + env.SESSION_TTL_HOURS * 60 * 60 * 1000,
|
||||||
|
).toISOString();
|
||||||
|
|
||||||
|
await createAdminSessionRecord({
|
||||||
|
id: createStableId(),
|
||||||
|
adminId,
|
||||||
|
sessionHash: sha256(`${env.SESSION_SECRET}:${token}`),
|
||||||
|
expiresAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.set(ADMIN_COOKIE_NAME, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: shouldUseSecureCookies(),
|
||||||
|
path: "/",
|
||||||
|
expires: new Date(expiresAt),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUserSession(userId: number) {
|
||||||
|
const env = getEnv();
|
||||||
|
const token = createOpaqueToken(32);
|
||||||
|
const expiresAt = new Date(
|
||||||
|
Date.now() + env.SESSION_TTL_HOURS * 60 * 60 * 1000,
|
||||||
|
).toISOString();
|
||||||
|
|
||||||
|
await createUserSessionRecord({
|
||||||
|
id: createStableId(),
|
||||||
|
userId,
|
||||||
|
sessionHash: sha256(`${env.SESSION_SECRET}:${token}`),
|
||||||
|
expiresAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.set(USER_COOKIE_NAME, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: shouldUseSecureCookies(),
|
||||||
|
path: "/",
|
||||||
|
expires: new Date(expiresAt),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function destroyAdminSession() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const raw = cookieStore.get(ADMIN_COOKIE_NAME)?.value;
|
||||||
|
|
||||||
|
if (raw) {
|
||||||
|
await deleteAdminSessionByHash(sha256(`${getEnv().SESSION_SECRET}:${raw}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
cookieStore.delete(ADMIN_COOKIE_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function destroyUserSession() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const raw = cookieStore.get(USER_COOKIE_NAME)?.value;
|
||||||
|
|
||||||
|
if (raw) {
|
||||||
|
await deleteUserSessionByHash(sha256(`${getEnv().SESSION_SECRET}:${raw}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
cookieStore.delete(USER_COOKIE_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAdminSession() {
|
||||||
|
const raw = (await cookies()).get(ADMIN_COOKIE_NAME)?.value;
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
const session = await getAdminSessionByHash(
|
||||||
|
sha256(`${getEnv().SESSION_SECRET}:${raw}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (new Date(session.expires_at).getTime() <= Date.now()) {
|
||||||
|
await deleteAdminSessionByHash(sha256(`${getEnv().SESSION_SECRET}:${raw}`));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Date.now() - new Date(session.last_seen_at).getTime() > 5 * 60 * 1000) {
|
||||||
|
await touchAdminSession(session.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserSession() {
|
||||||
|
const raw = (await cookies()).get(USER_COOKIE_NAME)?.value;
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
const session = await getUserSessionByHash(
|
||||||
|
sha256(`${getEnv().SESSION_SECRET}:${raw}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (new Date(session.expires_at).getTime() <= Date.now()) {
|
||||||
|
await deleteUserSessionByHash(sha256(`${getEnv().SESSION_SECRET}:${raw}`));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Date.now() - new Date(session.last_seen_at).getTime() > 5 * 60 * 1000) {
|
||||||
|
await touchUserSession(session.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireAdminSession() {
|
||||||
|
const session = await getAdminSession();
|
||||||
|
if (!session) {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireAdminAccess() {
|
||||||
|
const adminSession = await getAdminSession();
|
||||||
|
if (adminSession) {
|
||||||
|
return {
|
||||||
|
username: adminSession.username,
|
||||||
|
source: "admin" as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const userSession = await getUserSession();
|
||||||
|
if (userSession?.is_admin) {
|
||||||
|
return {
|
||||||
|
username: userSession.username,
|
||||||
|
source: "user" as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireUserSession() {
|
||||||
|
const session = await getUserSession();
|
||||||
|
if (!session) {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
1062
lib/store.ts
Normal file
1062
lib/store.ts
Normal file
File diff suppressed because it is too large
Load Diff
196
lib/subscription.ts
Normal file
196
lib/subscription.ts
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { decryptText } from "@/lib/crypto";
|
||||||
|
import { getEnv } from "@/lib/env";
|
||||||
|
import type {
|
||||||
|
Hy2NodeRow,
|
||||||
|
Hy2UserRow,
|
||||||
|
ProxyGroupConfig,
|
||||||
|
} from "@/lib/store";
|
||||||
|
import { buildNodeAuthUrl, formatBytes } from "@/lib/store";
|
||||||
|
|
||||||
|
type ClashSettings = {
|
||||||
|
template: string;
|
||||||
|
extraProxyGroups: string;
|
||||||
|
profileName: string;
|
||||||
|
proxyGroups: ProxyGroupConfig[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function readDefaultTemplate() {
|
||||||
|
const filePath = path.join(process.cwd(), "app", "config", "config.yaml");
|
||||||
|
return fs.readFileSync(filePath, "utf8").trimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
function indent(text: string, spaces: number) {
|
||||||
|
const prefix = " ".repeat(spaces);
|
||||||
|
return text
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => (line ? `${prefix}${line}` : line))
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProxy(node: Hy2NodeRow, authString: string) {
|
||||||
|
const lines = [
|
||||||
|
`- name: "${node.client_name || node.name}"`,
|
||||||
|
" type: hysteria2",
|
||||||
|
` server: ${node.server_host}`,
|
||||||
|
` port: ${node.server_port}`,
|
||||||
|
` password: "${authString}"`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (node.sni) {
|
||||||
|
lines.push(` sni: ${node.sni}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.skip_cert_verify) {
|
||||||
|
lines.push(" skip-cert-verify: true");
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(" bandwidth:");
|
||||||
|
lines.push(` up: ${node.bandwidth_up_mbps} mbps`);
|
||||||
|
lines.push(` down: ${node.bandwidth_down_mbps} mbps`);
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGroupEntry(name: string, proxyNames: string[]) {
|
||||||
|
const quoted = proxyNames.map((name) => `"${name}"`).join(", ");
|
||||||
|
return [
|
||||||
|
` - name: ${name}`,
|
||||||
|
" type: select",
|
||||||
|
` proxies: [${quoted}]`,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeRFC5987ValueChars(value: string) {
|
||||||
|
return encodeURIComponent(value)
|
||||||
|
.replace(/['()]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)
|
||||||
|
.replace(/\*/g, "%2A")
|
||||||
|
.replace(/%(7C|60|5E)/g, (_, code) =>
|
||||||
|
String.fromCharCode(Number.parseInt(code, 16)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAsciiFilenameFallback(value: string) {
|
||||||
|
const ascii = value
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/[^\x20-\x7E]+/g, " ")
|
||||||
|
.replace(/["\\]/g, "_")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
return ascii || "subscription";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSubscriptionHeaders(user: Hy2UserRow, profileName: string) {
|
||||||
|
const expire = user.expires_at
|
||||||
|
? Math.round(new Date(user.expires_at).getTime() / 1000)
|
||||||
|
: user.is_admin
|
||||||
|
? 4102444800
|
||||||
|
: Math.round(Date.now() / 1000) + 3600 * 24 * 365 * 10;
|
||||||
|
|
||||||
|
const total =
|
||||||
|
user.is_admin || user.traffic_limit_bytes == null
|
||||||
|
? "9223372036854775807"
|
||||||
|
: String(user.traffic_limit_bytes);
|
||||||
|
const fallbackFilename = buildAsciiFilenameFallback(profileName);
|
||||||
|
|
||||||
|
return {
|
||||||
|
"Content-Disposition": `attachment; filename="${fallbackFilename}"; filename*=UTF-8''${encodeRFC5987ValueChars(profileName)}`,
|
||||||
|
"Content-Type": "text/yaml; charset=utf-8",
|
||||||
|
"Profile-Update-Interval": "12",
|
||||||
|
"subscription-userinfo": `upload=${user.used_tx_bytes}; download=${user.used_rx_bytes}; total=${total}; expire=${expire}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSubscriptionUrl(token: string, origin = getEnv().APP_URL) {
|
||||||
|
return `${origin}/api/subscription/${token}/clash`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildNodeHy2Config(node: Hy2NodeRow, origin = getEnv().APP_URL) {
|
||||||
|
const authUrl = buildNodeAuthUrl(node.slug, node.auth_token, origin);
|
||||||
|
|
||||||
|
return [
|
||||||
|
`listen: ${node.hy2_listen || `:${node.server_port}`}`,
|
||||||
|
"tls:",
|
||||||
|
" cert: /etc/hysteria/server.crt",
|
||||||
|
" key: /etc/hysteria/server.key",
|
||||||
|
"auth:",
|
||||||
|
" type: http",
|
||||||
|
" http:",
|
||||||
|
` url: ${authUrl}`,
|
||||||
|
" insecure: false",
|
||||||
|
"trafficStats:",
|
||||||
|
` listen: ${node.traffic_stats_listen}`,
|
||||||
|
` secret: ${node.traffic_stats_secret}`,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSubscriptionConfig(input: {
|
||||||
|
user: Hy2UserRow;
|
||||||
|
nodes: Hy2NodeRow[];
|
||||||
|
settings: ClashSettings;
|
||||||
|
}) {
|
||||||
|
const authPassword = decryptText(input.user.auth_secret_encrypted);
|
||||||
|
if (!authPassword) {
|
||||||
|
throw new Error("user_secret_unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
const authString = `${input.user.username}:${authPassword}`;
|
||||||
|
const eligibleNodes = input.nodes.filter(
|
||||||
|
(node) => node.enabled && node.server_host && node.server_port > 0,
|
||||||
|
);
|
||||||
|
const nodeNameBySlug = new Map(
|
||||||
|
eligibleNodes.map((node) => [node.slug, node.client_name || node.name]),
|
||||||
|
);
|
||||||
|
const proxyNames = eligibleNodes.map((node) => node.client_name || node.name);
|
||||||
|
const proxiesBlock = eligibleNodes.map((node) => renderProxy(node, authString)).join("\n");
|
||||||
|
const defaultTemplate = input.settings.template.trimEnd() || readDefaultTemplate();
|
||||||
|
const renderedGroups = [
|
||||||
|
renderGroupEntry(input.settings.profileName || "FeieProxy", proxyNames),
|
||||||
|
...input.settings.proxyGroups
|
||||||
|
.map((group) => {
|
||||||
|
const members = group.memberSlugs
|
||||||
|
.map((slug) => nodeNameBySlug.get(slug))
|
||||||
|
.filter((name): name is string => Boolean(name));
|
||||||
|
if (members.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderGroupEntry(group.name, members);
|
||||||
|
})
|
||||||
|
.filter((group): group is string => Boolean(group)),
|
||||||
|
].join("\n");
|
||||||
|
const extraGroups = input.settings.extraProxyGroups
|
||||||
|
.trim()
|
||||||
|
.replace(/^proxy-groups:\s*/i, "");
|
||||||
|
|
||||||
|
return [
|
||||||
|
defaultTemplate,
|
||||||
|
"",
|
||||||
|
"proxies:",
|
||||||
|
indent(proxiesBlock, 2),
|
||||||
|
"",
|
||||||
|
"proxy-groups:",
|
||||||
|
renderedGroups,
|
||||||
|
extraGroups ? `\n${extraGroups}` : "",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultClashTemplate() {
|
||||||
|
return readDefaultTemplate();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describeRemainingTraffic(user: Hy2UserRow) {
|
||||||
|
if (user.is_admin || user.traffic_limit_bytes == null) {
|
||||||
|
return "不限流量";
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = Math.max(
|
||||||
|
user.traffic_limit_bytes - user.used_tx_bytes - user.used_rx_bytes,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return `${formatBytes(remaining)} 剩余`;
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
serverExternalPackages: ["better-sqlite3"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
5172
package-lock.json
generated
Normal file
5172
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -32,6 +32,7 @@
|
|||||||
"unrs-resolver"
|
"unrs-resolver"
|
||||||
],
|
],
|
||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
|
"better-sqlite3",
|
||||||
"sharp",
|
"sharp",
|
||||||
"unrs-resolver"
|
"unrs-resolver"
|
||||||
]
|
]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user