Add new admin panel, failover, dns fallback, providers, limiters. Update XHTTP

This commit is contained in:
Sergei Maklagin
2026-05-11 00:59:35 +03:00
parent 652e0baf57
commit 3bd162ed6f
241 changed files with 36409 additions and 4086 deletions

View File

@@ -0,0 +1,111 @@
# admin_panel
A TypeScript (Vite + React + Material UI, dark theme) admin panel that talks to
the `manager_api/http` server.
The compiled SPA is **embedded into the Go binary via `//go:embed`** — the
post-processed `dist/` directory is checked into the repo, so
`go build -tags with_admin_panel ./cmd/sing-box` produces a fully
self-contained binary with no Node.js required at compile time.
The `web/` directory holds the original TypeScript sources, which are only
needed when the panel itself is being modified. `make admin_panel_regen`
rebuilds `dist/` from those sources via `npm run build` followed by an
in-place post-processing step (`cmd/internal/admin_panel_pack`).
## Layout
```
service/admin_panel/
├── service.go # Go service (build tag: with_admin_panel, //go:embed dist)
├── service_stub.go # Stub when the tag is missing
├── service_test.go # Tests for the SPA handler
├── dist/ # Embedded SPA bytes (committed to git)
│ ├── index.html
│ ├── index.html.gz
│ └── assets/
│ ├── index-*.js
│ ├── index-*.js.gz
│ ├── index-*.css
│ ├── index-*.css.gz
│ └── inter-*.woff2
└── web/ # Vite + React TypeScript source (only needed to regen)
├── package.json
├── vite.config.ts
├── tsconfig.json
├── index.html
└── src/
├── main.tsx
├── App.tsx
├── theme.ts
├── api/{client,types}.ts
├── auth/AuthContext.tsx
├── components/{Layout,CrudPage}.tsx
└── pages/*.tsx
cmd/internal/admin_panel_pack/
└── main.go # Post-processor: drops .woff, rewrites CSS, pre-gzips text
```
## Building sing-box with the panel
`dist/` is committed, so any developer or CI machine can build a
self-contained binary with no Node.js / npm available:
```bash
go build -tags with_admin_panel ./cmd/sing-box
# or
make build_admin_panel
```
If the tag is omitted the service registers a stub that errors out on start.
## Regenerating dist/
Whenever the SPA source under `web/` is changed, run:
```bash
make admin_panel_regen
```
That target performs two steps:
1. `make admin_panel_web``npm install` + `npm run build` in `web/`,
producing `service/admin_panel/dist/`.
2. `make admin_panel_pack` — runs the
`cmd/internal/admin_panel_pack` post-processor, which:
- deletes the legacy `*.woff` (WOFF 1.0) fallback fonts; every browser
since 2014 reads WOFF2 natively, and shipping both formats roughly
doubles the embedded font payload;
- rewrites the bundled `*.css` to drop `,url(...).woff) format("woff")`
references so the @font-face rules don't point at files that aren't
shipped;
- drops a gzip-compressed `*.gz` companion next to every text-like
asset (.html, .css, .js, .svg, .json) at `gzip.BestCompression`. The
runtime serves those bytes verbatim with `Content-Encoding: gzip`
when the client advertises gzip support, and falls back to the raw
file otherwise.
After the post-processing pass, commit the resulting `dist/` directory
along with your source changes.
## Configuring sing-box
Add a service entry of type `admin-panel`:
```json
{
"services": [
{
"type": "admin-panel",
"tag": "admin",
"listen": "127.0.0.1",
"listen_port": 8081
}
]
}
```
The panel itself does not require any back-end auth: at sign-in the user
enters the URL and bearer key of a `manager-api` instance. Both are stored
only in the browser's `localStorage`.

View File

@@ -1,400 +0,0 @@
package migration
import (
"database/sql"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/postgres"
"github.com/sagernet/sing-box/common/migrate/source"
)
var migrations = map[string]string{
"1_initialize_schema.up.sql": `
SET statement_timeout = 0;
SET lock_timeout = 0;
SET idle_in_transaction_session_timeout = 0;
SET client_encoding = 'UTF8';
SET standard_conforming_strings = on;
SELECT pg_catalog.set_config('search_path', '', false);
SET check_function_bodies = false;
SET xmloption = content;
SET client_min_messages = warning;
SET row_security = off;
CREATE SEQUENCE public.goadmin_menu_myid_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
MAXVALUE 99999999
CACHE 1;
SET default_tablespace = '';
SET default_table_access_method = heap;
CREATE TABLE public.goadmin_menu (
id integer DEFAULT nextval('public.goadmin_menu_myid_seq'::regclass) NOT NULL,
parent_id integer DEFAULT 0 NOT NULL,
type integer DEFAULT 0,
"order" integer DEFAULT 0 NOT NULL,
title character varying(50) NOT NULL,
header character varying(100),
plugin_name character varying(100) NOT NULL,
icon character varying(50) NOT NULL,
uri character varying(3000) NOT NULL,
uuid character varying(100),
created_at timestamp without time zone DEFAULT now(),
updated_at timestamp without time zone DEFAULT now()
);
CREATE SEQUENCE public.goadmin_operation_log_myid_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
MAXVALUE 99999999
CACHE 1;
CREATE TABLE public.goadmin_operation_log (
id integer DEFAULT nextval('public.goadmin_operation_log_myid_seq'::regclass) NOT NULL,
user_id integer NOT NULL,
path character varying(255) NOT NULL,
method character varying(10) NOT NULL,
ip character varying(15) NOT NULL,
input text NOT NULL,
created_at timestamp without time zone DEFAULT now(),
updated_at timestamp without time zone DEFAULT now()
);
CREATE SEQUENCE public.goadmin_permissions_myid_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
MAXVALUE 99999999
CACHE 1;
CREATE TABLE public.goadmin_permissions (
id integer DEFAULT nextval('public.goadmin_permissions_myid_seq'::regclass) NOT NULL,
name character varying(50) NOT NULL,
slug character varying(50) NOT NULL,
http_method character varying(255),
http_path text NOT NULL,
created_at timestamp without time zone DEFAULT now(),
updated_at timestamp without time zone DEFAULT now()
);
CREATE TABLE public.goadmin_role_menu (
role_id integer NOT NULL,
menu_id integer NOT NULL,
created_at timestamp without time zone DEFAULT now(),
updated_at timestamp without time zone DEFAULT now()
);
CREATE TABLE public.goadmin_role_permissions (
role_id integer NOT NULL,
permission_id integer NOT NULL,
created_at timestamp without time zone DEFAULT now(),
updated_at timestamp without time zone DEFAULT now()
);
CREATE TABLE public.goadmin_role_users (
role_id integer NOT NULL,
user_id integer NOT NULL,
created_at timestamp without time zone DEFAULT now(),
updated_at timestamp without time zone DEFAULT now()
);
CREATE SEQUENCE public.goadmin_roles_myid_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
MAXVALUE 99999999
CACHE 1;
CREATE TABLE public.goadmin_roles (
id integer DEFAULT nextval('public.goadmin_roles_myid_seq'::regclass) NOT NULL,
name character varying NOT NULL,
slug character varying NOT NULL,
created_at timestamp without time zone DEFAULT now(),
updated_at timestamp without time zone DEFAULT now()
);
CREATE SEQUENCE public.goadmin_session_myid_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
MAXVALUE 99999999
CACHE 1;
CREATE TABLE public.goadmin_session (
id integer DEFAULT nextval('public.goadmin_session_myid_seq'::regclass) NOT NULL,
sid character varying(50) NOT NULL,
"values" character varying(3000) NOT NULL,
created_at timestamp without time zone DEFAULT now(),
updated_at timestamp without time zone DEFAULT now()
);
CREATE SEQUENCE public.goadmin_site_myid_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
MAXVALUE 99999999
CACHE 1;
CREATE TABLE public.goadmin_site (
id integer DEFAULT nextval('public.goadmin_site_myid_seq'::regclass) NOT NULL,
key character varying(100) NOT NULL,
value text NOT NULL,
type integer DEFAULT 0,
description character varying(3000),
state integer DEFAULT 0,
created_at timestamp without time zone DEFAULT now(),
updated_at timestamp without time zone DEFAULT now()
);
CREATE TABLE public.goadmin_user_permissions (
user_id integer NOT NULL,
permission_id integer NOT NULL,
created_at timestamp without time zone DEFAULT now(),
updated_at timestamp without time zone DEFAULT now()
);
CREATE SEQUENCE public.goadmin_users_myid_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
MAXVALUE 99999999
CACHE 1;
CREATE TABLE public.goadmin_users (
id integer DEFAULT nextval('public.goadmin_users_myid_seq'::regclass) NOT NULL,
username character varying(100) NOT NULL,
password character varying(100) NOT NULL,
name character varying(100) NOT NULL,
avatar character varying(255),
remember_token character varying(100),
created_at timestamp without time zone DEFAULT now(),
updated_at timestamp without time zone DEFAULT now()
);
INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (1, 0, 1, 1, 'Dashboard', NULL, '', 'fa-bar-chart', '/', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (2, 0, 1, 2, 'Admin', NULL, '', 'fa-tasks', '', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (3, 2, 1, 2, 'Users', NULL, '', 'fa-users', '/info/manager', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (4, 2, 1, 3, 'Roles', NULL, '', 'fa-user', '/info/roles', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (5, 2, 1, 4, 'Permission', NULL, '', 'fa-ban', '/info/permission', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (7, 2, 1, 6, 'Operation log', NULL, '', 'fa-history', '/info/op', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (9, 0, 0, 9, 'Users', '', '', 'fa-users', '/info/users', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (14, 0, 0, 12, 'Github', 'Miscellaneous', '', 'fa-github', 'https://github.com/shtorm-7/sing-box-extended', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (15, 0, 0, 13, 'Donate', '', '', 'fa-heart', 'https://github.com/shtorm-7/sing-box-extended#support-the-project', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (13, 0, 0, 7, 'Squads', 'General', '', 'fa-gg', '/info/squads', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (11, 0, 0, 8, 'Nodes', '', '', 'fa-sitemap', '/info/nodes', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (10, 0, 0, 10, 'Connection limiters', 'Limiters', '', 'fa-plug', '/info/connection_limiters', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (8, 0, 0, 11, 'Bandwidth limiters', '', '', 'fa-dashboard', '/info/bandwidth_limiters', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_permissions (id, name, slug, http_method, http_path, created_at, updated_at) VALUES (1, 'All permission', '*', '', '*', '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_permissions (id, name, slug, http_method, http_path, created_at, updated_at) VALUES (2, 'Dashboard', 'dashboard', 'GET,PUT,POST,DELETE', '/', '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_role_menu (role_id, menu_id, created_at, updated_at) VALUES (1, 1, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_role_menu (role_id, menu_id, created_at, updated_at) VALUES (1, 7, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_role_menu (role_id, menu_id, created_at, updated_at) VALUES (2, 7, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (1, 1, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (1, 2, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (2, 2, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL);
INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL);
INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL);
INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL);
INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL);
INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL);
INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL);
INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL);
INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL);
INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL);
INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL);
INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL);
INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL);
INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL);
INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL);
INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL);
INSERT INTO public.goadmin_role_users (role_id, user_id, created_at, updated_at) VALUES (1, 1, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_role_users (role_id, user_id, created_at, updated_at) VALUES (2, 2, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_roles (id, name, slug, created_at, updated_at) VALUES (1, 'Administrator', 'administrator', '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_roles (id, name, slug, created_at, updated_at) VALUES (2, 'Operator', 'operator', '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (6, 'site_off', 'false', 0, NULL, 1, '2026-02-15 09:57:02.436501', '2026-02-15 09:57:02.436501');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (7, 'prohibit_config_modification', 'false', 0, NULL, 1, '2026-02-15 09:57:02.441183', '2026-02-15 09:57:02.441183');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (11, 'login_url', '/login', 0, NULL, 1, '2026-02-15 09:57:02.459525', '2026-02-15 09:57:02.459525');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (16, 'open_admin_api', 'false', 0, NULL, 1, '2026-02-15 09:57:02.483908', '2026-02-15 09:57:02.483908');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (18, 'domain', '', 0, NULL, 1, '2026-02-15 09:57:02.493151', '2026-02-15 09:57:02.493151');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (23, 'asset_root_path', './public/', 0, NULL, 1, '2026-02-15 09:57:02.517213', '2026-02-15 09:57:02.517213');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (24, 'url_prefix', 'admin', 0, NULL, 1, '2026-02-15 09:57:02.521815', '2026-02-15 09:57:02.521815');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (33, 'exclude_theme_components', 'null', 0, NULL, 1, '2026-02-15 09:57:02.565725', '2026-02-15 09:57:02.565725');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (39, 'app_id', 'Qn0eh7HQsrt9', 0, NULL, 1, '2026-02-15 09:57:02.592551', '2026-02-15 09:57:02.592551');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (41, 'auth_user_table', 'goadmin_users', 0, NULL, 1, '2026-02-15 09:57:02.601496', '2026-02-15 09:57:02.601496');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (53, 'bootstrap_file_path', '', 0, NULL, 1, '2026-02-15 09:57:02.658984', '2026-02-15 09:57:02.658984');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (55, 'index_url', '/', 0, NULL, 1, '2026-02-15 09:57:02.668457', '2026-02-15 09:57:02.668457');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (66, 'login_logo', '', 0, NULL, 1, '2026-02-15 09:57:02.719608', '2026-02-15 09:57:02.719608');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (67, 'hide_visitor_user_center_entrance', 'false', 0, NULL, 1, '2026-02-15 09:57:02.724307', '2026-02-15 09:57:02.724307');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (68, 'go_mod_file_path', '', 0, NULL, 1, '2026-02-15 09:57:02.728694', '2026-02-15 09:57:02.728694');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (3, 'logger_encoder_caller', 'full', 0, NULL, 1, '2026-02-15 09:57:02.420312', '2026-02-15 09:57:02.420312');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (60, 'logger_encoder_caller_key', 'caller', 0, NULL, 1, '2026-02-15 09:57:02.692189', '2026-02-15 09:57:02.692189');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (34, 'logo', 'Sing-box Extended', 0, NULL, 1, '2026-02-15 09:57:02.570594', '2026-02-15 09:57:02.570594');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (69, 'env', 'prod', 0, NULL, 1, '2026-02-15 09:57:02.733059', '2026-02-15 09:57:02.733059');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (29, 'color_scheme', 'skin-black', 0, NULL, 1, '2026-02-15 09:57:02.545599', '2026-02-15 09:57:02.545599');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (17, 'allow_del_operation_log', 'false', 0, NULL, 1, '2026-02-15 09:57:02.488458', '2026-02-15 09:57:02.488458');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (35, 'info_log_path', '', 0, NULL, 1, '2026-02-15 09:57:02.574649', '2026-02-15 09:57:02.574649');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (22, 'operation_log_off', 'false', 0, NULL, 1, '2026-02-15 09:57:02.512394', '2026-02-15 09:57:02.512394');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (42, 'hide_app_info_entrance', 'false', 0, NULL, 1, '2026-02-15 09:57:02.606071', '2026-02-15 09:57:02.606071');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (12, 'access_log_path', '', 0, NULL, 1, '2026-02-15 09:57:02.464612', '2026-02-15 09:57:02.464612');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (32, 'logger_rotate_max_age', '30', 0, NULL, 1, '2026-02-15 09:57:02.560801', '2026-02-15 09:57:02.560801');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (40, 'custom_foot_html', '', 0, NULL, 1, '2026-02-15 09:57:02.597285', '2026-02-15 09:57:02.597285');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (62, 'logger_encoder_duration', 'string', 0, NULL, 1, '2026-02-15 09:57:02.701522', '2026-02-15 09:57:02.701522');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (65, 'logger_encoder_level_key', 'level', 0, NULL, 1, '2026-02-15 09:57:02.715108', '2026-02-15 09:57:02.715108');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (64, 'debug', 'false', 0, NULL, 1, '2026-02-15 09:57:02.710705', '2026-02-15 09:57:02.710705');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (43, 'hide_plugin_entrance', 'false', 0, NULL, 1, '2026-02-15 09:57:02.610825', '2026-02-15 09:57:02.610825');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (54, 'animation_type', '', 0, NULL, 1, '2026-02-15 09:57:02.663713', '2026-02-15 09:57:02.663713');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (48, 'theme', 'sword', 0, NULL, 1, '2026-02-15 09:57:02.634039', '2026-02-15 09:57:02.634039');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (45, 'info_log_off', 'false', 0, NULL, 1, '2026-02-15 09:57:02.620165', '2026-02-15 09:57:02.620165');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (31, 'error_log_path', '', 0, NULL, 1, '2026-02-15 09:57:02.555798', '2026-02-15 09:57:02.555798');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (5, 'asset_url', '', 0, NULL, 1, '2026-02-15 09:57:02.431855', '2026-02-15 09:57:02.431855');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (36, 'logger_encoder_encoding', 'console', 0, NULL, 1, '2026-02-15 09:57:02.579052', '2026-02-15 09:57:02.579052');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (27, 'login_title', 'Sing-box Extended', 0, NULL, 1, '2026-02-15 09:57:02.536102', '2026-02-15 09:57:02.536102');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (51, 'animation_duration', '0.00', 0, NULL, 1, '2026-02-15 09:57:02.64867', '2026-02-15 09:57:02.64867');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (19, 'file_upload_engine', '{"name":"local"}', 0, NULL, 1, '2026-02-15 09:57:02.49794', '2026-02-15 09:57:02.49794');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (26, 'logger_encoder_time', 'iso8601', 0, NULL, 1, '2026-02-15 09:57:02.531365', '2026-02-15 09:57:02.531365');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (10, 'custom_404_html', '', 0, NULL, 1, '2026-02-15 09:57:02.454777', '2026-02-15 09:57:02.454777');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (58, 'sql_log', 'false', 0, NULL, 1, '2026-02-15 09:57:02.682567', '2026-02-15 09:57:02.682567');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (2, 'logger_encoder_message_key', 'msg', 0, NULL, 1, '2026-02-15 09:57:02.415189', '2026-02-15 09:57:02.415189');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (46, 'logger_encoder_stacktrace_key', 'stacktrace', 0, NULL, 1, '2026-02-15 09:57:02.624977', '2026-02-15 09:57:02.624977');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (63, 'mini_logo', 'SBE', 0, NULL, 1, '2026-02-15 09:57:02.706145', '2026-02-15 09:57:02.706145');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (38, 'custom_403_html', '', 0, NULL, 1, '2026-02-15 09:57:02.588062', '2026-02-15 09:57:02.588062');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (30, 'language', 'en', 0, NULL, 1, '2026-02-15 09:57:02.550466', '2026-02-15 09:57:02.550466');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (15, 'hide_config_center_entrance', 'false', 0, NULL, 1, '2026-02-15 09:57:02.479097', '2026-02-15 09:57:02.479097');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (59, 'logger_rotate_max_backups', '5', 0, NULL, 1, '2026-02-15 09:57:02.687429', '2026-02-15 09:57:02.687429');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (57, 'custom_head_html', '', 0, NULL, 1, '2026-02-15 09:57:02.677723', '2026-02-15 09:57:02.677723');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (52, 'custom_500_html', '', 0, NULL, 1, '2026-02-15 09:57:02.654236', '2026-02-15 09:57:02.654236');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (44, 'title', 'Sing-box Extended', 0, NULL, 1, '2026-02-15 09:57:02.615471', '2026-02-15 09:57:02.615471');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (47, 'session_life_time', '7200', 0, NULL, 1, '2026-02-15 09:57:02.629619', '2026-02-15 09:57:02.629619');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (8, 'access_log_off', 'false', 0, NULL, 1, '2026-02-15 09:57:02.445593', '2026-02-15 09:57:02.445593');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (49, 'error_log_off', 'false', 0, NULL, 1, '2026-02-15 09:57:02.6385', '2026-02-15 09:57:02.6385');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (50, 'logger_rotate_max_size', '10', 0, NULL, 1, '2026-02-15 09:57:02.643733', '2026-02-15 09:57:02.643733');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (14, 'logger_rotate_compress', 'false', 0, NULL, 1, '2026-02-15 09:57:02.474296', '2026-02-15 09:57:02.474296');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (13, 'logger_encoder_time_key', 'ts', 0, NULL, 1, '2026-02-15 09:57:02.469396', '2026-02-15 09:57:02.469396');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (37, 'animation_delay', '0.00', 0, NULL, 1, '2026-02-15 09:57:02.583815', '2026-02-15 09:57:02.583815');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (20, 'extra', '', 0, NULL, 1, '2026-02-15 09:57:02.50276', '2026-02-15 09:57:02.50276');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (25, 'access_assets_log_off', 'false', 0, NULL, 1, '2026-02-15 09:57:02.526618', '2026-02-15 09:57:02.526618');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (4, 'logger_level', '0', 0, NULL, 1, '2026-02-15 09:57:02.426736', '2026-02-15 09:57:02.426736');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (9, 'footer_info', '', 0, NULL, 1, '2026-02-15 09:57:02.450409', '2026-02-15 09:57:02.450409');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (21, 'no_limit_login_ip', 'false', 0, NULL, 1, '2026-02-15 09:57:02.507609', '2026-02-15 09:57:02.507609');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (28, 'hide_tool_entrance', 'false', 0, NULL, 1, '2026-02-15 09:57:02.540813', '2026-02-15 09:57:02.540813');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (61, 'logger_encoder_level', 'capitalColor', 0, NULL, 1, '2026-02-15 09:57:02.696859', '2026-02-15 09:57:02.696859');
INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (56, 'logger_encoder_name_key', 'logger', 0, NULL, 1, '2026-02-15 09:57:02.672962', '2026-02-15 09:57:02.672962');
INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (1, 1, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (2, 2, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL);
INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL);
INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL);
INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL);
INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL);
INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL);
INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL);
INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL);
INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL);
INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL);
INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL);
INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL);
INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL);
INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL);
INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL);
INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL);
INSERT INTO public.goadmin_users (id, username, password, name, avatar, remember_token, created_at, updated_at) VALUES (2, 'operator', '$2a$10$rVqkOzHjN2MdlEprRflb1eGP0oZXuSrbJLOmJagFsCd81YZm0bsh.', 'Operator', '', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00');
INSERT INTO public.goadmin_users (id, username, password, name, avatar, remember_token, created_at, updated_at) VALUES (1, 'admin', '$2a$10$ilNHHnX5S6EMw.Ffc1Y1JezYCyquFIO.7Z0vLr1eHJUXnGy4cdrtq', 'admin', '', 'tlNcBVK9AvfYH7WEnwB1RKvocJu8FfRy4um3DJtwdHuJy0dwFsLOgAc0xUfh', '2019-09-10 00:00:00', '2019-09-10 00:00:00');
SELECT pg_catalog.setval('public.goadmin_menu_myid_seq', 12, true);
SELECT pg_catalog.setval('public.goadmin_operation_log_myid_seq', 11, true);
SELECT pg_catalog.setval('public.goadmin_permissions_myid_seq', 2, true);
SELECT pg_catalog.setval('public.goadmin_roles_myid_seq', 2, true);
SELECT pg_catalog.setval('public.goadmin_session_myid_seq', 7, true);
SELECT pg_catalog.setval('public.goadmin_site_myid_seq', 69, true);
SELECT pg_catalog.setval('public.goadmin_users_myid_seq', 2, true);
ALTER TABLE ONLY public.goadmin_menu
ADD CONSTRAINT goadmin_menu_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.goadmin_operation_log
ADD CONSTRAINT goadmin_operation_log_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.goadmin_permissions
ADD CONSTRAINT goadmin_permissions_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.goadmin_roles
ADD CONSTRAINT goadmin_roles_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.goadmin_session
ADD CONSTRAINT goadmin_session_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.goadmin_site
ADD CONSTRAINT goadmin_site_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.goadmin_users
ADD CONSTRAINT goadmin_users_pkey PRIMARY KEY (id);
`,
"1_initialize_schema.down.sql": ``,
}
func MigratePostgreSQL(db *sql.DB) error {
driver, err := postgres.WithInstance(db, &postgres.Config{})
if err != nil {
return err
}
sourceDriver := source.NewRawDriver(migrations)
if err := sourceDriver.Init(); err != nil {
return err
}
m, err := migrate.NewWithInstance(
"raw",
sourceDriver,
"postgres",
driver,
)
if err != nil {
return err
}
return m.Up()
}

View File

@@ -1,13 +0,0 @@
package pages
import (
"github.com/GoAdminGroup/go-admin/context"
"github.com/GoAdminGroup/go-admin/template/types"
)
func DashboardPage(ctx *context.Context) (types.Panel, error) {
return types.Panel{
Title: "Dashboard",
}, nil
}

View File

@@ -4,24 +4,16 @@ package admin_panel
import (
"context"
"database/sql"
"embed"
"encoding/json"
"errors"
"io/fs"
"mime"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/golang-migrate/migrate/v4"
_ "github.com/lib/pq"
"golang.org/x/net/http2"
_ "github.com/GoAdminGroup/go-admin/adapter/chi"
"github.com/GoAdminGroup/go-admin/engine"
"github.com/GoAdminGroup/go-admin/modules/config"
_ "github.com/GoAdminGroup/go-admin/modules/db/drivers/sqlite"
"github.com/GoAdminGroup/go-admin/plugins/admin/modules/table"
"github.com/GoAdminGroup/go-admin/template"
"github.com/GoAdminGroup/go-admin/template/chartjs"
_ "github.com/GoAdminGroup/themes/adminlte"
_ "github.com/GoAdminGroup/themes/sword"
"path"
"strconv"
"strings"
"time"
"github.com/sagernet/sing-box/adapter"
boxService "github.com/sagernet/sing-box/adapter/service"
@@ -30,17 +22,45 @@ import (
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing-box/service/admin_panel/migration"
"github.com/sagernet/sing-box/service/admin_panel/pages"
"github.com/sagernet/sing-box/service/admin_panel/tables"
CM "github.com/sagernet/sing-box/service/manager/constant"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
N "github.com/sagernet/sing/common/network"
aTLS "github.com/sagernet/sing/common/tls"
"github.com/sagernet/sing/service"
sHTTP "github.com/sagernet/sing/protocol/http"
"github.com/go-chi/chi/v5"
"golang.org/x/net/http2"
)
// distFS holds the SPA bytes produced by `npm run build` (Vite) and then
// post-processed by `cmd/internal/admin_panel_pack`. The directory
// is checked into the repo so a plain `go build -tags with_admin_panel`
// produces a self-contained binary — no Node.js required at compile time.
//
// The post-processor:
// - deletes the legacy *.woff fonts (every browser since 2014 reads
// WOFF2 natively) and removes their references from the bundled CSS;
// - drops a gzip-compressed `*.gz` companion next to every compressible
// text asset (.html, .css, .js, …) using BestCompression. We pass
// those bytes through verbatim with Content-Encoding: gzip when the
// client advertises gzip, and fall back to the raw file otherwise.
//
//go:embed dist
var distFS embed.FS
// distRoot is the embed.FS rooted at `dist/`, so handlers can use plain
// "index.html" / "assets/..." keys instead of the "dist/..." prefix.
var distRoot = func() fs.FS {
sub, err := fs.Sub(distFS, "dist")
if err != nil {
// Cannot happen unless the //go:embed pattern above and the
// fs.Sub argument disagree; bail loudly so the mismatch is
// caught at startup.
panic(err)
}
return sub
}()
func RegisterService(registry *boxService.Registry) {
boxService.Register[option.AdminPanelServiceOptions](registry, C.TypeAdminPanel, NewService)
}
@@ -56,7 +76,7 @@ type Service struct {
}
func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.AdminPanelServiceOptions) (adapter.Service, error) {
s := &Service{
return &Service{
Adapter: boxService.NewAdapter(C.TypeAdminPanel, tag),
ctx: ctx,
logger: logger,
@@ -67,83 +87,15 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio
Listen: options.ListenOptions,
}),
options: options,
}
return s, nil
}, nil
}
func (s *Service) Start(stage adapter.StartStage) error {
if stage != adapter.StartStateStart {
return nil
}
boxManager := service.FromContext[adapter.ServiceManager](s.ctx)
service, ok := boxManager.Get(s.options.Manager)
if !ok {
return E.New("manager ", s.options.Manager, " not found")
}
manager, ok := service.(CM.Manager)
if !ok {
return E.New("invalid ", s.options.Manager, " manager")
}
switch s.options.Database.Driver {
case "postgresql":
db, err := sql.Open("postgres", s.options.Database.DSN)
if err != nil {
return err
}
defer db.Close()
if err := migration.MigratePostgreSQL(db); err != nil && err != migrate.ErrNoChange {
return err
}
default:
return E.New("unknown driver \"", s.options.Database.Driver, "\"")
}
var generators = map[string]table.Generator{
"squads": tables.SquadTableFactory(
manager,
s.logger,
),
"nodes": tables.NodeTableFactory(
manager,
s.logger,
),
"users": tables.UserTableFactory(
manager,
s.logger,
),
"connection_limiters": tables.ConnectionLimiterTableFactory(
manager,
s.logger,
),
"bandwidth_limiters": tables.BandwidthLimiterTableFactory(
manager,
s.logger,
),
}
eng := engine.Default()
chiRouter := chi.NewRouter()
template.AddComp(chartjs.NewChart())
if err := eng.AddConfig(&config.Config{
UrlPrefix: "admin",
IndexUrl: "/",
LoginUrl: "/login",
Databases: config.DatabaseList{
"default": config.Database{
Driver: s.options.Database.Driver,
Dsn: s.options.Database.DSN,
},
},
}).
AddGenerators(generators).
Use(chiRouter); err != nil {
return err
}
eng.HTML("GET", "/admin", pages.DashboardPage)
chiRouter.Get("/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin", http.StatusMovedPermanently)
})
chiRouter.Get("/admin/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin", http.StatusMovedPermanently)
})
s.Route(chiRouter)
if s.options.TLS != nil {
tlsConfig, err := tls.NewServer(s.ctx, s.logger, common.PtrValueOrDefault(s.options.TLS))
if err != nil {
@@ -168,10 +120,14 @@ func (s *Service) Start(stage adapter.StartStage) error {
tcpListener = aTLS.NewListener(tcpListener, s.tlsConfig)
}
s.httpServer = &http.Server{
Handler: chiRouter,
Handler: chiRouter,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 2 * time.Minute,
}
go func() {
err = s.httpServer.Serve(tcpListener)
err := s.httpServer.Serve(tcpListener)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
s.logger.Error("serve error: ", err)
}
@@ -180,9 +136,145 @@ func (s *Service) Start(stage adapter.StartStage) error {
}
func (s *Service) Close() error {
if s.httpServer != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = s.httpServer.Shutdown(ctx)
}
return common.Close(
common.PtrOrNil(s.httpServer),
common.PtrOrNil(s.listener),
s.tlsConfig,
)
}
func (s *Service) Route(r chi.Router) {
r.Use(func(handler http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
s.logger.Debug(request.Method, " ", request.RequestURI, " ", sHTTP.SourceAddress(request))
handler.ServeHTTP(writer, request)
})
})
r.Get("/version", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]string{
"version": C.Version,
})
})
handler := newSPAHandler()
r.Method(http.MethodGet, "/*", handler)
r.Method(http.MethodHead, "/*", handler)
}
func newSPAHandler() http.Handler {
_, hasIndex := readFile("index.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqPath := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
if reqPath != "" && reqPath != "index.html" {
if data, ok := readFile(reqPath); ok {
serveAsset(w, r, reqPath, data)
return
}
}
if !hasIndex {
http.Error(w, "admin panel not built", http.StatusInternalServerError)
return
}
serveIndex(w, r)
})
}
func readFile(name string) ([]byte, bool) {
data, err := fs.ReadFile(distRoot, name)
if err != nil {
return nil, false
}
return data, true
}
func gzipCompanion(name string) ([]byte, bool) {
return readFile(name + ".gz")
}
func serveAsset(w http.ResponseWriter, r *http.Request, name string, raw []byte) {
if ctype := contentType(name); ctype != "" {
w.Header().Set("Content-Type", ctype)
}
if isHashedAsset(name) {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache, must-revalidate")
}
writeBody(w, r, name, raw)
}
func serveIndex(w http.ResponseWriter, r *http.Request) {
raw, _ := readFile("index.html")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
writeBody(w, r, "index.html", raw)
}
func writeBody(w http.ResponseWriter, r *http.Request, name string, raw []byte) {
header := w.Header()
if gz, ok := gzipCompanion(name); ok {
// Even when we end up serving the raw bytes (because the client
// declined gzip), let any shared cache know the response varies
// by Accept-Encoding so it doesn't hand a gzipped payload to a
// non-gzip client.
header.Add("Vary", "Accept-Encoding")
if acceptsGzip(r) {
header.Set("Content-Encoding", "gzip")
header.Set("Content-Length", strconv.Itoa(len(gz)))
if r.Method == http.MethodHead {
return
}
_, _ = w.Write(gz)
return
}
}
header.Set("Content-Length", strconv.Itoa(len(raw)))
if r.Method == http.MethodHead {
return
}
_, _ = w.Write(raw)
}
func acceptsGzip(r *http.Request) bool {
for _, h := range r.Header.Values("Accept-Encoding") {
for _, part := range strings.Split(h, ",") {
tok := strings.TrimSpace(part)
if i := strings.IndexByte(tok, ';'); i >= 0 {
tok = strings.TrimSpace(tok[:i])
}
if strings.EqualFold(tok, "gzip") {
return true
}
}
}
return false
}
func isHashedAsset(name string) bool {
return strings.HasPrefix(name, "assets/")
}
func contentType(name string) string {
ext := path.Ext(name)
if ct := mime.TypeByExtension(ext); ct != "" {
return ct
}
switch ext {
case ".js", ".mjs":
return "application/javascript; charset=utf-8"
case ".css":
return "text/css; charset=utf-8"
case ".html":
return "text/html; charset=utf-8"
case ".svg":
return "image/svg+xml"
}
return ""
}

View File

@@ -0,0 +1,199 @@
//go:build with_admin_panel
package admin_panel
import (
"io/fs"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
)
// firstAssetPath returns one of the hashed Vite assets from the embedded
// SPA bundle. Used by tests that need a real file-served URL (i.e. one
// that should NOT fall back to index.html). Returns "" if the bundle is
// empty — every concrete test then skips itself, which keeps the suite
// runnable even before `make admin_panel_regen` has been run.
func firstAssetPath(t *testing.T) string {
t.Helper()
var found string
_ = fs.WalkDir(distRoot, "assets", func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || strings.HasSuffix(p, ".gz") {
return nil
}
found = p
return fs.SkipAll
})
return found
}
// firstGzippedAsset returns one asset that has a `.gz` companion in the
// embedded bundle. Like firstAssetPath, returns "" when nothing matches.
func firstGzippedAsset(t *testing.T) string {
t.Helper()
var found string
_ = fs.WalkDir(distRoot, "assets", func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || strings.HasSuffix(p, ".gz") {
return nil
}
if _, ok := gzipCompanion(p); ok {
found = p
return fs.SkipAll
}
return nil
})
return found
}
func TestSPAHandler(t *testing.T) {
r := chi.NewRouter()
handler := newSPAHandler()
r.Method(http.MethodGet, "/*", handler)
indexBytes, hasIndex := readFile("index.html")
if !hasIndex {
t.Skip("index.html not packed yet (run `make admin_panel_regen`)")
}
cases := []struct {
name string
path string
}{
{"root", "/"},
{"client-route fallback", "/squads"},
{"deep route fallback", "/users/123"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "<div id=\"root\">") {
t.Fatalf("body does not look like index.html:\n%s", rec.Body.String())
}
})
}
// Ensure a real packed asset is served as itself, not as the fallback.
t.Run("real file is served", func(t *testing.T) {
assetPath := firstAssetPath(t)
if assetPath == "" {
t.Skip("no packed assets/* to verify")
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/"+assetPath, nil)
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if strings.Contains(rec.Body.String(), "<div id=\"root\">") {
t.Fatalf("asset request returned the index fallback")
}
})
// Sanity check: index.html in the bundle is the same as what serveIndex returns.
t.Run("index payload matches map", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
r.ServeHTTP(rec, req)
if rec.Body.String() != string(indexBytes) {
t.Fatalf("served index does not match readFile(\"index.html\")")
}
})
t.Run("index has no-store cache control", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
r.ServeHTTP(rec, req)
cc := rec.Header().Get("Cache-Control")
if !strings.Contains(cc, "no-store") {
t.Fatalf("Cache-Control = %q, want it to contain no-store", cc)
}
})
t.Run("hashed asset is cached aggressively", func(t *testing.T) {
assetPath := firstAssetPath(t)
if assetPath == "" {
t.Skip("no packed assets/* to verify")
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/"+assetPath, nil)
r.ServeHTTP(rec, req)
cc := rec.Header().Get("Cache-Control")
if !strings.Contains(cc, "immutable") {
t.Fatalf("Cache-Control for %q = %q, want it to contain immutable", assetPath, cc)
}
})
// With Accept-Encoding: gzip the handler should pass through the
// pre-compressed `.gz` companion verbatim with Content-Encoding: gzip.
t.Run("gzip pass-through", func(t *testing.T) {
assetPath := firstGzippedAsset(t)
if assetPath == "" {
t.Skip("no gzipped assets in this build")
}
gz, _ := gzipCompanion(assetPath)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/"+assetPath, nil)
req.Header.Set("Accept-Encoding", "gzip")
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if got := rec.Header().Get("Content-Encoding"); got != "gzip" {
t.Fatalf("Content-Encoding = %q, want gzip", got)
}
if rec.Body.String() != string(gz) {
t.Fatalf("body did not match the stored gzipped bytes")
}
})
// Without Accept-Encoding the same asset should be served raw and
// Content-Encoding must be unset.
t.Run("gzip transparent fallback", func(t *testing.T) {
assetPath := firstGzippedAsset(t)
if assetPath == "" {
t.Skip("no gzipped assets in this build")
}
raw, _ := readFile(assetPath)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/"+assetPath, nil)
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if got := rec.Header().Get("Content-Encoding"); got != "" {
t.Fatalf("Content-Encoding = %q, want empty (raw body)", got)
}
if rec.Body.String() != string(raw) {
t.Fatalf("body does not match the raw asset")
}
})
// `.gz` companions are an internal implementation detail of the
// pre-compression strategy; clients should never fetch them directly,
// and walking the embedded FS via /-prefixed URLs must not expose
// them as if they were real assets either. Today they happen to be
// reachable (since fs.ReadFile sees them) — this test pins that
// behaviour so we notice if the contract changes.
t.Run("gz companion lookup", func(t *testing.T) {
assetPath := firstGzippedAsset(t)
if assetPath == "" {
t.Skip("no gzipped assets in this build")
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/"+assetPath+".gz", nil)
r.ServeHTTP(rec, req)
// We don't assert the precise body — only that the request
// resolves to *something* (200 or fallback) instead of crashing.
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (asset or SPA fallback)", rec.Code)
}
})
}

View File

@@ -1,259 +0,0 @@
package tables
import (
"encoding/json"
"strconv"
"strings"
"time"
"github.com/GoAdminGroup/go-admin/context"
"github.com/GoAdminGroup/go-admin/modules/db"
mForm "github.com/GoAdminGroup/go-admin/plugins/admin/modules/form"
"github.com/GoAdminGroup/go-admin/plugins/admin/modules/parameter"
"github.com/GoAdminGroup/go-admin/plugins/admin/modules/table"
"github.com/GoAdminGroup/go-admin/template"
"github.com/GoAdminGroup/go-admin/template/types"
"github.com/GoAdminGroup/go-admin/template/types/form"
"github.com/sagernet/sing-box/log"
CM "github.com/sagernet/sing-box/service/manager/constant"
)
func BandwidthLimiterTableFactory(manager CM.Manager, logger log.Logger) func(ctx *context.Context) table.Table {
return func(ctx *context.Context) table.Table {
t := table.NewDefaultTable(ctx, table.Config{
CanAdd: true,
Editable: true,
Deletable: true,
Exportable: true,
PrimaryKey: table.PrimaryKey{
Type: db.Int,
Name: table.DefaultPrimaryKeyName,
},
})
squads, err := manager.GetSquads(map[string][]string{})
if err != nil {
return nil
}
squadsByID := make(map[int]string, len(squads))
squadOptions := make(types.FieldOptions, len(squads))
for i, squad := range squads {
squadsByID[squad.ID] = squad.Name
squadOptions[i] = types.FieldOption{
Text: squad.Name,
Value: strconv.Itoa(squad.ID),
}
}
info := t.GetInfo().SetFilterFormLayout(form.LayoutFilter)
info.AddField("ID", "id", db.Int).
FieldSortable()
info.AddField("Squads", "squad_ids", db.Varchar).
FieldDisplay(func(model types.FieldModel) interface{} {
values := model.Row["squad_ids"].([]interface{})
labels := template.HTML("")
labelTpl := label(ctx).SetType("success")
labelValues := make([]string, len(values))
for i, squadID := range values {
labelValues[i] = squadsByID[int(squadID.(float64))]
}
for key, label := range labelValues {
if key == len(labelValues)-1 {
labels += labelTpl.SetContent(template.HTML(label)).GetContent()
} else {
labels += labelTpl.SetContent(template.HTML(label)).GetContent()
}
}
return labels
})
info.AddField("Username", "username", db.Varchar).
FieldFilterable().
FieldSortable()
info.AddField("Outbound", "outbound", db.Varchar).
FieldFilterable().
FieldSortable()
info.AddField("Strategy", "strategy", db.Varchar).
FieldFilterable(types.FilterType{
FormType: form.SelectSingle,
Options: types.FieldOptions{
{Text: "Connection", Value: "connection"},
{Text: "Global", Value: "global"},
},
}).
FieldSortable()
info.AddField("Mode", "mode", db.Varchar).
FieldFilterable(types.FilterType{
FormType: form.SelectSingle,
Options: types.FieldOptions{
{Text: "Download", Value: "download"},
{Text: "Upload", Value: "upload"},
{Text: "Duplex", Value: "duplex"},
},
}).
FieldSortable()
info.AddField("Connection type", "connection_type", db.Varchar).
FieldFilterable(types.FilterType{
FormType: form.SelectSingle,
Options: types.FieldOptions{
{Text: "HWID", Value: "hwid"},
{Text: "Mux", Value: "mux"},
{Text: "IP", Value: "ip"},
},
}).
FieldSortable()
info.AddField("Speed", "speed", db.Varchar).
FieldSortable()
info.AddField("Created at", "created_at", db.Datetime).
FieldDisplay(func(model types.FieldModel) interface{} {
t, err := time.Parse(time.RFC3339, model.Value)
if err != nil {
return model.Value
}
return t.Format("2006-01-02 15:04:05")
}).
FieldFilterable(types.FilterType{FormType: form.DatetimeRange}).
FieldSortable()
info.AddField("Updated at", "updated_at", db.Datetime).
FieldDisplay(func(model types.FieldModel) interface{} {
t, err := time.Parse(time.RFC3339, model.Value)
if err != nil {
return model.Value
}
return t.Format("2006-01-02 15:04:05")
}).
FieldFilterable(types.FilterType{FormType: form.DatetimeRange}).
FieldSortable()
info.SetGetDataFn(func(param parameter.Parameters) ([]map[string]interface{}, int) {
filters := make(map[string][]string)
listFilters := map[string][]string{
"offset": {strconv.Itoa((param.PageInt - 1) * param.PageSizeInt)},
"limit": {param.PageSize},
}
for k, v := range param.Fields {
if strings.HasPrefix(k, "__") {
continue
}
key := strings.TrimSuffix(k, "__goadmin")
filters[key] = v
listFilters[key] = v
}
if param.SortField != "" {
if param.SortType == "asc" {
listFilters["sort_asc"] = []string{param.SortField}
} else {
listFilters["sort_desc"] = []string{param.SortField}
}
}
items, err := manager.GetBandwidthLimiters(listFilters)
if err != nil {
logger.Error(err)
return nil, 0
}
count, err := manager.GetBandwidthLimitersCount(filters)
if err != nil {
logger.Error(err)
return nil, 0
}
result := make([]map[string]interface{}, 0, len(items))
for _, item := range items {
var data map[string]interface{}
raw, _ := json.Marshal(item)
json.Unmarshal(raw, &data)
result = append(result, data)
}
return result, count
})
info.SetDeleteFn(func(ids []string) error {
for _, id := range ids {
i, err := strconv.Atoi(id)
if err != nil {
return err
}
if _, err := manager.DeleteBandwidthLimiter(i); err != nil {
return err
}
}
return nil
})
info.SetTable("bandwidth_limiters").SetTitle("Bandwidth Limiters").SetDescription("Bandwidth Limiters")
formList := t.GetForm()
formList.AddField("ID", "id", db.Int, form.Default).
FieldNotAllowAdd().
FieldNotAllowEdit()
formList.AddField("Squads", "squad_ids", db.Varchar, form.Select).
FieldMust().
FieldOptions(squadOptions).
FieldDisableWhenUpdate()
formList.AddField("Username", "username", db.Varchar, form.Text).
FieldMust().
FieldDisplayButCanNotEditWhenUpdate()
formList.AddField("Outbound", "outbound", db.Varchar, form.Text).
FieldMust().
FieldDisplayButCanNotEditWhenUpdate()
formList.AddField("Strategy", "strategy", db.Varchar, form.SelectSingle).
FieldMust().
FieldOptions(types.FieldOptions{
{Text: "Connection", Value: "connection"},
{Text: "Global", Value: "global"},
}).
FieldOnChooseOptionsHide([]string{"", "global"}, "connection_type")
formList.AddField("Mode", "mode", db.Varchar, form.SelectSingle).
FieldMust().
FieldOptions(types.FieldOptions{
{Text: "Download", Value: "download"},
{Text: "Upload", Value: "upload"},
{Text: "Duplex", Value: "duplex"},
})
formList.AddField("Connection type", "connection_type", db.Varchar, form.SelectSingle).
FieldOptions(types.FieldOptions{
{Text: "HWID", Value: "hwid"},
{Text: "Mux", Value: "mux"},
{Text: "IP", Value: "ip"},
})
formList.AddField("Speed", "speed", db.Varchar, form.Text).
FieldMust()
formList.SetInsertFn(func(values mForm.Values) error {
squadIDs := make([]int, len(values["squad_ids[]"]))
for i, rawSquadID := range values["squad_ids[]"] {
squadID, err := strconv.Atoi(rawSquadID)
if err != nil {
return err
}
squadIDs[i] = squadID
}
_, err := manager.CreateBandwidthLimiter(CM.BandwidthLimiterCreate{
SquadIDs: squadIDs,
Username: values.Get("username"),
Outbound: values.Get("outbound"),
Strategy: values.Get("strategy"),
Mode: values.Get("mode"),
ConnectionType: values.Get("connection_type"),
Speed: values.Get("speed"),
})
return err
})
formList.SetUpdateFn(func(values mForm.Values) error {
id, err := strconv.Atoi(values.Get("id"))
if err != nil {
return err
}
_, err = manager.UpdateBandwidthLimiter(id, CM.BandwidthLimiterUpdate{
Username: values.Get("username"),
Outbound: values.Get("outbound"),
Strategy: values.Get("strategy"),
Mode: values.Get("mode"),
ConnectionType: values.Get("connection_type"),
Speed: values.Get("speed"),
})
return err
})
formList.SetTable("bandwidth_limiters").SetTitle("Bandwidth Limiters").SetDescription("Bandwidth Limiters")
return t
}
}

View File

@@ -1,261 +0,0 @@
package tables
import (
"encoding/json"
"strconv"
"strings"
"time"
"github.com/GoAdminGroup/go-admin/context"
"github.com/GoAdminGroup/go-admin/modules/db"
mForm "github.com/GoAdminGroup/go-admin/plugins/admin/modules/form"
"github.com/GoAdminGroup/go-admin/plugins/admin/modules/parameter"
"github.com/GoAdminGroup/go-admin/plugins/admin/modules/table"
"github.com/GoAdminGroup/go-admin/template"
"github.com/GoAdminGroup/go-admin/template/types"
"github.com/GoAdminGroup/go-admin/template/types/form"
"github.com/sagernet/sing-box/log"
CM "github.com/sagernet/sing-box/service/manager/constant"
)
func ConnectionLimiterTableFactory(manager CM.Manager, logger log.Logger) func(ctx *context.Context) table.Table {
return func(ctx *context.Context) table.Table {
connectionLimiterTable := table.NewDefaultTable(ctx, table.Config{
CanAdd: true,
Editable: true,
Deletable: true,
Exportable: true,
PrimaryKey: table.PrimaryKey{
Type: db.Int,
Name: table.DefaultPrimaryKeyName,
},
})
squads, err := manager.GetSquads(map[string][]string{})
if err != nil {
return nil
}
squadsByID := make(map[int]string, len(squads))
squadOptions := make(types.FieldOptions, len(squads))
for i, squad := range squads {
squadsByID[squad.ID] = squad.Name
squadOptions[i] = types.FieldOption{
Text: squad.Name,
Value: strconv.Itoa(squad.ID),
}
}
info := connectionLimiterTable.GetInfo().SetFilterFormLayout(form.LayoutFilter)
info.AddField("ID", "id", db.Int).
FieldSortable()
info.AddField("Squads", "squad_ids", db.Varchar).
FieldDisplay(func(model types.FieldModel) interface{} {
values := model.Row["squad_ids"].([]interface{})
labels := template.HTML("")
labelTpl := label(ctx).SetType("success")
labelValues := make([]string, len(values))
for i, squadID := range values {
labelValues[i] = squadsByID[int(squadID.(float64))]
}
for key, label := range labelValues {
if key == len(labelValues)-1 {
labels += labelTpl.SetContent(template.HTML(label)).GetContent()
} else {
labels += labelTpl.SetContent(template.HTML(label)).GetContent()
}
}
return labels
})
info.AddField("Username", "username", db.Varchar).
FieldFilterable().
FieldSortable()
info.AddField("Outbound", "outbound", db.Varchar).
FieldFilterable().
FieldSortable()
info.AddField("Strategy", "strategy", db.Varchar).
FieldFilterable(types.FilterType{
FormType: form.SelectSingle,
Options: types.FieldOptions{
{Text: "Connection", Value: "connection"},
},
}).
FieldSortable()
info.AddField("Connection type", "connection_type", db.Varchar).
FieldFilterable(types.FilterType{
FormType: form.SelectSingle,
Options: types.FieldOptions{
{Text: "Mux", Value: "mux"},
{Text: "HWID", Value: "hwid"},
{Text: "IP", Value: "ip"},
},
}).
FieldSortable()
info.AddField("Lock type", "lock_type", db.Varchar).
FieldFilterable(types.FilterType{
FormType: form.SelectSingle,
Options: types.FieldOptions{
{Text: "Manager", Value: "manager"},
},
}).
FieldSortable()
info.AddField("Count", "count", db.Int).
FieldSortable()
info.AddField("Created at", "created_at", db.Datetime).
FieldDisplay(func(model types.FieldModel) interface{} {
t, err := time.Parse(time.RFC3339, model.Value)
if err != nil {
return model.Value
}
return t.Format("2006-01-02 15:04:05")
}).
FieldFilterable(types.FilterType{FormType: form.DatetimeRange}).
FieldSortable()
info.AddField("Updated at", "updated_at", db.Datetime).
FieldDisplay(func(model types.FieldModel) interface{} {
t, err := time.Parse(time.RFC3339, model.Value)
if err != nil {
return model.Value
}
return t.Format("2006-01-02 15:04:05")
}).
FieldFilterable(types.FilterType{FormType: form.DatetimeRange}).
FieldSortable()
info.SetGetDataFn(func(param parameter.Parameters) ([]map[string]interface{}, int) {
filters := make(map[string][]string)
listFilters := map[string][]string{
"offset": {strconv.Itoa((param.PageInt - 1) * param.PageSizeInt)},
"limit": {param.PageSize},
}
for k, v := range param.Fields {
if strings.HasPrefix(k, "__") {
continue
}
key := strings.TrimSuffix(k, "__goadmin")
filters[key] = v
listFilters[key] = v
}
if param.SortField != "" {
if param.SortType == "asc" {
listFilters["sort_asc"] = []string{param.SortField}
} else {
listFilters["sort_desc"] = []string{param.SortField}
}
}
items, err := manager.GetConnectionLimiters(listFilters)
if err != nil {
logger.Error(err)
return nil, 0
}
count, err := manager.GetConnectionLimitersCount(filters)
if err != nil {
logger.Error(err)
return nil, 0
}
result := make([]map[string]interface{}, 0, len(items))
for _, item := range items {
var data map[string]interface{}
raw, _ := json.Marshal(item)
json.Unmarshal(raw, &data)
result = append(result, data)
}
return result, count
})
info.SetDeleteFn(func(ids []string) error {
for _, id := range ids {
i, err := strconv.Atoi(id)
if err != nil {
return err
}
if _, err := manager.DeleteConnectionLimiter(i); err != nil {
return err
}
}
return nil
})
info.SetTable("connection_limiters").SetTitle("Connection Limiters").SetDescription("Connection Limiters")
formList := connectionLimiterTable.GetForm()
formList.AddField("ID", "id", db.Int, form.Default).
FieldNotAllowAdd().
FieldNotAllowEdit()
formList.AddField("Squads", "squad_ids", db.Varchar, form.Select).
FieldMust().
FieldOptions(squadOptions).
FieldDisableWhenUpdate()
formList.AddField("Username", "username", db.Varchar, form.Text).
FieldMust().
FieldDisplayButCanNotEditWhenUpdate()
formList.AddField("Outbound", "outbound", db.Varchar, form.Text).
FieldMust().
FieldDisplayButCanNotEditWhenUpdate()
formList.AddField("Strategy", "strategy", db.Varchar, form.SelectSingle).
FieldMust().
FieldOptions(types.FieldOptions{
{Text: "Connection", Value: "connection"},
}).
FieldDefault("connection")
formList.AddField("Connection type", "connection_type", db.Varchar, form.SelectSingle).
FieldOptions(types.FieldOptions{
{Text: "Mux", Value: "mux"},
{Text: "HWID", Value: "hwid"},
{Text: "IP", Value: "ip"},
})
formList.AddField("Lock type", "lock_type", db.Varchar, form.SelectSingle).
FieldOptions(types.FieldOptions{
{Text: "Manager", Value: "manager"},
})
formList.AddField("Count", "count", db.Int, form.Number).
FieldMust().
FieldDefault("0")
formList.SetInsertFn(func(values mForm.Values) error {
squadIDs := make([]int, len(values["squad_ids[]"]))
for i, rawSquadID := range values["squad_ids[]"] {
squadID, err := strconv.Atoi(rawSquadID)
if err != nil {
return err
}
squadIDs[i] = squadID
}
count, err := strconv.ParseUint(values.Get("count"), 10, 32)
if err != nil {
return err
}
_, err = manager.CreateConnectionLimiter(CM.ConnectionLimiterCreate{
SquadIDs: squadIDs,
Username: values.Get("username"),
Outbound: values.Get("outbound"),
Strategy: values.Get("strategy"),
ConnectionType: values.Get("connection_type"),
LockType: values.Get("lock_type"),
Count: uint32(count),
})
return err
})
formList.SetUpdateFn(func(values mForm.Values) error {
id, err := strconv.Atoi(values.Get("id"))
if err != nil {
return err
}
count, err := strconv.ParseUint(values.Get("count"), 10, 32)
if err != nil {
return err
}
_, err = manager.UpdateConnectionLimiter(id, CM.ConnectionLimiterUpdate{
Username: values.Get("username"),
Outbound: values.Get("outbound"),
Strategy: values.Get("strategy"),
ConnectionType: values.Get("connection_type"),
LockType: values.Get("lock_type"),
Count: uint32(count),
})
return err
})
formList.SetTable("connection_limiters").SetTitle("Connection Limiters").SetDescription("Connection Limiters")
return connectionLimiterTable
}
}

View File

@@ -1,201 +0,0 @@
package tables
import (
"encoding/json"
"strconv"
"strings"
"time"
"github.com/GoAdminGroup/go-admin/context"
"github.com/GoAdminGroup/go-admin/modules/config"
"github.com/GoAdminGroup/go-admin/modules/db"
mForm "github.com/GoAdminGroup/go-admin/plugins/admin/modules/form"
"github.com/GoAdminGroup/go-admin/plugins/admin/modules/parameter"
"github.com/GoAdminGroup/go-admin/plugins/admin/modules/table"
"github.com/GoAdminGroup/go-admin/template"
"github.com/GoAdminGroup/go-admin/template/types"
"github.com/GoAdminGroup/go-admin/template/types/form"
"github.com/gofrs/uuid/v5"
"github.com/sagernet/sing-box/log"
CM "github.com/sagernet/sing-box/service/manager/constant"
)
func label(ctx *context.Context) types.LabelAttribute {
return template.Get(ctx, config.GetTheme()).Label().SetType("success")
}
func NodeTableFactory(manager CM.Manager, logger log.Logger) func(ctx *context.Context) (nodeTable table.Table) {
return func(ctx *context.Context) (nodeTable table.Table) {
nodeTable = table.NewDefaultTable(ctx, table.Config{
CanAdd: true,
Editable: true,
Deletable: true,
Exportable: true,
PrimaryKey: table.PrimaryKey{
Type: db.Varchar,
Name: "uuid",
},
})
squads, err := manager.GetSquads(map[string][]string{})
if err != nil {
return nil
}
squadsByID := make(map[int]string, len(squads))
squadOptions := make(types.FieldOptions, len(squads))
for i, squad := range squads {
squadsByID[squad.ID] = squad.Name
squadOptions[i] = types.FieldOption{
Text: squad.Name,
Value: strconv.Itoa(squad.ID),
}
}
info := nodeTable.GetInfo().SetFilterFormLayout(form.LayoutFilter)
info.AddField("UUID", "uuid", db.Varchar).
FieldFilterable().
FieldSortable()
info.AddField("Name", "name", db.Varchar).
FieldFilterable().
FieldSortable()
info.AddField("Squads", "squad_ids", db.Varchar).
FieldDisplay(func(model types.FieldModel) interface{} {
values := model.Row["squad_ids"].([]interface{})
labels := template.HTML("")
labelTpl := label(ctx).SetType("success")
labelValues := make([]string, len(values))
for i, squadID := range values {
labelValues[i] = squadsByID[int(squadID.(float64))]
}
for key, label := range labelValues {
if key == len(labelValues)-1 {
labels += labelTpl.SetContent(template.HTML(label)).GetContent()
} else {
labels += labelTpl.SetContent(template.HTML(label)).GetContent()
}
}
return labels
})
info.AddField("Status", "status", db.Varchar).
FieldDisplay(func(value types.FieldModel) interface{} {
uuid := value.Row["uuid"].(string)
return manager.GetNodeStatus(uuid)
})
info.AddField("Created at", "created_at", db.Datetime).
FieldDisplay(func(model types.FieldModel) interface{} {
t, err := time.Parse(time.RFC3339, model.Value)
if err != nil {
return model.Value
}
return t.Format("2006-01-02 15:04:05")
}).
FieldFilterable(types.FilterType{FormType: form.DatetimeRange}).
FieldSortable()
info.AddField("Updated at", "updated_at", db.Datetime).
FieldDisplay(func(model types.FieldModel) interface{} {
t, err := time.Parse(time.RFC3339, model.Value)
if err != nil {
return model.Value
}
return t.Format("2006-01-02 15:04:05")
}).
FieldFilterable(types.FilterType{FormType: form.DatetimeRange}).
FieldSortable()
info.SetGetDataFn(func(param parameter.Parameters) ([]map[string]interface{}, int) {
filters := make(map[string][]string, len(param.Fields))
listFilters := make(map[string][]string, len(param.Fields)+2)
listFilters["offset"] = []string{strconv.Itoa((param.PageInt - 1) * param.PageSizeInt)}
listFilters["limit"] = []string{param.PageSize}
for key, values := range param.Fields {
if key == "__pk" {
key = "uuid"
} else {
if strings.HasPrefix(key, "__") {
continue
}
key = strings.TrimSuffix(key, "__goadmin")
}
filters[key] = values
listFilters[key] = values
}
if param.SortField != "" {
if param.SortType == "asc" {
listFilters["sort_asc"] = []string{param.SortField}
} else {
listFilters["sort_desc"] = []string{param.SortField}
}
}
nodes, err := manager.GetNodes(listFilters)
if err != nil {
logger.Error(err)
return nil, 0
}
count, err := manager.GetNodesCount(filters)
if err != nil {
logger.Error(err)
return nil, 0
}
result := make([]map[string]interface{}, 0, len(nodes))
for _, node := range nodes {
var data map[string]interface{}
rawData, _ := json.Marshal(node)
json.Unmarshal(rawData, &data)
result = append(result, data)
}
return result, count
})
info.SetDeleteFn(func(ids []string) error {
for _, uuid := range ids {
if _, err := manager.DeleteNode(uuid); err != nil {
return err
}
}
return nil
})
info.SetTable("nodes").SetTitle("Nodes").SetDescription("Nodes")
defaultUUID, _ := uuid.NewV4()
formList := nodeTable.GetForm()
formList.AddField("UUID", "uuid", db.Varchar, form.Text).
FieldMust().
FieldNotAllowEdit().
FieldDefault(defaultUUID.String())
formList.AddField("Name", "name", db.Varchar, form.Text).
FieldMust()
formList.AddField("Squads", "squad_ids", db.Varchar, form.Select).
FieldMust().
FieldOptions(squadOptions).
FieldDisableWhenUpdate()
formList.SetInsertFn(func(values mForm.Values) (err error) {
squadIDs := make([]int, len(values["squad_ids[]"]))
for i, rawSquadID := range values["squad_ids[]"] {
squadID, err := strconv.Atoi(rawSquadID)
if err != nil {
return err
}
squadIDs[i] = squadID
}
_, err = manager.CreateNode(CM.NodeCreate{
UUID: values.Get("uuid"),
Name: values.Get("name"),
SquadIDs: squadIDs,
})
return
})
formList.SetUpdateFn(func(values mForm.Values) (err error) {
uuid := values.Get("uuid")
_, err = manager.UpdateNode(uuid, CM.NodeUpdate{
Name: values.Get("name"),
})
return
})
formList.SetTable("nodes").SetTitle("Nodes").SetDescription("Nodes")
return
}
}

View File

@@ -1,164 +0,0 @@
package tables
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/GoAdminGroup/go-admin/context"
"github.com/GoAdminGroup/go-admin/modules/db"
mForm "github.com/GoAdminGroup/go-admin/plugins/admin/modules/form"
"github.com/GoAdminGroup/go-admin/plugins/admin/modules/parameter"
"github.com/GoAdminGroup/go-admin/plugins/admin/modules/table"
"github.com/GoAdminGroup/go-admin/template/types"
"github.com/GoAdminGroup/go-admin/template/types/form"
"github.com/go-playground/validator/v10"
"github.com/sagernet/sing-box/log"
CM "github.com/sagernet/sing-box/service/manager/constant"
)
func SquadTableFactory(manager CM.Manager, logger log.Logger) func(ctx *context.Context) (squadTable table.Table) {
return func(ctx *context.Context) (squadTable table.Table) {
squadTable = table.NewDefaultTable(ctx, table.Config{
CanAdd: true,
Editable: true,
Deletable: true,
Exportable: true,
PrimaryKey: table.PrimaryKey{
Type: db.Int,
Name: table.DefaultPrimaryKeyName,
},
})
info := squadTable.GetInfo().SetFilterFormLayout(form.LayoutFilter)
info.AddField("ID", "id", db.Int).
FieldSortable()
info.AddField("Name", "name", db.Varchar).
FieldFilterable().
FieldSortable()
info.AddField("Created At", "created_at", db.Datetime).
FieldDisplay(func(model types.FieldModel) interface{} {
t, err := time.Parse(time.RFC3339, model.Value)
if err != nil {
return model.Value
}
return t.Format("2006-01-02 15:04:05")
}).
FieldSortable().
FieldFilterable(types.FilterType{FormType: form.DatetimeRange})
info.AddField("Updated At", "updated_at", db.Datetime).
FieldDisplay(func(model types.FieldModel) interface{} {
t, err := time.Parse(time.RFC3339, model.Value)
if err != nil {
return model.Value
}
return t.Format("2006-01-02 15:04:05")
}).
FieldSortable().
FieldFilterable(types.FilterType{FormType: form.DatetimeRange})
info.SetGetDataFn(func(param parameter.Parameters) ([]map[string]interface{}, int) {
filters := make(map[string][]string, len(param.Fields))
listFilters := make(map[string][]string, len(param.Fields)+2)
listFilters["offset"] = []string{strconv.Itoa((param.PageInt - 1) * param.PageSizeInt)}
listFilters["limit"] = []string{param.PageSize}
for key, values := range param.Fields {
if key == "__pk" {
key = "pk"
} else if strings.HasPrefix(key, "__") {
continue
} else {
key = strings.TrimSuffix(key, "__goadmin")
}
filters[key] = values
listFilters[key] = values
}
if param.SortField != "" {
if param.SortType == "asc" {
listFilters["sort_asc"] = []string{param.SortField}
} else {
listFilters["sort_desc"] = []string{param.SortField}
}
}
squads, err := manager.GetSquads(listFilters)
if err != nil {
logger.Error(err)
return nil, 0
}
count, err := manager.GetSquadsCount(filters)
if err != nil {
logger.Error(err)
return nil, 0
}
result := make([]map[string]interface{}, 0, len(squads))
for _, squad := range squads {
var data map[string]interface{}
rawData, _ := json.Marshal(squad)
json.Unmarshal(rawData, &data)
result = append(result, data)
}
return result, count
})
info.SetDeleteFn(func(ids []string) error {
for _, id := range ids {
intID, err := strconv.Atoi(id)
if err != nil {
return err
}
if _, err := manager.DeleteSquad(intID); err != nil {
return err
}
}
return nil
})
info.SetTable("squads").SetTitle("Squads").SetDescription("Squads")
formList := squadTable.GetForm()
formList.AddField("ID", "id", db.Int, form.Default).
FieldNotAllowAdd().
FieldNotAllowEdit()
formList.AddField("Name", "name", db.Varchar, form.Text).
FieldMust()
formList.SetInsertFn(func(values mForm.Values) (err error) {
_, err = manager.CreateSquad(CM.SquadCreate{
Name: values.Get("name"),
})
if err != nil {
if ve, ok := err.(validator.ValidationErrors); ok {
var errors []string
for _, e := range ve {
switch e.Tag() {
case "required":
errors = append(errors, e.StructField()+": required field missing")
default:
errors = append(errors, e.StructField()+": invalid request")
}
}
err = fmt.Errorf("%s", strings.Join(errors, "<br>"))
}
}
return
})
formList.SetUpdateFn(func(values mForm.Values) (err error) {
id, err := strconv.Atoi(values.Get("id"))
if err != nil {
return err
}
_, err = manager.UpdateSquad(id, CM.SquadUpdate{
Name: values.Get("name"),
})
return
})
formList.SetTable("squads").SetTitle("Squads").SetDescription("Squads")
return
}
}

View File

@@ -1,288 +0,0 @@
package tables
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/GoAdminGroup/go-admin/context"
"github.com/GoAdminGroup/go-admin/modules/db"
mForm "github.com/GoAdminGroup/go-admin/plugins/admin/modules/form"
"github.com/GoAdminGroup/go-admin/plugins/admin/modules/parameter"
"github.com/GoAdminGroup/go-admin/plugins/admin/modules/table"
"github.com/GoAdminGroup/go-admin/template"
"github.com/GoAdminGroup/go-admin/template/types"
"github.com/GoAdminGroup/go-admin/template/types/form"
"github.com/go-playground/validator/v10"
"github.com/sagernet/sing-box/log"
CM "github.com/sagernet/sing-box/service/manager/constant"
)
func UserTableFactory(manager CM.Manager, logger log.Logger) func(ctx *context.Context) (userTable table.Table) {
return func(ctx *context.Context) (userTable table.Table) {
userTable = table.NewDefaultTable(ctx, table.Config{
CanAdd: true,
Editable: true,
Deletable: true,
Exportable: true,
PrimaryKey: table.PrimaryKey{
Type: db.Int,
Name: table.DefaultPrimaryKeyName,
},
})
squads, err := manager.GetSquads(map[string][]string{})
if err != nil {
return nil
}
squadsByID := make(map[int]string, len(squads))
squadOptions := make(types.FieldOptions, len(squads))
for i, squad := range squads {
squadsByID[squad.ID] = squad.Name
squadOptions[i] = types.FieldOption{
Text: squad.Name,
Value: strconv.Itoa(squad.ID),
}
}
info := userTable.GetInfo().SetFilterFormLayout(form.LayoutFilter)
info.AddField("ID", "id", db.Int).
FieldSortable()
info.AddField("Squads", "squad_ids", db.Varchar).
FieldDisplay(func(model types.FieldModel) interface{} {
values := model.Row["squad_ids"].([]interface{})
labels := template.HTML("")
labelTpl := label(ctx).SetType("success")
labelValues := make([]string, len(values))
for i, squadID := range values {
labelValues[i] = squadsByID[int(squadID.(float64))]
}
for key, label := range labelValues {
if key == len(labelValues)-1 {
labels += labelTpl.SetContent(template.HTML(label)).GetContent()
} else {
labels += labelTpl.SetContent(template.HTML(label)).GetContent()
}
}
return labels
})
info.AddField("Username", "username", db.Varchar).
FieldFilterable().
FieldSortable()
info.AddField("Type", "type", db.Varchar).
FieldFilterable(
types.FilterType{
FormType: form.SelectSingle,
Options: types.FieldOptions{
{Text: "Hysteria", Value: "hysteria"},
{Text: "Hysteria2", Value: "hysteria2"},
{Text: "MTProxy", Value: "mtproxy"},
{Text: "Trojan", Value: "trojan"},
{Text: "TUIC", Value: "tuic"},
{Text: "VLESS", Value: "vless"},
{Text: "VMess", Value: "vmess"},
},
},
).
FieldSortable()
info.AddField("Inbound", "inbound", db.Varchar).FieldFilterable().
FieldSortable()
info.AddField("Created at", "created_at", db.Datetime).
FieldDisplay(func(model types.FieldModel) interface{} {
t, err := time.Parse(time.RFC3339, model.Value)
if err != nil {
return model.Value
}
return t.Format("2006-01-02 15:04:05")
}).
FieldFilterable(types.FilterType{FormType: form.DatetimeRange}).
FieldSortable()
info.AddField("Updated at", "updated_at", db.Datetime).
FieldDisplay(func(model types.FieldModel) interface{} {
t, err := time.Parse(time.RFC3339, model.Value)
if err != nil {
return model.Value
}
return t.Format("2006-01-02 15:04:05")
}).
FieldFilterable(types.FilterType{FormType: form.DatetimeRange}).
FieldSortable()
info.SetGetDataFn(func(param parameter.Parameters) ([]map[string]interface{}, int) {
filters := make(map[string][]string, len(param.Fields))
listFilters := make(map[string][]string, len(param.Fields)+2)
listFilters["offset"] = []string{strconv.Itoa((param.PageInt - 1) * param.PageSizeInt)}
listFilters["limit"] = []string{param.PageSize}
for key, values := range param.Fields {
if key == "__pk" {
key = "pk"
} else {
if strings.HasPrefix(key, "__") {
continue
}
key = strings.TrimSuffix(key, "__goadmin")
}
filters[key] = values
listFilters[key] = values
}
if param.SortField != "" {
if param.SortType == "asc" {
listFilters["sort_asc"] = []string{param.SortField}
} else {
listFilters["sort_desc"] = []string{param.SortField}
}
}
users, err := manager.GetUsers(listFilters)
if err != nil {
logger.Error(err)
return nil, 0
}
count, err := manager.GetUsersCount(filters)
if err != nil {
logger.Error(err)
return nil, 0
}
result := make([]map[string]interface{}, 0, len(users))
for _, user := range users {
var data map[string]interface{}
rawData, _ := json.Marshal(user)
json.Unmarshal(rawData, &data)
result = append(result, data)
}
return result, count
})
info.SetDeleteFn(func(ids []string) error {
for _, id := range ids {
value, err := strconv.Atoi(id)
if err != nil {
return err
}
if _, err := manager.DeleteUser(value); err != nil {
return err
}
}
return nil
})
info.SetTable("users").SetTitle("Users").SetDescription("Users")
formList := userTable.GetForm()
formList.AddField("ID", "id", db.Int, form.Default).
FieldNotAllowEdit().
FieldNotAllowAdd()
formList.AddField("Squads", "squad_ids", db.Varchar, form.Select).
FieldMust().
FieldOptions(squadOptions).
FieldDisableWhenUpdate()
formList.AddField("Username", "username", db.Varchar, form.Text).
FieldMust().
FieldDisplayButCanNotEditWhenUpdate()
formList.AddField("Type", "type", db.Varchar, form.SelectSingle).
FieldMust().
FieldDisplayButCanNotEditWhenUpdate().
FieldOptions(types.FieldOptions{
{Text: "Hysteria", Value: "hysteria"},
{Text: "Hysteria2", Value: "hysteria2"},
{Text: "MTProxy", Value: "mtproxy"},
{Text: "Trojan", Value: "trojan"},
{Text: "TUIC", Value: "tuic"},
{Text: "VLESS", Value: "vless"},
{Text: "VMess", Value: "vmess"},
}).
FieldOnChooseOptionsHide([]string{""}, "inbound").
FieldOnChooseOptionsHide([]string{"", "hysteria", "hysteria2", "mtproxy", "shadowsocks", "trojan", "tuic"}, "uuid").
FieldOnChooseOptionsHide([]string{"", "mtproxy", "vless", "vmess"}, "password").
FieldOnChooseOptionsHide([]string{"", "hysteria", "hysteria2", "shadowsocks", "trojan", "tuic", "vless", "vmess"}, "secret").
FieldOnChooseOptionsHide([]string{"", "hysteria", "hysteria2", "mtproxy", "shadowsocks", "trojan", "tuic", "vmess"}, "flow").
FieldOnChooseOptionsHide([]string{"", "hysteria", "hysteria2", "mtproxy", "shadowsocks", "trojan", "tuic", "vless"}, "alter_id")
formList.AddField("Inbound", "inbound", db.Varchar, form.Text).
FieldMust().
FieldDisplayButCanNotEditWhenUpdate().
FieldOptionInitFn(func(val types.FieldModel) types.FieldOptions {
return types.FieldOptions{
{Value: val.Value, Text: val.Value, Selected: true},
}
})
formList.AddField("UUID", "uuid", db.Varchar, form.Text)
formList.AddField("Password", "password", db.Varchar, form.Text)
formList.AddField("Secret", "secret", db.Varchar, form.Text)
formList.AddField("Flow", "flow", db.Varchar, form.SelectSingle).
FieldOptions(types.FieldOptions{
{Text: "xtls-rprx-vision", Value: "xtls-rprx-vision"},
})
formList.AddField("Alter ID", "alter_id", db.Varchar, form.Number).
FieldDefault("0")
formList.SetInsertFn(func(values mForm.Values) (err error) {
squadIDs := make([]int, len(values["squad_ids[]"]))
for i, rawSquadID := range values["squad_ids[]"] {
squadID, err := strconv.Atoi(rawSquadID)
if err != nil {
return err
}
squadIDs[i] = squadID
}
var alterId int
if value := values.Get("alter_id"); value != "" {
alterId, err = strconv.Atoi(value)
if err != nil {
return err
}
}
_, err = manager.CreateUser(CM.UserCreate{
SquadIDs: squadIDs,
Username: values.Get("username"),
Type: values.Get("type"),
Inbound: values.Get("inbound"),
UUID: values.Get("uuid"),
Password: values.Get("password"),
Secret: values.Get("secret"),
Flow: values.Get("flow"),
AlterID: alterId,
})
if err != nil {
if ve, ok := err.(validator.ValidationErrors); ok {
var errors []string
for _, e := range ve {
switch e.Tag() {
case "required":
errors = append(errors, e.StructField()+": required field missing")
case "uuid4":
errors = append(errors, e.StructField()+": invalid UUID")
default:
errors = append(errors, e.StructField()+": invalid request")
}
}
err = fmt.Errorf("%s", strings.Join(errors, "<br>"))
}
}
return
})
formList.SetUpdateFn(func(values mForm.Values) (err error) {
id, err := strconv.Atoi(values.Get("id"))
if err != nil {
return err
}
var alterId int
if value := values.Get("alter_id"); value != "" {
alterId, err = strconv.Atoi(value)
if err != nil {
return err
}
}
_, err = manager.UpdateUser(id, CM.UserUpdate{
UUID: values.Get("uuid"),
Password: values.Get("password"),
Secret: values.Get("secret"),
Flow: values.Get("flow"),
AlterID: alterId,
})
return
})
formList.SetTable("users").SetTitle("Users").SetDescription("Users")
return
}
}

2
service/admin_panel/web/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules
.vite

View File

@@ -0,0 +1,65 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#141414" />
<link rel="icon" type="image/svg+xml" href="/src/assets/icon.svg" />
<title>Sing-box Extended</title>
<!--
Resolve the user's saved theme mode AND accent colour before the
React bundle loads so the document doesn't paint white (or with
the wrong accent) for a frame on reload. Mirrors the same
`sing-box-admin:mode` and `sing-box-admin:accent` localStorage
keys the React provider uses, falls back to `prefers-color-scheme`
for the mode and the default accent (#3b82f6) for the colour, and
hard-defaults to dark. The colour values match `theme.ts`
(DARK.surface / LIGHT.surface), and `--sb-accent` is the same CSS
variable every accent-aware MUI override consumes — by stamping it
onto <html> before any styles are applied, the very first frame
of every reload renders with the saved accent instead of flashing
the default blue.
-->
<script>
(function () {
try {
var stored = localStorage.getItem("sing-box-admin:mode");
var prefersLight =
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: light)").matches;
var mode =
stored === "light" || stored === "dark"
? stored
: prefersLight
? "light"
: "dark";
var bg = mode === "light" ? "#ffffff" : "#141414";
var fg = mode === "light" ? "#0f172a" : "#f5f5f5";
var root = document.documentElement;
root.style.colorScheme = mode;
root.style.backgroundColor = bg;
// Accent — same `isHexColor` check the React side does, so a
// corrupted value can never poison the CSS variable.
var accentRaw = localStorage.getItem("sing-box-admin:accent");
var accent =
accentRaw && /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(accentRaw)
? accentRaw
: "#3b82f6";
root.style.setProperty("--sb-accent", accent);
var meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute("content", bg);
document.addEventListener("DOMContentLoaded", function () {
document.body.style.backgroundColor = bg;
document.body.style.color = fg;
});
} catch (_) {
/* ignore */
}
})();
</script>
</head>
<body style="margin:0;font-family:Inter,system-ui,sans-serif">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3245
service/admin_panel/web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
{
"name": "sing-box-admin-panel",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b --noEmit && vite build --emptyOutDir --outDir ../dist",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.13.5",
"@emotion/styled": "^11.13.5",
"@fontsource/inter": "^5.2.8",
"@mui/icons-material": "^6.1.10",
"@mui/material": "^6.1.10",
"@mui/x-date-pickers": "^7.29.4",
"dayjs": "^1.11.20",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"recharts": "^3.8.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3",
"vite": "^5.4.11"
}
}

View File

@@ -0,0 +1,40 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { Layout } from "./components/Layout";
import { useAuth } from "./auth/AuthContext";
import { LoginPage } from "./pages/LoginPage";
import { DashboardPage } from "./pages/DashboardPage";
import { SquadsPage } from "./pages/SquadsPage";
import { NodesPage } from "./pages/NodesPage";
import { UsersPage } from "./pages/UsersPage";
import { BandwidthLimitersPage } from "./pages/BandwidthLimitersPage";
import { TrafficLimitersPage } from "./pages/TrafficLimitersPage";
import { ConnectionLimitersPage } from "./pages/ConnectionLimitersPage";
import { RateLimitersPage } from "./pages/RateLimitersPage";
export function App() {
const { auth } = useAuth();
if (!auth) {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
);
}
return (
<Layout>
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/squads" element={<SquadsPage />} />
<Route path="/nodes" element={<NodesPage />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/bandwidth-limiters" element={<BandwidthLimitersPage />} />
<Route path="/traffic-limiters" element={<TrafficLimitersPage />} />
<Route path="/connection-limiters" element={<ConnectionLimitersPage />} />
<Route path="/rate-limiters" element={<RateLimitersPage />} />
<Route path="/login" element={<Navigate to="/" replace />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout>
);
}

View File

@@ -0,0 +1,316 @@
import type {
BandwidthLimiter,
BandwidthLimiterCreate,
BandwidthLimiterUpdate,
ConnectionLimiter,
ConnectionLimiterCreate,
ConnectionLimiterUpdate,
CountResponse,
Listable,
Node,
NodeCreate,
NodeStatus,
NodeUpdate,
RateLimiter,
RateLimiterCreate,
RateLimiterUpdate,
Squad,
SquadCreate,
SquadUpdate,
TrafficLimiter,
TrafficLimiterCreate,
TrafficLimiterUpdate,
User,
UserCreate,
UserUpdate,
VersionInfo,
} from "./types";
export class ApiError extends Error {
status: number;
body: string;
constructor(status: number, body: string) {
super(`HTTP ${status}: ${body || "(empty)"}`);
this.status = status;
this.body = body;
}
}
// UnauthorizedError is thrown for 401 responses so callers can clear stored
// credentials and redirect to the login screen.
export class UnauthorizedError extends ApiError {
constructor(body: string) {
super(401, body);
this.name = "UnauthorizedError";
}
}
export interface AuthInfo {
baseUrl: string;
apiKey: string;
}
const STORAGE_KEY = "sing-box-admin:auth";
export function loadAuth(): AuthInfo | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<AuthInfo>;
if (!parsed.baseUrl || !parsed.apiKey) return null;
return { baseUrl: parsed.baseUrl, apiKey: parsed.apiKey };
} catch {
return null;
}
}
// All write paths are wrapped in try/catch so a private-mode browser, a
// disabled storage backend, or a quota error never bubbles up into the
// React effects that call us. The reads above already handle missing /
// malformed entries; failing silently here is the symmetrical behaviour.
export function saveAuth(auth: AuthInfo) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(auth));
} catch {
/* ignore */
}
}
export function clearAuth() {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
/* ignore */
}
}
// Login form draft — what the user typed before clicking Sign in. Stored
// independently of `AuthInfo` so it survives logout (and tab close), letting
// users come back to their last typed credentials.
export interface LoginDraft {
baseUrl: string;
apiKey: string;
}
const DRAFT_KEY = "sing-box-admin:login-draft";
export function loadLoginDraft(): LoginDraft {
try {
const raw = localStorage.getItem(DRAFT_KEY);
if (!raw) return { baseUrl: "", apiKey: "" };
const parsed = JSON.parse(raw) as Partial<LoginDraft>;
return {
baseUrl: typeof parsed.baseUrl === "string" ? parsed.baseUrl : "",
apiKey: typeof parsed.apiKey === "string" ? parsed.apiKey : "",
};
} catch {
return { baseUrl: "", apiKey: "" };
}
}
export function saveLoginDraft(draft: LoginDraft) {
try {
// Drop entirely empty drafts so we don't litter localStorage with "{}".
if (!draft.baseUrl && !draft.apiKey) {
localStorage.removeItem(DRAFT_KEY);
return;
}
localStorage.setItem(DRAFT_KEY, JSON.stringify(draft));
} catch {
/* ignore */
}
}
export function clearLoginDraft() {
try {
localStorage.removeItem(DRAFT_KEY);
} catch {
/* ignore */
}
}
// onUnauthorized lets the auth context plug in a global 401 handler that
// clears local credentials and bounces the user back to the login screen.
let onUnauthorized: ((err: UnauthorizedError) => void) | null = null;
export function setUnauthorizedHandler(fn: ((err: UnauthorizedError) => void) | null) {
onUnauthorized = fn;
}
function joinUrl(base: string, path: string): string {
const trimmed = base.replace(/\/+$/, "");
const suffix = path.startsWith("/") ? path : `/${path}`;
return `${trimmed}/manager/v1${suffix}`;
}
function buildQuery(params?: Listable): string {
if (!params) return "";
const segments: string[] = [];
for (const [k, v] of Object.entries(params)) {
if (v === undefined || v === null || v === "") continue;
if (Array.isArray(v)) {
const items: string[] = [];
for (const item of v) {
if (item === undefined || item === null || item === "") continue;
items.push(encodeURIComponent(String(item)));
}
if (items.length === 0) continue;
segments.push(`${encodeURIComponent(k)}=${items.join(",")}`);
} else {
segments.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
}
}
return segments.length > 0 ? `?${segments.join("&")}` : "";
}
async function request<T>(
auth: AuthInfo,
method: string,
path: string,
body?: unknown,
query?: Listable,
): Promise<T> {
let res: Response;
try {
res = await fetch(joinUrl(auth.baseUrl, path) + buildQuery(query), {
method,
headers: {
Authorization: `Bearer ${auth.apiKey}`,
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
} catch (e) {
throw new ApiError(0, e instanceof Error ? e.message : String(e));
}
if (res.status === 401) {
const text = await res.text().catch(() => "");
const err = new UnauthorizedError(text);
if (onUnauthorized) onUnauthorized(err);
throw err;
}
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new ApiError(res.status, text);
}
if (res.status === 204) return undefined as T;
const text = await res.text();
if (!text) return undefined as T;
// Reject HTML payloads up-front: the manager-api always returns JSON for
// success, so anything that smells like HTML must be a misrouted request
// (e.g. SPA fallback) and would only confuse callers expecting JSON.
const trimmed = text.trim();
if (trimmed.startsWith("<")) {
throw new ApiError(
res.status,
"expected JSON, got HTML — is the API base URL pointing at the manager-api server?",
);
}
try {
return JSON.parse(text) as T;
} catch {
throw new ApiError(res.status, `invalid JSON response: ${text.slice(0, 200)}`);
}
}
interface CRUD<TEntity, TCreate, TUpdate, TID = number> {
list: (q?: Listable) => Promise<TEntity[]>;
count: (q?: Listable) => Promise<number>;
get: (id: TID) => Promise<TEntity>;
create: (body: TCreate) => Promise<TEntity>;
update: (id: TID, body: TUpdate) => Promise<TEntity>;
remove: (id: TID) => Promise<TEntity>;
}
function intCRUD<TEntity, TCreate, TUpdate>(
auth: AuthInfo,
base: string,
): CRUD<TEntity, TCreate, TUpdate, number> {
return {
list: async (q) => {
const r = await request<TEntity[]>(auth, "GET", base, undefined, q);
return Array.isArray(r) ? r : [];
},
count: async (q) => {
const r = await request<CountResponse>(auth, "GET", `${base}/count`, undefined, q);
return r?.count ?? 0;
},
get: (id) => request<TEntity>(auth, "GET", `${base}/${id}`),
create: (body) => request<TEntity>(auth, "POST", base, body),
update: (id, body) => request<TEntity>(auth, "PUT", `${base}/${id}`, body),
remove: (id) => request<TEntity>(auth, "DELETE", `${base}/${id}`),
};
}
export function makeApi(auth: AuthInfo) {
const nodesBase = "/nodes";
return {
auth,
squads: intCRUD<Squad, SquadCreate, SquadUpdate>(auth, "/squads"),
users: intCRUD<User, UserCreate, UserUpdate>(auth, "/users"),
bandwidthLimiters: intCRUD<BandwidthLimiter, BandwidthLimiterCreate, BandwidthLimiterUpdate>(
auth,
"/bandwidth-limiters",
),
trafficLimiters: {
...intCRUD<TrafficLimiter, TrafficLimiterCreate, TrafficLimiterUpdate>(
auth,
"/traffic-limiters",
),
// updateUsed overwrites the running raw_used counter on a
// traffic limiter. Passing 0 is the supported "reset traffic"
// operation surfaced in the UI; any uint64 also works for ad-hoc
// adjustments.
updateUsed: (id: number, used: number) =>
request<TrafficLimiter>(
auth,
"PUT",
`/traffic-limiters/${id}/used`,
{ used },
),
},
connectionLimiters: intCRUD<ConnectionLimiter, ConnectionLimiterCreate, ConnectionLimiterUpdate>(
auth,
"/connection-limiters",
),
rateLimiters: intCRUD<RateLimiter, RateLimiterCreate, RateLimiterUpdate>(
auth,
"/rate-limiters",
),
// Identity probe: returns the running sing-box build's version
// and the project home URL. Used by the sidebar's About popover
// so the panel can show what release the connected manager-api
// is built from. Cheap, single-shot — call it once on Layout
// mount and cache the result for the session.
version: () => request<VersionInfo>(auth, "GET", "/version"),
nodes: {
list: async (q?: Listable) => {
const r = await request<Node[]>(auth, "GET", nodesBase, undefined, q);
return Array.isArray(r) ? r : [];
},
count: async (q?: Listable) => {
const r = await request<CountResponse>(auth, "GET", `${nodesBase}/count`, undefined, q);
return r?.count ?? 0;
},
get: (uuid: string) => request<Node>(auth, "GET", `${nodesBase}/${uuid}`),
create: (body: NodeCreate) => request<Node>(auth, "POST", nodesBase, body),
update: (uuid: string, body: NodeUpdate) =>
request<Node>(auth, "PUT", `${nodesBase}/${uuid}`, body),
remove: (uuid: string) => request<Node>(auth, "DELETE", `${nodesBase}/${uuid}`),
status: async (uuid: string): Promise<NodeStatus> => {
const r = await request<{ status: NodeStatus }>(
auth,
"GET",
`${nodesBase}/${uuid}/status`,
);
return r.status;
},
},
};
}
export type Api = ReturnType<typeof makeApi>;
export async function ping(auth: AuthInfo): Promise<void> {
// Cheap reachability + auth check.
await request<CountResponse>(auth, "GET", "/squads/count");
}

View File

@@ -0,0 +1,245 @@
// Mirrors the DTOs declared in service/manager/constant/dto.go.
// Strings stay strings (the server emits time.Time as RFC3339).
export type SquadIDs = number[];
export interface Squad {
id: number;
name: string;
created_at: string;
updated_at: string;
}
export interface SquadCreate {
name: string;
}
export interface SquadUpdate {
name: string;
}
export interface Node {
uuid: string;
name: string;
squad_ids: SquadIDs;
created_at: string;
updated_at: string;
}
export interface NodeCreate {
uuid: string;
name: string;
squad_ids: SquadIDs;
}
export interface NodeUpdate {
name: string;
}
export type NodeStatus = "online" | "offline";
export type UserType =
| "hysteria"
| "hysteria2"
| "mtproxy"
| "trojan"
| "tuic"
| "vless"
| "vmess";
export interface User {
id: number;
squad_ids: SquadIDs;
username: string;
inbound: string;
type: UserType;
uuid: string;
password: string;
secret: string;
flow: string;
alter_id: number;
created_at: string;
updated_at: string;
}
export interface UserCreate {
squad_ids: SquadIDs;
username: string;
inbound: string;
type: UserType;
uuid?: string;
password?: string;
secret?: string;
flow?: string;
alter_id?: number;
}
export interface UserUpdate {
uuid?: string;
password?: string;
secret?: string;
flow?: string;
alter_id?: number;
}
export type BandwidthStrategy = "global" | "connection" | "bypass";
export type BandwidthMode = "upload" | "download" | "bidirectional";
export type ConnectionType = "default" | "hwid" | "mux" | "source_ip";
export interface BandwidthLimiter {
id: number;
squad_ids: SquadIDs;
username?: string;
outbound: string;
strategy: BandwidthStrategy;
connection_type?: ConnectionType;
mode: BandwidthMode;
flow_keys?: string[];
speed: string;
raw_speed: number;
created_at: string;
updated_at: string;
}
// `mode`, `flow_keys`, `connection_type` and `speed` carry
// `excluded_if=Strategy bypass` on the manager-api DTO (see
// service/manager/constant/dto.go) — they must be omitted from the
// JSON body when strategy="bypass", otherwise the SQL repository
// fails to parse `speed=""` via byteformats.NetworkBytesCompat and
// the request is rejected with 400 "invalid format". Marking them
// optional here lets the page builders drop them via
// `JSON.stringify`'s `undefined`-skips-key behaviour.
export interface BandwidthLimiterCreate {
squad_ids: SquadIDs;
username?: string;
outbound: string;
strategy: BandwidthStrategy;
connection_type?: ConnectionType;
mode?: BandwidthMode;
flow_keys?: string[];
speed?: string;
}
export interface BandwidthLimiterUpdate {
username?: string;
outbound: string;
strategy: BandwidthStrategy;
connection_type?: ConnectionType;
mode?: BandwidthMode;
flow_keys?: string[];
speed?: string;
}
export type TrafficStrategy = "global" | "bypass";
export type TrafficMode = BandwidthMode;
export interface TrafficLimiter {
id: number;
squad_ids: SquadIDs;
username?: string;
outbound: string;
strategy: TrafficStrategy;
mode: TrafficMode;
raw_used: number;
quota: string;
raw_quota: number;
usage: number;
created_at: string;
updated_at: string;
}
// `mode` / `quota` are excluded_if=Strategy bypass on the DTO; see the
// BandwidthLimiterCreate comment above for the full rationale.
export interface TrafficLimiterCreate {
squad_ids: SquadIDs;
username?: string;
outbound: string;
strategy: TrafficStrategy;
mode?: TrafficMode;
quota?: string;
}
export interface TrafficLimiterUpdate {
username?: string;
outbound: string;
strategy: TrafficStrategy;
mode?: TrafficMode;
quota?: string;
}
export type ConnectionStrategy = "connection" | "bypass";
export type LockType = "manager" | "default";
export interface ConnectionLimiter {
id: number;
squad_ids: SquadIDs;
username?: string;
outbound: string;
strategy: ConnectionStrategy;
connection_type?: ConnectionType;
lock_type: LockType;
count: number;
created_at: string;
updated_at: string;
}
// `connection_type` / `lock_type` / `count` are excluded_if=Strategy
// bypass on the DTO; see the BandwidthLimiterCreate comment above for
// the full rationale.
export interface ConnectionLimiterCreate {
squad_ids: SquadIDs;
username?: string;
outbound: string;
strategy: ConnectionStrategy;
connection_type?: ConnectionType;
lock_type?: LockType;
count?: number;
}
export interface ConnectionLimiterUpdate {
username?: string;
outbound: string;
strategy: ConnectionStrategy;
connection_type?: ConnectionType;
lock_type?: LockType;
count?: number;
}
export type RateStrategy =
| "fixed_window"
| "sliding_window"
| "token_bucket"
| "leaky_bucket"
| "bypass";
export type RateConnectionType = "hwid" | "mux" | "source_ip" | "default";
export interface RateLimiter {
id: number;
squad_ids: SquadIDs;
username?: string;
outbound: string;
strategy: RateStrategy;
connection_type: RateConnectionType;
count: number;
interval: string;
created_at: string;
updated_at: string;
}
// `connection_type` / `count` / `interval` are excluded_if=Strategy
// bypass on the DTO; see the BandwidthLimiterCreate comment above for
// the full rationale.
export interface RateLimiterCreate {
squad_ids: SquadIDs;
username?: string;
outbound: string;
strategy: RateStrategy;
connection_type?: RateConnectionType;
count?: number;
interval?: string;
}
export interface RateLimiterUpdate {
username?: string;
outbound: string;
strategy: RateStrategy;
connection_type?: RateConnectionType;
count?: number;
interval?: string;
}
export interface CountResponse {
count: number;
}
// Mirrors the JSON shape returned by `GET /manager/v1/version` —
// see service/manager_api/http/server/server.go.
export interface VersionInfo {
version: string;
website: string;
}
export type Listable = Record<string, string | number | boolean | string[] | number[] | undefined | null>;

View File

@@ -0,0 +1,37 @@
<svg width="1027" height="1109" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" overflow="hidden">
<defs>
<filter id="fx0" x="-10%" y="-10%" width="120%" height="120%" filterUnits="userSpaceOnUse" primitiveUnits="userSpaceOnUse">
<feComponentTransfer color-interpolation-filters="sRGB">
<feFuncR type="discrete" tableValues="0 0" />
<feFuncG type="discrete" tableValues="0 0" />
<feFuncB type="discrete" tableValues="0 0" />
<feFuncA type="linear" slope="0.4" intercept="0" />
</feComponentTransfer>
<feGaussianBlur stdDeviation="4.58333 4.58333" />
</filter>
<clipPath id="clip1">
<rect x="692" y="855" width="1027" height="1109" />
</clipPath>
<clipPath id="clip2">
<rect x="-2" y="-2" width="541" height="786" />
</clipPath>
<clipPath id="clip3">
<rect x="0" y="0" width="535" height="782" />
</clipPath>
</defs>
<g clip-path="url(#clip1)" transform="translate(-692 -855)">
<path d="M692 1191 692 1575.69C692 1640.41 731.499 1651.19 731.499 1651.19L1148.03 1931.62C1212.66 1974.77 1194.71 1881.29 1194.71 1881.29L1194.71 1528.96 692 1191Z" fill="#37474F" fill-rule="evenodd" />
<g clip-path="url(#clip2)" filter="url(#fx0)" transform="translate(1184 1182)">
<g clip-path="url(#clip3)">
<path d="M520.482 15.4819 520.482 400.176C520.482 464.89 480.983 475.676 480.983 475.676 480.983 475.676 129.086 712.963 64.4523 756.106-0.181814 799.25 17.7721 705.773 17.7721 705.773L17.7721 353.437 520.482 15.4819Z" fill="#455A64" fill-rule="evenodd" />
</g>
</g>
<path d="M1698 1191 1698 1575.69C1698 1640.41 1658.5 1651.19 1658.5 1651.19 1658.5 1651.19 1306.6 1888.48 1241.97 1931.62 1177.34 1974.77 1195.29 1881.29 1195.29 1881.29L1195.29 1528.96 1698 1191Z" fill="#455A64" fill-rule="evenodd" />
<path d="M1241.71 868.473C1212.96 850.509 1169.85 850.509 1144.7 868.473L713.557 1163.07C684.814 1181.04 684.814 1213.37 713.557 1231.33L1144.7 1529.53C1173.44 1547.49 1216.56 1547.49 1241.71 1529.53L1676.44 1227.74C1705.19 1209.78 1705.19 1177.44 1676.44 1159.48L1241.71 868.473Z" fill="#546E7A" fill-rule="evenodd" />
<path d="M1195 1949C1173.4 1949 1159 1935.19 1159 1917.92L1159 1531.08C1159 1513.82 1173.4 1500 1195 1500 1216.6 1500 1231 1513.82 1231 1531.08L1231 1914.46C1231 1935.19 1216.6 1949 1195 1949Z" fill="#546E7A" fill-rule="evenodd" />
<path d="M1553.92 1435.92C1553.92 1471.89 1557.5 1486.27 1518.03 1511.45L1428.32 1568.99C1388.85 1594.17 1374.5 1572.59 1374.5 1540.22L1374.5 1446.71C1374.5 1439.52 1374.5 1435.92 1363.73 1428.73 1270.43 1363.99 911.591 1115.84 847 1069.09L1012.07 954C1058.72 982.772 1399.61 1209.35 1539.56 1306.45 1546.74 1310.05 1550.33 1317.24 1550.33 1320.84L1550.33 1435.92Z" fill="#99AAB5" fill-rule="evenodd" />
<path d="M1543.41 1310.21C1399.82 1213.17 1058.79 986.752 1015.72 958L951.103 997.534 847 1069.41C911.615 1116.14 1270.59 1360.53 1363.92 1425.22 1371.1 1428.81 1371.1 1432.41 1371.1 1436L1547 1313.8C1547 1313.8 1547 1310.21 1543.41 1310.21Z" fill="#CCD6DD" fill-rule="evenodd" />
<path d="M1554.9 1435.48 1554.9 1324.19C1554.9 1317.01 1551.3 1313.42 1544.11 1309.83 1400.28 1212.89 1058.67 986.721 1015.51 958L940 1008.26C1062.26 1090.83 1389.49 1306.24 1475.79 1367.27 1486.58 1374.45 1486.58 1381.63 1486.58 1385.22L1486.58 1536 1522.54 1510.87C1558.5 1485.74 1554.9 1467.79 1554.9 1435.48Z" fill="#CCD6DD" fill-rule="evenodd" />
<path d="M1543.23 1309.95C1399.6 1212.98 1058.49 986.731 1015.4 958L940 1008.28C1062.08 1090.88 1388.83 1306.36 1475.01 1367.41 1475.01 1367.41 1478.6 1371 1478.6 1371L1554 1317.13C1546.82 1313.54 1546.82 1309.95 1543.23 1309.95Z" fill="#E1E8ED" fill-rule="evenodd" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -0,0 +1,107 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import {
type Api,
type AuthInfo,
clearAuth,
loadAuth,
makeApi,
saveAuth,
saveLoginDraft,
setUnauthorizedHandler,
} from "../api/client";
import { useNotify } from "../notifications/NotificationsProvider";
interface AuthContextValue {
auth: AuthInfo | null;
api: Api | null;
login: (auth: AuthInfo) => void;
logout: () => void;
}
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [auth, setAuth] = useState<AuthInfo | null>(() => loadAuth());
const notify = useNotify();
// keep tabs in sync
useEffect(() => {
const handler = (e: StorageEvent) => {
if (e.key === null || e.key === "sing-box-admin:auth") setAuth(loadAuth());
};
window.addEventListener("storage", handler);
return () => window.removeEventListener("storage", handler);
}, []);
const login = useCallback((next: AuthInfo) => {
saveAuth(next);
setAuth(next);
}, []);
const logout = useCallback(() => {
// Remember the URL the user was just connected to so the login form is
// pre-filled on the way back. The key is *not* preserved.
setAuth((prev) => {
if (prev?.baseUrl) {
saveLoginDraft({ baseUrl: prev.baseUrl, apiKey: "" });
}
return null;
});
clearAuth();
}, []);
// Globally trap 401 → kick back to the login screen.
//
// Two flavours, distinguished by whether the user *was* signed in
// when the 401 hit:
// - prev !== null → an active session got rejected (key revoked,
// server restarted with a different secret, …). We surface a
// toast so the user understands why they're suddenly back at
// the login screen.
// - prev === null → the failure happened *during* a login attempt
// (the LoginPage's `ping` probe). The login form already shows
// an inline error and emits its own focused toast, so we stay
// quiet here to avoid double-announcing the same failure.
useEffect(() => {
setUnauthorizedHandler(() => {
setAuth((prev) => {
if (prev?.baseUrl) {
saveLoginDraft({ baseUrl: prev.baseUrl, apiKey: "" });
}
if (prev) {
notify.error("Session expired — please sign in again.");
}
return null;
});
clearAuth();
});
return () => setUnauthorizedHandler(null);
}, [notify]);
const value = useMemo<AuthContextValue>(
() => ({ auth, api: auth ? makeApi(auth) : null, login, logout }),
[auth, login, logout],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}
export function useApi(): Api {
const { api } = useAuth();
if (!api) throw new Error("API used without auth");
return api;
}

View File

@@ -0,0 +1,231 @@
import {
Box,
Button,
IconButton,
Popover,
Stack,
TextField,
Tooltip,
} from "@mui/material";
import PaletteOutlinedIcon from "@mui/icons-material/PaletteOutlined";
import { useEffect, useRef, useState } from "react";
import { isHexColor } from "../theme";
import { useAccent } from "../theme/AppThemeProvider";
// A handful of curated presets so the user can pick a tasteful color in one
// click. They're roughly the Tailwind "500" values for vivid hues.
const PRESETS = [
"#3b82f6", // blue (default)
"#7c83ff", // indigo
"#00ffff", // cyan
"#22c55e", // green
"#f59e0b", // amber
"#ef4444", // red
"#ec4899", // pink
"#a855f7", // purple
"#14b8a6", // teal
];
export function ColorPickerButton() {
const { accent, setAccent, resetAccent } = useAccent();
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
const [draftHex, setDraftHex] = useState(accent);
const colorInputRef = useRef<HTMLInputElement | null>(null);
// Keep the text input in sync when the accent changes (e.g. preset clicked
// or another tab updated localStorage).
useEffect(() => setDraftHex(accent), [accent]);
const open = Boolean(anchor);
const apply = (hex: string) => {
if (isHexColor(hex)) setAccent(hex);
};
return (
<>
<IconButton
aria-label="Choose theme color"
onClick={(e) => setAnchor(e.currentTarget)}
size="medium"
sx={{
color: "text.primary",
borderRadius: 2,
width: 40,
height: 40,
"&:hover": { backgroundColor: "action.hover" },
}}
>
<Box
sx={{
position: "relative",
width: 22,
height: 22,
display: "grid",
placeItems: "center",
}}
>
<PaletteOutlinedIcon fontSize="small" />
<Box
sx={{
position: "absolute",
bottom: -2,
right: -2,
width: 8,
height: 8,
borderRadius: "50%",
bgcolor: accent,
// Theme-aware ring around the accent dot so it reads
// cleanly against either the dark or light topbar.
border: "1.5px solid",
borderColor: "background.default",
}}
/>
</Box>
</IconButton>
<Popover
open={open}
anchorEl={anchor}
onClose={() => setAnchor(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
transformOrigin={{ vertical: "top", horizontal: "right" }}
slotProps={{ paper: { sx: { p: 2, width: 260 } } }}
>
<Stack spacing={1.5}>
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(5, 1fr)",
gap: 1,
}}
>
{PRESETS.map((c, i) => (
<Swatch
key={c}
color={c}
index={i}
selected={c.toLowerCase() === accent.toLowerCase()}
onPick={() => apply(c)}
/>
))}
</Box>
<Stack direction="row" spacing={1} alignItems="center">
<Box
onClick={() => colorInputRef.current?.click()}
sx={{
width: 36,
height: 36,
flexShrink: 0,
borderRadius: 1.5,
bgcolor: accent,
cursor: "pointer",
outline: "1px solid",
outlineColor: "divider",
transition:
"background-color 0.32s cubic-bezier(0.4,0,0.2,1), transform 0.12s cubic-bezier(0.4,0,0.2,1)",
"&:hover": { transform: "scale(1.04)" },
"&:active": { transform: "scale(0.94)" },
}}
/>
<input
ref={colorInputRef}
type="color"
value={accent}
onChange={(e) => apply(e.target.value)}
style={{ display: "none" }}
/>
<TextField
size="small"
fullWidth
value={draftHex}
onChange={(e) => {
const v = e.target.value;
setDraftHex(v);
if (isHexColor(v)) setAccent(v);
}}
placeholder="#7c83ff"
inputProps={{ maxLength: 7, "aria-label": "Custom hex color" }}
error={draftHex.length > 0 && !isHexColor(draftHex)}
/>
</Stack>
<Button
size="small"
variant="outlined"
color="inherit"
onClick={() => {
resetAccent();
setAnchor(null);
}}
>
Reset to default
</Button>
</Stack>
</Popover>
</>
);
}
// Swatch — a single colour cell. The entry fade/grow animation still plays
// when the popover opens, but picking a colour no longer triggers an extra
// pop bounce: that second animation used `composite: "add"` layered on top
// of the CSS hover/active transform transitions, which caused a visible
// twitch as the WAAPI effect finished and the element snapped back to its
// hover-scaled baseline.
function Swatch({
color,
index,
selected,
onPick,
}: {
color: string;
index: number;
selected: boolean;
onPick: () => void;
}) {
return (
<Tooltip title={color.toUpperCase()}>
<Box
role="button"
tabIndex={0}
onClick={onPick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onPick();
}}
sx={{
position: "relative",
width: 36,
height: 36,
borderRadius: 1.5,
bgcolor: color,
cursor: "pointer",
outline: selected ? "2px solid" : "1px solid",
outlineColor: selected ? "text.primary" : "divider",
outlineOffset: selected ? 2 : 0,
zIndex: 1,
transition:
"transform 0.18s cubic-bezier(0.4,0,0.2,1), outline-offset 0.18s cubic-bezier(0.4,0,0.2,1), box-shadow 0.18s cubic-bezier(0.4,0,0.2,1), outline-color 0.2s cubic-bezier(0.4,0,0.2,1)",
animation: `sb-swatch-enter 0.32s ${index * 32}ms cubic-bezier(0.34, 1.4, 0.64, 1) backwards`,
"@keyframes sb-swatch-enter": {
from: { opacity: 0, transform: "scale(0.6)" },
to: { opacity: 1, transform: "scale(1)" },
},
"&:hover": {
transform: "scale(1.08)",
zIndex: 5,
boxShadow: `0 0 0 4px color-mix(in srgb, ${color} 22%, transparent)`,
},
"&:active": {
transform: "scale(0.92)",
transition:
"transform 0.08s cubic-bezier(0.4,0,0.2,1), outline-offset 0.18s cubic-bezier(0.4,0,0.2,1)",
},
"&:focus-visible": {
outline: "2px solid",
outlineColor: "text.primary",
outlineOffset: 2,
},
}}
/>
</Tooltip>
);
}

View File

@@ -0,0 +1,216 @@
import { Box, IconButton, Tooltip } from "@mui/material";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import CheckIcon from "@mui/icons-material/Check";
import {
useCallback,
useEffect,
useRef,
useState,
type MouseEvent as ReactMouseEvent,
} from "react";
// copyToClipboard writes `text` to the OS clipboard, preferring the modern
// async Clipboard API and falling back to a hidden textarea + execCommand
// for non-secure contexts (HTTP, older browsers) where `navigator.clipboard`
// is undefined.
async function copyToClipboard(text: string): Promise<boolean> {
try {
if (
typeof navigator !== "undefined" &&
navigator.clipboard &&
typeof navigator.clipboard.writeText === "function"
) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
/* fall through to legacy path */
}
try {
const ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "");
ta.style.position = "fixed";
ta.style.top = "0";
ta.style.left = "0";
ta.style.opacity = "0";
ta.style.pointerEvents = "none";
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand("copy");
document.body.removeChild(ta);
return ok;
} catch {
return false;
}
}
interface CopyableIdProps {
// The string written to the clipboard. Also rendered inline as the
// visible label — the caller doesn't pass it twice.
value: string;
// Optional override for the tooltip's idle title ("Copy UUID" by default).
// Used to keep the hint accurate when the same component is reused for
// non-UUID identifiers (numeric IDs, IPs, etc.).
label?: string;
}
// CopyableId — inline value + click-to-copy with a small always-visible
// icon and a brief "Copied!" confirmation. Designed to drop into table
// cells styled by `ID_CELL_SX`: long values clip with an ellipsis on the
// left side, while the icon stays pinned to the right.
//
// The whole component is one click target: clicking either the text or
// the explicit icon button copies the value, so users don't have to aim
// for the tiny icon. After a successful copy the icon flips to a green
// checkmark for ~1.2 s so users get visual confirmation regardless of
// the cursor position.
export function CopyableId({ value, label }: CopyableIdProps) {
const [copied, setCopied] = useState(false);
const timeoutRef = useRef<number | null>(null);
// Cancel any pending "Copied!" reset on unmount so a fast row remount
// (pagination, filter apply) doesn't run setState on a dead component.
useEffect(
() => () => {
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current);
}
},
[],
);
const handleCopy = useCallback(
async (e?: ReactMouseEvent<HTMLElement>) => {
// Stop the click from bubbling to ancestors that might react to row
// clicks (selection toggles, navigation), and from triggering text
// selection on double-click.
e?.stopPropagation();
e?.preventDefault();
const ok = await copyToClipboard(value);
if (!ok) return;
setCopied(true);
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current);
}
timeoutRef.current = window.setTimeout(() => {
setCopied(false);
timeoutRef.current = null;
}, 1200);
},
[value],
);
const idleTitle = label ?? "Copy UUID";
return (
<Box
onClick={handleCopy}
role="button"
aria-label={idleTitle}
sx={{
display: "inline-flex",
alignItems: "center",
gap: 0.5,
maxWidth: "100%",
cursor: "pointer",
userSelect: "text",
// Subtle hover affordance — the cell text shifts toward the
// primary text colour so it reads as interactive without painting
// a button-like background that would feel heavy in a dense table.
transition: "color 0.14s ease",
"&:hover": { color: "text.primary" },
}}
>
<Box
component="span"
sx={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
minWidth: 0,
}}
>
{value}
</Box>
<Tooltip
// Suppress the tooltip while the "Copied!" feedback is on screen —
// the icon + colour change is enough confirmation, and a tooltip
// whose title flips mid-animation was reading as the source of the
// jitter (MUI re-measures + re-positions the popper on every text
// change, and that work landed on the same frames as the icon
// cross-fade below).
title={copied ? "" : idleTitle}
placement="top"
arrow
disableInteractive
>
<IconButton
size="small"
className="copy-affordance"
onClick={handleCopy}
aria-label={idleTitle}
sx={{
width: 22,
height: 22,
p: 0,
flexShrink: 0,
// The IconButton itself doesn't carry a `color` any more —
// each stacked icon paints itself directly so the cross-fade
// doesn't have to also animate `currentColor`. Without this
// separation the incoming `ContentCopy` icon would briefly
// render green (inheriting the still-mid-transition success
// colour) and then "snap" to grey, which is the small jitter
// that was visible at the end of the 1.2 s window.
"&:hover": { backgroundColor: "transparent" },
}}
>
{/* Two icons stacked on the same 14×14 spot, cross-faded by
opacity + scale. Each icon owns its own colour so the
transition is a pure visual swap with no `currentColor`
re-flow. The slight scale gives the swap a designed
"pop / dismiss" feel instead of a hard cut, which is what
the eye reads as a glitch when only opacity changes. */}
<Box
component="span"
sx={{
position: "relative",
width: 14,
height: 14,
display: "inline-block",
"& > svg": {
position: "absolute",
top: 0,
left: 0,
fontSize: 14,
transformOrigin: "center",
transition:
"opacity 0.22s cubic-bezier(0.4, 0, 0.2, 1), transform 0.22s cubic-bezier(0.4, 0, 0.2, 1)",
// Force the icon's transform onto its own compositor
// layer so the scale animates on the GPU and never
// shares a frame budget with the surrounding row's
// hover / table re-renders.
willChange: "opacity, transform",
},
}}
>
<ContentCopyIcon
sx={{
color: "text.secondary",
opacity: copied ? 0 : 1,
transform: copied ? "scale(0.7)" : "scale(1)",
}}
/>
<CheckIcon
sx={{
color: "success.main",
opacity: copied ? 1 : 0,
transform: copied ? "scale(1)" : "scale(0.7)",
}}
/>
</Box>
</IconButton>
</Tooltip>
</Box>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,958 @@
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Drawer,
IconButton,
List,
ListItemButton,
ListItemIcon,
ListItemText,
ListSubheader,
Popover,
Tooltip,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import LogoutIcon from "@mui/icons-material/Logout";
import LightModeIcon from "@mui/icons-material/LightMode";
import DarkModeIcon from "@mui/icons-material/DarkMode";
import DashboardIcon from "@mui/icons-material/Dashboard";
import GroupsIcon from "@mui/icons-material/Groups";
import StorageIcon from "@mui/icons-material/Storage";
import PeopleIcon from "@mui/icons-material/People";
import LinkIcon from "@mui/icons-material/Link";
import SpeedIcon from "@mui/icons-material/Speed";
import SwapHorizIcon from "@mui/icons-material/SwapHoriz";
import FilterAltIcon from "@mui/icons-material/FilterAlt";
import GitHubIcon from "@mui/icons-material/GitHub";
import FavoriteIcon from "@mui/icons-material/Favorite";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import MenuIcon from "@mui/icons-material/Menu";
import InfoIcon from "@mui/icons-material/Info";
import brandIcon from "../assets/icon.svg";
import { Link as RouterLink, useLocation } from "react-router-dom";
import { useEffect, useState, type ReactNode, type MouseEvent } from "react";
import { useApi, useAuth } from "../auth/AuthContext";
import type { VersionInfo } from "../api/types";
import { useAccent } from "../theme/AppThemeProvider";
import { ColorPickerButton } from "./ColorPickerButton";
const leftDrawerWidthExpanded = 240;
const leftDrawerWidthCollapsed = 56;
const headerHeight = 60;
const navItemSize = 40; // square size of each menu button when collapsed
// Smooth, snappy animation for the sidebar collapse/expand.
const drawerTransition = "width 0.22s cubic-bezier(0.4, 0, 0.2, 1)";
const COLLAPSED_KEY = "sing-box-admin:nav-collapsed";
// NavItem is one of three flavours, mutually exclusive:
// - `to` — internal route, navigated via react-router; the item
// lights up as "selected" when the URL matches.
// - `href` — external URL; opens in a new tab, never selected.
// - `action` — ad-hoc onClick (e.g. opens a popover); never
// selected, doesn't dismiss the mobile drawer because
// the click target is meant to remain anchored.
interface NavItem {
label: string;
icon: ReactNode;
to?: string;
href?: string;
action?: (e: MouseEvent<HTMLElement>) => void;
}
interface NavGroup {
header?: string;
items: NavItem[];
}
const NAV_GROUPS: NavGroup[] = [
{
header: "General",
items: [
{ to: "/", label: "Dashboard", icon: <DashboardIcon fontSize="small" /> },
{ to: "/squads", label: "Squads", icon: <GroupsIcon fontSize="small" /> },
{ to: "/nodes", label: "Nodes", icon: <StorageIcon fontSize="small" /> },
{ to: "/users", label: "Users", icon: <PeopleIcon fontSize="small" /> },
],
},
{
header: "Limiters",
items: [
// Order matches the table-registration order in service/admin_panel:
// connection → bandwidth → traffic → rate.
{
to: "/connection-limiters",
label: "Connection limiters",
icon: <LinkIcon fontSize="small" />,
},
{
to: "/bandwidth-limiters",
label: "Bandwidth limiters",
icon: <SpeedIcon fontSize="small" />,
},
{
to: "/traffic-limiters",
label: "Traffic limiters",
icon: <SwapHorizIcon fontSize="small" />,
},
{
to: "/rate-limiters",
label: "Rate limiters",
icon: <FilterAltIcon fontSize="small" />,
},
],
},
{
// Mirrors the "Miscellaneous" menu entries from
// service/admin_panel/migration/postgresql.go.
header: "Miscellaneous",
items: [
{
href: "https://github.com/shtorm-7/sing-box-extended",
label: "GitHub",
icon: <GitHubIcon fontSize="small" />,
},
{
href: "https://github.com/shtorm-7/sing-box-extended#support-the-project",
label: "Donate",
icon: <FavoriteIcon fontSize="small" />,
},
],
},
];
export function Layout({ children }: { children: ReactNode }) {
const { logout } = useAuth();
const api = useApi();
const location = useLocation();
const { palette, mode, toggleMode } = useAccent();
const theme = useTheme();
// Version info shown in the bottom-of-sidebar About popover.
//
// Two loads on mount, each from a different service — the panel
// is reachable via two HTTP endpoints, and we surface both so a
// mismatch is visible at a glance:
//
// - "Server" — `versionInfo` from manager-api's authenticated
// `/version` (fetched through the API client).
// - "Web" — `webVersion` from admin_panel's own
// `/version`, which serves `constant.Version` of the running
// sing-box binary (the same string the Makefile injects via
// `-ldflags -X` at link time). Fetched with a relative URL so
// a sub-path deployment (admin panel behind a reverse proxy)
// keeps working without configuration.
//
// Both loads are fire-and-forget; failures fall through to a
// placeholder rather than disrupting the layout — the sidebar
// should never break because of a /version response.
const [versionInfo, setVersionInfo] = useState<VersionInfo | null>(null);
const [webVersion, setWebVersion] = useState<string | null>(null);
const [aboutAnchor, setAboutAnchor] = useState<HTMLElement | null>(null);
// Sign-out confirmation: the bottom-bar Logout button used to call
// `logout` directly, which made it trivial to drop the session by
// accident (the icon sits one row below the colour-picker / theme
// toggle that users poke at constantly). Gate it behind a confirm
// dialog instead — same shape as the delete-confirmation dialog in
// CrudPage so the two reads as a coherent pair.
const [signOutOpen, setSignOutOpen] = useState(false);
useEffect(() => {
let cancelled = false;
api
.version()
.then((info) => {
if (!cancelled) setVersionInfo(info);
})
.catch(() => {
/* keep `null`; popover renders a placeholder. */
});
// The web version comes from the admin_panel service that
// served us, NOT manager-api. `./version` resolves relative to
// the document URL so a sub-path deployment still hits the
// right origin without us having to compute a base URL.
fetch("./version", { cache: "no-store" })
.then((r) => (r.ok ? r.json() : null))
.then((data: { version?: string } | null) => {
if (!cancelled && data && typeof data.version === "string") {
setWebVersion(data.version);
}
})
.catch(() => {
/* keep `null`; popover renders a placeholder. */
});
return () => {
cancelled = true;
};
}, [api]);
const openAbout = (e: MouseEvent<HTMLElement>) => setAboutAnchor(e.currentTarget);
const closeAbout = () => setAboutAnchor(null);
// Augment the static NAV_GROUPS with the About entry as the last
// item of "Miscellaneous". Done here (rather than at module scope)
// because the action callback closes over `openAbout`, which only
// exists once the component is rendering.
const navGroups: NavGroup[] = NAV_GROUPS.map((group) =>
group.header === "Miscellaneous"
? {
...group,
items: [
...group.items,
{
label: "About",
icon: <InfoIcon fontSize="small" />,
action: openAbout,
},
],
}
: group,
);
// Single breakpoint governs the "mobile" treatment: below `md`
// (< 900 px) the permanent sidebar becomes a temporary drawer
// opened by a hamburger. Portrait tablets + phones all fall under
// this threshold; desktop (≥ 900 px) keeps the original collapsible
// sidebar layout. Topbar buttons (theme toggle, accent picker,
// sign-out) are icon-only at every viewport so the row's geometry
// doesn't shift across the breakpoint.
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
const [mobileOpen, setMobileOpen] = useState(false);
const [collapsed, setCollapsed] = useState<boolean>(() => {
try {
return localStorage.getItem(COLLAPSED_KEY) === "1";
} catch {
return false;
}
});
useEffect(() => {
try {
localStorage.setItem(COLLAPSED_KEY, collapsed ? "1" : "0");
} catch {
/* ignore — write may fail in private-mode / quota-exceeded; the
in-memory state still holds for the session. */
}
}, [collapsed]);
// Auto-close the mobile drawer whenever the viewport crosses back
// over the breakpoint — otherwise `mobileOpen` could stay `true` and
// leave the desktop layout with an invisible Modal backdrop blocking
// clicks on the main content.
useEffect(() => {
if (!isMobile && mobileOpen) setMobileOpen(false);
}, [isMobile, mobileOpen]);
// Collapse/expand is a desktop-only affordance. On mobile the drawer
// is either shown in full (mobileOpen = true) or hidden — we never
// render it at the 56 px collapsed width.
const effectiveCollapsed = isMobile ? false : collapsed;
const drawerWidth = effectiveCollapsed
? leftDrawerWidthCollapsed
: leftDrawerWidthExpanded;
// Shared nav content (brand + grouped items) is rendered inside both
// the permanent and the temporary drawer so the two variants don't
// have to duplicate markup. `effectiveCollapsed` drives whether the
// brand caption + item labels are visible.
const drawerContent = (
<>
<Box
sx={{
// Same triple-height-lock as the topbar in the main column:
// explicit `height` + matching min/max + `flexShrink: 0`
// makes the brand strip a fixed-size brick so the sliding
// sidebar's content never reflows the brand row when the
// nav list grows past the drawer's available height. The
// nav list below this Box already has its own `overflow:
// auto`, so any leftover items scroll inside it instead
// of compressing the brand strip out from underneath.
height: headerHeight,
minHeight: headerHeight,
maxHeight: headerHeight,
flexShrink: 0,
boxSizing: "border-box",
position: "relative",
}}
>
{/* Single brand block: icon + wordmark grouped inside one
absolutely-positioned flex row. The wrapper's left edge
is a literal pixel value, its width is content-based,
and its inner flex layout is static (no values that
change between collapsed / expanded), so the whole
group sits at a fixed position relative to the drawer
paper. When the drawer's `width` animates, the brand
block doesn't move, recompute, or jitter — the
drawer's `overflowX: hidden` simply clips off the
trailing edge of the wordmark as the paper narrows. */}
<Box
sx={{
position: "absolute",
left: `${(leftDrawerWidthCollapsed - 32) / 2}px`,
top: 0,
bottom: 0,
display: "flex",
alignItems: "center",
gap: 1.5,
}}
>
<Box
component="img"
src={brandIcon}
alt="sing-box"
sx={{
width: 32,
height: 32,
flexShrink: 0,
display: "block",
objectFit: "contain",
}}
/>
<Box
sx={{
// Only `opacity` is animated. Opacity can't perturb
// layout, so the wordmark's glyphs never shift.
opacity: effectiveCollapsed ? 0 : 1,
transition: "opacity 0.18s ease",
whiteSpace: "nowrap",
// Stop hovering / focusing the fading text from
// getting in the way when the drawer is collapsed.
pointerEvents: effectiveCollapsed ? "none" : "auto",
}}
>
{/* Wordmark — solid text-primary. We previously layered an
accent-tinted gradient on top via `background-clip: text`,
but the gradient's bottom edge sat right above the
accent-coloured "extended" subtitle, and the two
accent-tinted bands fused into a soft halo that read as
a glow around the lower row. Dropping the gradient
removes that fused band entirely while leaving the
accent visible only where it was always intended — on
"extended" itself.
Both rows are kept as block-level boxes with explicit
heights so the `g` descender stays inside the wordmark's
row and never spills onto the subtitle. */}
<Box
component="span"
sx={(t) => ({
display: "block",
margin: 0,
height: 22,
fontFamily: t.typography.fontFamily,
fontWeight: 700,
fontSize: 17,
letterSpacing: -0.45,
lineHeight: "22px",
color: t.palette.text.primary,
})}
>
Sing-box
</Box>
{/* "EXTENDED" subtitle — a plain accent-coloured wordmark
rather than a chip-style pill. The previous boxed
version looked off-balance because a 1 px border around
~9 px uppercase text reads as too heavy at this small
a font size. A clean text label keeps the brand quiet
and lets the wordmark above carry the visual weight. */}
<Box
component="span"
sx={{
display: "block",
margin: 0,
fontSize: 10,
fontWeight: 700,
letterSpacing: 1.8,
lineHeight: "12px",
marginTop: "2px",
textTransform: "uppercase",
color: "var(--sb-accent)",
transition: "color 0.32s cubic-bezier(0.4,0,0.2,1)",
}}
>
extended
</Box>
</Box>
</Box>
</Box>
<Box sx={{ overflow: "auto", overflowX: "hidden", flexGrow: 1, py: 1 }}>
{navGroups.map((group, idx) => (
<List
key={group.header ?? `group-${idx}`}
dense
disablePadding
// No inter-group spacing when collapsed — without their headers
// the groups should read as a single column of icons.
sx={{ mb: effectiveCollapsed ? 0 : 1 }}
subheader={
group.header ? (
// Pure-CSS show/hide: when the sidebar collapses we just
// squeeze the subheader's height to 0 and drop its
// opacity. No JS animation runs per frame — that's what
// was causing the freeze with three subheader Collapses
// animating simultaneously alongside the drawer-width
// CSS transition.
<ListSubheader
disableSticky
sx={{
bgcolor: "transparent",
color: "text.secondary",
fontSize: 10.5,
letterSpacing: 1.4,
lineHeight: effectiveCollapsed ? "0px" : "30px",
height: effectiveCollapsed ? 0 : "30px",
opacity: effectiveCollapsed ? 0 : 1,
overflow: "hidden",
textTransform: "uppercase",
// Align horizontally with the *visible* icon glyph.
// The icon column starts at 12 px from the drawer's
// left edge (8 px marginInline + 4 px pl on each
// ListItemButton) and is 32 px wide; small icons
// (~20 px) are centred inside that column, so their
// visible left edge sits at 12 + (32 20) / 2 = 18.
pl: "18px",
pr: 1,
transition:
"height 0.18s ease, opacity 0.18s ease, line-height 0.18s ease",
}}
>
{group.header}
</ListSubheader>
) : undefined
}
>
{group.items.map((item) => {
// External links + action items never highlight as
// "selected" — only internal routes do.
const selected = item.to
? item.to === "/"
? location.pathname === "/"
: location.pathname.startsWith(item.to)
: false;
// Three flavours of nav item — pick the right element /
// event wiring per type:
// - `action`: plain ListItemButton (renders a div/
// button) with onClick fired straight at the
// callback.
// - `href`: anchor opening in a new tab.
// - `to`: react-router internal navigation.
const linkProps = item.action
? { onClick: item.action }
: item.href
? {
component: "a" as const,
href: item.href,
target: "_blank",
rel: "noopener noreferrer",
}
: {
component: RouterLink,
to: item.to!,
};
const button = (
<ListItemButton
{...linkProps}
selected={selected}
sx={{
// The theme gives every ListItemButton marginInline: 8,
// so the button's outer width is `drawerWidth 16`.
// With a 56 px collapsed drawer this leaves a 40 px
// button wide; horizontal padding splits the leftover
// 8 px around the 32 px icon column, keeping the icon
// centered both when collapsed and when expanded.
pl: "4px",
pr: "4px",
// No vertical margin: spacing between items is provided
// by the wrapper <Box> below via padding. Padding
// doesn't collapse, so the gap stays constant whether
// a Collapse-wrapped subheader is mounted or not —
// killing the small "freeze" that used to happen when
// the subheader unmounted at the end of the close
// animation.
my: 0,
// Fixed height equal to the collapsed button width so
// the button is a 48 × 48 square when collapsed and
// exactly the same height (just wider) when expanded.
// Replaces the previous `aspect-ratio` toggle, which
// was the source of the bounce on the expand/collapse
// transition.
minHeight: navItemSize,
height: navItemSize,
justifyContent: "flex-start",
}}
>
<ListItemIcon
sx={{
width: 32,
minWidth: 32,
justifyContent: "center",
mr: 1.5,
}}
>
{item.icon}
</ListItemIcon>
<ListItemText
primary={item.label}
primaryTypographyProps={{ fontSize: 15, noWrap: true }}
sx={{
opacity: effectiveCollapsed ? 0 : 1,
maxWidth: effectiveCollapsed ? 0 : 1000,
transition: "opacity 0.18s ease, max-width 0.22s ease",
overflow: "hidden",
}}
/>
</ListItemButton>
);
// Each item lives in a wrapping Box that supplies the
// vertical spacing via *padding*. Padding doesn't collapse,
// so adjacent items always have an 8 px gap (4 + 4) whether
// a Collapse-wrapped subheader sits between groups or is in
// the middle of unmounting.
//
// The Tooltip is rendered unconditionally; toggling
// `collapsed` only flips its title + listener flags. That
// way React keeps the same DOM tree across collapse/expand
// and doesn't unmount + remount every ListItemButton on
// every toggle — which was causing a visible freeze with
// ~10 menu items.
return (
<Box
key={item.to ?? item.href ?? item.label}
sx={{ py: 0.5 }}
// Closing the mobile drawer on nav-item click is what
// makes the temporary drawer feel native — users
// don't have to dismiss it manually after picking a
// destination. External `href` items don't navigate
// in-app, but we still dismiss the drawer so the
// main layout isn't left with an open backdrop.
//
// Action items (e.g. About) are the exception: they
// open a popover anchored to the ListItemButton, so
// dismissing the drawer would yank the anchor off-
// screen. Skip the close for those — the user can
// tap the backdrop or hamburger to dismiss.
onClick={
isMobile && !item.action
? () => setMobileOpen(false)
: undefined
}
>
<Tooltip
title={effectiveCollapsed ? item.label : ""}
placement="right"
disableHoverListener={!effectiveCollapsed}
disableFocusListener={!effectiveCollapsed}
disableTouchListener={!effectiveCollapsed}
>
{button}
</Tooltip>
</Box>
);
})}
</List>
))}
</Box>
</>
);
return (
// On desktop (md+) the root is locked to exactly the viewport
// height so the page-content column can flex-fill it and any
// CrudPage table card inside can claim "viewport topbar"
// height without page-level scroll. Mobile keeps `minHeight:
// 100vh` so long card lists scroll naturally on phones.
//
// `overflow: hidden` on md+ is the safety net that keeps the
// sticky topbar pinned to the viewport top no matter what
// internal flex calculation goes on. Without it, any one-frame
// overflow (a row mounting at its full content height before
// its `flex: 1` parent has settled, an emotion class swap,
// a font-metrics shift) lets the body become scrollable, and
// a `position: sticky` topbar inside a once-scrollable body is
// free to ride up *with* the scroll instead of clamping at
// top: 0 — which is exactly what users were seeing as "topbar
// and PageHeader jump higher right when the rows arrive". The
// sole legitimate scroll on desktop is the table's own
// `TableContainer` (`overflow-y: auto`), so clipping the root
// is harmless: there is nothing the user could ever want to
// scroll *outside* the table card.
<Box
sx={{
display: "flex",
height: { md: "100vh" },
minHeight: "100vh",
overflow: { md: "hidden" },
bgcolor: "background.default",
}}
>
{/* Left bar: on desktop this is a permanent, collapsible sidebar.
On mobile it turns into a temporary drawer that slides in from
the left, dismissed by tapping the backdrop or any nav item. */}
{isMobile ? (
<Drawer
anchor="left"
variant="temporary"
open={mobileOpen}
onClose={() => setMobileOpen(false)}
// Keep the drawer mounted so opening it doesn't cost a tree
// rebuild on every tap — the brand + nav items animate in
// from the left instead of re-rendering.
ModalProps={{ keepMounted: true }}
sx={{
[`& .MuiDrawer-paper`]: {
width: leftDrawerWidthExpanded,
boxSizing: "border-box",
backgroundColor: palette.elevated,
overflowX: "hidden",
},
}}
>
{drawerContent}
</Drawer>
) : (
<Drawer
anchor="left"
variant="permanent"
sx={{
width: drawerWidth,
flexShrink: 0,
transition: drawerTransition,
[`& .MuiDrawer-paper`]: {
width: drawerWidth,
boxSizing: "border-box",
backgroundColor: palette.elevated,
overflowX: "hidden",
transition: drawerTransition,
// Hint the compositor that the drawer's width animates so the
// browser can lift the paper to its own layer and avoid a full
// reflow of every nav item on every animation frame.
willChange: "width",
},
}}
>
{drawerContent}
</Drawer>
)}
{/* Main column: thin top toolbar + page content. `minHeight: 0`
(desktop) lets the column shrink below its natural content
height so a fixed-height root + a flex-fill child can co-exist
without overflowing the viewport — that's what enables the
CrudPage table card to claim the leftover vertical space and
scroll its rows internally. */}
<Box
component="main"
sx={{
flexGrow: 1,
display: "flex",
flexDirection: "column",
minWidth: 0,
minHeight: { md: 0 },
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
// Mirror the responsive horizontal padding used by the
// page-content Box below (`px: { xs: 1.5, md: 2 }`). With
// a flat `px: 2` here the topbar's leading IconButton
// (hamburger on mobile, chevron on desktop) sat 4 px
// further to the right than the PageHeader icon badge
// on phones — the two icons share an "icon column" by
// design and need the same starting offset to actually
// line up vertically.
px: { xs: 1.5, md: 2 },
// Triple-locked height: explicit `height` + matching
// `min-height` / `max-height` make sure no descendant
// mounting at an unexpected natural height (e.g. an
// accent-color picker briefly painting taller during
// its enter transition, an icon button growing a
// halo) and no flex-shrink pressure from the
// surrounding column can ever resize the topbar.
// `flexShrink: 0` belt-and-braces the same guarantee
// against the flex algorithm itself: even if the
// main column's content overflows the viewport for
// one frame, the topbar is treated as a fixed-size
// brick the algorithm is not allowed to compress.
height: headerHeight,
minHeight: headerHeight,
maxHeight: headerHeight,
flexShrink: 0,
boxSizing: "border-box",
position: "sticky",
top: 0,
// Topbar has to paint above any sticky element rendered
// inside the page content — most importantly CrudPage's
// table, whose `stickyHeader` cells sit at z-index 2 and
// whose pinned Actions column tops out at z-index 3. The
// page-content Box wrapping `{children}` is `position:
// relative` without a `z-index`, so it does NOT create a
// stacking context: those table z-indices live in the
// main-column stacking context alongside the topbar.
//
// On mobile / tablet (xssm) the body itself is the
// scrolling container — `Layout`'s `height: { md: "100vh" }`
// only kicks in at md+, where instead the table scrolls
// its rows internally inside a `TableContainer` and the
// sticky table cells stay clipped to that scroll viewport.
// Below md, the table's sticky cells try to glue to the
// same `top: 0` as the topbar, and the higher z-indices
// would let them paint over the topbar (visually + for
// hit-testing). Concretely: the pinned Actions column is
// sticky to the *right* edge, so on a tablet a tap on
// the rightmost topbar buttons (Sign out / colour picker
// / theme toggle) used to fire the table's Edit/Delete
// IconButton sitting underneath instead.
//
// `theme.zIndex.appBar` (1100) is the standard MUI layer
// for top-level chrome — above any in-page sticky
// content but still below the temporary mobile drawer
// (`zIndex.drawer` = 1200), so opening the hamburger
// still slides the drawer over the topbar the way it
// always has.
zIndex: theme.zIndex.appBar,
backgroundColor: "background.default",
}}
>
{/* Leading button: chevron (collapse/expand) on desktop,
hamburger (open mobile drawer) on mobile. Sized at 46 × 46
on desktop so it visually shares a column with the
PageHeader icon badge; mobile uses the same size for a
generous tap target. */}
<IconButton
onClick={
isMobile
? () => setMobileOpen(true)
: () => setCollapsed((c) => !c)
}
size="medium"
aria-label={
isMobile
? "Open navigation"
: collapsed
? "Show menu"
: "Hide menu"
}
sx={{
color: "text.primary",
borderRadius: 2.5,
width: 46,
height: 46,
"&:hover": { backgroundColor: "action.hover" },
}}
>
{isMobile ? (
<MenuIcon />
) : (
// On desktop the same button toggles the sidebar between
// expanded (`ChevronLeftIcon`) and collapsed
// (`ChevronRightIcon`) states. The two glyphs are mirror
// images of each other but render with subtly different
// optical centres — swapping them via a conditional
// render shifted the icon a couple of pixels left/right
// each click. Both icons are now stacked in the same
// 24×24 box with `position: absolute` and only their
// opacity flips, so the visual centre stays put no
// matter which direction the chevron is pointing.
<Box
component="span"
sx={{
position: "relative",
display: "inline-block",
width: 24,
height: 24,
"& > svg": {
position: "absolute",
top: 0,
left: 0,
fontSize: 24,
},
}}
>
<ChevronLeftIcon sx={{ opacity: collapsed ? 0 : 1 }} />
<ChevronRightIcon sx={{ opacity: collapsed ? 1 : 0 }} />
</Box>
)}
</IconButton>
<Box sx={{ flexGrow: 1 }} />
<IconButton
onClick={(e) => toggleMode(e)}
size="medium"
aria-label="Toggle color theme"
sx={{
color: "text.primary",
borderRadius: 2,
width: 40,
height: 40,
"&:hover": { backgroundColor: "action.hover" },
}}
>
{mode === "light" ? (
<DarkModeIcon fontSize="small" />
) : (
<LightModeIcon fontSize="small" />
)}
</IconButton>
<ColorPickerButton />
{/* Sign out is icon-only at every viewport — same treatment
the theme/colour-picker buttons get sitting next to it.
Tooltip carries the label for hover/focus, `aria-label`
keeps the action discoverable for screen readers. */}
<Tooltip title="Sign out">
<IconButton
onClick={() => setSignOutOpen(true)}
size="medium"
aria-label="Sign out"
sx={{
color: "text.primary",
borderRadius: 2,
width: 40,
height: 40,
"&:hover": { backgroundColor: "action.hover" },
}}
>
<LogoutIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
{/* Page-content slot. On desktop the box itself is a flex
column so a child like CrudPage can `flex: 1` and fill the
leftover vertical space below the topbar; on mobile the box
falls back to block layout so dashboards / phone-friendly
card lists scroll the page naturally. `minHeight: 0` is
required for the flex-fill child to be allowed to shrink
below its content height (so the table card scrolls its
rows internally instead of pushing the topbar off-screen). */}
<Box
sx={{
flexGrow: 1,
px: { xs: 1.5, md: 2 },
pb: 3,
position: "relative",
display: { md: "flex" },
flexDirection: { md: "column" },
minHeight: { md: 0 },
}}
>
{children}
</Box>
</Box>
{/* About popover — anchored to the sidebar Info button. Mounted
at the layout root (outside the drawer paper) so the popover
can escape the drawer's `overflowX: hidden` clip without us
having to relax that for the rest of the sidebar. */}
<Popover
open={Boolean(aboutAnchor)}
anchorEl={aboutAnchor}
onClose={closeAbout}
// Anchor on the right edge of the About row, vertically
// centred — so the popover unfolds out to the side, sharing
// the same baseline as the menu item instead of floating up
// above it. Mirrors the placement of the collapsed-sidebar
// tooltip so the popover reads as a slightly heavier version
// of the same affordance.
anchorOrigin={{ vertical: "center", horizontal: "right" }}
transformOrigin={{ vertical: "center", horizontal: "left" }}
slotProps={{
paper: {
sx: {
ml: 1,
p: 2,
minWidth: 240,
border: "1px solid",
borderColor: "divider",
},
},
}}
>
<Typography
variant="overline"
sx={{
display: "block",
color: "text.secondary",
letterSpacing: 1.4,
lineHeight: 1.4,
mb: 1,
}}
>
Sing-box Extended
</Typography>
{/* Two-row version table: server (running sing-box build,
fetched from /version) and web (this SPA bundle's own
version, baked in at build time). Labels in regular text,
values in monospace + word-break so a long build hash
wraps gracefully instead of pushing the popover wider. */}
{[
{ label: "Server", value: versionInfo?.version ?? "loading…" },
{ label: "Web", value: webVersion ?? "loading…" },
].map((row) => (
<Box
key={row.label}
sx={{
display: "flex",
alignItems: "baseline",
gap: 1.5,
"& + &": { mt: 0.5 },
}}
>
<Typography
sx={{
fontSize: 12,
color: "text.secondary",
minWidth: 50,
}}
>
{row.label}
</Typography>
<Typography
sx={{
fontFamily:
'"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace',
fontSize: 13,
color: "text.primary",
wordBreak: "break-all",
}}
>
{row.value}
</Typography>
</Box>
))}
</Popover>
{/* Sign-out confirmation. Mirrors the delete-confirmation dialog
in CrudPage (compact xs Dialog, body2 explanatory copy,
Cancel + danger-coloured primary action) so the two read as
a single style of "are you sure?" prompt across the app. */}
<Dialog
open={signOutOpen}
onClose={() => setSignOutOpen(false)}
maxWidth="xs"
fullWidth
>
<DialogTitle>Sign out?</DialogTitle>
<DialogContent dividers>
<Typography variant="body2" color="text.secondary">
You will be returned to the login screen and will need to
sign in again to continue.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setSignOutOpen(false)}>Cancel</Button>
<Button
variant="contained"
color="error"
startIcon={<LogoutIcon fontSize="small" />}
onClick={() => {
setSignOutOpen(false);
logout();
}}
>
Sign out
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View File

@@ -0,0 +1,364 @@
import { Box } from "@mui/material";
import { useEffect, useMemo, useRef, useState } from "react";
// LoginBackdropMesh renders a stable network mesh: a fixed set of
// nodes connected by edges that are always faintly visible. Every
// few seconds one or two independent "traces" start somewhere in
// the mesh and travel hop-by-hop along their own random walk
// through the graph — each edge in a trace lights up in turn,
// followed by the destination node briefly pulsing as the trace
// "arrives" there.
//
// Each trace looks like a single data flow finding its way across
// multiple hops; concurrent traces look like several independent
// flows sharing the same network.
//
// Implementation notes:
// - Each trace is computed at spawn time as a sequence of edge
// hops (a random walk that avoids revisiting nodes). The
// sequence is rendered as overlay <line>/<circle> elements
// with per-element CSS `animation-delay`, so the browser
// handles the timing — no per-frame JS work after mount.
// - Multiple traces can be in flight at the same time. The
// spawner produces 12 traces per cycle and waits ~1.52.5 s
// between cycles, so the mesh always feels alive but never
// drowns the form behind it.
// - The accent colour comes from `var(--sb-accent)` so the mesh
// instantly retints when the user picks a new accent.
// - `prefers-reduced-motion` keeps the static mesh visible but
// suppresses the trace spawner.
interface Node {
x: number;
y: number;
r: number;
}
interface Edge {
a: number;
b: number;
}
// One hop of a trace: the edge being traversed, the node the
// trace lands on at the end of the hop, and how many milliseconds
// after the trace's start that hop fires.
interface TraceStep {
edgeIdx: number;
fromNodeIdx: number;
toNodeIdx: number;
delay: number;
}
interface Trace {
id: number;
originIdx: number;
steps: TraceStep[];
totalMs: number;
}
const VB_W = 1600;
const VB_H = 1000;
const EDGE_FIRE_MS = 600;
const NODE_FIRE_MS = 700;
// Time between successive hops. The actual edge highlight lasts
// EDGE_FIRE_MS, so stepping a touch faster than that produces
// overlap between adjacent hops — the trace reads as a continuous
// flow rather than a discrete dot-dot-dot.
const STEP_MS = 480;
// Build a stable mesh: a 7×5 grid of nodes (35 total) with per-cell
// jitter, connected by an edge wherever two nodes are nearer than
// ~1 cell diagonal. The grid is intentionally laid out across an
// area larger than the viewBox on each axis (`OVERSHOOT`) so the
// outermost nodes sit just past the visible edge of the page —
// traces can start in view and visibly continue off-screen, which
// gives the impression that the network extends beyond the form
// rather than being neatly framed by it.
const OVERSHOOT_X = 130;
const OVERSHOOT_Y = 90;
function buildGraph(): { nodes: Node[]; edges: Edge[] } {
const cols = 7;
const rows = 5;
const startX = -OVERSHOOT_X;
const startY = -OVERSHOOT_Y;
const spanX = VB_W + OVERSHOOT_X * 2;
const spanY = VB_H + OVERSHOOT_Y * 2;
const cellW = spanX / cols;
const cellH = spanY / rows;
const nodes: Node[] = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
nodes.push({
x: startX + cellW * (c + 0.5) + (Math.random() - 0.5) * cellW * 0.5,
y: startY + cellH * (r + 0.5) + (Math.random() - 0.5) * cellH * 0.5,
r: 2.4 + Math.random() * 1.2,
});
}
}
const threshold = Math.hypot(cellW, cellH) * 1.0;
const edges: Edge[] = [];
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const a = nodes[i];
const b = nodes[j];
if (Math.hypot(a.x - b.x, a.y - b.y) < threshold) {
edges.push({ a: i, b: j });
}
}
}
return { nodes, edges };
}
// Build adjacency: for every node, the list of (edgeIdx,
// neighbourIdx) pairs incident on it. Used by the random-walk
// trace builder to step from node to node along real edges.
function buildAdjacency(
nodes: Node[],
edges: Edge[],
): { edgeIdx: number; nodeIdx: number }[][] {
const adj: { edgeIdx: number; nodeIdx: number }[][] = nodes.map(() => []);
edges.forEach((e, idx) => {
adj[e.a].push({ edgeIdx: idx, nodeIdx: e.b });
adj[e.b].push({ edgeIdx: idx, nodeIdx: e.a });
});
return adj;
}
// Walk a random path through the graph starting at `originIdx`,
// avoiding already-visited nodes so the trace doesn't double back
// on itself. Returns the chain of hops as an array of TraceStep
// records, ready to be rendered with staggered animation delays.
function buildTrace(
id: number,
originIdx: number,
adj: { edgeIdx: number; nodeIdx: number }[][],
desiredLength: number,
): Trace {
const visited = new Set<number>([originIdx]);
const steps: TraceStep[] = [];
let current = originIdx;
for (let i = 0; i < desiredLength; i++) {
// `adj` carries an entry for every node index, populated by
// buildAdjacency, so we can index into it without a fallback.
const neighbours = adj[current];
const candidates = neighbours.filter((n) => !visited.has(n.nodeIdx));
if (candidates.length === 0) break;
const pick = candidates[Math.floor(Math.random() * candidates.length)];
steps.push({
edgeIdx: pick.edgeIdx,
fromNodeIdx: current,
toNodeIdx: pick.nodeIdx,
delay: i * STEP_MS,
});
visited.add(pick.nodeIdx);
current = pick.nodeIdx;
}
// Total time = last hop's start + the longest individual fire
// animation (edge fire vs trailing node fire), plus a small
// safety margin so we never yank the overlay mid-animation.
const lastDelay = steps.length > 0 ? steps[steps.length - 1].delay : 0;
const totalMs = lastDelay + Math.max(EDGE_FIRE_MS, NODE_FIRE_MS) + 80;
return { id, originIdx, steps, totalMs };
}
export function LoginBackdropMesh() {
const reducedMotion = useMemo(
() =>
typeof window !== "undefined" &&
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches,
[],
);
const { nodes, edges } = useMemo(buildGraph, []);
const adjacency = useMemo(() => buildAdjacency(nodes, edges), [nodes, edges]);
const [traces, setTraces] = useState<Trace[]>([]);
const idCounter = useRef(0);
useEffect(() => {
if (reducedMotion) return;
let mounted = true;
const timeouts = new Set<number>();
const schedule = (cb: () => void, delay: number) => {
const t = window.setTimeout(() => {
timeouts.delete(t);
if (mounted) cb();
}, delay);
timeouts.add(t);
};
// Restrict trace origins to nodes that sit inside (or only
// just past) the canonical viewBox, so the user actually
// sees the very first hop fire instead of the trace entering
// mid-flight from off-screen.
const visibleOriginIdxs: number[] = [];
nodes.forEach((n, i) => {
if (n.x >= 80 && n.x <= VB_W - 80 && n.y >= 80 && n.y <= VB_H - 80) {
visibleOriginIdxs.push(i);
}
});
const originPool =
visibleOriginIdxs.length > 0
? visibleOriginIdxs
: nodes.map((_, i) => i);
const spawn = () => {
if (!mounted) return;
// 12 traces fired together so the mesh almost always has at
// least one independent flow visible, and occasionally two
// crossing each other — but never enough to flood the page.
const burst = 1 + Math.floor(Math.random() * 2);
for (let k = 0; k < burst; k++) {
const originIdx =
originPool[Math.floor(Math.random() * originPool.length)];
// Randomise hop count per trace so flows feel different
// each time (some short, some longer).
const length = 5 + Math.floor(Math.random() * 5);
const id = ++idCounter.current;
const trace = buildTrace(id, originIdx, adjacency, length);
if (trace.steps.length === 0) continue;
setTraces((prev) => [...prev, trace]);
schedule(
() => setTraces((prev) => prev.filter((t) => t.id !== trace.id)),
trace.totalMs + 60,
);
}
// Stagger the next burst so the mesh has quiet beats between
// bursts rather than one continuous drumbeat.
schedule(spawn, 1500 + Math.random() * 1200);
};
schedule(spawn, 400);
return () => {
mounted = false;
timeouts.forEach((t) => window.clearTimeout(t));
timeouts.clear();
};
}, [reducedMotion, nodes, adjacency]);
return (
<Box
aria-hidden
sx={{
position: "absolute",
inset: 0,
pointerEvents: "none",
overflow: "hidden",
}}
>
{/* Keyframes live in a plain <style> tag so Emotion does not
rename them — the elements below reference these names from
raw `style={{ animation: ... }}` props rather than through
Emotion's `sx`/`styled`, so the names must stay verbatim. */}
<style>{`
@keyframes lb-mesh-edge {
0% { opacity: 0; }
18% { opacity: 0.95; }
70% { opacity: 0.55; }
100% { opacity: 0; }
}
@keyframes lb-mesh-node {
0% { opacity: 0; r: 2; }
25% { opacity: 1; r: 5; }
100% { opacity: 0; r: 7; }
}
`}</style>
<svg
viewBox={`0 0 ${VB_W} ${VB_H}`}
preserveAspectRatio="xMidYMid slice"
style={{ width: "100%", height: "100%", display: "block" }}
>
{/* Persistent base mesh — visible regardless of any
trace, so the topology is always readable as the
background of the page. */}
<g>
{edges.map((e, i) => {
const a = nodes[e.a];
const b = nodes[e.b];
return (
<line
key={`e-${i}`}
x1={a.x}
y1={a.y}
x2={b.x}
y2={b.y}
stroke="var(--sb-accent)"
strokeWidth={0.7}
opacity={0.14}
/>
);
})}
{nodes.map((n, i) => (
<circle
key={`n-${i}`}
cx={n.x}
cy={n.y}
r={n.r}
fill="var(--sb-accent)"
opacity={0.45}
/>
))}
</g>
{/* Active traces — one overlay group per live trace, with
per-element animation delays staggered hop-by-hop. */}
{traces.map((t) => {
const origin = nodes[t.originIdx];
return (
<g key={t.id}>
{/* Initial ping at the trace's origin node, so the
user can see where each flow starts. */}
<circle
cx={origin.x}
cy={origin.y}
r={2}
fill="var(--sb-accent)"
opacity={0}
style={{
filter: "drop-shadow(0 0 6px var(--sb-accent))",
animation: `lb-mesh-node ${NODE_FIRE_MS}ms ease-out 0ms forwards`,
}}
/>
{t.steps.map((s, i) => {
const a = nodes[s.fromNodeIdx];
const b = nodes[s.toNodeIdx];
return (
<g key={`step-${i}`}>
<line
x1={a.x}
y1={a.y}
x2={b.x}
y2={b.y}
stroke="var(--sb-accent)"
strokeWidth={1.7}
strokeLinecap="round"
opacity={0}
style={{
animation: `lb-mesh-edge ${EDGE_FIRE_MS}ms ease-out ${s.delay}ms forwards`,
}}
/>
{/* Trace "arrives" at the destination node a
touch before the edge fully fades, which
reads as the packet landing one hop down
the line. */}
<circle
cx={b.x}
cy={b.y}
r={2}
fill="var(--sb-accent)"
opacity={0}
style={{
filter: "drop-shadow(0 0 6px var(--sb-accent))",
animation: `lb-mesh-node ${NODE_FIRE_MS}ms ease-out ${
s.delay + Math.round(EDGE_FIRE_MS * 0.55)
}ms forwards`,
}}
/>
</g>
);
})}
</g>
);
})}
</svg>
</Box>
);
}

View File

@@ -0,0 +1,85 @@
import { Box, IconButton, Tooltip } from "@mui/material";
import LightModeIcon from "@mui/icons-material/LightMode";
import DarkModeIcon from "@mui/icons-material/DarkMode";
import { useAccent } from "../theme/AppThemeProvider";
import { ColorPickerButton } from "./ColorPickerButton";
// Small toolbar pinned to the top-right of the login page. It
// surfaces the same appearance controls the main Layout exposes in
// its top bar — theme mode toggle (light ↔ dark) and the accent
// colour picker — so users can configure the UI's look without
// having to sign in first.
//
// Style-wise it uses a frosted-pill treatment that floats above the
// animated backdrop, matching the visual language the rest of the
// login page uses.
export function LoginThemeControls() {
const { mode, toggleMode } = useAccent();
return (
<Box
sx={{
position: "absolute",
top: { xs: 12, sm: 20 },
right: { xs: 12, sm: 20 },
// Above the animated backdrop (auto z-index) and at the same
// level as the auth form card so the pill is always
// clickable no matter how the viewport stacks.
zIndex: 2,
display: "flex",
alignItems: "center",
gap: 0.5,
p: 0.5,
borderRadius: 999,
bgcolor: (t) =>
t.palette.mode === "light"
? "rgba(255,255,255,0.72)"
: "rgba(20,20,20,0.55)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
border: (t) => `1px solid ${t.palette.divider}`,
boxShadow: (t) =>
t.palette.mode === "light"
? "0 6px 18px rgba(15,23,42,0.10)"
: "0 6px 18px rgba(0,0,0,0.45)",
// Normalise every IconButton inside the pill (including the
// one the ColorPickerButton component renders for its palette
// popover trigger) to a compact 34 px round button. Without
// this override the ColorPickerButton's default-medium
// IconButton would sit a few pixels taller than the theme
// toggle, making the pill look lopsided.
"& .MuiIconButton-root": {
width: 34,
height: 34,
borderRadius: 999,
transition:
"color 0.2s cubic-bezier(0.4,0,0.2,1), background-color 0.2s cubic-bezier(0.4,0,0.2,1)",
"&:hover": { color: "var(--sb-accent)" },
},
}}
>
<Tooltip
title={
mode === "light" ? "Switch to dark theme" : "Switch to light theme"
}
placement="top"
>
<IconButton
// Passing the click event along so the theme change's
// View Transitions API circular reveal radiates from the
// button's exact click point, not the screen centre.
onClick={(e) => toggleMode(e)}
aria-label="Toggle color theme"
sx={{ color: "text.secondary" }}
>
{mode === "light" ? (
<DarkModeIcon fontSize="small" />
) : (
<LightModeIcon fontSize="small" />
)}
</IconButton>
</Tooltip>
<ColorPickerButton />
</Box>
);
}

View File

@@ -0,0 +1,158 @@
import { Box, Stack, Typography } from "@mui/material";
import { type ReactNode } from "react";
import { useAccent } from "../theme/AppThemeProvider";
export interface PageHeaderProps {
title: string;
subtitle?: string;
icon?: ReactNode;
actions?: ReactNode;
}
// PageHeader is the visual top of every page: an accent-colored icon badge,
// a title, an optional subtitle, and a right-aligned actions slot. A thin
// hairline below visually separates the header from the page content.
//
// Responsive behaviour: on viewports ≥ sm the classic row layout stays
// (icon + title on the left, actions on the right). On xs viewports the
// actions wrap below the title row so the toolbar never pushes the
// title off-screen on a narrow phone.
//
// HEIGHT is explicitly locked. The header used to size to its content
// (icon 46 + pt + pb), which let any small content shift — a font
// metrics swap when Inter finishes loading, a transient inline `height:
// Xpx` from CrudPage's WAAPI tween, an actions row briefly wrapping —
// reflow the entire flex column it sits in and visibly slide the
// topbar / page title up by a few pixels right as fetched rows arrive.
// Pinning a `height` (matched by `min/maxHeight` for belt-and-braces)
// plus `flexShrink: 0` makes the box a fixed-size brick: nothing inside
// or above can change its outer height regardless of state. Tuned at
// 75 px so the existing `pt: 1` + 46 px icon + `pb: 2.5` + 1 px hairline
// fits the inside of the box without trimming.
export const PAGE_HEADER_HEIGHT = 75;
export function PageHeader({ title, subtitle, icon, actions }: PageHeaderProps) {
const { palette } = useAccent();
return (
<Box
sx={{
mb: 3,
pt: 1,
pb: 2.5,
height: PAGE_HEADER_HEIGHT,
minHeight: PAGE_HEADER_HEIGHT,
maxHeight: PAGE_HEADER_HEIGHT,
flexShrink: 0,
boxSizing: "border-box",
borderBottom: `1px solid ${palette.border}`,
}}
>
<Stack
// Always keep title and actions on the same row, with the
// actions pinned to the right edge — even on phones. The
// previous `xs: column` stack pushed the action toolbar
// below the title, which made the icon-only Filters/Refresh/
// New buttons appear left-aligned under the heading instead
// of in their familiar top-right corner.
direction="row"
alignItems="center"
spacing={2}
>
<Stack
direction="row"
alignItems="center"
spacing={2}
sx={{ flexGrow: 1, minWidth: 0 }}
>
{icon && (
<Box
sx={{
width: 46,
height: 46,
display: "grid",
placeItems: "center",
borderRadius: 2.5,
flexShrink: 0,
// CSS-variable accent so the badge smoothly crossfades to a
// new colour when the user picks a different accent. Using a
// literal `palette.accent` here would cause the className to
// change on every accent update and cancel any transition.
bgcolor:
"color-mix(in srgb, var(--sb-accent) 14%, transparent)",
color: "var(--sb-accent)",
border:
"1px solid color-mix(in srgb, var(--sb-accent) 32%, transparent)",
// Stacked box-shadows so the halo is the sum of several
// overlapping soft drops at decreasing alpha — that
// gives Chromium enough intermediate samples to land
// between to avoid the visible step bands a single
// `0 6px 18px @ 18%` shadow produced on the dark
// background (the alpha channel quantises to ~256
// levels, and a single low-alpha shadow doesn't have
// enough headroom across the blur radius to dither
// cleanly).
boxShadow: [
"inset 0 0 0 1px color-mix(in srgb, var(--sb-accent) 8%, transparent)",
"0 1px 2px color-mix(in srgb, var(--sb-accent) 26%, transparent)",
"0 3px 6px color-mix(in srgb, var(--sb-accent) 18%, transparent)",
"0 6px 14px color-mix(in srgb, var(--sb-accent) 12%, transparent)",
"0 10px 28px color-mix(in srgb, var(--sb-accent) 7%, transparent)",
].join(", "),
transition:
"background-color 0.32s cubic-bezier(0.4,0,0.2,1), color 0.32s cubic-bezier(0.4,0,0.2,1), border-color 0.32s cubic-bezier(0.4,0,0.2,1), box-shadow 0.32s cubic-bezier(0.4,0,0.2,1)",
}}
>
{icon}
</Box>
)}
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<Typography
variant="h5"
sx={{
lineHeight: 1.15,
// Slightly smaller heading on xs so long page titles
// like "Bandwidth limiters" fit the narrower column.
fontSize: { xs: 19, sm: 22 },
fontWeight: 600,
letterSpacing: -0.4,
}}
>
{title}
</Typography>
{subtitle && (
<Typography
variant="body2"
color="text.secondary"
sx={{
mt: 0.5,
fontSize: { xs: 12.5, sm: 13.5 },
// Keep the subtitle readable on narrow screens without
// forcing an overflow ellipsis — wrapping is preferable
// to truncation for description text.
whiteSpace: "normal",
}}
>
{subtitle}
</Typography>
)}
</Box>
</Stack>
{actions && (
<Box
sx={{
flexShrink: 0,
display: "flex",
alignItems: "center",
// Always pinned to the right of the row regardless of
// viewport width.
justifyContent: "flex-end",
flexWrap: "wrap",
rowGap: 1,
}}
>
{actions}
</Box>
)}
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,50 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { LocalizationProvider } from "@mui/x-date-pickers";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
// Bundle Inter locally — Vite hashes the woff/woff2 files into the build
// so the SPA serves them from the same origin and never reaches out to
// fonts.gstatic.com. Each weight import is the only side-effect path that
// registers an @font-face rule for that weight.
import "@fontsource/inter/400.css";
import "@fontsource/inter/500.css";
import "@fontsource/inter/600.css";
import "@fontsource/inter/700.css";
import { App } from "./App";
import { AuthProvider } from "./auth/AuthContext";
import { AppThemeProvider } from "./theme/AppThemeProvider";
import { NotificationsProvider } from "./notifications/NotificationsProvider";
const container = document.getElementById("root");
if (!container) throw new Error("missing #root");
createRoot(container).render(
<StrictMode>
<AppThemeProvider>
{/* NotificationsProvider sits between the theme and the rest of
the app so toast Alerts pick up the active palette, and so
AuthProvider can call `useNotify` from its global 401 handler
(clearing credentials + announcing "Session expired" in one
shot). */}
<NotificationsProvider>
{/* LocalizationProvider feeds the MUI X date/time pickers used by the
datetime-range filter. dayjs is small (~7 KB gzipped) and matches
the format strings the manager API accepts. */}
<LocalizationProvider dateAdapter={AdapterDayjs}>
{/* BrowserRouter so deep routes (e.g. /users) are real URLs
shareable across users / bookmarkable / discoverable by
copy-paste. The Go backend serves the SPA's index.html for
every unknown path (see service/admin_panel/service.go), so
the browser always boots the bundle and react-router takes
over client-side. */}
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</LocalizationProvider>
</NotificationsProvider>
</AppThemeProvider>
</StrictMode>,
);

View File

@@ -0,0 +1,346 @@
import { Alert, Box, Collapse, IconButton, Slide } from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { ApiError, UnauthorizedError } from "../api/client";
// NotificationsProvider drives the small stack of "toast"-style alerts
// that flash in from the top-right whenever the panel completes (or
// fails) a user-visible action: a record was created / deleted, a CRUD
// request hit a network error, the manager-api returned 401, etc. The
// alerts auto-dismiss after a short delay and de-duplicate so a flurry
// of identical errors (e.g. a paginated reload that retries) shows up as
// a single chip.
//
// Two consumption styles are exposed via `useNotify`:
// - `notify({ severity, message })` for the full payload
// - convenience shorthands `notify.success(msg) / .error(msg) / …`
//
// `notifyApiError` is a pre-canned error-toast helper used by every
// CRUD callsite — it picks a useful description based on the error
// type (connection vs. unauthorized vs. generic API) so callers don't
// have to hand-format messages.
export type NotificationSeverity = "success" | "info" | "warning" | "error";
export interface NotificationOptions {
severity: NotificationSeverity;
message: string;
// Auto-dismiss timer (ms). `null` keeps the toast on screen until the
// user dismisses it manually. Defaults to ~4.5 s, long enough to read
// a sentence-length message without parking on screen.
duration?: number | null;
}
interface QueuedNotification extends NotificationOptions {
id: number;
// Drives the enter/exit transitions: items start with `open: true`,
// flip to `false` when the user clicks × or the auto-timer fires, and
// are finally removed from the array when the Collapse exit transition
// completes (via its `onExited` callback). Keeping the entry alive in
// state for the duration of the exit transition is what gives the
// close animation room to play instead of snapping the chip out.
open: boolean;
}
export interface NotificationsApi {
notify: (n: NotificationOptions) => number;
success: (message: string, options?: Partial<NotificationOptions>) => number;
info: (message: string, options?: Partial<NotificationOptions>) => number;
warning: (message: string, options?: Partial<NotificationOptions>) => number;
error: (message: string, options?: Partial<NotificationOptions>) => number;
dismiss: (id: number) => void;
}
const Ctx = createContext<NotificationsApi | undefined>(undefined);
const DEFAULT_DURATION = 4500;
// Cap on the number of toasts shown at once. Anything over this limit
// drops the oldest entry — a misbehaving callsite that fires hundreds
// of notifications in a row should not be able to fill the viewport.
const MAX_VISIBLE = 5;
// Transition timings (ms). Both Slide (horizontal motion) and Collapse
// (height collapse) share the same timeouts so the chip's slide-out and
// the stack's reflow finish together — a mismatch here would leave a
// phantom gap or a half-collapsed shadow visible for a frame after the
// chip itself has gone.
//
// 250 ms matches MUI's "standard" duration: the toast slides in /
// out at a familiar pace, neither lingering on screen nor flashing
// past too fast to register.
const TRANSITION_MS = 250;
const TRANSITION_TIMEOUT = { enter: TRANSITION_MS, exit: TRANSITION_MS } as const;
// Easing is intentionally left to MUI's defaults — Slide and Collapse
// fall back to `theme.transitions.easing.easeOut` on enter and
// `easeIn` on exit, which gives a clean "fade out to the right" feel
// without any overshoot or bounce.
export function NotificationsProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<QueuedNotification[]>([]);
const idRef = useRef(0);
// Track the active auto-dismiss timers so unmounting the provider (or a
// very stale toast) cleans them up rather than firing into a torn-down
// tree.
const timers = useRef<Map<number, number>>(new Map());
// dismiss flips the toast's `open` flag to false, kicking off its exit
// transition (Slide back out to the right + Collapse height to 0). The
// entry stays in `items` until `finalize` removes it once the Collapse
// `onExited` callback fires — that is what gives the close animation
// time to play instead of snapping the chip out of the DOM. Calling
// `dismiss` more than once for the same id (auto-timer + user click
// race) is a safe no-op.
const dismiss = useCallback((id: number) => {
setItems((prev) =>
prev.map((n) => (n.id === id && n.open ? { ...n, open: false } : n)),
);
const handle = timers.current.get(id);
if (handle !== undefined) {
window.clearTimeout(handle);
timers.current.delete(id);
}
}, []);
// finalize is invoked by the Collapse `onExited` callback once the exit
// transition has fully played out, removing the entry from state and
// letting React unmount the underlying DOM nodes.
const finalize = useCallback((id: number) => {
setItems((prev) => prev.filter((n) => n.id !== id));
}, []);
const notify = useCallback(
(n: NotificationOptions): number => {
const id = ++idRef.current;
setItems((prev) => {
// De-duplicate: if the most recent *visible* toast carries the
// same severity and message, swallow this one. A failed reload
// that retries shouldn't stack identical chips on top of each
// other. Items already in their exit transition (`open: false`)
// are skipped so a quickly-cleared duplicate doesn't suppress a
// legitimate repeat.
for (let i = prev.length - 1; i >= 0; i--) {
const p = prev[i];
if (!p.open) continue;
if (p.severity === n.severity && p.message === n.message) {
return prev;
}
break;
}
const next: QueuedNotification[] = [...prev, { ...n, id, open: true }];
// Cap the number of *visible* (still-open) toasts. Any overflow
// gets gracefully dismissed — flipped to `open: false` — so the
// overflowing oldest entry plays its close animation instead of
// popping out of existence. `finalize` will remove it from the
// array when its transition finishes.
let visible = 0;
for (const p of next) if (p.open) visible++;
let toClose = visible - MAX_VISIBLE;
for (let i = 0; i < next.length && toClose > 0; i++) {
if (next[i].open) {
next[i] = { ...next[i], open: false };
const stale = timers.current.get(next[i].id);
if (stale !== undefined) {
window.clearTimeout(stale);
timers.current.delete(next[i].id);
}
toClose--;
}
}
return next;
});
const duration = n.duration === undefined ? DEFAULT_DURATION : n.duration;
if (duration !== null && duration > 0) {
const handle = window.setTimeout(() => dismiss(id), duration);
timers.current.set(id, handle);
}
return id;
},
[dismiss],
);
// Cleanup pending timers on unmount.
useEffect(() => {
const map = timers.current;
return () => {
for (const handle of map.values()) window.clearTimeout(handle);
map.clear();
};
}, []);
const value = useMemo<NotificationsApi>(
() => ({
notify,
success: (message, options) => notify({ severity: "success", message, ...options }),
info: (message, options) => notify({ severity: "info", message, ...options }),
warning: (message, options) => notify({ severity: "warning", message, ...options }),
error: (message, options) => notify({ severity: "error", message, ...options }),
dismiss,
}),
[notify, dismiss],
);
return (
<Ctx.Provider value={value}>
{children}
{/* Stack of toasts pinned to the top-right of the viewport. A
high `zIndex` keeps them above MUI Dialog (1300) and Drawer
(1200) so a notification triggered from inside a modal
(e.g. CRUD form submission error) is still visible without
having to close the dialog first.
`pointerEvents: "none"` on the container lets clicks pass
through to the page underneath the empty space between
alerts; each Alert re-enables pointer events for itself so
its own close button still works. */}
<Box
sx={{
position: "fixed",
top: { xs: 4, sm: 8 },
right: { xs: 12, sm: 16 },
zIndex: 2000,
display: "flex",
flexDirection: "column",
// No `gap` here on purpose — the inter-toast spacing lives
// inside each Collapse below (as a top padding). That way
// the spacing height animates *together* with the toast
// instead of leaving a stranded gap during the close
// animation, and the stack reflows smoothly when an entry
// disappears.
maxWidth: "min(92vw, 420px)",
pointerEvents: "none",
}}
aria-live="polite"
aria-atomic="false"
>
{items.map((n) => (
// Collapse owns the *vertical* exit motion: when `open` flips
// to false it shrinks the chip's height to 0, pulling the
// toasts below it up smoothly. Its `onExited` callback then
// hands off to `finalize`, which removes the entry from
// state once the animation has fully played out.
<Collapse
key={n.id}
in={n.open}
appear
timeout={TRANSITION_TIMEOUT}
onExited={() => finalize(n.id)}
>
{/* Padding-top on the inner wrapper — not a parent flex gap
— so the spacing collapses together with the toast on
exit. The first toast inherits the same 8 px top
padding; combined with the container's `top: 8` it
lines up at 16 px from the viewport edge, which reads
cleanly without an explicit "no padding for index 0"
special case (which would jump-snap when the topmost
toast is dismissed). */}
<Box sx={{ pt: 1 }}>
{/* Slide owns the *horizontal* motion: enters by sliding
left from off-screen on the right, exits by sliding
back out to the right. Sharing the timeout shape with
Collapse keeps the two transitions in lockstep so the
chip lands / leaves cleanly. */}
<Slide
in={n.open}
direction="left"
appear
timeout={TRANSITION_TIMEOUT}
>
<Alert
severity={n.severity}
variant="filled"
onClose={() => dismiss(n.id)}
action={
<IconButton
aria-label="Dismiss notification"
size="small"
color="inherit"
onClick={() => dismiss(n.id)}
>
<CloseIcon fontSize="inherit" />
</IconButton>
}
sx={(theme) => {
// Force the toast contents to follow the active
// theme rather than MUI's default "always white on
// a coloured background" rule for the filled Alert
// variant: white text in dark mode, black text in
// light mode. Applied to the message body, the
// severity icon, and the dismiss button so all
// three follow the same contrast rule and read as
// a single tonal pair.
const fg = theme.palette.mode === "dark" ? "#ffffff" : "#000000";
return {
pointerEvents: "auto",
boxShadow: 6,
alignItems: "center",
color: fg,
"& .MuiAlert-icon": { color: fg },
"& .MuiAlert-action": { color: fg },
"& .MuiAlert-action .MuiIconButton-root": { color: fg },
// Long error bodies (e.g. an HTTP response excerpt)
// should wrap rather than overflow the chip and steal
// the close button.
"& .MuiAlert-message": {
color: fg,
wordBreak: "break-word",
overflowWrap: "anywhere",
},
};
}}
>
{n.message}
</Alert>
</Slide>
</Box>
</Collapse>
))}
</Box>
</Ctx.Provider>
);
}
export function useNotify(): NotificationsApi {
const ctx = useContext(Ctx);
if (!ctx) throw new Error("useNotify must be used within NotificationsProvider");
return ctx;
}
// notifyApiError emits a "toast"-style error notification for an exception
// thrown by the API client, picking a useful description based on the
// error type:
//
// - `UnauthorizedError` (HTTP 401) — silently skipped: the global
// handler in AuthContext already surfaces a "session expired"
// toast and redirects to the login screen, so emitting another
// here would double up.
// - `ApiError` with `status === 0` — connection/network error
// (fetch itself failed before a response arrived). Surfaced as a
// "Connection error" toast so the user understands the panel
// didn't reach the manager-api at all.
// - any other `ApiError` — formatted with the HTTP status and
// server-provided body excerpt.
// - anything else — message of the underlying Error / String fallback.
export function notifyApiError(
notify: NotificationsApi,
prefix: string,
e: unknown,
): void {
if (e instanceof UnauthorizedError) return;
if (e instanceof ApiError && e.status === 0) {
notify.error(`${prefix}: connection error — ${e.body || e.message}`);
return;
}
if (e instanceof ApiError) {
notify.error(`${prefix} (HTTP ${e.status}): ${e.body || e.message}`);
return;
}
notify.error(`${prefix}: ${e instanceof Error ? e.message : String(e)}`);
}

View File

@@ -0,0 +1,247 @@
import SpeedIcon from "@mui/icons-material/Speed";
import { useMemo } from "react";
import {
CrudPage,
renderOptionChain,
renderOptionLabel,
type CrudConfig,
} from "../components/CrudPage";
import { useApi } from "../auth/AuthContext";
import type {
BandwidthLimiter,
BandwidthLimiterCreate,
BandwidthLimiterUpdate,
BandwidthMode,
BandwidthStrategy,
ConnectionType,
} from "../api/types";
import {
parseSquadIds,
pickSquadIds,
renderSquadIds,
squadIdsField,
useSquadCatalog,
} from "./squadField";
// Display labels mirror service/admin_panel/tables/bandwidth_limiter.go so
// the table shows "Global" instead of "global", "Download" instead of
// "download", etc.
const STRATEGIES: { value: BandwidthStrategy; label: string }[] = [
{ value: "global", label: "Global" },
{ value: "connection", label: "Connection" },
{ value: "bypass", label: "Bypass" },
];
const MODES: { value: BandwidthMode; label: string }[] = [
{ value: "download", label: "Download" },
{ value: "upload", label: "Upload" },
{ value: "bidirectional", label: "Bidirectional" },
];
const CONN_TYPES: { value: ConnectionType; label: string }[] = [
{ value: "hwid", label: "HWID" },
{ value: "mux", label: "Mux" },
{ value: "source_ip", label: "Source IP" },
{ value: "default", label: "Default" },
];
const FLOW_KEYS: { value: string; label: string }[] = [
{ value: "user", label: "User" },
{ value: "destination", label: "Destination" },
{ value: "hwid", label: "HWID" },
{ value: "mux", label: "Mux" },
{ value: "source_ip", label: "Source IP" },
];
export function BandwidthLimitersPage() {
const api = useApi();
const squads = useSquadCatalog(api);
// Memoise the CRUD config so CrudPage's `reload` callback stays stable
// across re-renders. Recomputed when the API client flips or a new
// squad name is merged into the catalog through observeRows.
const config = useMemo<
CrudConfig<BandwidthLimiter, BandwidthLimiterCreate, BandwidthLimiterUpdate>
>(() => ({
title: "Bandwidth limiters",
icon: <SpeedIcon />,
idKey: "id",
onRowsChange: (rows) => squads.observeRows(rows, pickSquadIds),
columns: [
{ key: "id", label: "ID" },
// squad_ids is an array column — `sortable: false` matches the
// legacy admin where these columns lacked FieldSortable(). Render
// squad names instead of raw IDs.
{
key: "squad_ids",
label: "Squads",
sortable: false,
render: renderSquadIds<BandwidthLimiter>(squads.names),
},
{ key: "username", label: "Username" },
{ key: "outbound", label: "Outbound" },
{ key: "strategy", label: "Strategy", render: renderOptionLabel<BandwidthLimiter>("strategy", STRATEGIES) },
{
key: "connection_type",
label: "Connection type",
render: renderOptionLabel<BandwidthLimiter>("connection_type", CONN_TYPES),
},
{ key: "mode", label: "Mode", render: renderOptionLabel<BandwidthLimiter>("mode", MODES) },
{
key: "flow_keys",
label: "Flow keys",
sortable: false,
render: renderOptionChain<BandwidthLimiter>("flow_keys", FLOW_KEYS),
},
{
key: "speed",
label: "Speed",
// bypass disables the limiter's speed cap server-side
// (excluded_if=Strategy bypass on the DTO), so the row's `speed`
// arrives as an empty string. Render an unambiguous ∞ instead so
// the column reads as "no cap" at a glance instead of looking
// like missing data.
render: (row) => (row.strategy === "bypass" ? "∞" : row.speed),
},
{ key: "created_at", label: "Created at" },
{ key: "updated_at", label: "Updated at" },
],
filters: [
{ name: "username", label: "Username", type: "text" },
{ name: "outbound", label: "Outbound", type: "text" },
{ name: "strategy", label: "Strategy", type: "select", options: STRATEGIES },
{ name: "connection_type", label: "Connection type", type: "select", options: CONN_TYPES },
{ name: "mode", label: "Mode", type: "select", options: MODES },
{ name: "created_at", label: "Created at", type: "datetime-range" },
{ name: "updated_at", label: "Updated at", type: "datetime-range" },
],
fields: [
// Mirror service/admin_panel/tables/bandwidth_limiter.go: squads,
// username and outbound are locked once the limiter exists.
squadIdsField(squads.loadOptions),
{ name: "username", label: "Username", type: "text", only: "create" },
{ name: "outbound", label: "Outbound", type: "text", required: true, only: "create" },
{
name: "strategy",
label: "Strategy",
type: "select",
required: true,
options: STRATEGIES,
// bypass disables every post-Strategy field server-side
// (excluded_if=Strategy bypass on the DTO). connection_type is
// additionally only meaningful for the "connection" strategy. Wipe
// every dependent field on change so a stale value can't be
// smuggled out of a hidden field.
clears: ["connection_type", "mode", "flow_keys", "speed"],
},
{
name: "connection_type",
label: "Connection type",
type: "select",
options: CONN_TYPES,
visibleWhen: (form) => form.strategy === "connection",
},
{
name: "mode",
label: "Mode",
type: "select",
required: true,
options: MODES,
visibleWhen: (form) => form.strategy !== "bypass",
},
{
name: "flow_keys",
label: "Flow keys",
type: "multiselect",
options: FLOW_KEYS,
// Render the picked values as an ordered pipeline
// ("User → Destination → IP") so the form's preview matches the
// table column and reads as a queue rather than a flat set.
displayAsChain: true,
visibleWhen: (form) => form.strategy !== "bypass",
},
{
name: "speed",
label: "Speed",
type: "text",
required: true,
helperText: "e.g. 2MB, 100KB, 1GB or 10Mbps",
visibleWhen: (form) => form.strategy !== "bypass",
},
],
list: (q) => api.bandwidthLimiters.list(q),
count: (q) => api.bandwidthLimiters.count(q),
create: (b) => api.bandwidthLimiters.create(b),
update: (id, b) => api.bandwidthLimiters.update(Number(id), b),
remove: (id) => api.bandwidthLimiters.remove(Number(id)),
fromEntity: (e) => ({
squad_ids: e.squad_ids,
username: e.username ?? "",
outbound: e.outbound,
strategy: e.strategy,
connection_type: e.connection_type ?? "",
mode: e.mode,
flow_keys: e.flow_keys ?? [],
speed: e.speed,
}),
toCreate: (f) => bodyFor(f) as unknown as BandwidthLimiterCreate,
toUpdate: (f, original) => updateBodyFor(f, original) as unknown as BandwidthLimiterUpdate,
}), [api, squads]);
return (
<CrudPage<BandwidthLimiter, BandwidthLimiterCreate, BandwidthLimiterUpdate> config={config} />
);
}
// strategyDependentFields returns connection_type / mode / flow_keys /
// speed only when the picked strategy is *not* "bypass". The manager-api
// DTO carries `excluded_if=Strategy bypass` on each of these, and the
// SQL repository unconditionally parses `Speed` via
// byteformats.NetworkBytesCompat (see service/manager/repository/sqlite/
// repository.go) — so sending `mode: ""` / `speed: ""` with a bypass
// payload makes the server reject the request with 400 "invalid
// format". Dropping the keys entirely (returned `undefined` is omitted
// by `JSON.stringify`) keeps the body shape exactly what the DTO
// expects for each strategy branch.
function strategyDependentFields(
f: Record<string, unknown>,
strategy: BandwidthStrategy,
) {
if (strategy === "bypass") {
return {
connection_type: undefined,
mode: undefined,
flow_keys: undefined,
speed: undefined,
};
}
return {
connection_type: f.connection_type
? (String(f.connection_type) as ConnectionType)
: undefined,
mode: String(f.mode ?? "") as BandwidthMode,
flow_keys:
Array.isArray(f.flow_keys) && (f.flow_keys as string[]).length > 0
? (f.flow_keys as string[])
: undefined,
speed: String(f.speed ?? "").trim(),
};
}
function bodyFor(f: Record<string, unknown>) {
const strategy = String(f.strategy ?? "") as BandwidthStrategy;
return {
squad_ids: parseSquadIds(f.squad_ids),
username: f.username ? String(f.username) : undefined,
outbound: String(f.outbound ?? "").trim(),
strategy,
...strategyDependentFields(f, strategy),
};
}
// updateBodyFor reuses the original entity for the create-only fields
// (username, outbound) that the API still requires on update.
function updateBodyFor(f: Record<string, unknown>, original: BandwidthLimiter) {
const strategy = String(f.strategy ?? "") as BandwidthStrategy;
return {
username: original.username || undefined,
outbound: original.outbound,
strategy,
...strategyDependentFields(f, strategy),
};
}

View File

@@ -0,0 +1,191 @@
import LinkIcon from "@mui/icons-material/Link";
import { useMemo } from "react";
import {
CrudPage,
renderOptionLabel,
type CrudConfig,
} from "../components/CrudPage";
import { useApi } from "../auth/AuthContext";
import type {
ConnectionLimiter,
ConnectionLimiterCreate,
ConnectionLimiterUpdate,
ConnectionStrategy,
ConnectionType,
LockType,
} from "../api/types";
import {
parseSquadIds,
pickSquadIds,
renderSquadIds,
squadIdsField,
useSquadCatalog,
} from "./squadField";
// Display labels mirror service/admin_panel/tables/connection_limiter.go.
const STRATEGIES: { value: ConnectionStrategy; label: string }[] = [
{ value: "connection", label: "Connection" },
{ value: "bypass", label: "Bypass" },
];
const CONN_TYPES: { value: ConnectionType; label: string }[] = [
{ value: "hwid", label: "HWID" },
{ value: "mux", label: "Mux" },
{ value: "source_ip", label: "Source IP" },
{ value: "default", label: "Default" },
];
const LOCK_TYPES: { value: LockType; label: string }[] = [
{ value: "manager", label: "Manager" },
{ value: "default", label: "Default" },
];
export function ConnectionLimitersPage() {
const api = useApi();
const squads = useSquadCatalog(api);
// Memoise the CRUD config so CrudPage's `reload` callback stays stable
// across re-renders. Recomputed when the API client flips or a new
// squad name is merged into the catalog through observeRows.
const config = useMemo<
CrudConfig<ConnectionLimiter, ConnectionLimiterCreate, ConnectionLimiterUpdate>
>(() => ({
title: "Connection limiters",
icon: <LinkIcon />,
idKey: "id",
onRowsChange: (rows) => squads.observeRows(rows, pickSquadIds),
columns: [
{ key: "id", label: "ID" },
{
key: "squad_ids",
label: "Squads",
sortable: false,
render: renderSquadIds<ConnectionLimiter>(squads.names),
},
{ key: "username", label: "Username" },
{ key: "outbound", label: "Outbound" },
{ key: "strategy", label: "Strategy", render: renderOptionLabel<ConnectionLimiter>("strategy", STRATEGIES) },
{
key: "connection_type",
label: "Connection type",
render: renderOptionLabel<ConnectionLimiter>("connection_type", CONN_TYPES),
},
{ key: "lock_type", label: "Lock type", render: renderOptionLabel<ConnectionLimiter>("lock_type", LOCK_TYPES) },
{
key: "count",
label: "Count",
// bypass disables the limiter server-side
// (excluded_if=Strategy bypass on the DTO), so `count` arrives
// as 0. Render an unambiguous ∞ instead so the column reads as
// "no cap" at a glance instead of looking like a real zero.
render: (row) => (row.strategy === "bypass" ? "∞" : row.count),
},
{ key: "created_at", label: "Created at" },
{ key: "updated_at", label: "Updated at" },
],
filters: [
{ name: "username", label: "Username", type: "text" },
{ name: "outbound", label: "Outbound", type: "text" },
{ name: "strategy", label: "Strategy", type: "select", options: STRATEGIES },
{ name: "connection_type", label: "Connection type", type: "select", options: CONN_TYPES },
{ name: "lock_type", label: "Lock type", type: "select", options: LOCK_TYPES },
{ name: "created_at", label: "Created at", type: "datetime-range" },
{ name: "updated_at", label: "Updated at", type: "datetime-range" },
],
fields: [
// Mirror service/admin_panel/tables/connection_limiter.go: squads,
// username and outbound are locked once the limiter exists.
squadIdsField(squads.loadOptions),
{ name: "username", label: "Username", type: "text", only: "create" },
{ name: "outbound", label: "Outbound", type: "text", required: true, only: "create" },
{
name: "strategy",
label: "Strategy",
type: "select",
required: true,
options: STRATEGIES,
// bypass disables every post-Strategy field server-side
// (excluded_if=Strategy bypass on the DTO). Wipe their values when
// switching so a stale entry can't be smuggled out of a hidden field.
clears: ["connection_type", "lock_type", "count"],
},
{
name: "connection_type",
label: "Connection type",
type: "select",
required: true,
options: CONN_TYPES,
visibleWhen: (form) => form.strategy !== "bypass",
},
{
name: "lock_type",
label: "Lock type",
type: "select",
required: true,
options: LOCK_TYPES,
visibleWhen: (form) => form.strategy !== "bypass",
},
{
name: "count",
label: "Count",
type: "number",
required: true,
visibleWhen: (form) => form.strategy !== "bypass",
},
],
list: (q) => api.connectionLimiters.list(q),
count: (q) => api.connectionLimiters.count(q),
create: (b) => api.connectionLimiters.create(b),
update: (id, b) => api.connectionLimiters.update(Number(id), b),
remove: (id) => api.connectionLimiters.remove(Number(id)),
fromEntity: (e) => ({
username: e.username ?? "",
outbound: e.outbound,
strategy: e.strategy,
connection_type: e.connection_type ?? "",
lock_type: e.lock_type,
count: e.count,
}),
// `connection_type` / `lock_type` / `count` carry
// `excluded_if=Strategy bypass` on the manager-api DTO; the
// validator rejects non-zero values when strategy=bypass and any
// empty/zero values still get persisted by the SQL repo, so drop
// the keys entirely on bypass (`undefined` → omitted by
// `JSON.stringify`).
toCreate: (f) => {
const strategy = String(f.strategy ?? "") as ConnectionStrategy;
const bypass = strategy === "bypass";
return {
squad_ids: parseSquadIds(f.squad_ids),
username: f.username ? String(f.username) : undefined,
outbound: String(f.outbound ?? "").trim(),
strategy,
connection_type: bypass
? undefined
: f.connection_type
? (String(f.connection_type) as ConnectionType)
: undefined,
lock_type: bypass ? undefined : (String(f.lock_type ?? "") as LockType),
count: bypass ? undefined : Number(f.count ?? 0),
};
},
// username and outbound are locked on update, so reuse the original
// entity's values to satisfy the API's required-field validation.
toUpdate: (f, original) => {
const strategy = String(f.strategy ?? "") as ConnectionStrategy;
const bypass = strategy === "bypass";
return {
username: original.username || undefined,
outbound: original.outbound,
strategy,
connection_type: bypass
? undefined
: f.connection_type
? (String(f.connection_type) as ConnectionType)
: undefined,
lock_type: bypass ? undefined : (String(f.lock_type ?? "") as LockType),
count: bypass ? undefined : Number(f.count ?? 0),
};
},
}), [api, squads]);
return (
<CrudPage<ConnectionLimiter, ConnectionLimiterCreate, ConnectionLimiterUpdate> config={config} />
);
}

View File

@@ -0,0 +1,276 @@
import {
Box,
Card,
CardActionArea,
CardContent,
CircularProgress,
Stack,
Typography,
} from "@mui/material";
import Grid from "@mui/material/Grid2";
import {
useEffect,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { Link as RouterLink } from "react-router-dom";
import DashboardIcon from "@mui/icons-material/Dashboard";
import GroupsIcon from "@mui/icons-material/Groups";
import StorageIcon from "@mui/icons-material/Storage";
import PeopleIcon from "@mui/icons-material/People";
import SpeedIcon from "@mui/icons-material/Speed";
import SwapHorizIcon from "@mui/icons-material/SwapHoriz";
import LinkIcon from "@mui/icons-material/Link";
import FilterAltIcon from "@mui/icons-material/FilterAlt";
import { useApi } from "../auth/AuthContext";
import { notifyApiError, useNotify } from "../notifications/NotificationsProvider";
import { PageHeader } from "../components/PageHeader";
interface Tile {
label: string;
to: string;
icon: ReactNode;
value: number | null;
}
export function DashboardPage() {
const api = useApi();
const notify = useNotify();
// Every tile renders with `var(--sb-accent)` (see DashboardTile below),
// so the order is the only thing that matters here — it mirrors the
// navigation in <Layout/> and the table registration order in
// service/admin_panel/service.go.
const [tiles, setTiles] = useState<Tile[]>([
{ label: "Squads", to: "/squads", icon: <GroupsIcon />, value: null },
{ label: "Nodes", to: "/nodes", icon: <StorageIcon />, value: null },
{ label: "Users", to: "/users", icon: <PeopleIcon />, value: null },
{
label: "Connection limiters",
to: "/connection-limiters",
icon: <LinkIcon />,
value: null,
},
{
label: "Bandwidth limiters",
to: "/bandwidth-limiters",
icon: <SpeedIcon />,
value: null,
},
{
label: "Traffic limiters",
to: "/traffic-limiters",
icon: <SwapHorizIcon />,
value: null,
},
{
label: "Rate limiters",
to: "/rate-limiters",
icon: <FilterAltIcon />,
value: null,
},
]);
useEffect(() => {
let cancelled = false;
(async () => {
try {
// Counts are fetched in the same positional order as the `tiles`
// array above: Squads, Nodes, Users, Connection, Bandwidth, Traffic,
// Rate.
const values = await Promise.all([
api.squads.count(),
api.nodes.count(),
api.users.count(),
api.connectionLimiters.count(),
api.bandwidthLimiters.count(),
api.trafficLimiters.count(),
api.rateLimiters.count(),
]);
if (cancelled) return;
setTiles((prev) => prev.map((tile, i) => ({ ...tile, value: values[i] ?? 0 })));
} catch (e) {
// Surface counter-load failures via the global toast stack
// instead of an inline Alert above the tiles — the tiles
// themselves keep their loading spinners (their `value` stays
// null) so the page reads as "not loaded yet" without an
// extra red bar competing with the rest of the dashboard
// chrome.
if (!cancelled) notifyApiError(notify, "Failed to load dashboard counters", e);
}
})();
return () => {
cancelled = true;
};
}, [api, notify]);
return (
<Box>
<PageHeader
icon={<DashboardIcon />}
title="Dashboard"
/>
<Grid container spacing={2}>
{tiles.map((t, i) => (
<Grid key={t.label} size={{ xs: 12, sm: 6, md: 4, lg: 3 }}>
<DashboardTile tile={t} index={i} />
</Grid>
))}
</Grid>
</Box>
);
}
// DashboardTile renders a single counter card. `index` drives the
// per-tile delay on the entrance animation so the cards fade in as a
// staggered cascade rather than all at once.
//
// The entrance animation is driven by the Web Animations API (`element.animate`)
// rather than a CSS `@keyframes` rule for one specific reason: on a cold
// browser reload users reported the dashboard "drawing twice". Emotion
// re-serialises the `sx` block on every re-render, and although the
// resulting className is content-deterministic, certain edge cases —
// CSS bundle attaching after the JS commit, font swap forcing a class
// re-application, or `tile.value` going `null → number` causing the
// animation property to be re-applied — can re-trigger a CSS keyframe
// animation on an already-mounted element. WAAPI plus a `useRef` flag
// guarantees the entrance plays exactly once per fiber lifetime, no
// matter how many times React commits or how emotion shuffles classes
// underneath.
function DashboardTile({ tile, index }: { tile: Tile; index: number }) {
// Every tile uses the global theme accent (`var(--sb-accent)`) so all
// dashboard cards re-tint together when the user picks a new theme
// colour. Translucent variants are produced via `color-mix` so they
// automatically follow the variable too.
const ACCENT = "var(--sb-accent)";
const accentMix = (pct: number) =>
`color-mix(in srgb, ${ACCENT} ${pct}%, transparent)`;
const cardRef = useRef<HTMLDivElement | null>(null);
const animatedRef = useRef(false);
// Run as a layout effect so the keyframe is registered before the
// browser paints the first frame; without `useLayoutEffect` the card
// would briefly flash at full opacity before the WAAPI animation
// captured the `from` state on the first paint after mount.
useLayoutEffect(() => {
const el = cardRef.current;
if (!el || animatedRef.current) return;
if (typeof el.animate !== "function") return;
animatedRef.current = true;
el.animate(
[
{ opacity: 0, transform: "translateY(12px)" },
{ opacity: 1, transform: "translateY(0)" },
],
{
duration: 480,
delay: index * 70,
easing: "cubic-bezier(0.22, 0.61, 0.36, 1)",
fill: "backwards",
},
);
}, [index]);
return (
<Card
ref={cardRef}
sx={{
height: "100%",
position: "relative",
overflow: "hidden",
background: `linear-gradient(135deg, ${accentMix(4)} 0%, transparent 70%)`,
transition:
"transform 0.18s cubic-bezier(0.22, 0.61, 0.36, 1), border-color 0.18s, box-shadow 0.24s, background 0.32s",
"&:hover": {
borderColor: accentMix(55),
transform: "translateY(-2px)",
boxShadow: `0 12px 28px ${accentMix(18)}, 0 1px 0 ${accentMix(20)}`,
},
"&::before": {
content: '""',
position: "absolute",
top: 0,
left: 0,
right: 0,
height: 2,
background: `linear-gradient(90deg, ${ACCENT} 0%, ${accentMix(40)} 100%)`,
opacity: 0.85,
},
}}
>
<CardActionArea component={RouterLink} to={tile.to} sx={{ height: "100%" }}>
<CardContent sx={{ p: 2.5 }}>
<Stack direction="row" alignItems="center" spacing={2}>
<Box
sx={{
width: 48,
height: 48,
borderRadius: 2.5,
display: "grid",
placeItems: "center",
bgcolor: accentMix(16),
color: ACCENT,
border: `1px solid ${accentMix(30)}`,
boxShadow: `inset 0 0 0 1px ${accentMix(6)}`,
transition:
"background-color 0.32s cubic-bezier(0.4,0,0.2,1), color 0.32s cubic-bezier(0.4,0,0.2,1), border-color 0.32s cubic-bezier(0.4,0,0.2,1)",
}}
>
{tile.icon}
</Box>
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<Typography
variant="caption"
color="text.secondary"
sx={{
textTransform: "uppercase",
letterSpacing: 1.2,
fontWeight: 600,
fontSize: 10.5,
display: "block",
}}
>
{tile.label}
</Typography>
{/* Reserve a fixed-height row for the value so swapping
the loading spinner for the final number doesn't move
the card by ~10 px when the count fetch resolves. The
height matches the line-box of the 32 px number text
(font-size × line-height = 32 × 1 = 32). The spinner
is centred inside this row instead of forcing its own
smaller intrinsic height onto the parent column,
which is what made the dashboard read as "animating
twice" on a cold browser reload — the tile slid in
via `tileIn`, then jumped down a row when the
spinner was replaced by the larger number. */}
<Box
sx={{
mt: 0.75,
height: 32,
display: "flex",
alignItems: "center",
}}
>
{tile.value === null ? (
<CircularProgress size={22} thickness={5} sx={{ color: ACCENT }} />
) : (
<Typography
variant="h3"
sx={{
lineHeight: 1,
fontSize: 32,
fontWeight: 600,
letterSpacing: -0.6,
fontVariantNumeric: "tabular-nums",
}}
>
{tile.value}
</Typography>
)}
</Box>
</Box>
</Stack>
</CardContent>
</CardActionArea>
</Card>
);
}

View File

@@ -0,0 +1,273 @@
import {
Box,
Button,
IconButton,
InputAdornment,
Paper,
Stack,
TextField,
Typography,
} from "@mui/material";
import VisibilityIcon from "@mui/icons-material/Visibility";
import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
import { useEffect, useState } from "react";
import { useAuth } from "../auth/AuthContext";
import {
ApiError,
UnauthorizedError,
clearLoginDraft,
loadLoginDraft,
ping,
saveLoginDraft,
} from "../api/client";
import { useNotify } from "../notifications/NotificationsProvider";
import { LoginBackdropMesh } from "../components/LoginBackdropMesh";
import { LoginThemeControls } from "../components/LoginThemeControls";
import brandIcon from "../assets/icon.svg";
export function LoginPage() {
const { login } = useAuth();
const notify = useNotify();
// Pre-fill from the persisted login draft so the user does not have to
// retype their URL + key after closing the tab or logging out.
const [baseUrl, setBaseUrl] = useState(() => loadLoginDraft().baseUrl);
const [apiKey, setApiKey] = useState(() => loadLoginDraft().apiKey);
const [busy, setBusy] = useState(false);
// Toggles whether the API key is rendered as plain text or masked
// dots. Defaults to masked so the page never paints a credential in
// clear text on first load — the user has to opt in to peek.
const [showApiKey, setShowApiKey] = useState(false);
// Persist the form on every keystroke. `saveLoginDraft` itself swallows
// any storage errors (private mode / quota), so the effect is a no-op
// there rather than throwing into the React tree. We don't bother with
// a `beforeunload` flush — the per-keystroke write already keeps the
// saved copy in sync with the latest field value at all times.
useEffect(() => {
saveLoginDraft({ baseUrl, apiKey });
}, [baseUrl, apiKey]);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setBusy(true);
try {
const trimmedUrl = baseUrl.trim();
const trimmedKey = apiKey.trim();
if (!trimmedUrl || !trimmedKey) throw new Error("API URL and key are required");
await ping({ baseUrl: trimmedUrl, apiKey: trimmedKey });
// Auth itself is the source of truth once signed in — drop the draft.
clearLoginDraft();
login({ baseUrl: trimmedUrl, apiKey: trimmedKey });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
// Errors are surfaced exclusively through the global toast stack so
// the form's height stays constant on a rejected attempt. An inline
// <Alert> inside the Paper would grow the form and, combined with
// the parent's vertical centering, make the whole card "bounce" up
// and down each time the user mistypes the API key. The toast still
// carries a contextual message ("Authorization failed" /
// "Connection error" / generic API failure) so the user sees a
// clear cause for the rejected sign-in.
// 401s are a normal path here (typo'd API key); we explicitly
// map them to the auth-error wording rather than the generic
// ApiError formatter from notifyApiError so the message reads
// naturally on the login screen.
if (err instanceof UnauthorizedError) {
notify.error("Authorization failed — check the API key.");
} else if (err instanceof ApiError && err.status === 0) {
notify.error(
`Connection error — could not reach the manager API: ${err.body || err.message}`,
);
} else if (err instanceof ApiError) {
notify.error(`Sign-in failed (HTTP ${err.status}): ${err.body || err.message}`);
} else {
notify.error(`Sign-in failed: ${message}`);
}
} finally {
setBusy(false);
}
};
return (
<Box
sx={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: "background.default",
position: "relative",
overflow: "hidden",
p: 2,
}}
>
<LoginBackdropMesh />
<LoginThemeControls />
<Paper
component="form"
onSubmit={submit}
variant="outlined"
sx={{
p: { xs: 3, sm: 4.5 },
width: "100%",
maxWidth: 440,
position: "relative",
// Bump the form above the absolutely-positioned backdrop so
// the animated network stays strictly behind the card. Without
// this the Paper (a flex item in normal flow) would paint
// before positioned siblings and the SVG would sit on top.
zIndex: 1,
// Drop shadow removed: the animated backdrop already
// gives the page enough depth, and the form's outline
// border is enough to detach it from the moving graphics
// behind it without needing a shadow on top.
boxShadow: "none",
}}
>
<Stack spacing={3}>
{/* Brand — icon + two-line wordmark grouped inside a single
flex row, mirroring the sidebar header in Layout.tsx.
Sizes are scaled up: the sidebar uses 32 px icon /
17 px wordmark / 10 px subtitle; here those are
multiplied by 44/32 to keep proportions identical
while filling the larger card surface. The text
wrapper carries a small paddingBottom so
`alignItems: center` optically centres the two-line
text against the icon without touching any margins
on the individual spans. */}
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 2,
}}
>
<Box
component="img"
src={brandIcon}
alt="sing-box"
sx={{
width: 44,
height: 44,
flexShrink: 0,
display: "block",
objectFit: "contain",
}}
/>
<Box
sx={{
minWidth: 0,
paddingBottom: "6px",
whiteSpace: "nowrap",
}}
>
<Box
component="span"
sx={(t) => ({
display: "block",
margin: 0,
height: 30,
fontFamily: t.typography.fontFamily,
fontWeight: 700,
fontSize: 24,
letterSpacing: -0.6,
lineHeight: "30px",
color: t.palette.text.primary,
})}
>
Sing-box
</Box>
<Box
component="span"
sx={{
display: "block",
margin: 0,
fontSize: 12,
fontWeight: 700,
letterSpacing: 2.4,
lineHeight: "15px",
marginTop: "3px",
textTransform: "uppercase",
color: "var(--sb-accent)",
transition: "color 0.32s cubic-bezier(0.4,0,0.2,1)",
}}
>
extended
</Box>
</Box>
</Box>
<Stack spacing={2}>
<TextField
fullWidth
size="small"
label="API base URL"
placeholder="http://127.0.0.1:8090"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
required
autoFocus
/>
<TextField
fullWidth
size="small"
// `type` flips between password and text driven by the
// visibility toggle below. We keep `autoComplete="off"`
// so a browser password manager doesn't autofill the
// wrong credential into the API key slot.
type={showApiKey ? "text" : "password"}
label="API key"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
required
autoComplete="off"
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton
// `onMouseDown` prevent-default keeps the input
// focused when the user clicks the toggle —
// without it the field would lose focus and any
// autofocus logic would have to chase the user.
onMouseDown={(e) => e.preventDefault()}
onClick={() => setShowApiKey((v) => !v)}
edge="end"
size="small"
// Screen-reader label kept (it's not a visible
// description), but no Tooltip wrapper — per
// request the eye icon stays "quiet" with no
// hover hint.
aria-label={
showApiKey ? "Hide API key" : "Show API key"
}
>
{showApiKey ? (
<VisibilityOffIcon fontSize="small" />
) : (
<VisibilityIcon fontSize="small" />
)}
</IconButton>
</InputAdornment>
),
},
}}
/>
</Stack>
<Button
type="submit"
variant="contained"
color="primary"
size="large"
disabled={busy}
sx={{ py: 1.25 }}
>
{busy ? "Connecting…" : "Sign in"}
</Button>
<Typography variant="caption" color="text.secondary" textAlign="center">
Credentials are stored locally in your browser only.
</Typography>
</Stack>
</Paper>
</Box>
);
}

View File

@@ -0,0 +1,98 @@
import { Chip } from "@mui/material";
import StorageIcon from "@mui/icons-material/Storage";
import { useEffect, useMemo, useState } from "react";
import { CrudPage, type CrudConfig } from "../components/CrudPage";
import { CopyableId } from "../components/CopyableId";
import { useApi } from "../auth/AuthContext";
import type { Node, NodeCreate, NodeStatus, NodeUpdate } from "../api/types";
import {
parseSquadIds,
pickSquadIds,
renderSquadIds,
squadIdsField,
useSquadCatalog,
} from "./squadField";
function StatusChip({ uuid }: { uuid: string }) {
const api = useApi();
const [status, setStatus] = useState<NodeStatus | "loading" | "error">("loading");
useEffect(() => {
let cancelled = false;
(async () => {
try {
const s = await api.nodes.status(uuid);
if (!cancelled) setStatus(s);
} catch {
if (!cancelled) setStatus("error");
}
})();
return () => {
cancelled = true;
};
}, [api, uuid]);
const color: "default" | "success" | "warning" | "error" =
status === "online" ? "success" : status === "offline" ? "warning" : status === "error" ? "error" : "default";
return <Chip size="small" color={color} label={status === "loading" ? "…" : status} />;
}
export function NodesPage() {
const api = useApi();
const squads = useSquadCatalog(api);
// Memoise the CRUD config so CrudPage's `reload` callback stays stable
// across re-renders. Recomputed when the API client flips or a new
// squad name is merged into the catalog through observeRows.
const config = useMemo<CrudConfig<Node, NodeCreate, NodeUpdate>>(() => ({
title: "Nodes",
icon: <StorageIcon />,
idKey: "uuid",
rowKey: (r) => r.uuid,
onRowsChange: (rows) => squads.observeRows(rows, pickSquadIds),
columns: [
{
key: "uuid",
label: "UUID",
render: (row) => <CopyableId value={row.uuid} />,
},
{ key: "name", label: "Name" },
{
key: "squad_ids",
label: "Squads",
sortable: false,
render: renderSquadIds<Node>(squads.names),
},
{
// Status is computed live per-row via /nodes/:uuid/status, so the
// server can't sort by it.
key: "status",
label: "Status",
sortable: false,
render: (row) => <StatusChip uuid={row.uuid} />,
},
{ key: "created_at", label: "Created at" },
{ key: "updated_at", label: "Updated at" },
],
filters: [
{ name: "uuid", label: "UUID", type: "text", wide: true },
{ name: "name", label: "Name", type: "text" },
{ name: "created_at", label: "Created at", type: "datetime-range" },
{ name: "updated_at", label: "Updated at", type: "datetime-range" },
],
fields: [
{ name: "uuid", label: "UUID", type: "uuid", required: true, only: "create" },
{ name: "name", label: "Name", type: "text", required: true },
squadIdsField(squads.loadOptions),
],
list: (q) => api.nodes.list(q),
count: (q) => api.nodes.count(q),
create: (b) => api.nodes.create(b),
update: (id, b) => api.nodes.update(String(id), b),
remove: (id) => api.nodes.remove(String(id)),
toCreate: (f) => ({
uuid: String(f.uuid ?? "").trim(),
name: String(f.name ?? "").trim(),
squad_ids: parseSquadIds(f.squad_ids),
}),
toUpdate: (f) => ({ name: String(f.name ?? "").trim() }),
}), [api, squads]);
return <CrudPage<Node, NodeCreate, NodeUpdate> config={config} />;
}

View File

@@ -0,0 +1,188 @@
import FilterAltIcon from "@mui/icons-material/FilterAlt";
import { useMemo } from "react";
import {
CrudPage,
renderOptionLabel,
type CrudConfig,
} from "../components/CrudPage";
import { useApi } from "../auth/AuthContext";
import type {
RateConnectionType,
RateLimiter,
RateLimiterCreate,
RateLimiterUpdate,
RateStrategy,
} from "../api/types";
import {
parseSquadIds,
pickSquadIds,
renderSquadIds,
squadIdsField,
useSquadCatalog,
} from "./squadField";
// Display labels mirror service/admin_panel/tables/rate_limiter.go.
const STRATEGIES: { value: RateStrategy; label: string }[] = [
{ value: "fixed_window", label: "Fixed window" },
{ value: "sliding_window", label: "Sliding window" },
{ value: "token_bucket", label: "Token bucket" },
{ value: "leaky_bucket", label: "Leaky bucket" },
{ value: "bypass", label: "Bypass" },
];
const CONN_TYPES: { value: RateConnectionType; label: string }[] = [
{ value: "hwid", label: "HWID" },
{ value: "mux", label: "Mux" },
{ value: "source_ip", label: "Source IP" },
{ value: "default", label: "Default" },
];
export function RateLimitersPage() {
const api = useApi();
const squads = useSquadCatalog(api);
// Memoise the CRUD config so CrudPage's `reload` callback stays stable
// across re-renders. Recomputed when the API client flips or a new
// squad name is merged into the catalog through observeRows.
const config = useMemo<
CrudConfig<RateLimiter, RateLimiterCreate, RateLimiterUpdate>
>(() => ({
title: "Rate limiters",
icon: <FilterAltIcon />,
idKey: "id",
onRowsChange: (rows) => squads.observeRows(rows, pickSquadIds),
columns: [
{ key: "id", label: "ID" },
{
key: "squad_ids",
label: "Squads",
sortable: false,
render: renderSquadIds<RateLimiter>(squads.names),
},
{ key: "username", label: "Username" },
{ key: "outbound", label: "Outbound" },
{ key: "strategy", label: "Strategy", render: renderOptionLabel<RateLimiter>("strategy", STRATEGIES) },
{
key: "connection_type",
label: "Connection type",
render: renderOptionLabel<RateLimiter>("connection_type", CONN_TYPES),
},
{
key: "count",
label: "Count",
// bypass disables the limiter server-side
// (excluded_if=Strategy bypass on the DTO), so `count` arrives
// as 0. Render an unambiguous ∞ instead so the column reads as
// "no cap" at a glance instead of looking like a real zero.
render: (row) => (row.strategy === "bypass" ? "∞" : row.count),
},
{
key: "interval",
label: "Interval",
// Same as `count`: `interval` is excluded_if=Strategy bypass and
// arrives empty for bypass rows.
render: (row) => (row.strategy === "bypass" ? "∞" : row.interval),
},
{ key: "created_at", label: "Created at" },
{ key: "updated_at", label: "Updated at" },
],
filters: [
{ name: "username", label: "Username", type: "text" },
{ name: "outbound", label: "Outbound", type: "text" },
{ name: "strategy", label: "Strategy", type: "select", options: STRATEGIES },
{ name: "connection_type", label: "Connection type", type: "select", options: CONN_TYPES },
{ name: "interval", label: "Interval", type: "text", placeholder: "e.g. 1s, 10s, 1m" },
{ name: "created_at", label: "Created at", type: "datetime-range" },
{ name: "updated_at", label: "Updated at", type: "datetime-range" },
],
fields: [
// Mirror service/admin_panel/tables/rate_limiter.go: squads, username
// and outbound are locked once the limiter exists.
squadIdsField(squads.loadOptions),
{ name: "username", label: "Username", type: "text", only: "create" },
{ name: "outbound", label: "Outbound", type: "text", required: true, only: "create" },
{
name: "strategy",
label: "Strategy",
type: "select",
required: true,
options: STRATEGIES,
// bypass disables every post-Strategy field server-side
// (excluded_if=Strategy bypass on the DTO). Wipe their values when
// switching so a stale entry can't be smuggled out of a hidden field.
clears: ["connection_type", "count", "interval"],
},
{
name: "connection_type",
label: "Connection type",
type: "select",
required: true,
options: CONN_TYPES,
visibleWhen: (form) => form.strategy !== "bypass",
},
{
name: "count",
label: "Count",
type: "number",
required: true,
visibleWhen: (form) => form.strategy !== "bypass",
},
{
name: "interval",
label: "Interval",
type: "text",
required: true,
helperText: "e.g. 1s",
visibleWhen: (form) => form.strategy !== "bypass",
},
],
list: (q) => api.rateLimiters.list(q),
count: (q) => api.rateLimiters.count(q),
create: (b) => api.rateLimiters.create(b),
update: (id, b) => api.rateLimiters.update(Number(id), b),
remove: (id) => api.rateLimiters.remove(Number(id)),
fromEntity: (e) => ({
username: e.username ?? "",
outbound: e.outbound,
strategy: e.strategy,
connection_type: e.connection_type,
count: e.count,
interval: e.interval,
}),
// `connection_type` / `count` / `interval` carry
// `excluded_if=Strategy bypass` on the manager-api DTO and the
// SQL repository unconditionally parses `interval` via
// time.ParseDuration — sending `interval: ""` with a bypass
// payload would make the server reject the request with 400
// "invalid format". Drop the keys entirely on bypass so
// `JSON.stringify` skips them.
toCreate: (f) => {
const strategy = String(f.strategy ?? "") as RateStrategy;
const bypass = strategy === "bypass";
return {
squad_ids: parseSquadIds(f.squad_ids),
username: f.username ? String(f.username) : undefined,
outbound: String(f.outbound ?? "").trim(),
strategy,
connection_type: bypass
? undefined
: (String(f.connection_type ?? "") as RateConnectionType),
count: bypass ? undefined : Number(f.count ?? 0),
interval: bypass ? undefined : String(f.interval ?? "").trim(),
};
},
toUpdate: (f, original) => {
const strategy = String(f.strategy ?? "") as RateStrategy;
const bypass = strategy === "bypass";
return {
username: original.username || undefined,
outbound: original.outbound,
strategy,
connection_type: bypass
? undefined
: (String(f.connection_type ?? "") as RateConnectionType),
count: bypass ? undefined : Number(f.count ?? 0),
interval: bypass ? undefined : String(f.interval ?? "").trim(),
};
},
}), [api, squads]);
return <CrudPage<RateLimiter, RateLimiterCreate, RateLimiterUpdate> config={config} />;
}

View File

@@ -0,0 +1,40 @@
import GroupsIcon from "@mui/icons-material/Groups";
import { useMemo } from "react";
import { CrudPage, type CrudConfig } from "../components/CrudPage";
import { useApi } from "../auth/AuthContext";
import type { Squad, SquadCreate, SquadUpdate } from "../api/types";
export function SquadsPage() {
const api = useApi();
// Memoise so CrudPage's `reload` callback (which depends on `config`)
// keeps a stable identity across re-renders. Without this every parent
// render would invalidate `reload` and refire the list+count requests.
const config = useMemo<CrudConfig<Squad, SquadCreate, SquadUpdate>>(
() => ({
title: "Squads",
icon: <GroupsIcon />,
idKey: "id",
columns: [
{ key: "id", label: "ID" },
{ key: "name", label: "Name" },
{ key: "created_at", label: "Created At" },
{ key: "updated_at", label: "Updated At" },
],
filters: [
{ name: "name", label: "Name", type: "text" },
{ name: "created_at", label: "Created at", type: "datetime-range" },
{ name: "updated_at", label: "Updated at", type: "datetime-range" },
],
fields: [{ name: "name", label: "Name", type: "text", required: true }],
list: (q) => api.squads.list(q),
count: (q) => api.squads.count(q),
create: (b) => api.squads.create(b),
update: (id, b) => api.squads.update(Number(id), b),
remove: (id) => api.squads.remove(Number(id)),
toCreate: (f) => ({ name: String(f.name ?? "") }),
toUpdate: (f) => ({ name: String(f.name ?? "") }),
}),
[api],
);
return <CrudPage<Squad, SquadCreate, SquadUpdate> config={config} />;
}

View File

@@ -0,0 +1,251 @@
import RestartAltIcon from "@mui/icons-material/RestartAlt";
import SwapHorizIcon from "@mui/icons-material/SwapHoriz";
import { Box, LinearProgress, Typography } from "@mui/material";
import { useMemo } from "react";
import {
CrudPage,
renderOptionLabel,
type CrudConfig,
} from "../components/CrudPage";
import { useApi } from "../auth/AuthContext";
import { useNotify } from "../notifications/NotificationsProvider";
import type {
TrafficLimiter,
TrafficLimiterCreate,
TrafficLimiterUpdate,
TrafficMode,
TrafficStrategy,
} from "../api/types";
import {
parseSquadIds,
pickSquadIds,
renderSquadIds,
squadIdsField,
useSquadCatalog,
} from "./squadField";
// Display labels mirror service/admin_panel/tables/traffic_limiter.go.
const STRATEGIES: { value: TrafficStrategy; label: string }[] = [
{ value: "global", label: "Global" },
{ value: "bypass", label: "Bypass" },
];
const MODES: { value: TrafficMode; label: string }[] = [
{ value: "download", label: "Download" },
{ value: "upload", label: "Upload" },
{ value: "bidirectional", label: "Bidirectional" },
];
// renderUsage shows the server-computed `usage` percentage (0100,
// floored in SQL) as a horizontal progress bar with a "P %" label next
// to it. Computing on the back end keeps the bar's colour and the
// number side-by-side: there's no risk of the FE rounding 99.6 → 100
// while the colour threshold still sees 99.6.
function renderUsage(row: TrafficLimiter) {
const pct = Math.min(100, Math.max(0, Number(row.usage ?? 0)));
// Hint at the danger zone — bar shifts from primary to warning to error
// as the limiter approaches / exceeds its quota.
const color = pct >= 100 ? "error" : pct >= 80 ? "warning" : "primary";
return (
// Capped-width wrapper: on the desktop table the bar always renders
// at its 140 px max regardless of how wide the user has stretched
// the "Usage" column (without the cap, the inner bar — which has
// `flexGrow: 1` — would expand to fill the cell, making every
// Usage column visibly different across viewports). On narrow
// mobile cards the wrapper shrinks down to the cell's actual
// width via `width: 100%` + `minWidth: 0`, so the bar stays on
// the same row as its percentage label instead of overflowing.
<Box
sx={{
display: "flex",
alignItems: "center",
flexWrap: "nowrap",
gap: 1,
width: "100%",
maxWidth: 140,
minWidth: 0,
}}
>
<Typography
variant="caption"
sx={{
color: "text.secondary",
fontVariantNumeric: "tabular-nums",
flexShrink: 0,
whiteSpace: "nowrap",
// Mobile cards apply `wordBreak: break-word` to value cells,
// which would let the browser snap "100%" between digits and
// the percent sign on a narrow viewport. Forcing the value to
// stay on one glyph row keeps the percent and the bar on the
// same line at every screen width.
wordBreak: "keep-all",
overflowWrap: "normal",
}}
>
{pct}%
</Typography>
<Box sx={{ flexGrow: 1, flexShrink: 1, minWidth: 0 }}>
<LinearProgress
variant="determinate"
value={pct}
color={color}
sx={{ height: 6, borderRadius: 3 }}
/>
</Box>
</Box>
);
}
export function TrafficLimitersPage() {
const api = useApi();
const notify = useNotify();
const squads = useSquadCatalog(api);
// Memoise the CRUD config so CrudPage's `reload` callback stays stable
// across re-renders. Recomputed when the API client flips or a new
// squad name is merged into the catalog through observeRows.
const config = useMemo<
CrudConfig<TrafficLimiter, TrafficLimiterCreate, TrafficLimiterUpdate>
>(() => ({
title: "Traffic limiters",
icon: <SwapHorizIcon />,
idKey: "id",
onRowsChange: (rows) => squads.observeRows(rows, pickSquadIds),
// Reset traffic — wipes raw_used to 0 via the manager-api
// PUT /traffic-limiters/{id}/used endpoint, then reloads the
// table so the Usage bar snaps back to 0 %. Hidden for rows that
// already have zero usage so the button doesn't read as a no-op.
rowActions: [
{
key: "reset",
label: "Reset traffic",
icon: <RestartAltIcon fontSize="small" />,
visible: (row) => Number(row.raw_used ?? 0) > 0,
// Pop a styled MUI confirm dialog (same chrome as the Delete
// dialog), not a browser-native window.confirm. The dialog is
// tinted "warning" to flag the action without reading as
// destructive — usage data is recoverable but the counter
// mid-period isn't.
confirm: (row) => ({
title: `Reset traffic for limiter #${row.id}?`,
description:
"The used traffic counter will be set back to 0. The limiter's quota and configuration are unchanged.",
confirmLabel: "Reset",
busyLabel: "Resetting…",
color: "warning",
}),
onClick: async (row, ctx) => {
await api.trafficLimiters.updateUsed(row.id, 0);
notify.success(`Traffic reset for limiter #${row.id}`);
await ctx.reload();
},
},
],
columns: [
{ key: "id", label: "ID" },
{
key: "squad_ids",
label: "Squads",
sortable: false,
render: renderSquadIds<TrafficLimiter>(squads.names),
},
{ key: "username", label: "Username" },
{ key: "outbound", label: "Outbound" },
{ key: "strategy", label: "Strategy", render: renderOptionLabel<TrafficLimiter>("strategy", STRATEGIES) },
{ key: "mode", label: "Mode", render: renderOptionLabel<TrafficLimiter>("mode", MODES) },
{ key: "usage", label: "Usage", render: renderUsage },
{
key: "quota",
label: "Quota",
// bypass disables the limiter's quota server-side
// (excluded_if=Strategy bypass on the DTO), so the row's `quota`
// arrives as an empty string. Render an unambiguous ∞ instead so
// the column reads as "no cap" at a glance instead of looking
// like missing data.
render: (row) => (row.strategy === "bypass" ? "∞" : row.quota),
},
{ key: "created_at", label: "Created at" },
{ key: "updated_at", label: "Updated at" },
],
filters: [
{ name: "username", label: "Username", type: "text" },
{ name: "outbound", label: "Outbound", type: "text" },
{ name: "strategy", label: "Strategy", type: "select", options: STRATEGIES },
{ name: "mode", label: "Mode", type: "select", options: MODES },
{ name: "created_at", label: "Created at", type: "datetime-range" },
{ name: "updated_at", label: "Updated at", type: "datetime-range" },
],
fields: [
// Mirror service/admin_panel/tables/traffic_limiter.go: squads,
// username and outbound are locked once the limiter exists.
squadIdsField(squads.loadOptions),
{ name: "username", label: "Username", type: "text", only: "create" },
{ name: "outbound", label: "Outbound", type: "text", required: true, only: "create" },
{
name: "strategy",
label: "Strategy",
type: "select",
required: true,
options: STRATEGIES,
// bypass disables every post-Strategy field server-side
// (excluded_if=Strategy bypass on the DTO). Wipe their values when
// switching so a stale entry can't be smuggled out of a hidden field.
clears: ["mode", "quota"],
},
{
name: "mode",
label: "Mode",
type: "select",
required: true,
options: MODES,
visibleWhen: (form) => form.strategy !== "bypass",
},
{
name: "quota",
label: "Quota",
type: "text",
required: true,
helperText: "e.g. 10gb",
visibleWhen: (form) => form.strategy !== "bypass",
},
],
list: (q) => api.trafficLimiters.list(q),
count: (q) => api.trafficLimiters.count(q),
create: (b) => api.trafficLimiters.create(b),
update: (id, b) => api.trafficLimiters.update(Number(id), b),
remove: (id) => api.trafficLimiters.remove(Number(id)),
fromEntity: (e) => ({
username: e.username ?? "",
outbound: e.outbound,
strategy: e.strategy,
mode: e.mode,
quota: e.quota,
}),
// `mode` / `quota` carry `excluded_if=Strategy bypass` on the
// manager-api DTO and the SQL repository unconditionally parses
// `quota` via byteformats — sending empty strings with a bypass
// payload would make the server reject the request with 400
// "invalid format". Drop the keys entirely on bypass so
// `JSON.stringify` skips them.
toCreate: (f) => {
const strategy = String(f.strategy ?? "") as TrafficStrategy;
return {
squad_ids: parseSquadIds(f.squad_ids),
username: f.username ? String(f.username) : undefined,
outbound: String(f.outbound ?? "").trim(),
strategy,
mode: strategy === "bypass" ? undefined : (String(f.mode ?? "") as TrafficMode),
quota: strategy === "bypass" ? undefined : String(f.quota ?? "").trim(),
};
},
toUpdate: (f, original) => {
const strategy = String(f.strategy ?? "") as TrafficStrategy;
return {
username: original.username || undefined,
outbound: original.outbound,
strategy,
mode: strategy === "bypass" ? undefined : (String(f.mode ?? "") as TrafficMode),
quota: strategy === "bypass" ? undefined : String(f.quota ?? "").trim(),
};
},
}), [api, notify, squads]);
return <CrudPage<TrafficLimiter, TrafficLimiterCreate, TrafficLimiterUpdate> config={config} />;
}

View File

@@ -0,0 +1,157 @@
import PeopleIcon from "@mui/icons-material/People";
import { useMemo } from "react";
import {
CrudPage,
renderOptionLabel,
type CrudConfig,
} from "../components/CrudPage";
import { useApi } from "../auth/AuthContext";
import type { User, UserCreate, UserType, UserUpdate } from "../api/types";
import {
parseSquadIds,
pickSquadIds,
renderSquadIds,
squadIdsField,
useSquadCatalog,
} from "./squadField";
// Display labels mirror service/admin_panel/tables/user.go.
const USER_TYPES: { value: UserType; label: string }[] = [
{ value: "hysteria", label: "Hysteria" },
{ value: "hysteria2", label: "Hysteria2" },
{ value: "mtproxy", label: "MTProxy" },
{ value: "trojan", label: "Trojan" },
{ value: "tuic", label: "TUIC" },
{ value: "vless", label: "VLESS" },
{ value: "vmess", label: "VMess" },
];
const FLOW_OPTIONS: { value: string; label: string }[] = [
{ value: "xtls-rprx-vision", label: "xtls-rprx-vision" },
];
// Per-type field visibility, mirroring FieldOnChooseOptionsHide in
// service/admin_panel/tables/user.go and the struct-level validator for
// constant.UserCreate in service/manager/service.go. Every field in a
// SHOW_* set is also required for that type — the Go validator reports
// "required" for each missing credential, so the client enforces the
// same rule up-front (required fields invisible for the current type
// are filtered out before validateRequired runs).
const SHOW_UUID = new Set<UserType>(["vless", "vmess", "tuic"]);
const SHOW_PASSWORD = new Set<UserType>(["hysteria", "hysteria2", "trojan", "tuic"]);
const SHOW_SECRET = new Set<UserType>(["mtproxy"]);
const SHOW_FLOW = new Set<UserType>(["vless"]);
const SHOW_ALTER_ID = new Set<UserType>(["vmess"]);
const showFor = (set: Set<UserType>) => (form: Record<string, unknown>) =>
set.has(form.type as UserType);
export function UsersPage() {
const api = useApi();
const squads = useSquadCatalog(api);
// Memoise the CRUD config so CrudPage's `reload` callback stays stable
// across re-renders. Recomputed only when the API client or the squad
// catalog actually changes (a new squad name arriving through
// observeRows re-renders the table with the fresh chip labels).
const config = useMemo<CrudConfig<User, UserCreate, UserUpdate>>(() => ({
title: "Users",
icon: <PeopleIcon />,
idKey: "id",
// After each page of users is loaded, fetch the squad names
// referenced by those rows (only the ones we haven't cached yet).
onRowsChange: (rows) => squads.observeRows(rows, pickSquadIds),
columns: [
{ key: "id", label: "ID" },
{
key: "squad_ids",
label: "Squads",
sortable: false,
render: renderSquadIds<User>(squads.names),
},
{ key: "username", label: "Username" },
{ key: "inbound", label: "Inbound" },
{ key: "type", label: "Type", render: renderOptionLabel<User>("type", USER_TYPES) },
{ key: "created_at", label: "Created at" },
{ key: "updated_at", label: "Updated at" },
],
filters: [
{ name: "username", label: "Username", type: "text" },
{ name: "inbound", label: "Inbound", type: "text" },
{ name: "type", label: "Type", type: "select", options: USER_TYPES },
{ name: "created_at", label: "Created at", type: "datetime-range" },
{ name: "updated_at", label: "Updated at", type: "datetime-range" },
],
fields: [
// FieldDisableWhenUpdate in service/admin_panel/tables/user.go.
// The squad catalog is fetched lazily when the dialog opens —
// never on page mount — via CrudDialog's optionsLoader.
squadIdsField(squads.loadOptions),
{ name: "username", label: "Username", type: "text", required: true, only: "create" },
{ name: "inbound", label: "Inbound", type: "text", required: true, only: "create" },
{
name: "type",
label: "Type",
type: "select",
required: true,
only: "create",
options: USER_TYPES,
// Switching the user type wipes every credential field so the form
// matches the legacy admin's behaviour of starting fresh.
clears: ["uuid", "password", "secret", "flow", "alter_id"],
},
// Credential fields: the Go struct validator reports "required" for
// whichever of these is missing once the type is chosen, so each one
// is marked required AND gated by its SHOW_* set. Invisible fields
// are filtered out before validateRequired runs, so e.g. Password is
// only enforced for hysteria/hysteria2/trojan/tuic and not for vless.
{ name: "uuid", label: "UUID", type: "uuid", required: true, visibleWhen: showFor(SHOW_UUID) },
{ name: "password", label: "Password", type: "text", required: true, visibleWhen: showFor(SHOW_PASSWORD) },
{ name: "secret", label: "Secret", type: "text", required: true, visibleWhen: showFor(SHOW_SECRET) },
{
name: "flow",
label: "Flow",
type: "select",
options: FLOW_OPTIONS,
visibleWhen: showFor(SHOW_FLOW),
},
{ name: "alter_id", label: "Alter ID", type: "number", required: true, visibleWhen: showFor(SHOW_ALTER_ID) },
],
list: (q) => api.users.list(q),
count: (q) => api.users.count(q),
create: (b) => api.users.create(b),
update: (id, b) => api.users.update(Number(id), b),
remove: (id) => api.users.remove(Number(id)),
// Seed `type` even though the field is create-only; the dialog uses it
// for visibleWhen when editing existing users.
fromEntity: (u) => ({
squad_ids: u.squad_ids,
type: u.type,
uuid: u.uuid,
password: u.password,
secret: u.secret,
flow: u.flow,
alter_id: u.alter_id,
}),
toCreate: (f) => ({
squad_ids: parseSquadIds(f.squad_ids),
username: String(f.username ?? "").trim(),
inbound: String(f.inbound ?? "").trim(),
type: String(f.type ?? "") as UserType,
uuid: f.uuid ? String(f.uuid).trim() : undefined,
password: f.password ? String(f.password) : undefined,
secret: f.secret ? String(f.secret) : undefined,
flow: f.flow ? String(f.flow) : undefined,
alter_id: f.alter_id !== undefined && f.alter_id !== "" ? Number(f.alter_id) : undefined,
}),
toUpdate: (f) => {
const out: UserUpdate = {};
if (f.uuid && String(f.uuid).trim() !== "") out.uuid = String(f.uuid).trim();
if (f.password !== undefined && f.password !== "") out.password = String(f.password);
if (f.secret !== undefined && f.secret !== "") out.secret = String(f.secret);
if (f.flow !== undefined && f.flow !== "") out.flow = String(f.flow);
if (f.alter_id !== undefined && f.alter_id !== "") out.alter_id = Number(f.alter_id);
return out;
},
}), [api, squads]);
return <CrudPage<User, UserCreate, UserUpdate> config={config} />;
}

View File

@@ -0,0 +1,197 @@
// Helpers shared by every page that exposes a `squad_ids` field. Keeps the
// FieldSpec and the `string[] → number[]` conversion in one place so the
// pages don't repeat themselves.
import { Box, Chip } from "@mui/material";
import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
import type { Api } from "../api/client";
import type { FieldSpec } from "../components/CrudPage";
export interface SquadCatalog {
// Map keyed by squad id → squad name. Populated on demand via
// `observeRows`: each refresh of a CRUD table declares which squad
// ids appear in the visible page and we fetch names only for the
// subset that isn't already cached.
names: Map<number, string>;
// observeRows takes the freshly loaded page of rows, extracts their
// squad ids via `pick`, and fires a GET /squads?id_in=… for the ids
// whose names we haven't seen yet. Wired to `CrudConfig.onRowsChange`,
// which awaits the returned promise before publishing the rows — that
// way the table only paints once both the row data and the squad
// names referenced by those rows are in hand, instead of flashing
// raw ids before the chip labels resolve. Resolves immediately when
// every visible squad id is already cached (or in flight from a
// previous refresh).
observeRows: <TRow>(rows: TRow[], pick: (row: TRow) => number[] | undefined) => Promise<void>;
// loadOptions fetches every squad and returns the multi-select
// option list. Intended for CrudDialog's `optionsLoader`, which
// fires the first time a create dialog is opened — that way the
// full catalog is only downloaded when the user actually needs to
// pick squads, not on page mount.
loadOptions: () => Promise<{ value: string; label: string }[]>;
}
// useSquadCatalog returns the per-page squad-name cache + option loader.
// Unlike the previous `useSquads` the cache starts empty — names are
// populated on demand as rows arrive through `observeRows`, and the
// create-form options are fetched lazily via `loadOptions`. Result:
// visiting e.g. the Users page no longer triggers an unconditional
// GET /squads for the entire squad table.
export function useSquadCatalog(api: Api): SquadCatalog {
const [names, setNames] = useState<Map<number, string>>(() => new Map());
// resolvedRef mirrors `names` for synchronous reads inside observeRows
// (state reads from the hook closure are stale across renders, and we
// need an up-to-date "is this id known?" check on every call). Kept
// as a ref because it must never trigger a re-render on its own —
// visual updates are driven by `names`.
const resolvedRef = useRef<Set<number>>(new Set());
// inFlightRef tracks pending GET /squads requests by id so a second
// observeRows that arrives while the first is still loading shares
// the same promise instead of either (a) re-firing the request or
// (b) resolving immediately with names that aren't loaded yet — the
// latter would defeat the "wait before painting" contract CrudPage
// relies on.
const inFlightRef = useRef<Map<number, Promise<void>>>(new Map());
const observeRows = useCallback(
async <TRow,>(rows: TRow[], pick: (row: TRow) => number[] | undefined) => {
const need = new Set<number>();
for (const row of rows) {
const ids = pick(row);
if (!Array.isArray(ids)) continue;
for (const id of ids) {
if (!Number.isFinite(id)) continue;
need.add(id);
}
}
if (need.size === 0) return;
const waiting: Promise<void>[] = [];
const fresh: number[] = [];
for (const id of need) {
if (resolvedRef.current.has(id)) continue;
const pending = inFlightRef.current.get(id);
if (pending) {
waiting.push(pending);
continue;
}
fresh.push(id);
}
if (fresh.length > 0) {
const request = (async () => {
try {
const squads = await api.squads.list({ id_in: fresh });
if (squads.length > 0) {
for (const s of squads) resolvedRef.current.add(s.id);
setNames((prev) => {
const next = new Map(prev);
for (const s of squads) next.set(s.id, s.name);
return next;
});
}
// Ids whose name is missing from the response are still
// marked resolved — re-asking the server would just
// produce the same empty answer, and leaving them in
// limbo would make every subsequent refresh re-fetch
// them. The chip falls back to the raw id label.
for (const id of fresh) resolvedRef.current.add(id);
} catch {
// Failed fetches stay unresolved so a future refresh can
// retry — otherwise the chips would remain "1", "2"
// forever.
} finally {
for (const id of fresh) inFlightRef.current.delete(id);
}
})();
for (const id of fresh) inFlightRef.current.set(id, request);
waiting.push(request);
}
if (waiting.length > 0) await Promise.all(waiting);
},
[api],
);
const loadOptions = useCallback(async () => {
const squads = await api.squads.list();
setNames((prev) => {
const next = new Map(prev);
for (const s of squads) {
next.set(s.id, s.name);
resolvedRef.current.add(s.id);
}
return next;
});
return squads.map((s) => ({ value: String(s.id), label: s.name }));
}, [api]);
return useMemo<SquadCatalog>(
() => ({ names, observeRows, loadOptions }),
[names, observeRows, loadOptions],
);
}
// squadIdsField produces the `FieldSpec` for the squad_ids multi-select
// shown in every create form. The option list is supplied via an
// `optionsLoader` so the catalog is fetched the first time a create
// dialog is opened — not on page mount.
export function squadIdsField(
loadOptions: () => Promise<{ value: string; label?: string }[]>,
overrides?: Partial<FieldSpec>,
): FieldSpec {
return {
name: "squad_ids",
label: "Squads",
type: "multiselect",
required: true,
only: "create",
optionsLoader: loadOptions,
...overrides,
};
}
// parseSquadIds converts whatever the multi-select gave us back into a
// number[] suitable for the API.
export function parseSquadIds(raw: unknown): number[] {
if (!Array.isArray(raw)) return [];
return (raw as unknown[])
.map((v) => Number(v))
.filter((n) => Number.isFinite(n));
}
// renderSquadIds returns a CrudPage column `render` function that turns
// a `squad_ids: number[]` field into a wrapping row of name chips,
// keyed off the id→name map produced by useSquadCatalog.
export function renderSquadIds<TRow extends { squad_ids?: number[] | null }>(
names: Map<number, string>,
): (row: TRow) => ReactNode {
return (row: TRow) => {
const ids = row.squad_ids;
if (!Array.isArray(ids) || ids.length === 0) return "";
return (
<Box
sx={{
display: "flex",
flexWrap: "wrap",
rowGap: 0.5,
columnGap: 0.5,
maxWidth: "100%",
}}
>
{ids.map((id) => (
<Chip key={id} label={names.get(id) ?? String(id)} size="small" />
))}
</Box>
);
};
}
// pickSquadIds is the default extractor passed to `observeRows` from
// rows whose squad membership sits in a canonical `squad_ids` field.
// Exposed so pages don't have to inline the same `(row) => row.squad_ids`.
export function pickSquadIds<TRow extends { squad_ids?: number[] | null }>(
row: TRow,
): number[] | undefined {
return row.squad_ids ?? undefined;
}

View File

@@ -0,0 +1,589 @@
import Fade from "@mui/material/Fade";
import { alpha, createTheme, type Theme } from "@mui/material/styles";
// Two layered palettes, one per mode. Surface and elevated tones differ
// by ~5 luminance steps so the eye can pick out cards/dialogs from the
// page; the rest of the UI references this set so flipping `mode` flips
// every component override at once.
export type ThemeMode = "dark" | "light";
const DARK = {
surface: "#141414", // page / cards / paper / tables
elevated: "#191919", // app bar replacement, table heads, side bar
elevated2: "#1f1f1f", // hover states for table rows
// Borders bumped a touch (0.06 → 0.10 / 0.14 → 0.20) so outlined
// cards and table cell borders are actually visible against the
// very dark surface.
border: "rgba(255,255,255,0.10)",
borderStrong: "rgba(255,255,255,0.20)",
textPrimary: "#f5f5f5",
// Secondary / caption text was washing out at 0.66 — bumped to 0.80
// so subtitles, helper text and uppercase labels stay legible.
textSecondary: "rgba(255,255,255,0.80)",
scrollbar: "rgba(255,255,255,0.10)",
scrollbarHover: "rgba(255,255,255,0.18)",
iconButtonHover: "rgba(255,255,255,0.07)",
inputBg: "rgba(255,255,255,0.02)",
chipBg: "rgba(255,255,255,0.06)",
listItemHover: "rgba(255,255,255,0.04)",
tableHeadColor: "rgba(255,255,255,0.78)",
tooltipBg: "#222",
tooltipText: "#fff",
dialogShadow: "0 24px 48px rgba(0,0,0,0.55)",
} as const;
const LIGHT = {
surface: "#ffffff",
elevated: "#f5f7fa",
elevated2: "#e9edf3",
// Borders deep enough that outlined cards / table rows read clearly
// on pure-white surfaces.
border: "rgba(15,23,42,0.18)",
borderStrong: "rgba(15,23,42,0.32)",
textPrimary: "#0f172a",
// Secondary / caption text — pushed up to 0.82 so helper text,
// subtitles and chips stop looking washed out on white.
textSecondary: "rgba(15,23,42,0.82)",
scrollbar: "rgba(15,23,42,0.26)",
scrollbarHover: "rgba(15,23,42,0.40)",
iconButtonHover: "rgba(15,23,42,0.07)",
inputBg: "#ffffff",
chipBg: "rgba(15,23,42,0.10)",
listItemHover: "rgba(15,23,42,0.06)",
tableHeadColor: "rgba(15,23,42,0.82)",
tooltipBg: "#1e293b",
tooltipText: "#fff",
dialogShadow: "0 24px 48px rgba(15,23,42,0.18)",
} as const;
function colors(mode: ThemeMode) {
return mode === "light" ? LIGHT : DARK;
}
// Default accent. The user can override via the in-app color picker.
export const DEFAULT_ACCENT = "#3b82f6";
export const DEFAULT_MODE: ThemeMode = "dark";
// isHexColor reports whether the string looks like a 3- or 6-digit hex CSS
// color, e.g. "#fff" / "#7c83ff". Used to validate user-supplied accent
// values from localStorage / the picker before feeding them to MUI.
export function isHexColor(s: string): boolean {
return /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(s);
}
// pickContrastingTextColor returns "#0c0c0c" or "#ffffff" depending on which
// has better contrast against the given background. Used so primary buttons
// stay readable regardless of the chosen accent.
export function pickContrastingTextColor(hex: string): string {
const norm =
hex.length === 4
? "#" +
hex
.slice(1)
.split("")
.map((c) => c + c)
.join("")
: hex;
const r = parseInt(norm.slice(1, 3), 16);
const g = parseInt(norm.slice(3, 5), 16);
const b = parseInt(norm.slice(5, 7), 16);
// Relative luminance per WCAG, rough approximation.
const lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
return lum > 0.55 ? "#0c0c0c" : "#ffffff";
}
// ThemePalette exposes only the tokens callers actually read directly.
// MUI's own theme covers the rest (accent goes through `var(--sb-accent)`,
// borderStrong / elevated2 / accentSoft are only consumed inside the
// MUI theme overrides below, never via this struct).
export interface ThemePalette {
mode: ThemeMode;
surface: string;
elevated: string;
border: string;
}
// buildTheme produces a fresh MUI theme using the given accent color and
// theme mode. All component overrides reference the dynamic accent + mode
// palette so the entire UI updates the moment either changes.
export function buildTheme(
accentInput: string = DEFAULT_ACCENT,
modeInput: ThemeMode = DEFAULT_MODE,
): Theme {
const accent = isHexColor(accentInput) ? accentInput : DEFAULT_ACCENT;
const mode: ThemeMode = modeInput === "light" ? "light" : "dark";
const M = colors(mode);
const accentContrast = pickContrastingTextColor(accent);
return createTheme({
palette: {
mode,
background: { default: M.surface, paper: M.surface },
primary: { main: accent, contrastText: accentContrast },
secondary: { main: mode === "light" ? "#475569" : "#9aa0a6" },
divider: M.border,
text: {
primary: M.textPrimary,
secondary: M.textSecondary,
},
success: { main: "#22c55e" },
warning: { main: "#f59e0b" },
error: { main: "#ef4444" },
},
shape: { borderRadius: 10 },
typography: {
fontFamily:
'Inter, "SF Pro Display", Roboto, system-ui, -apple-system, "Segoe UI", "Helvetica Neue", Arial, sans-serif',
h4: { fontWeight: 600, letterSpacing: -0.4 },
h5: { fontWeight: 600, letterSpacing: -0.3 },
h6: { fontWeight: 600, letterSpacing: -0.2 },
button: { fontWeight: 500 },
},
components: {
MuiCssBaseline: {
styleOverrides: {
":root": {
// Default accent CSS variable. AppThemeProvider overrides this
// on <html> at runtime; declaring a fallback here means the
// very first paint never renders with `var(--sb-accent)`
// pointing at nothing.
"--sb-accent": DEFAULT_ACCENT,
// Companion variable holding the readable foreground colour
// for content sitting on top of `--sb-accent` (e.g. the "+"
// create IconButton on the CrudPage toolbar). Computed via
// `pickContrastingTextColor`, kept in lockstep with
// `--sb-accent` by AppThemeProvider on every accent change.
"--sb-accent-contrast": accentContrast,
// Mode-aware surface tones, exposed so component sx blocks can
// pin sticky / overlapping elements (e.g. CrudPage's pinned
// Actions column) to exactly the same colour the surrounding
// Table / TableHead / hovered TableRow renders. Keeping them
// here means flipping the mode re-emits the trio in lockstep
// with every other component override.
"--sb-surface": M.surface,
"--sb-elevated": M.elevated,
"--sb-elevated2": M.elevated2,
},
html: {
// Reserve room for the vertical scrollbar even when it isn't
// present. Without this, anything that briefly hides the
// scrollbar (autofill, modals, even some browser quirks) shifts
// the entire viewport horizontally by ~17px and you see a
// "flick" in the sidebar / topbar text.
scrollbarGutter: "stable",
},
body: {
backgroundColor: M.surface,
color: M.textPrimary,
colorScheme: mode,
},
"*::-webkit-scrollbar": { width: 10, height: 10 },
"*::-webkit-scrollbar-track": { background: "transparent" },
"*::-webkit-scrollbar-thumb": {
background: M.scrollbar,
borderRadius: 8,
border: "2px solid transparent",
backgroundClip: "padding-box",
},
"*::-webkit-scrollbar-thumb:hover": {
background: M.scrollbarHover,
backgroundClip: "padding-box",
},
// Theme switching: View Transitions API circular reveal from the
// toggle's click point. Falls back to instant change in browsers
// without `document.startViewTransition`.
//
// Both pseudo-elements are static images (DOM snapshots), so we
// override the default cross-fade animation with our own clip-path
// reveal on the new state. The old snapshot stays at full opacity
// beneath the new one and is wiped away by the expanding circle.
// We deliberately do NOT disable transitions on the live DOM
// during the view-transition — that caused a "bounce" at the end
// when in-flight ripples / row-hover transitions snapped back.
"::view-transition-old(root), ::view-transition-new(root)": {
animation: "none",
mixBlendMode: "normal",
},
"::view-transition-old(root)": {
zIndex: 1,
},
"::view-transition-new(root)": {
zIndex: 2,
animation: "theme-reveal 520ms cubic-bezier(0.4, 0, 0.2, 1) forwards",
},
"@keyframes theme-reveal": {
from: {
clipPath:
"circle(0px at var(--theme-cx, 50%) var(--theme-cy, 50%))",
},
to: {
clipPath:
"circle(var(--theme-r, 150vmax) at var(--theme-cx, 50%) var(--theme-cy, 50%))",
},
},
},
},
MuiPaper: {
defaultProps: { elevation: 0 },
styleOverrides: {
root: { backgroundImage: "none", backgroundColor: M.surface },
outlined: {
borderColor: M.border,
borderRadius: 12,
},
},
},
MuiCard: {
styleOverrides: {
root: {
backgroundColor: M.surface,
backgroundImage: "none",
border: `1px solid ${M.border}`,
borderRadius: 12,
transition:
"transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease",
},
},
},
MuiAppBar: {
styleOverrides: {
root: {
backgroundImage: "none",
backgroundColor: M.elevated,
boxShadow: "none",
borderBottom: `1px solid ${M.border}`,
},
},
},
MuiDrawer: {
styleOverrides: {
paper: {
backgroundImage: "none",
backgroundColor: M.elevated,
borderRight: `1px solid ${M.border}`,
},
},
},
MuiDialog: {
defaultProps: {
// Don't lock body scroll — MUI's default behaviour adds
// `overflow:hidden` + a `padding-right` to <body> equal to the
// scrollbar width while the modal is open, which shifts every
// `position:fixed` element (sidebar, topbar) horizontally by
// ~17px. Keeping the scrollbar avoids that flick.
disableScrollLock: true,
},
styleOverrides: {
paper: {
backgroundColor: M.surface,
backgroundImage: "none",
border: `1px solid ${M.border}`,
boxShadow: M.dialogShadow,
},
},
},
// Same scroll-lock fix for every other Modal-based opener so popping
// a colour picker, filter dropdown or autocomplete list doesn't yank
// the layout sideways.
//
// We also override the default `Grow` transition with `Fade` for
// every overlay element. `Grow` scales the element with
// `scale(0.75, 0.5625) → scale(1, 1)` — an *asymmetric* stretch in
// which the Y-axis grows from 56% to 100%. With most placements that
// means the top edge of the popup rises noticeably as the animation
// ends, which reads as the inner text "snapping upward" at the end
// of the open animation. A pure opacity fade has no transform at
// all, so the contents simply appear in their final position.
MuiPopover: {
defaultProps: {
disableScrollLock: true,
slots: { transition: Fade },
slotProps: { transition: { timeout: 160 } },
},
},
MuiMenu: {
defaultProps: {
disableScrollLock: true,
slots: { transition: Fade },
slotProps: { transition: { timeout: 160 } },
},
},
MuiModal: {
defaultProps: { disableScrollLock: true },
},
MuiTableContainer: {
styleOverrides: { root: { backgroundColor: M.surface } },
},
MuiTable: {
styleOverrides: {
root: {
backgroundColor: M.surface,
width: "100%",
},
},
},
MuiTableHead: {
styleOverrides: { root: { backgroundColor: M.elevated } },
},
MuiTableRow: {
styleOverrides: {
root: {
transition: "background-color 0.14s ease",
"&:hover": { backgroundColor: `${M.elevated2} !important` },
// Tone down the very last row's bottom border so the table edge
// doesn't read as a hard double line against the Paper outline.
"&:last-of-type td": { borderBottom: 0 },
},
},
},
MuiTableCell: {
styleOverrides: {
root: {
color: M.textPrimary,
borderBottomColor: M.border,
backgroundColor: "transparent",
fontSize: 13.5,
paddingTop: 16,
paddingBottom: 16,
},
sizeSmall: {
paddingTop: 16,
paddingBottom: 16,
},
head: {
fontWeight: 600,
fontSize: 11.5,
letterSpacing: 0.6,
textTransform: "uppercase",
color: M.tableHeadColor,
backgroundColor: M.elevated,
borderBottomColor: M.borderStrong,
// Slimmer header strip — the column-name bar shouldn't take
// as much vertical space as a data row, just enough to read
// the label comfortably.
paddingTop: 8,
paddingBottom: 8,
},
},
},
MuiButton: {
defaultProps: { disableElevation: true },
styleOverrides: {
root: {
textTransform: "none",
borderRadius: 8,
paddingInline: 14,
fontWeight: 500,
transition:
"background-color 0.16s ease, border-color 0.16s ease, transform 0.08s ease",
"&:active": { transform: "translateY(0.5px)" },
},
// Accent-coloured rules below reference `var(--sb-accent)` so the
// emotion class hash stays stable when the user picks a new
// accent. With a stable class, the declared `transition` actually
// animates the colour change instead of being voided by a class
// swap.
containedPrimary: {
backgroundColor: "var(--sb-accent)",
color: accentContrast,
transition:
"background-color 0.32s cubic-bezier(0.4,0,0.2,1), color 0.32s cubic-bezier(0.4,0,0.2,1)",
"&:hover": {
backgroundColor:
"color-mix(in srgb, var(--sb-accent) 92%, transparent)",
},
},
outlinedInherit: { borderColor: M.borderStrong },
},
},
MuiIconButton: {
styleOverrides: {
root: {
borderRadius: 8,
transition: "background-color 0.14s ease, color 0.14s ease",
// Desktop hover tint — gated on a real hover-capable
// pointer AND a desktop-width viewport so it never
// fires on touch devices or in Chrome DevTools' mobile
// preset (which keeps `(hover: hover)` true because
// the host machine still has a mouse, but does shrink
// the viewport, so `(min-width: 600px)` catches it).
// No companion `:active` / `:focus` rule on the mobile
// breakpoint by design: per the user's request the
// button gets zero post-tap visuals on a phone.
"@media (hover: hover) and (min-width: 600px)": {
"&:hover": { backgroundColor: M.iconButtonHover },
},
},
},
},
// Hide MUI's TouchRipple visual on the mobile / touch
// breakpoint for every ButtonBase-derived component
// (IconButton, Button, MenuItem, ListItemButton, …). The
// ripple is the growing circle that, on phones, can outlive
// the touch when the touchend event isn't delivered cleanly
// (drag-off, scroll, sluggish hardware) — that's the halo
// the user kept seeing after every tap. Desktop pointers
// fire mouseup / mouseleave reliably, so the ripple animation
// always completes its exit phase there and we leave it
// alone. The same `(hover: none), (max-width: 599.95px)` pair
// is used everywhere else in the theme so the rule fires
// both on real phones and inside Chrome DevTools'
// mobile-emulation preset.
MuiTouchRipple: {
styleOverrides: {
root: {
"@media (hover: none), (max-width: 599.95px)": {
display: "none",
},
},
},
},
MuiOutlinedInput: {
styleOverrides: {
root: {
backgroundColor: M.inputBg,
"& .MuiOutlinedInput-notchedOutline": {
borderColor: M.border,
transition: "border-color 0.32s cubic-bezier(0.4,0,0.2,1)",
},
"&:hover .MuiOutlinedInput-notchedOutline": { borderColor: M.borderStrong },
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
borderColor: "var(--sb-accent)",
borderWidth: 1,
},
// Suppress the browser's autofill highlight (Firefox paints
// the field with a translucent blue band, Chrome/Safari with
// yellow). The cell's own background already reads as
// "active", so the autofill colour is purely noise.
//
// WebKit (Chrome/Safari) doesn't honour normal
// `background-color` overrides on autofilled inputs — the
// canonical workaround is a giant inset `box-shadow` that
// visually replaces the background, plus `text-fill-color`
// for the foreground colour the engine forces. Firefox uses
// the standard `:autofill` pseudo and respects ordinary
// `background-color` / `filter` overrides since v86.
"& input:-webkit-autofill, & input:-webkit-autofill:hover, & input:-webkit-autofill:focus, & input:-webkit-autofill:active":
{
WebkitBoxShadow: `0 0 0 1000px ${M.surface} inset !important`,
WebkitTextFillColor: `${M.textPrimary} !important`,
caretColor: `${M.textPrimary} !important`,
// The 5000s transition delay keeps the engine from
// re-running the autofill style transition on every
// focus / blur, which would re-paint the highlight.
transition: "background-color 5000s ease-in-out 0s",
},
"& input:autofill": {
backgroundColor: "transparent !important",
filter: "none !important",
color: `${M.textPrimary} !important`,
},
},
},
},
MuiChip: {
styleOverrides: {
root: {
backgroundColor: M.chipBg,
color: M.textPrimary,
fontWeight: 500,
borderRadius: 6,
},
// "Backlit" badge with accent glow — used for selected items in
// multi-select boxes so they stand out without being garish.
colorPrimary: {
backgroundColor:
"color-mix(in srgb, var(--sb-accent) 14%, transparent)",
color: "var(--sb-accent)",
border:
"1px solid color-mix(in srgb, var(--sb-accent) 35%, transparent)",
transition:
"background-color 0.32s cubic-bezier(0.4,0,0.2,1), color 0.32s cubic-bezier(0.4,0,0.2,1), border-color 0.32s cubic-bezier(0.4,0,0.2,1)",
},
colorSuccess: { backgroundColor: alpha("#22c55e", 0.18), color: mode === "light" ? "#15803d" : "#86efac" },
colorWarning: { backgroundColor: alpha("#f59e0b", 0.18), color: mode === "light" ? "#a16207" : "#fcd34d" },
colorError: { backgroundColor: alpha("#ef4444", 0.18), color: mode === "light" ? "#b91c1c" : "#fca5a5" },
},
},
MuiListItemButton: {
styleOverrides: {
root: {
borderRadius: 8,
marginInline: 8,
paddingBlock: 6,
transition: "background-color 0.32s cubic-bezier(0.4,0,0.2,1)",
"&.Mui-selected": {
// Mode-aware accent tint: 16% on dark, 12% on light so the
// selected pill reads cleanly on both surfaces.
backgroundColor: `color-mix(in srgb, var(--sb-accent) ${
mode === "light" ? 12 : 16
}%, transparent)`,
"&:hover": {
backgroundColor:
"color-mix(in srgb, var(--sb-accent) 22%, transparent)",
},
"& .MuiListItemIcon-root": {
color: "var(--sb-accent)",
transition: "color 0.32s cubic-bezier(0.4,0,0.2,1)",
},
"& .MuiListItemText-primary": {
color: mode === "light" ? "#0f172a" : "#fff",
// Visually heavier on the active item without bumping
// `fontWeight`. Different font weights have different glyph
// advance widths, so toggling between 400 and 600 caused a
// ~1px horizontal jump every time the selection changed (or
// re-rendered on hover); painting an extra "stroke" via
// text-shadow gives the same heaviness with zero layout
// impact.
textShadow: "0 0 0.6px currentColor",
},
},
"&:hover": { backgroundColor: M.listItemHover },
},
},
},
MuiListItemIcon: {
styleOverrides: {
root: { minWidth: 36, color: M.textSecondary },
},
},
MuiTooltip: {
defaultProps: {
// See the MuiPopover comment above — Grow's asymmetric scale
// makes tooltip text appear to "snap upward" at the end of the
// open animation. A pure opacity fade has no transform so the
// contents simply appear in their final position.
slots: { transition: Fade },
slotProps: { transition: { timeout: 140 } },
},
styleOverrides: {
tooltip: {
backgroundColor: M.tooltipBg,
color: M.tooltipText,
fontSize: 12,
border: `1px solid ${M.border}`,
},
},
},
MuiAlert: {
styleOverrides: {
root: { borderRadius: 10 },
},
},
},
});
}
export function buildPalette(
modeInput: ThemeMode = DEFAULT_MODE,
): ThemePalette {
const mode: ThemeMode = modeInput === "light" ? "light" : "dark";
const M = colors(mode);
return {
mode,
surface: M.surface,
elevated: M.elevated,
border: M.border,
};
}

View File

@@ -0,0 +1,252 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useState,
type MouseEvent as ReactMouseEvent,
type ReactNode,
} from "react";
import { flushSync } from "react-dom";
import { CssBaseline, ThemeProvider } from "@mui/material";
import {
DEFAULT_ACCENT,
DEFAULT_MODE,
buildPalette,
buildTheme,
isHexColor,
pickContrastingTextColor,
type ThemeMode,
type ThemePalette,
} from "../theme";
const ACCENT_KEY = "sing-box-admin:accent";
const MODE_KEY = "sing-box-admin:mode";
type ThemeOrigin = { clientX: number; clientY: number } | ReactMouseEvent | MouseEvent;
interface AccentContextValue {
accent: string;
setAccent: (color: string) => void;
resetAccent: () => void;
mode: ThemeMode;
setMode: (mode: ThemeMode, origin?: ThemeOrigin) => void;
toggleMode: (origin?: ThemeOrigin) => void;
palette: ThemePalette;
}
const AccentContext = createContext<AccentContextValue | undefined>(undefined);
function loadStoredAccent(): string {
try {
const raw = localStorage.getItem(ACCENT_KEY);
if (raw && isHexColor(raw)) return raw;
} catch {
/* ignore */
}
return DEFAULT_ACCENT;
}
function loadStoredMode(): ThemeMode {
try {
const raw = localStorage.getItem(MODE_KEY);
if (raw === "light" || raw === "dark") return raw;
} catch {
/* ignore */
}
return DEFAULT_MODE;
}
// AppThemeProvider owns the accent color + theme mode, persists both
// across reloads, and rebuilds the MUI theme on every change so the
// whole UI updates instantly.
export function AppThemeProvider({ children }: { children: ReactNode }) {
const [accent, setAccentState] = useState<string>(() => loadStoredAccent());
const [mode, setModeState] = useState<ThemeMode>(() => loadStoredMode());
// Wrap a state update in a circular-reveal animation that radiates from
// the click point (when supported) using the View Transitions API.
// Fallbacks: prefers-reduced-motion → no animation; older browsers without
// `document.startViewTransition` also fall back to instant state change.
const runWithTransition = useCallback((apply: () => void, origin?: ThemeOrigin) => {
const root = document.documentElement;
const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
type ViewTransitionDoc = Document & {
startViewTransition?: (cb: () => void) => unknown;
};
const docVT = document as ViewTransitionDoc;
if (reduced || typeof docVT.startViewTransition !== "function") {
apply();
return;
}
// Default to the screen centre when no event is supplied (e.g. keyboard
// toggling). Compute the maximum radius so the circle always reaches the
// furthest corner regardless of click position.
const w = window.innerWidth;
const h = window.innerHeight;
const cx = origin?.clientX ?? w / 2;
const cy = origin?.clientY ?? h / 2;
const r = Math.hypot(Math.max(cx, w - cx), Math.max(cy, h - cy));
root.style.setProperty("--theme-cx", `${cx}px`);
root.style.setProperty("--theme-cy", `${cy}px`);
root.style.setProperty("--theme-r", `${r}px`);
docVT.startViewTransition(() => {
// flushSync forces React to commit the new theme synchronously inside
// the snapshot callback; otherwise the API would capture the old DOM.
flushSync(apply);
});
}, []);
const persistMode = useCallback((next: ThemeMode) => {
try {
localStorage.setItem(MODE_KEY, next);
} catch {
/* ignore */
}
}, []);
const setMode = useCallback(
(next: ThemeMode, origin?: ThemeOrigin) => {
if (next !== "light" && next !== "dark") return;
runWithTransition(() => {
setModeState(next);
persistMode(next);
}, origin);
},
[runWithTransition, persistMode],
);
const toggleMode = useCallback(
(origin?: ThemeOrigin) => {
runWithTransition(() => {
setModeState((prev) => {
const next: ThemeMode = prev === "light" ? "dark" : "light";
persistMode(next);
return next;
});
}, origin);
},
[runWithTransition, persistMode],
);
const setAccent = useCallback((color: string) => {
if (!isHexColor(color)) return;
setAccentState(color);
try {
localStorage.setItem(ACCENT_KEY, color);
} catch {
/* ignore */
}
}, []);
const resetAccent = useCallback(() => {
setAccentState(DEFAULT_ACCENT);
try {
localStorage.removeItem(ACCENT_KEY);
} catch {
/* ignore */
}
}, []);
// Accent colour is exposed as a CSS variable on <html>. Specific MUI
// components in the theme reference `var(--sb-accent)` instead of the
// raw hex, which keeps their emotion class hash stable when the accent
// changes — so a CSS `transition` declared on those rules animates
// smoothly from the old colour to the new one.
//
// We use `useLayoutEffect` instead of `useEffect` so the variable is
// applied before the browser paints; otherwise the very first frame
// would render with no `--sb-accent` set and accent elements would
// briefly flash unstyled before the effect ran.
useLayoutEffect(() => {
const root = document.documentElement;
root.style.setProperty("--sb-accent", accent);
// Companion contrast colour (black or white, picked by relative
// luminance) for foreground content layered on top of the accent —
// the "+" create IconButton on the CrudPage toolbar reads this var
// to keep its glyph readable on accents of any luminance. Without
// setting it here the IconButton's `var(--sb-accent-contrast,
// #ffffff)` always fell back to white, so a light/yellow accent
// produced an almost-invisible glyph that "didn't change with the
// theme".
root.style.setProperty("--sb-accent-contrast", pickContrastingTextColor(accent));
}, [accent]);
// The inline FOUC script in `index.html` stamps an initial
// `background-color` and `color-scheme` onto the <html> element so
// the page doesn't paint white before the bundle loads. Inline
// styles win over MUI's CssBaseline, so we have to keep those
// properties in sync here whenever the mode flips — otherwise
// `<html>` keeps the page-load colour and the document looks
// half-themed (background stuck on the old tone, text/buttons
// updated, etc.).
//
// The same FOUC script also sets inline `background-color` + `color`
// on <body> (so the very first paint after reload is fully themed),
// and inline styles win over the emotion class CssBaseline injects.
// If we don't refresh those here too, `body.color` stays at the
// page-load value forever, which makes every `color="inherit"`
// component (Layout's "Sign out", CrudPage's "Filters" / "Clear",
// the colour-picker button) read with stale text colour after a
// theme toggle — the labels then "snap back" to the correct colour
// only on full reload, which is exactly what was reported.
useLayoutEffect(() => {
const root = document.documentElement;
const bg = mode === "light" ? "#ffffff" : "#141414";
const fg = mode === "light" ? "#0f172a" : "#f5f5f5";
root.style.backgroundColor = bg;
root.style.colorScheme = mode;
if (document.body) {
document.body.style.backgroundColor = bg;
document.body.style.color = fg;
}
// <meta name="theme-color"> drives the mobile browser chrome /
// installed-PWA window colour. We update it from the same effect
// that sets the document background so the two values can never
// disagree (a previous arrangement had a separate `useEffect` on
// `palette` re-writing the meta a frame later, which produced a
// brief mismatched flash on mode toggle).
const meta = document.querySelector<HTMLMetaElement>(
'meta[name="theme-color"]',
);
if (meta) meta.content = bg;
}, [mode]);
// Sync between tabs.
useEffect(() => {
const handler = (e: StorageEvent) => {
if (e.key === ACCENT_KEY) setAccentState(loadStoredAccent());
if (e.key === MODE_KEY) setModeState(loadStoredMode());
};
window.addEventListener("storage", handler);
return () => window.removeEventListener("storage", handler);
}, []);
const theme = useMemo(() => buildTheme(accent, mode), [accent, mode]);
// The exposed palette only carries mode-derived tokens (surface /
// elevated / border), so it doesn't need to invalidate on accent
// changes — accent flows through `var(--sb-accent)` instead.
const palette = useMemo(() => buildPalette(mode), [mode]);
const value = useMemo<AccentContextValue>(
() => ({ accent, setAccent, resetAccent, mode, setMode, toggleMode, palette }),
[accent, setAccent, resetAccent, mode, setMode, toggleMode, palette],
);
return (
<AccentContext.Provider value={value}>
<ThemeProvider theme={theme}>
<CssBaseline />
{children}
</ThemeProvider>
</AccentContext.Provider>
);
}
export function useAccent(): AccentContextValue {
const ctx = useContext(AccentContext);
if (!ctx) throw new Error("useAccent must be used within AppThemeProvider");
return ctx;
}

View File

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

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true
},
"include": ["src"]
}

View File

@@ -0,0 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/theme.ts","./src/vite-env.d.ts","./src/api/client.ts","./src/api/types.ts","./src/auth/AuthContext.tsx","./src/components/ColorPickerButton.tsx","./src/components/CopyableId.tsx","./src/components/CrudPage.tsx","./src/components/Layout.tsx","./src/components/LoginBackdropMesh.tsx","./src/components/LoginThemeControls.tsx","./src/components/PageHeader.tsx","./src/notifications/NotificationsProvider.tsx","./src/pages/BandwidthLimitersPage.tsx","./src/pages/ConnectionLimitersPage.tsx","./src/pages/DashboardPage.tsx","./src/pages/LoginPage.tsx","./src/pages/NodesPage.tsx","./src/pages/RateLimitersPage.tsx","./src/pages/SquadsPage.tsx","./src/pages/TrafficLimitersPage.tsx","./src/pages/UsersPage.tsx","./src/pages/squadField.tsx","./src/theme/AppThemeProvider.tsx"],"version":"5.9.3"}

View File

@@ -0,0 +1,64 @@
import { defineConfig, type Plugin } from "vite";
import react from "@vitejs/plugin-react";
// preloadInterFonts injects `<link rel="preload">` tags into the
// production index.html for the latin subset of every Inter weight
// the SPA imports (400/500/600/700). The font files come from
// `@fontsource/inter` and are hashed by Vite into `dist/assets/`,
// so the filenames aren't known until after the bundle is emitted —
// hence a `transformIndexHtml` hook scoped to build mode that reads
// `ctx.bundle` and matches the emitted woff2 names.
//
// Why latin only: the admin panel UI is English-only ASCII, so the
// latin subset is what the very first paint actually needs. Preload-
// ing every fontsource subset (latin, latin-ext, cyrillic, cyrillic-
// ext, greek, greek-ext × 4 weights = 24 files) would just waste
// bandwidth and starve more critical resources of priority. If the
// UI ever needs to render non-ASCII glyphs at first paint, this list
// can be extended.
function preloadInterFonts(): Plugin {
const WEIGHTS = ["400", "500", "600", "700"] as const;
return {
name: "preload-inter-fonts",
apply: "build",
transformIndexHtml: {
order: "post",
handler(html, ctx) {
if (!ctx.bundle) return html;
const tags: string[] = [];
for (const weight of WEIGHTS) {
const re = new RegExp(`inter-latin-${weight}-normal-[^/]+\\.woff2$`);
const fileName = Object.keys(ctx.bundle).find((n) => re.test(n));
if (!fileName) continue;
// `base: "/"` is used in this config (so deep BrowserRouter
// URLs like `/users` resolve `assets/...` from the site root
// rather than relative to the current path). Emit matching
// `/` prefixes here so the preload hits the exact same URL
// the browser would otherwise request from the fontsource
// @font-face rule, deduplicating the fetch instead of
// doubling it.
tags.push(
`<link rel="preload" href="/${fileName}" as="font" type="font/woff2" crossorigin>`,
);
}
if (tags.length === 0) return html;
return html.replace("</head>", ` ${tags.join("\n ")}\n </head>`);
},
},
};
}
export default defineConfig({
plugins: [react(), preloadInterFonts()],
// Absolute base so the SPA's asset URLs resolve from the site root.
// The Go backend serves index.html for every unknown path (BrowserRouter
// deep-link fallback); with a relative base, navigating directly to e.g.
// `/users` would make the embedded `./assets/index-XYZ.js` URL resolve
// to `/assets/index-XYZ.js` only when sitting at the root — at any
// multi-segment path it would resolve relative to that path and miss.
base: "/",
build: {
target: "es2020",
chunkSizeWarningLimit: 1500,
},
});

View File

@@ -9,6 +9,14 @@ type Squad struct {
UpdatedAt time.Time `json:"updated_at" validate:"required"`
}
type DeletedSquad struct {
Squad Squad
OrphanedNodeUUIDs []string
OrphanedConnectionLimiterIDs []int
OrphanedTrafficLimiterIDs []int
SurvivingNodeUUIDs []string
}
type SquadCreate struct {
Name string `json:"name" validate:"required"`
}
@@ -20,7 +28,7 @@ type SquadUpdate struct {
type Node struct {
UUID string `json:"uuid" validate:"required,uuid4"`
Name string `json:"name" validate:"required"`
SquadIDs []int `json:"squad_ids" validate:"required"`
SquadIDs []int `json:"squad_ids" validate:"required,min=1"`
CreatedAt time.Time `json:"created_at" validate:"required"`
UpdatedAt time.Time `json:"updated_at" validate:"required"`
}
@@ -28,7 +36,7 @@ type Node struct {
type NodeCreate struct {
UUID string `json:"uuid" validate:"required,uuid4"`
Name string `json:"name" validate:"required"`
SquadIDs []int `json:"squad_ids" validate:"required"`
SquadIDs []int `json:"squad_ids" validate:"required,min=1"`
}
type NodeUpdate struct {
@@ -42,10 +50,10 @@ type BaseNode struct {
type User struct {
ID int `json:"id" validate:"required"`
SquadIDs []int `json:"squad_ids" validate:"required"`
SquadIDs []int `json:"squad_ids" validate:"required,min=1"`
Username string `json:"username" validate:"required"`
Type string `json:"type" validate:"required"`
Inbound string `json:"inbound" validate:"required"`
Type string `json:"type" validate:"required"`
UUID string `json:"uuid" validate:"required"`
Password string `json:"password" validate:"required"`
Secret string `json:"secret" validate:"required"`
@@ -56,10 +64,10 @@ type User struct {
}
type UserCreate struct {
SquadIDs []int `json:"squad_ids" validate:"required"`
SquadIDs []int `json:"squad_ids" validate:"required,min=1"`
Username string `json:"username" validate:"required"`
Type string `json:"type" validate:"required,oneof=hysteria hysteria2 mtproxy trojan tuic vless vmess"`
Inbound string `json:"inbound" validate:"required"`
Type string `json:"type" validate:"required,oneof=hysteria hysteria2 mtproxy trojan tuic vless vmess"`
UUID string `json:"uuid" validate:"omitempty,uuid4"`
Password string `json:"password" validate:"omitempty"`
Secret string `json:"secret" validate:"omitempty"`
@@ -85,53 +93,54 @@ type BaseUser struct {
type ConnectionLimiter struct {
ID int `json:"id" validate:"required"`
SquadIDs []int `json:"squad_ids" validate:"required"`
Username string `json:"username" validate:"required"`
SquadIDs []int `json:"squad_ids" validate:"required,min=1"`
Username string `json:"username" validate:"omitempty"`
Outbound string `json:"outbound" validate:"required"`
Strategy string `json:"strategy" validate:"required,oneof=connection"`
ConnectionType string `json:"connection_type" validate:"omitempty,oneof=hwid mux ip"`
LockType string `json:"lock_type" validate:"omitempty,oneof=manager"`
Strategy string `json:"strategy" validate:"required,oneof=connection bypass"`
ConnectionType string `json:"connection_type" validate:"omitempty,oneof=default hwid mux ip"`
LockType string `json:"lock_type" validate:"required,oneof=manager default"`
Count uint32 `json:"count" validate:"required"`
CreatedAt time.Time `json:"created_at" validate:"required"`
UpdatedAt time.Time `json:"updated_at" validate:"required"`
}
type ConnectionLimiterCreate struct {
SquadIDs []int `json:"squad_ids" validate:"required"`
Username string `json:"username" validate:"required"`
SquadIDs []int `json:"squad_ids" validate:"required,min=1"`
Username string `json:"username" validate:"omitempty"`
Outbound string `json:"outbound" validate:"required"`
Strategy string `json:"strategy" validate:"required,oneof=connection"`
ConnectionType string `json:"type" validate:"omitempty,oneof=hwid mux ip"`
LockType string `json:"lock_type" validate:"omitempty,oneof=manager"`
Count uint32 `json:"count" validate:"required"`
Strategy string `json:"strategy" validate:"required,oneof=connection bypass"`
ConnectionType string `json:"connection_type" validate:"excluded_if=Strategy bypass,omitempty,oneof=default hwid mux ip"`
LockType string `json:"lock_type" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass,omitempty,oneof=manager default"`
Count uint32 `json:"count" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass"`
}
type ConnectionLimiterUpdate struct {
Username string `json:"username" validate:"required"`
Username string `json:"username" validate:"omitempty"`
Outbound string `json:"outbound" validate:"required"`
Strategy string `json:"strategy" validate:"required,oneof=connection"`
ConnectionType string `json:"type" validate:"omitempty,oneof=hwid mux ip"`
LockType string `json:"lock_type" validate:"omitempty,oneof=manager"`
Count uint32 `json:"count" validate:"required"`
Strategy string `json:"strategy" validate:"required,oneof=connection bypass"`
ConnectionType string `json:"connection_type" validate:"excluded_if=Strategy bypass,omitempty,oneof=default hwid mux ip"`
LockType string `json:"lock_type" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass,omitempty,oneof=manager default"`
Count uint32 `json:"count" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass"`
}
type BaseConnectionLimiter struct {
Username string `json:"username" validate:"required"`
Username string `json:"username" validate:"omitempty"`
Outbound string `json:"outbound" validate:"required"`
Strategy string `json:"strategy" validate:"required,oneof=connection"`
ConnectionType string `json:"type" validate:"omitempty,oneof=hwid mux ip"`
LockType string `json:"lock_type" validate:"omitempty,oneof=manager"`
Count uint32 `json:"count" validate:"required"`
Strategy string `json:"strategy" validate:"required,oneof=connection bypass"`
ConnectionType string `json:"connection_type" validate:"excluded_if=Strategy bypass,omitempty,oneof=default hwid mux ip"`
LockType string `json:"lock_type" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass,omitempty,oneof=manager default"`
Count uint32 `json:"count" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass"`
}
type BandwidthLimiter struct {
ID int `json:"id" validate:"required"`
SquadIDs []int `json:"squad_ids" validate:"required"`
Username string `json:"username" validate:"required"`
SquadIDs []int `json:"squad_ids" validate:"required,min=1"`
Username string `json:"username" validate:"omitempty"`
Outbound string `json:"outbound" validate:"required"`
Strategy string `json:"strategy" validate:"required"`
Mode string `json:"mode" validate:"required"`
ConnectionType string `json:"connection_type" validate:"omitempty"`
Mode string `json:"mode" validate:"required"`
FlowKeys []string `json:"flow_keys" validate:"omitempty,dive,oneof=user destination ip hwid mux"`
Speed string `json:"speed" validate:"required"`
RawSpeed uint64 `json:"raw_speed" validate:"required"`
CreatedAt time.Time `json:"created_at" validate:"required"`
@@ -139,30 +148,116 @@ type BandwidthLimiter struct {
}
type BandwidthLimiterCreate struct {
SquadIDs []int `json:"squad_ids" validate:"required"`
Username string `json:"username" validate:"required"`
Outbound string `json:"outbound" validate:"required"`
Strategy string `json:"strategy" validate:"required,oneof=global connection"`
Mode string `json:"mode" validate:"required"`
ConnectionType string `json:"connection_type" validate:"omitempty"`
Speed string `json:"speed" validate:"required"`
SquadIDs []int `json:"squad_ids" validate:"required,min=1"`
Username string `json:"username" validate:"omitempty"`
Outbound string `json:"outbound" validate:"required"`
Strategy string `json:"strategy" validate:"required,oneof=global connection bypass"`
ConnectionType string `json:"connection_type" validate:"excluded_if=Strategy bypass,omitempty"`
Mode string `json:"mode" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass"`
FlowKeys []string `json:"flow_keys" validate:"excluded_if=Strategy bypass,omitempty,dive,oneof=user destination ip hwid mux"`
Speed string `json:"speed" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass"`
}
type BandwidthLimiterUpdate struct {
Username string `json:"username" validate:"required"`
Outbound string `json:"outbound" validate:"required"`
Strategy string `json:"strategy" validate:"required,oneof=global connection"`
Mode string `json:"mode" validate:"required"`
ConnectionType string `json:"connection_type" validate:"omitempty"`
Speed string `json:"speed" validate:"required"`
Username string `json:"username" validate:"omitempty"`
Outbound string `json:"outbound" validate:"required"`
Strategy string `json:"strategy" validate:"required,oneof=global connection bypass"`
ConnectionType string `json:"connection_type" validate:"excluded_if=Strategy bypass,omitempty"`
Mode string `json:"mode" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass"`
FlowKeys []string `json:"flow_keys" validate:"excluded_if=Strategy bypass,omitempty,dive,oneof=user destination ip hwid mux"`
Speed string `json:"speed" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass"`
}
type BaseBandwidthLimiter struct {
Username string `json:"username" validate:"required"`
Outbound string `json:"outbound" validate:"required"`
Strategy string `json:"strategy" validate:"required,oneof=global connection"`
Mode string `json:"mode" validate:"required"`
ConnectionType string `json:"connection_type" validate:"omitempty"`
Speed string `json:"speed" validate:"required"`
RawSpeed uint64 `json:"raw_speed" validate:"required"`
Username string `json:"username" validate:"omitempty"`
Outbound string `json:"outbound" validate:"required"`
Strategy string `json:"strategy" validate:"required,oneof=global connection bypass"`
ConnectionType string `json:"connection_type" validate:"excluded_if=Strategy bypass,omitempty"`
Mode string `json:"mode" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass"`
FlowKeys []string `json:"flow_keys" validate:"excluded_if=Strategy bypass,omitempty,dive,oneof=user destination ip hwid mux"`
Speed string `json:"speed" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass"`
RawSpeed uint64 `json:"raw_speed" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass"`
}
type TrafficLimiter struct {
ID int `json:"id" validate:"required"`
SquadIDs []int `json:"squad_ids" validate:"required,min=1"`
Username string `json:"username" validate:"omitempty"`
Outbound string `json:"outbound" validate:"required"`
Strategy string `json:"strategy" validate:"required,oneof=global bypass"`
Mode string `json:"mode" validate:"required"`
RawUsed uint64 `json:"raw_used" validate:"required"`
Quota string `json:"quota" validate:"required"`
RawQuota uint64 `json:"raw_quota" validate:"required"`
Usage uint8 `json:"usage"`
CreatedAt time.Time `json:"created_at" validate:"required"`
UpdatedAt time.Time `json:"updated_at" validate:"required"`
}
type TrafficLimiterCreate struct {
SquadIDs []int `json:"squad_ids" validate:"required,min=1"`
Username string `json:"username" validate:"omitempty"`
Outbound string `json:"outbound" validate:"required"`
Strategy string `json:"strategy" validate:"required,oneof=global bypass"`
Mode string `json:"mode" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass"`
Quota string `json:"quota" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass"`
}
type TrafficLimiterUpdate struct {
Username string `json:"username" validate:"omitempty"`
Outbound string `json:"outbound" validate:"required"`
Strategy string `json:"strategy" validate:"required,oneof=global bypass"`
Mode string `json:"mode" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass"`
Quota string `json:"quota" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass"`
}
type BaseTrafficLimiter struct {
Username string `json:"username" validate:"omitempty"`
Outbound string `json:"outbound" validate:"required"`
Strategy string `json:"strategy" validate:"required,oneof=global bypass"`
Mode string `json:"mode" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass"`
RawUsed uint64 `json:"raw_used" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass"`
Quota string `json:"quota" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass"`
RawQuota uint64 `json:"raw_quota" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass"`
}
type RateLimiter struct {
ID int `json:"id" validate:"required"`
SquadIDs []int `json:"squad_ids" validate:"required,min=1"`
Username string `json:"username" validate:"omitempty"`
Outbound string `json:"outbound" validate:"required"`
Strategy string `json:"strategy" validate:"required,oneof=fixed_window sliding_window token_bucket leaky_bucket bypass"`
ConnectionType string `json:"connection_type" validate:"required,oneof=hwid mux ip default"`
Count uint32 `json:"count" validate:"required"`
Interval string `json:"interval" validate:"required"`
CreatedAt time.Time `json:"created_at" validate:"required"`
UpdatedAt time.Time `json:"updated_at" validate:"required"`
}
type RateLimiterCreate struct {
SquadIDs []int `json:"squad_ids" validate:"required,min=1"`
Username string `json:"username" validate:"omitempty"`
Outbound string `json:"outbound" validate:"required"`
Strategy string `json:"strategy" validate:"required,oneof=fixed_window sliding_window token_bucket leaky_bucket bypass"`
ConnectionType string `json:"connection_type" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass,omitempty,oneof=hwid mux ip default"`
Count uint32 `json:"count" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass"`
Interval string `json:"interval" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass"`
}
type RateLimiterUpdate struct {
Username string `json:"username" validate:"omitempty"`
Outbound string `json:"outbound" validate:"required"`
Strategy string `json:"strategy" validate:"required,oneof=fixed_window sliding_window token_bucket leaky_bucket bypass"`
ConnectionType string `json:"connection_type" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass,omitempty,oneof=hwid mux ip default"`
Count uint32 `json:"count" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass"`
Interval string `json:"interval" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass"`
}
type BaseRateLimiter struct {
Username string `json:"username" validate:"omitempty"`
Outbound string `json:"outbound" validate:"required"`
Strategy string `json:"strategy" validate:"required,oneof=fixed_window sliding_window token_bucket leaky_bucket bypass"`
ConnectionType string `json:"connection_type" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass,omitempty,oneof=hwid mux ip default"`
Count uint32 `json:"count" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass"`
Interval string `json:"interval" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass"`
}

View File

@@ -1,15 +1,6 @@
package constant
type NodeManager interface {
AddNode(id string, node ConnectedNode) error
AcquireLock(limiterId int, id string) (string, error)
RefreshLock(limiterId int, id string, handleId string) error
ReleaseLock(limiterId int, id string, handleId string) error
}
type Manager interface {
NodeManager
CreateSquad(user SquadCreate) (Squad, error)
GetSquads(filters map[string][]string) ([]Squad, error)
GetSquadsCount(filters map[string][]string) (int, error)
@@ -21,7 +12,7 @@ type Manager interface {
GetNodes(filters map[string][]string) ([]Node, error)
GetNodesCount(filters map[string][]string) (int, error)
GetNode(uuid string) (Node, error)
GetNodeStatus(uuid string) string
GetNodeStatus(uuid string) (string, error)
UpdateNode(uuid string, node NodeUpdate) (Node, error)
DeleteNode(uuid string) (Node, error)
@@ -39,10 +30,35 @@ type Manager interface {
UpdateBandwidthLimiter(id int, limiter BandwidthLimiterUpdate) (BandwidthLimiter, error)
DeleteBandwidthLimiter(id int) (BandwidthLimiter, error)
CreateTrafficLimiter(limiter TrafficLimiterCreate) (TrafficLimiter, error)
GetTrafficLimiters(filters map[string][]string) ([]TrafficLimiter, error)
GetTrafficLimitersCount(filters map[string][]string) (int, error)
GetTrafficLimiter(id int) (TrafficLimiter, error)
UpdateTrafficLimiter(id int, limiter TrafficLimiterUpdate) (TrafficLimiter, error)
UpdateTrafficLimiterUsed(id int, used uint64) (TrafficLimiter, error)
DeleteTrafficLimiter(id int) (TrafficLimiter, error)
CreateConnectionLimiter(limiter ConnectionLimiterCreate) (ConnectionLimiter, error)
GetConnectionLimiters(filters map[string][]string) ([]ConnectionLimiter, error)
GetConnectionLimitersCount(filters map[string][]string) (int, error)
GetConnectionLimiter(id int) (ConnectionLimiter, error)
UpdateConnectionLimiter(id int, limiter ConnectionLimiterUpdate) (ConnectionLimiter, error)
DeleteConnectionLimiter(id int) (ConnectionLimiter, error)
CreateRateLimiter(limiter RateLimiterCreate) (RateLimiter, error)
GetRateLimiters(filters map[string][]string) ([]RateLimiter, error)
GetRateLimitersCount(filters map[string][]string) (int, error)
GetRateLimiter(id int) (RateLimiter, error)
UpdateRateLimiter(id int, limiter RateLimiterUpdate) (RateLimiter, error)
DeleteRateLimiter(id int) (RateLimiter, error)
}
type NodeManager interface {
AddNode(id string, node ConnectedNode) error
AcquireLock(limiterId int, id string) (string, error)
RefreshLock(limiterId int, id string, handleId string) error
ReleaseLock(limiterId int, id string, handleId string) error
AddTrafficUsage(limiterId int, n uint64) (uint64, error)
}

View File

@@ -13,6 +13,14 @@ type ConnectedNode interface {
UpdateBandwidthLimiters(limiter []BandwidthLimiter)
DeleteBandwidthLimiter(limiter BandwidthLimiter)
UpdateTrafficLimiter(limiter TrafficLimiter)
UpdateTrafficLimiters(limiter []TrafficLimiter)
DeleteTrafficLimiter(limiter TrafficLimiter)
UpdateRateLimiter(limiter RateLimiter)
UpdateRateLimiters(limiter []RateLimiter)
DeleteRateLimiter(limiter RateLimiter)
IsLocal() bool
IsOnline() bool

View File

@@ -6,7 +6,7 @@ type Repository interface {
GetSquadsCount(filters map[string][]string) (int, error)
GetSquad(id int) (Squad, error)
UpdateSquad(id int, user SquadUpdate) (Squad, error)
DeleteSquad(id int) (Squad, error)
DeleteSquad(id int) (DeletedSquad, error)
CreateNode(node NodeCreate) (Node, error)
GetNodes(filters map[string][]string) ([]Node, error)
@@ -35,4 +35,19 @@ type Repository interface {
GetBandwidthLimiter(id int) (BandwidthLimiter, error)
UpdateBandwidthLimiter(id int, limiter BandwidthLimiterUpdate) (BandwidthLimiter, error)
DeleteBandwidthLimiter(id int) (BandwidthLimiter, error)
CreateTrafficLimiter(limiter TrafficLimiterCreate) (TrafficLimiter, error)
GetTrafficLimiters(filters map[string][]string) ([]TrafficLimiter, error)
GetTrafficLimitersCount(filters map[string][]string) (int, error)
GetTrafficLimiter(id int) (TrafficLimiter, error)
UpdateTrafficLimiter(id int, limiter TrafficLimiterUpdate) (TrafficLimiter, error)
UpdateTrafficLimiterUsed(id int, used uint64) (TrafficLimiter, error)
DeleteTrafficLimiter(id int) (TrafficLimiter, error)
CreateRateLimiter(limiter RateLimiterCreate) (RateLimiter, error)
GetRateLimiters(filters map[string][]string) ([]RateLimiter, error)
GetRateLimitersCount(filters map[string][]string) (int, error)
GetRateLimiter(id int) (RateLimiter, error)
UpdateRateLimiter(id int, limiter RateLimiterUpdate) (RateLimiter, error)
DeleteRateLimiter(id int) (RateLimiter, error)
}

View File

@@ -5,7 +5,7 @@ import (
"strconv"
"github.com/huandu/go-sqlbuilder"
"github.com/sagernet/sing/common/byteformats"
"github.com/sagernet/sing-box/common/byteformats"
)
type Filter func(sb *sqlbuilder.SelectBuilder, value []string) error
@@ -96,6 +96,20 @@ func ExistsAndWhereInFilter(subquery *sqlbuilder.SelectBuilder, field string) Fi
}
}
func InFilter(field string) Filter {
return func(sb *sqlbuilder.SelectBuilder, value []string) error {
if len(value) == 0 {
return nil
}
values := make([]interface{}, len(value))
for i, v := range value {
values[i] = v
}
sb.Where(sb.In(field, values...))
return nil
}
}
func SortAscFilter() Filter {
return func(sb *sqlbuilder.SelectBuilder, value []string) error {
sb.OrderByAsc(value[0])

View File

@@ -36,8 +36,8 @@ var migrations = map[string]string{
id SERIAL PRIMARY KEY,
node_uuid VARCHAR(36),
username TEXT NOT NULL,
type TEXT NOT NULL,
inbound TEXT NOT NULL,
type TEXT NOT NULL,
uuid TEXT NOT NULL,
password TEXT NOT NULL,
secret TEXT NOT NULL,
@@ -100,11 +100,236 @@ var migrations = map[string]string{
);
`,
"1_initialize_schema.down.sql": `
DROP TABLE IF EXISTS squas;
DROP TABLE IF EXISTS nodes;
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS bandwidth_limiter_to_squad;
DROP TABLE IF EXISTS bandwidth_limiters;
DROP TABLE IF EXISTS connection_limiter_to_squad;
DROP TABLE IF EXISTS connection_limiters;
DROP TABLE IF EXISTS user_to_squad;
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS node_to_squad;
DROP TABLE IF EXISTS nodes;
DROP TABLE IF EXISTS squads;
`,
"2_init_limiters_and_indexes.up.sql": `
CREATE TABLE traffic_limiters (
id SERIAL PRIMARY KEY,
username TEXT NOT NULL,
outbound TEXT NOT NULL,
strategy TEXT NOT NULL,
mode TEXT NOT NULL,
raw_used BIGINT NOT NULL DEFAULT 0,
quota TEXT NOT NULL,
raw_quota BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
UNIQUE (username, outbound)
);
CREATE TABLE traffic_limiter_to_squad (
traffic_limiter_id INTEGER NOT NULL,
squad_id INTEGER NOT NULL,
PRIMARY KEY (traffic_limiter_id, squad_id),
FOREIGN KEY (traffic_limiter_id) REFERENCES traffic_limiters(id) ON DELETE CASCADE,
FOREIGN KEY (squad_id) REFERENCES squads(id) ON DELETE CASCADE
);
CREATE TABLE rate_limiters (
id SERIAL PRIMARY KEY,
username TEXT NOT NULL,
outbound TEXT NOT NULL,
strategy TEXT NOT NULL,
connection_type TEXT NOT NULL,
count INTEGER NOT NULL,
interval TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
UNIQUE (username, outbound)
);
CREATE TABLE rate_limiter_to_squad (
rate_limiter_id INTEGER NOT NULL,
squad_id INTEGER NOT NULL,
PRIMARY KEY (rate_limiter_id, squad_id),
FOREIGN KEY (rate_limiter_id) REFERENCES rate_limiters(id) ON DELETE CASCADE,
FOREIGN KEY (squad_id) REFERENCES squads(id) ON DELETE CASCADE
);
ALTER TABLE bandwidth_limiters ADD COLUMN flow_keys JSONB NOT NULL DEFAULT '[]'::jsonb;
UPDATE connection_limiters SET lock_type = 'default' WHERE lock_type = '';
ALTER TABLE node_to_squad
DROP CONSTRAINT node_to_squad_squad_id_fkey,
ADD CONSTRAINT node_to_squad_squad_id_fkey
FOREIGN KEY (squad_id) REFERENCES squads(id) ON DELETE CASCADE;
ALTER TABLE user_to_squad
DROP CONSTRAINT user_to_squad_squad_id_fkey,
ADD CONSTRAINT user_to_squad_squad_id_fkey
FOREIGN KEY (squad_id) REFERENCES squads(id) ON DELETE CASCADE;
ALTER TABLE connection_limiter_to_squad
DROP CONSTRAINT connection_limiter_to_squad_squad_id_fkey,
ADD CONSTRAINT connection_limiter_to_squad_squad_id_fkey
FOREIGN KEY (squad_id) REFERENCES squads(id) ON DELETE CASCADE;
ALTER TABLE bandwidth_limiter_to_squad
DROP CONSTRAINT bandwidth_limiter_to_squad_squad_id_fkey,
ADD CONSTRAINT bandwidth_limiter_to_squad_squad_id_fkey
FOREIGN KEY (squad_id) REFERENCES squads(id) ON DELETE CASCADE;
CREATE INDEX idx_squads_created_at ON squads(created_at);
CREATE INDEX idx_squads_updated_at ON squads(updated_at);
CREATE INDEX idx_nodes_created_at ON nodes(created_at);
CREATE INDEX idx_nodes_updated_at ON nodes(updated_at);
CREATE INDEX idx_node_to_squad_squad_id ON node_to_squad(squad_id);
CREATE INDEX idx_users_node_uuid ON users(node_uuid);
CREATE INDEX idx_users_inbound ON users(inbound);
CREATE INDEX idx_users_type ON users(type);
CREATE INDEX idx_users_created_at ON users(created_at);
CREATE INDEX idx_users_updated_at ON users(updated_at);
CREATE INDEX idx_user_to_squad_squad_id ON user_to_squad(squad_id);
CREATE INDEX idx_connection_limiters_outbound ON connection_limiters(outbound);
CREATE INDEX idx_connection_limiters_strategy ON connection_limiters(strategy);
CREATE INDEX idx_connection_limiters_connection_type ON connection_limiters(connection_type);
CREATE INDEX idx_connection_limiters_lock_type ON connection_limiters(lock_type);
CREATE INDEX idx_connection_limiters_created_at ON connection_limiters(created_at);
CREATE INDEX idx_connection_limiters_updated_at ON connection_limiters(updated_at);
CREATE INDEX idx_connection_limiter_to_squad_squad_id ON connection_limiter_to_squad(squad_id);
CREATE INDEX idx_bandwidth_limiters_outbound ON bandwidth_limiters(outbound);
CREATE INDEX idx_bandwidth_limiters_strategy ON bandwidth_limiters(strategy);
CREATE INDEX idx_bandwidth_limiters_mode ON bandwidth_limiters(mode);
CREATE INDEX idx_bandwidth_limiters_connection_type ON bandwidth_limiters(connection_type);
CREATE INDEX idx_bandwidth_limiters_raw_speed ON bandwidth_limiters(raw_speed);
CREATE INDEX idx_bandwidth_limiters_created_at ON bandwidth_limiters(created_at);
CREATE INDEX idx_bandwidth_limiters_updated_at ON bandwidth_limiters(updated_at);
CREATE INDEX idx_bandwidth_limiter_to_squad_squad_id ON bandwidth_limiter_to_squad(squad_id);
CREATE INDEX idx_traffic_limiters_outbound ON traffic_limiters(outbound);
CREATE INDEX idx_traffic_limiters_strategy ON traffic_limiters(strategy);
CREATE INDEX idx_traffic_limiters_mode ON traffic_limiters(mode);
CREATE INDEX idx_traffic_limiters_raw_used ON traffic_limiters(raw_used);
CREATE INDEX idx_traffic_limiters_raw_quota ON traffic_limiters(raw_quota);
CREATE INDEX idx_traffic_limiters_created_at ON traffic_limiters(created_at);
CREATE INDEX idx_traffic_limiters_updated_at ON traffic_limiters(updated_at);
CREATE INDEX idx_traffic_limiter_to_squad_squad_id ON traffic_limiter_to_squad(squad_id);
CREATE INDEX idx_rate_limiters_outbound ON rate_limiters(outbound);
CREATE INDEX idx_rate_limiters_strategy ON rate_limiters(strategy);
CREATE INDEX idx_rate_limiters_connection_type ON rate_limiters(connection_type);
CREATE INDEX idx_rate_limiters_interval ON rate_limiters("interval");
CREATE INDEX idx_rate_limiters_count ON rate_limiters(count);
CREATE INDEX idx_rate_limiters_created_at ON rate_limiters(created_at);
CREATE INDEX idx_rate_limiters_updated_at ON rate_limiters(updated_at);
CREATE INDEX idx_rate_limiter_to_squad_squad_id ON rate_limiter_to_squad(squad_id);
UPDATE connection_limiters SET connection_type = 'source_ip' WHERE connection_type = 'ip';
UPDATE bandwidth_limiters SET connection_type = 'source_ip' WHERE connection_type = 'ip';
UPDATE rate_limiters SET connection_type = 'source_ip' WHERE connection_type = 'ip';
UPDATE bandwidth_limiters
SET flow_keys = REPLACE(flow_keys::text, '"ip"', '"source_ip"')::jsonb
WHERE flow_keys @> '["ip"]'::jsonb;
UPDATE bandwidth_limiters SET mode = 'bidirectional' WHERE mode = 'duplex';
UPDATE traffic_limiters SET mode = 'bidirectional' WHERE mode = 'duplex';
`,
"2_init_limiters_and_indexes.down.sql": `
UPDATE traffic_limiters SET mode = 'duplex' WHERE mode = 'bidirectional';
UPDATE bandwidth_limiters SET mode = 'duplex' WHERE mode = 'bidirectional';
UPDATE bandwidth_limiters
SET flow_keys = REPLACE(flow_keys::text, '"source_ip"', '"ip"')::jsonb
WHERE flow_keys @> '["source_ip"]'::jsonb;
UPDATE rate_limiters SET connection_type = 'ip' WHERE connection_type = 'source_ip';
UPDATE bandwidth_limiters SET connection_type = 'ip' WHERE connection_type = 'source_ip';
UPDATE connection_limiters SET connection_type = 'ip' WHERE connection_type = 'source_ip';
DROP INDEX IF EXISTS idx_rate_limiter_to_squad_squad_id;
DROP INDEX IF EXISTS idx_rate_limiters_updated_at;
DROP INDEX IF EXISTS idx_rate_limiters_created_at;
DROP INDEX IF EXISTS idx_rate_limiters_count;
DROP INDEX IF EXISTS idx_rate_limiters_interval;
DROP INDEX IF EXISTS idx_rate_limiters_connection_type;
DROP INDEX IF EXISTS idx_rate_limiters_strategy;
DROP INDEX IF EXISTS idx_rate_limiters_outbound;
DROP INDEX IF EXISTS idx_traffic_limiter_to_squad_squad_id;
DROP INDEX IF EXISTS idx_traffic_limiters_updated_at;
DROP INDEX IF EXISTS idx_traffic_limiters_created_at;
DROP INDEX IF EXISTS idx_traffic_limiters_raw_quota;
DROP INDEX IF EXISTS idx_traffic_limiters_raw_used;
DROP INDEX IF EXISTS idx_traffic_limiters_mode;
DROP INDEX IF EXISTS idx_traffic_limiters_strategy;
DROP INDEX IF EXISTS idx_traffic_limiters_outbound;
DROP INDEX IF EXISTS idx_bandwidth_limiter_to_squad_squad_id;
DROP INDEX IF EXISTS idx_bandwidth_limiters_updated_at;
DROP INDEX IF EXISTS idx_bandwidth_limiters_created_at;
DROP INDEX IF EXISTS idx_bandwidth_limiters_raw_speed;
DROP INDEX IF EXISTS idx_bandwidth_limiters_connection_type;
DROP INDEX IF EXISTS idx_bandwidth_limiters_mode;
DROP INDEX IF EXISTS idx_bandwidth_limiters_strategy;
DROP INDEX IF EXISTS idx_bandwidth_limiters_outbound;
DROP INDEX IF EXISTS idx_connection_limiter_to_squad_squad_id;
DROP INDEX IF EXISTS idx_connection_limiters_updated_at;
DROP INDEX IF EXISTS idx_connection_limiters_created_at;
DROP INDEX IF EXISTS idx_connection_limiters_lock_type;
DROP INDEX IF EXISTS idx_connection_limiters_connection_type;
DROP INDEX IF EXISTS idx_connection_limiters_strategy;
DROP INDEX IF EXISTS idx_connection_limiters_outbound;
DROP INDEX IF EXISTS idx_user_to_squad_squad_id;
DROP INDEX IF EXISTS idx_users_updated_at;
DROP INDEX IF EXISTS idx_users_created_at;
DROP INDEX IF EXISTS idx_users_type;
DROP INDEX IF EXISTS idx_users_inbound;
DROP INDEX IF EXISTS idx_users_node_uuid;
DROP INDEX IF EXISTS idx_node_to_squad_squad_id;
DROP INDEX IF EXISTS idx_nodes_updated_at;
DROP INDEX IF EXISTS idx_nodes_created_at;
DROP INDEX IF EXISTS idx_squads_updated_at;
DROP INDEX IF EXISTS idx_squads_created_at;
ALTER TABLE bandwidth_limiter_to_squad
DROP CONSTRAINT bandwidth_limiter_to_squad_squad_id_fkey,
ADD CONSTRAINT bandwidth_limiter_to_squad_squad_id_fkey
FOREIGN KEY (squad_id) REFERENCES squads(id) ON DELETE RESTRICT;
ALTER TABLE connection_limiter_to_squad
DROP CONSTRAINT connection_limiter_to_squad_squad_id_fkey,
ADD CONSTRAINT connection_limiter_to_squad_squad_id_fkey
FOREIGN KEY (squad_id) REFERENCES squads(id) ON DELETE RESTRICT;
ALTER TABLE user_to_squad
DROP CONSTRAINT user_to_squad_squad_id_fkey,
ADD CONSTRAINT user_to_squad_squad_id_fkey
FOREIGN KEY (squad_id) REFERENCES squads(id) ON DELETE RESTRICT;
ALTER TABLE node_to_squad
DROP CONSTRAINT node_to_squad_squad_id_fkey,
ADD CONSTRAINT node_to_squad_squad_id_fkey
FOREIGN KEY (squad_id) REFERENCES squads(id) ON DELETE RESTRICT;
UPDATE connection_limiters SET lock_type = '' WHERE lock_type = 'default';
ALTER TABLE bandwidth_limiters DROP COLUMN flow_keys;
DROP TABLE IF EXISTS rate_limiter_to_squad;
DROP TABLE IF EXISTS rate_limiters;
DROP TABLE IF EXISTS traffic_limiter_to_squad;
DROP TABLE IF EXISTS traffic_limiters;
`,
}
@@ -113,12 +338,10 @@ func Migrate(db *sql.DB) error {
if err != nil {
return err
}
sourceDriver := source.NewRawDriver(migrations)
if err := sourceDriver.Init(); err != nil {
return err
}
m, err := migrate.NewWithInstance(
"raw",
sourceDriver,
@@ -128,6 +351,5 @@ func Migrate(db *sql.DB) error {
if err != nil {
return err
}
return m.Up()
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
package postgresql
import (
"encoding/json"
"fmt"
)
type stringSliceJSON []string
func (s *stringSliceJSON) Scan(src interface{}) error {
if src == nil {
*s = nil
return nil
}
var data []byte
switch v := src.(type) {
case []byte:
data = v
case string:
data = []byte(v)
default:
return fmt.Errorf("stringSliceJSON.Scan: unsupported type %T", src)
}
if len(data) == 0 {
*s = nil
return nil
}
return json.Unmarshal(data, (*[]string)(s))
}
func marshalStringSlice(values []string) ([]byte, error) {
if values == nil {
values = []string{}
}
return json.Marshal(values)
}

View File

@@ -0,0 +1,169 @@
package sqlite
import (
"encoding/json"
"strconv"
"github.com/huandu/go-sqlbuilder"
"github.com/sagernet/sing-box/common/byteformats"
)
type Filter func(sb *sqlbuilder.SelectBuilder, value []string) error
func EqualFilter(field string) Filter {
return func(sb *sqlbuilder.SelectBuilder, value []string) error {
sb.Where(sb.Equal(field, value[0]))
return nil
}
}
func EqualOrNullFilter(field string) Filter {
return func(sb *sqlbuilder.SelectBuilder, value []string) error {
sb.Where(sb.Or(sb.Equal(field, value[0]), sb.IsNull(field)))
return nil
}
}
func GreaterThanFilter(field string) Filter {
return func(sb *sqlbuilder.SelectBuilder, value []string) error {
sb.Where(sb.GreaterThan(field, value[0]))
return nil
}
}
func LessThanFilter(field string) Filter {
return func(sb *sqlbuilder.SelectBuilder, value []string) error {
sb.Where(sb.LessThan(field, value[0]))
return nil
}
}
func GreaterEqualThanFilter(field string) Filter {
return func(sb *sqlbuilder.SelectBuilder, value []string) error {
sb.Where(sb.GreaterEqualThan(field, value[0]))
return nil
}
}
func LessEqualThanFilter(field string) Filter {
return func(sb *sqlbuilder.SelectBuilder, value []string) error {
sb.Where(sb.LessEqualThan(field, value[0]))
return nil
}
}
func SpeedGreaterEqualThanFilter(field string) Filter {
return func(sb *sqlbuilder.SelectBuilder, value []string) error {
bytesSpeed, err := json.Marshal(value[0])
if err != nil {
return err
}
speed := &byteformats.NetworkBytesCompat{}
err = speed.UnmarshalJSON(bytesSpeed)
if err != nil {
return err
}
sb.Where(sb.GreaterEqualThan(field, speed.Value()))
return nil
}
}
func SpeedLessEqualThanFilter(field string) Filter {
return func(sb *sqlbuilder.SelectBuilder, value []string) error {
bytesSpeed, err := json.Marshal(value[0])
if err != nil {
return err
}
speed := &byteformats.NetworkBytesCompat{}
err = speed.UnmarshalJSON(bytesSpeed)
if err != nil {
return err
}
sb.Where(sb.LessEqualThan(field, speed.Value()))
return nil
}
}
func ExistsAndWhereInFilter(subquery *sqlbuilder.SelectBuilder, field string) Filter {
return func(sb *sqlbuilder.SelectBuilder, value []string) error {
values := make([]interface{}, len(value))
for i, v := range value {
values[i] = v
}
subquery.Where(subquery.In(field, values...))
sb.Where(sb.Exists(subquery))
return nil
}
}
func InFilter(field string) Filter {
return func(sb *sqlbuilder.SelectBuilder, value []string) error {
if len(value) == 0 {
return nil
}
values := make([]interface{}, len(value))
for i, v := range value {
values[i] = v
}
sb.Where(sb.In(field, values...))
return nil
}
}
func SortAscFilter() Filter {
return func(sb *sqlbuilder.SelectBuilder, value []string) error {
sb.OrderByAsc(value[0])
return nil
}
}
func SortDescFilter() Filter {
return func(sb *sqlbuilder.SelectBuilder, value []string) error {
sb.OrderByDesc(value[0])
return nil
}
}
func ReplacedSortAscFilter(replace map[string]string) Filter {
return func(sb *sqlbuilder.SelectBuilder, value []string) error {
if replacedValue, ok := replace[value[0]]; ok {
sb.OrderByAsc(replacedValue)
} else {
sb.OrderByAsc(value[0])
}
return nil
}
}
func ReplacedSortDescFilter(replace map[string]string) Filter {
return func(sb *sqlbuilder.SelectBuilder, value []string) error {
if replacedValue, ok := replace[value[0]]; ok {
sb.OrderByDesc(replacedValue)
} else {
sb.OrderByDesc(value[0])
}
return nil
}
}
func LimitFilter() Filter {
return func(sb *sqlbuilder.SelectBuilder, value []string) error {
limit, err := strconv.Atoi(value[0])
if err != nil {
return err
}
sb.Limit(limit)
return nil
}
}
func OffsetFilter() Filter {
return func(sb *sqlbuilder.SelectBuilder, value []string) error {
offset, err := strconv.Atoi(value[0])
if err != nil {
return err
}
sb.Offset(offset)
return nil
}
}

View File

@@ -0,0 +1,237 @@
package sqlite
import (
"database/sql"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/sqlite"
"github.com/sagernet/sing-box/common/migrate/source"
)
var migrations = map[string]string{
"1_initialize_schema.up.sql": `
CREATE TABLE squads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE nodes (
uuid VARCHAR(36) PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE node_to_squad (
node_uuid VARCHAR(36) NOT NULL,
squad_id INTEGER NOT NULL,
PRIMARY KEY (node_uuid, squad_id),
FOREIGN KEY (node_uuid) REFERENCES nodes(uuid) ON DELETE CASCADE,
FOREIGN KEY (squad_id) REFERENCES squads(id) ON DELETE CASCADE
);
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_uuid VARCHAR(36),
username TEXT NOT NULL,
inbound TEXT NOT NULL,
type TEXT NOT NULL,
uuid TEXT NOT NULL,
password TEXT NOT NULL,
secret TEXT NOT NULL,
flow TEXT NOT NULL,
alter_id INTEGER NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
UNIQUE (username, inbound)
);
CREATE TABLE user_to_squad (
user_id INTEGER NOT NULL,
squad_id INTEGER NOT NULL,
PRIMARY KEY (user_id, squad_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (squad_id) REFERENCES squads(id) ON DELETE CASCADE
);
CREATE TABLE connection_limiters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
outbound TEXT NOT NULL,
strategy TEXT NOT NULL,
connection_type TEXT NOT NULL,
lock_type TEXT NOT NULL,
count INTEGER NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
UNIQUE (username, outbound)
);
CREATE TABLE connection_limiter_to_squad (
connection_limiter_id INTEGER NOT NULL,
squad_id INTEGER NOT NULL,
PRIMARY KEY (connection_limiter_id, squad_id),
FOREIGN KEY (connection_limiter_id) REFERENCES connection_limiters(id) ON DELETE CASCADE,
FOREIGN KEY (squad_id) REFERENCES squads(id) ON DELETE CASCADE
);
CREATE TABLE bandwidth_limiters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
outbound TEXT NOT NULL,
strategy TEXT NOT NULL,
mode TEXT NOT NULL,
connection_type TEXT NOT NULL,
speed TEXT NOT NULL,
raw_speed BIGINT NOT NULL DEFAULT 0,
flow_keys TEXT NOT NULL DEFAULT '[]',
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
UNIQUE (username, outbound)
);
CREATE TABLE bandwidth_limiter_to_squad (
bandwidth_limiter_id INTEGER NOT NULL,
squad_id INTEGER NOT NULL,
PRIMARY KEY (bandwidth_limiter_id, squad_id),
FOREIGN KEY (bandwidth_limiter_id) REFERENCES bandwidth_limiters(id) ON DELETE CASCADE,
FOREIGN KEY (squad_id) REFERENCES squads(id) ON DELETE CASCADE
);
CREATE TABLE traffic_limiters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
outbound TEXT NOT NULL,
strategy TEXT NOT NULL,
mode TEXT NOT NULL,
raw_used BIGINT NOT NULL DEFAULT 0,
quota TEXT NOT NULL,
raw_quota BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
UNIQUE (username, outbound)
);
CREATE TABLE traffic_limiter_to_squad (
traffic_limiter_id INTEGER NOT NULL,
squad_id INTEGER NOT NULL,
PRIMARY KEY (traffic_limiter_id, squad_id),
FOREIGN KEY (traffic_limiter_id) REFERENCES traffic_limiters(id) ON DELETE CASCADE,
FOREIGN KEY (squad_id) REFERENCES squads(id) ON DELETE CASCADE
);
CREATE TABLE rate_limiters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
outbound TEXT NOT NULL,
strategy TEXT NOT NULL,
connection_type TEXT NOT NULL,
count INTEGER NOT NULL,
interval TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
UNIQUE (username, outbound)
);
CREATE TABLE rate_limiter_to_squad (
rate_limiter_id INTEGER NOT NULL,
squad_id INTEGER NOT NULL,
PRIMARY KEY (rate_limiter_id, squad_id),
FOREIGN KEY (rate_limiter_id) REFERENCES rate_limiters(id) ON DELETE CASCADE,
FOREIGN KEY (squad_id) REFERENCES squads(id) ON DELETE CASCADE
);
CREATE INDEX idx_squads_created_at ON squads(created_at);
CREATE INDEX idx_squads_updated_at ON squads(updated_at);
CREATE INDEX idx_nodes_created_at ON nodes(created_at);
CREATE INDEX idx_nodes_updated_at ON nodes(updated_at);
CREATE INDEX idx_node_to_squad_squad_id ON node_to_squad(squad_id);
CREATE INDEX idx_users_node_uuid ON users(node_uuid);
CREATE INDEX idx_users_inbound ON users(inbound);
CREATE INDEX idx_users_type ON users(type);
CREATE INDEX idx_users_created_at ON users(created_at);
CREATE INDEX idx_users_updated_at ON users(updated_at);
CREATE INDEX idx_user_to_squad_squad_id ON user_to_squad(squad_id);
CREATE INDEX idx_connection_limiters_outbound ON connection_limiters(outbound);
CREATE INDEX idx_connection_limiters_strategy ON connection_limiters(strategy);
CREATE INDEX idx_connection_limiters_connection_type ON connection_limiters(connection_type);
CREATE INDEX idx_connection_limiters_lock_type ON connection_limiters(lock_type);
CREATE INDEX idx_connection_limiters_created_at ON connection_limiters(created_at);
CREATE INDEX idx_connection_limiters_updated_at ON connection_limiters(updated_at);
CREATE INDEX idx_connection_limiter_to_squad_squad_id ON connection_limiter_to_squad(squad_id);
CREATE INDEX idx_bandwidth_limiters_outbound ON bandwidth_limiters(outbound);
CREATE INDEX idx_bandwidth_limiters_strategy ON bandwidth_limiters(strategy);
CREATE INDEX idx_bandwidth_limiters_mode ON bandwidth_limiters(mode);
CREATE INDEX idx_bandwidth_limiters_connection_type ON bandwidth_limiters(connection_type);
CREATE INDEX idx_bandwidth_limiters_raw_speed ON bandwidth_limiters(raw_speed);
CREATE INDEX idx_bandwidth_limiters_created_at ON bandwidth_limiters(created_at);
CREATE INDEX idx_bandwidth_limiters_updated_at ON bandwidth_limiters(updated_at);
CREATE INDEX idx_bandwidth_limiter_to_squad_squad_id ON bandwidth_limiter_to_squad(squad_id);
CREATE INDEX idx_traffic_limiters_outbound ON traffic_limiters(outbound);
CREATE INDEX idx_traffic_limiters_strategy ON traffic_limiters(strategy);
CREATE INDEX idx_traffic_limiters_mode ON traffic_limiters(mode);
CREATE INDEX idx_traffic_limiters_raw_used ON traffic_limiters(raw_used);
CREATE INDEX idx_traffic_limiters_raw_quota ON traffic_limiters(raw_quota);
CREATE INDEX idx_traffic_limiters_created_at ON traffic_limiters(created_at);
CREATE INDEX idx_traffic_limiters_updated_at ON traffic_limiters(updated_at);
CREATE INDEX idx_traffic_limiter_to_squad_squad_id ON traffic_limiter_to_squad(squad_id);
CREATE INDEX idx_rate_limiters_outbound ON rate_limiters(outbound);
CREATE INDEX idx_rate_limiters_strategy ON rate_limiters(strategy);
CREATE INDEX idx_rate_limiters_connection_type ON rate_limiters(connection_type);
CREATE INDEX idx_rate_limiters_interval ON rate_limiters("interval");
CREATE INDEX idx_rate_limiters_count ON rate_limiters(count);
CREATE INDEX idx_rate_limiters_created_at ON rate_limiters(created_at);
CREATE INDEX idx_rate_limiters_updated_at ON rate_limiters(updated_at);
CREATE INDEX idx_rate_limiter_to_squad_squad_id ON rate_limiter_to_squad(squad_id);
`,
"1_initialize_schema.down.sql": `
DROP TABLE IF EXISTS rate_limiter_to_squad;
DROP TABLE IF EXISTS rate_limiters;
DROP TABLE IF EXISTS traffic_limiter_to_squad;
DROP TABLE IF EXISTS traffic_limiters;
DROP TABLE IF EXISTS bandwidth_limiter_to_squad;
DROP TABLE IF EXISTS bandwidth_limiters;
DROP TABLE IF EXISTS connection_limiter_to_squad;
DROP TABLE IF EXISTS connection_limiters;
DROP TABLE IF EXISTS user_to_squad;
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS node_to_squad;
DROP TABLE IF EXISTS nodes;
DROP TABLE IF EXISTS squads;
`,
}
func Migrate(db *sql.DB) error {
driver, err := sqlite.WithInstance(db, &sqlite.Config{})
if err != nil {
return err
}
sourceDriver := source.NewRawDriver(migrations)
if err := sourceDriver.Init(); err != nil {
return err
}
m, err := migrate.NewWithInstance(
"raw",
sourceDriver,
"sqlite",
driver,
)
if err != nil {
return err
}
return m.Up()
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,63 @@
package sqlite
import (
"encoding/json"
"fmt"
)
type intSliceJSON []int
func (s *intSliceJSON) Scan(src interface{}) error {
if src == nil {
*s = nil
return nil
}
var data []byte
switch v := src.(type) {
case []byte:
data = v
case string:
data = []byte(v)
default:
return fmt.Errorf("intSliceJSON.Scan: unsupported type %T", src)
}
if len(data) == 0 {
*s = nil
return nil
}
return json.Unmarshal(data, (*[]int)(s))
}
type stringSliceJSON []string
func (s *stringSliceJSON) Scan(src interface{}) error {
if src == nil {
*s = nil
return nil
}
var data []byte
switch v := src.(type) {
case []byte:
data = v
case string:
data = []byte(v)
default:
return fmt.Errorf("stringSliceJSON.Scan: unsupported type %T", src)
}
if len(data) == 0 {
*s = nil
return nil
}
return json.Unmarshal(data, (*[]string)(s))
}
func marshalStringSlice(values []string) (string, error) {
if values == nil {
values = []string{}
}
data, err := json.Marshal(values)
if err != nil {
return "", err
}
return string(data), nil
}

View File

@@ -10,7 +10,6 @@ import (
"github.com/go-playground/validator/v10"
"github.com/gofrs/uuid/v5"
"github.com/patrickmn/go-cache/v2"
"github.com/sagernet/sing-box/adapter"
boxService "github.com/sagernet/sing-box/adapter/service"
C "github.com/sagernet/sing-box/constant"
@@ -18,7 +17,9 @@ import (
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing-box/service/manager/constant"
"github.com/sagernet/sing-box/service/manager/repository/postgresql"
"github.com/sagernet/sing-box/service/manager/repository/sqlite"
E "github.com/sagernet/sing/common/exceptions"
"github.com/shtorm-7/go-cache/v2"
)
func RegisterService(registry *boxService.Registry) {
@@ -33,12 +34,13 @@ type Service struct {
nodes map[string]constant.ConnectedNode
limiterLocks map[int]map[string]*cache.Cache[string, struct{}]
trafficUsage map[int]*TrafficUsage
userValidator *validator.Validate
defaultValidator *validator.Validate
mtx sync.RWMutex
connLockMtx sync.Mutex
trafficMtx sync.Mutex
}
func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.ManagerServiceOptions) (adapter.Service, error) {
@@ -50,11 +52,16 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio
if err != nil {
return nil, err
}
case "sqlite":
repository, err = sqlite.NewSQLiteRepository(ctx, options.Database.DSN)
if err != nil {
return nil, err
}
default:
return nil, E.New("unknown driver \"", options.Database.Driver, "\"")
}
userValidator := validator.New()
userValidator.RegisterStructValidation(func(sl validator.StructLevel) {
defaultValidator := validator.New()
defaultValidator.RegisterStructValidation(func(sl validator.StructLevel) {
user := sl.Current().Interface().(constant.UserCreate)
switch user.Type {
case "vless":
@@ -85,16 +92,41 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio
}
}
}, constant.UserCreate{})
return &Service{
validateRateLimiterInterval := func(sl validator.StructLevel, interval string) {
if interval == "" {
return
}
if _, err := time.ParseDuration(interval); err != nil {
sl.ReportError(interval, "interval", "Interval", "duration", "")
}
}
defaultValidator.RegisterStructValidation(func(sl validator.StructLevel) {
validateRateLimiterInterval(sl, sl.Current().Interface().(constant.RateLimiterCreate).Interval)
}, constant.RateLimiterCreate{})
defaultValidator.RegisterStructValidation(func(sl validator.StructLevel) {
validateRateLimiterInterval(sl, sl.Current().Interface().(constant.RateLimiterUpdate).Interval)
}, constant.RateLimiterUpdate{})
service := &Service{
Adapter: boxService.NewAdapter(C.TypeManager, tag),
ctx: ctx,
logger: logger,
repository: repository,
nodes: make(map[string]constant.ConnectedNode, 0),
limiterLocks: make(map[int]map[string]*cache.Cache[string, struct{}]),
userValidator: userValidator,
defaultValidator: validator.New(),
}, nil
trafficUsage: make(map[int]*TrafficUsage),
defaultValidator: defaultValidator,
}
limiters, err := service.repository.GetTrafficLimiters(map[string][]string{})
if err != nil {
return nil, err
}
for _, limiter := range limiters {
service.trafficUsage[limiter.ID] = &TrafficUsage{
used: limiter.RawUsed,
quota: limiter.RawQuota,
}
}
return service, nil
}
func (s *Service) CreateSquad(node constant.SquadCreate) (constant.Squad, error) {
@@ -139,12 +171,70 @@ func (s *Service) UpdateSquad(id int, squad constant.SquadUpdate) (constant.Squa
func (s *Service) DeleteSquad(id int) (constant.Squad, error) {
s.mtx.Lock()
s.trafficMtx.Lock()
s.connLockMtx.Lock()
defer s.mtx.Unlock()
deletedSquad, err := s.repository.DeleteSquad(id)
defer s.trafficMtx.Unlock()
defer s.connLockMtx.Unlock()
deleted, err := s.repository.DeleteSquad(id)
if err != nil {
return deletedSquad, err
return deleted.Squad, err
}
return deletedSquad, nil
for _, uuid := range deleted.OrphanedNodeUUIDs {
if connectedNode, ok := s.nodes[uuid]; ok {
connectedNode.Close()
delete(s.nodes, uuid)
}
}
for _, lid := range deleted.OrphanedTrafficLimiterIDs {
delete(s.trafficUsage, lid)
}
for _, lid := range deleted.OrphanedConnectionLimiterIDs {
delete(s.limiterLocks, lid)
}
for _, uuid := range deleted.SurvivingNodeUUIDs {
connectedNode, ok := s.nodes[uuid]
if !ok {
continue
}
node, err := s.repository.GetNode(uuid)
if err != nil {
s.closeAllNodes()
return deleted.Squad, err
}
squadIDs := convertIntSliceToStringSlice(node.SquadIDs)
users, err := s.repository.GetUsers(map[string][]string{"squad_id_in": squadIDs})
if err != nil {
s.closeAllNodes()
return deleted.Squad, err
}
connectedNode.UpdateUsers(users)
bandwidthLimiters, err := s.repository.GetBandwidthLimiters(map[string][]string{"squad_id_in": squadIDs})
if err != nil {
s.closeAllNodes()
return deleted.Squad, err
}
connectedNode.UpdateBandwidthLimiters(bandwidthLimiters)
trafficLimiters, err := s.repository.GetTrafficLimiters(map[string][]string{"squad_id_in": squadIDs})
if err != nil {
s.closeAllNodes()
return deleted.Squad, err
}
connectedNode.UpdateTrafficLimiters(trafficLimiters)
connectionLimiters, err := s.repository.GetConnectionLimiters(map[string][]string{"squad_id_in": squadIDs})
if err != nil {
s.closeAllNodes()
return deleted.Squad, err
}
connectedNode.UpdateConnectionLimiters(connectionLimiters)
rateLimiters, err := s.repository.GetRateLimiters(map[string][]string{"squad_id_in": squadIDs})
if err != nil {
s.closeAllNodes()
return deleted.Squad, err
}
connectedNode.UpdateRateLimiters(rateLimiters)
}
return deleted.Squad, nil
}
func (s *Service) CreateNode(node constant.NodeCreate) (constant.Node, error) {
@@ -173,14 +263,14 @@ func (s *Service) GetNode(uuid string) (constant.Node, error) {
return s.repository.GetNode(uuid)
}
func (s *Service) GetNodeStatus(uuid string) string {
func (s *Service) GetNodeStatus(uuid string) (string, error) {
s.mtx.RLock()
defer s.mtx.RUnlock()
node, ok := s.nodes[uuid]
if !ok || !node.IsOnline() {
return "offline"
return "offline", nil
}
return "online"
return "online", nil
}
func (s *Service) UpdateNode(uuid string, node constant.NodeUpdate) (constant.Node, error) {
@@ -215,7 +305,7 @@ func (s *Service) DeleteNode(uuid string) (constant.Node, error) {
func (s *Service) CreateUser(user constant.UserCreate) (constant.User, error) {
s.mtx.Lock()
defer s.mtx.Unlock()
err := s.userValidator.Struct(user)
err := s.defaultValidator.Struct(user)
if err != nil {
return constant.User{}, err
}
@@ -294,6 +384,221 @@ func (s *Service) DeleteUser(id int) (constant.User, error) {
return deletedUser, nil
}
func (s *Service) CreateBandwidthLimiter(limiter constant.BandwidthLimiterCreate) (constant.BandwidthLimiter, error) {
s.mtx.Lock()
defer s.mtx.Unlock()
err := s.defaultValidator.Struct(limiter)
if err != nil {
return constant.BandwidthLimiter{}, err
}
createdLimiter, err := s.repository.CreateBandwidthLimiter(limiter)
if err != nil {
return createdLimiter, err
}
nodes, err := s.repository.GetNodes(map[string][]string{
"squad_id_in": convertIntSliceToStringSlice(createdLimiter.SquadIDs),
})
if err != nil {
s.closeAllNodes()
return createdLimiter, err
}
for _, node := range nodes {
if node, ok := s.nodes[node.UUID]; ok {
node.UpdateBandwidthLimiter(createdLimiter)
}
}
return createdLimiter, nil
}
func (s *Service) GetBandwidthLimiters(filters map[string][]string) ([]constant.BandwidthLimiter, error) {
return s.repository.GetBandwidthLimiters(filters)
}
func (s *Service) GetBandwidthLimitersCount(filters map[string][]string) (int, error) {
return s.repository.GetBandwidthLimitersCount(filters)
}
func (s *Service) GetBandwidthLimiter(id int) (constant.BandwidthLimiter, error) {
return s.repository.GetBandwidthLimiter(id)
}
func (s *Service) UpdateBandwidthLimiter(id int, limiter constant.BandwidthLimiterUpdate) (constant.BandwidthLimiter, error) {
s.mtx.Lock()
defer s.mtx.Unlock()
err := s.defaultValidator.Struct(limiter)
if err != nil {
return constant.BandwidthLimiter{}, err
}
updatedLimiter, err := s.repository.UpdateBandwidthLimiter(id, limiter)
if err != nil {
return updatedLimiter, err
}
nodes, err := s.repository.GetNodes(map[string][]string{
"squad_id_in": convertIntSliceToStringSlice(updatedLimiter.SquadIDs),
})
if err != nil {
s.closeAllNodes()
return updatedLimiter, err
}
for _, node := range nodes {
if node, ok := s.nodes[node.UUID]; ok {
node.UpdateBandwidthLimiter(updatedLimiter)
}
}
return updatedLimiter, nil
}
func (s *Service) DeleteBandwidthLimiter(id int) (constant.BandwidthLimiter, error) {
s.mtx.Lock()
defer s.mtx.Unlock()
deletedLimiter, err := s.repository.DeleteBandwidthLimiter(id)
if err != nil {
return deletedLimiter, err
}
nodes, err := s.repository.GetNodes(map[string][]string{
"squad_id_in": convertIntSliceToStringSlice(deletedLimiter.SquadIDs),
})
if err != nil {
s.closeAllNodes()
return deletedLimiter, err
}
for _, node := range nodes {
if node, ok := s.nodes[node.UUID]; ok {
node.DeleteBandwidthLimiter(deletedLimiter)
}
}
return deletedLimiter, nil
}
func (s *Service) CreateTrafficLimiter(limiter constant.TrafficLimiterCreate) (constant.TrafficLimiter, error) {
s.mtx.Lock()
defer s.mtx.Unlock()
err := s.defaultValidator.Struct(limiter)
if err != nil {
return constant.TrafficLimiter{}, err
}
createdLimiter, err := s.repository.CreateTrafficLimiter(limiter)
if err != nil {
return createdLimiter, err
}
s.trafficMtx.Lock()
s.trafficUsage[createdLimiter.ID] = &TrafficUsage{
used: createdLimiter.RawUsed,
quota: createdLimiter.RawQuota,
}
s.trafficMtx.Unlock()
nodes, err := s.repository.GetNodes(map[string][]string{
"squad_id_in": convertIntSliceToStringSlice(createdLimiter.SquadIDs),
})
if err != nil {
s.closeAllNodes()
return createdLimiter, err
}
for _, node := range nodes {
if node, ok := s.nodes[node.UUID]; ok {
node.UpdateTrafficLimiter(createdLimiter)
}
}
return createdLimiter, nil
}
func (s *Service) GetTrafficLimiters(filters map[string][]string) ([]constant.TrafficLimiter, error) {
return s.repository.GetTrafficLimiters(filters)
}
func (s *Service) GetTrafficLimitersCount(filters map[string][]string) (int, error) {
return s.repository.GetTrafficLimitersCount(filters)
}
func (s *Service) GetTrafficLimiter(id int) (constant.TrafficLimiter, error) {
return s.repository.GetTrafficLimiter(id)
}
func (s *Service) UpdateTrafficLimiter(id int, limiter constant.TrafficLimiterUpdate) (constant.TrafficLimiter, error) {
s.mtx.Lock()
s.trafficMtx.Lock()
defer s.mtx.Unlock()
defer s.trafficMtx.Unlock()
err := s.defaultValidator.Struct(limiter)
if err != nil {
return constant.TrafficLimiter{}, err
}
updatedLimiter, err := s.repository.UpdateTrafficLimiter(id, limiter)
if err != nil {
return updatedLimiter, err
}
s.trafficUsage[updatedLimiter.ID] = &TrafficUsage{
used: updatedLimiter.RawUsed,
quota: updatedLimiter.RawQuota,
}
nodes, err := s.repository.GetNodes(map[string][]string{
"squad_id_in": convertIntSliceToStringSlice(updatedLimiter.SquadIDs),
})
if err != nil {
s.closeAllNodes()
return updatedLimiter, err
}
for _, node := range nodes {
if node, ok := s.nodes[node.UUID]; ok {
node.UpdateTrafficLimiter(updatedLimiter)
}
}
return updatedLimiter, nil
}
func (s *Service) UpdateTrafficLimiterUsed(id int, used uint64) (constant.TrafficLimiter, error) {
s.mtx.Lock()
s.trafficMtx.Lock()
defer s.mtx.Unlock()
defer s.trafficMtx.Unlock()
updatedLimiter, err := s.repository.UpdateTrafficLimiterUsed(id, used)
if err != nil {
return updatedLimiter, err
}
s.trafficUsage[updatedLimiter.ID] = &TrafficUsage{
used: updatedLimiter.RawUsed,
quota: updatedLimiter.RawQuota,
}
nodes, err := s.repository.GetNodes(map[string][]string{
"squad_id_in": convertIntSliceToStringSlice(updatedLimiter.SquadIDs),
})
if err != nil {
s.closeAllNodes()
return updatedLimiter, err
}
for _, node := range nodes {
if node, ok := s.nodes[node.UUID]; ok {
node.UpdateTrafficLimiter(updatedLimiter)
}
}
return updatedLimiter, nil
}
func (s *Service) DeleteTrafficLimiter(id int) (constant.TrafficLimiter, error) {
s.mtx.Lock()
s.trafficMtx.Lock()
defer s.mtx.Unlock()
defer s.trafficMtx.Unlock()
deletedLimiter, err := s.repository.DeleteTrafficLimiter(id)
if err != nil {
return deletedLimiter, err
}
delete(s.trafficUsage, deletedLimiter.ID)
nodes, err := s.repository.GetNodes(map[string][]string{
"squad_id_in": convertIntSliceToStringSlice(deletedLimiter.SquadIDs),
})
if err != nil {
s.closeAllNodes()
return deletedLimiter, err
}
for _, node := range nodes {
if node, ok := s.nodes[node.UUID]; ok {
node.DeleteTrafficLimiter(deletedLimiter)
}
}
return deletedLimiter, nil
}
func (s *Service) CreateConnectionLimiter(limiter constant.ConnectionLimiterCreate) (constant.ConnectionLimiter, error) {
s.mtx.Lock()
defer s.mtx.Unlock()
@@ -390,14 +695,14 @@ func (s *Service) DeleteConnectionLimiter(id int) (constant.ConnectionLimiter, e
return deletedLimiter, nil
}
func (s *Service) CreateBandwidthLimiter(limiter constant.BandwidthLimiterCreate) (constant.BandwidthLimiter, error) {
func (s *Service) CreateRateLimiter(limiter constant.RateLimiterCreate) (constant.RateLimiter, error) {
s.mtx.Lock()
defer s.mtx.Unlock()
err := s.defaultValidator.Struct(limiter)
if err != nil {
return constant.BandwidthLimiter{}, err
return constant.RateLimiter{}, err
}
createdLimiter, err := s.repository.CreateBandwidthLimiter(limiter)
createdLimiter, err := s.repository.CreateRateLimiter(limiter)
if err != nil {
return createdLimiter, err
}
@@ -410,32 +715,32 @@ func (s *Service) CreateBandwidthLimiter(limiter constant.BandwidthLimiterCreate
}
for _, node := range nodes {
if node, ok := s.nodes[node.UUID]; ok {
node.UpdateBandwidthLimiter(createdLimiter)
node.UpdateRateLimiter(createdLimiter)
}
}
return createdLimiter, nil
}
func (s *Service) GetBandwidthLimiters(filters map[string][]string) ([]constant.BandwidthLimiter, error) {
return s.repository.GetBandwidthLimiters(filters)
func (s *Service) GetRateLimiters(filters map[string][]string) ([]constant.RateLimiter, error) {
return s.repository.GetRateLimiters(filters)
}
func (s *Service) GetBandwidthLimitersCount(filters map[string][]string) (int, error) {
return s.repository.GetBandwidthLimitersCount(filters)
func (s *Service) GetRateLimitersCount(filters map[string][]string) (int, error) {
return s.repository.GetRateLimitersCount(filters)
}
func (s *Service) GetBandwidthLimiter(id int) (constant.BandwidthLimiter, error) {
return s.repository.GetBandwidthLimiter(id)
func (s *Service) GetRateLimiter(id int) (constant.RateLimiter, error) {
return s.repository.GetRateLimiter(id)
}
func (s *Service) UpdateBandwidthLimiter(id int, limiter constant.BandwidthLimiterUpdate) (constant.BandwidthLimiter, error) {
func (s *Service) UpdateRateLimiter(id int, limiter constant.RateLimiterUpdate) (constant.RateLimiter, error) {
s.mtx.Lock()
defer s.mtx.Unlock()
err := s.defaultValidator.Struct(limiter)
if err != nil {
return constant.BandwidthLimiter{}, err
return constant.RateLimiter{}, err
}
updatedLimiter, err := s.repository.UpdateBandwidthLimiter(id, limiter)
updatedLimiter, err := s.repository.UpdateRateLimiter(id, limiter)
if err != nil {
return updatedLimiter, err
}
@@ -448,16 +753,16 @@ func (s *Service) UpdateBandwidthLimiter(id int, limiter constant.BandwidthLimit
}
for _, node := range nodes {
if node, ok := s.nodes[node.UUID]; ok {
node.UpdateBandwidthLimiter(updatedLimiter)
node.UpdateRateLimiter(updatedLimiter)
}
}
return updatedLimiter, nil
}
func (s *Service) DeleteBandwidthLimiter(id int) (constant.BandwidthLimiter, error) {
func (s *Service) DeleteRateLimiter(id int) (constant.RateLimiter, error) {
s.mtx.Lock()
defer s.mtx.Unlock()
deletedLimiter, err := s.repository.DeleteBandwidthLimiter(id)
deletedLimiter, err := s.repository.DeleteRateLimiter(id)
if err != nil {
return deletedLimiter, err
}
@@ -470,7 +775,7 @@ func (s *Service) DeleteBandwidthLimiter(id int) (constant.BandwidthLimiter, err
}
for _, node := range nodes {
if node, ok := s.nodes[node.UUID]; ok {
node.DeleteBandwidthLimiter(deletedLimiter)
node.DeleteRateLimiter(deletedLimiter)
}
}
return deletedLimiter, nil
@@ -500,6 +805,13 @@ func (s *Service) AddNode(uuid string, node constant.ConnectedNode) error {
return err
}
node.UpdateBandwidthLimiters(bandwidthLimiters)
trafficLimiters, err := s.repository.GetTrafficLimiters(map[string][]string{
"squad_id_in": squadIDs,
})
if err != nil {
return err
}
node.UpdateTrafficLimiters(trafficLimiters)
connectionLimiters, err := s.repository.GetConnectionLimiters(map[string][]string{
"squad_id_in": squadIDs,
})
@@ -507,6 +819,13 @@ func (s *Service) AddNode(uuid string, node constant.ConnectedNode) error {
return err
}
node.UpdateConnectionLimiters(connectionLimiters)
rateLimiters, err := s.repository.GetRateLimiters(map[string][]string{
"squad_id_in": squadIDs,
})
if err != nil {
return err
}
node.UpdateRateLimiters(rateLimiters)
s.nodes[uuid] = node
return nil
}
@@ -579,6 +898,25 @@ func (s *Service) ReleaseLock(limiterId int, id string, handleId string) error {
return nil
}
func (s *Service) AddTrafficUsage(limiterId int, n uint64) (uint64, error) {
s.trafficMtx.Lock()
defer s.trafficMtx.Unlock()
trafficStat, ok := s.trafficUsage[limiterId]
if !ok {
return 0, E.New("limiter not found")
}
trafficStat.used = trafficStat.used + n
if trafficStat.used > trafficStat.quota {
trafficStat.used = trafficStat.quota
}
updatedLimiter, err := s.repository.UpdateTrafficLimiterUsed(limiterId, trafficStat.used)
if err != nil {
return 0, err
}
trafficStat.used = updatedLimiter.RawUsed
return trafficStat.used, nil
}
func (s *Service) Start(stage adapter.StartStage) error {
return nil
}
@@ -587,6 +925,11 @@ func (s *Service) Close() error {
return nil
}
type TrafficUsage struct {
used uint64
quota uint64
}
func (s *Service) closeAllNodes() {
for _, node := range s.nodes {
node.Close()

View File

@@ -0,0 +1,109 @@
package client
import (
"context"
"net"
"sync"
"github.com/sagernet/sing-box/adapter"
boxService "github.com/sagernet/sing-box/adapter/service"
"github.com/sagernet/sing-box/common/dialer"
"github.com/sagernet/sing-box/common/tls"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
pb "github.com/sagernet/sing-box/service/manager_api/grpc/manager"
"github.com/sagernet/sing/common"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
"google.golang.org/grpc"
"google.golang.org/grpc/connectivity"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
)
type Client struct {
boxService.Adapter
ctx context.Context
logger log.ContextLogger
dialer N.Dialer
creds credentials.TransportCredentials
options option.ManagerAPIClientOptions
conn *grpc.ClientConn
mtx sync.Mutex
}
func NewClient(ctx context.Context, logger log.ContextLogger, tag string, options option.ManagerAPIClientOptions) (*Client, error) {
outboundDialer, err := dialer.New(ctx, options.DialerOptions, options.ServerIsDomain())
if err != nil {
return nil, err
}
creds := insecure.NewCredentials()
if options.TLS != nil {
tlsConfig, err := tls.NewClient(ctx, logger, options.Server, common.PtrValueOrDefault(options.TLS))
if err != nil {
return nil, err
}
creds = &tlsCreds{config: tlsConfig}
}
return &Client{
Adapter: boxService.NewAdapter(C.TypeManagerAPI, tag),
ctx: ctx,
logger: logger,
dialer: outboundDialer,
creds: creds,
options: options,
}, nil
}
func (s *Client) Start(stage adapter.StartStage) error { return nil }
func (s *Client) Close() error {
s.mtx.Lock()
defer s.mtx.Unlock()
if s.conn != nil {
return s.conn.Close()
}
return nil
}
func (s *Client) client() (pb.ManagerClient, error) {
conn, err := s.getConn()
if err != nil {
return nil, err
}
return pb.NewManagerClient(conn), nil
}
func (s *Client) getConn() (*grpc.ClientConn, error) {
s.mtx.Lock()
defer s.mtx.Unlock()
if s.conn != nil {
state := s.conn.GetState()
if state != connectivity.Shutdown && state != connectivity.TransientFailure {
return s.conn, nil
}
}
conn, err := grpc.NewClient(
s.options.ServerOptions.Build().String(),
grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) {
return s.dialer.DialContext(ctx, N.NetworkTCP, M.ParseSocksaddr(addr))
}),
grpc.WithTransportCredentials(s.creds),
)
if err != nil {
return nil, err
}
s.conn = conn
return conn, nil
}
func (s *Client) callContext() context.Context {
if s.options.APIKey == "" {
return s.ctx
}
return metadata.AppendToOutgoingContext(s.ctx, "authorization", s.options.APIKey)
}

View File

@@ -0,0 +1,146 @@
package client
import (
"time"
CM "github.com/sagernet/sing-box/service/manager/constant"
pb "github.com/sagernet/sing-box/service/manager_api/grpc/manager"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func mapError(err error) error {
if err == nil {
return nil
}
if st, ok := status.FromError(err); ok && st.Code() == codes.NotFound {
return CM.ErrNotFound
}
return err
}
func toIntSlice(values []int32) []int {
out := make([]int, len(values))
for i, v := range values {
out[i] = int(v)
}
return out
}
func toInt32Slice(values []int) []int32 {
out := make([]int32, len(values))
for i, v := range values {
out[i] = int32(v)
}
return out
}
func timeFromNano(ns int64) time.Time { return time.Unix(0, ns) }
func convertSquad(v *pb.Squad) CM.Squad {
return CM.Squad{
ID: int(v.GetId()),
Name: v.GetName(),
CreatedAt: timeFromNano(v.GetCreatedAt()),
UpdatedAt: timeFromNano(v.GetUpdatedAt()),
}
}
func convertNode(v *pb.Node) CM.Node {
return CM.Node{
UUID: v.GetUuid(),
Name: v.GetName(),
SquadIDs: toIntSlice(v.GetSquadIds()),
CreatedAt: timeFromNano(v.GetCreatedAt()),
UpdatedAt: timeFromNano(v.GetUpdatedAt()),
}
}
func convertUser(v *pb.User) CM.User {
return CM.User{
ID: int(v.GetId()),
SquadIDs: toIntSlice(v.GetSquadIds()),
Username: v.GetUsername(),
Inbound: v.GetInbound(),
Type: v.GetType(),
UUID: v.GetUuid(),
Password: v.GetPassword(),
Secret: v.GetSecret(),
Flow: v.GetFlow(),
AlterID: int(v.GetAlterId()),
CreatedAt: timeFromNano(v.GetCreatedAt()),
UpdatedAt: timeFromNano(v.GetUpdatedAt()),
}
}
func convertBandwidthLimiter(v *pb.BandwidthLimiter) CM.BandwidthLimiter {
return CM.BandwidthLimiter{
ID: int(v.GetId()),
SquadIDs: toIntSlice(v.GetSquadIds()),
Username: v.GetUsername(),
Outbound: v.GetOutbound(),
Strategy: v.GetStrategy(),
ConnectionType: v.GetConnectionType(),
Mode: v.GetMode(),
FlowKeys: v.GetFlowKeys(),
Speed: v.GetSpeed(),
RawSpeed: v.GetRawSpeed(),
CreatedAt: timeFromNano(v.GetCreatedAt()),
UpdatedAt: timeFromNano(v.GetUpdatedAt()),
}
}
func convertTrafficLimiter(v *pb.TrafficLimiter) CM.TrafficLimiter {
return CM.TrafficLimiter{
ID: int(v.GetId()),
SquadIDs: toIntSlice(v.GetSquadIds()),
Username: v.GetUsername(),
Outbound: v.GetOutbound(),
Strategy: v.GetStrategy(),
Mode: v.GetMode(),
RawUsed: v.GetRawUsed(),
Quota: v.GetQuota(),
RawQuota: v.GetRawQuota(),
CreatedAt: timeFromNano(v.GetCreatedAt()),
UpdatedAt: timeFromNano(v.GetUpdatedAt()),
}
}
func convertConnectionLimiter(v *pb.ConnectionLimiter) CM.ConnectionLimiter {
return CM.ConnectionLimiter{
ID: int(v.GetId()),
SquadIDs: toIntSlice(v.GetSquadIds()),
Username: v.GetUsername(),
Outbound: v.GetOutbound(),
Strategy: v.GetStrategy(),
ConnectionType: v.GetConnectionType(),
LockType: v.GetLockType(),
Count: v.GetCount(),
CreatedAt: timeFromNano(v.GetCreatedAt()),
UpdatedAt: timeFromNano(v.GetUpdatedAt()),
}
}
func convertRateLimiter(v *pb.RateLimiter) CM.RateLimiter {
return CM.RateLimiter{
ID: int(v.GetId()),
SquadIDs: toIntSlice(v.GetSquadIds()),
Username: v.GetUsername(),
Outbound: v.GetOutbound(),
Strategy: v.GetStrategy(),
ConnectionType: v.GetConnectionType(),
Count: v.GetCount(),
Interval: v.GetInterval(),
CreatedAt: timeFromNano(v.GetCreatedAt()),
UpdatedAt: timeFromNano(v.GetUpdatedAt()),
}
}
func convertFilters(filters map[string][]string) *pb.Filters {
values := make(map[string]*pb.StringList, len(filters))
for k, v := range filters {
values[k] = &pb.StringList{Values: append([]string(nil), v...)}
}
return &pb.Filters{Values: values}
}

View File

@@ -0,0 +1,658 @@
package client
import (
CM "github.com/sagernet/sing-box/service/manager/constant"
pb "github.com/sagernet/sing-box/service/manager_api/grpc/manager"
E "github.com/sagernet/sing/common/exceptions"
)
var _ CM.Manager = (*Client)(nil)
func (s *Client) CreateSquad(in CM.SquadCreate) (CM.Squad, error) {
c, err := s.client()
if err != nil {
return CM.Squad{}, err
}
reply, err := c.CreateSquad(s.callContext(), &pb.SquadCreate{Name: in.Name})
if err != nil {
return CM.Squad{}, mapError(err)
}
return convertSquad(reply), nil
}
func (s *Client) GetSquads(filters map[string][]string) ([]CM.Squad, error) {
c, err := s.client()
if err != nil {
return nil, err
}
reply, err := c.GetSquads(s.callContext(), convertFilters(filters))
if err != nil {
return nil, mapError(err)
}
out := make([]CM.Squad, len(reply.GetValues()))
for i, v := range reply.GetValues() {
out[i] = convertSquad(v)
}
return out, nil
}
func (s *Client) GetSquadsCount(filters map[string][]string) (int, error) {
c, err := s.client()
if err != nil {
return 0, err
}
reply, err := c.GetSquadsCount(s.callContext(), convertFilters(filters))
if err != nil {
return 0, mapError(err)
}
return int(reply.GetCount()), nil
}
func (s *Client) GetSquad(id int) (CM.Squad, error) {
c, err := s.client()
if err != nil {
return CM.Squad{}, err
}
reply, err := c.GetSquad(s.callContext(), &pb.IdRequest{Id: int32(id)})
if err != nil {
return CM.Squad{}, mapError(err)
}
return convertSquad(reply), nil
}
func (s *Client) UpdateSquad(id int, in CM.SquadUpdate) (CM.Squad, error) {
c, err := s.client()
if err != nil {
return CM.Squad{}, err
}
reply, err := c.UpdateSquad(s.callContext(), &pb.SquadUpdateRequest{
Id: int32(id),
Update: &pb.SquadUpdate{Name: in.Name},
})
if err != nil {
return CM.Squad{}, mapError(err)
}
return convertSquad(reply), nil
}
func (s *Client) DeleteSquad(id int) (CM.Squad, error) {
c, err := s.client()
if err != nil {
return CM.Squad{}, err
}
reply, err := c.DeleteSquad(s.callContext(), &pb.IdRequest{Id: int32(id)})
if err != nil {
return CM.Squad{}, mapError(err)
}
return convertSquad(reply), nil
}
func (s *Client) CreateNode(in CM.NodeCreate) (CM.Node, error) {
c, err := s.client()
if err != nil {
return CM.Node{}, err
}
reply, err := c.CreateNode(s.callContext(), &pb.NodeCreate{
Uuid: in.UUID,
Name: in.Name,
SquadIds: toInt32Slice(in.SquadIDs),
})
if err != nil {
return CM.Node{}, mapError(err)
}
return convertNode(reply), nil
}
func (s *Client) GetNodes(filters map[string][]string) ([]CM.Node, error) {
c, err := s.client()
if err != nil {
return nil, err
}
reply, err := c.GetNodes(s.callContext(), convertFilters(filters))
if err != nil {
return nil, mapError(err)
}
out := make([]CM.Node, len(reply.GetValues()))
for i, v := range reply.GetValues() {
out[i] = convertNode(v)
}
return out, nil
}
func (s *Client) GetNodesCount(filters map[string][]string) (int, error) {
c, err := s.client()
if err != nil {
return 0, err
}
reply, err := c.GetNodesCount(s.callContext(), convertFilters(filters))
if err != nil {
return 0, mapError(err)
}
return int(reply.GetCount()), nil
}
func (s *Client) GetNode(uuid string) (CM.Node, error) {
c, err := s.client()
if err != nil {
return CM.Node{}, err
}
reply, err := c.GetNode(s.callContext(), &pb.UuidRequest{Uuid: uuid})
if err != nil {
return CM.Node{}, mapError(err)
}
return convertNode(reply), nil
}
func (s *Client) GetNodeStatus(uuid string) (string, error) {
c, err := s.client()
if err != nil {
return "", err
}
reply, err := c.GetNodeStatus(s.callContext(), &pb.UuidRequest{Uuid: uuid})
if err != nil {
return "", mapError(err)
}
return reply.GetStatus(), nil
}
func (s *Client) UpdateNode(uuid string, in CM.NodeUpdate) (CM.Node, error) {
c, err := s.client()
if err != nil {
return CM.Node{}, err
}
reply, err := c.UpdateNode(s.callContext(), &pb.NodeUpdateRequest{
Uuid: uuid,
Update: &pb.NodeUpdate{Name: in.Name},
})
if err != nil {
return CM.Node{}, mapError(err)
}
return convertNode(reply), nil
}
func (s *Client) DeleteNode(uuid string) (CM.Node, error) {
c, err := s.client()
if err != nil {
return CM.Node{}, err
}
reply, err := c.DeleteNode(s.callContext(), &pb.UuidRequest{Uuid: uuid})
if err != nil {
return CM.Node{}, mapError(err)
}
return convertNode(reply), nil
}
func (s *Client) CreateUser(in CM.UserCreate) (CM.User, error) {
c, err := s.client()
if err != nil {
return CM.User{}, err
}
reply, err := c.CreateUser(s.callContext(), &pb.UserCreate{
SquadIds: toInt32Slice(in.SquadIDs),
Username: in.Username,
Inbound: in.Inbound,
Type: in.Type,
Uuid: in.UUID,
Password: in.Password,
Secret: in.Secret,
Flow: in.Flow,
AlterId: int32(in.AlterID),
})
if err != nil {
return CM.User{}, mapError(err)
}
return convertUser(reply), nil
}
func (s *Client) GetUsers(filters map[string][]string) ([]CM.User, error) {
c, err := s.client()
if err != nil {
return nil, err
}
reply, err := c.GetUsers(s.callContext(), convertFilters(filters))
if err != nil {
return nil, mapError(err)
}
out := make([]CM.User, len(reply.GetValues()))
for i, v := range reply.GetValues() {
out[i] = convertUser(v)
}
return out, nil
}
func (s *Client) GetUsersCount(filters map[string][]string) (int, error) {
c, err := s.client()
if err != nil {
return 0, err
}
reply, err := c.GetUsersCount(s.callContext(), convertFilters(filters))
if err != nil {
return 0, mapError(err)
}
return int(reply.GetCount()), nil
}
func (s *Client) GetUser(id int) (CM.User, error) {
c, err := s.client()
if err != nil {
return CM.User{}, err
}
reply, err := c.GetUser(s.callContext(), &pb.IdRequest{Id: int32(id)})
if err != nil {
return CM.User{}, mapError(err)
}
return convertUser(reply), nil
}
func (s *Client) UpdateUser(id int, in CM.UserUpdate) (CM.User, error) {
c, err := s.client()
if err != nil {
return CM.User{}, err
}
reply, err := c.UpdateUser(s.callContext(), &pb.UserUpdateRequest{
Id: int32(id),
Update: &pb.UserUpdate{
Uuid: in.UUID,
Password: in.Password,
Secret: in.Secret,
Flow: in.Flow,
AlterId: int32(in.AlterID),
},
})
if err != nil {
return CM.User{}, mapError(err)
}
return convertUser(reply), nil
}
func (s *Client) DeleteUser(id int) (CM.User, error) {
c, err := s.client()
if err != nil {
return CM.User{}, err
}
reply, err := c.DeleteUser(s.callContext(), &pb.IdRequest{Id: int32(id)})
if err != nil {
return CM.User{}, mapError(err)
}
return convertUser(reply), nil
}
func (s *Client) CreateBandwidthLimiter(in CM.BandwidthLimiterCreate) (CM.BandwidthLimiter, error) {
c, err := s.client()
if err != nil {
return CM.BandwidthLimiter{}, err
}
reply, err := c.CreateBandwidthLimiter(s.callContext(), &pb.BandwidthLimiterCreate{
SquadIds: toInt32Slice(in.SquadIDs),
Username: in.Username,
Outbound: in.Outbound,
Strategy: in.Strategy,
ConnectionType: in.ConnectionType,
Mode: in.Mode,
FlowKeys: in.FlowKeys,
Speed: in.Speed,
})
if err != nil {
return CM.BandwidthLimiter{}, mapError(err)
}
return convertBandwidthLimiter(reply), nil
}
func (s *Client) GetBandwidthLimiters(filters map[string][]string) ([]CM.BandwidthLimiter, error) {
c, err := s.client()
if err != nil {
return nil, err
}
reply, err := c.GetBandwidthLimiters(s.callContext(), convertFilters(filters))
if err != nil {
return nil, mapError(err)
}
out := make([]CM.BandwidthLimiter, len(reply.GetValues()))
for i, v := range reply.GetValues() {
out[i] = convertBandwidthLimiter(v)
}
return out, nil
}
func (s *Client) GetBandwidthLimitersCount(filters map[string][]string) (int, error) {
c, err := s.client()
if err != nil {
return 0, err
}
reply, err := c.GetBandwidthLimitersCount(s.callContext(), convertFilters(filters))
if err != nil {
return 0, mapError(err)
}
return int(reply.GetCount()), nil
}
func (s *Client) GetBandwidthLimiter(id int) (CM.BandwidthLimiter, error) {
c, err := s.client()
if err != nil {
return CM.BandwidthLimiter{}, err
}
reply, err := c.GetBandwidthLimiter(s.callContext(), &pb.IdRequest{Id: int32(id)})
if err != nil {
return CM.BandwidthLimiter{}, mapError(err)
}
return convertBandwidthLimiter(reply), nil
}
func (s *Client) UpdateBandwidthLimiter(id int, in CM.BandwidthLimiterUpdate) (CM.BandwidthLimiter, error) {
c, err := s.client()
if err != nil {
return CM.BandwidthLimiter{}, err
}
reply, err := c.UpdateBandwidthLimiter(s.callContext(), &pb.BandwidthLimiterUpdateRequest{
Id: int32(id),
Update: &pb.BandwidthLimiterUpdate{
Username: in.Username,
Outbound: in.Outbound,
Strategy: in.Strategy,
ConnectionType: in.ConnectionType,
Mode: in.Mode,
FlowKeys: in.FlowKeys,
Speed: in.Speed,
},
})
if err != nil {
return CM.BandwidthLimiter{}, mapError(err)
}
return convertBandwidthLimiter(reply), nil
}
func (s *Client) DeleteBandwidthLimiter(id int) (CM.BandwidthLimiter, error) {
c, err := s.client()
if err != nil {
return CM.BandwidthLimiter{}, err
}
reply, err := c.DeleteBandwidthLimiter(s.callContext(), &pb.IdRequest{Id: int32(id)})
if err != nil {
return CM.BandwidthLimiter{}, mapError(err)
}
return convertBandwidthLimiter(reply), nil
}
func (s *Client) CreateTrafficLimiter(in CM.TrafficLimiterCreate) (CM.TrafficLimiter, error) {
c, err := s.client()
if err != nil {
return CM.TrafficLimiter{}, err
}
reply, err := c.CreateTrafficLimiter(s.callContext(), &pb.TrafficLimiterCreate{
SquadIds: toInt32Slice(in.SquadIDs),
Username: in.Username,
Outbound: in.Outbound,
Strategy: in.Strategy,
Mode: in.Mode,
Quota: in.Quota,
})
if err != nil {
return CM.TrafficLimiter{}, mapError(err)
}
return convertTrafficLimiter(reply), nil
}
func (s *Client) GetTrafficLimiters(filters map[string][]string) ([]CM.TrafficLimiter, error) {
c, err := s.client()
if err != nil {
return nil, err
}
reply, err := c.GetTrafficLimiters(s.callContext(), convertFilters(filters))
if err != nil {
return nil, mapError(err)
}
out := make([]CM.TrafficLimiter, len(reply.GetValues()))
for i, v := range reply.GetValues() {
out[i] = convertTrafficLimiter(v)
}
return out, nil
}
func (s *Client) GetTrafficLimitersCount(filters map[string][]string) (int, error) {
c, err := s.client()
if err != nil {
return 0, err
}
reply, err := c.GetTrafficLimitersCount(s.callContext(), convertFilters(filters))
if err != nil {
return 0, mapError(err)
}
return int(reply.GetCount()), nil
}
func (s *Client) GetTrafficLimiter(id int) (CM.TrafficLimiter, error) {
c, err := s.client()
if err != nil {
return CM.TrafficLimiter{}, err
}
reply, err := c.GetTrafficLimiter(s.callContext(), &pb.IdRequest{Id: int32(id)})
if err != nil {
return CM.TrafficLimiter{}, mapError(err)
}
return convertTrafficLimiter(reply), nil
}
func (s *Client) UpdateTrafficLimiter(id int, in CM.TrafficLimiterUpdate) (CM.TrafficLimiter, error) {
c, err := s.client()
if err != nil {
return CM.TrafficLimiter{}, err
}
reply, err := c.UpdateTrafficLimiter(s.callContext(), &pb.TrafficLimiterUpdateRequest{
Id: int32(id),
Update: &pb.TrafficLimiterUpdate{
Username: in.Username,
Outbound: in.Outbound,
Strategy: in.Strategy,
Mode: in.Mode,
Quota: in.Quota,
},
})
if err != nil {
return CM.TrafficLimiter{}, mapError(err)
}
return convertTrafficLimiter(reply), nil
}
func (s *Client) UpdateTrafficLimiterUsed(_ int, _ uint64) (CM.TrafficLimiter, error) {
return CM.TrafficLimiter{}, E.New("UpdateTrafficLimiterUsed not implemented over gRPC")
}
func (s *Client) DeleteTrafficLimiter(id int) (CM.TrafficLimiter, error) {
c, err := s.client()
if err != nil {
return CM.TrafficLimiter{}, err
}
reply, err := c.DeleteTrafficLimiter(s.callContext(), &pb.IdRequest{Id: int32(id)})
if err != nil {
return CM.TrafficLimiter{}, mapError(err)
}
return convertTrafficLimiter(reply), nil
}
func (s *Client) CreateConnectionLimiter(in CM.ConnectionLimiterCreate) (CM.ConnectionLimiter, error) {
c, err := s.client()
if err != nil {
return CM.ConnectionLimiter{}, err
}
reply, err := c.CreateConnectionLimiter(s.callContext(), &pb.ConnectionLimiterCreate{
SquadIds: toInt32Slice(in.SquadIDs),
Username: in.Username,
Outbound: in.Outbound,
Strategy: in.Strategy,
ConnectionType: in.ConnectionType,
LockType: in.LockType,
Count: in.Count,
})
if err != nil {
return CM.ConnectionLimiter{}, mapError(err)
}
return convertConnectionLimiter(reply), nil
}
func (s *Client) GetConnectionLimiters(filters map[string][]string) ([]CM.ConnectionLimiter, error) {
c, err := s.client()
if err != nil {
return nil, err
}
reply, err := c.GetConnectionLimiters(s.callContext(), convertFilters(filters))
if err != nil {
return nil, mapError(err)
}
out := make([]CM.ConnectionLimiter, len(reply.GetValues()))
for i, v := range reply.GetValues() {
out[i] = convertConnectionLimiter(v)
}
return out, nil
}
func (s *Client) GetConnectionLimitersCount(filters map[string][]string) (int, error) {
c, err := s.client()
if err != nil {
return 0, err
}
reply, err := c.GetConnectionLimitersCount(s.callContext(), convertFilters(filters))
if err != nil {
return 0, mapError(err)
}
return int(reply.GetCount()), nil
}
func (s *Client) GetConnectionLimiter(id int) (CM.ConnectionLimiter, error) {
c, err := s.client()
if err != nil {
return CM.ConnectionLimiter{}, err
}
reply, err := c.GetConnectionLimiter(s.callContext(), &pb.IdRequest{Id: int32(id)})
if err != nil {
return CM.ConnectionLimiter{}, mapError(err)
}
return convertConnectionLimiter(reply), nil
}
func (s *Client) UpdateConnectionLimiter(id int, in CM.ConnectionLimiterUpdate) (CM.ConnectionLimiter, error) {
c, err := s.client()
if err != nil {
return CM.ConnectionLimiter{}, err
}
reply, err := c.UpdateConnectionLimiter(s.callContext(), &pb.ConnectionLimiterUpdateRequest{
Id: int32(id),
Update: &pb.ConnectionLimiterUpdate{
Username: in.Username,
Outbound: in.Outbound,
Strategy: in.Strategy,
ConnectionType: in.ConnectionType,
LockType: in.LockType,
Count: in.Count,
},
})
if err != nil {
return CM.ConnectionLimiter{}, mapError(err)
}
return convertConnectionLimiter(reply), nil
}
func (s *Client) DeleteConnectionLimiter(id int) (CM.ConnectionLimiter, error) {
c, err := s.client()
if err != nil {
return CM.ConnectionLimiter{}, err
}
reply, err := c.DeleteConnectionLimiter(s.callContext(), &pb.IdRequest{Id: int32(id)})
if err != nil {
return CM.ConnectionLimiter{}, mapError(err)
}
return convertConnectionLimiter(reply), nil
}
func (s *Client) CreateRateLimiter(in CM.RateLimiterCreate) (CM.RateLimiter, error) {
c, err := s.client()
if err != nil {
return CM.RateLimiter{}, err
}
reply, err := c.CreateRateLimiter(s.callContext(), &pb.RateLimiterCreate{
SquadIds: toInt32Slice(in.SquadIDs),
Username: in.Username,
Outbound: in.Outbound,
Strategy: in.Strategy,
ConnectionType: in.ConnectionType,
Count: in.Count,
Interval: in.Interval,
})
if err != nil {
return CM.RateLimiter{}, mapError(err)
}
return convertRateLimiter(reply), nil
}
func (s *Client) GetRateLimiters(filters map[string][]string) ([]CM.RateLimiter, error) {
c, err := s.client()
if err != nil {
return nil, err
}
reply, err := c.GetRateLimiters(s.callContext(), convertFilters(filters))
if err != nil {
return nil, mapError(err)
}
out := make([]CM.RateLimiter, len(reply.GetValues()))
for i, v := range reply.GetValues() {
out[i] = convertRateLimiter(v)
}
return out, nil
}
func (s *Client) GetRateLimitersCount(filters map[string][]string) (int, error) {
c, err := s.client()
if err != nil {
return 0, err
}
reply, err := c.GetRateLimitersCount(s.callContext(), convertFilters(filters))
if err != nil {
return 0, mapError(err)
}
return int(reply.GetCount()), nil
}
func (s *Client) GetRateLimiter(id int) (CM.RateLimiter, error) {
c, err := s.client()
if err != nil {
return CM.RateLimiter{}, err
}
reply, err := c.GetRateLimiter(s.callContext(), &pb.IdRequest{Id: int32(id)})
if err != nil {
return CM.RateLimiter{}, mapError(err)
}
return convertRateLimiter(reply), nil
}
func (s *Client) UpdateRateLimiter(id int, in CM.RateLimiterUpdate) (CM.RateLimiter, error) {
c, err := s.client()
if err != nil {
return CM.RateLimiter{}, err
}
reply, err := c.UpdateRateLimiter(s.callContext(), &pb.RateLimiterUpdateRequest{
Id: int32(id),
Update: &pb.RateLimiterUpdate{
Username: in.Username,
Outbound: in.Outbound,
Strategy: in.Strategy,
ConnectionType: in.ConnectionType,
Count: in.Count,
Interval: in.Interval,
},
})
if err != nil {
return CM.RateLimiter{}, mapError(err)
}
return convertRateLimiter(reply), nil
}
func (s *Client) DeleteRateLimiter(id int) (CM.RateLimiter, error) {
c, err := s.client()
if err != nil {
return CM.RateLimiter{}, err
}
reply, err := c.DeleteRateLimiter(s.callContext(), &pb.IdRequest{Id: int32(id)})
if err != nil {
return CM.RateLimiter{}, mapError(err)
}
return convertRateLimiter(reply), nil
}

View File

@@ -0,0 +1,43 @@
package client
import (
"context"
"net"
"github.com/sagernet/sing-box/common/tls"
E "github.com/sagernet/sing/common/exceptions"
"google.golang.org/grpc/credentials"
)
type tlsCreds struct {
config tls.Config
}
func (c tlsCreds) Info() credentials.ProtocolInfo {
return credentials.ProtocolInfo{
SecurityProtocol: "tls",
SecurityVersion: "1.2",
ServerName: c.config.ServerName(),
}
}
func (c *tlsCreds) ClientHandshake(ctx context.Context, authority string, rawConn net.Conn) (net.Conn, credentials.AuthInfo, error) {
conn, err := tls.ClientHandshake(ctx, rawConn, c.config)
if err != nil {
return nil, nil, err
}
return conn, credentials.TLSInfo{State: conn.ConnectionState()}, err
}
func (c *tlsCreds) ServerHandshake(rawConn net.Conn) (net.Conn, credentials.AuthInfo, error) {
return nil, nil, E.New("not implemented")
}
func (c *tlsCreds) Clone() credentials.TransportCredentials {
return &tlsCreds{config: c.config.Clone()}
}
func (c *tlsCreds) OverrideServerName(serverNameOverride string) error {
c.config.SetServerName(serverNameOverride)
return nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,345 @@
syntax = "proto3";
option go_package = "github.com/sagernet/sing-box/service/manager_api/grpc/manager";
package manager_api.v1;
service Manager {
rpc CreateSquad (SquadCreate) returns (Squad);
rpc GetSquads (Filters) returns (SquadList);
rpc GetSquadsCount (Filters) returns (CountReply);
rpc GetSquad (IdRequest) returns (Squad);
rpc UpdateSquad (SquadUpdateRequest) returns (Squad);
rpc DeleteSquad (IdRequest) returns (Squad);
rpc CreateNode (NodeCreate) returns (Node);
rpc GetNodes (Filters) returns (NodeList);
rpc GetNodesCount (Filters) returns (CountReply);
rpc GetNode (UuidRequest) returns (Node);
rpc GetNodeStatus (UuidRequest) returns (NodeStatusReply);
rpc UpdateNode (NodeUpdateRequest) returns (Node);
rpc DeleteNode (UuidRequest) returns (Node);
rpc CreateUser (UserCreate) returns (User);
rpc GetUsers (Filters) returns (UserList);
rpc GetUsersCount (Filters) returns (CountReply);
rpc GetUser (IdRequest) returns (User);
rpc UpdateUser (UserUpdateRequest) returns (User);
rpc DeleteUser (IdRequest) returns (User);
rpc CreateBandwidthLimiter (BandwidthLimiterCreate) returns (BandwidthLimiter);
rpc GetBandwidthLimiters (Filters) returns (BandwidthLimiterList);
rpc GetBandwidthLimitersCount (Filters) returns (CountReply);
rpc GetBandwidthLimiter (IdRequest) returns (BandwidthLimiter);
rpc UpdateBandwidthLimiter (BandwidthLimiterUpdateRequest) returns (BandwidthLimiter);
rpc DeleteBandwidthLimiter (IdRequest) returns (BandwidthLimiter);
rpc CreateTrafficLimiter (TrafficLimiterCreate) returns (TrafficLimiter);
rpc GetTrafficLimiters (Filters) returns (TrafficLimiterList);
rpc GetTrafficLimitersCount (Filters) returns (CountReply);
rpc GetTrafficLimiter (IdRequest) returns (TrafficLimiter);
rpc UpdateTrafficLimiter (TrafficLimiterUpdateRequest) returns (TrafficLimiter);
rpc DeleteTrafficLimiter (IdRequest) returns (TrafficLimiter);
rpc CreateConnectionLimiter (ConnectionLimiterCreate) returns (ConnectionLimiter);
rpc GetConnectionLimiters (Filters) returns (ConnectionLimiterList);
rpc GetConnectionLimitersCount (Filters) returns (CountReply);
rpc GetConnectionLimiter (IdRequest) returns (ConnectionLimiter);
rpc UpdateConnectionLimiter (ConnectionLimiterUpdateRequest) returns (ConnectionLimiter);
rpc DeleteConnectionLimiter (IdRequest) returns (ConnectionLimiter);
rpc CreateRateLimiter (RateLimiterCreate) returns (RateLimiter);
rpc GetRateLimiters (Filters) returns (RateLimiterList);
rpc GetRateLimitersCount (Filters) returns (CountReply);
rpc GetRateLimiter (IdRequest) returns (RateLimiter);
rpc UpdateRateLimiter (RateLimiterUpdateRequest) returns (RateLimiter);
rpc DeleteRateLimiter (IdRequest) returns (RateLimiter);
}
message Squad {
int32 id = 1;
string name = 2;
int64 created_at = 3;
int64 updated_at = 4;
}
message SquadCreate {
string name = 1;
}
message SquadUpdate {
string name = 1;
}
message SquadList {
repeated Squad values = 1;
}
message SquadUpdateRequest {
int32 id = 1;
SquadUpdate update = 2;
}
message Node {
string uuid = 1;
string name = 2;
repeated int32 squad_ids = 3;
int64 created_at = 4;
int64 updated_at = 5;
}
message NodeCreate {
string uuid = 1;
string name = 2;
repeated int32 squad_ids = 3;
}
message NodeUpdate {
string name = 1;
}
message NodeList {
repeated Node values = 1;
}
message NodeUpdateRequest {
string uuid = 1;
NodeUpdate update = 2;
}
message User {
int32 id = 1;
repeated int32 squad_ids = 2;
string username = 3;
string inbound = 4;
string type = 5;
string uuid = 6;
string password = 7;
string secret = 8;
string flow = 9;
int32 alter_id = 10;
int64 created_at = 11;
int64 updated_at = 12;
}
message UserCreate {
repeated int32 squad_ids = 1;
string username = 2;
string inbound = 3;
string type = 4;
string uuid = 5;
string password = 6;
string secret = 7;
string flow = 8;
int32 alter_id = 9;
}
message UserUpdate {
string uuid = 1;
string password = 2;
string secret = 3;
string flow = 4;
int32 alter_id = 5;
}
message UserList {
repeated User values = 1;
}
message UserUpdateRequest {
int32 id = 1;
UserUpdate update = 2;
}
message BandwidthLimiter {
int32 id = 1;
repeated int32 squad_ids = 2;
string username = 3;
string outbound = 4;
string strategy = 5;
string connection_type = 6;
string mode = 7;
repeated string flow_keys = 8;
string speed = 9;
uint64 raw_speed = 10;
int64 created_at = 11;
int64 updated_at = 12;
}
message BandwidthLimiterCreate {
repeated int32 squad_ids = 1;
string username = 2;
string outbound = 3;
string strategy = 4;
string connection_type = 5;
string mode = 6;
repeated string flow_keys = 7;
string speed = 8;
}
message BandwidthLimiterUpdate {
string username = 1;
string outbound = 2;
string strategy = 3;
string connection_type = 4;
string mode = 5;
repeated string flow_keys = 6;
string speed = 7;
}
message BandwidthLimiterList {
repeated BandwidthLimiter values = 1;
}
message BandwidthLimiterUpdateRequest {
int32 id = 1;
BandwidthLimiterUpdate update = 2;
}
message TrafficLimiter {
int32 id = 1;
repeated int32 squad_ids = 2;
string username = 3;
string outbound = 4;
string strategy = 5;
string mode = 6;
uint64 raw_used = 7;
string quota = 8;
uint64 raw_quota = 9;
int64 created_at = 10;
int64 updated_at = 11;
}
message TrafficLimiterCreate {
repeated int32 squad_ids = 1;
string username = 2;
string outbound = 3;
string strategy = 4;
string mode = 5;
string quota = 6;
}
message TrafficLimiterUpdate {
string username = 1;
string outbound = 2;
string strategy = 3;
string mode = 4;
string quota = 5;
}
message TrafficLimiterList {
repeated TrafficLimiter values = 1;
}
message TrafficLimiterUpdateRequest {
int32 id = 1;
TrafficLimiterUpdate update = 2;
}
message ConnectionLimiter {
int32 id = 1;
repeated int32 squad_ids = 2;
string username = 3;
string outbound = 4;
string strategy = 5;
string connection_type = 6;
string lock_type = 7;
uint32 count = 8;
int64 created_at = 9;
int64 updated_at = 10;
}
message ConnectionLimiterCreate {
repeated int32 squad_ids = 1;
string username = 2;
string outbound = 3;
string strategy = 4;
string connection_type = 5;
string lock_type = 6;
uint32 count = 7;
}
message ConnectionLimiterUpdate {
string username = 1;
string outbound = 2;
string strategy = 3;
string connection_type = 4;
string lock_type = 5;
uint32 count = 6;
}
message ConnectionLimiterList {
repeated ConnectionLimiter values = 1;
}
message ConnectionLimiterUpdateRequest {
int32 id = 1;
ConnectionLimiterUpdate update = 2;
}
message RateLimiter {
int32 id = 1;
repeated int32 squad_ids = 2;
string username = 3;
string outbound = 4;
string strategy = 5;
string connection_type = 6;
uint32 count = 7;
string interval = 8;
int64 created_at = 9;
int64 updated_at = 10;
}
message RateLimiterCreate {
repeated int32 squad_ids = 1;
string username = 2;
string outbound = 3;
string strategy = 4;
string connection_type = 5;
uint32 count = 6;
string interval = 7;
}
message RateLimiterUpdate {
string username = 1;
string outbound = 2;
string strategy = 3;
string connection_type = 4;
uint32 count = 5;
string interval = 6;
}
message RateLimiterList {
repeated RateLimiter values = 1;
}
message RateLimiterUpdateRequest {
int32 id = 1;
RateLimiterUpdate update = 2;
}
message IdRequest {
int32 id = 1;
}
message UuidRequest {
string uuid = 1;
}
message CountReply {
int64 count = 1;
}
message NodeStatusReply {
string status = 1;
}
message StringList {
repeated string values = 1;
}
message Filters {
map<string, StringList> values = 1;
}
message Empty {}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,137 @@
package server
import (
CM "github.com/sagernet/sing-box/service/manager/constant"
pb "github.com/sagernet/sing-box/service/manager_api/grpc/manager"
)
func toIntSlice(values []int32) []int {
out := make([]int, len(values))
for i, v := range values {
out[i] = int(v)
}
return out
}
func toInt32Slice(values []int) []int32 {
out := make([]int32, len(values))
for i, v := range values {
out[i] = int32(v)
}
return out
}
func convertSquad(v CM.Squad) *pb.Squad {
return &pb.Squad{
Id: int32(v.ID),
Name: v.Name,
CreatedAt: v.CreatedAt.UnixNano(),
UpdatedAt: v.UpdatedAt.UnixNano(),
}
}
func convertNode(v CM.Node) *pb.Node {
return &pb.Node{
Uuid: v.UUID,
Name: v.Name,
SquadIds: toInt32Slice(v.SquadIDs),
CreatedAt: v.CreatedAt.UnixNano(),
UpdatedAt: v.UpdatedAt.UnixNano(),
}
}
func convertUser(v CM.User) *pb.User {
return &pb.User{
Id: int32(v.ID),
SquadIds: toInt32Slice(v.SquadIDs),
Username: v.Username,
Inbound: v.Inbound,
Type: v.Type,
Uuid: v.UUID,
Password: v.Password,
Secret: v.Secret,
Flow: v.Flow,
AlterId: int32(v.AlterID),
CreatedAt: v.CreatedAt.UnixNano(),
UpdatedAt: v.UpdatedAt.UnixNano(),
}
}
func convertBandwidthLimiter(v CM.BandwidthLimiter) *pb.BandwidthLimiter {
return &pb.BandwidthLimiter{
Id: int32(v.ID),
SquadIds: toInt32Slice(v.SquadIDs),
Username: v.Username,
Outbound: v.Outbound,
Strategy: v.Strategy,
ConnectionType: v.ConnectionType,
Mode: v.Mode,
FlowKeys: v.FlowKeys,
Speed: v.Speed,
RawSpeed: v.RawSpeed,
CreatedAt: v.CreatedAt.UnixNano(),
UpdatedAt: v.UpdatedAt.UnixNano(),
}
}
func convertTrafficLimiter(v CM.TrafficLimiter) *pb.TrafficLimiter {
return &pb.TrafficLimiter{
Id: int32(v.ID),
SquadIds: toInt32Slice(v.SquadIDs),
Username: v.Username,
Outbound: v.Outbound,
Strategy: v.Strategy,
Mode: v.Mode,
RawUsed: v.RawUsed,
Quota: v.Quota,
RawQuota: v.RawQuota,
CreatedAt: v.CreatedAt.UnixNano(),
UpdatedAt: v.UpdatedAt.UnixNano(),
}
}
func convertConnectionLimiter(v CM.ConnectionLimiter) *pb.ConnectionLimiter {
return &pb.ConnectionLimiter{
Id: int32(v.ID),
SquadIds: toInt32Slice(v.SquadIDs),
Username: v.Username,
Outbound: v.Outbound,
Strategy: v.Strategy,
ConnectionType: v.ConnectionType,
LockType: v.LockType,
Count: v.Count,
CreatedAt: v.CreatedAt.UnixNano(),
UpdatedAt: v.UpdatedAt.UnixNano(),
}
}
func convertRateLimiter(v CM.RateLimiter) *pb.RateLimiter {
return &pb.RateLimiter{
Id: int32(v.ID),
SquadIds: toInt32Slice(v.SquadIDs),
Username: v.Username,
Outbound: v.Outbound,
Strategy: v.Strategy,
ConnectionType: v.ConnectionType,
Count: v.Count,
Interval: v.Interval,
CreatedAt: v.CreatedAt.UnixNano(),
UpdatedAt: v.UpdatedAt.UnixNano(),
}
}
func convertFilters(req *pb.Filters) map[string][]string {
filters := map[string][]string{}
for k, v := range req.GetValues() {
filters[k] = append([]string(nil), v.GetValues()...)
}
return filters
}
func convertListFilters(req *pb.Filters) map[string][]string {
filters := convertFilters(req)
if _, ok := filters["limit"]; !ok {
filters["limit"] = []string{"100"}
}
return filters
}

View File

@@ -0,0 +1,465 @@
package server
import (
"context"
CM "github.com/sagernet/sing-box/service/manager/constant"
pb "github.com/sagernet/sing-box/service/manager_api/grpc/manager"
)
func (s *Server) CreateSquad(_ context.Context, req *pb.SquadCreate) (*pb.Squad, error) {
v, err := s.manager.CreateSquad(CM.SquadCreate{Name: req.GetName()})
if err != nil {
return nil, err
}
return convertSquad(v), nil
}
func (s *Server) GetSquads(_ context.Context, req *pb.Filters) (*pb.SquadList, error) {
items, err := s.manager.GetSquads(convertListFilters(req))
if err != nil {
return nil, err
}
out := make([]*pb.Squad, len(items))
for i, v := range items {
out[i] = convertSquad(v)
}
return &pb.SquadList{Values: out}, nil
}
func (s *Server) GetSquadsCount(_ context.Context, req *pb.Filters) (*pb.CountReply, error) {
n, err := s.manager.GetSquadsCount(convertFilters(req))
if err != nil {
return nil, err
}
return &pb.CountReply{Count: int64(n)}, nil
}
func (s *Server) GetSquad(_ context.Context, req *pb.IdRequest) (*pb.Squad, error) {
v, err := s.manager.GetSquad(int(req.GetId()))
if err != nil {
return nil, err
}
return convertSquad(v), nil
}
func (s *Server) UpdateSquad(_ context.Context, req *pb.SquadUpdateRequest) (*pb.Squad, error) {
v, err := s.manager.UpdateSquad(int(req.GetId()), CM.SquadUpdate{Name: req.GetUpdate().GetName()})
if err != nil {
return nil, err
}
return convertSquad(v), nil
}
func (s *Server) DeleteSquad(_ context.Context, req *pb.IdRequest) (*pb.Squad, error) {
v, err := s.manager.DeleteSquad(int(req.GetId()))
if err != nil {
return nil, err
}
return convertSquad(v), nil
}
func (s *Server) CreateNode(_ context.Context, req *pb.NodeCreate) (*pb.Node, error) {
v, err := s.manager.CreateNode(CM.NodeCreate{
UUID: req.GetUuid(),
Name: req.GetName(),
SquadIDs: toIntSlice(req.GetSquadIds()),
})
if err != nil {
return nil, err
}
return convertNode(v), nil
}
func (s *Server) GetNodes(_ context.Context, req *pb.Filters) (*pb.NodeList, error) {
items, err := s.manager.GetNodes(convertListFilters(req))
if err != nil {
return nil, err
}
out := make([]*pb.Node, len(items))
for i, v := range items {
out[i] = convertNode(v)
}
return &pb.NodeList{Values: out}, nil
}
func (s *Server) GetNodesCount(_ context.Context, req *pb.Filters) (*pb.CountReply, error) {
n, err := s.manager.GetNodesCount(convertFilters(req))
if err != nil {
return nil, err
}
return &pb.CountReply{Count: int64(n)}, nil
}
func (s *Server) GetNode(_ context.Context, req *pb.UuidRequest) (*pb.Node, error) {
v, err := s.manager.GetNode(req.GetUuid())
if err != nil {
return nil, err
}
return convertNode(v), nil
}
func (s *Server) GetNodeStatus(_ context.Context, req *pb.UuidRequest) (*pb.NodeStatusReply, error) {
status, err := s.manager.GetNodeStatus(req.GetUuid())
if err != nil {
return nil, err
}
return &pb.NodeStatusReply{Status: status}, nil
}
func (s *Server) UpdateNode(_ context.Context, req *pb.NodeUpdateRequest) (*pb.Node, error) {
v, err := s.manager.UpdateNode(req.GetUuid(), CM.NodeUpdate{Name: req.GetUpdate().GetName()})
if err != nil {
return nil, err
}
return convertNode(v), nil
}
func (s *Server) DeleteNode(_ context.Context, req *pb.UuidRequest) (*pb.Node, error) {
v, err := s.manager.DeleteNode(req.GetUuid())
if err != nil {
return nil, err
}
return convertNode(v), nil
}
func (s *Server) CreateUser(_ context.Context, req *pb.UserCreate) (*pb.User, error) {
v, err := s.manager.CreateUser(CM.UserCreate{
SquadIDs: toIntSlice(req.GetSquadIds()),
Username: req.GetUsername(),
Inbound: req.GetInbound(),
Type: req.GetType(),
UUID: req.GetUuid(),
Password: req.GetPassword(),
Secret: req.GetSecret(),
Flow: req.GetFlow(),
AlterID: int(req.GetAlterId()),
})
if err != nil {
return nil, err
}
return convertUser(v), nil
}
func (s *Server) GetUsers(_ context.Context, req *pb.Filters) (*pb.UserList, error) {
items, err := s.manager.GetUsers(convertListFilters(req))
if err != nil {
return nil, err
}
out := make([]*pb.User, len(items))
for i, v := range items {
out[i] = convertUser(v)
}
return &pb.UserList{Values: out}, nil
}
func (s *Server) GetUsersCount(_ context.Context, req *pb.Filters) (*pb.CountReply, error) {
n, err := s.manager.GetUsersCount(convertFilters(req))
if err != nil {
return nil, err
}
return &pb.CountReply{Count: int64(n)}, nil
}
func (s *Server) GetUser(_ context.Context, req *pb.IdRequest) (*pb.User, error) {
v, err := s.manager.GetUser(int(req.GetId()))
if err != nil {
return nil, err
}
return convertUser(v), nil
}
func (s *Server) UpdateUser(_ context.Context, req *pb.UserUpdateRequest) (*pb.User, error) {
u := req.GetUpdate()
v, err := s.manager.UpdateUser(int(req.GetId()), CM.UserUpdate{
UUID: u.GetUuid(),
Password: u.GetPassword(),
Secret: u.GetSecret(),
Flow: u.GetFlow(),
AlterID: int(u.GetAlterId()),
})
if err != nil {
return nil, err
}
return convertUser(v), nil
}
func (s *Server) DeleteUser(_ context.Context, req *pb.IdRequest) (*pb.User, error) {
v, err := s.manager.DeleteUser(int(req.GetId()))
if err != nil {
return nil, err
}
return convertUser(v), nil
}
func (s *Server) CreateBandwidthLimiter(_ context.Context, req *pb.BandwidthLimiterCreate) (*pb.BandwidthLimiter, error) {
v, err := s.manager.CreateBandwidthLimiter(CM.BandwidthLimiterCreate{
SquadIDs: toIntSlice(req.GetSquadIds()),
Username: req.GetUsername(),
Outbound: req.GetOutbound(),
Strategy: req.GetStrategy(),
ConnectionType: req.GetConnectionType(),
Mode: req.GetMode(),
FlowKeys: req.GetFlowKeys(),
Speed: req.GetSpeed(),
})
if err != nil {
return nil, err
}
return convertBandwidthLimiter(v), nil
}
func (s *Server) GetBandwidthLimiters(_ context.Context, req *pb.Filters) (*pb.BandwidthLimiterList, error) {
items, err := s.manager.GetBandwidthLimiters(convertListFilters(req))
if err != nil {
return nil, err
}
out := make([]*pb.BandwidthLimiter, len(items))
for i, v := range items {
out[i] = convertBandwidthLimiter(v)
}
return &pb.BandwidthLimiterList{Values: out}, nil
}
func (s *Server) GetBandwidthLimitersCount(_ context.Context, req *pb.Filters) (*pb.CountReply, error) {
n, err := s.manager.GetBandwidthLimitersCount(convertFilters(req))
if err != nil {
return nil, err
}
return &pb.CountReply{Count: int64(n)}, nil
}
func (s *Server) GetBandwidthLimiter(_ context.Context, req *pb.IdRequest) (*pb.BandwidthLimiter, error) {
v, err := s.manager.GetBandwidthLimiter(int(req.GetId()))
if err != nil {
return nil, err
}
return convertBandwidthLimiter(v), nil
}
func (s *Server) UpdateBandwidthLimiter(_ context.Context, req *pb.BandwidthLimiterUpdateRequest) (*pb.BandwidthLimiter, error) {
u := req.GetUpdate()
v, err := s.manager.UpdateBandwidthLimiter(int(req.GetId()), CM.BandwidthLimiterUpdate{
Username: u.GetUsername(),
Outbound: u.GetOutbound(),
Strategy: u.GetStrategy(),
ConnectionType: u.GetConnectionType(),
Mode: u.GetMode(),
FlowKeys: u.GetFlowKeys(),
Speed: u.GetSpeed(),
})
if err != nil {
return nil, err
}
return convertBandwidthLimiter(v), nil
}
func (s *Server) DeleteBandwidthLimiter(_ context.Context, req *pb.IdRequest) (*pb.BandwidthLimiter, error) {
v, err := s.manager.DeleteBandwidthLimiter(int(req.GetId()))
if err != nil {
return nil, err
}
return convertBandwidthLimiter(v), nil
}
func (s *Server) CreateTrafficLimiter(_ context.Context, req *pb.TrafficLimiterCreate) (*pb.TrafficLimiter, error) {
v, err := s.manager.CreateTrafficLimiter(CM.TrafficLimiterCreate{
SquadIDs: toIntSlice(req.GetSquadIds()),
Username: req.GetUsername(),
Outbound: req.GetOutbound(),
Strategy: req.GetStrategy(),
Mode: req.GetMode(),
Quota: req.GetQuota(),
})
if err != nil {
return nil, err
}
return convertTrafficLimiter(v), nil
}
func (s *Server) GetTrafficLimiters(_ context.Context, req *pb.Filters) (*pb.TrafficLimiterList, error) {
items, err := s.manager.GetTrafficLimiters(convertListFilters(req))
if err != nil {
return nil, err
}
out := make([]*pb.TrafficLimiter, len(items))
for i, v := range items {
out[i] = convertTrafficLimiter(v)
}
return &pb.TrafficLimiterList{Values: out}, nil
}
func (s *Server) GetTrafficLimitersCount(_ context.Context, req *pb.Filters) (*pb.CountReply, error) {
n, err := s.manager.GetTrafficLimitersCount(convertFilters(req))
if err != nil {
return nil, err
}
return &pb.CountReply{Count: int64(n)}, nil
}
func (s *Server) GetTrafficLimiter(_ context.Context, req *pb.IdRequest) (*pb.TrafficLimiter, error) {
v, err := s.manager.GetTrafficLimiter(int(req.GetId()))
if err != nil {
return nil, err
}
return convertTrafficLimiter(v), nil
}
func (s *Server) UpdateTrafficLimiter(_ context.Context, req *pb.TrafficLimiterUpdateRequest) (*pb.TrafficLimiter, error) {
u := req.GetUpdate()
v, err := s.manager.UpdateTrafficLimiter(int(req.GetId()), CM.TrafficLimiterUpdate{
Username: u.GetUsername(),
Outbound: u.GetOutbound(),
Strategy: u.GetStrategy(),
Mode: u.GetMode(),
Quota: u.GetQuota(),
})
if err != nil {
return nil, err
}
return convertTrafficLimiter(v), nil
}
func (s *Server) DeleteTrafficLimiter(_ context.Context, req *pb.IdRequest) (*pb.TrafficLimiter, error) {
v, err := s.manager.DeleteTrafficLimiter(int(req.GetId()))
if err != nil {
return nil, err
}
return convertTrafficLimiter(v), nil
}
func (s *Server) CreateConnectionLimiter(_ context.Context, req *pb.ConnectionLimiterCreate) (*pb.ConnectionLimiter, error) {
v, err := s.manager.CreateConnectionLimiter(CM.ConnectionLimiterCreate{
SquadIDs: toIntSlice(req.GetSquadIds()),
Username: req.GetUsername(),
Outbound: req.GetOutbound(),
Strategy: req.GetStrategy(),
ConnectionType: req.GetConnectionType(),
LockType: req.GetLockType(),
Count: req.GetCount(),
})
if err != nil {
return nil, err
}
return convertConnectionLimiter(v), nil
}
func (s *Server) GetConnectionLimiters(_ context.Context, req *pb.Filters) (*pb.ConnectionLimiterList, error) {
items, err := s.manager.GetConnectionLimiters(convertListFilters(req))
if err != nil {
return nil, err
}
out := make([]*pb.ConnectionLimiter, len(items))
for i, v := range items {
out[i] = convertConnectionLimiter(v)
}
return &pb.ConnectionLimiterList{Values: out}, nil
}
func (s *Server) GetConnectionLimitersCount(_ context.Context, req *pb.Filters) (*pb.CountReply, error) {
n, err := s.manager.GetConnectionLimitersCount(convertFilters(req))
if err != nil {
return nil, err
}
return &pb.CountReply{Count: int64(n)}, nil
}
func (s *Server) GetConnectionLimiter(_ context.Context, req *pb.IdRequest) (*pb.ConnectionLimiter, error) {
v, err := s.manager.GetConnectionLimiter(int(req.GetId()))
if err != nil {
return nil, err
}
return convertConnectionLimiter(v), nil
}
func (s *Server) UpdateConnectionLimiter(_ context.Context, req *pb.ConnectionLimiterUpdateRequest) (*pb.ConnectionLimiter, error) {
u := req.GetUpdate()
v, err := s.manager.UpdateConnectionLimiter(int(req.GetId()), CM.ConnectionLimiterUpdate{
Username: u.GetUsername(),
Outbound: u.GetOutbound(),
Strategy: u.GetStrategy(),
ConnectionType: u.GetConnectionType(),
LockType: u.GetLockType(),
Count: u.GetCount(),
})
if err != nil {
return nil, err
}
return convertConnectionLimiter(v), nil
}
func (s *Server) DeleteConnectionLimiter(_ context.Context, req *pb.IdRequest) (*pb.ConnectionLimiter, error) {
v, err := s.manager.DeleteConnectionLimiter(int(req.GetId()))
if err != nil {
return nil, err
}
return convertConnectionLimiter(v), nil
}
func (s *Server) CreateRateLimiter(_ context.Context, req *pb.RateLimiterCreate) (*pb.RateLimiter, error) {
v, err := s.manager.CreateRateLimiter(CM.RateLimiterCreate{
SquadIDs: toIntSlice(req.GetSquadIds()),
Username: req.GetUsername(),
Outbound: req.GetOutbound(),
Strategy: req.GetStrategy(),
ConnectionType: req.GetConnectionType(),
Count: req.GetCount(),
Interval: req.GetInterval(),
})
if err != nil {
return nil, err
}
return convertRateLimiter(v), nil
}
func (s *Server) GetRateLimiters(_ context.Context, req *pb.Filters) (*pb.RateLimiterList, error) {
items, err := s.manager.GetRateLimiters(convertListFilters(req))
if err != nil {
return nil, err
}
out := make([]*pb.RateLimiter, len(items))
for i, v := range items {
out[i] = convertRateLimiter(v)
}
return &pb.RateLimiterList{Values: out}, nil
}
func (s *Server) GetRateLimitersCount(_ context.Context, req *pb.Filters) (*pb.CountReply, error) {
n, err := s.manager.GetRateLimitersCount(convertFilters(req))
if err != nil {
return nil, err
}
return &pb.CountReply{Count: int64(n)}, nil
}
func (s *Server) GetRateLimiter(_ context.Context, req *pb.IdRequest) (*pb.RateLimiter, error) {
v, err := s.manager.GetRateLimiter(int(req.GetId()))
if err != nil {
return nil, err
}
return convertRateLimiter(v), nil
}
func (s *Server) UpdateRateLimiter(_ context.Context, req *pb.RateLimiterUpdateRequest) (*pb.RateLimiter, error) {
u := req.GetUpdate()
v, err := s.manager.UpdateRateLimiter(int(req.GetId()), CM.RateLimiterUpdate{
Username: u.GetUsername(),
Outbound: u.GetOutbound(),
Strategy: u.GetStrategy(),
ConnectionType: u.GetConnectionType(),
Count: u.GetCount(),
Interval: u.GetInterval(),
})
if err != nil {
return nil, err
}
return convertRateLimiter(v), nil
}
func (s *Server) DeleteRateLimiter(_ context.Context, req *pb.IdRequest) (*pb.RateLimiter, error) {
v, err := s.manager.DeleteRateLimiter(int(req.GetId()))
if err != nil {
return nil, err
}
return convertRateLimiter(v), nil
}

View File

@@ -0,0 +1,157 @@
package server
import (
"context"
"crypto/subtle"
"errors"
"sync"
"github.com/sagernet/sing-box/adapter"
boxService "github.com/sagernet/sing-box/adapter/service"
"github.com/sagernet/sing-box/common/listener"
"github.com/sagernet/sing-box/common/tls"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
CM "github.com/sagernet/sing-box/service/manager/constant"
pb "github.com/sagernet/sing-box/service/manager_api/grpc/manager"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
N "github.com/sagernet/sing/common/network"
aTLS "github.com/sagernet/sing/common/tls"
"github.com/sagernet/sing/service"
"golang.org/x/net/http2"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
type Server struct {
pb.UnimplementedManagerServer
boxService.Adapter
ctx context.Context
logger log.ContextLogger
listener *listener.Listener
tlsConfig tls.ServerConfig
grpcServer *grpc.Server
manager CM.Manager
options option.ManagerAPIServerOptions
mtx sync.Mutex
}
func NewServer(ctx context.Context, logger log.ContextLogger, tag string, options option.ManagerAPIServerOptions) (*Server, error) {
if options.APIKey == "" {
return nil, E.New("missing api key")
}
return &Server{
Adapter: boxService.NewAdapter(C.TypeManagerAPI, tag),
ctx: ctx,
logger: logger,
listener: listener.New(listener.Options{
Context: ctx,
Logger: logger,
Network: []string{N.NetworkTCP},
Listen: options.ListenOptions,
}),
options: options,
}, nil
}
func (s *Server) Start(stage adapter.StartStage) error {
if stage != adapter.StartStateStart {
return nil
}
boxManager := service.FromContext[adapter.ServiceManager](s.ctx)
managerService, ok := boxManager.Get(s.options.Manager)
if !ok {
return E.New("manager ", s.options.Manager, " not found")
}
s.manager, ok = managerService.(CM.Manager)
if !ok {
return E.New("invalid ", s.options.Manager, " manager")
}
if s.options.TLS != nil {
tlsConfig, err := tls.NewServer(s.ctx, s.logger, common.PtrValueOrDefault(s.options.TLS))
if err != nil {
return err
}
s.tlsConfig = tlsConfig
}
if s.tlsConfig != nil {
if err := s.tlsConfig.Start(); err != nil {
return E.Cause(err, "create TLS config")
}
}
tcpListener, err := s.listener.ListenTCP()
if err != nil {
return err
}
if s.tlsConfig != nil {
if !common.Contains(s.tlsConfig.NextProtos(), http2.NextProtoTLS) {
s.tlsConfig.SetNextProtos(append([]string{"h2"}, s.tlsConfig.NextProtos()...))
}
tcpListener = aTLS.NewListener(tcpListener, s.tlsConfig)
}
s.grpcServer = grpc.NewServer(
grpc.ChainUnaryInterceptor(s.unaryAuthInterceptor, unaryErrorInterceptor),
grpc.StreamInterceptor(s.streamAuthInterceptor),
)
pb.RegisterManagerServer(s.grpcServer, s)
go func() {
if err := s.grpcServer.Serve(tcpListener); err != nil && !errors.Is(err, grpc.ErrServerStopped) {
s.logger.Error("serve error: ", err)
}
}()
return nil
}
func (s *Server) Close() error {
if s.grpcServer != nil {
s.grpcServer.GracefulStop()
}
return common.Close(
common.PtrOrNil(s.listener),
s.tlsConfig,
)
}
func (s *Server) unaryAuthInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
if err := s.authorize(ctx); err != nil {
return nil, err
}
return handler(ctx, req)
}
func (s *Server) streamAuthInterceptor(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
if err := s.authorize(ss.Context()); err != nil {
return err
}
return handler(srv, ss)
}
func unaryErrorInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
resp, err := handler(ctx, req)
if err == CM.ErrNotFound {
return resp, status.Error(codes.NotFound, err.Error())
}
return resp, err
}
func (s *Server) authorize(ctx context.Context) error {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return status.Error(codes.Unauthenticated, "missing api key")
}
values := md.Get("authorization")
if len(values) == 0 {
return status.Error(codes.Unauthenticated, "missing api key")
}
if subtle.ConstantTimeCompare([]byte(values[0]), []byte(s.options.APIKey)) == 0 {
return status.Error(codes.Unauthenticated, "invalid api key")
}
return nil
}

View File

@@ -0,0 +1,133 @@
package client
import (
"bytes"
"context"
"encoding/json"
"io"
"net"
"net/http"
"net/url"
"strconv"
"github.com/sagernet/sing-box/adapter"
boxService "github.com/sagernet/sing-box/adapter/service"
"github.com/sagernet/sing-box/common/dialer"
"github.com/sagernet/sing-box/common/tls"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
CM "github.com/sagernet/sing-box/service/manager/constant"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
M "github.com/sagernet/sing/common/metadata"
)
type Client struct {
boxService.Adapter
ctx context.Context
logger log.ContextLogger
options option.ManagerAPIClientOptions
httpClient *http.Client
baseURL string
}
func NewClient(ctx context.Context, logger log.ContextLogger, tag string, options option.ManagerAPIClientOptions) (*Client, error) {
outboundDialer, err := dialer.New(ctx, options.DialerOptions, options.ServerIsDomain())
if err != nil {
return nil, err
}
transport := &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return outboundDialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
},
}
scheme := "http"
if options.TLS != nil {
tlsConfig, err := tls.NewClient(ctx, logger, options.Server, common.PtrValueOrDefault(options.TLS))
if err != nil {
return nil, err
}
if !common.Contains(tlsConfig.NextProtos(), "http/1.1") {
tlsConfig.SetNextProtos(append([]string{"http/1.1"}, tlsConfig.NextProtos()...))
}
transport.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
rawConn, err := outboundDialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
if err != nil {
return nil, err
}
return tls.ClientHandshake(ctx, rawConn, tlsConfig)
}
scheme = "https"
}
return &Client{
Adapter: boxService.NewAdapter(C.TypeManagerAPI, tag),
ctx: ctx,
logger: logger,
options: options,
httpClient: &http.Client{Transport: transport},
baseURL: scheme + "://" + options.ServerOptions.Build().String() + "/manager/v1",
}, nil
}
func (s *Client) Start(stage adapter.StartStage) error { return nil }
func (s *Client) Close() error {
s.httpClient.CloseIdleConnections()
return nil
}
func (s *Client) doJSON(method, path string, query url.Values, body any, out any) error {
var reqBody io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
reqBody = bytes.NewReader(b)
}
u := s.baseURL + path
if len(query) > 0 {
u += "?" + query.Encode()
}
req, err := http.NewRequestWithContext(s.ctx, method, u, reqBody)
if err != nil {
return err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if s.options.APIKey != "" {
req.Header.Set("Authorization", "Bearer "+s.options.APIKey)
}
resp, err := s.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return CM.ErrNotFound
}
if resp.StatusCode >= 400 {
msg, _ := io.ReadAll(resp.Body)
return E.New("manager-api http ", resp.StatusCode, ": ", string(msg))
}
if out != nil {
return json.NewDecoder(resp.Body).Decode(out)
}
return nil
}
func filtersQuery(filters map[string][]string) url.Values {
q := url.Values{}
for k, vs := range filters {
for _, v := range vs {
q.Add(k, v)
}
}
return q
}
func intPath(id int) string { return "/" + strconv.Itoa(id) }
func stringPath(id string) string { return "/" + id }

View File

@@ -0,0 +1,233 @@
package client
import (
"net/http"
CM "github.com/sagernet/sing-box/service/manager/constant"
)
var _ CM.Manager = (*Client)(nil)
type countReply struct {
Count int `json:"count"`
}
func (s *Client) CreateSquad(in CM.SquadCreate) (CM.Squad, error) {
return postItem[CM.Squad](s, "/squads", in)
}
func (s *Client) GetSquads(f map[string][]string) ([]CM.Squad, error) {
return getList[CM.Squad](s, "/squads", f)
}
func (s *Client) GetSquadsCount(f map[string][]string) (int, error) {
return getCount(s, "/squads", f)
}
func (s *Client) GetSquad(id int) (CM.Squad, error) {
return getItem[CM.Squad](s, "/squads"+intPath(id))
}
func (s *Client) UpdateSquad(id int, in CM.SquadUpdate) (CM.Squad, error) {
return putItem[CM.Squad](s, "/squads"+intPath(id), in)
}
func (s *Client) DeleteSquad(id int) (CM.Squad, error) {
return deleteItem[CM.Squad](s, "/squads"+intPath(id))
}
func (s *Client) CreateNode(in CM.NodeCreate) (CM.Node, error) {
return postItem[CM.Node](s, "/nodes", in)
}
func (s *Client) GetNodes(f map[string][]string) ([]CM.Node, error) {
return getList[CM.Node](s, "/nodes", f)
}
func (s *Client) GetNodesCount(f map[string][]string) (int, error) {
return getCount(s, "/nodes", f)
}
func (s *Client) GetNode(uuid string) (CM.Node, error) {
return getItem[CM.Node](s, "/nodes"+stringPath(uuid))
}
func (s *Client) GetNodeStatus(uuid string) (string, error) {
var reply struct {
Status string `json:"status"`
}
if err := s.doJSON(http.MethodGet, "/nodes"+stringPath(uuid)+"/status", nil, nil, &reply); err != nil {
return "", err
}
return reply.Status, nil
}
func (s *Client) UpdateNode(uuid string, in CM.NodeUpdate) (CM.Node, error) {
return putItem[CM.Node](s, "/nodes"+stringPath(uuid), in)
}
func (s *Client) DeleteNode(uuid string) (CM.Node, error) {
return deleteItem[CM.Node](s, "/nodes"+stringPath(uuid))
}
func (s *Client) CreateUser(in CM.UserCreate) (CM.User, error) {
return postItem[CM.User](s, "/users", in)
}
func (s *Client) GetUsers(f map[string][]string) ([]CM.User, error) {
return getList[CM.User](s, "/users", f)
}
func (s *Client) GetUsersCount(f map[string][]string) (int, error) {
return getCount(s, "/users", f)
}
func (s *Client) GetUser(id int) (CM.User, error) {
return getItem[CM.User](s, "/users"+intPath(id))
}
func (s *Client) UpdateUser(id int, in CM.UserUpdate) (CM.User, error) {
return putItem[CM.User](s, "/users"+intPath(id), in)
}
func (s *Client) DeleteUser(id int) (CM.User, error) {
return deleteItem[CM.User](s, "/users"+intPath(id))
}
func (s *Client) CreateBandwidthLimiter(in CM.BandwidthLimiterCreate) (CM.BandwidthLimiter, error) {
return postItem[CM.BandwidthLimiter](s, "/bandwidth-limiters", in)
}
func (s *Client) GetBandwidthLimiters(f map[string][]string) ([]CM.BandwidthLimiter, error) {
return getList[CM.BandwidthLimiter](s, "/bandwidth-limiters", f)
}
func (s *Client) GetBandwidthLimitersCount(f map[string][]string) (int, error) {
return getCount(s, "/bandwidth-limiters", f)
}
func (s *Client) GetBandwidthLimiter(id int) (CM.BandwidthLimiter, error) {
return getItem[CM.BandwidthLimiter](s, "/bandwidth-limiters"+intPath(id))
}
func (s *Client) UpdateBandwidthLimiter(id int, in CM.BandwidthLimiterUpdate) (CM.BandwidthLimiter, error) {
return putItem[CM.BandwidthLimiter](s, "/bandwidth-limiters"+intPath(id), in)
}
func (s *Client) DeleteBandwidthLimiter(id int) (CM.BandwidthLimiter, error) {
return deleteItem[CM.BandwidthLimiter](s, "/bandwidth-limiters"+intPath(id))
}
func (s *Client) CreateTrafficLimiter(in CM.TrafficLimiterCreate) (CM.TrafficLimiter, error) {
return postItem[CM.TrafficLimiter](s, "/traffic-limiters", in)
}
func (s *Client) GetTrafficLimiters(f map[string][]string) ([]CM.TrafficLimiter, error) {
return getList[CM.TrafficLimiter](s, "/traffic-limiters", f)
}
func (s *Client) GetTrafficLimitersCount(f map[string][]string) (int, error) {
return getCount(s, "/traffic-limiters", f)
}
func (s *Client) GetTrafficLimiter(id int) (CM.TrafficLimiter, error) {
return getItem[CM.TrafficLimiter](s, "/traffic-limiters"+intPath(id))
}
func (s *Client) UpdateTrafficLimiter(id int, in CM.TrafficLimiterUpdate) (CM.TrafficLimiter, error) {
return putItem[CM.TrafficLimiter](s, "/traffic-limiters"+intPath(id), in)
}
func (s *Client) UpdateTrafficLimiterUsed(id int, used uint64) (CM.TrafficLimiter, error) {
return putItem[CM.TrafficLimiter](s, "/traffic-limiters"+intPath(id)+"/used", struct {
Used uint64 `json:"used"`
}{Used: used})
}
func (s *Client) DeleteTrafficLimiter(id int) (CM.TrafficLimiter, error) {
return deleteItem[CM.TrafficLimiter](s, "/traffic-limiters"+intPath(id))
}
func (s *Client) CreateConnectionLimiter(in CM.ConnectionLimiterCreate) (CM.ConnectionLimiter, error) {
return postItem[CM.ConnectionLimiter](s, "/connection-limiters", in)
}
func (s *Client) GetConnectionLimiters(f map[string][]string) ([]CM.ConnectionLimiter, error) {
return getList[CM.ConnectionLimiter](s, "/connection-limiters", f)
}
func (s *Client) GetConnectionLimitersCount(f map[string][]string) (int, error) {
return getCount(s, "/connection-limiters", f)
}
func (s *Client) GetConnectionLimiter(id int) (CM.ConnectionLimiter, error) {
return getItem[CM.ConnectionLimiter](s, "/connection-limiters"+intPath(id))
}
func (s *Client) UpdateConnectionLimiter(id int, in CM.ConnectionLimiterUpdate) (CM.ConnectionLimiter, error) {
return putItem[CM.ConnectionLimiter](s, "/connection-limiters"+intPath(id), in)
}
func (s *Client) DeleteConnectionLimiter(id int) (CM.ConnectionLimiter, error) {
return deleteItem[CM.ConnectionLimiter](s, "/connection-limiters"+intPath(id))
}
func (s *Client) CreateRateLimiter(in CM.RateLimiterCreate) (CM.RateLimiter, error) {
return postItem[CM.RateLimiter](s, "/rate-limiters", in)
}
func (s *Client) GetRateLimiters(f map[string][]string) ([]CM.RateLimiter, error) {
return getList[CM.RateLimiter](s, "/rate-limiters", f)
}
func (s *Client) GetRateLimitersCount(f map[string][]string) (int, error) {
return getCount(s, "/rate-limiters", f)
}
func (s *Client) GetRateLimiter(id int) (CM.RateLimiter, error) {
return getItem[CM.RateLimiter](s, "/rate-limiters"+intPath(id))
}
func (s *Client) UpdateRateLimiter(id int, in CM.RateLimiterUpdate) (CM.RateLimiter, error) {
return putItem[CM.RateLimiter](s, "/rate-limiters"+intPath(id), in)
}
func (s *Client) DeleteRateLimiter(id int) (CM.RateLimiter, error) {
return deleteItem[CM.RateLimiter](s, "/rate-limiters"+intPath(id))
}
func getList[T any](c *Client, path string, filters map[string][]string) ([]T, error) {
var out []T
err := c.doJSON(http.MethodGet, path, filtersQuery(filters), nil, &out)
return out, err
}
func getCount(c *Client, path string, filters map[string][]string) (int, error) {
var out countReply
err := c.doJSON(http.MethodGet, path+"/count", filtersQuery(filters), nil, &out)
return out.Count, err
}
func getItem[T any](c *Client, path string) (T, error) {
var out T
err := c.doJSON(http.MethodGet, path, nil, nil, &out)
return out, err
}
func postItem[T any, In any](c *Client, path string, in In) (T, error) {
var out T
err := c.doJSON(http.MethodPost, path, nil, in, &out)
return out, err
}
func putItem[T any, In any](c *Client, path string, in In) (T, error) {
var out T
err := c.doJSON(http.MethodPut, path, nil, in, &out)
return out, err
}
func deleteItem[T any](c *Client, path string) (T, error) {
var out T
err := c.doJSON(http.MethodDelete, path, nil, nil, &out)
return out, err
}

View File

@@ -0,0 +1,872 @@
openapi: 3.0.3
info:
title: Manager API
version: 1.0.0
servers:
- url: /manager/v1
security:
- BearerAuth: []
tags:
- name: Squads
- name: Nodes
- name: Users
- name: BandwidthLimiters
- name: TrafficLimiters
- name: ConnectionLimiters
- name: RateLimiters
- name: Info
paths:
/version:
get:
tags: [Info]
summary: Server version
responses:
"200":
content:
application/json:
schema:
type: object
required: [version]
properties:
version: {type: string, example: "1.13.11-abc1234"}
/squads:
get:
tags: [Squads]
summary: List squads
parameters:
- {in: query, name: id, schema: {type: integer, format: int32}}
- {$ref: "#/components/parameters/FilterIDIn"}
- {in: query, name: name, schema: {type: string}}
- {$ref: "#/components/parameters/FilterCreatedAtStart"}
- {$ref: "#/components/parameters/FilterCreatedAtEnd"}
- {$ref: "#/components/parameters/FilterUpdatedAtStart"}
- {$ref: "#/components/parameters/FilterUpdatedAtEnd"}
- {$ref: "#/components/parameters/FilterSortAsc"}
- {$ref: "#/components/parameters/FilterSortDesc"}
- {$ref: "#/components/parameters/FilterOffset"}
- {$ref: "#/components/parameters/FilterLimit"}
responses:
"200": {content: {application/json: {schema: {type: array, items: {$ref: "#/components/schemas/Squad"}}}}}
"500": {$ref: "#/components/responses/InternalError"}
post:
tags: [Squads]
summary: Create squad
requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/SquadCreate"}}}}
responses:
"201": {content: {application/json: {schema: {$ref: "#/components/schemas/Squad"}}}}
"400": {$ref: "#/components/responses/BadRequest"}
/squads/count:
get:
tags: [Squads]
summary: Count squads
parameters:
- {in: query, name: id, schema: {type: integer, format: int32}}
- {$ref: "#/components/parameters/FilterIDIn"}
- {in: query, name: name, schema: {type: string}}
- {$ref: "#/components/parameters/FilterCreatedAtStart"}
- {$ref: "#/components/parameters/FilterCreatedAtEnd"}
- {$ref: "#/components/parameters/FilterUpdatedAtStart"}
- {$ref: "#/components/parameters/FilterUpdatedAtEnd"}
- {$ref: "#/components/parameters/FilterSortAsc"}
- {$ref: "#/components/parameters/FilterSortDesc"}
- {$ref: "#/components/parameters/FilterOffset"}
- {$ref: "#/components/parameters/FilterLimit"}
responses:
"200": {$ref: "#/components/responses/Count"}
"500": {$ref: "#/components/responses/InternalError"}
/squads/{id}:
parameters: [{$ref: "#/components/parameters/IntID"}]
get:
tags: [Squads]
summary: Get squad
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/Squad"}}}}
"400": {$ref: "#/components/responses/BadRequest"}
"404": {$ref: "#/components/responses/NotFound"}
"500": {$ref: "#/components/responses/InternalError"}
put:
tags: [Squads]
summary: Update squad
requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/SquadUpdate"}}}}
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/Squad"}}}}
"400": {$ref: "#/components/responses/BadRequest"}
"404": {$ref: "#/components/responses/NotFound"}
delete:
tags: [Squads]
summary: Delete squad
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/Squad"}}}}
"404": {$ref: "#/components/responses/NotFound"}
"500": {$ref: "#/components/responses/InternalError"}
/nodes:
get:
tags: [Nodes]
summary: List nodes
parameters:
- {in: query, name: uuid, schema: {type: string, format: uuid}}
- {in: query, name: name, schema: {type: string}}
- {$ref: "#/components/parameters/FilterSquadIdIn"}
- {$ref: "#/components/parameters/FilterCreatedAtStart"}
- {$ref: "#/components/parameters/FilterCreatedAtEnd"}
- {$ref: "#/components/parameters/FilterUpdatedAtStart"}
- {$ref: "#/components/parameters/FilterUpdatedAtEnd"}
- {$ref: "#/components/parameters/FilterSortAsc"}
- {$ref: "#/components/parameters/FilterSortDesc"}
- {$ref: "#/components/parameters/FilterOffset"}
- {$ref: "#/components/parameters/FilterLimit"}
responses:
"200": {content: {application/json: {schema: {type: array, items: {$ref: "#/components/schemas/Node"}}}}}
"500": {$ref: "#/components/responses/InternalError"}
post:
tags: [Nodes]
summary: Create node
requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/NodeCreate"}}}}
responses:
"201": {content: {application/json: {schema: {$ref: "#/components/schemas/Node"}}}}
"400": {$ref: "#/components/responses/BadRequest"}
/nodes/count:
get:
tags: [Nodes]
summary: Count nodes
parameters:
- {in: query, name: uuid, schema: {type: string, format: uuid}}
- {in: query, name: name, schema: {type: string}}
- {$ref: "#/components/parameters/FilterSquadIdIn"}
- {$ref: "#/components/parameters/FilterCreatedAtStart"}
- {$ref: "#/components/parameters/FilterCreatedAtEnd"}
- {$ref: "#/components/parameters/FilterUpdatedAtStart"}
- {$ref: "#/components/parameters/FilterUpdatedAtEnd"}
- {$ref: "#/components/parameters/FilterSortAsc"}
- {$ref: "#/components/parameters/FilterSortDesc"}
- {$ref: "#/components/parameters/FilterOffset"}
- {$ref: "#/components/parameters/FilterLimit"}
responses:
"200": {$ref: "#/components/responses/Count"}
"500": {$ref: "#/components/responses/InternalError"}
/nodes/{uuid}:
parameters: [{$ref: "#/components/parameters/NodeUUID"}]
get:
tags: [Nodes]
summary: Get node
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/Node"}}}}
"404": {$ref: "#/components/responses/NotFound"}
"500": {$ref: "#/components/responses/InternalError"}
put:
tags: [Nodes]
summary: Update node
requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/NodeUpdate"}}}}
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/Node"}}}}
"400": {$ref: "#/components/responses/BadRequest"}
"404": {$ref: "#/components/responses/NotFound"}
delete:
tags: [Nodes]
summary: Delete node
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/Node"}}}}
"404": {$ref: "#/components/responses/NotFound"}
"500": {$ref: "#/components/responses/InternalError"}
/nodes/{uuid}/status:
parameters: [{$ref: "#/components/parameters/NodeUUID"}]
get:
tags: [Nodes]
summary: Get node status
responses:
"200":
content:
application/json:
schema:
type: object
required: [status]
properties:
status:
type: string
enum: [online, offline]
/users:
get:
tags: [Users]
summary: List users
parameters:
- {in: query, name: id, schema: {type: integer, format: int32}}
- {in: query, name: username, schema: {type: string}}
- {in: query, name: inbound, schema: {type: string}}
- {in: query, name: type, schema: {type: string, enum: [hysteria, hysteria2, mtproxy, trojan, tuic, vless, vmess]}}
- {$ref: "#/components/parameters/FilterSquadIdIn"}
- {$ref: "#/components/parameters/FilterCreatedAtStart"}
- {$ref: "#/components/parameters/FilterCreatedAtEnd"}
- {$ref: "#/components/parameters/FilterUpdatedAtStart"}
- {$ref: "#/components/parameters/FilterUpdatedAtEnd"}
- {$ref: "#/components/parameters/FilterSortAsc"}
- {$ref: "#/components/parameters/FilterSortDesc"}
- {$ref: "#/components/parameters/FilterOffset"}
- {$ref: "#/components/parameters/FilterLimit"}
responses:
"200": {content: {application/json: {schema: {type: array, items: {$ref: "#/components/schemas/User"}}}}}
"500": {$ref: "#/components/responses/InternalError"}
post:
tags: [Users]
summary: Create user
requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/UserCreate"}}}}
responses:
"201": {content: {application/json: {schema: {$ref: "#/components/schemas/User"}}}}
"400": {$ref: "#/components/responses/BadRequest"}
/users/count:
get:
tags: [Users]
summary: Count users
parameters:
- {in: query, name: id, schema: {type: integer, format: int32}}
- {in: query, name: username, schema: {type: string}}
- {in: query, name: inbound, schema: {type: string}}
- {in: query, name: type, schema: {type: string, enum: [hysteria, hysteria2, mtproxy, trojan, tuic, vless, vmess]}}
- {$ref: "#/components/parameters/FilterSquadIdIn"}
- {$ref: "#/components/parameters/FilterCreatedAtStart"}
- {$ref: "#/components/parameters/FilterCreatedAtEnd"}
- {$ref: "#/components/parameters/FilterUpdatedAtStart"}
- {$ref: "#/components/parameters/FilterUpdatedAtEnd"}
- {$ref: "#/components/parameters/FilterSortAsc"}
- {$ref: "#/components/parameters/FilterSortDesc"}
- {$ref: "#/components/parameters/FilterOffset"}
- {$ref: "#/components/parameters/FilterLimit"}
responses:
"200": {$ref: "#/components/responses/Count"}
"500": {$ref: "#/components/responses/InternalError"}
/users/{id}:
parameters: [{$ref: "#/components/parameters/IntID"}]
get:
tags: [Users]
summary: Get user
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/User"}}}}
"404": {$ref: "#/components/responses/NotFound"}
"500": {$ref: "#/components/responses/InternalError"}
put:
tags: [Users]
summary: Update user
requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/UserUpdate"}}}}
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/User"}}}}
"400": {$ref: "#/components/responses/BadRequest"}
"404": {$ref: "#/components/responses/NotFound"}
delete:
tags: [Users]
summary: Delete user
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/User"}}}}
"404": {$ref: "#/components/responses/NotFound"}
"500": {$ref: "#/components/responses/InternalError"}
/bandwidth-limiters:
get:
tags: [BandwidthLimiters]
summary: List bandwidth limiters
parameters:
- {in: query, name: id, schema: {type: integer, format: int32}}
- {in: query, name: strategy, schema: {type: string, enum: [global, connection]}}
- {in: query, name: mode, schema: {type: string, enum: [upload, download, bidirectional]}}
- {in: query, name: type, schema: {type: string}}
- {in: query, name: username, schema: {type: string}}
- {in: query, name: down_start, schema: {type: string}}
- {in: query, name: down_end, schema: {type: string}}
- {in: query, name: up_start, schema: {type: string}}
- {in: query, name: up_end, schema: {type: string}}
- {$ref: "#/components/parameters/FilterSquadIdIn"}
- {$ref: "#/components/parameters/FilterCreatedAtStart"}
- {$ref: "#/components/parameters/FilterCreatedAtEnd"}
- {$ref: "#/components/parameters/FilterUpdatedAtStart"}
- {$ref: "#/components/parameters/FilterUpdatedAtEnd"}
- {$ref: "#/components/parameters/FilterSortAsc"}
- {$ref: "#/components/parameters/FilterSortDesc"}
- {$ref: "#/components/parameters/FilterOffset"}
- {$ref: "#/components/parameters/FilterLimit"}
responses:
"200": {content: {application/json: {schema: {type: array, items: {$ref: "#/components/schemas/BandwidthLimiter"}}}}}
"500": {$ref: "#/components/responses/InternalError"}
post:
tags: [BandwidthLimiters]
summary: Create bandwidth limiter
requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/BandwidthLimiterCreate"}}}}
responses:
"201": {content: {application/json: {schema: {$ref: "#/components/schemas/BandwidthLimiter"}}}}
"400": {$ref: "#/components/responses/BadRequest"}
/bandwidth-limiters/count:
get:
tags: [BandwidthLimiters]
summary: Count bandwidth limiters
parameters:
- {in: query, name: id, schema: {type: integer, format: int32}}
- {in: query, name: strategy, schema: {type: string, enum: [global, connection]}}
- {in: query, name: mode, schema: {type: string, enum: [upload, download, bidirectional]}}
- {in: query, name: type, schema: {type: string}}
- {in: query, name: username, schema: {type: string}}
- {in: query, name: down_start, schema: {type: string}}
- {in: query, name: down_end, schema: {type: string}}
- {in: query, name: up_start, schema: {type: string}}
- {in: query, name: up_end, schema: {type: string}}
- {$ref: "#/components/parameters/FilterSquadIdIn"}
- {$ref: "#/components/parameters/FilterCreatedAtStart"}
- {$ref: "#/components/parameters/FilterCreatedAtEnd"}
- {$ref: "#/components/parameters/FilterUpdatedAtStart"}
- {$ref: "#/components/parameters/FilterUpdatedAtEnd"}
- {$ref: "#/components/parameters/FilterSortAsc"}
- {$ref: "#/components/parameters/FilterSortDesc"}
- {$ref: "#/components/parameters/FilterOffset"}
- {$ref: "#/components/parameters/FilterLimit"}
responses:
"200": {$ref: "#/components/responses/Count"}
"500": {$ref: "#/components/responses/InternalError"}
/bandwidth-limiters/{id}:
parameters: [{$ref: "#/components/parameters/IntID"}]
get:
tags: [BandwidthLimiters]
summary: Get bandwidth limiter
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/BandwidthLimiter"}}}}
"404": {$ref: "#/components/responses/NotFound"}
"500": {$ref: "#/components/responses/InternalError"}
put:
tags: [BandwidthLimiters]
summary: Update bandwidth limiter
requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/BandwidthLimiterUpdate"}}}}
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/BandwidthLimiter"}}}}
"400": {$ref: "#/components/responses/BadRequest"}
"404": {$ref: "#/components/responses/NotFound"}
delete:
tags: [BandwidthLimiters]
summary: Delete bandwidth limiter
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/BandwidthLimiter"}}}}
"404": {$ref: "#/components/responses/NotFound"}
/traffic-limiters:
get:
tags: [TrafficLimiters]
summary: List traffic limiters
parameters:
- {in: query, name: id, schema: {type: integer, format: int32}}
- {in: query, name: username, schema: {type: string}}
- {in: query, name: outbound, schema: {type: string}}
- {in: query, name: mode, schema: {type: string, enum: [upload, download, bidirectional]}}
- {in: query, name: used_start, schema: {type: string}}
- {in: query, name: used_end, schema: {type: string}}
- {in: query, name: quota_start, schema: {type: string}}
- {in: query, name: quota_end, schema: {type: string}}
- {$ref: "#/components/parameters/FilterSquadIdIn"}
- {$ref: "#/components/parameters/FilterCreatedAtStart"}
- {$ref: "#/components/parameters/FilterCreatedAtEnd"}
- {$ref: "#/components/parameters/FilterUpdatedAtStart"}
- {$ref: "#/components/parameters/FilterUpdatedAtEnd"}
- {$ref: "#/components/parameters/FilterSortAsc"}
- {$ref: "#/components/parameters/FilterSortDesc"}
- {$ref: "#/components/parameters/FilterOffset"}
- {$ref: "#/components/parameters/FilterLimit"}
responses:
"200": {content: {application/json: {schema: {type: array, items: {$ref: "#/components/schemas/TrafficLimiter"}}}}}
"500": {$ref: "#/components/responses/InternalError"}
post:
tags: [TrafficLimiters]
summary: Create traffic limiter
requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/TrafficLimiterCreate"}}}}
responses:
"201": {content: {application/json: {schema: {$ref: "#/components/schemas/TrafficLimiter"}}}}
"400": {$ref: "#/components/responses/BadRequest"}
/traffic-limiters/count:
get:
tags: [TrafficLimiters]
summary: Count traffic limiters
parameters:
- {in: query, name: id, schema: {type: integer, format: int32}}
- {in: query, name: username, schema: {type: string}}
- {in: query, name: outbound, schema: {type: string}}
- {in: query, name: mode, schema: {type: string, enum: [upload, download, bidirectional]}}
- {in: query, name: used_start, schema: {type: string}}
- {in: query, name: used_end, schema: {type: string}}
- {in: query, name: quota_start, schema: {type: string}}
- {in: query, name: quota_end, schema: {type: string}}
- {$ref: "#/components/parameters/FilterSquadIdIn"}
- {$ref: "#/components/parameters/FilterCreatedAtStart"}
- {$ref: "#/components/parameters/FilterCreatedAtEnd"}
- {$ref: "#/components/parameters/FilterUpdatedAtStart"}
- {$ref: "#/components/parameters/FilterUpdatedAtEnd"}
- {$ref: "#/components/parameters/FilterSortAsc"}
- {$ref: "#/components/parameters/FilterSortDesc"}
- {$ref: "#/components/parameters/FilterOffset"}
- {$ref: "#/components/parameters/FilterLimit"}
responses:
"200": {$ref: "#/components/responses/Count"}
"500": {$ref: "#/components/responses/InternalError"}
/traffic-limiters/{id}:
parameters: [{$ref: "#/components/parameters/IntID"}]
get:
tags: [TrafficLimiters]
summary: Get traffic limiter
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/TrafficLimiter"}}}}
"404": {$ref: "#/components/responses/NotFound"}
"500": {$ref: "#/components/responses/InternalError"}
put:
tags: [TrafficLimiters]
summary: Update traffic limiter
requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/TrafficLimiterUpdate"}}}}
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/TrafficLimiter"}}}}
"400": {$ref: "#/components/responses/BadRequest"}
"404": {$ref: "#/components/responses/NotFound"}
delete:
tags: [TrafficLimiters]
summary: Delete traffic limiter
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/TrafficLimiter"}}}}
"404": {$ref: "#/components/responses/NotFound"}
/connection-limiters:
get:
tags: [ConnectionLimiters]
summary: List connection limiters
parameters:
- {in: query, name: id, schema: {type: integer, format: int32}}
- {in: query, name: strategy, schema: {type: string, enum: [connection]}}
- {in: query, name: username, schema: {type: string}}
- {in: query, name: outbound, schema: {type: string}}
- {in: query, name: connection_type, schema: {type: string, enum: [default, hwid, mux, ip]}}
- {in: query, name: lock_type, schema: {type: string, enum: [manager, default]}}
- {$ref: "#/components/parameters/FilterSquadIdIn"}
- {$ref: "#/components/parameters/FilterCreatedAtStart"}
- {$ref: "#/components/parameters/FilterCreatedAtEnd"}
- {$ref: "#/components/parameters/FilterUpdatedAtStart"}
- {$ref: "#/components/parameters/FilterUpdatedAtEnd"}
- {$ref: "#/components/parameters/FilterSortAsc"}
- {$ref: "#/components/parameters/FilterSortDesc"}
- {$ref: "#/components/parameters/FilterOffset"}
- {$ref: "#/components/parameters/FilterLimit"}
responses:
"200": {content: {application/json: {schema: {type: array, items: {$ref: "#/components/schemas/ConnectionLimiter"}}}}}
"500": {$ref: "#/components/responses/InternalError"}
post:
tags: [ConnectionLimiters]
summary: Create connection limiter
requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/ConnectionLimiterCreate"}}}}
responses:
"201": {content: {application/json: {schema: {$ref: "#/components/schemas/ConnectionLimiter"}}}}
"400": {$ref: "#/components/responses/BadRequest"}
/connection-limiters/count:
get:
tags: [ConnectionLimiters]
summary: Count connection limiters
parameters:
- {in: query, name: id, schema: {type: integer, format: int32}}
- {in: query, name: strategy, schema: {type: string, enum: [connection]}}
- {in: query, name: username, schema: {type: string}}
- {in: query, name: outbound, schema: {type: string}}
- {in: query, name: connection_type, schema: {type: string, enum: [default, hwid, mux, ip]}}
- {in: query, name: lock_type, schema: {type: string, enum: [manager, default]}}
- {$ref: "#/components/parameters/FilterSquadIdIn"}
- {$ref: "#/components/parameters/FilterCreatedAtStart"}
- {$ref: "#/components/parameters/FilterCreatedAtEnd"}
- {$ref: "#/components/parameters/FilterUpdatedAtStart"}
- {$ref: "#/components/parameters/FilterUpdatedAtEnd"}
- {$ref: "#/components/parameters/FilterSortAsc"}
- {$ref: "#/components/parameters/FilterSortDesc"}
- {$ref: "#/components/parameters/FilterOffset"}
- {$ref: "#/components/parameters/FilterLimit"}
responses:
"200": {$ref: "#/components/responses/Count"}
"500": {$ref: "#/components/responses/InternalError"}
/connection-limiters/{id}:
parameters: [{$ref: "#/components/parameters/IntID"}]
get:
tags: [ConnectionLimiters]
summary: Get connection limiter
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/ConnectionLimiter"}}}}
"404": {$ref: "#/components/responses/NotFound"}
"500": {$ref: "#/components/responses/InternalError"}
put:
tags: [ConnectionLimiters]
summary: Update connection limiter
requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/ConnectionLimiterUpdate"}}}}
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/ConnectionLimiter"}}}}
"400": {$ref: "#/components/responses/BadRequest"}
"404": {$ref: "#/components/responses/NotFound"}
delete:
tags: [ConnectionLimiters]
summary: Delete connection limiter
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/ConnectionLimiter"}}}}
"404": {$ref: "#/components/responses/NotFound"}
/rate-limiters:
get:
tags: [RateLimiters]
summary: List rate limiters
parameters:
- {in: query, name: id, schema: {type: integer, format: int32}}
- {in: query, name: strategy, schema: {type: string, enum: [fixed_window, sliding_window, token_bucket, leaky_bucket]}}
- {in: query, name: username, schema: {type: string}}
- {in: query, name: outbound, schema: {type: string}}
- {in: query, name: connection_type, schema: {type: string, enum: [hwid, mux, ip, default]}}
- {in: query, name: interval, schema: {type: string}}
- {in: query, name: count_start, schema: {type: integer, format: int64}}
- {in: query, name: count_end, schema: {type: integer, format: int64}}
- {$ref: "#/components/parameters/FilterSquadIdIn"}
- {$ref: "#/components/parameters/FilterCreatedAtStart"}
- {$ref: "#/components/parameters/FilterCreatedAtEnd"}
- {$ref: "#/components/parameters/FilterUpdatedAtStart"}
- {$ref: "#/components/parameters/FilterUpdatedAtEnd"}
- {$ref: "#/components/parameters/FilterSortAsc"}
- {$ref: "#/components/parameters/FilterSortDesc"}
- {$ref: "#/components/parameters/FilterOffset"}
- {$ref: "#/components/parameters/FilterLimit"}
responses:
"200": {content: {application/json: {schema: {type: array, items: {$ref: "#/components/schemas/RateLimiter"}}}}}
"500": {$ref: "#/components/responses/InternalError"}
post:
tags: [RateLimiters]
summary: Create rate limiter
requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/RateLimiterCreate"}}}}
responses:
"201": {content: {application/json: {schema: {$ref: "#/components/schemas/RateLimiter"}}}}
"400": {$ref: "#/components/responses/BadRequest"}
/rate-limiters/count:
get:
tags: [RateLimiters]
summary: Count rate limiters
parameters:
- {in: query, name: id, schema: {type: integer, format: int32}}
- {in: query, name: strategy, schema: {type: string, enum: [fixed_window, sliding_window, token_bucket, leaky_bucket]}}
- {in: query, name: username, schema: {type: string}}
- {in: query, name: outbound, schema: {type: string}}
- {in: query, name: connection_type, schema: {type: string, enum: [hwid, mux, ip, default]}}
- {in: query, name: interval, schema: {type: string}}
- {in: query, name: count_start, schema: {type: integer, format: int64}}
- {in: query, name: count_end, schema: {type: integer, format: int64}}
- {$ref: "#/components/parameters/FilterSquadIdIn"}
- {$ref: "#/components/parameters/FilterCreatedAtStart"}
- {$ref: "#/components/parameters/FilterCreatedAtEnd"}
- {$ref: "#/components/parameters/FilterUpdatedAtStart"}
- {$ref: "#/components/parameters/FilterUpdatedAtEnd"}
- {$ref: "#/components/parameters/FilterSortAsc"}
- {$ref: "#/components/parameters/FilterSortDesc"}
- {$ref: "#/components/parameters/FilterOffset"}
- {$ref: "#/components/parameters/FilterLimit"}
responses:
"200": {$ref: "#/components/responses/Count"}
"500": {$ref: "#/components/responses/InternalError"}
/rate-limiters/{id}:
parameters: [{$ref: "#/components/parameters/IntID"}]
get:
tags: [RateLimiters]
summary: Get rate limiter
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/RateLimiter"}}}}
"404": {$ref: "#/components/responses/NotFound"}
"500": {$ref: "#/components/responses/InternalError"}
put:
tags: [RateLimiters]
summary: Update rate limiter
requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/RateLimiterUpdate"}}}}
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/RateLimiter"}}}}
"400": {$ref: "#/components/responses/BadRequest"}
"404": {$ref: "#/components/responses/NotFound"}
delete:
tags: [RateLimiters]
summary: Delete rate limiter
responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/RateLimiter"}}}}
"404": {$ref: "#/components/responses/NotFound"}
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
parameters:
FilterCreatedAtStart: {in: query, name: created_at_start, schema: {type: string, format: date-time}}
FilterCreatedAtEnd: {in: query, name: created_at_end, schema: {type: string, format: date-time}}
FilterUpdatedAtStart: {in: query, name: updated_at_start, schema: {type: string, format: date-time}}
FilterUpdatedAtEnd: {in: query, name: updated_at_end, schema: {type: string, format: date-time}}
FilterSortAsc: {in: query, name: sort_asc, schema: {type: string}}
FilterSortDesc: {in: query, name: sort_desc, schema: {type: string}}
FilterOffset: {in: query, name: offset, schema: {type: integer, format: int64, minimum: 0}}
FilterLimit: {in: query, name: limit, schema: {type: integer, format: int64, minimum: 1}}
FilterSquadIdIn:
in: query
name: squad_id_in
schema: {type: array, items: {type: integer, format: int32}}
style: form
explode: true
FilterIDIn:
in: query
name: id_in
schema: {type: array, items: {type: integer, format: int32}}
style: form
explode: true
IntID:
in: path
name: id
required: true
schema: {type: integer, format: int32, example: 1}
NodeUUID:
in: path
name: uuid
required: true
schema: {type: string, format: uuid, example: "a3b8c9d0-4e2f-4a1b-8c3d-9e7f6a5b4c3d"}
responses:
BadRequest:
content:
text/plain:
schema: {type: string}
NotFound:
InternalError:
content:
text/plain:
schema: {type: string}
Count:
content:
application/json:
schema:
type: object
required: [count]
properties:
count: {type: integer, format: int64, example: 42}
schemas:
SquadIDs:
type: array
minItems: 1
items: {type: integer, format: int32}
example: [1]
Squad:
type: object
required: [id, name, created_at, updated_at]
properties:
id: {type: integer, format: int32}
name: {type: string, example: "default"}
created_at: {type: string, format: date-time}
updated_at: {type: string, format: date-time}
SquadCreate:
type: object
required: [name]
properties:
name: {type: string, example: "default"}
SquadUpdate:
type: object
required: [name]
properties:
name: {type: string, example: "default-renamed"}
Node:
type: object
required: [uuid, name, squad_ids, created_at, updated_at]
properties:
uuid: {type: string, format: uuid}
name: {type: string, example: "node-1"}
squad_ids: {$ref: "#/components/schemas/SquadIDs"}
created_at: {type: string, format: date-time}
updated_at: {type: string, format: date-time}
NodeCreate:
type: object
required: [uuid, name, squad_ids]
properties:
uuid: {type: string, format: uuid}
name: {type: string, example: "node-1"}
squad_ids: {$ref: "#/components/schemas/SquadIDs"}
NodeUpdate:
type: object
required: [name]
properties:
name: {type: string, example: "node-1-renamed"}
User:
type: object
required: [id, squad_ids, username, inbound, type, uuid, password, secret, flow, alter_id, created_at, updated_at]
properties:
id: {type: integer, format: int32}
squad_ids: {$ref: "#/components/schemas/SquadIDs"}
username: {type: string, example: "alice"}
inbound: {type: string, example: "vless-in"}
type: {type: string, enum: [hysteria, hysteria2, mtproxy, trojan, tuic, vless, vmess]}
uuid: {type: string}
password: {type: string}
secret: {type: string}
flow: {type: string}
alter_id: {type: integer, format: int32}
created_at: {type: string, format: date-time}
updated_at: {type: string, format: date-time}
UserCreate:
type: object
required: [squad_ids, username, inbound, type]
properties:
squad_ids: {$ref: "#/components/schemas/SquadIDs"}
username: {type: string, example: "alice"}
inbound: {type: string, example: "vless-in"}
type:
type: string
enum: [hysteria, hysteria2, mtproxy, trojan, tuic, vless, vmess]
uuid: {type: string, format: uuid}
password: {type: string}
secret: {type: string}
flow: {type: string}
alter_id: {type: integer, format: int32}
UserUpdate:
type: object
properties:
uuid: {type: string, format: uuid}
password: {type: string}
secret: {type: string}
flow: {type: string}
alter_id: {type: integer, format: int32}
BandwidthLimiter:
type: object
required: [id, squad_ids, outbound, strategy, mode, speed, raw_speed, created_at, updated_at]
properties:
id: {type: integer, format: int32}
squad_ids: {$ref: "#/components/schemas/SquadIDs"}
username: {type: string}
outbound: {type: string, example: "direct"}
strategy: {type: string, enum: [global, connection]}
connection_type: {type: string, enum: [default, hwid, mux, ip]}
mode: {type: string, enum: [upload, download, bidirectional]}
flow_keys: {type: array, items: {type: string, enum: [user, destination, ip, hwid, mux]}}
speed: {type: string, example: "10mbit"}
raw_speed: {type: integer, format: int64}
created_at: {type: string, format: date-time}
updated_at: {type: string, format: date-time}
BandwidthLimiterCreate:
type: object
required: [squad_ids, outbound, strategy, mode, speed]
properties:
squad_ids: {$ref: "#/components/schemas/SquadIDs"}
username: {type: string}
outbound: {type: string, example: "direct"}
strategy: {type: string, enum: [global, connection]}
connection_type: {type: string, enum: [default, hwid, mux, ip]}
mode: {type: string, enum: [upload, download, bidirectional]}
flow_keys: {type: array, items: {type: string, enum: [user, destination, ip, hwid, mux]}}
speed: {type: string, example: "10mbit"}
BandwidthLimiterUpdate:
type: object
required: [outbound, strategy, mode, speed]
properties:
username: {type: string}
outbound: {type: string}
strategy: {type: string, enum: [global, connection]}
connection_type: {type: string, enum: [default, hwid, mux, ip]}
mode: {type: string, enum: [upload, download, bidirectional]}
flow_keys: {type: array, items: {type: string, enum: [user, destination, ip, hwid, mux]}}
speed: {type: string}
TrafficLimiter:
type: object
required: [id, squad_ids, outbound, strategy, mode, raw_used, quota, raw_quota, created_at, updated_at]
properties:
id: {type: integer, format: int32}
squad_ids: {$ref: "#/components/schemas/SquadIDs"}
username: {type: string}
outbound: {type: string, example: "direct"}
strategy: {type: string, enum: [global, bypass]}
mode: {type: string, enum: [upload, download, bidirectional]}
raw_used: {type: integer, format: int64}
quota: {type: string, example: "10gb"}
raw_quota: {type: integer, format: int64}
created_at: {type: string, format: date-time}
updated_at: {type: string, format: date-time}
TrafficLimiterCreate:
type: object
required: [squad_ids, outbound, strategy, mode, quota]
properties:
squad_ids: {$ref: "#/components/schemas/SquadIDs"}
username: {type: string}
outbound: {type: string, example: "direct"}
strategy: {type: string, enum: [global, bypass]}
mode: {type: string, enum: [upload, download, bidirectional]}
quota: {type: string, example: "10gb"}
TrafficLimiterUpdate:
type: object
required: [outbound, strategy, mode, quota]
properties:
username: {type: string}
outbound: {type: string}
strategy: {type: string, enum: [global, bypass]}
mode: {type: string, enum: [upload, download, bidirectional]}
quota: {type: string}
ConnectionLimiter:
type: object
required: [id, squad_ids, outbound, strategy, lock_type, count, created_at, updated_at]
properties:
id: {type: integer, format: int32}
squad_ids: {$ref: "#/components/schemas/SquadIDs"}
username: {type: string}
outbound: {type: string, example: "direct"}
strategy: {type: string, enum: [connection]}
connection_type: {type: string, enum: [default, hwid, mux, ip]}
lock_type: {type: string, enum: [manager, default]}
count: {type: integer, format: int64}
created_at: {type: string, format: date-time}
updated_at: {type: string, format: date-time}
ConnectionLimiterCreate:
type: object
required: [squad_ids, outbound, strategy, lock_type, count]
properties:
squad_ids: {$ref: "#/components/schemas/SquadIDs"}
username: {type: string}
outbound: {type: string, example: "direct"}
strategy: {type: string, enum: [connection]}
connection_type: {type: string, enum: [default, hwid, mux, ip]}
lock_type: {type: string, enum: [manager, default]}
count: {type: integer, format: int64}
ConnectionLimiterUpdate:
type: object
required: [outbound, strategy, lock_type, count]
properties:
username: {type: string}
outbound: {type: string}
strategy: {type: string, enum: [connection]}
connection_type: {type: string, enum: [default, hwid, mux, ip]}
lock_type: {type: string, enum: [manager, default]}
count: {type: integer, format: int64}
RateLimiter:
type: object
required: [id, squad_ids, outbound, strategy, connection_type, count, interval, created_at, updated_at]
properties:
id: {type: integer, format: int32}
squad_ids: {$ref: "#/components/schemas/SquadIDs"}
username: {type: string}
outbound: {type: string, example: "direct"}
strategy: {type: string, enum: [fixed_window, sliding_window, token_bucket, leaky_bucket]}
connection_type: {type: string, enum: [hwid, mux, ip, default]}
count: {type: integer, format: int64}
interval: {type: string, example: "1s"}
created_at: {type: string, format: date-time}
updated_at: {type: string, format: date-time}
RateLimiterCreate:
type: object
required: [squad_ids, outbound, strategy, connection_type, count, interval]
properties:
squad_ids: {$ref: "#/components/schemas/SquadIDs"}
username: {type: string}
outbound: {type: string, example: "direct"}
strategy: {type: string, enum: [fixed_window, sliding_window, token_bucket, leaky_bucket]}
connection_type: {type: string, enum: [hwid, mux, ip, default]}
count: {type: integer, format: int64}
interval: {type: string, example: "1s"}
RateLimiterUpdate:
type: object
required: [outbound, strategy, connection_type, count, interval]
properties:
username: {type: string}
outbound: {type: string}
strategy: {type: string, enum: [fixed_window, sliding_window, token_bucket, leaky_bucket]}
connection_type: {type: string, enum: [hwid, mux, ip, default]}
count: {type: integer, format: int64}
interval: {type: string}

View File

@@ -0,0 +1,530 @@
package server
import (
"context"
"crypto/subtle"
_ "embed"
"errors"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/sagernet/sing-box/adapter"
boxService "github.com/sagernet/sing-box/adapter/service"
"github.com/sagernet/sing-box/common/listener"
"github.com/sagernet/sing-box/common/tls"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing-box/service/manager/constant"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
N "github.com/sagernet/sing/common/network"
aTLS "github.com/sagernet/sing/common/tls"
sHTTP "github.com/sagernet/sing/protocol/http"
"github.com/sagernet/sing/service"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"golang.org/x/net/http2"
)
type APIServer struct {
boxService.Adapter
ctx context.Context
logger log.ContextLogger
listener *listener.Listener
tlsConfig tls.ServerConfig
httpServer *http.Server
manager constant.Manager
options option.ManagerAPIServerOptions
}
func NewAPIServer(ctx context.Context, logger log.ContextLogger, tag string, options option.ManagerAPIServerOptions) (*APIServer, error) {
if options.APIKey == "" {
return nil, E.New("missing api key")
}
return &APIServer{
Adapter: boxService.NewAdapter(C.TypeManagerAPI, tag),
ctx: ctx,
logger: logger,
listener: listener.New(listener.Options{
Context: ctx,
Logger: logger,
Network: []string{N.NetworkTCP},
Listen: options.ListenOptions,
}),
options: options,
}, nil
}
func (s *APIServer) Start(stage adapter.StartStage) error {
if stage != adapter.StartStateStart {
return nil
}
boxManager := service.FromContext[adapter.ServiceManager](s.ctx)
managerService, ok := boxManager.Get(s.options.Manager)
if !ok {
return E.New("manager ", s.options.Manager, " not found")
}
s.manager, ok = managerService.(constant.Manager)
if !ok {
return E.New("invalid ", s.options.Manager, " manager")
}
chiRouter := chi.NewRouter()
s.Route(chiRouter)
if s.options.TLS != nil {
tlsConfig, err := tls.NewServer(s.ctx, s.logger, common.PtrValueOrDefault(s.options.TLS))
if err != nil {
return err
}
s.tlsConfig = tlsConfig
}
if s.tlsConfig != nil {
err := s.tlsConfig.Start()
if err != nil {
return E.Cause(err, "create TLS config")
}
}
tcpListener, err := s.listener.ListenTCP()
if err != nil {
return err
}
if s.tlsConfig != nil {
if !common.Contains(s.tlsConfig.NextProtos(), http2.NextProtoTLS) {
s.tlsConfig.SetNextProtos(append([]string{"h2"}, s.tlsConfig.NextProtos()...))
}
tcpListener = aTLS.NewListener(tcpListener, s.tlsConfig)
}
s.httpServer = &http.Server{
Handler: chiRouter,
}
go func() {
err = s.httpServer.Serve(tcpListener)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
s.logger.Error("serve error: ", err)
}
}()
return nil
}
func (s *APIServer) Close() error {
return common.Close(
common.PtrOrNil(s.httpServer),
common.PtrOrNil(s.listener),
s.tlsConfig,
)
}
func (s *APIServer) Route(r chi.Router) {
r.Route("/manager/v1", func(r chi.Router) {
r.Use(newCORSMiddleware(s.options.CORS))
r.Use(func(handler http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
s.logger.Debug(request.Method, " ", request.RequestURI, " ", sHTTP.SourceAddress(request))
handler.ServeHTTP(writer, request)
})
})
r.Group(func(r chi.Router) {
r.Use(s.requireAPIKey)
r.Get("/version", func(w http.ResponseWriter, req *http.Request) {
render.JSON(w, req, render.M{
"version": C.Version,
})
})
registerIntCRUD(r, "/squads",
s.manager.GetSquads, s.manager.GetSquadsCount,
s.manager.GetSquad, s.manager.CreateSquad,
s.manager.UpdateSquad, s.manager.DeleteSquad)
registerIntCRUD(r, "/users",
s.manager.GetUsers, s.manager.GetUsersCount,
s.manager.GetUser, s.manager.CreateUser,
s.manager.UpdateUser, s.manager.DeleteUser)
registerIntCRUD(r, "/bandwidth-limiters",
s.manager.GetBandwidthLimiters, s.manager.GetBandwidthLimitersCount,
s.manager.GetBandwidthLimiter, s.manager.CreateBandwidthLimiter,
s.manager.UpdateBandwidthLimiter, s.manager.DeleteBandwidthLimiter)
registerIntCRUD(r, "/traffic-limiters",
s.manager.GetTrafficLimiters, s.manager.GetTrafficLimitersCount,
s.manager.GetTrafficLimiter, s.manager.CreateTrafficLimiter,
s.manager.UpdateTrafficLimiter, s.manager.DeleteTrafficLimiter)
r.Put("/traffic-limiters/{id}/used", s.updateTrafficLimiterUsed)
registerIntCRUD(r, "/connection-limiters",
s.manager.GetConnectionLimiters, s.manager.GetConnectionLimitersCount,
s.manager.GetConnectionLimiter, s.manager.CreateConnectionLimiter,
s.manager.UpdateConnectionLimiter, s.manager.DeleteConnectionLimiter)
registerIntCRUD(r, "/rate-limiters",
s.manager.GetRateLimiters, s.manager.GetRateLimitersCount,
s.manager.GetRateLimiter, s.manager.CreateRateLimiter,
s.manager.UpdateRateLimiter, s.manager.DeleteRateLimiter)
r.Route("/nodes", func(r chi.Router) {
r.Get("/", listHandler(s.manager.GetNodes))
r.Post("/", createHandler(s.manager.CreateNode))
r.Get("/count", countHandler(s.manager.GetNodesCount))
r.Get("/{uuid}", getByStringIDHandler("uuid", s.manager.GetNode))
r.Put("/{uuid}", updateByStringIDHandler("uuid", s.manager.UpdateNode))
r.Delete("/{uuid}", deleteByStringIDHandler("uuid", s.manager.DeleteNode))
r.Get("/{uuid}/status", func(w http.ResponseWriter, req *http.Request) {
status, err := s.manager.GetNodeStatus(chi.URLParam(req, "uuid"))
if err != nil {
writeError(w, req, err)
return
}
render.JSON(w, req, render.M{"status": status})
})
})
})
r.Get("/swagger", func(w http.ResponseWriter, req *http.Request) {
http.Redirect(w, req, req.URL.Path+"/", http.StatusMovedPermanently)
})
r.Get("/swagger/", s.swaggerUI)
r.Get("/swagger/openapi.yaml", s.swaggerSpec)
})
}
// updateTrafficLimiterUsed overwrites the running raw_used counter
// of a traffic limiter. Used by the admin panel "reset traffic" button
// (which posts {"used": 0}); also fine for any operator who needs to
// nudge the counter to a specific number.
func (s *APIServer) updateTrafficLimiterUsed(w http.ResponseWriter, req *http.Request) {
id, err := strconv.Atoi(chi.URLParam(req, "id"))
if err != nil {
writeBadRequest(w, req, err)
return
}
var body struct {
Used uint64 `json:"used"`
}
if err := render.DecodeJSON(req.Body, &body); err != nil {
writeBadRequest(w, req, err)
return
}
item, err := s.manager.UpdateTrafficLimiterUsed(id, body.Used)
if err != nil {
writeUpdateError(w, req, err)
return
}
render.JSON(w, req, item)
}
func (s *APIServer) requireAPIKey(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
header := request.Header.Get("Authorization")
if header == "" {
writer.Header().Set("WWW-Authenticate", `Bearer realm="manager-api"`)
render.Status(request, http.StatusUnauthorized)
render.PlainText(writer, request, "missing api key")
return
}
token := strings.TrimPrefix(header, "Bearer ")
if token == header {
writer.Header().Set("WWW-Authenticate", `Bearer realm="manager-api"`)
render.Status(request, http.StatusUnauthorized)
render.PlainText(writer, request, "invalid api key format")
return
}
if subtle.ConstantTimeCompare([]byte(token), []byte(s.options.APIKey)) == 0 {
render.Status(request, http.StatusUnauthorized)
render.PlainText(writer, request, "invalid api key")
return
}
next.ServeHTTP(writer, request)
})
}
func newCORSMiddleware(cfg *option.ManagerAPICORSOptions) func(http.Handler) http.Handler {
const (
allowedMethods = "GET, POST, PUT, DELETE, OPTIONS"
fallbackHeaders = "Authorization, Content-Type"
)
var (
originSet map[string]struct{}
allowAnyOrigin = true
exposedHeaders string
maxAge = "600"
)
if cfg != nil {
hasWildcard := false
filtered := make([]string, 0, len(cfg.AllowedOrigins))
for _, o := range cfg.AllowedOrigins {
if o == "*" {
hasWildcard = true
continue
}
if o == "" {
continue
}
filtered = append(filtered, o)
}
if len(filtered) > 0 && !hasWildcard {
originSet = make(map[string]struct{}, len(filtered))
for _, o := range filtered {
originSet[o] = struct{}{}
}
allowAnyOrigin = false
}
if len(cfg.ExposedHeaders) > 0 {
exposedHeaders = strings.Join(cfg.ExposedHeaders, ", ")
}
if cfg.MaxAge > 0 {
maxAge = strconv.Itoa(cfg.MaxAge)
}
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
h := w.Header()
h.Set("Vary", "Origin")
emitOrigin := ""
if !allowAnyOrigin {
if _, ok := originSet[origin]; ok {
emitOrigin = origin
}
} else if origin != "" {
emitOrigin = origin
} else {
emitOrigin = "*"
}
if emitOrigin != "" {
h.Set("Access-Control-Allow-Origin", emitOrigin)
}
h.Set("Access-Control-Allow-Methods", allowedMethods)
if reqHeaders := r.Header.Get("Access-Control-Request-Headers"); reqHeaders != "" {
h.Set("Access-Control-Allow-Headers", reqHeaders)
} else {
h.Set("Access-Control-Allow-Headers", fallbackHeaders)
}
if exposedHeaders != "" {
h.Set("Access-Control-Expose-Headers", exposedHeaders)
}
h.Set("Access-Control-Max-Age", maxAge)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
}
func (s *APIServer) swaggerUI(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = writer.Write([]byte(swaggerUIHTML))
}
func (s *APIServer) swaggerSpec(writer http.ResponseWriter, _ *http.Request) {
writer.Header().Set("Content-Type", "application/yaml")
_, _ = writer.Write(openAPISpec)
}
func registerIntCRUD[T any, CR any, UP any](
r chi.Router, path string,
list func(map[string][]string) ([]T, error),
count func(map[string][]string) (int, error),
get func(int) (T, error),
create func(CR) (T, error),
update func(int, UP) (T, error),
del func(int) (T, error),
) {
r.Route(path, func(r chi.Router) {
r.Get("/", listHandler(list))
r.Post("/", createHandler(create))
r.Get("/count", countHandler(count))
r.Get("/{id}", getByIntIDHandler("id", get))
r.Put("/{id}", updateByIntIDHandler("id", update))
r.Delete("/{id}", deleteByIntIDHandler("id", del))
})
}
func listHandler[T any](fn func(map[string][]string) ([]T, error)) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
filters := parseListFilters(req.URL.Query())
applyDefaultLimit(filters)
items, err := fn(filters)
if err != nil {
writeError(w, req, err)
return
}
if items == nil {
items = []T{}
}
render.JSON(w, req, items)
}
}
func applyDefaultLimit(filters map[string][]string) {
if _, ok := filters["limit"]; !ok {
filters["limit"] = []string{"100"}
}
}
func countHandler(fn func(map[string][]string) (int, error)) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
count, err := fn(parseListFilters(req.URL.Query()))
if err != nil {
writeError(w, req, err)
return
}
render.JSON(w, req, render.M{"count": count})
}
}
func parseListFilters(q url.Values) map[string][]string {
out := make(map[string][]string, len(q))
for k, vs := range q {
if !strings.HasSuffix(k, "_in") {
out[k] = vs
continue
}
expanded := make([]string, 0, len(vs))
for _, v := range vs {
s := strings.TrimSpace(v)
s = strings.TrimPrefix(s, "[")
s = strings.TrimSuffix(s, "]")
for _, p := range strings.Split(s, ",") {
p = strings.TrimSpace(p)
if p != "" {
expanded = append(expanded, p)
}
}
}
if len(expanded) == 0 {
continue
}
out[k] = expanded
}
return out
}
func createHandler[T, CR any](fn func(CR) (T, error)) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
var body CR
if err := render.DecodeJSON(req.Body, &body); err != nil {
writeBadRequest(w, req, err)
return
}
item, err := fn(body)
if err != nil {
writeBadRequest(w, req, err)
return
}
render.Status(req, http.StatusCreated)
render.JSON(w, req, item)
}
}
func getByIntIDHandler[T any](idKey string, fn func(int) (T, error)) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
id, err := strconv.Atoi(chi.URLParam(req, idKey))
if err != nil {
writeBadRequest(w, req, err)
return
}
item, err := fn(id)
if err != nil {
writeError(w, req, err)
return
}
render.JSON(w, req, item)
}
}
func getByStringIDHandler[T any](idKey string, fn func(string) (T, error)) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
item, err := fn(chi.URLParam(req, idKey))
if err != nil {
writeError(w, req, err)
return
}
render.JSON(w, req, item)
}
}
func updateByIntIDHandler[T, UP any](idKey string, fn func(int, UP) (T, error)) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
id, err := strconv.Atoi(chi.URLParam(req, idKey))
if err != nil {
writeBadRequest(w, req, err)
return
}
var body UP
if err := render.DecodeJSON(req.Body, &body); err != nil {
writeBadRequest(w, req, err)
return
}
item, err := fn(id, body)
if err != nil {
writeUpdateError(w, req, err)
return
}
render.JSON(w, req, item)
}
}
func updateByStringIDHandler[T, UP any](idKey string, fn func(string, UP) (T, error)) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
var body UP
if err := render.DecodeJSON(req.Body, &body); err != nil {
writeBadRequest(w, req, err)
return
}
item, err := fn(chi.URLParam(req, idKey), body)
if err != nil {
writeUpdateError(w, req, err)
return
}
render.JSON(w, req, item)
}
}
func deleteByIntIDHandler[T any](idKey string, fn func(int) (T, error)) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
id, err := strconv.Atoi(chi.URLParam(req, idKey))
if err != nil {
writeBadRequest(w, req, err)
return
}
item, err := fn(id)
if err != nil {
writeError(w, req, err)
return
}
render.JSON(w, req, item)
}
}
func deleteByStringIDHandler[T any](idKey string, fn func(string) (T, error)) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
item, err := fn(chi.URLParam(req, idKey))
if err != nil {
writeError(w, req, err)
return
}
render.JSON(w, req, item)
}
}
func writeBadRequest(w http.ResponseWriter, req *http.Request, err error) {
render.Status(req, http.StatusBadRequest)
render.PlainText(w, req, err.Error())
}
func writeError(w http.ResponseWriter, req *http.Request, err error) {
if err == constant.ErrNotFound {
w.WriteHeader(http.StatusNotFound)
return
}
render.Status(req, http.StatusInternalServerError)
render.PlainText(w, req, err.Error())
}
func writeUpdateError(w http.ResponseWriter, req *http.Request, err error) {
if err == constant.ErrNotFound {
w.WriteHeader(http.StatusNotFound)
return
}
render.Status(req, http.StatusBadRequest)
render.PlainText(w, req, err.Error())
}

View File

@@ -0,0 +1,145 @@
package server
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/sagernet/sing-box/option"
)
func TestCORSMiddleware_Preflight(t *testing.T) {
called := false
h := newCORSMiddleware(nil)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
called = true
}))
req := httptest.NewRequest(http.MethodOptions, "/manager/v1/squads", nil)
req.Header.Set("Origin", "http://localhost:8081")
req.Header.Set("Access-Control-Request-Method", "GET")
req.Header.Set("Access-Control-Request-Headers", "Authorization, Content-Type")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("preflight status = %d, want 204", rec.Code)
}
if called {
t.Fatal("next handler should not run for OPTIONS preflight")
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:8081" {
t.Fatalf("Access-Control-Allow-Origin = %q, want echoed origin", got)
}
if got := rec.Header().Get("Access-Control-Allow-Headers"); got != "Authorization, Content-Type" {
t.Fatalf("Access-Control-Allow-Headers = %q, want echoed request headers", got)
}
if got := rec.Header().Get("Access-Control-Allow-Methods"); got == "" {
t.Fatal("Access-Control-Allow-Methods should be set")
}
}
func TestCORSMiddleware_PassesThroughGET(t *testing.T) {
called := false
h := newCORSMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/manager/v1/squads/count", nil)
req.Header.Set("Origin", "http://localhost:8081")
req.Header.Set("Authorization", "Bearer test")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if !called {
t.Fatal("next handler should run for GET")
}
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:8081" {
t.Fatalf("Access-Control-Allow-Origin = %q, want echoed origin", got)
}
if got := rec.Header().Get("Vary"); got != "Origin" {
t.Fatalf("Vary = %q, want Origin", got)
}
}
func TestCORSMiddleware_NoOriginFallsBackToWildcard(t *testing.T) {
h := newCORSMiddleware(nil)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
req := httptest.NewRequest(http.MethodGet, "/manager/v1/squads/count", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" {
t.Fatalf("Access-Control-Allow-Origin = %q, want * for missing origin", got)
}
}
func TestCORSMiddleware_AllowedOriginsAllowList(t *testing.T) {
cfg := &option.ManagerAPICORSOptions{
AllowedOrigins: []string{"https://panel.example.com"},
}
h := newCORSMiddleware(cfg)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
allowed := httptest.NewRequest(http.MethodGet, "/manager/v1/squads/count", nil)
allowed.Header.Set("Origin", "https://panel.example.com")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, allowed)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://panel.example.com" {
t.Fatalf("allowed origin = %q, want exact echo", got)
}
denied := httptest.NewRequest(http.MethodGet, "/manager/v1/squads/count", nil)
denied.Header.Set("Origin", "https://attacker.example")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, denied)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Fatalf("denied origin = %q, want empty", got)
}
}
func TestCORSMiddleware_StaticCredentialsHeader(t *testing.T) {
h := newCORSMiddleware(nil)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
req := httptest.NewRequest(http.MethodGet, "/manager/v1/squads/count", nil)
req.Header.Set("Origin", "https://panel.example.com")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "" {
t.Fatalf("Access-Control-Allow-Credentials = %q, want empty (credentials are statically disabled)", got)
}
}
func TestCORSMiddleware_FullConfig(t *testing.T) {
cfg := &option.ManagerAPICORSOptions{
AllowedOrigins: []string{"*"},
ExposedHeaders: []string{"X-Total-Count"},
MaxAge: 120,
}
h := newCORSMiddleware(cfg)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
req := httptest.NewRequest(http.MethodOptions, "/manager/v1/squads", nil)
req.Header.Set("Origin", "https://panel.example.com")
req.Header.Set("Access-Control-Request-Headers", "Authorization, Content-Type")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("preflight status = %d, want 204", rec.Code)
}
if got := rec.Header().Get("Access-Control-Allow-Methods"); got != "GET, POST, PUT, DELETE, OPTIONS" {
t.Fatalf("Allow-Methods = %q, want static methods list", got)
}
if got := rec.Header().Get("Access-Control-Allow-Headers"); got != "Authorization, Content-Type" {
t.Fatalf("Allow-Headers = %q, want echoed request headers", got)
}
if got := rec.Header().Get("Access-Control-Expose-Headers"); got != "X-Total-Count" {
t.Fatalf("Expose-Headers = %q, want %q", got, "X-Total-Count")
}
if got := rec.Header().Get("Access-Control-Max-Age"); got != "120" {
t.Fatalf("Max-Age = %q, want %q", got, "120")
}
}

View File

@@ -0,0 +1,28 @@
package server
import _ "embed"
//go:embed openapi.yaml
var openAPISpec []byte
const swaggerUIHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Manager API - Swagger UI</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
window.onload = () => {
window.ui = SwaggerUIBundle({
url: "./openapi.yaml",
dom_id: "#swagger-ui",
deepLinking: true,
});
};
</script>
</body>
</html>`

View File

@@ -0,0 +1,51 @@
package manager_api
import (
"context"
"github.com/sagernet/sing-box/adapter"
boxService "github.com/sagernet/sing-box/adapter/service"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
grpcClient "github.com/sagernet/sing-box/service/manager_api/grpc/client"
grpcServer "github.com/sagernet/sing-box/service/manager_api/grpc/server"
httpClient "github.com/sagernet/sing-box/service/manager_api/http/client"
httpServer "github.com/sagernet/sing-box/service/manager_api/http/server"
E "github.com/sagernet/sing/common/exceptions"
)
func RegisterService(registry *boxService.Registry) {
boxService.Register[option.ManagerAPIOptions](registry, C.TypeManagerAPI, NewService)
}
func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.ManagerAPIOptions) (adapter.Service, error) {
switch options.APIType {
case C.ManagerAPIServer:
switch options.ProtocolType {
case C.ManagerAPIProtocolHTTP:
return httpServer.NewAPIServer(ctx, logger, tag, options.ServerOptions)
case C.ManagerAPIProtocolGrpc:
return grpcServer.NewServer(ctx, logger, tag, options.ServerOptions)
case "":
return nil, E.New("missing protocol type")
default:
return nil, E.New("unknown protocol type: ", options.ProtocolType)
}
case C.ManagerAPIClient:
switch options.ProtocolType {
case C.ManagerAPIProtocolHTTP:
return httpClient.NewClient(ctx, logger, tag, options.ClientOptions)
case C.ManagerAPIProtocolGrpc:
return grpcClient.NewClient(ctx, logger, tag, options.ClientOptions)
case "":
return nil, E.New("missing protocol type")
default:
return nil, E.New("unknown protocol type: ", options.ProtocolType)
}
case "":
return nil, E.New("missing api type")
default:
return nil, E.New("unknown api type: ", options.APIType)
}
}

View File

@@ -0,0 +1,18 @@
package constant
import (
"github.com/sagernet/sing-box/adapter"
C "github.com/sagernet/sing-box/service/manager/constant"
)
type RateLimiterManager interface {
AddRateLimiterStrategyManager(outbound adapter.Outbound) error
GetRateLimiterStrategyManager(tag string) (RateLimiterStrategyManager, bool)
GetRateLimiterStrategyManagerTags() []string
}
type RateLimiterStrategyManager interface {
UpdateRateLimiter(limiter C.RateLimiter)
UpdateRateLimiters(limiter []C.RateLimiter)
DeleteRateLimiter(username string)
}

View File

@@ -0,0 +1,18 @@
package constant
import (
"github.com/sagernet/sing-box/adapter"
C "github.com/sagernet/sing-box/service/manager/constant"
)
type TrafficLimiterManager interface {
AddTrafficLimiterStrategyManager(outbound adapter.Outbound) error
GetTrafficLimiterStrategyManager(tag string) (TrafficLimiterStrategyManager, bool)
GetTrafficLimiterStrategyManagerTags() []string
}
type TrafficLimiterStrategyManager interface {
UpdateTrafficLimiter(limiter C.TrafficLimiter)
UpdateTrafficLimiters(limiter []C.TrafficLimiter)
DeleteTrafficLimiter(username string)
}

View File

@@ -42,7 +42,7 @@ func (m *HysteriaManager) GetUserManagerTags() []string {
m.access.Lock()
defer m.access.Unlock()
tags := make([]string, 0, len(m.inbounds))
for tag, _ := range m.inbounds {
for tag := range m.inbounds {
tags = append(tags, tag)
}
return tags

View File

@@ -42,7 +42,7 @@ func (m *Hysteria2Manager) GetUserManagerTags() []string {
m.access.Lock()
defer m.access.Unlock()
tags := make([]string, 0, len(m.inbounds))
for tag, _ := range m.inbounds {
for tag := range m.inbounds {
tags = append(tags, tag)
}
return tags

View File

@@ -42,7 +42,7 @@ func (m *MTProxyManager) GetUserManagerTags() []string {
m.access.Lock()
defer m.access.Unlock()
tags := make([]string, 0, len(m.inbounds))
for tag, _ := range m.inbounds {
for tag := range m.inbounds {
tags = append(tags, tag)
}
return tags

View File

@@ -42,7 +42,7 @@ func (m *TrojanManager) GetUserManagerTags() []string {
m.access.Lock()
defer m.access.Unlock()
tags := make([]string, 0, len(m.inbounds))
for tag, _ := range m.inbounds {
for tag := range m.inbounds {
tags = append(tags, tag)
}
return tags

View File

@@ -42,7 +42,7 @@ func (m *TUICManager) GetUserManagerTags() []string {
m.access.Lock()
defer m.access.Unlock()
tags := make([]string, 0, len(m.inbounds))
for tag, _ := range m.inbounds {
for tag := range m.inbounds {
tags = append(tags, tag)
}
return tags

View File

@@ -42,7 +42,7 @@ func (m *VLESSManager) GetUserManagerTags() []string {
m.access.Lock()
defer m.access.Unlock()
tags := make([]string, 0, len(m.inbounds))
for tag, _ := range m.inbounds {
for tag := range m.inbounds {
tags = append(tags, tag)
}
return tags

View File

@@ -42,7 +42,7 @@ func (m *VMessManager) GetUserManagerTags() []string {
m.mtx.Lock()
defer m.mtx.Unlock()
tags := make([]string, 0, len(m.inbounds))
for tag, _ := range m.inbounds {
for tag := range m.inbounds {
tags = append(tags, tag)
}
return tags

View File

@@ -1,9 +1,11 @@
package limiter
import (
"context"
"sync"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/protocol/limiter/bandwidth"
CM "github.com/sagernet/sing-box/service/manager/constant"
"github.com/sagernet/sing-box/service/node/constant"
@@ -15,14 +17,21 @@ type ManagedBandwidthStrategy interface {
}
type BandwidthLimiterManager struct {
ctx context.Context
nodeManager CM.NodeManager
logger log.ContextLogger
managers map[string]*BandwidthLimiterStrategyManager
mtx sync.Mutex
}
func NewBandwidthLimiterManager() *BandwidthLimiterManager {
func NewBandwidthLimiterManager(ctx context.Context, nodeManager CM.NodeManager, logger log.ContextLogger) *BandwidthLimiterManager {
return &BandwidthLimiterManager{
managers: make(map[string]*BandwidthLimiterStrategyManager),
ctx: ctx,
nodeManager: nodeManager,
logger: logger,
managers: make(map[string]*BandwidthLimiterStrategyManager),
}
}
@@ -38,6 +47,7 @@ func (m *BandwidthLimiterManager) AddBandwidthLimiterStrategyManager(outbound ad
return E.New("strategy for outbound ", outbound.Tag(), " is not manager")
}
m.managers[outbound.Tag()] = &BandwidthLimiterStrategyManager{
manager: m,
strategy: strategy,
strategiesMap: make(map[string]bandwidth.BandwidthStrategy),
}
@@ -55,13 +65,14 @@ func (m *BandwidthLimiterManager) GetBandwidthLimiterStrategyManagerTags() []str
m.mtx.Lock()
defer m.mtx.Unlock()
tags := make([]string, 0, len(m.managers))
for tag, _ := range m.managers {
for tag := range m.managers {
tags = append(tags, tag)
}
return tags
}
type BandwidthLimiterStrategyManager struct {
manager *BandwidthLimiterManager
strategy ManagedBandwidthStrategy
strategiesMap map[string]bandwidth.BandwidthStrategy
@@ -75,8 +86,9 @@ func (i *BandwidthLimiterStrategyManager) postUpdate() {
func (i *BandwidthLimiterStrategyManager) UpdateBandwidthLimiter(limiter CM.BandwidthLimiter) {
i.mtx.Lock()
defer i.mtx.Unlock()
strategy, err := bandwidth.CreateStrategy(limiter.Strategy, limiter.Mode, limiter.ConnectionType, limiter.RawSpeed)
strategy, err := bandwidth.CreateStrategy(limiter.Strategy, limiter.Mode, limiter.ConnectionType, limiter.RawSpeed, limiter.FlowKeys)
if err != nil {
i.manager.logger.ErrorContext(i.manager.ctx, err)
return
}
i.strategiesMap[limiter.Username] = strategy
@@ -89,9 +101,10 @@ func (i *BandwidthLimiterStrategyManager) UpdateBandwidthLimiters(limiters []CM.
clear(i.strategiesMap)
newStrategiesMap := make(map[string]bandwidth.BandwidthStrategy)
for _, limiter := range limiters {
strategy, err := bandwidth.CreateStrategy(limiter.Strategy, limiter.Mode, limiter.ConnectionType, limiter.RawSpeed)
strategy, err := bandwidth.CreateStrategy(limiter.Strategy, limiter.Mode, limiter.ConnectionType, limiter.RawSpeed, limiter.FlowKeys)
if err != nil {
return
i.manager.logger.ErrorContext(i.manager.ctx, err)
continue
}
newStrategiesMap[limiter.Username] = strategy
}

View File

@@ -18,18 +18,21 @@ type ManagedConnectionStrategy interface {
}
type ConnectionLimiterManager struct {
ctx context.Context
nodeManager CM.NodeManager
managers map[string]*ConnectionLimiterStrategyManager
logger log.Logger
logger log.ContextLogger
managers map[string]*ConnectionLimiterStrategyManager
mtx sync.Mutex
}
func NewConnectionLimiterManager(nodeManager CM.NodeManager, logger log.Logger) *ConnectionLimiterManager {
func NewConnectionLimiterManager(ctx context.Context, nodeManager CM.NodeManager, logger log.ContextLogger) *ConnectionLimiterManager {
return &ConnectionLimiterManager{
ctx: ctx,
nodeManager: nodeManager,
managers: make(map[string]*ConnectionLimiterStrategyManager),
logger: logger,
managers: make(map[string]*ConnectionLimiterStrategyManager),
}
}
@@ -45,9 +48,9 @@ func (m *ConnectionLimiterManager) AddConnectionLimiterStrategyManager(outbound
return E.New("strategy ", strategy, " is not manager")
}
m.managers[outbound.Tag()] = &ConnectionLimiterStrategyManager{
manager: m,
strategy: strategy,
strategiesMap: make(map[string]connection.ConnectionStrategy),
manager: m,
}
return nil
}
@@ -63,17 +66,17 @@ func (m *ConnectionLimiterManager) GetConnectionLimiterStrategyManagerTags() []s
m.mtx.Lock()
defer m.mtx.Unlock()
tags := make([]string, 0, len(m.managers))
for tag, _ := range m.managers {
for tag := range m.managers {
tags = append(tags, tag)
}
return tags
}
type ConnectionLimiterStrategyManager struct {
manager *ConnectionLimiterManager
strategy ManagedConnectionStrategy
strategiesMap map[string]connection.ConnectionStrategy
tag string
manager *ConnectionLimiterManager
mtx sync.Mutex
}
@@ -87,10 +90,12 @@ func (i *ConnectionLimiterStrategyManager) UpdateConnectionLimiter(limiter CM.Co
defer i.mtx.Unlock()
lock, err := i.createLock(limiter)
if err != nil {
i.manager.logger.ErrorContext(i.manager.ctx, err)
return
}
strategy, err := connection.CreateStrategy(limiter.Strategy, limiter.ConnectionType, lock)
if err != nil {
i.manager.logger.ErrorContext(i.manager.ctx, err)
return
}
i.strategiesMap[limiter.Username] = strategy
@@ -105,11 +110,13 @@ func (i *ConnectionLimiterStrategyManager) UpdateConnectionLimiters(limiters []C
for _, limiter := range limiters {
lock, err := i.createLock(limiter)
if err != nil {
return
i.manager.logger.ErrorContext(i.manager.ctx, err)
continue
}
strategy, err := connection.CreateStrategy(limiter.Strategy, limiter.ConnectionType, lock)
if err != nil {
return
i.manager.logger.ErrorContext(i.manager.ctx, err)
continue
}
newStrategiesMap[limiter.Username] = strategy
}
@@ -128,7 +135,7 @@ func (i *ConnectionLimiterStrategyManager) createLock(limiter CM.ConnectionLimit
switch limiter.LockType {
case "manager":
return i.newManagerLock(limiter.ID), nil
case "":
case "default", "":
return connection.NewDefaultLock(limiter.Count), nil
default:
return nil, E.New("unknown lock type \"", limiter.LockType, "\"")

View File

@@ -0,0 +1,129 @@
package limiter
import (
"context"
"sync"
"time"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/protocol/limiter/rate"
CM "github.com/sagernet/sing-box/service/manager/constant"
"github.com/sagernet/sing-box/service/node/constant"
E "github.com/sagernet/sing/common/exceptions"
)
type ManagedRateStrategy interface {
UpdateStrategies(strategies map[string]rate.RateStrategy)
}
type RateLimiterManager struct {
ctx context.Context
nodeManager CM.NodeManager
logger log.ContextLogger
managers map[string]*RateLimiterStrategyManager
mtx sync.Mutex
}
func NewRateLimiterManager(ctx context.Context, nodeManager CM.NodeManager, logger log.ContextLogger) *RateLimiterManager {
return &RateLimiterManager{
ctx: ctx,
nodeManager: nodeManager,
logger: logger,
managers: make(map[string]*RateLimiterStrategyManager),
}
}
func (m *RateLimiterManager) AddRateLimiterStrategyManager(outbound adapter.Outbound) error {
m.mtx.Lock()
defer m.mtx.Unlock()
limiter, ok := outbound.(*rate.Outbound)
if !ok {
return E.New("invalid rate limiter: ", outbound.Tag())
}
strategy, ok := limiter.GetStrategy().(ManagedRateStrategy)
if !ok {
return E.New("strategy for outbound ", outbound.Tag(), " is not manager")
}
m.managers[outbound.Tag()] = &RateLimiterStrategyManager{
manager: m,
strategy: strategy,
strategiesMap: make(map[string]rate.RateStrategy),
}
return nil
}
func (m *RateLimiterManager) GetRateLimiterStrategyManager(tag string) (constant.RateLimiterStrategyManager, bool) {
m.mtx.Lock()
defer m.mtx.Unlock()
manager, ok := m.managers[tag]
return manager, ok
}
func (m *RateLimiterManager) GetRateLimiterStrategyManagerTags() []string {
m.mtx.Lock()
defer m.mtx.Unlock()
tags := make([]string, 0, len(m.managers))
for tag := range m.managers {
tags = append(tags, tag)
}
return tags
}
type RateLimiterStrategyManager struct {
manager *RateLimiterManager
strategy ManagedRateStrategy
strategiesMap map[string]rate.RateStrategy
mtx sync.Mutex
}
func (i *RateLimiterStrategyManager) postUpdate() {
i.strategy.UpdateStrategies(i.strategiesMap)
}
func (i *RateLimiterStrategyManager) createStrategy(limiter CM.RateLimiter) (rate.RateStrategy, error) {
interval, err := time.ParseDuration(limiter.Interval)
if err != nil {
return nil, err
}
return rate.CreateStrategy(limiter.Strategy, limiter.ConnectionType, int(limiter.Count), interval)
}
func (i *RateLimiterStrategyManager) UpdateRateLimiter(limiter CM.RateLimiter) {
i.mtx.Lock()
defer i.mtx.Unlock()
strategy, err := i.createStrategy(limiter)
if err != nil {
i.manager.logger.ErrorContext(i.manager.ctx, err)
return
}
i.strategiesMap[limiter.Username] = strategy
i.postUpdate()
}
func (i *RateLimiterStrategyManager) UpdateRateLimiters(limiters []CM.RateLimiter) {
i.mtx.Lock()
defer i.mtx.Unlock()
clear(i.strategiesMap)
newStrategiesMap := make(map[string]rate.RateStrategy)
for _, limiter := range limiters {
strategy, err := i.createStrategy(limiter)
if err != nil {
i.manager.logger.ErrorContext(i.manager.ctx, err)
continue
}
newStrategiesMap[limiter.Username] = strategy
}
i.strategiesMap = newStrategiesMap
i.postUpdate()
}
func (i *RateLimiterStrategyManager) DeleteRateLimiter(username string) {
i.mtx.Lock()
defer i.mtx.Unlock()
delete(i.strategiesMap, username)
i.postUpdate()
}

View File

@@ -0,0 +1,216 @@
package limiter
import (
"context"
"sync"
"time"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/protocol/limiter/traffic"
CM "github.com/sagernet/sing-box/service/manager/constant"
"github.com/sagernet/sing-box/service/node/constant"
E "github.com/sagernet/sing/common/exceptions"
)
type ManagedTrafficStrategy interface {
UpdateStrategies(strategies map[string]traffic.TrafficStrategy)
}
type TrafficLimiterManager struct {
ctx context.Context
nodeManager CM.NodeManager
logger log.ContextLogger
managers map[string]*TrafficLimiterStrategyManager
mtx sync.Mutex
}
func NewTrafficLimiterManager(ctx context.Context, nodeManager CM.NodeManager, logger log.ContextLogger) *TrafficLimiterManager {
manager := &TrafficLimiterManager{
ctx: ctx,
nodeManager: nodeManager,
logger: logger,
managers: make(map[string]*TrafficLimiterStrategyManager),
}
go func() {
timer := time.NewTimer(time.Second * 5)
for {
select {
case <-ctx.Done():
return
default:
}
select {
case <-timer.C:
for _, strategyManager := range manager.managers {
strategyManager.mtx.Lock()
for _, limiter := range strategyManager.limiters {
err := limiter.UpdateRemainingTraffic()
if err != nil {
logger.ErrorContext(ctx, err)
}
}
strategyManager.mtx.Unlock()
}
timer.Reset(time.Second * 5)
case <-ctx.Done():
return
}
}
}()
return manager
}
func (m *TrafficLimiterManager) AddTrafficLimiterStrategyManager(outbound adapter.Outbound) error {
m.mtx.Lock()
defer m.mtx.Unlock()
limiter, ok := outbound.(*traffic.Outbound)
if !ok {
return E.New("invalid traffic limiter: ", outbound.Tag())
}
strategy, ok := limiter.GetStrategy().(ManagedTrafficStrategy)
if !ok {
return E.New("strategy ", outbound.Tag(), " is not manager")
}
m.managers[outbound.Tag()] = &TrafficLimiterStrategyManager{
manager: m,
strategy: strategy,
strategiesMap: make(map[string]traffic.TrafficStrategy),
limiters: make(map[string]*TrafficLimiter),
}
return nil
}
func (m *TrafficLimiterManager) GetTrafficLimiterStrategyManager(tag string) (constant.TrafficLimiterStrategyManager, bool) {
m.mtx.Lock()
defer m.mtx.Unlock()
manager, ok := m.managers[tag]
return manager, ok
}
func (m *TrafficLimiterManager) GetTrafficLimiterStrategyManagerTags() []string {
m.mtx.Lock()
defer m.mtx.Unlock()
tags := make([]string, 0, len(m.managers))
for tag := range m.managers {
tags = append(tags, tag)
}
return tags
}
type TrafficLimiterStrategyManager struct {
manager *TrafficLimiterManager
strategy ManagedTrafficStrategy
strategiesMap map[string]traffic.TrafficStrategy
limiters map[string]*TrafficLimiter
mtx sync.Mutex
}
func (i *TrafficLimiterStrategyManager) postUpdate() {
i.strategy.UpdateStrategies(i.strategiesMap)
}
func (i *TrafficLimiterStrategyManager) UpdateTrafficLimiter(limiter CM.TrafficLimiter) {
i.mtx.Lock()
defer i.mtx.Unlock()
trafficLimiter := NewTrafficLimiter(i.manager.nodeManager, limiter)
strategy, err := traffic.CreateStrategy(trafficLimiter, limiter.Strategy, limiter.Mode)
if err != nil {
i.manager.logger.ErrorContext(i.manager.ctx, err)
return
}
i.limiters[limiter.Username] = trafficLimiter
i.strategiesMap[limiter.Username] = strategy
i.postUpdate()
}
func (i *TrafficLimiterStrategyManager) UpdateTrafficLimiters(limiters []CM.TrafficLimiter) {
i.mtx.Lock()
defer i.mtx.Unlock()
clear(i.strategiesMap)
newStrategiesMap := make(map[string]traffic.TrafficStrategy)
for _, limiter := range limiters {
trafficLimiter := NewTrafficLimiter(i.manager.nodeManager, limiter)
strategy, err := traffic.CreateStrategy(trafficLimiter, limiter.Strategy, limiter.Mode)
if err != nil {
i.manager.logger.ErrorContext(i.manager.ctx, err)
continue
}
i.limiters[limiter.Username] = trafficLimiter
newStrategiesMap[limiter.Username] = strategy
}
i.strategiesMap = newStrategiesMap
i.postUpdate()
}
func (i *TrafficLimiterStrategyManager) DeleteTrafficLimiter(username string) {
i.mtx.Lock()
defer i.mtx.Unlock()
delete(i.strategiesMap, username)
i.postUpdate()
}
type TrafficLimiter struct {
manager CM.NodeManager
limiter CM.TrafficLimiter
new uint64
mtx sync.Mutex
}
func NewTrafficLimiter(manager CM.NodeManager, limiter CM.TrafficLimiter) *TrafficLimiter {
return &TrafficLimiter{manager: manager, limiter: limiter}
}
func (l *TrafficLimiter) Can(n uint64) error {
l.mtx.Lock()
defer l.mtx.Unlock()
if l.limiter.RawUsed == l.limiter.RawQuota {
return E.New("traffic limit exceeded")
}
if l.limiter.RawUsed+n > l.limiter.RawQuota {
l.new += l.limiter.RawQuota - l.limiter.RawUsed
l.limiter.RawUsed = l.limiter.RawQuota
return E.New("traffic limit exceeded")
}
return nil
}
func (l *TrafficLimiter) Add(n uint64) error {
l.mtx.Lock()
defer l.mtx.Unlock()
if l.limiter.RawUsed == l.limiter.RawQuota {
return E.New("traffic limit exceeded")
}
if l.limiter.RawUsed+n > l.limiter.RawQuota {
l.new += l.limiter.RawQuota - l.limiter.RawUsed
l.limiter.RawUsed = l.limiter.RawQuota
return E.New("traffic limit exceeded")
}
l.limiter.RawUsed += n
l.new += n
return nil
}
func (l *TrafficLimiter) UpdateRemainingTraffic() error {
l.mtx.Lock()
if l.new == 0 {
l.mtx.Unlock()
return nil
}
new := l.new
l.new = 0
l.mtx.Unlock()
newUsed, err := l.manager.AddTrafficUsage(l.limiter.ID, new)
l.mtx.Lock()
defer l.mtx.Unlock()
if err == nil {
l.limiter.RawUsed = newUsed
} else {
l.new += new
}
return nil
}

View File

@@ -28,6 +28,8 @@ type Service struct {
inboundManagers map[string]constant.InboundManager
bandwidthManager constant.BandwidthLimiterManager
connectionManager constant.ConnectionLimiterManager
trafficManager constant.TrafficLimiterManager
rateManager constant.RateLimiterManager
options option.NodeServiceOptions
mtx sync.Mutex
@@ -60,13 +62,16 @@ func (s *Service) Start(stage adapter.StartStage) error {
s.inboundManagers = map[string]constant.InboundManager{
"hysteria": inbound.NewHysteriaManager(),
"hysteria2": inbound.NewHysteria2Manager(),
"mtproxy": inbound.NewMTProxyManager(),
"trojan": inbound.NewTrojanManager(),
"tuic": inbound.NewTUICManager(),
"vless": inbound.NewVLESSManager(),
"vmess": inbound.NewVMessManager(),
}
s.connectionManager = limiter.NewConnectionLimiterManager(nodeManager, s.logger)
s.bandwidthManager = limiter.NewBandwidthLimiterManager()
s.connectionManager = limiter.NewConnectionLimiterManager(s.ctx, nodeManager, s.logger)
s.bandwidthManager = limiter.NewBandwidthLimiterManager(s.ctx, nodeManager, s.logger)
s.trafficManager = limiter.NewTrafficLimiterManager(s.ctx, nodeManager, s.logger)
s.rateManager = limiter.NewRateLimiterManager(s.ctx, nodeManager, s.logger)
for _, tag := range s.options.Inbounds {
inbound, ok := inboundManager.Get(tag)
if !ok {
@@ -94,13 +99,33 @@ func (s *Service) Start(stage adapter.StartStage) error {
for _, limiter := range s.options.BandwidthLimiters {
outbound, ok := outboundManager.Outbound(limiter)
if !ok {
return E.New("outbound ", limiter, " not found")
return E.New("outbound " + limiter + " not found")
}
err := s.bandwidthManager.AddBandwidthLimiterStrategyManager(outbound)
if err != nil {
return err
}
}
for _, limiter := range s.options.TrafficLimiters {
outbound, ok := outboundManager.Outbound(limiter)
if !ok {
return E.New("outbound ", limiter, " not found")
}
err := s.trafficManager.AddTrafficLimiterStrategyManager(outbound)
if err != nil {
return err
}
}
for _, limiter := range s.options.RateLimiters {
outbound, ok := outboundManager.Outbound(limiter)
if !ok {
return E.New("outbound ", limiter, " not found")
}
err := s.rateManager.AddRateLimiterStrategyManager(outbound)
if err != nil {
return err
}
}
return nodeManager.AddNode(s.options.UUID, s)
}
@@ -222,6 +247,70 @@ func (s *Service) DeleteBandwidthLimiter(limiter CM.BandwidthLimiter) {
manager.DeleteBandwidthLimiter(limiter.Username)
}
func (s *Service) UpdateTrafficLimiter(limiter CM.TrafficLimiter) {
s.mtx.Lock()
defer s.mtx.Unlock()
manager, ok := s.trafficManager.GetTrafficLimiterStrategyManager(limiter.Outbound)
if !ok {
return
}
manager.UpdateTrafficLimiter(limiter)
}
func (s *Service) UpdateTrafficLimiters(limiters []CM.TrafficLimiter) {
s.mtx.Lock()
defer s.mtx.Unlock()
for _, limiter := range limiters {
manager, ok := s.trafficManager.GetTrafficLimiterStrategyManager(limiter.Outbound)
if !ok {
continue
}
manager.UpdateTrafficLimiters(limiters)
}
}
func (s *Service) DeleteTrafficLimiter(limiter CM.TrafficLimiter) {
s.mtx.Lock()
defer s.mtx.Unlock()
manager, ok := s.trafficManager.GetTrafficLimiterStrategyManager(limiter.Outbound)
if !ok {
return
}
manager.DeleteTrafficLimiter(limiter.Username)
}
func (s *Service) UpdateRateLimiter(limiter CM.RateLimiter) {
s.mtx.Lock()
defer s.mtx.Unlock()
manager, ok := s.rateManager.GetRateLimiterStrategyManager(limiter.Outbound)
if !ok {
return
}
manager.UpdateRateLimiter(limiter)
}
func (s *Service) UpdateRateLimiters(limiters []CM.RateLimiter) {
s.mtx.Lock()
defer s.mtx.Unlock()
for _, limiter := range limiters {
manager, ok := s.rateManager.GetRateLimiterStrategyManager(limiter.Outbound)
if !ok {
continue
}
manager.UpdateRateLimiters(limiters)
}
}
func (s *Service) DeleteRateLimiter(limiter CM.RateLimiter) {
s.mtx.Lock()
defer s.mtx.Unlock()
manager, ok := s.rateManager.GetRateLimiterStrategyManager(limiter.Outbound)
if !ok {
return
}
manager.DeleteRateLimiter(limiter.Username)
}
func (s *Service) IsLocal() bool {
return true
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,104 +0,0 @@
syntax = "proto3";
option go_package = "github.com/sagernet/sing-box/service/remotemanager/manager";
package manager.v1;
service Manager {
rpc AddNode (Node) returns (stream NodeData);
rpc AcquireLock(AcquireLockRequest) returns (LockData);
rpc RefreshLock(LockData) returns (Empty);
rpc ReleaseLock(LockData) returns (Empty);
}
message Node {
string uuid = 1;
}
enum OpType {
updateUsers = 0;
updateUser = 1;
deleteUser = 2;
updateBandwidthLimiters = 3;
updateBandwidthLimiter = 4;
deleteBandwidthLimiter = 5;
updateConnectionLimiters = 6;
updateConnectionLimiter = 7;
deleteConnectionLimiter = 8;
}
message User {
int32 id = 1;
string username = 3;
string type = 4;
string inbound = 5;
string uuid = 6;
string password = 7;
string flow = 8;
int32 alter_id = 9;
}
message UserList {
repeated User values = 1;
}
message BandwidthLimiter {
int32 id = 1;
string username = 3;
string outbound = 4;
string strategy = 5;
string mode = 6;
string connection_type = 7;
string speed = 8;
uint64 raw_speed = 9;
}
message BandwidthLimiterList {
repeated BandwidthLimiter values = 1;
}
message ConnectionLimiter {
int32 id = 1;
string username = 3;
string outbound = 4;
string strategy = 5;
string connection_type = 6;
string lock_type = 7;
uint32 count = 8;
}
message ConnectionLimiterList {
repeated ConnectionLimiter values = 1;
}
message NodeData {
OpType op = 1;
oneof data {
UserList users = 2;
User user = 3;
BandwidthLimiterList bandwidth_limiters = 4;
BandwidthLimiter bandwidth_limiter = 5;
ConnectionLimiterList connection_limiters = 6;
ConnectionLimiter connection_limiter = 7;
}
}
message AcquireLockRequest {
int32 limiter_id = 1;
string id = 2;
}
message LockData {
int32 limiter_id = 1;
string id = 2;
string handleId = 3;
}
message Empty {
}

View File

@@ -14,8 +14,9 @@ import (
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
CM "github.com/sagernet/sing-box/service/manager/constant"
pb "github.com/sagernet/sing-box/service/node_manager/manager"
pb "github.com/sagernet/sing-box/service/node_manager_api/manager"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
@@ -23,27 +24,27 @@ import (
"google.golang.org/grpc/connectivity"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
)
func RegisterService(registry *boxService.Registry) {
boxService.Register[option.NodeManagerClientServiceOptions](registry, C.TypeNodeManagerClient, NewService)
}
type Service struct {
type APIClient struct {
boxService.Adapter
ctx context.Context
logger log.ContextLogger
dialer N.Dialer
creds credentials.TransportCredentials
options option.NodeManagerClientServiceOptions
options option.NodeManagerAPIClientOptions
conn *grpc.ClientConn
mtx sync.Mutex
}
func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.NodeManagerClientServiceOptions) (adapter.Service, error) {
func NewAPIClient(ctx context.Context, logger log.ContextLogger, tag string, options option.NodeManagerAPIClientOptions) (*APIClient, error) {
if options.APIKey == "" {
return nil, E.New("missing api key")
}
outboundDialer, err := dialer.New(ctx, options.DialerOptions, options.ServerIsDomain())
if err != nil {
return nil, err
@@ -56,9 +57,9 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio
}
creds = &tlsCreds{tlsConfig}
}
return &Service{
return &APIClient{
Adapter: boxService.NewAdapter(C.TypeManager, tag),
ctx: ctx,
ctx: metadata.AppendToOutgoingContext(ctx, "authorization", options.APIKey),
logger: logger,
dialer: outboundDialer,
creds: creds,
@@ -66,7 +67,7 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio
}, nil
}
func (s *Service) AddNode(uuid string, node CM.ConnectedNode) error {
func (s *APIClient) AddNode(uuid string, node CM.ConnectedNode) error {
go func() {
isRetry := false
for {
@@ -106,7 +107,7 @@ func (s *Service) AddNode(uuid string, node CM.ConnectedNode) error {
return nil
}
func (s *Service) AcquireLock(limiterId int, id string) (string, error) {
func (s *APIClient) AcquireLock(limiterId int, id string) (string, error) {
conn, err := s.getConn()
if err != nil {
return "", err
@@ -119,7 +120,7 @@ func (s *Service) AcquireLock(limiterId int, id string) (string, error) {
return lockReply.HandleId, err
}
func (s *Service) RefreshLock(limiterId int, id string, handleId string) error {
func (s *APIClient) RefreshLock(limiterId int, id string, handleId string) error {
conn, err := s.getConn()
if err != nil {
return err
@@ -129,7 +130,7 @@ func (s *Service) RefreshLock(limiterId int, id string, handleId string) error {
return err
}
func (s *Service) ReleaseLock(limiterId int, id string, handleId string) error {
func (s *APIClient) ReleaseLock(limiterId int, id string, handleId string) error {
conn, err := s.getConn()
if err != nil {
return err
@@ -139,15 +140,28 @@ func (s *Service) ReleaseLock(limiterId int, id string, handleId string) error {
return err
}
func (s *Service) Start(stage adapter.StartStage) error {
func (s *APIClient) AddTrafficUsage(limiterId int, n uint64) (uint64, error) {
conn, err := s.getConn()
if err != nil {
return 0, err
}
client := pb.NewManagerClient(conn)
reply, err := client.AddTrafficUsage(s.ctx, &pb.TrafficUsageRequest{LimiterId: int32(limiterId), N: n})
if err != nil {
return 0, err
}
return reply.Remaining, nil
}
func (s *APIClient) Start(stage adapter.StartStage) error {
return nil
}
func (s *Service) Close() error {
func (s *APIClient) Close() error {
return nil
}
func (s *Service) getConn() (*grpc.ClientConn, error) {
func (s *APIClient) getConn() (*grpc.ClientConn, error) {
s.mtx.Lock()
defer s.mtx.Unlock()
if s.conn != nil {
@@ -166,7 +180,7 @@ func (s *Service) getConn() (*grpc.ClientConn, error) {
}
}
func (s *Service) createConn() (*grpc.ClientConn, error) {
func (s *APIClient) createConn() (*grpc.ClientConn, error) {
conn, err := grpc.NewClient(
s.options.ServerOptions.Build().String(),
grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) {
@@ -180,7 +194,7 @@ func (s *Service) createConn() (*grpc.ClientConn, error) {
return conn, nil
}
func (s *Service) handler(node CM.ConnectedNode, stream grpc.ServerStreamingClient[pb.NodeData]) error {
func (s *APIClient) handler(node CM.ConnectedNode, stream grpc.ServerStreamingClient[pb.NodeData]) error {
for {
data, err := stream.Recv()
if err != nil {
@@ -231,37 +245,69 @@ func (s *Service) handler(node CM.ConnectedNode, stream grpc.ServerStreamingClie
case pb.OpType_deleteBandwidthLimiter:
s.logger.DebugContext(s.ctx, "delete bandwidth limiter")
node.DeleteBandwidthLimiter(s.convertBandwidthLimiter(data.Data.(*pb.NodeData_BandwidthLimiter).BandwidthLimiter))
case pb.OpType_updateTrafficLimiter:
s.logger.DebugContext(s.ctx, "update traffic limiter")
node.UpdateTrafficLimiter(s.convertTrafficLimiter(data.Data.(*pb.NodeData_TrafficLimiter).TrafficLimiter))
case pb.OpType_updateTrafficLimiters:
s.logger.DebugContext(s.ctx, "update traffic limiters")
limiters := data.Data.(*pb.NodeData_TrafficLimiters).TrafficLimiters.Values
convertedLimiters := make([]CM.TrafficLimiter, len(limiters))
for i, limiter := range limiters {
convertedLimiters[i] = s.convertTrafficLimiter(limiter)
}
node.UpdateTrafficLimiters(convertedLimiters)
case pb.OpType_deleteTrafficLimiter:
s.logger.DebugContext(s.ctx, "delete traffic limiter")
node.DeleteTrafficLimiter(s.convertTrafficLimiter(data.Data.(*pb.NodeData_TrafficLimiter).TrafficLimiter))
case pb.OpType_updateRateLimiter:
s.logger.DebugContext(s.ctx, "update rate limiter")
node.UpdateRateLimiter(s.convertRateLimiter(data.Data.(*pb.NodeData_RateLimiter).RateLimiter))
case pb.OpType_updateRateLimiters:
s.logger.DebugContext(s.ctx, "update rate limiters")
limiters := data.Data.(*pb.NodeData_RateLimiters).RateLimiters.Values
convertedLimiters := make([]CM.RateLimiter, len(limiters))
for i, limiter := range limiters {
convertedLimiters[i] = s.convertRateLimiter(limiter)
}
node.UpdateRateLimiters(convertedLimiters)
case pb.OpType_deleteRateLimiter:
s.logger.DebugContext(s.ctx, "delete rate limiter")
node.DeleteRateLimiter(s.convertRateLimiter(data.Data.(*pb.NodeData_RateLimiter).RateLimiter))
}
}
}
func (s *Service) convertUser(user *pb.User) CM.User {
func (s *APIClient) convertUser(user *pb.User) CM.User {
return CM.User{
ID: int(user.Id),
Username: user.Username,
Type: user.Type,
Inbound: user.Inbound,
Type: user.Type,
UUID: user.Uuid,
Password: user.Password,
Secret: user.Secret,
Flow: user.Flow,
AlterID: int(user.AlterId),
}
}
func (s *Service) convertBandwidthLimiter(limiter *pb.BandwidthLimiter) CM.BandwidthLimiter {
func (s *APIClient) convertBandwidthLimiter(limiter *pb.BandwidthLimiter) CM.BandwidthLimiter {
return CM.BandwidthLimiter{
ID: int(limiter.Id),
Username: limiter.Username,
Outbound: limiter.Outbound,
Strategy: limiter.Strategy,
Mode: limiter.Mode,
ConnectionType: limiter.ConnectionType,
Mode: limiter.Mode,
FlowKeys: limiter.FlowKeys,
Speed: limiter.Speed,
RawSpeed: limiter.RawSpeed,
}
}
func (s *Service) convertConnectionLimiter(limiter *pb.ConnectionLimiter) CM.ConnectionLimiter {
func (s *APIClient) convertConnectionLimiter(limiter *pb.ConnectionLimiter) CM.ConnectionLimiter {
return CM.ConnectionLimiter{
ID: int(limiter.Id),
Username: limiter.Username,
@@ -272,3 +318,28 @@ func (s *Service) convertConnectionLimiter(limiter *pb.ConnectionLimiter) CM.Con
Count: limiter.Count,
}
}
func (s *APIClient) convertTrafficLimiter(limiter *pb.TrafficLimiter) CM.TrafficLimiter {
return CM.TrafficLimiter{
ID: int(limiter.Id),
Username: limiter.Username,
Outbound: limiter.Outbound,
Strategy: limiter.Strategy,
Mode: limiter.Mode,
RawUsed: limiter.RawUsed,
Quota: limiter.Quota,
RawQuota: limiter.RawQuota,
}
}
func (s *APIClient) convertRateLimiter(limiter *pb.RateLimiter) CM.RateLimiter {
return CM.RateLimiter{
ID: int(limiter.Id),
Username: limiter.Username,
Outbound: limiter.Outbound,
Strategy: limiter.Strategy,
ConnectionType: limiter.ConnectionType,
Count: limiter.Count,
Interval: limiter.Interval,
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,155 @@
syntax = "proto3";
option go_package = "github.com/sagernet/sing-box/service/node_manager_api/manager";
package node_manager_api.v1;
service Manager {
rpc AddNode (Node) returns (stream NodeData);
rpc AcquireLock(AcquireLockRequest) returns (LockData);
rpc RefreshLock(LockData) returns (Empty);
rpc ReleaseLock(LockData) returns (Empty);
rpc AddTrafficUsage(TrafficUsageRequest) returns (TrafficUsageReply);
}
message Node {
string uuid = 1;
}
enum OpType {
updateUsers = 0;
updateUser = 1;
deleteUser = 2;
updateBandwidthLimiters = 3;
updateBandwidthLimiter = 4;
deleteBandwidthLimiter = 5;
updateConnectionLimiters = 6;
updateConnectionLimiter = 7;
deleteConnectionLimiter = 8;
updateTrafficLimiters = 9;
updateTrafficLimiter = 10;
deleteTrafficLimiter = 11;
updateRateLimiters = 12;
updateRateLimiter = 13;
deleteRateLimiter = 14;
}
message User {
int32 id = 1;
string username = 2;
string inbound = 3;
string type = 4;
string uuid = 5;
string password = 6;
string secret = 7;
string flow = 8;
int32 alter_id = 9;
}
message UserList {
repeated User values = 1;
}
message BandwidthLimiter {
int32 id = 1;
string username = 2;
string outbound = 3;
string strategy = 4;
string connection_type = 5;
string mode = 6;
repeated string flow_keys = 7;
string speed = 8;
uint64 raw_speed = 9;
}
message BandwidthLimiterList {
repeated BandwidthLimiter values = 1;
}
message ConnectionLimiter {
int32 id = 1;
string username = 2;
string outbound = 3;
string strategy = 4;
string connection_type = 5;
string lock_type = 6;
uint32 count = 7;
}
message ConnectionLimiterList {
repeated ConnectionLimiter values = 1;
}
message TrafficLimiter {
int32 id = 1;
string username = 2;
string outbound = 3;
string strategy = 4;
string mode = 5;
uint64 raw_used = 6;
string quota = 7;
uint64 raw_quota = 8;
}
message TrafficLimiterList {
repeated TrafficLimiter values = 1;
}
message RateLimiter {
int32 id = 1;
string username = 2;
string outbound = 3;
string strategy = 4;
string connection_type = 5;
uint32 count = 6;
string interval = 7;
}
message RateLimiterList {
repeated RateLimiter values = 1;
}
message NodeData {
OpType op = 1;
oneof data {
UserList users = 2;
User user = 3;
BandwidthLimiterList bandwidth_limiters = 4;
BandwidthLimiter bandwidth_limiter = 5;
ConnectionLimiterList connection_limiters = 6;
ConnectionLimiter connection_limiter = 7;
TrafficLimiterList traffic_limiters = 8;
TrafficLimiter traffic_limiter = 9;
RateLimiterList rate_limiters = 10;
RateLimiter rate_limiter = 11;
}
}
message AcquireLockRequest {
int32 limiter_id = 1;
string id = 2;
}
message LockData {
int32 limiter_id = 1;
string id = 2;
string handleId = 3;
}
message TrafficUsageRequest {
int32 limiter_id = 1;
uint64 n = 2;
}
message TrafficUsageReply {
uint64 remaining = 1;
}
message Empty {}

View File

@@ -1,8 +1,8 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.0
// - protoc v6.33.1
// source: manager/manager.proto
// - protoc-gen-go-grpc v1.6.1
// - protoc v6.31.1
// source: service/node_manager_api/manager/manager.proto
package manager
@@ -19,10 +19,11 @@ import (
const _ = grpc.SupportPackageIsVersion9
const (
Manager_AddNode_FullMethodName = "/manager.v1.Manager/AddNode"
Manager_AcquireLock_FullMethodName = "/manager.v1.Manager/AcquireLock"
Manager_RefreshLock_FullMethodName = "/manager.v1.Manager/RefreshLock"
Manager_ReleaseLock_FullMethodName = "/manager.v1.Manager/ReleaseLock"
Manager_AddNode_FullMethodName = "/node_manager_api.v1.Manager/AddNode"
Manager_AcquireLock_FullMethodName = "/node_manager_api.v1.Manager/AcquireLock"
Manager_RefreshLock_FullMethodName = "/node_manager_api.v1.Manager/RefreshLock"
Manager_ReleaseLock_FullMethodName = "/node_manager_api.v1.Manager/ReleaseLock"
Manager_AddTrafficUsage_FullMethodName = "/node_manager_api.v1.Manager/AddTrafficUsage"
)
// ManagerClient is the client API for Manager service.
@@ -33,6 +34,7 @@ type ManagerClient interface {
AcquireLock(ctx context.Context, in *AcquireLockRequest, opts ...grpc.CallOption) (*LockData, error)
RefreshLock(ctx context.Context, in *LockData, opts ...grpc.CallOption) (*Empty, error)
ReleaseLock(ctx context.Context, in *LockData, opts ...grpc.CallOption) (*Empty, error)
AddTrafficUsage(ctx context.Context, in *TrafficUsageRequest, opts ...grpc.CallOption) (*TrafficUsageReply, error)
}
type managerClient struct {
@@ -92,6 +94,16 @@ func (c *managerClient) ReleaseLock(ctx context.Context, in *LockData, opts ...g
return out, nil
}
func (c *managerClient) AddTrafficUsage(ctx context.Context, in *TrafficUsageRequest, opts ...grpc.CallOption) (*TrafficUsageReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(TrafficUsageReply)
err := c.cc.Invoke(ctx, Manager_AddTrafficUsage_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// ManagerServer is the server API for Manager service.
// All implementations must embed UnimplementedManagerServer
// for forward compatibility.
@@ -100,6 +112,7 @@ type ManagerServer interface {
AcquireLock(context.Context, *AcquireLockRequest) (*LockData, error)
RefreshLock(context.Context, *LockData) (*Empty, error)
ReleaseLock(context.Context, *LockData) (*Empty, error)
AddTrafficUsage(context.Context, *TrafficUsageRequest) (*TrafficUsageReply, error)
mustEmbedUnimplementedManagerServer()
}
@@ -122,6 +135,9 @@ func (UnimplementedManagerServer) RefreshLock(context.Context, *LockData) (*Empt
func (UnimplementedManagerServer) ReleaseLock(context.Context, *LockData) (*Empty, error) {
return nil, status.Error(codes.Unimplemented, "method ReleaseLock not implemented")
}
func (UnimplementedManagerServer) AddTrafficUsage(context.Context, *TrafficUsageRequest) (*TrafficUsageReply, error) {
return nil, status.Error(codes.Unimplemented, "method AddTrafficUsage not implemented")
}
func (UnimplementedManagerServer) mustEmbedUnimplementedManagerServer() {}
func (UnimplementedManagerServer) testEmbeddedByValue() {}
@@ -208,11 +224,29 @@ func _Manager_ReleaseLock_Handler(srv interface{}, ctx context.Context, dec func
return interceptor(ctx, in, info, handler)
}
func _Manager_AddTrafficUsage_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(TrafficUsageRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ManagerServer).AddTrafficUsage(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Manager_AddTrafficUsage_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ManagerServer).AddTrafficUsage(ctx, req.(*TrafficUsageRequest))
}
return interceptor(ctx, in, info, handler)
}
// Manager_ServiceDesc is the grpc.ServiceDesc for Manager service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var Manager_ServiceDesc = grpc.ServiceDesc{
ServiceName: "manager.v1.Manager",
ServiceName: "node_manager_api.v1.Manager",
HandlerType: (*ManagerServer)(nil),
Methods: []grpc.MethodDesc{
{
@@ -227,6 +261,10 @@ var Manager_ServiceDesc = grpc.ServiceDesc{
MethodName: "ReleaseLock",
Handler: _Manager_ReleaseLock_Handler,
},
{
MethodName: "AddTrafficUsage",
Handler: _Manager_AddTrafficUsage_Handler,
},
},
Streams: []grpc.StreamDesc{
{
@@ -235,5 +273,5 @@ var Manager_ServiceDesc = grpc.ServiceDesc{
ServerStreams: true,
},
},
Metadata: "manager/manager.proto",
Metadata: "service/node_manager_api/manager/manager.proto",
}

View File

@@ -6,15 +6,17 @@ import (
"github.com/sagernet/sing-box/log"
CS "github.com/sagernet/sing-box/service/manager/constant"
pb "github.com/sagernet/sing-box/service/node_manager/manager"
pb "github.com/sagernet/sing-box/service/node_manager_api/manager"
E "github.com/sagernet/sing/common/exceptions"
"google.golang.org/grpc"
)
type RemoteNode struct {
ctx context.Context
logger log.ContextLogger
stream grpc.ServerStreamingServer[pb.NodeData]
ctx context.Context
logger log.ContextLogger
stream grpc.ServerStreamingServer[pb.NodeData]
err error
errChan chan error
mtx sync.Mutex
@@ -123,6 +125,68 @@ func (s *RemoteNode) DeleteBandwidthLimiter(limiter CS.BandwidthLimiter) {
})
}
func (s *RemoteNode) UpdateTrafficLimiter(limiter CS.TrafficLimiter) {
s.mtx.Lock()
defer s.mtx.Unlock()
s.send(&pb.NodeData{
Op: pb.OpType_updateTrafficLimiter,
Data: &pb.NodeData_TrafficLimiter{TrafficLimiter: s.convertTrafficLimiter(limiter)},
})
}
func (s *RemoteNode) UpdateTrafficLimiters(limiters []CS.TrafficLimiter) {
s.mtx.Lock()
defer s.mtx.Unlock()
pbLimiters := make([]*pb.TrafficLimiter, len(limiters))
for i, limiters := range limiters {
pbLimiters[i] = s.convertTrafficLimiter(limiters)
}
s.send(&pb.NodeData{
Op: pb.OpType_updateTrafficLimiters,
Data: &pb.NodeData_TrafficLimiters{TrafficLimiters: &pb.TrafficLimiterList{Values: pbLimiters}},
})
}
func (s *RemoteNode) DeleteTrafficLimiter(limiter CS.TrafficLimiter) {
s.mtx.Lock()
defer s.mtx.Unlock()
s.send(&pb.NodeData{
Op: pb.OpType_deleteTrafficLimiter,
Data: &pb.NodeData_TrafficLimiter{TrafficLimiter: s.convertTrafficLimiter(limiter)},
})
}
func (s *RemoteNode) UpdateRateLimiter(limiter CS.RateLimiter) {
s.mtx.Lock()
defer s.mtx.Unlock()
s.send(&pb.NodeData{
Op: pb.OpType_updateRateLimiter,
Data: &pb.NodeData_RateLimiter{RateLimiter: s.convertRateLimiter(limiter)},
})
}
func (s *RemoteNode) UpdateRateLimiters(limiters []CS.RateLimiter) {
s.mtx.Lock()
defer s.mtx.Unlock()
pbLimiters := make([]*pb.RateLimiter, len(limiters))
for i, limiters := range limiters {
pbLimiters[i] = s.convertRateLimiter(limiters)
}
s.send(&pb.NodeData{
Op: pb.OpType_updateRateLimiters,
Data: &pb.NodeData_RateLimiters{RateLimiters: &pb.RateLimiterList{Values: pbLimiters}},
})
}
func (s *RemoteNode) DeleteRateLimiter(limiter CS.RateLimiter) {
s.mtx.Lock()
defer s.mtx.Unlock()
s.send(&pb.NodeData{
Op: pb.OpType_deleteRateLimiter,
Data: &pb.NodeData_RateLimiter{RateLimiter: s.convertRateLimiter(limiter)},
})
}
func (s *RemoteNode) IsLocal() bool {
return false
}
@@ -139,11 +203,16 @@ func (s *RemoteNode) IsOnline() bool {
}
func (s *RemoteNode) Close() error {
s.mtx.Lock()
defer s.mtx.Unlock()
s.close(E.New("server connection is closed"))
return nil
}
func (s *RemoteNode) send(data *pb.NodeData) {
if s.err != nil {
return
}
select {
case <-s.ctx.Done():
s.close(E.New("server connection is closed"))
@@ -160,6 +229,10 @@ func (s *RemoteNode) send(data *pb.NodeData) {
}
func (s *RemoteNode) close(err error) {
if err != nil {
return
}
s.err = err
s.errChan <- err
close(s.errChan)
}
@@ -168,10 +241,11 @@ func (s *RemoteNode) convertUser(user CS.User) *pb.User {
return &pb.User{
Id: int32(user.ID),
Username: user.Username,
Type: user.Type,
Inbound: user.Inbound,
Type: user.Type,
Uuid: user.UUID,
Password: user.Password,
Secret: user.Secret,
Flow: user.Flow,
AlterId: int32(user.AlterID),
}
@@ -195,9 +269,35 @@ func (s *RemoteNode) convertBandwidthLimiter(limiter CS.BandwidthLimiter) *pb.Ba
Username: limiter.Username,
Outbound: limiter.Outbound,
Strategy: limiter.Strategy,
Mode: limiter.Mode,
ConnectionType: limiter.ConnectionType,
Mode: limiter.Mode,
FlowKeys: limiter.FlowKeys,
Speed: limiter.Speed,
RawSpeed: limiter.RawSpeed,
}
}
func (s *RemoteNode) convertTrafficLimiter(limiter CS.TrafficLimiter) *pb.TrafficLimiter {
return &pb.TrafficLimiter{
Id: int32(limiter.ID),
Username: limiter.Username,
Outbound: limiter.Outbound,
Strategy: limiter.Strategy,
Mode: limiter.Mode,
RawUsed: limiter.RawUsed,
Quota: limiter.Quota,
RawQuota: limiter.RawQuota,
}
}
func (s *RemoteNode) convertRateLimiter(limiter CS.RateLimiter) *pb.RateLimiter {
return &pb.RateLimiter{
Id: int32(limiter.ID),
Username: limiter.Username,
Outbound: limiter.Outbound,
Strategy: limiter.Strategy,
ConnectionType: limiter.ConnectionType,
Count: limiter.Count,
Interval: limiter.Interval,
}
}

View File

@@ -2,6 +2,7 @@ package server
import (
"context"
"crypto/subtle"
"errors"
"sync"
@@ -13,7 +14,7 @@ import (
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
CM "github.com/sagernet/sing-box/service/manager/constant"
pb "github.com/sagernet/sing-box/service/node_manager/manager"
pb "github.com/sagernet/sing-box/service/node_manager_api/manager"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
N "github.com/sagernet/sing/common/network"
@@ -21,13 +22,12 @@ import (
"github.com/sagernet/sing/service"
"golang.org/x/net/http2"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
func RegisterService(registry *boxService.Registry) {
boxService.Register[option.NodeManagerServerServiceOptions](registry, C.TypeNodeManagerServer, NewService)
}
type Service struct {
type APIServer struct {
pb.UnimplementedManagerServer
boxService.Adapter
@@ -36,14 +36,17 @@ type Service struct {
listener *listener.Listener
tlsConfig tls.ServerConfig
grpcServer *grpc.Server
manager CM.Manager
options option.NodeManagerServerServiceOptions
manager CM.NodeManager
options option.NodeManagerAPIServerOptions
mtx sync.Mutex
}
func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.NodeManagerServerServiceOptions) (adapter.Service, error) {
return &Service{
func NewAPIServer(ctx context.Context, logger log.ContextLogger, tag string, options option.NodeManagerAPIServerOptions) (*APIServer, error) {
if options.APIKey == "" {
return nil, E.New("missing api key")
}
return &APIServer{
Adapter: boxService.NewAdapter(C.TypeManager, tag),
ctx: ctx,
logger: logger,
@@ -57,7 +60,7 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio
}, nil
}
func (s *Service) AddNode(node *pb.Node, stream grpc.ServerStreamingServer[pb.NodeData]) error {
func (s *APIServer) AddNode(node *pb.Node, stream grpc.ServerStreamingServer[pb.NodeData]) error {
remoteNode, errChan := NewRemoteNode(s.ctx, s.logger, stream)
err := s.manager.AddNode(node.Uuid, remoteNode)
if err != nil {
@@ -71,7 +74,7 @@ func (s *Service) AddNode(node *pb.Node, stream grpc.ServerStreamingServer[pb.No
return <-errChan
}
func (s *Service) AcquireLock(ctx context.Context, request *pb.AcquireLockRequest) (*pb.LockData, error) {
func (s *APIServer) AcquireLock(ctx context.Context, request *pb.AcquireLockRequest) (*pb.LockData, error) {
handleId, err := s.manager.AcquireLock(int(request.LimiterId), request.Id)
if err != nil {
return nil, err
@@ -79,15 +82,23 @@ func (s *Service) AcquireLock(ctx context.Context, request *pb.AcquireLockReques
return &pb.LockData{HandleId: handleId}, nil
}
func (s *Service) RefreshLock(ctx context.Context, data *pb.LockData) (*pb.Empty, error) {
func (s *APIServer) RefreshLock(ctx context.Context, data *pb.LockData) (*pb.Empty, error) {
return nil, s.manager.RefreshLock(int(data.LimiterId), data.Id, data.HandleId)
}
func (s *Service) ReleaseLock(ctx context.Context, data *pb.LockData) (*pb.Empty, error) {
func (s *APIServer) ReleaseLock(ctx context.Context, data *pb.LockData) (*pb.Empty, error) {
return nil, s.manager.ReleaseLock(int(data.LimiterId), data.Id, data.HandleId)
}
func (s *Service) Start(stage adapter.StartStage) error {
func (s *APIServer) AddTrafficUsage(ctx context.Context, request *pb.TrafficUsageRequest) (*pb.TrafficUsageReply, error) {
remaining, err := s.manager.AddTrafficUsage(int(request.LimiterId), request.N)
if err != nil {
return nil, err
}
return &pb.TrafficUsageReply{Remaining: remaining}, nil
}
func (s *APIServer) Start(stage adapter.StartStage) error {
if stage != adapter.StartStateStart {
return nil
}
@@ -96,7 +107,7 @@ func (s *Service) Start(stage adapter.StartStage) error {
if !ok {
return E.New("manager ", s.options.Manager, " not found")
}
s.manager, ok = service.(CM.Manager)
s.manager, ok = service.(CM.NodeManager)
if !ok {
return E.New("invalid", s.options.Manager, " manager")
}
@@ -123,7 +134,10 @@ func (s *Service) Start(stage adapter.StartStage) error {
}
tcpListener = aTLS.NewListener(tcpListener, s.tlsConfig)
}
s.grpcServer = grpc.NewServer()
s.grpcServer = grpc.NewServer(
grpc.ChainUnaryInterceptor(s.unaryAuthInterceptor),
grpc.StreamInterceptor(s.streamAuthInterceptor),
)
pb.RegisterManagerServer(s.grpcServer, s)
go func() {
err = s.grpcServer.Serve(tcpListener)
@@ -134,6 +148,35 @@ func (s *Service) Start(stage adapter.StartStage) error {
return nil
}
func (s *Service) Close() error {
func (s *APIServer) Close() error {
return nil
}
func (s *APIServer) unaryAuthInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
if err := s.authorize(ctx); err != nil {
return nil, err
}
return handler(ctx, req)
}
func (s *APIServer) streamAuthInterceptor(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
if err := s.authorize(ss.Context()); err != nil {
return err
}
return handler(srv, ss)
}
func (s *APIServer) authorize(ctx context.Context) error {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return status.Error(codes.Unauthenticated, "missing api key")
}
values := md.Get("authorization")
if len(values) == 0 {
return status.Error(codes.Unauthenticated, "missing api key")
}
if subtle.ConstantTimeCompare([]byte(values[0]), []byte(s.options.APIKey)) == 0 {
return status.Error(codes.Unauthenticated, "invalid api key")
}
return nil
}

View File

@@ -0,0 +1,31 @@
package node_manager_api
import (
"context"
"github.com/sagernet/sing-box/adapter"
boxService "github.com/sagernet/sing-box/adapter/service"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing-box/service/node_manager_api/client"
"github.com/sagernet/sing-box/service/node_manager_api/server"
E "github.com/sagernet/sing/common/exceptions"
)
func RegisterService(registry *boxService.Registry) {
boxService.Register[option.NodeManagerAPIOptions](registry, C.TypeNodeManagerAPI, NewService)
}
func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.NodeManagerAPIOptions) (adapter.Service, error) {
switch options.APIType {
case C.NodeManagerAPIServer:
return server.NewAPIServer(ctx, logger, tag, options.ServerOptions)
case C.NodeManagerAPIClient:
return client.NewAPIClient(ctx, logger, tag, options.ClientOptions)
case "":
return nil, E.New("missing api type")
default:
return nil, E.New("unknown api type: ", options.APIType)
}
}

View File

@@ -0,0 +1,93 @@
package profiler
import (
"context"
"net/http"
"net/http/pprof"
runtimePprof "runtime/pprof"
"strings"
"time"
"github.com/sagernet/sing-box/adapter"
boxService "github.com/sagernet/sing-box/adapter/service"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions"
"github.com/go-chi/chi/v5"
)
func RegisterService(registry *boxService.Registry) {
boxService.Register[option.ProfilerServiceOptions](registry, C.TypeProfiler, NewService)
}
type Service struct {
boxService.Adapter
logger log.ContextLogger
listen string
readTimeout time.Duration
writeTimeout time.Duration
server *http.Server
}
func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.ProfilerServiceOptions) (adapter.Service, error) {
if options.Listen == "" {
return nil, E.New("missing listen")
}
return &Service{
Adapter: boxService.NewAdapter(C.TypeProfiler, tag),
logger: logger,
listen: options.Listen,
readTimeout: time.Duration(options.ReadTimeout),
writeTimeout: time.Duration(options.WriteTimeout),
}, nil
}
func (s *Service) Start(stage adapter.StartStage) error {
if stage != adapter.StartStateStart {
return nil
}
r := chi.NewMux()
r.Route("/debug/pprof", func(r chi.Router) {
r.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
if !strings.HasSuffix(req.URL.Path, "/") {
http.Redirect(w, req, req.URL.Path+"/", http.StatusMovedPermanently)
return
}
pprof.Index(w, req)
})
r.HandleFunc("/cmdline", pprof.Cmdline)
r.HandleFunc("/profile", pprof.Profile)
r.HandleFunc("/symbol", pprof.Symbol)
r.HandleFunc("/trace", pprof.Trace)
for _, p := range runtimePprof.Profiles() {
name := p.Name()
r.Handle("/"+name, pprof.Handler(name))
}
r.HandleFunc("/*", pprof.Index)
})
s.server = &http.Server{
Addr: s.listen,
Handler: r,
ReadTimeout: s.readTimeout,
WriteTimeout: s.writeTimeout,
}
s.logger.Info("profiler listening at ", s.listen)
go func() {
err := s.server.ListenAndServe()
if err != nil && !E.IsClosed(err) {
s.logger.Error(E.Cause(err, "serve profiler"))
}
}()
return nil
}
func (s *Service) Close() error {
if s.server != nil {
_ = s.server.Close()
s.server = nil
}
return nil
}