mirror of
https://github.com/shtorm-7/sing-box-extended.git
synced 2026-05-14 00:51:12 +03:00
Add new admin panel, failover, dns fallback, providers, limiters. Update XHTTP
This commit is contained in:
111
service/admin_panel/README.md
Normal file
111
service/admin_panel/README.md
Normal 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`.
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
199
service/admin_panel/service_test.go
Normal file
199
service/admin_panel/service_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
2
service/admin_panel/web/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
.vite
|
||||
65
service/admin_panel/web/index.html
Normal file
65
service/admin_panel/web/index.html
Normal 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
3245
service/admin_panel/web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
service/admin_panel/web/package.json
Normal file
31
service/admin_panel/web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
40
service/admin_panel/web/src/App.tsx
Normal file
40
service/admin_panel/web/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
316
service/admin_panel/web/src/api/client.ts
Normal file
316
service/admin_panel/web/src/api/client.ts
Normal 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");
|
||||
}
|
||||
245
service/admin_panel/web/src/api/types.ts
Normal file
245
service/admin_panel/web/src/api/types.ts
Normal 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>;
|
||||
37
service/admin_panel/web/src/assets/icon.svg
Normal file
37
service/admin_panel/web/src/assets/icon.svg
Normal 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 |
107
service/admin_panel/web/src/auth/AuthContext.tsx
Normal file
107
service/admin_panel/web/src/auth/AuthContext.tsx
Normal 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;
|
||||
}
|
||||
231
service/admin_panel/web/src/components/ColorPickerButton.tsx
Normal file
231
service/admin_panel/web/src/components/ColorPickerButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
216
service/admin_panel/web/src/components/CopyableId.tsx
Normal file
216
service/admin_panel/web/src/components/CopyableId.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5262
service/admin_panel/web/src/components/CrudPage.tsx
Normal file
5262
service/admin_panel/web/src/components/CrudPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
958
service/admin_panel/web/src/components/Layout.tsx
Normal file
958
service/admin_panel/web/src/components/Layout.tsx
Normal 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 (xs–sm) 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>
|
||||
);
|
||||
}
|
||||
364
service/admin_panel/web/src/components/LoginBackdropMesh.tsx
Normal file
364
service/admin_panel/web/src/components/LoginBackdropMesh.tsx
Normal 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 1–2 traces per cycle and waits ~1.5–2.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;
|
||||
// 1–2 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
158
service/admin_panel/web/src/components/PageHeader.tsx
Normal file
158
service/admin_panel/web/src/components/PageHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
service/admin_panel/web/src/main.tsx
Normal file
50
service/admin_panel/web/src/main.tsx
Normal 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>,
|
||||
);
|
||||
@@ -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)}`);
|
||||
}
|
||||
247
service/admin_panel/web/src/pages/BandwidthLimitersPage.tsx
Normal file
247
service/admin_panel/web/src/pages/BandwidthLimitersPage.tsx
Normal 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),
|
||||
};
|
||||
}
|
||||
191
service/admin_panel/web/src/pages/ConnectionLimitersPage.tsx
Normal file
191
service/admin_panel/web/src/pages/ConnectionLimitersPage.tsx
Normal 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} />
|
||||
);
|
||||
}
|
||||
276
service/admin_panel/web/src/pages/DashboardPage.tsx
Normal file
276
service/admin_panel/web/src/pages/DashboardPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
273
service/admin_panel/web/src/pages/LoginPage.tsx
Normal file
273
service/admin_panel/web/src/pages/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
service/admin_panel/web/src/pages/NodesPage.tsx
Normal file
98
service/admin_panel/web/src/pages/NodesPage.tsx
Normal 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} />;
|
||||
}
|
||||
188
service/admin_panel/web/src/pages/RateLimitersPage.tsx
Normal file
188
service/admin_panel/web/src/pages/RateLimitersPage.tsx
Normal 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} />;
|
||||
}
|
||||
40
service/admin_panel/web/src/pages/SquadsPage.tsx
Normal file
40
service/admin_panel/web/src/pages/SquadsPage.tsx
Normal 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} />;
|
||||
}
|
||||
251
service/admin_panel/web/src/pages/TrafficLimitersPage.tsx
Normal file
251
service/admin_panel/web/src/pages/TrafficLimitersPage.tsx
Normal 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 (0–100,
|
||||
// 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} />;
|
||||
}
|
||||
157
service/admin_panel/web/src/pages/UsersPage.tsx
Normal file
157
service/admin_panel/web/src/pages/UsersPage.tsx
Normal 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} />;
|
||||
}
|
||||
197
service/admin_panel/web/src/pages/squadField.tsx
Normal file
197
service/admin_panel/web/src/pages/squadField.tsx
Normal 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;
|
||||
}
|
||||
589
service/admin_panel/web/src/theme.ts
Normal file
589
service/admin_panel/web/src/theme.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
252
service/admin_panel/web/src/theme/AppThemeProvider.tsx
Normal file
252
service/admin_panel/web/src/theme/AppThemeProvider.tsx
Normal 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;
|
||||
}
|
||||
1
service/admin_panel/web/src/vite-env.d.ts
vendored
Normal file
1
service/admin_panel/web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
21
service/admin_panel/web/tsconfig.json
Normal file
21
service/admin_panel/web/tsconfig.json
Normal 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"]
|
||||
}
|
||||
1
service/admin_panel/web/tsconfig.tsbuildinfo
Normal file
1
service/admin_panel/web/tsconfig.tsbuildinfo
Normal 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"}
|
||||
64
service/admin_panel/web/vite.config.ts
Normal file
64
service/admin_panel/web/vite.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
36
service/manager/repository/postgresql/utils.go
Normal file
36
service/manager/repository/postgresql/utils.go
Normal 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)
|
||||
}
|
||||
169
service/manager/repository/sqlite/filter.go
Normal file
169
service/manager/repository/sqlite/filter.go
Normal 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
|
||||
}
|
||||
}
|
||||
237
service/manager/repository/sqlite/migration.go
Normal file
237
service/manager/repository/sqlite/migration.go
Normal 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()
|
||||
}
|
||||
2238
service/manager/repository/sqlite/repository.go
Normal file
2238
service/manager/repository/sqlite/repository.go
Normal file
File diff suppressed because it is too large
Load Diff
63
service/manager/repository/sqlite/utils.go
Normal file
63
service/manager/repository/sqlite/utils.go
Normal 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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
109
service/manager_api/grpc/client/client.go
Normal file
109
service/manager_api/grpc/client/client.go
Normal 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)
|
||||
}
|
||||
146
service/manager_api/grpc/client/converter.go
Normal file
146
service/manager_api/grpc/client/converter.go
Normal 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}
|
||||
}
|
||||
658
service/manager_api/grpc/client/manager.go
Normal file
658
service/manager_api/grpc/client/manager.go
Normal 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
|
||||
}
|
||||
43
service/manager_api/grpc/client/tls.go
Normal file
43
service/manager_api/grpc/client/tls.go
Normal 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
|
||||
}
|
||||
3319
service/manager_api/grpc/manager/manager.pb.go
Normal file
3319
service/manager_api/grpc/manager/manager.pb.go
Normal file
File diff suppressed because it is too large
Load Diff
345
service/manager_api/grpc/manager/manager.proto
Normal file
345
service/manager_api/grpc/manager/manager.proto
Normal 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 {}
|
||||
1717
service/manager_api/grpc/manager/manager_grpc.pb.go
Normal file
1717
service/manager_api/grpc/manager/manager_grpc.pb.go
Normal file
File diff suppressed because it is too large
Load Diff
137
service/manager_api/grpc/server/converter.go
Normal file
137
service/manager_api/grpc/server/converter.go
Normal 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
|
||||
}
|
||||
465
service/manager_api/grpc/server/rpc.go
Normal file
465
service/manager_api/grpc/server/rpc.go
Normal 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
|
||||
}
|
||||
157
service/manager_api/grpc/server/server.go
Normal file
157
service/manager_api/grpc/server/server.go
Normal 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
|
||||
}
|
||||
133
service/manager_api/http/client/client.go
Normal file
133
service/manager_api/http/client/client.go
Normal 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 }
|
||||
233
service/manager_api/http/client/manager.go
Normal file
233
service/manager_api/http/client/manager.go
Normal 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
|
||||
}
|
||||
872
service/manager_api/http/server/openapi.yaml
Normal file
872
service/manager_api/http/server/openapi.yaml
Normal 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}
|
||||
530
service/manager_api/http/server/server.go
Normal file
530
service/manager_api/http/server/server.go
Normal 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())
|
||||
}
|
||||
145
service/manager_api/http/server/server_test.go
Normal file
145
service/manager_api/http/server/server_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
28
service/manager_api/http/server/swagger.go
Normal file
28
service/manager_api/http/server/swagger.go
Normal 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>`
|
||||
51
service/manager_api/service.go
Normal file
51
service/manager_api/service.go
Normal 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)
|
||||
}
|
||||
}
|
||||
18
service/node/constant/rate.go
Normal file
18
service/node/constant/rate.go
Normal 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)
|
||||
}
|
||||
18
service/node/constant/traffic.go
Normal file
18
service/node/constant/traffic.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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, "\"")
|
||||
|
||||
129
service/node/limiter/rate.go
Normal file
129
service/node/limiter/rate.go
Normal 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()
|
||||
}
|
||||
216
service/node/limiter/traffic.go
Normal file
216
service/node/limiter/traffic.go
Normal 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
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
1556
service/node_manager_api/manager/manager.pb.go
Normal file
1556
service/node_manager_api/manager/manager.pb.go
Normal file
File diff suppressed because it is too large
Load Diff
155
service/node_manager_api/manager/manager.proto
Normal file
155
service/node_manager_api/manager/manager.proto
Normal 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 {}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
31
service/node_manager_api/service.go
Normal file
31
service/node_manager_api/service.go
Normal 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)
|
||||
}
|
||||
}
|
||||
93
service/profiler/service.go
Normal file
93
service/profiler/service.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user