diff --git a/.eslintrc.json b/.eslintrc.json index 41a2dd4..42a3892 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -3,7 +3,7 @@ "browser": true, "es2021": true }, - "extends": ["plugin:prettier/recommended", "prettier"], + "extends": ["prettier"], "parser": "@typescript-eslint/parser", "parserOptions": { "ecmaVersion": "latest", diff --git a/.prettierrc.json b/.prettierrc.json index cf21dc3..85195a6 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -2,7 +2,7 @@ "semi": true, "singleQuote": false, "trailingComma": "all", - "printWidth": 80, + "printWidth": 100, "tabWidth": 4, "endOfLine": "auto" } diff --git a/package.json b/package.json index a49718f..778d82a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "3x-ui", - "version": "1.2.0", + "version": "1.3.1", "type": "module", "main": "build/index.js", "types": "build/index.d.ts", diff --git a/src/core/api.ts b/src/core/api.ts deleted file mode 100644 index 3d548f0..0000000 --- a/src/core/api.ts +++ /dev/null @@ -1,343 +0,0 @@ -import type * as T from "../types"; -import { ProxyAgent } from "proxy-agent"; -import { createLogger } from "../util/logger"; -import { Panel } from "./panel"; -import cache from "node-cache"; -import axios from "axios"; -import urljoin from "url-join"; -import qs from "qs"; - -export class Api { - private readonly cache; - private readonly logger; - private readonly axios; - - constructor( - public readonly panel: Panel, - private readonly cookie: string, - debug = false, - ) { - this.cache = new cache({ stdTTL: 5 }); - this.logger = createLogger(`api: ${this.panel.host}`); - this.logger.silent = !debug; - - this.axios = axios.create({ - baseURL: urljoin( - `${this.panel.protocol}://${this.panel.host}:${this.panel.port}`, - this.panel.path, - ), - validateStatus: () => true, - httpAgent: new ProxyAgent(), - httpsAgent: new ProxyAgent(), - }); - } - - setCacheTTL(ttl: number) { - this.cache.options.stdTTL = ttl; - this.logger.debug(`Cache TTL set to ${ttl} seconds.`); - } - - private async get(path: string, data?: unknown) { - const url = urljoin("/panel/api/inbounds", path); - this.logger.debug(`GET ${url}`); - - const response = await this.axios.get(url, { - data: qs.stringify(data), - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - Cookie: this.cookie, - }, - }); - - if (response.status !== 200 || !response.data.success) { - this.logger.error(`Request to ${path} have failed.`); - throw new Error(`Request to ${path} have failed.`); - } - - return response.data.obj as T; - } - - private async post(path: string, data?: unknown) { - const url = urljoin("/panel/api/inbounds", path); - this.logger.debug(`POST ${url}`); - - const response = await this.axios.post(url, JSON.stringify(data), { - headers: { - "Content-Type": "application/json", - Accept: "application/json", - Cookie: this.cookie, - }, - }); - - if (response.status !== 200 || !response.data.success) { - this.logger.error(`Request to ${path} have failed.`); - throw new Error(`Request to ${path} have failed.`); - } - - return response.data.obj as T; - } - - private saveInboundInCache(inbound: T.Inbound) { - this.cache.set(`inbound:${inbound.id}`, inbound); - this.logger.debug(`Inbound ${inbound.id} saved in cache.`); - - if (inbound.clientStats) { - const settings = JSON.parse(inbound.settings) as { - clients: T.ClientOptions[]; - }; - - inbound.clientStats.map((client) => { - const options = settings.clients.find( - (options: T.ClientOptions) => - options.email === client.email, - ) as T.ClientOptions; - - this.cache.set(`client:${client.email}:stat`, client); - this.cache.set(`client:${client.email}:options`, options); - - let clientId: string = ""; - if ("id" in options) clientId = options.id; - if ("password" in options) clientId = options.password; - - this.cache.set(`client:${clientId}:stat`, client); - this.cache.set(`client:${clientId}:options`, options); - - this.logger.debug(`Client ${client.email} saved in cache.`); - }); - } - } - - async getInbounds() { - if (this.cache.get("inbounds")) { - this.logger.debug("Inbounds loaded from cache."); - return this.cache.get("inbounds") as T.Inbound[]; - } - - const inbounds = await this.get("/list"); - - this.cache.set("inbounds", inbounds); - inbounds.map((inbound) => this.saveInboundInCache(inbound)); - - this.logger.debug("Inbounds loaded from API."); - return inbounds; - } - - async getInbound(id: number) { - if (this.cache.get(`inbound:${id}`)) { - this.logger.debug(`Inbound ${id} loaded from cache.`); - return this.cache.get(`inbound:${id}`) as T.Inbound; - } - - try { - const inbound = await this.get(`/get/${id}`); - this.saveInboundInCache(inbound); - - this.logger.debug(`Inbound ${id} loaded from API.`); - return inbound; - } catch (error) { - return null; - } - } - - async addInbound(options: T.InboundOptions) { - this.logger.debug(`Adding inbound ${options.remark}.`); - const inbound = await this.post("/add", options); - this.cache.flushAll(); - return inbound; - } - - async updateInbound(id: number, options: Partial) { - this.logger.debug(`Updating inbound ${id}.`); - - const inbound = await this.getInbound(id); - if (!inbound) throw new Error("Inbound not found."); - - options = { ...inbound, ...options }; - const updated = await this.post(`/update/${id}`, options); - this.cache.flushAll(); - - return updated; - } - - async resetInboundsStat() { - await this.post(`/resetAllTraffics`).catch(() => {}); - this.logger.debug("Inbounds stat reseted."); - this.cache.flushAll(); - } - - async resetInboundStat(id: number) { - await this.post(`/resetAllClientTraffics/${id}`).catch(() => {}); - this.logger.debug(`Inbound ${id} stat reseted.`); - this.cache.flushAll(); - } - - async deleteInbound(id: number) { - await this.post(`/del/${id}`).catch(() => {}); - this.logger.debug(`Inbound ${id} deleted.`); - this.cache.flushAll(); - } - - async getClients() { - const inbounds = await this.getInbounds(); - const clients = inbounds.map((inbound) => inbound.clientStats).flat(); - this.logger.debug("Clients loaded from cache."); - return clients; - } - - async getClient(email: string) { - if (this.cache.get(`client:${email}:stat`)) { - this.logger.debug(`Client ${email} loaded from cache.`); - return this.cache.get(`client:${email}:stat`) as T.Client; - } - - const client = await this.get(`/getClientTraffics/${email}`); - this.cache.set(`client:${email}:stat`, client); - this.logger.debug(`Client ${email} loaded from API.`); - - if (!client) { - this.logger.debug(`Client email ${email} not found.`); - await this.getInbounds(); - - if (this.cache.get(`client:${email}:stat`)) { - this.logger.debug(`Client id ${email} loaded from cache.`); - return this.cache.get(`client:${email}:stat`) as T.Client; - } - } - - return client; - } - - async getClientOptions(email: string) { - await this.getInbounds(); - const options = this.cache.get( - `client:${email}:options`, - ) as T.ClientOptions; - this.logger.debug(`Client ${email} options loaded from cache.`); - return options; - } - - async addClient(inboundId: number, options: T.ClientOptions) { - await this.post("/addClient", { - id: inboundId, - settings: JSON.stringify({ - clients: [options], - }), - }); - - this.cache.flushAll(); - this.logger.debug(`Client ${options.email} added.`); - } - - async addClients(inboundId: number, clients: T.ClientOptions[]) { - await this.post("/addClient", { - id: inboundId, - settings: JSON.stringify({ clients }), - }); - - this.cache.flushAll(); - this.logger.debug(`${clients.length} clients added.`); - } - - async updateClient( - inboundId: number, - clientId: string, - options: Partial, - ) { - await this.getInbound(inboundId); - const defaultOptions = this.cache.get( - `client:${clientId}:options`, - ) as T.ClientOptions; - - const clientOptions = { - ...defaultOptions, - ...options, - }; - - await this.post(`/updateClient/${clientId}`, { - id: inboundId, - settings: JSON.stringify({ - clients: [clientOptions], - }), - }); - - this.cache.flushAll(); - this.logger.debug(`Client ${clientId} updated.`); - } - - async getClientIps(email: string) { - if (this.cache.get(`client:${email}:ips`)) { - this.logger.debug(`Client ${email} IPs loaded from cache.`); - return this.cache.get(`client:${email}:ips`) as string[]; - } - - try { - const data = await this.post(`/clientIps/${email}`); - if (data === "No IP Record") { - this.logger.debug(`Client ${email} has no IPs.`); - return []; - } - - const ips = data.split(/,|\s/gm).filter((ip) => ip.length); - this.cache.set(`client:${email}:ips`, ips); - this.logger.debug(`Client ${email} IPs loaded from API.`); - return ips; - } catch (error) { - return []; - } - } - - async resetClientIps(email: string) { - await this.post(`/clearClientIps/${email}`).catch(() => {}); - this.cache.del(`client:${email}:ips`); - this.logger.debug(`Client ${email} IPs reseted.`); - } - - async resetClientStat(inboundId: number, email: string) { - await this.post(`/${inboundId}/resetClientTraffic/${email}`).catch( - () => {}, - ); - this.cache.flushAll(); - this.logger.debug(`Client ${email} stat reseted.`); - } - - async deleteClient(inboundId: number, email: string) { - await this.post(`/${inboundId}/delClient/${email}`).catch(() => {}); - this.cache.flushAll(); - this.logger.debug(`Client ${email} deleted.`); - } - - async deleteDepletedClients() { - await this.post("/delDepletedClients").catch(() => {}); - this.cache.flushAll(); - this.logger.debug(`Depleted clients deleted.`); - } - - async deleteInboundDepletedClients(inboundId: number) { - await this.post(`/delDepletedClients/${inboundId}`).catch(() => {}); - this.cache.flushAll(); - this.logger.debug(`Depleted clients deleted.`); - } - - async getOnlineClients() { - if (this.cache.get("clients:online")) { - this.logger.debug("Online clients loaded from cache."); - return this.cache.get("clients:online") as string[]; - } - - try { - const emails = await this.post("/onlines"); - this.cache.set("clients:online", emails); - this.logger.debug("Online clients loaded from API."); - return emails; - } catch (err) { - this.logger.error("Failed to load online clients."); - return []; - } - } - - async exportDatabase() { - await this.get("/createbackup").catch(() => {}); - this.logger.debug("Database exported."); - } -} diff --git a/src/core/panel.ts b/src/core/panel.ts deleted file mode 100644 index 214b32e..0000000 --- a/src/core/panel.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { ProxyAgent } from "proxy-agent"; -import { createLogger } from "../util/logger"; -import { Api } from "./api"; -import axios from "axios"; -import qs from "qs"; -import urljoin from "url-join"; - -export class Panel { - public readonly host: string; - public readonly port: number; - public readonly protocol: string; - public readonly path: string; - private readonly logger; - - constructor(uri: string, debug = false) { - const url = new URL(encodeURI(uri)); - - this.host = url.hostname; - this.port = url.port.length ? Number(url.port) : 80; - this.protocol = url.protocol.slice(0, -1); - this.path = url.pathname; - - this.logger = createLogger(`panel: ${this.host}`); - this.logger.silent = !debug; - - this.logger.info(`Host: ${this.host}:${this.port}`); - if (this.protocol !== "https") { - this.logger.warn("Connection is not secure"); - } - } - - async login(username: string, password: string) { - const cerdentials = qs.stringify({ username, password }); - this.logger.debug(`trying to login via ${username}`); - - try { - const response = await axios.post("/login", cerdentials, { - baseURL: urljoin( - `${this.protocol}://${this.host}:${this.port}`, - this.path, - ), - validateStatus: () => true, - httpAgent: new ProxyAgent(), - httpsAgent: new ProxyAgent(), - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - }); - - if ( - response.status !== 200 || - !response.data.success || - !response.headers["set-cookie"] - ) { - this.logger.error("Failed to initialize session."); - throw new Error("Failed to initialize session."); - } - - const cookie = response.headers["set-cookie"][0]; - this.logger.debug(`cookie: ${cookie}`); - - return new Api(this, cookie, !this.logger.silent); - } catch (err) { - this.logger.error("connection failed"); - throw new Error("connection failed"); - } - } -} diff --git a/src/index.ts b/src/index.ts index 128142b..6e7ddd0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,2 @@ -export type * from "./types"; -export * from "./core/panel"; -export * from "./core/api"; +export * from "./types"; +export * from "./panel"; diff --git a/src/util/logger.ts b/src/logger.ts similarity index 100% rename from src/util/logger.ts rename to src/logger.ts diff --git a/src/panel.ts b/src/panel.ts new file mode 100644 index 0000000..664d4fe --- /dev/null +++ b/src/panel.ts @@ -0,0 +1,399 @@ +import type * as T from "./types"; +import { ProxyAgent } from "proxy-agent"; +import { createLogger } from "./logger"; +import qs from "qs"; +import urljoin from "url-join"; +import axios from "axios"; +import cache from "node-cache"; + +export class Panel { + readonly host: string; + readonly port: number; + readonly protocol: string; + readonly path: string; + readonly username: string; + private readonly password: string; + private readonly logger; + private readonly cache; + private readonly axios; + private cookie: string = ""; + + constructor(uri: string) { + const url = new URL(encodeURI(uri)); + this.protocol = url.protocol.slice(0, -1); + this.host = url.hostname; + this.port = url.port.length ? Number(url.port) : this.protocol === "https" ? 443 : 80; + this.path = url.pathname; + this.username = decodeURIComponent(url.username); + this.password = decodeURIComponent(url.password); + + this.logger = createLogger(`[${this.host}][${this.username}]`); + this.logger.silent = true; + + this.cache = new cache(); + this.cache.options.stdTTL = 60; + + this.axios = axios.create({ + baseURL: urljoin(`${this.protocol}://${this.host}:${this.port}`, this.path), + proxy: false, + httpAgent: new ProxyAgent(), + httpsAgent: new ProxyAgent(), + validateStatus: () => true, + headers: { + Accept: "application/json", + }, + }); + } + + /** + * Logger status + */ + set debug(enable: boolean) { + this.logger.silent = !enable; + } + + /** + * Cache standard time to live in seconds. + * 0 = infinity + */ + set stdTTL(ttl: number) { + this.cache.options.stdTTL = ttl; + } + + private async login() { + if (this.cookie.length) { + return; + } + + const cerdentials = qs.stringify({ + username: this.username, + password: this.password, + }); + + this.logger.debug("POST /login"); + const response = await this.axios + .post("/login", cerdentials, { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }) + .catch(() => {}); + + if ( + !response || + response.status !== 200 || + !response.data.success || + !response.headers["set-cookie"] + ) { + this.logger.error("Failed to initialize session."); + throw new Error("Failed to initialize session."); + } + + this.cookie = response.headers["set-cookie"][0]; + this.logger.info(`Logged-in`); + } + + private async get(path: string, params?: unknown) { + await this.login(); + + const url = urljoin("/panel/api/inbounds", path); + this.logger.debug(`GET ${url}`); + + const response = await this.axios + .get(url, { + data: qs.stringify(params), + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + Cookie: this.cookie, + }, + }) + .catch(() => {}); + + if (!response || response.status !== 200 || !response.data.success) { + this.logger.error(`${path} have failed.`); + throw new Error(`${path} have failed.`); + } + + return response.data.obj as T; + } + + private async post(path: string, params?: unknown) { + await this.login(); + + const url = urljoin("/panel/api/inbounds", path); + this.logger.debug(`POST ${url}`); + + const data = JSON.stringify(params); + const response = await this.axios + .post(url, data, { + headers: { + "Content-Type": "application/json", + Accept: "application/json", + Cookie: this.cookie, + }, + }) + .catch(() => {}); + + if (!response || response.status !== 200 || !response.data.success) { + this.logger.error(`${path} have failed.`); + throw new Error(`${path} have failed.`); + } + + return response.data.obj as T; + } + + private cacheInbound(inbound: T.Inbound) { + this.cache.set(`inbound:${inbound.id}`, inbound); + this.logger.debug(`Inbound ${inbound.id} saved in cache.`); + + if (inbound.settings) { + const settings = JSON.parse(inbound.settings) as { + clients: T.ClientOptions[]; + }; + + if (!settings) return; + settings.clients.map((options) => { + let clientId: string = ""; + if ("id" in options) clientId = options.id; + if ("password" in options) clientId = options.password; + + this.cache.set(`client:options:${options.email}`, options); + this.cache.set(`client:id:${options.email}`, clientId); + this.cache.set(`client:options:${clientId}`, options); + }); + } + + if (inbound.clientStats) { + inbound.clientStats.map((client) => { + const clientId = this.cache.get(`client:id:${client.email}`); + if (clientId) this.cache.set(`client:${clientId}`, client); + this.cache.set(`client:${client.email}`, client); + }); + } + } + + async getInbounds() { + if (this.cache.get("inbounds")) { + this.logger.debug("Inbounds loaded from cache."); + return this.cache.get("inbounds") as T.Inbound[]; + } + + const inbounds = await this.get("/list"); + this.cache.set("inbounds", inbounds); + inbounds.map((inbound) => this.cacheInbound(inbound)); + + this.logger.debug("Inbounds loaded from API."); + return inbounds; + } + + async getInbound(id: number) { + if (this.cache.get(`inbound:${id}`)) { + this.logger.debug(`Inbound ${id} loaded from cache.`); + return this.cache.get(`inbound:${id}`) as T.Inbound; + } + + const inbound = await this.get(`/get/${id}`).catch(() => {}); + if (!inbound) return null; + + this.logger.debug(`Inbound ${id} loaded from API.`); + this.cacheInbound(inbound); + return inbound; + } + + async addInbound(options: T.InboundOptions) { + this.logger.debug(`Adding inbound ${options.remark}.`); + + const inbound = await this.post("/add", options); + this.cache.flushAll(); + + this.logger.info(`Inbound ${inbound.remark} added.`); + return inbound; + } + + async updateInbound(id: number, options: Partial) { + this.logger.debug(`Updating inbound ${id}.`); + + const inbound = await this.getInbound(id); + if (!inbound) throw new Error("Inbound not found."); + + options = { ...inbound, ...options }; + const updated = await this.post(`/update/${id}`, options); + this.cache.flushAll(); + + this.logger.info(`Inbound ${id} updated.`); + return updated; + } + + async resetInboundsStat() { + await this.post(`/resetAllTraffics`).catch(() => {}); + this.logger.debug("Inbounds stat reseted."); + this.cache.flushAll(); + } + + async resetInboundStat(id: number) { + await this.post(`/resetAllClientTraffics/${id}`).catch(() => {}); + this.logger.debug(`Inbound ${id} stat reseted.`); + this.cache.flushAll(); + } + + async deleteInbound(id: number) { + await this.post(`/del/${id}`).catch(() => {}); + this.logger.debug(`Inbound ${id} deleted.`); + this.cache.flushAll(); + } + + async getClient(email: string) { + if (this.cache.get(`client:${email}`)) { + this.logger.debug(`Client ${email} loaded from cache.`); + return this.cache.get(`client:${email}`) as T.Client; + } + + const client = await this.get(`/getClientTraffics/${email}`); + if (client) { + this.cache.set(`client:${email}`, client); + this.logger.debug(`Client ${email} loaded from API.`); + return client; + } + + this.logger.debug(`Try to find client ${email} in inbounds.`); + await this.getInbounds(); + + if (this.cache.get(`client:${email}`)) { + this.logger.debug(`Client id ${email} loaded from inbounds.`); + return this.cache.get(`client:${email}`) as T.Client; + } + + return null; + } + + async getClientOptions(email: string) { + if (this.cache.get(`client:options:${email}`)) { + this.logger.debug(`Client ${email} options loaded from cache.`); + return this.cache.get(`client:options:${email}`) as T.ClientOptions; + } + + await this.getInbounds(); + if (this.cache.get(`client:options:${email}`)) { + this.logger.debug(`Client ${email} options loaded from cache.`); + return this.cache.get(`client:options:${email}`) as T.ClientOptions; + } + + return null; + } + + async addClient(inboundId: number, options: T.ClientOptions) { + await this.post("/addClient", { + id: inboundId, + settings: JSON.stringify({ + clients: [options], + }), + }); + + this.cache.flushAll(); + this.logger.debug(`Client ${options.email} added.`); + } + + async addClients(inboundId: number, clients: T.ClientOptions[]) { + await this.post("/addClient", { + id: inboundId, + settings: JSON.stringify({ clients }), + }); + + this.cache.flushAll(); + this.logger.debug(`${clients.length} clients added.`); + } + + async updateClient(inboundId: number, clientId: string, options: Partial) { + await this.getInbound(inboundId); + const defaultOptions = this.getClientOptions(clientId); + if (!defaultOptions) { + throw new Error("Client not found to be updated."); + } + + const clientOptions = { + ...defaultOptions, + ...options, + }; + + await this.post(`/updateClient/${clientId}`, { + id: inboundId, + settings: JSON.stringify({ + clients: [clientOptions], + }), + }); + + this.cache.flushAll(); + this.logger.debug(`Client ${clientId} updated.`); + } + + async getClientIps(email: string) { + if (this.cache.get(`client:ips:${email}`)) { + this.logger.debug(`Client ${email} IPs loaded from cache.`); + return this.cache.get(`client:ips:${email}`) as string[]; + } + + const data = await this.post(`/clientIps/${email}`).catch(() => {}); + if (!data || data === "No IP Record") { + this.logger.debug(`Client ${email} has no IPs.`); + return []; + } + + const ips = data.split(/,|\s/gm).filter((ip) => ip.length); + this.cache.set(`client:ips:${email}`, ips); + this.logger.debug(`Client ${email} IPs loaded from API.`); + return ips; + } + + async resetClientIps(email: string) { + await this.post(`/clearClientIps/${email}`).catch(() => {}); + this.cache.del(`client:ips:${email}`); + this.logger.debug(`Client ${email} IPs reseted.`); + } + + async resetClientStat(inboundId: number, email: string) { + await this.post(`/${inboundId}/resetClientTraffic/${email}`).catch(() => {}); + this.cache.flushAll(); + this.logger.debug(`Client ${email} stat reseted.`); + } + + async deleteClient(inboundId: number, email: string) { + await this.post(`/${inboundId}/delClient/${email}`).catch(() => {}); + this.cache.flushAll(); + this.logger.debug(`Client ${email} deleted.`); + } + + async deleteDepletedClients() { + await this.post("/delDepletedClients").catch(() => {}); + this.cache.flushAll(); + this.logger.debug(`Depleted clients deleted.`); + } + + async deleteInboundDepletedClients(inboundId: number) { + await this.post(`/delDepletedClients/${inboundId}`).catch(() => {}); + this.cache.flushAll(); + this.logger.debug(`Depleted clients deleted.`); + } + + async getOnlineClients() { + if (this.cache.get("clients:online")) { + this.logger.debug("Online clients loaded from cache."); + return this.cache.get("clients:online") as string[]; + } + + const emails = await this.post("/onlines").catch(() => {}); + if (!emails) { + this.logger.error("Failed to load online clients."); + return []; + } + + this.cache.set("clients:online", emails); + this.logger.debug("Online clients loaded from API."); + return emails; + } + + async exportDatabase() { + await this.get("/createbackup").catch(() => {}); + this.logger.debug("Database exported."); + } +}