This commit is contained in:
feie9454 2026-04-14 18:05:32 +08:00
parent 68457c9fbd
commit 6378ab4994
40 changed files with 10488 additions and 113 deletions

0
.codex Normal file
View File

120
README.md
View File

@ -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
View 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
View 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>
);
}

View 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
View 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>
);
}

View 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
View 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 URLtrafficStats
</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
View File

@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function AdminIndexPage() {
redirect("/admin/dashboard");
}

View 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");
}

View 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>
);
}

View 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
View 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>
);
}

View 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"`,
},
});
}

View 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 });
}

View 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
View 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"

View File

@ -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;
}
} }

View File

@ -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
View 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
View 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
View 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
View 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>
);
}

View File

@ -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>
);
} }

View File

@ -26,6 +26,7 @@
}, },
}, },
"trustedDependencies": [ "trustedDependencies": [
"better-sqlite3",
"sharp", "sharp",
"unrs-resolver", "unrs-resolver",
], ],

17
deploy/hy2-panel.service Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

196
lib/subscription.ts Normal file
View 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)} 剩余`;
}

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -32,6 +32,7 @@
"unrs-resolver" "unrs-resolver"
], ],
"trustedDependencies": [ "trustedDependencies": [
"better-sqlite3",
"sharp", "sharp",
"unrs-resolver" "unrs-resolver"
] ]